From 7b178ff30bc64fc6d70eceb9b82f257fb56ba257 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:08:54 +0200 Subject: [PATCH 01/29] fix(security): harden server against verified code-review findings Applies fixes for 20 adversarially-verified findings from a whole-codebase security review (server side). All Go build-tag variants build, `go vet` is clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a pre-existing nil-harness failure unrelated to these changes). High severity: - auth: close TOCTOU in TOTP verify rate-limit by recording each attempt atomically up-front (was Check-then-Allow), restoring the per-user brute-force cap. - plugin: enforce the CPU/time budget on every WASM guest call via a WithTimeout context (WithCloseOnContextDone interrupts runaways); the configured budget was previously parsed but never applied. - api/waf: inspect request bodies for chunked (ContentLength==-1) requests so the SQLi/XSS/RCE body rules can no longer be bypassed. - ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which fan out to every participant and could force mass disconnects. Medium severity: - api: run bcrypt on the unknown-user login path (no || short-circuit) to remove the timing-based username-enumeration oracle. - ws: verify LiveKit webhooks via the SDK receiver so the signature is bound to the body hash (kills forgery/replay). - authz: require READ_MESSAGES for reactions and for plugin-command broadcasts; route the latter through RequireChannelAccess. - api: cache the client-update signature fetch and rate-limit the endpoint. - service: propagate DeleteOtherSessions failure from ChangePassword instead of silently reporting success. - api: trust the rightmost non-proxy X-Forwarded-For entry, not the client-controllable leftmost one. - plugin: route auto-registered commands through the conflict-checked RegisterCommand; pin the DNS-validated IP for host_http dials (DNS-rebinding TOCTOU). - api: mark access-controlled downloads private/no-cache + Vary: Origin. Low severity: - auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth (was returning the ciphertext as plaintext). - api: apply the livekit-proxy path allowlist to WebSocket upgrades too. - service: verify attachment ownership before linking (IDOR). - admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the update binary hash immediately before rename+spawn (TOCTOU). - service: require BanMembers + role hierarchy for moderation ban/unban. chore: stop tracking the stray Server/owncord-server.exe build artifact. Test infra: add uploader_id to the hand-rolled ws test attachment schemas and make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior. Co-Authored-By: Claude Opus 4.8 --- Server/admin/setup_handler.go | 5 ++- Server/admin/update_handlers.go | 39 +++++++++++++++++++ Server/api/auth_handler.go | 12 +++++- Server/api/client_update.go | 6 ++- Server/api/constants.go | 3 ++ Server/api/livekit_proxy.go | 16 +++++--- Server/api/middleware.go | 20 +++++++--- Server/api/router.go | 8 +++- Server/api/totp_handler.go | 12 +++++- Server/api/upload_handler.go | 7 +++- Server/api/upload_handler_test.go | 7 ++-- Server/api/waf.go | 8 +++- Server/auth/totp_encrypt.go | 13 ++++--- Server/go.mod | 2 + Server/go.sum | 4 ++ Server/plugin/host_http.go | 39 +++++++++++++------ Server/plugin/sandbox_wazero.go | 52 ++++++++++++++++++++----- Server/service/message.go | 25 +++++++++++- Server/service/moderation.go | 52 +++++++++++++++++++++++-- Server/service/service.go | 2 +- Server/service/user.go | 5 +++ Server/store/memstore.go | 12 ++++-- Server/updater/updater.go | 39 +++++++++++++++++++ Server/ws/coverage_boost_test.go | 1 + Server/ws/handlers_command.go | 42 ++++++++++++++++++-- Server/ws/handlers_test.go | 1 + Server/ws/handlers_voice.go | 12 ++++++ Server/ws/livekit_webhook.go | 64 +++++++++++-------------------- Server/ws/voice_e2ee.go | 23 ++++++++++- Server/ws/voice_join.go | 22 +++++++++++ 30 files changed, 449 insertions(+), 104 deletions(-) diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index aa4d48c9..4d1f169c 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -142,7 +142,10 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st _, _ = database.CreateChannel("General", "voice", "Voice Channels", "", 0) // Generate a bootstrap invite code so the owner can invite others. - inviteCode, err := database.CreateInvite(uid, 0, nil) // unlimited uses, no expiry + // Bound it (5 uses / 24h) rather than minting an unlimited, non-expiring + // invite — the owner can create fresh invites once logged in. + bootstrapInviteExpiry := time.Now().Add(24 * time.Hour) + inviteCode, err := database.CreateInvite(uid, 5, &bootstrapInviteExpiry) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code") return diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 3aa1574c..3ed3e013 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -2,6 +2,9 @@ package admin import ( "context" + "crypto/sha256" + "encoding/hex" + "io" "log/slog" "net/http" "os" @@ -84,6 +87,17 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha return } + // Snapshot the hash of the just-verified staged binary. It is re-checked + // immediately before rename+spawn to close the TOCTOU window between + // verification here and the swap in the background goroutine below. + stagedHash, err := fileSHA256(newPath) + if err != nil { + slog.Error("update: failed to hash staged binary", "err", err) + _ = os.Remove(newPath) + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to stage update") + return + } + // Respond to the client before shutting down. writeJSON(w, http.StatusOK, map[string]string{ "status": "applying", @@ -97,6 +111,14 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha } time.Sleep(5 * time.Second) + // TOCTOU guard: re-verify the staged binary is byte-for-byte the one + // we verified before responding. If it was swapped between then and + // now, abort without renaming or spawning it. + if err := u.VerifyChecksum(newPath, stagedHash); err != nil { + slog.Error("update: staged binary re-verification failed, aborting update", "error", err) + return + } + // Rename: current -> .old, .new -> current _ = os.Remove(oldPath) // remove any stale .old if err := os.Rename(exePath, oldPath); err != nil { @@ -137,3 +159,20 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha }() }) } + +// fileSHA256 returns the hex-encoded SHA256 of the file at path. Used to +// snapshot a verified update binary so it can be re-checked (via +// updater.VerifyChecksum) immediately before it is renamed and executed. +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 73acfa99..48e021cc 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -317,7 +317,17 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. failKey := "login_fail:" + ip userFailKey := "login_user_fail:" + req.Username - if user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) { + // Always run the password check — with an empty hash when the user does + // not exist. auth.CheckPassword performs a dummy bcrypt comparison for an + // empty hash, so bcrypt executes on every path and response time stays + // constant, preventing timing-based username enumeration. (A `user == nil + // || CheckPassword(...)` short-circuit would skip bcrypt entirely for + // unknown usernames, reintroducing the timing side-channel.) + storedHash := "" + if user != nil { + storedHash = user.PasswordHash + } + if !auth.CheckPassword(storedHash, req.Password) { // Track failures per-IP; lockout on threshold. if !limiter.Allow(failKey, loginFailureThreshold, loginFailureWindow) { limiter.Lockout(lockKey, loginLockoutDuration) diff --git a/Server/api/client_update.go b/Server/api/client_update.go index 93d7d995..2700b17b 100644 --- a/Server/api/client_update.go +++ b/Server/api/client_update.go @@ -67,8 +67,10 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc { return } - // Fetch the signature file content (small text file). - sigContent, err := u.FetchTextAsset(r.Context(), sigURL) + // Fetch the signature file content (small text file). Cached with the + // same TTL as the release info so this unauthenticated endpoint does not + // perform an outbound fetch on every request (DoS hardening). + sigContent, err := u.FetchTextAssetCached(r.Context(), sigURL) if err != nil { http.Error(w, "failed to fetch signature", http.StatusBadGateway) return diff --git a/Server/api/constants.go b/Server/api/constants.go index 9ffc9cea..523e440b 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -31,6 +31,9 @@ const ( // livekitProxyRateLimitPerMinute is the maximum LiveKit proxy requests per IP per minute. livekitProxyRateLimitPerMinute = 30 + // clientUpdateRateLimitPerMinute is the maximum client-update checks per IP per minute. + clientUpdateRateLimitPerMinute = 30 + // loginFailureThreshold is the number of failed login attempts (within // loginFailureWindow) before the IP is locked out. loginFailureThreshold = 9 diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 5b9b7f9d..9f7ccb7c 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -59,11 +59,9 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { blockedSegments := map[string]bool{"admin": true, "metrics": true, "debug": true, "twirp": true} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Detect WebSocket upgrade requests. - if isWebSocketUpgrade(r) { - proxyWebSocket(w, r, &wsTarget, allowedOrigins) - return - } + // Enforce the path allowlist and Origin check for EVERY request, + // including WebSocket upgrades — otherwise a client could reach a + // blocked/admin endpoint simply by sending an Upgrade header. // Block sensitive LiveKit endpoints (exact segment match). for _, seg := range strings.Split(strings.ToLower(r.URL.Path), "/") { @@ -76,7 +74,7 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { } } - // Validate Origin header for HTTP requests (mirrors WS OriginPatterns). + // Validate Origin header (mirrors WS OriginPatterns). if !isOriginAllowed(r, allowedOrigins) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -85,6 +83,12 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { return } + // Detect WebSocket upgrade requests. + if isWebSocketUpgrade(r) { + proxyWebSocket(w, r, &wsTarget, allowedOrigins) + return + } + httpProxy.ServeHTTP(w, r) }) } diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 7b1de13a..054d16d8 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -218,13 +218,23 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { } } - // Fall back to the leftmost (client) entry in X-Forwarded-For. + // Fall back to X-Forwarded-For, walking from the RIGHT and skipping entries + // that are themselves trusted proxies. The first non-trusted, valid address + // is the real client. Taking the leftmost entry (BUG-112) would trust a + // client-supplied value: a client can prepend a spoofed IP + // (`X-Forwarded-For: , `) that the proxy then appends to, + // letting it forge per-IP rate-limit and lockout keys. if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.SplitN(xff, ",", 2) - if client := strings.TrimSpace(parts[0]); client != "" { - if net.ParseIP(client) != nil { - return client + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(parts[i]) + if candidate == "" || net.ParseIP(candidate) == nil { + continue } + if trusted, _ := isTrustedProxy(candidate, trustedCIDRs); trusted { + continue // our own proxy hop, keep walking left + } + return candidate } } diff --git a/Server/api/router.go b/Server/api/router.go index f1248084..02f4732f 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -251,8 +251,12 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri }) }) - // Client auto-update endpoint (unauthenticated). - MountClientUpdateRoute(r, u) + // Client auto-update endpoint (unauthenticated). Per-IP rate limited to + // bound abuse; the signature fetch is cached inside the updater (DoS fix). + MountClientUpdateRoute( + r.With(RateLimitMiddleware(limiter, clientUpdateRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)), + u, + ) // Issue 15: Warn if AllowedOrigins contains wildcard. for _, o := range cfg.Server.AllowedOrigins { diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index 08d18508..e7730099 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -66,7 +66,14 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } totpRateLimitKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) - if !limiter.Check(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) { + // Atomically record this attempt and reject once the per-user failure cap + // is reached. Recording up-front — rather than a read-only Check now and + // Allow only on failure — closes a TOCTOU where many concurrent requests + // reusing one valid partial token all pass the read-only check before any + // failure is recorded, defeating the per-user brute-force cap (the only + // cross-IP defence). A successful verification resets the counter below, + // so legitimate retries are not penalised. + if !limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", Message: "too many failed attempts, try again later", @@ -94,7 +101,8 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { - limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) + // The attempt was already recorded atomically up-front via + // limiter.Allow; only the per-partial-token counter is advanced here. partialStore.RegisterFailure(partialToken, partialAuthMaxFailures) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index d27b097b..b83a8292 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -304,7 +304,12 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s disposition = "attachment" } w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": aa.Filename})) - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", fileCacheMaxAgeSeconds)) + // These downloads are access-controlled, so they must never be stored by + // shared/proxy caches (info-leak). Mark private and force revalidation. + w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d, no-cache", fileCacheMaxAgeSeconds)) + // The Access-Control-Allow-Origin header below reflects the request + // Origin, so responses vary by Origin and must not be cross-served. + w.Header().Set("Vary", "Origin") // CORS: allow webview to read the response body using configured origins. if origin := r.Header.Get("Origin"); origin != "" { for _, allowed := range allowedOrigins { diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 5651a183..9a68bab0 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -727,10 +727,11 @@ func TestServeFile_Success(t *testing.T) { t.Error("expected Content-Type header on served file") } - // Verify cache control header. + // Verify cache control header. Access-controlled downloads must be marked + // private + no-cache so shared/proxy caches never store them (info-leak). cc := rr2.Header().Get("Cache-Control") - if cc != "public, max-age=31536000, immutable" { - t.Errorf("Cache-Control = %q, want 'public, max-age=31536000, immutable'", cc) + if cc != "private, max-age=31536000, no-cache" { + t.Errorf("Cache-Control = %q, want 'private, max-age=31536000, no-cache'", cc) } // Verify Content-Disposition header. diff --git a/Server/api/waf.go b/Server/api/waf.go index 49636588..a21ac3c4 100644 --- a/Server/api/waf.go +++ b/Server/api/waf.go @@ -92,8 +92,12 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { return } - // Process request body (if applicable) - if r.Body != nil && r.ContentLength > 0 { + // Process request body (if applicable). Use ContentLength != 0 so + // chunked requests (Transfer-Encoding: chunked → ContentLength == -1) + // are inspected too; otherwise the SQLi/XSS/RCE body rules are silently + // skipped for them. The read is bounded by SecRequestBodyLimit inside + // Coraza. ContentLength == 0 (no body) still skips inspection. + if r.Body != nil && r.ContentLength != 0 { if it, _, err := tx.ReadRequestBodyFrom(r.Body); it != nil { handleWAFInterruption(w, it) return diff --git a/Server/auth/totp_encrypt.go b/Server/auth/totp_encrypt.go index 12f8abc7..811160db 100644 --- a/Server/auth/totp_encrypt.go +++ b/Server/auth/totp_encrypt.go @@ -138,11 +138,14 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { nonce, sealed := data[:nonceSize], data[nonceSize:] plaintext, err := gcm.Open(nil, nonce, sealed, nil) if err != nil { - // Decryption failed -- likely an unencrypted legacy secret or wrong key. - // Return as-is for backwards compatibility. - slog.Warn("TOTP secret decryption failed — returning as plaintext (check TOTP_ENCRYPTION_KEY)", - "error", err) - return ciphertext, nil //nolint:nilerr + // The value has the full encrypted shape (valid hex, long enough for + // nonce+tag) but GCM authentication failed. That is a real error — a + // wrong TOTP_ENCRYPTION_KEY or a tampered/corrupted ciphertext — not a + // legacy plaintext secret (those are caught by the not-hex and + // too-short branches above). Fail CLOSED: returning the ciphertext as + // if it were the secret would silently mask key misconfiguration. + slog.Error("TOTP secret decryption failed — check TOTP_ENCRYPTION_KEY", "error", err) + return "", fmt.Errorf("decrypting TOTP secret: %w", err) } return string(plaintext), nil diff --git a/Server/go.mod b/Server/go.mod index fcee7995..41932c58 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -69,6 +69,8 @@ require ( github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/Server/go.sum b/Server/go.sum index 10b595bf..bd430dd7 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -119,6 +119,10 @@ github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcN github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index 34056d50..ae240ac1 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -76,26 +76,43 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) for k, v := range req.Header { httpReq.Header.Set(k, v) } - // Custom transport with a guarded DialContext: every actual TCP dial - // re-checks the resolved IP, closing the DNS-rebinding TOCTOU window - // between rejectPrivateAddrs above and the underlying dial. + // Custom transport with a guarded DialContext: the host is resolved once, + // every candidate IP is validated against the blocklist, and the actual + // connection is made to that specific vetted IP — never re-resolved by + // hostname. This closes the DNS-rebinding TOCTOU window where a second + // lookup (the one net.Dialer would perform on a hostname) could return an + // internal IP after rejectPrivateAddrs above had already approved the name. dialer := &net.Dialer{Timeout: httpTimeout} transport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - h, _, splitErr := net.SplitHostPort(addr) + h, port, splitErr := net.SplitHostPort(addr) if splitErr != nil { return nil, splitErr } - ip := net.ParseIP(h) - if ip == nil { - // Hostname — resolve and validate every address before dial. - if err := rejectPrivateAddrs(ctx, h); err != nil { + // IP literal: validate and dial as-is (no resolution happens). + if ip := net.ParseIP(h); ip != nil { + if err := ipAllowed(ip); err != nil { return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) } - } else if err := ipAllowed(ip); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + return dialer.DialContext(ctx, network, addr) } - return dialer.DialContext(ctx, network, addr) + // Hostname: resolve once, validate every returned address, then + // dial the concrete vetted IP so the connection target is exactly + // the address that was checked. + resolver := &net.Resolver{} + ips, lookupErr := resolver.LookupIPAddr(ctx, h) + if lookupErr != nil { + return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) + } + if len(ips) == 0 { + return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) + } + for _, resolved := range ips { + if err := ipAllowed(resolved.IP); err != nil { + return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) }, } client := &http.Client{ diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index ec0de81c..2be24278 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -5,7 +5,7 @@ // elsewhere in the repo so the default sqlite-only build does not pull // wazero into go.mod at runtime. // -// Architecture +// # Architecture // // The wazero-tagged build provides: // @@ -36,8 +36,10 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "strings" + "time" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -121,17 +123,26 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err) } - // Register the module and auto-bind any commands the plugin exports - // via list_commands. The plugin must also have declared the `commands` - // capability in its manifest, otherwise no binding happens. + // Store the module under the lock, then auto-bind any commands the plugin + // exports via list_commands. Binding is routed through RegisterCommand so + // each name goes through the same normalization (trim "/" + lowercase) and + // conflict check the direct registration path uses: a command already owned + // by a DIFFERENT plugin is refused rather than silently clobbered, closing + // the cross-plugin command-hijack hole. RegisterCommand acquires r.mu + // itself, so it is called outside the lock below to avoid re-entrant + // locking. The plugin must also have declared the `commands` capability in + // its manifest, otherwise no binding happens. r.mu.Lock() inst.module = module + r.mu.Unlock() if inst.Manifest.HasCapability(CapCommands) { for _, cmd := range listExportedCommands(ctx, module) { - r.commands[cmd] = inst + if err := r.RegisterCommand(cmd, inst); err != nil { + slog.Warn("plugin: skipping command binding", + "plugin", inst.Manifest.Name, "command", cmd, "err", err) + } } } - r.mu.Unlock() return nil } @@ -195,9 +206,27 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: fmt.Sprintf("plugin %s: marshal payload: %v", inst.Manifest.Name, err)}, true } + // Enforce the plugin's CPU budget. The effective budget is the manifest's + // Resources.CPUBudgetMs, falling back to the configured default, then a + // hard 100ms floor so a zero/negative value can never mean "no limit". + // Every guest call (allocate / command_dispatch / deallocate) runs under + // this deadline instead of the long-lived WebSocket context. The runtime + // was created WithCloseOnContextDone(true), so an expired deadline closes + // the module and interrupts a runaway guest (e.g. `for {}`) — the Call + // returns an error rather than panicking, which the paths below surface. + budgetMs := inst.Manifest.Resources.CPUBudgetMs + if budgetMs <= 0 { + budgetMs = r.cfg.CPUBudgetMs + } + if budgetMs <= 0 { + budgetMs = 100 + } + callCtx, cancel := context.WithTimeout(ctx, time.Duration(budgetMs)*time.Millisecond) + defer cancel() + // Allocate guest memory for the input payload. size := uint64(len(payload)) - ptrs, callErr := allocFn.Call(ctx, size) + ptrs, callErr := allocFn.Call(callCtx, size) if callErr != nil || len(ptrs) == 0 { return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true } @@ -208,14 +237,19 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true } - results, callErr := dispatchFn.Call(ctx, ptr, size) + results, callErr := dispatchFn.Call(callCtx, ptr, size) // Free the input buffer regardless of dispatch outcome. if deallocFn != nil { - _, _ = deallocFn.Call(ctx, ptr, size) + _, _ = deallocFn.Call(callCtx, ptr, size) } if callErr != nil { + // Surface a CPU-budget overrun as a clean, specific error rather than + // leaking the raw "module closed with context deadline exceeded". + if callCtx.Err() == context.DeadlineExceeded { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: command exceeded CPU budget of %dms", inst.Manifest.Name, budgetMs)}, true + } return &CommandResult{Reply: fmt.Sprintf("plugin %s: dispatch: %v", inst.Manifest.Name, callErr)}, true } if len(results) < 2 { diff --git a/Server/service/message.go b/Server/service/message.go index 3ea6ab49..083a16de 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -173,6 +173,26 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } + // Verify attachment ownership before persisting, to prevent hijacking + // another user's unlinked upload (IDOR). Any referenced attachment that + // exists must belong to the sender (uploader_id == p.UserID) and must not + // already be linked to a message (message_id IS NULL). Nonexistent IDs are + // ignored — LinkAttachmentsToMessage silently skips them. Checked before + // CreateMessage so a failed ownership check never persists a message. + for _, aid := range p.AttachmentIDs { + att, attErr := s.st.GetAttachmentByID(aid) + if attErr != nil { + slog.Error("MessageService.SendMessage GetAttachmentByID", "err", attErr, "attachment_id", aid) + return nil, fmt.Errorf("%w: failed to verify attachment ownership", ErrInternal) + } + if att == nil { + continue // nonexistent — the link query will skip it + } + if att.UploaderID == nil || *att.UploaderID != p.UserID || att.MessageID != nil { + return nil, fmt.Errorf("%w: attachment not owned by sender or already linked", ErrForbidden) + } + } + // Persist message. msgID, err := s.st.CreateMessage(p.ChannelID, p.UserID, content, p.ReplyTo) if err != nil { @@ -435,7 +455,10 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b if dmErr != nil || !ok { return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.AddReactions) { + } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { + // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot + // react in a channel they cannot read. Mirrors checkSendPermission, + // which requires ReadMessages|SendMessages for non-DM sends. return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) } diff --git a/Server/service/moderation.go b/Server/service/moderation.go index d454322c..be1b39cd 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -6,18 +6,54 @@ import ( "log/slog" "time" + "github.com/owncord/server/permissions" "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // ModerationService handles user ban/unban operations. type ModerationService struct { - st store.Store + st store.Store + perms *PermissionService } // NewModerationService creates a ModerationService. -func NewModerationService(st store.Store) *ModerationService { - return &ModerationService{st: st} +func NewModerationService(st store.Store, perms *PermissionService) *ModerationService { + return &ModerationService{st: st, perms: perms} +} + +// requireBanAuthority verifies the actor is allowed to ban/unban the target. +// The actor must hold BAN_MEMBERS (or Administrator, which bypasses permission +// checks) and must outrank the target in the role hierarchy — mirroring the +// position-based hierarchy used elsewhere (see admin/middleware.go and +// permissions.OwnerRolePosition). Returns ErrForbidden when either check fails. +func (s *ModerationService) requireBanAuthority(actorID, targetID int64) error { + if s.perms == nil { + // No permission service wired — fail closed rather than allow unchecked bans. + return fmt.Errorf("%w: permission service unavailable", ErrForbidden) + } + + actorRole, err := s.perms.GetRoleForUser(actorID) + if err != nil || actorRole == nil { + return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } + + if !permissions.HasAdmin(actorRole.Permissions) && + !permissions.HasPerm(actorRole.Permissions, permissions.BanMembers) { + return fmt.Errorf("%w: missing BAN_MEMBERS permission", ErrForbidden) + } + + // Role hierarchy: the actor must strictly outrank the target so a user + // cannot ban a peer or a higher-ranked user (e.g. the owner). + targetRole, err := s.perms.GetRoleForUser(targetID) + if err != nil || targetRole == nil { + return fmt.Errorf("%w: failed to load target role", ErrForbidden) + } + if actorRole.Position <= targetRole.Position { + return fmt.Errorf("%w: cannot moderate a user of equal or higher rank", ErrForbidden) + } + + return nil } // BanUser bans a target user. Validates the target exists and @@ -46,6 +82,11 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi return fmt.Errorf("%w: user not found", ErrNotFound) } + // Authorization: actor must hold BAN_MEMBERS and outrank the target. + if err := s.requireBanAuthority(actorID, targetID); err != nil { + return err + } + if err := s.st.BanUser(targetID, reason, expires); err != nil { return fmt.Errorf("%w: failed to ban user", ErrInternal) } @@ -69,6 +110,11 @@ func (s *ModerationService) UnbanUser(actorID, targetID int64) error { return fmt.Errorf("%w: user not found", ErrNotFound) } + // Authorization: actor must hold BAN_MEMBERS and outrank the target. + if err := s.requireBanAuthority(actorID, targetID); err != nil { + return err + } + if err := s.st.UnbanUser(targetID); err != nil { return fmt.Errorf("%w: failed to unban user", ErrInternal) } diff --git a/Server/service/service.go b/Server/service/service.go index c8d7ecee..113fa924 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -36,7 +36,7 @@ func New(st store.Store, limiter *auth.RateLimiter) *Services { DMs: NewDMService(st), Invites: NewInviteService(st), Blocks: NewBlockService(st), - Moderation: NewModerationService(st), + Moderation: NewModerationService(st, permSvc), Voice: NewVoiceService(st, permSvc), } } diff --git a/Server/service/user.go b/Server/service/user.go index 3a267270..a2cac293 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -60,6 +60,11 @@ func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepS revoked, err := s.st.DeleteOtherSessions(userID, keepSessionID) if err != nil { slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID) + // The password was updated, but other sessions could not be revoked, so + // devices authenticated under the old password remain valid. Surface this + // as a failure instead of silently reporting success — a password change + // is a security action and the caller must be able to warn/retry. + return revoked, fmt.Errorf("%w: password changed but failed to revoke other sessions", ErrInternal) } _ = s.st.LogAudit(userID, "password_change", "user", userID, "password changed") slog.Info("password changed", "user_id", userID, "sessions_revoked", revoked) diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 549ed96e..88c3324f 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -128,9 +128,9 @@ func (m *MemStore) SeedBlock(blockerID, blockedID int64) { // ---------- Store interface: top-level ---------- -func (m *MemStore) Close() error { return nil } -func (m *MemStore) SQLDb() *sql.DB { return nil } -func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) } +func (m *MemStore) Close() error { return nil } +func (m *MemStore) SQLDb() *sql.DB { return nil } +func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) } // ---------- MessageStore ---------- @@ -686,7 +686,11 @@ func (m *MemStore) CreateAttachment(_ string, _ int64, _, _, _ string, _ int64, } func (m *MemStore) GetAttachmentByID(_ string) (*db.Attachment, error) { - panic("memstore: not implemented: GetAttachmentByID") + // MemStore does not track attachments (CreateAttachment is unsupported and + // LinkAttachmentsToMessage is a no-op), so every lookup is "not found". + // Returning (nil, nil) rather than panicking keeps the attachment-ownership + // check in MessageService.SendMessage consistent with the no-op link path. + return nil, nil } func (m *MemStore) GetAttachmentWithChannel(_ string) (*db.AttachmentAccess, error) { diff --git a/Server/updater/updater.go b/Server/updater/updater.go index d158c2fc..115eb205 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -117,11 +117,20 @@ type Updater struct { cacheExpiry time.Time cachedErr error errCacheExpiry time.Time + textAssetCache map[string]textAssetCacheEntry mu syncutil.Mutex httpClient *http.Client signingKeyText string } +// textAssetCacheEntry caches a small text asset (e.g. a client update .sig +// file) alongside the release cache so repeated requests are served from +// memory instead of re-fetching from GitHub on every call. +type textAssetCacheEntry struct { + content string + expiry time.Time +} + // NewUpdater creates an Updater for the given repository. func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { return &Updater{ @@ -713,6 +722,36 @@ func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error return string(data), nil } +// FetchTextAssetCached is FetchTextAsset with an in-memory cache keyed by URL, +// using the same cacheTTL as the release cache. It lets unauthenticated, +// unrate-limited callers (e.g. the client-update endpoint) be served from +// memory instead of triggering an outbound fetch on every request. +func (u *Updater) FetchTextAssetCached(ctx context.Context, url string) (string, error) { + now := time.Now() + + u.mu.Lock() + if entry, ok := u.textAssetCache[url]; ok && now.Before(entry.expiry) { + content := entry.content + u.mu.Unlock() + return content, nil + } + u.mu.Unlock() + + content, err := u.FetchTextAsset(ctx, url) + if err != nil { + return "", err + } + + u.mu.Lock() + if u.textAssetCache == nil { + u.textAssetCache = make(map[string]textAssetCacheEntry) + } + u.textAssetCache[url] = textAssetCacheEntry{content: content, expiry: now.Add(cacheTTL)} + u.mu.Unlock() + + return content, nil +} + // downloadFile downloads the content at url and writes it to destPath. func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index c1914f04..4d887594 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + uploader_id INTEGER REFERENCES users(id), filename TEXT NOT NULL, stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 1c3f8704..2d57239c 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -83,9 +83,12 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } if result.Broadcast != "" && p.ChannelID != 0 { - // Verify the invoking client has permission to send to this channel - // before broadcasting the plugin result to all channel members. - if !h.requireChannelPerm(c, p.ChannelID, permissions.SendMessages, "SEND_MESSAGES") { + // Verify the invoking client can post to this channel before broadcasting + // the plugin result to all channel members. Mirrors the normal send path: + // non-DM channels require READ_MESSAGES|SEND_MESSAGES (so a user cannot + // post into a channel they cannot read), and DM channels are validated by + // participant membership rather than role permissions. + if !h.requireChannelBroadcastAccess(c, p.ChannelID) { return } // Channel broadcast — visible to everyone in the channel. @@ -95,6 +98,39 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } } +// requireChannelBroadcastAccess reports whether the client may post to +// channelID, mirroring the normal message-send permission path. DM channels are +// validated by participant membership; all other channels require +// READ_MESSAGES|SEND_MESSAGES. On failure it sends an error to the client and +// returns false. Routes through the shared permissions.Checker.RequireChannelAccess +// so DM handling matches the rest of the codebase. +func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool { + if c.user == nil { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated")) + return false + } + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return false + } + role, err := h.db.GetRoleByID(c.user.RoleID) + if err != nil || role == nil { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "role not found")) + return false + } + if accessErr := h.permChecker.RequireChannelAccess( + c.userID, role.Permissions, role.ID, ch.Type, channelID, + permissions.ReadMessages|permissions.SendMessages, + ); accessErr != nil { + slog.Warn("ws plugin broadcast permission denied", + "user_id", c.userID, "channel_id", channelID, "err", accessErr) + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel")) + return false + } + return true +} + // buildCommandReply builds an ephemeral command_reply envelope. func buildCommandReply(reqID, text string) []byte { type payload struct { diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 215e102b..82688904 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + uploader_id INTEGER REFERENCES users(id), filename TEXT NOT NULL, stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, diff --git a/Server/ws/handlers_voice.go b/Server/ws/handlers_voice.go index fb0da596..408f1ba8 100644 --- a/Server/ws/handlers_voice.go +++ b/Server/ws/handlers_voice.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/json" + "fmt" ) // registerVoiceHandlersV1 registers voice handlers that remain V1 (complex @@ -12,6 +13,17 @@ func registerVoiceHandlersV1(r *HandlerRegistry) { h.handleVoiceJoin(ctx, c, payload) }) r.Register(MsgTypeVoiceLeave, func(ctx context.Context, h *Hub, c *Client, _ string, _ json.RawMessage) { + // Rate limit only the explicit client-initiated voice_leave message. + // handleVoiceLeave is also invoked internally for disconnect and + // channel-switch cleanup (serve.go, voice_join.go); those paths must + // never be throttled or they would leak ghost voice states, so the + // limit lives here in the dispatch wrapper rather than inside the shared + // handleVoiceLeave routine. Mirrors the voice control Limiter idiom. + ratKey := fmt.Sprintf("voice_leave:%d", c.userID) + if h.limiter != nil && !h.limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) { + c.sendMsg(buildErrorMsg(ErrCodeRateLimited, "too many voice leave attempts")) + return + } h.handleVoiceLeave(ctx, c) }) } diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 5ff3e774..ac846a87 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -2,9 +2,7 @@ package ws import ( "context" - "encoding/json" "fmt" - "io" "log/slog" "net/http" "strconv" @@ -12,8 +10,13 @@ import ( "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/webhook" ) +// webhookMaxBodyBytes bounds the webhook request body to prevent unbounded +// reads from an unauthenticated caller. +const webhookMaxBodyBytes = 64 * 1024 + // NewLiveKitWebhookHandler returns an HTTP handler that processes LiveKit // webhook events. It synchronises LiveKit room state back into OwnCord's // voice_states DB — primarily for crash recovery when a participant @@ -22,56 +25,35 @@ import ( // Speaker detection is handled client-side via LiveKit's // RoomEvent.ActiveSpeakersChanged (lower latency than webhooks). func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFunc { + // The SDK receiver verifies the token signature AND that the token's sha256 + // claim matches the request body hash, binding verification to the body so + // a captured token cannot be replayed against a forged payload. + provider := auth.NewSimpleKeyProvider(apiKey, apiSecret) + return func(w http.ResponseWriter, r *http.Request) { // Check Authorization header BEFORE reading the body to avoid // allocating memory for unauthenticated requests. - authHeader := r.Header.Get("Authorization") - if authHeader == "" { + if r.Header.Get("Authorization") == "" { slog.Warn("livekit webhook: missing Authorization header") http.Error(w, "unauthorized", http.StatusUnauthorized) return } - body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + // Bound the body before the SDK reads it (ReceiveWebhookEvent uses an + // unbounded io.ReadAll internally). + r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes) + + // ReceiveWebhookEvent verifies the JWT signature, the token's body-hash + // claim, and the exp/nbf claims, then parses the payload. This replaces + // the previous manual ParseAPIToken/Verify sequence, which was not bound + // to the request body (forgery/replay). + event, err := webhook.ReceiveWebhookEvent(r, provider) if err != nil { - slog.Error("livekit webhook: read body failed", "error", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - // LiveKit sends "Bearer " in the Authorization header. - tokenStr := strings.TrimPrefix(authHeader, "Bearer ") - verifier, err := auth.ParseAPIToken(tokenStr) - if err != nil { - slog.Warn("livekit webhook: invalid token", "error", err) + slog.Warn("livekit webhook: verification failed", "error", err) http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if verifier.APIKey() != apiKey { - slog.Warn("livekit webhook: API key mismatch", - "got", verifier.APIKey(), "want", apiKey) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // Verify checks both the HMAC signature and the exp/nbf claims - // (via jwt.Claims.Validate with Time: time.Now() inside the SDK). - // Expired tokens are rejected with an error here. - if _, _, err := verifier.Verify(apiSecret); err != nil { - slog.Warn("livekit webhook: token verification failed", "error", err) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // Parse the webhook event payload. - var event livekit.WebhookEvent - if err := json.Unmarshal(body, &event); err != nil { - slog.Warn("livekit webhook: invalid JSON", "error", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - slog.Info("livekit webhook received", "event", event.Event, "room", event.GetRoom().GetName(), @@ -80,9 +62,9 @@ func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFun switch event.Event { case "participant_joined": - h.handleWebhookParticipantJoined(r.Context(), &event) + h.handleWebhookParticipantJoined(r.Context(), event) case "participant_left": - h.handleWebhookParticipantLeft(r.Context(), &event) + h.handleWebhookParticipantLeft(r.Context(), event) default: slog.Debug("livekit webhook: unhandled event", "event", event.Event) } diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 1a28235d..88204041 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -3,6 +3,17 @@ package ws import ( "context" "encoding/base64" + "fmt" + "time" +) + +// Voice E2EE rate limits. Both the announce and offer relays fan out to every +// other voice participant (and an offer can force a key rotation / disconnect +// for peers), so a single user must not be able to spam them. Mirrors the +// named-constant Limiter idiom used by the voice control handlers. +const ( + voiceE2EERateLimit = 5 + voiceE2EEWindow = time.Second ) // validateBase64Loose checks that s is valid padded (StdEncoding) or unpadded @@ -82,11 +93,16 @@ func (h *Hub) computeIsKeyHolder(channelID, userID int64) bool { // It validates the public key and returns a SetE2EEPubKey mutation plus a // VoiceE2EEAnnounceEvent for relay to other voice channel participants. func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { - _ = deps.(VoiceDeps) + d := deps.(VoiceDeps) announceCmd := cmd.(VoiceE2EEAnnounceCmd) userID := info.UserID voiceChID := info.VoiceChannelID + ratKey := fmt.Sprintf("voice_e2ee_announce:%d", userID) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee announcements"}} + } + if voiceChID == 0 { return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } @@ -131,6 +147,11 @@ func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, dep offerCmd := cmd.(VoiceE2EEOfferCmd) voiceChID := info.VoiceChannelID + ratKey := fmt.Sprintf("voice_e2ee_offer:%d", info.UserID) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} + } + if voiceChID == 0 { return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index 311405f5..473cc1c2 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -12,6 +12,19 @@ import ( "github.com/owncord/server/permissions" ) +// Voice join/leave rate limits. voice_join and voice_leave each fan out a +// broadcast to every connected client, so a single user must not be able to +// trigger them in a tight loop. Mirrors the named-constant idiom used by the +// voice control handlers (see voice_broadcast.go / voice_controls.go). +// voiceLeaveRateLimit/Window are consumed by the voice_leave message dispatch +// in handlers_voice.go (same package). +const ( + voiceJoinRateLimit = 5 + voiceJoinWindow = time.Second + voiceLeaveRateLimit = 5 + voiceLeaveWindow = time.Second +) + // validVoiceQuality returns true if q is an accepted voice quality preset. // Uses voiceQualities (defined in voice_broadcast.go) as the single source of truth. func validVoiceQuality(q string) bool { @@ -30,6 +43,15 @@ func validVoiceQuality(q string) bool { // 8. Broadcasts voice_state to all clients. // 9. Sends voice_config to the joiner. func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMessage) { + // Rate limit: voice_join broadcasts a voice_state update to every connected + // client, so cap how often a single user can trigger the fan-out. Mirrors the + // Limiter.Allow(...) idiom used by the voice control handlers. + ratKey := fmt.Sprintf("voice_join:%d", c.userID) + if h.limiter != nil && !h.limiter.Allow(ratKey, voiceJoinRateLimit, voiceJoinWindow) { + c.sendMsg(buildErrorMsg(ErrCodeRateLimited, "too many voice join attempts")) + return + } + channelID, err := parseChannelID(payload) if err != nil || channelID <= 0 { c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer")) From b084c07a17ddfbb1a77698954b44b09be2ca8051 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:11:17 +0200 Subject: [PATCH 02/29] Remove obsolete test files for agent-diff, agent-queue, backlog-parser, briefing-and-normalization, cache-mode, module-naming, and server-auth. These tests are no longer relevant to the current codebase and have been deleted to maintain a clean and efficient testing environment. --- PHASE_BC_LOCAL_TODO.md | 290 --- phase-b-acceleration.md | 148 -- phase-c-differentiation.md | 139 -- tools/project-map/.gitignore | 1 - tools/project-map/dashboard.html | 1960 ----------------- tools/project-map/index.mjs | 100 - tools/project-map/lib/agent-manager.mjs | 988 --------- tools/project-map/lib/backlog-parser.mjs | 124 -- tools/project-map/lib/debt-scanner.mjs | 311 --- tools/project-map/lib/file-watcher.mjs | 115 - tools/project-map/lib/git-scanner.mjs | 341 --- tools/project-map/lib/go-coverage.mjs | 131 -- tools/project-map/lib/import-graph.mjs | 472 ---- tools/project-map/lib/morning-briefing.mjs | 190 -- tools/project-map/lib/priority-engine.mjs | 180 -- tools/project-map/lib/report-generator.mjs | 162 -- tools/project-map/lib/research-agent.mjs | 223 -- tools/project-map/lib/scanner.mjs | 198 -- tools/project-map/lib/session-manager.mjs | 607 ----- tools/project-map/lib/session-parser.mjs | 357 --- tools/project-map/lib/suggestion-engine.mjs | 238 -- tools/project-map/lib/terminal-summary.mjs | 103 - tools/project-map/lib/vitest-coverage.mjs | 216 -- tools/project-map/lib/worktree-manager.mjs | 387 ---- tools/project-map/package.json | 13 - tools/project-map/server.mjs | 661 ------ tools/project-map/tests/agent-diff.test.mjs | 104 - tools/project-map/tests/agent-queue.test.mjs | 89 - .../project-map/tests/backlog-parser.test.mjs | 80 - .../tests/briefing-and-normalization.test.mjs | 254 --- tools/project-map/tests/cache-mode.test.mjs | 58 - .../project-map/tests/module-naming.test.mjs | 129 -- tools/project-map/tests/server-auth.test.mjs | 73 - 33 files changed, 9442 deletions(-) delete mode 100644 PHASE_BC_LOCAL_TODO.md delete mode 100644 phase-b-acceleration.md delete mode 100644 phase-c-differentiation.md delete mode 100644 tools/project-map/.gitignore delete mode 100644 tools/project-map/dashboard.html delete mode 100644 tools/project-map/index.mjs delete mode 100644 tools/project-map/lib/agent-manager.mjs delete mode 100644 tools/project-map/lib/backlog-parser.mjs delete mode 100644 tools/project-map/lib/debt-scanner.mjs delete mode 100644 tools/project-map/lib/file-watcher.mjs delete mode 100644 tools/project-map/lib/git-scanner.mjs delete mode 100644 tools/project-map/lib/go-coverage.mjs delete mode 100644 tools/project-map/lib/import-graph.mjs delete mode 100644 tools/project-map/lib/morning-briefing.mjs delete mode 100644 tools/project-map/lib/priority-engine.mjs delete mode 100644 tools/project-map/lib/report-generator.mjs delete mode 100644 tools/project-map/lib/research-agent.mjs delete mode 100644 tools/project-map/lib/scanner.mjs delete mode 100644 tools/project-map/lib/session-manager.mjs delete mode 100644 tools/project-map/lib/session-parser.mjs delete mode 100644 tools/project-map/lib/suggestion-engine.mjs delete mode 100644 tools/project-map/lib/terminal-summary.mjs delete mode 100644 tools/project-map/lib/vitest-coverage.mjs delete mode 100644 tools/project-map/lib/worktree-manager.mjs delete mode 100644 tools/project-map/package.json delete mode 100644 tools/project-map/server.mjs delete mode 100644 tools/project-map/tests/agent-diff.test.mjs delete mode 100644 tools/project-map/tests/agent-queue.test.mjs delete mode 100644 tools/project-map/tests/backlog-parser.test.mjs delete mode 100644 tools/project-map/tests/briefing-and-normalization.test.mjs delete mode 100644 tools/project-map/tests/cache-mode.test.mjs delete mode 100644 tools/project-map/tests/module-naming.test.mjs delete mode 100644 tools/project-map/tests/server-auth.test.mjs diff --git a/PHASE_BC_LOCAL_TODO.md b/PHASE_BC_LOCAL_TODO.md deleted file mode 100644 index addbc667..00000000 --- a/PHASE_BC_LOCAL_TODO.md +++ /dev/null @@ -1,290 +0,0 @@ -# Phase B + C — Local Follow-up TODO - -This file enumerates everything from `phase-b-acceleration.md` and -`phase-c-differentiation.md` that **could not be completed inside the -sandboxed Claude session** because the work requires: - -- network access to fetch new modules / npm packages, -- a Go toolchain matching `go.mod`'s `go 1.25.0` directive, -- a WASM toolchain (TinyGo / Rust / AssemblyScript), -- a real machine that can run `npm install`, `cargo`, `tauri`, etc. - -The session **branch is `claude/plan-phases-b-c-bGpoS`**. Everything below -must be run on a developer machine (or CI) before the branch is mergeable. - -The session-resident plan that was actually executed lives in -`/root/.claude/plans/woolly-wiggling-wolf.md` (not in this repo). - ---- - -## Verification (do first — confirms the in-session work compiles) - -- [x] `cd Server && go build ./...` — passes on the dev machine with Go 1.24.x. -- [x] `cd Server && go test ./store/... ./ws/... ./plugin/... ./telemetry/...` - — all pass; full suite `go test ./...` green. -- [x] `cd Server && go vet ./...` — clean. -- [x] `cd Client/tauri-client && npm install && npm run lint && npm run build` - — Pulls in `solid-js`, `vite-plugin-solid`, and - `@solidjs/testing-library` (added to `package.json`); confirms the - Solid pipeline compiles inside the existing Vite + TS setup. -- [x] `cd Client/tauri-client && npm run test` — runs the new - `Badge.test.tsx` smoke test. All 111 test files / 3186 tests pass. - ---- - -## Phase B Step 6 — Solid.js migration (rest of the components) - -The session landed: -- Vite + TS toolchain wiring (`vite.config.ts`, `tsconfig.json`) -- `solid-js` + `vite-plugin-solid` + `@solidjs/testing-library` in - `package.json` -- `src/lib/solidAdapter.ts` (wraps custom stores as Solid signals) -- `src/lib/solidMount.ts` (`{mount, destroy}` adapter for Solid roots) -- `src/components/solid/Badge.tsx` — first leaf -- `src/components/solid/ChannelListItem.tsx` — store-subscribed leaf -- `src/components/solid/Badge.test.tsx` — pipeline smoke test -- `src/components/solid/README.md` — migration recipe - -Still TODO locally: - -- [x] Run `npm install` and verify the build passes (sandbox had no - network). -- [ ] Migrate the remaining leaf components in - `src/components/` one PR at a time, following the recipe in - `src/components/solid/README.md`. Suggested order: presence pills, - typing indicators, message attachments, voice volume meters, then - containers (channel list, member list, message list). -- [ ] Once every leaf is migrated, replace the manual `mountSolid` calls - in containers with native Solid components and delete the old - vanilla DOM utilities (`createComponent`, factory shells) referenced - from `src/components/`. -- [x] Add Vitest config preset: vite-plugin-solid added to vitest.config.ts, - include expanded to pick up src/components/solid/**/*.test.tsx. - Badge.test.tsx now runs automatically (112 files, 3188 tests pass). - ---- - -## Phase B Step 7 — Event persistence - -The session landed: -- `Server/migrations/014_events_table.sql` (SQLite) -- `events` table appended to `Server/migrations/postgres/001_initial_schema.sql` -- `Server/db/queries/sqlite/events.sql`, `Server/db/queries/postgres/events.sql` -- `Server/db/persisted_event.go` — domain type -- `EventStore` sub-interface added to `Server/store/store.go` -- SQLite implementation in `Server/store/sqlite_events.go` (raw SQL via - `*sql.DB`, no `dbgen` dependency) -- MemStore implementation in `Server/store/memstore_events.go` -- Postgres stubs returning `ErrPostgresNotImplemented` -- `Server/ws/event_persister.go` — batched async writer -- `Server/ws/event_pruner.go` — retention pruner goroutine -- Three `replayBuf.Push` call sites in `Server/ws/hub.go` now also call - `h.persistEvent(...)` -- Tiered reconnect replay in `Server/ws/serve.go` (buffer → DB → full) -- Reconnect-tier metrics in the hub + telemetry counter -- `EventPersistenceConfig` added to `Server/config/config.go` with - defaults `{enabled: true, retention_hours: 24, batch_size: 50, - batch_flush_ms: 100, pruner_interval_minutes: 60}` -- `Server/main.go` wires the persister + pruner -- `Server/ws/event_persister_test.go` — batching, drop, drain tests - -Still TODO locally: - -- [x] Run `make sqlc-generate` — done; `db/pgdbgen/events.sql.go` and - `db/pgdbgen/plugins.sql.go` generated; `//go:build postgres` tag - prepended to all 19 pgdbgen files to gate pgx/v5 import. -- [x] Replace the postgres EventStore stubs in `Server/store/postgres.go` - with real implementations using PostgreSQL SQL syntax - (`$1/$2` params, `RETURNING id`, native `bool`/`time.Time`). -- [x] Add an integration test that pushes more than 1000 events through a - real hub with a 1000-slot buffer, disconnects at seq=500, and asserts - the DB tier returns the missing events. Landed in - `Server/ws/reconnect_db_test.go` (`TestReconnect_BufferMiss_FallsBackToDBTier`). -- [x] Add a `replay_source` field to the auth_ok payload — landed in - Pass 4. `buildAuthOK` takes the tier as a parameter, "none" on - fresh connect, "buffer" or "db" on resume. -- [x] Document the new `event_persistence` block in `defaultYAML` inside - `Server/config/config.go` — landed in Pass 3. - ---- - -## Phase B Step 8 — OpenTelemetry - -The session landed: -- `Server/telemetry/telemetry.go` — public API + no-op provider -- `Server/telemetry/telemetry_default.go` — default-build `Init` -- `Server/telemetry/telemetry_otel.go` — wazero/postgres-style build-tag - skeleton (build with `-tags otel`); compiles only when the OTel modules - are in `go.mod` and is currently a structural placeholder -- `Server/telemetry/metrics.go` — `AppMetrics` bundle -- `Server/telemetry/middleware.go` — `HTTPMiddleware` + `PrometheusHandler` -- `Server/telemetry/telemetry_test.go` -- `Server/api/router.go` mounts `telemetry.HTTPMiddleware()` - unconditionally and the Prometheus exporter when non-nil -- `Server/main.go` calls `telemetry.Init` early and defers `Shutdown` -- `TelemetryConfig` added to `Server/config/config.go` -- Spans added to `MessageService.SendMessage`, - `PermissionService.HasChannelPerm`, - `ChannelService.ListVisibleChannels` -- Reconnect-tier counter wired into `WSReconnectTierTotal` from - `Server/ws/serve.go` - -Still TODO locally: - -- [x] Add the OTel modules to `go.mod`: otel v1.43.0, sdk v1.43.0, - exporters/prometheus v0.65.0, - exporters/otlp/otlptrace/otlptracegrpc v1.43.0, and - `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` - v0.67.0 (otelhttp replaces the unmaintained otelchi wrapper - referenced by the original plan; otelhttp is upstream-supported - and wraps any `http.Handler` including a Chi router). -- [x] Replace the placeholder body of `telemetry/telemetry_otel.go`'s - `Init` with the real tracer + meter provider construction. The - tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC - trace exporter when `exporter=otlp` (with `OTLPInsecure` opt-in for - plaintext gRPC), `otelhttp.NewHandler` as the HTTP middleware, and - a real provider that re-binds `AppMetrics` instruments via - `resetAppMetricsForInit`. Tests in - `Server/telemetry/telemetry_otel_test.go` - (`TestOtelInitPrometheusExporter`, `TestOtelTracerRecordsSpan`, - `TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`, - `TestOtelConvertAttrsHandlesUnsignedInts`, - `TestOtelConvertAttrsUint64OverflowFallsBackToString`, - `TestOtelAppMetricsRebindsAfterInit`) run under - `go test -tags otel ./telemetry/...`. -- [x] Build with `-tags otel` — passes. Full 4-tag matrix green - (default, otel, wazero, otel+wazero) against Go 1.25.1. -- [ ] Add a CI job that exercises `go build -tags otel ./...` and - `go test -tags otel ./telemetry/...`. -- [x] Add spans to the remaining service-layer entry points - (`DMService`, `VoiceService`, `InviteService`, `ModerationService`, - `BlockService`, `UserService`) — landed in Pass 3, one entrypoint - per service. Add additional spans on demand. -- [x] Document the new `telemetry` block in `defaultYAML` inside - `Server/config/config.go` — landed in Pass 3. -- [x] Add a `make otel-up` target that spins up Jaeger via - docker-compose for local tracing development. Landed in - `Server/Makefile` (`otel-up` / `otel-down`); overlay file at - `Server/docker-compose.otel.yml`; Prometheus config at - `Server/prometheus.dev.yml`. - ---- - -## Phase C Step 9 — Wazero plugin runtime - -The session landed: -- `Server/plugin/manifest.go` — JSON manifest parser + capability checks -- `Server/plugin/loader.go` — directory scan + entrypoint validation -- `Server/plugin/registry.go` — registry + lifecycle (install/enable/uninstall) -- `Server/plugin/host_commands.go`, `host_storage.go`, `host_events.go`, - `host_http.go`, `host_ui.go` — capability surfaces -- `Server/plugin/sandbox_default.go` — no-op runtime (default build) -- `Server/plugin/sandbox_wazero.go` — `-tags wazero` skeleton -- `Server/plugin/errors.go` -- `Server/plugin/plugin_test.go` -- `Server/plugin/examples/hello/plugin.json` + `README.md` -- `Server/migrations/015_plugins.sql` (SQLite) -- `plugins` + `plugin_kv` tables appended to the postgres schema -- `Server/db/queries/sqlite/plugins.sql`, - `Server/db/queries/postgres/plugins.sql` -- `PluginStore` sub-interface in `Server/store/store.go` with SQLite, - MemStore, and postgres-stub implementations -- `Server/api/plugins_handler.go` — admin REST surface -- `Server/api/router.go` mounts the admin plugin handler -- `Server/main.go` constructs and starts the registry when - `cfg.Plugins.Enabled` -- `PluginsConfig` added to `Server/config/config.go` -- `Client/tauri-client/src/lib/pluginBridge.ts` — iframe + postMessage host -- `Client/tauri-client/src/components/solid/PluginContainer.tsx` — Solid - host component for plugin tabs - -Still TODO locally: - -- [x] Add `github.com/tetratelabs/wazero v1.11.0` to `go.mod`. -- [x] Replace the placeholder body in `Server/plugin/sandbox_wazero.go` - with real wazero runtime construction. The tagged build owns a - shared `wazero.Runtime` created in `platformInit` (with the - configured `MaxMemoryMB` translated to `WithMemoryLimitPages` and - WASI preview-1 imports pre-instantiated), compiles + instantiates - each plugin's `.wasm` entrypoint in `activateWithRuntime`, and - tears modules + runtime down in `platformDeactivate` / `Close`. - The host-guest command ABI is JSON-over-linear-memory: - `allocate(size)` / `command_dispatch(ptr,len) → (ptr,len)` / - `deallocate(ptr,len)`, with optional `list_commands` for command - auto-registration. Tests in `Server/plugin/sandbox_wazero_test.go` - (`TestWazeroRegistryCreatesRuntime`, - `TestWazeroActivateCompilesModule`, - `TestWazeroDispatchCommandMissingExport`, - `TestWazeroCloseTearsDownRuntime`, - `TestWazeroInvalidWASMFailsActivation`, - `TestWazeroDisablePluginFreesModule`) run under - `go test -tags wazero ./plugin/...` using a 41-byte embedded WASM - fixture for the smoke tests. -- [x] Add a precompiled `Server/plugin/examples/hello/hello.wasm` - (925 KiB) built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen - wasm-opt 129. Source in `examples/hello/main.go`; exports: - `allocate`, `deallocate`, `list_commands`, `command_dispatch`, - `on_event`. -- [x] Replace JSON-only manifest parsing with TOML support behind the - `wazero` build tag. Added `github.com/BurntSushi/toml` v1.6.0, - `manifest_toml.go` (wazero) + `manifest_nottoml.go` (!wazero); - `loader.go` prefers `plugin.toml` and falls back to - `plugin.json`. -- [x] Wire `Server/plugin/host_events.go` into the WS pub/sub hub. - Landed: `EventSink.SetBroadcaster`/`Emit` added; hub gains - `SetPluginEventSink`; `deliverBroadcast` calls `sink.Dispatch` - on each sequenced broadcast; wired in `api/router.go`. -- [x] Wire `Server/plugin/host_commands.go` into the WS slash-command - dispatcher. Landed: `chat_command` V1 handler in - `Server/ws/handlers_command.go`; hub gains `SetPluginRegistry`; - wired in `api/router.go`. Tests in `handlers_command_test.go`. -- [x] Pass the live `*plugin.Registry` from `Server/main.go` into - `NewPluginAdminHandler` — landed in Pass 2. The router now accepts - a `*plugin.Registry` parameter and the handler is also wrapped in - `admin.RequireAdminAuth` (Pass 2 closed the auth bypass too). -- [x] Add precompiled `Server/plugin/examples/hello/hello.wasm` (925 KiB). - Built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen wasm-opt 129. - Source in main.go; exports: allocate, deallocate, list_commands, - command_dispatch, on_event. -- [x] Implement plugin marketplace install path - (`POST /api/v1/admin/plugins/install` with multipart zip) — landed - in Pass 4. `Registry.InstallFromZip` does zip-slip validation, no - symlinks, 16 MiB compressed cap, 64 MiB uncompressed cap, then - atomic rename into the plugin directory. -- [x] Replace plugin postgres stubs in `Server/store/postgres.go` with - real SQL implementations (same session as EventStore stubs). -- [ ] Build the first real plugin: game detection. Pulls Steam API, - tracks playtime, exposes `/playtime` slash command. This is the - acceptance criterion in `phase-c-differentiation.md`. - ---- - -## Build-tag matrix the user should set up in CI - -| Tag set | What it builds | Why | -|---|---|---| -| (none) | Default sqlite-only server, no OTel SDK, no wazero | Existing path | -| `otel` | Above + OpenTelemetry SDK + Prometheus exporter | Phase B Step 8 | -| `wazero` | Above + plugin runtime executes WASM modules | Phase C Step 9 | -| `postgres` | Replaces sqlite with postgres backend | Phase A pending | -| `otel,wazero,postgres` | Full community-hub build | Production target | - -Each tag is independently selectable; CI should test every combination at -least minimally so the build-tag boundaries don't drift. - ---- - -## Things explicitly **out of scope** for this branch - -(Documenting so reviewers don't expect them.) - -- Migration of the entire vanilla TypeScript component tree to Solid. Two - proof-of-concept components landed; the rest is mechanical PRs. -- Full OTel SDK wiring (only the public API + no-op default + structural - build-tag skeleton landed). -- Real Wazero `.wasm` execution (only the registry, host APIs, and a - build-tag skeleton landed). -- Real game-detection plugin (the manifest fields and host APIs needed to - build it are in place). -- A reverse postgres → sqlite migration (Phase A documented this is - deliberately unavailable; nothing changed here). diff --git a/phase-b-acceleration.md b/phase-b-acceleration.md deleted file mode 100644 index 4cfd9045..00000000 --- a/phase-b-acceleration.md +++ /dev/null @@ -1,148 +0,0 @@ -# OwnCord — Phase B: Acceleration - -**Steps 6–8 | Weeks 6–14 | Community Scale Edition** - -*Solid.js Frontend • Event Persistence • OpenTelemetry* - ---- - -## Phase Overview - -Phase B runs after the server foundation is in place. It tackles the three remaining infrastructure gaps: a proper frontend framework to accelerate UI development, event persistence for reliable reconnection at scale, and pluggable observability for community hub operators. These steps overlap with the tail end of Phase A since Step 6 is entirely client-side work. - -The Solid.js migration (Step 6) is the highest-effort, highest-payoff change in the entire plan. Event persistence (Step 7) and OpenTelemetry (Step 8) are shorter server-side tasks that run in parallel. All three are required before the community hub milestone can ship. - -### Timeline - -| Step | Task | Duration | Depends On | Parallel? | -|------|------|----------|------------|-----------| -| 6 | Adopt Solid.js (incremental) | 4–6 weeks | — | Yes (client-side) | -| 7 | Event persistence layer | 1–2 weeks | Steps 1+3 | Yes (with 6, 8) | -| 8 | Add OpenTelemetry | 1–2 weeks | Step 1 | Yes (with 6, 7) | - -### Milestone Gate - -**After Phase B:** The client has a proper framework, reconnection is reliable at scale, and operators have pluggable monitoring. The community hub milestone (Milestone 2 from the parity plan) is shippable. - ---- - -## Step 6: Adopt Solid.js Frontend Framework - -**Effort:** High (4–6 weeks incremental) | **Impact:** Critical | **Phase:** B — Acceleration - -### Problem - -The vanilla TypeScript frontend has effectively become a custom framework: reactive stores, a component model, a dispatcher, virtual scrolling, manual DOM lifecycle management. Every new UI feature requires hand-wiring DOM creation, updates, and teardown. Community-scale features (role permission editors with tri-state per-channel overrides, audit log viewers with filtering, moderation dashboards, member lists grouped by role with presence indicators) are complex stateful UIs that are painful to build and maintain without a framework. - -### Options - -| Framework | Reactivity Model | Migration Path | Tauri Ecosystem | Verdict | -|-----------|-----------------|----------------|-----------------|---------| -| **Solid.js** | Signals, effects, memos — maps directly to existing store pattern | Incremental: wrap stores as signals, convert components one by one | Growing. Solid + Tauri examples available. Vite integration native. | **RECOMMENDED** | -| Svelte 5 | Runes (compile-time reactivity). Simple component model, no virtual DOM. | Requires rewriting components in .svelte files. Larger upfront cost. | Good. Tauri + Svelte is well-documented. | VIABLE | -| React | Virtual DOM reconciliation. useState/useEffect hooks. Largest ecosystem. | Full rewrite into JSX components. Hooks model differs from current stores. | Excellent. Most Tauri projects use React. Huge community. | VIABLE | -| Vue 3 | Composition API with ref/reactive. Template-based. Good DX. | Moderate. Composition API maps reasonably to stores. | Good. Tauri + Vue works well. | VIABLE | -| Stay vanilla | Custom reactive stores + manual DOM. Full control, full burden. | No migration. But every new feature carries the full cost. | N/A | AVOID | - -### Why Solid.js - -Solid is the recommended choice because it has the lowest migration cost and the closest philosophical match to what OwnCord already has. Your existing reactive stores are conceptually identical to Solid signals. Solid compiles to direct DOM operations with no virtual DOM overhead, so performance stays where it is now. The migration is incremental: wrap existing stores as Solid signals, convert one component at a time, keep the WebSocket dispatcher and LiveKit facade untouched since they are framework-agnostic. - -### Pros & Cons (Solid.js) - -| Pros | Cons | -|------|------| -| Signals map 1:1 to existing store pattern | Smaller community than React or Vue | -| No virtual DOM — same performance as vanilla | Fewer off-the-shelf UI component libraries | -| Incremental migration (component by component) | Team must learn Solid's reactivity rules | -| Proper component lifecycle and error boundaries | Some vanilla patterns don't translate directly | -| Strong TypeScript support (first-class) | Migration is still weeks of work even incrementally | -| Small bundle size (~7KB gzipped) | | -| Vite integration is native (no config changes) | | - -### Migration Strategy - -1. Install Solid.js and configure Vite for JSX/TSX. Keep all existing code working — Solid and vanilla coexist. -2. Wrap existing stores as Solid signals using a thin adapter. This lets new Solid components read from existing state immediately. -3. Convert one leaf component (a simple, self-contained UI element) to Solid as a proof of concept. Validate that it works in the Tauri WebView. -4. Migrate components bottom-up: leaf components first, then containers. Each converted component is a self-contained PR. -5. Leave framework-agnostic code (dispatcher, LiveKit facade, API client, crypto) untouched. These don't need to change. -6. Once all components are migrated, remove the old DOM manipulation utilities and the custom component model. - ---- - -## Step 7: Event Persistence Layer - -> **NEW IN V2** — This step was a deferred TODO in v1. At community scale the 1000-event ring buffer is insufficient — 100 active users can burn through it in minutes. - -**Effort:** Medium (1–2 weeks) | **Impact:** High | **Phase:** B — Acceleration - -### Problem - -The current reconnection protocol uses a 1000-event in-memory ring buffer. When a client reconnects, the server replays missed events from the buffer. At 100+ active users, a busy server generates 1000 events in minutes. Any user who goes offline for 10–15 minutes will find their last_seq is no longer in the buffer, forcing a full ready re-sync. That re-sync serializes all channels, permissions, member lists, and presence state — an expensive operation that gets more expensive with more users and channels. - -### Solution - -Implement a tiered event persistence model. Keep the in-memory ring buffer for hot events (last ~60 seconds). Write events to the database (via the Store interface from Step 3) for cold replay. On reconnect, the server first checks the ring buffer. If last_seq is too old, it queries the database for events since last_seq up to a reasonable limit. Only if both are exhausted does it fall back to a full ready re-sync. Events in the database are pruned after a configurable retention period (default: 24 hours). - -### Options - -| Option | Details | Verdict | -|--------|---------|---------| -| **Tiered (buffer + DB)** | Ring buffer for hot path, database for cold replay. Best balance of performance and reliability. Uses existing Store interface. | **RECOMMENDED** | -| Larger ring buffer | Increase from 1000 to 50000 events in memory. Simple but uses significant RAM and is still lossy. | VIABLE | -| External event log | Use NATS JetStream or Redis Streams. Robust but adds an external dependency to self-hosted deployments. | AVOID | -| Status quo | Keep 1000-event buffer. Works for friend groups, fails at community scale. | AVOID | - -### Pros & Cons (Tiered) - -| Pros | Cons | -|------|------| -| Hot path stays fast (in-memory ring buffer) | Event writes add DB load (mitigated by batching) | -| Cold replay from DB handles longer disconnections | Must handle event serialization/deserialization | -| Full re-sync becomes rare instead of common | Retention pruning adds a background job | -| Uses existing Store interface — no new dependencies | Query performance matters — needs indexed seq column | -| Configurable retention (24h default) keeps DB size bounded | Two code paths for replay (buffer vs DB) add complexity | -| Architecture was already designed to be persistence-ready | | - -### Implementation Plan - -1. Add an events table to the schema (both SQLite and PostgreSQL migrations): seq INTEGER PRIMARY KEY, event_type TEXT, payload BLOB, channel_id TEXT, created_at TIMESTAMP. Index on (channel_id, seq). -2. Add PersistEvent and GetEventsSince methods to the Store interface. The SQLite implementation uses INSERT with write batching (flush every 100ms or 50 events, whichever comes first). -3. Modify the reconnection handler: check ring buffer first, then query DB, then fall back to full re-sync. Each tier returns the events it can plus a "complete" flag. -4. Add a background goroutine that prunes events older than the retention period (configurable, default 24h). Runs every hour. -5. Add metrics for reconnection tier hits (buffer/db/full) to feed into OpenTelemetry (Step 8). - ---- - -## Step 8: Add OpenTelemetry for Observability - -> **MOVED UP FROM PHASE C** — This step was Phase C in v1. Community hub operators expect pluggable monitoring from day one. - -**Effort:** Medium (1–2 weeks) | **Impact:** High | **Phase:** B — Acceleration - -### Problem - -The current observability is custom: a /metrics endpoint with hand-picked counters, structured logging across two libraries, and client-side JSONL logs. Community hub operators running servers for 100+ people need plug-and-play compatibility with their existing monitoring stack (Prometheus, Grafana, Datadog). They also need distributed tracing to diagnose slow requests when users report latency. - -### Solution - -Integrate the OpenTelemetry Go SDK. Instrument the Chi router middleware for automatic HTTP tracing, add spans to service layer methods, and export metrics in Prometheus format via OTLP. Self-hosters get plug-and-play compatibility with whatever monitoring they already run. This replaces the custom /metrics endpoint with an industry-standard exporter. - -### What to Instrument - -- **HTTP layer:** Chi middleware for automatic request tracing — method, path, status code, duration. This is a single middleware addition. -- **Service layer:** Spans around key service methods (CreateMessage, EditMessage, CheckPermission, etc.). This shows where time is spent in business logic. -- **Database layer:** Query timing and connection pool metrics. Shows when the DB is the bottleneck. -- **WebSocket layer:** Custom metrics for messages/second, active connections, broadcast latency, reconnection rates (by tier from Step 7). -- **LiveKit:** Voice session metrics — active sessions, participant count, connection quality distribution. - -### Pros & Cons - -| Pros | Cons | -|------|------| -| Self-hosters plug into existing monitoring (Grafana, Datadog, etc.) | OTel SDK adds ~5MB to binary size | -| Distributed tracing across REST → Service → DB | Tracing overhead (small but nonzero) on every request | -| Replaces custom /metrics with industry-standard export | Configuration complexity for exporters | -| Chi and database drivers have OTel middleware available | Additional Docker Compose services (collector) for full setup | -| Essential for community hub operators to diagnose issues | | diff --git a/phase-c-differentiation.md b/phase-c-differentiation.md deleted file mode 100644 index bf99e844..00000000 --- a/phase-c-differentiation.md +++ /dev/null @@ -1,139 +0,0 @@ -# OwnCord — Phase C: Differentiation - -**Step 9 | When Phase 6 Features Start | Community Scale Edition** - -*Wazero Plugin Runtime • Game Detection • Server Browser • Stats* - ---- - -## Phase Overview - -Phase C is about what makes OwnCord different from every other Discord alternative. The plugin system is the core differentiator — game detection, server browser, stats, leaderboards, screenshots. All of these are implemented as plugins, not core features, which keeps the server lean while enabling an ecosystem. - -This phase has a single step: implementing the plugin runtime using Wazero. It waits until the plugin features from the product roadmap (Phase 6) are actively being built. There is no urgency to implement the runtime before the features that use it are in scope. The server foundation from Phase A and the client framework from Phase B must be in place first. - -### Timeline - -| Step | Task | Duration | Depends On | Parallel? | -|------|------|----------|------------|-----------| -| 9 | Wazero plugin runtime | 4–6 weeks | Step 1 | When Phase 6 starts | - -### Milestone Gate - -**After Phase C:** The plugin runtime enables the gaming features that differentiate OwnCord from every other Discord alternative. Game detection, rich presence, playtime tracking, server browser, stats, and leaderboards all run as sandboxed WASM plugins. Third-party developers can build and distribute plugins through the plugin marketplace. - -### Prerequisites - -- **Phase A complete:** The service layer (Step 1) provides the host API surface that plugins call into. The Store interface (Step 3) provides plugin-scoped storage. The pub/sub hub (Step 5) enables plugins to emit events to subscribed clients. -- **Phase B complete:** The Solid.js frontend (Step 6) enables plugin UI tabs and widgets. OpenTelemetry (Step 8) provides plugin performance monitoring. - ---- - -## Step 9: Use Wazero for Plugin Runtime - -**Effort:** High (4–6 weeks) | **Impact:** High | **Phase:** C — Differentiation - -### Problem - -The plugin system is OwnCord's core differentiator — game detection, server browser, stats, leaderboards — but the runtime is only designed, not implemented. The design mentions WASM or shared library loading. Shared libraries (.so/.dll) offer no sandboxing, crash the host process on failure, and are platform-specific. At community scale, a misbehaving plugin taking down a 100-user server is unacceptable. - -### Options - -| Option | Details | Verdict | -|--------|---------|---------| -| **Wazero** | Pure-Go WebAssembly runtime. No CGO. Memory-sandboxed, resource-limited, language-agnostic plugin authoring (Rust, Go, C, AssemblyScript). | **RECOMMENDED** | -| Extism | Plugin framework built on Wazero. Higher-level API, easier plugin development. Adds a dependency layer on top of Wazero. | VIABLE | -| Shared libs | Native .so/.dll loading via Go plugin package. No sandboxing, platform-specific, crashes take down the server. | AVOID | -| gRPC sidecar | Plugins as separate processes communicating via gRPC. Strong isolation but heavy: each plugin is a full process with its own lifecycle. | VIABLE | - -### Why Wazero - -Wazero is the recommended choice because it is pure Go (no CGO, matching the existing constraint), provides real memory sandboxing, supports resource limits per plugin (CPU and memory caps), and enables language-agnostic plugin development. Plugins compile to WASM from Rust, Go, C, or AssemblyScript. A crashing plugin cannot take down the server. At community scale this isolation is non-negotiable — you cannot allow a third-party plugin to crash a server serving 100+ users. - -Extism is a viable alternative if you want a higher-level abstraction over Wazero. It simplifies plugin authoring with PDKs (Plugin Development Kits) for multiple languages and handles memory management between host and guest. The tradeoff is an additional dependency and less control over the low-level WASM runtime. For OwnCord's plugin use cases (game detection, server queries, stats computation), the raw Wazero API is sufficient and more transparent. - -### Pros & Cons (Wazero) - -| Pros | Cons | -|------|------| -| Pure Go — no CGO, matches existing constraint | WASM has limited I/O capabilities by design | -| Real memory sandboxing (plugins can't crash server) | Plugin performance is slower than native (~2–5x) | -| Resource limits per plugin (CPU, memory) | Debugging WASM plugins is harder than native code | -| Language-agnostic: Rust, Go, C, AssemblyScript → WASM | Plugin authors must learn WASM toolchain | -| Well-defined host API for plugin ↔ server communication | Complex host API design for game detection, UI tabs, etc. | -| Critical for community scale — isolation is non-negotiable | | - ---- - -## Architecture - -The plugin runtime has four components: the loader, the host API, the sandbox, and the client bridge. - -### Plugin Loader - -Reads plugin.toml manifests, validates declared permissions, loads the .wasm binary into a Wazero runtime instance. Each plugin gets its own isolated module with a dedicated memory space. The loader handles plugin lifecycle: install, enable, disable, uninstall, update. - -### Host API - -The server exposes functions that plugins can call through WASM imports. These are the capabilities a plugin requests in its manifest: - -- **commands:** Register slash commands (/playtime, /serverstatus, /stats). The command dispatcher routes user input to the owning plugin. -- **events:** Subscribe to server events (message_send, user_join, voice_join). The pub/sub hub (Step 5) delivers subscribed events to the plugin. -- **storage:** Plugin-scoped key-value storage via the Store interface (Step 3). Each plugin gets its own namespace. No cross-plugin data access. -- **http:** Outbound HTTP requests (for querying game servers, APIs). Proxied through the server with configurable allowlists per plugin. -- **ui:** Register UI tabs and widgets that render in the Solid.js client (Step 6). Plugin declares HTML/JS assets, client renders them in an iframe sandbox. - -### Sandbox - -Each plugin instance runs with enforced limits: maximum memory allocation (default 64MB), CPU time budget per invocation (default 100ms), and no direct filesystem or network access. The server monitors resource usage and kills plugins that exceed their budget. A crashed or killed plugin is automatically disabled and the admin is notified via the mod log channel. - -### Client Bridge - -Plugins that declare UI capabilities get a rendering surface in the Solid.js client. Plugin UI runs in a sandboxed iframe with postMessage communication to the host client. The host provides a theme-aware CSS injection so plugin UIs match OwnCord's look and feel. The client bridge also handles plugin-specific settings panels. - ---- - -## Implementation Plan - -1. Define the plugin.toml manifest format: name, version, author, permissions (commands, events, storage, http, ui), resource limits. Validate against a JSON schema. -2. Implement the Wazero runtime wrapper: module loading, memory allocation, function imports/exports, lifecycle management (start, stop, restart). -3. Implement the host API functions one capability at a time. Start with commands (simplest — input/output only), then events (requires pub/sub integration), then storage, then HTTP. -4. Build the first plugin: game detection. This exercises commands (user queries playtime), events (presence updates), storage (playtime database), and HTTP (Steam API queries). If game detection works, the architecture is validated. -5. Add the UI capability: client-side iframe sandbox, postMessage bridge, theme injection. Build the server browser plugin to validate the UI integration. -6. Implement plugin marketplace: browse available plugins, install/update/remove from within the admin panel. Plugin packages are .wasm + assets in a zip archive hosted on a registry (GitHub Releases initially). - ---- - -## Complete Timeline — All Phases - -For reference, here is the complete execution timeline across all three phases. - -### Phase A: Foundation (Weeks 1–7) - -| Step | Task | Duration | Depends On | Parallel? | -|------|------|----------|------------|-----------| -| 1 | Extract service/domain layer + permission cache | 2–3 weeks | — | No | -| 2 | Adopt sqlc | 1 week | — | Yes (with 1) | -| 3 | Abstract DB + PostgreSQL target | 2–3 weeks | Steps 1+2 | No | -| 4 | Consolidate logging | 1–2 days | — | Yes (anytime) | -| 5 | Refactor hub to pub/sub + global rate limits | 2–3 weeks | Step 1 | After Step 1 | - -### Phase B: Acceleration (Weeks 6–14) - -| Step | Task | Duration | Depends On | Parallel? | -|------|------|----------|------------|-----------| -| 6 | Adopt Solid.js (incremental) | 4–6 weeks | — | Yes (client-side) | -| 7 | Event persistence layer | 1–2 weeks | Steps 1+3 | Yes (with 6, 8) | -| 8 | Add OpenTelemetry | 1–2 weeks | Step 1 | Yes (with 6, 7) | - -### Phase C: Differentiation (When Phase 6 Features Start) - -| Step | Task | Duration | Depends On | Parallel? | -|------|------|----------|------------|-----------| -| 9 | Wazero plugin runtime | 4–6 weeks | Step 1 | When ready | - -### Total - -**14–18 weeks** for Steps 1–8. Step 9 is deferred until plugin features are in scope. Phases overlap, so calendar time is shorter than the sum of estimates. - -The central principle: stop building infrastructure, start using infrastructure. Every hour spent maintaining a custom query layer, a custom component model, a custom broadcast loop, or a custom event buffer is an hour not spent on the 146 features that make OwnCord compete with Discord. diff --git a/tools/project-map/.gitignore b/tools/project-map/.gitignore deleted file mode 100644 index ceddaa37..00000000 --- a/tools/project-map/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.cache/ diff --git a/tools/project-map/dashboard.html b/tools/project-map/dashboard.html deleted file mode 100644 index 3cc05a36..00000000 --- a/tools/project-map/dashboard.html +++ /dev/null @@ -1,1960 +0,0 @@ - - - - - -OwnCord Project Map - - - - - -
-

OwnCord Project Map

-
- - - -
-
- -
-
Loading project data...
-
- - - - - - - - - - diff --git a/tools/project-map/index.mjs b/tools/project-map/index.mjs deleted file mode 100644 index 2fc75c0b..00000000 --- a/tools/project-map/index.mjs +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node -/** - * OwnCord Project Map Generator - * - * Scans the repository, collects test coverage, parses the backlog, - * scores priorities, and outputs a markdown report + terminal summary. - * - * Usage: - * node index.mjs # full run (runs tests for coverage) - * node index.mjs --quick # skip test runs, use cached coverage - * node index.mjs --research # interactive research launcher - * node index.mjs --serve # launch web dashboard - */ - -import { scanModules } from './lib/scanner.mjs'; -import { collectGoCoverage } from './lib/go-coverage.mjs'; -import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; -import { parseBacklog } from './lib/backlog-parser.mjs'; -import { scorePriorities } from './lib/priority-engine.mjs'; -import { generateReport } from './lib/report-generator.mjs'; -import { printTerminalSummary } from './lib/terminal-summary.mjs'; -import { launchResearchAgent } from './lib/research-agent.mjs'; -import { scanGitHistory } from './lib/git-scanner.mjs'; -import { parseSessionHistory } from './lib/session-parser.mjs'; -import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; -import { buildImportGraph } from './lib/import-graph.mjs'; -import { generateSuggestions } from './lib/suggestion-engine.mjs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync, mkdirSync } from 'node:fs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../..'); -const CACHE_DIR = resolve(__dirname, '.cache'); -const REPORT_PATH = resolve(ROOT, 'docs/brain/00-Overview/Project-Map.md'); - -const args = process.argv.slice(2); -const quick = args.includes('--quick'); -const research = args.includes('--research'); -const serve = args.includes('--serve'); - -if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); - -async function main() { - if (research) { - await launchResearchAgent(ROOT); - return; - } - - if (serve) { - // Dynamic import to launch the server - await import('./server.mjs'); - return; - } - - console.log(quick - ? '\n Project Map (quick mode — using cached data)\n' - : '\n Project Map (full mode — running tests for coverage)\n'); - - // Core data - const modules = await scanModules(ROOT); - const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); - const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); - const backlog = await parseBacklog(ROOT); - const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); - - // Enhanced data (graceful failures) - let gitData = null, sessionData = null, debtData = null, importGraph = null, suggestions = null; - try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Git] ${e.message}`); } - try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Session] ${e.message}`); } - try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Debt] ${e.message}`); } - try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Graph] ${e.message}`); } - try { - suggestions = await generateSuggestions(CACHE_DIR, { - priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, - }); - } catch (e) { console.error(` [Suggestions] ${e.message}`); } - - // Generate markdown report - await generateReport(REPORT_PATH, modules, goCoverage, vitestCoverage, backlog, priorities); - - // Print terminal summary - printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities); - - // Print top suggestions - if (suggestions?.suggestions?.length) { - const CYAN = '\x1b[36m', BOLD = '\x1b[1m', DIM = '\x1b[2m', RESET = '\x1b[0m'; - console.log(`\n ${CYAN}${BOLD}SMART SUGGESTIONS (${suggestions.strategy})${RESET}`); - console.log(` ${DIM}${'─'.repeat(60)}${RESET}`); - for (const s of suggestions.suggestions.slice(0, 5)) { - console.log(` ${CYAN}${s.rank}.${RESET} ${BOLD}${s.module}${RESET} (score: ${s.score}) — ${DIM}${s.rationale}${RESET}`); - } - console.log(''); - } -} - -main().catch(err => { - console.error('Project map failed:', err.message); - process.exit(1); -}); diff --git a/tools/project-map/lib/agent-manager.mjs b/tools/project-map/lib/agent-manager.mjs deleted file mode 100644 index 53dc5bf3..00000000 --- a/tools/project-map/lib/agent-manager.mjs +++ /dev/null @@ -1,988 +0,0 @@ -/** - * Fleet Agent Manager — concurrent job queue with worktree isolation. - * - * Reworked from single-job queue to support parallel agent execution. - * Each agent runs in its own git worktree via worktree-manager. - * - * Key changes from v1: - * - activeAgents Map replaces queueLocked boolean - * - QueueStore provides atomic read-modify-write for queue file - * - Ring buffer caps live output at 500KB per agent - * - Provisioning state for worktree creation phase - * - MAX_CONCURRENT configurable parallel limit - * - Timeout escalation: 80% warning → SIGTERM → 5s → SIGKILL - */ -import { execFileSync, spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - readdirSync, - statSync, - unlinkSync, -} from 'node:fs'; -import { resolve, join } from 'node:path'; - -import { - createWorktree, - destroyWorktree, - cleanupStaleWorktrees, -} from './worktree-manager.mjs'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const QUEUE_FILE = 'agent-queue.json'; -const RESULTS_DIR = 'agent-results'; -const PROMPTS_DIR = 'agent-prompts'; - -const ALLOWED_TYPES = new Set([ - 'research', - 'write-tests', - 'code-review', - 'security-audit', - 'fix-debt', - 'custom', -]); - -const TIMEOUTS = { - research: 600_000, - 'write-tests': 1_200_000, - 'code-review': 900_000, - 'security-audit': 1_200_000, - 'fix-debt': 900_000, - custom: 1_800_000, -}; - -/** Default max concurrent agents */ -let MAX_CONCURRENT = 4; - -/** Ring buffer cap per agent (bytes) */ -const RING_BUFFER_MAX = 512_000; // 500KB - -// --------------------------------------------------------------------------- -// QueueStore — centralized queue I/O with atomic updates -// --------------------------------------------------------------------------- - -class QueueStore { - #cacheDir; - #updateLock = false; - - constructor(cacheDir) { - this.#cacheDir = cacheDir; - } - - /** Read current queue state */ - get() { - const file = resolve(this.#cacheDir, QUEUE_FILE); - if (!existsSync(file)) return []; - try { - const raw = readFileSync(file, 'utf8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } - } - - /** Get a single job by ID */ - getJob(jobId) { - return this.get().find(j => j.id === jobId) ?? null; - } - - /** - * Atomic read-modify-write. The updater function receives the current - * jobs array and must return the new jobs array. - * Prevents concurrent reads from overwriting each other's changes. - */ - update(updaterFn) { - if (this.#updateLock) { - throw new Error('QueueStore: concurrent update detected'); - } - this.#updateLock = true; - try { - const jobs = this.get(); - const updated = updaterFn(jobs); - writeFileSync( - resolve(this.#cacheDir, QUEUE_FILE), - JSON.stringify(updated, null, 2), - ); - return updated; - } finally { - this.#updateLock = false; - } - } -} - -/** Module-level store instance — set via init() or createJob() */ -let store = null; - -function ensureStore(cacheDir) { - if (!store || store._dir !== cacheDir) { - store = new QueueStore(cacheDir); - store._dir = cacheDir; - } - return store; -} - -// --------------------------------------------------------------------------- -// Helpers — directory setup -// --------------------------------------------------------------------------- - -function ensureDirs(cacheDir) { - mkdirSync(resolve(cacheDir, RESULTS_DIR), { recursive: true }); - mkdirSync(resolve(cacheDir, PROMPTS_DIR), { recursive: true }); -} - -// --------------------------------------------------------------------------- -// Helpers — scope path resolution -// --------------------------------------------------------------------------- - -const GO_PACKAGES = new Set([ - 'api', 'ws', 'db', 'auth', 'config', 'admin', 'migrations', 'scripts', -]); - -const TS_AREAS = new Set([ - 'lib', 'stores', 'components', 'pages', -]); - -function resolveScopePath(target) { - if (GO_PACKAGES.has(target)) return `Server/${target}/`; - if (TS_AREAS.has(target)) return `Client/tauri-client/src/${target}/`; - if (target === 'tauri-rust') return 'Client/tauri-client/src-tauri/src/'; - return target; -} - -// --------------------------------------------------------------------------- -// Helpers — process checking -// --------------------------------------------------------------------------- - -function isProcessAlive(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return false; - try { - process.kill(safePid, 0); - return true; - } catch { - return false; - } -} - -function isClaudeProcess(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return false; - try { - if (process.platform === 'win32') { - const out = execFileSync( - 'tasklist', - ['/FI', `PID eq ${safePid}`, '/FO', 'CSV', '/NH'], - { stdio: 'pipe', timeout: 5_000 }, - ).toString(); - const lower = out.toLowerCase(); - return lower.includes('claude') || lower.includes('node'); - } - const out = execFileSync('ps', ['-p', String(safePid), '-o', 'comm='], { - stdio: 'pipe', - timeout: 5_000, - }).toString().trim().toLowerCase(); - return out.includes('claude') || out.includes('node'); - } catch { - return false; - } -} - -function killProcess(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return; - try { - process.kill(safePid, 'SIGTERM'); - setTimeout(() => { - try { - process.kill(safePid, 0); - process.kill(safePid, 'SIGKILL'); - } catch { /* already dead */ } - }, 5_000); - } catch { /* already dead */ } -} - -// --------------------------------------------------------------------------- -// Active agents — tracks running processes -// --------------------------------------------------------------------------- - -/** Map */ -const activeAgents = new Map(); - -/** Processing flag — guards the auto-process interval from double-spawn */ -let isProcessing = false; - -// --------------------------------------------------------------------------- -// Ring buffer for live output -// --------------------------------------------------------------------------- - -const liveOutputBuffers = new Map(); - -function appendToRingBuffer(jobId, text) { - const current = liveOutputBuffers.get(jobId) || ''; - let updated = current + text; - - if (updated.length > RING_BUFFER_MAX) { - const truncateMarker = '\n[... output truncated — showing last 500KB ...]\n'; - updated = truncateMarker + updated.slice(updated.length - RING_BUFFER_MAX + truncateMarker.length); - } - - liveOutputBuffers.set(jobId, updated); -} - -export function getLiveOutput(jobId) { - return liveOutputBuffers.get(jobId) || ''; -} - -export function clearLiveOutput(jobId) { - liveOutputBuffers.delete(jobId); -} - -// --------------------------------------------------------------------------- -// Prompt templates -// --------------------------------------------------------------------------- - -function buildPrompt(job, _root) { - const { type, target, customPrompt } = job; - const scopePath = resolveScopePath(target); - - switch (type) { - case 'research': - return [ - `You are a research agent for OwnCord. Investigate the ${target} module.`, - 'Focus on: coverage gaps, potential bugs, edge cases, security concerns, missing functionality.', - `Scope: ${scopePath}`, - 'Read relevant source and test files. Prioritize findings as CRITICAL/HIGH/MEDIUM/LOW.', - 'Output a structured markdown report.', - ].join('\n'); - - case 'write-tests': - return [ - 'You are a test-writing agent for OwnCord.', - `Module: ${target}`, - `Read the source files in ${scopePath}.`, - 'Read docs/brain/06-Specs/TESTING-STRATEGY.md for test patterns.', - 'Write tests targeting untested functions and branches. Aim for 80%+ coverage.', - 'Save tests to the appropriate test directory.', - ].join('\n'); - - case 'code-review': - return [ - 'You are a code review agent for OwnCord.', - `Review recent changes in the ${target} module for bugs, security issues, code quality.`, - `Scope: ${scopePath}`, - 'Output findings with file:line references and severity ratings.', - ].join('\n'); - - case 'security-audit': - return [ - 'You are a security audit agent for OwnCord.', - `Perform an OWASP Top 10 review of the ${target} module.`, - `Scope: ${scopePath}`, - 'Check for: injection, auth bypass, XSS, CSRF, path traversal, hardcoded secrets.', - 'Output findings with severity, file:line, and remediation steps.', - ].join('\n'); - - case 'fix-debt': - return [ - 'You are a technical debt agent for OwnCord.', - `Address TODO/FIXME/HACK items in the ${target} module.`, - `Scope: ${scopePath}`, - 'For each debt marker, either fix it or explain why it should remain.', - ].join('\n'); - - case 'custom': - return customPrompt; - - default: - return `Investigate ${target} in OwnCord.`; - } -} - -// --------------------------------------------------------------------------- -// Activity hint parsing — extract file paths from agent stdout -// --------------------------------------------------------------------------- - -const FILE_PATH_PATTERNS = [ - /Server\/[\w/.-]+\.go/g, - /Client\/[\w/.-]+\.tsx?/g, - /(? '+' + l).join('\n'); - } else { - const args = baselineSha - ? ['diff', baselineSha, '--', f.path] - : ['diff', '--', f.path]; - const content = gitArgs(args, diffRoot); - diffs[f.path] = content; - } - } catch { - diffs[f.path] = ''; - } - } - - return { files, diffs, freshness }; -} - -// --------------------------------------------------------------------------- -// Public API — configuration -// --------------------------------------------------------------------------- - -export function setMaxConcurrent(n) { - const val = parseInt(n, 10); - if (Number.isInteger(val) && val >= 1 && val <= 8) { - MAX_CONCURRENT = val; - } -} - -export function getMaxConcurrent() { - return MAX_CONCURRENT; -} - -export function getActiveCount() { - return activeAgents.size; -} - -export function getActiveAgentIds() { - return [...activeAgents.keys()]; -} - -// --------------------------------------------------------------------------- -// Public API — health check -// --------------------------------------------------------------------------- - -export function healthCheck() { - try { - const version = execFileSync('claude', ['--version'], { stdio: 'pipe' }) - .toString() - .trim(); - return { available: true, version }; - } catch (err) { - return { available: false, error: err.message || 'Claude CLI not found' }; - } -} - -// --------------------------------------------------------------------------- -// Public API — job CRUD -// --------------------------------------------------------------------------- - -export function getJobs(cacheDir) { - const s = ensureStore(cacheDir); - const jobs = s.get(); - const sorted = [...jobs].sort((a, b) => { - const priDiff = (b.priority ?? 1) - (a.priority ?? 1); - if (priDiff !== 0) return priDiff; - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }); - return { jobs: sorted }; -} - -export function createJob(cacheDir, { type, target, priority, customPrompt }) { - if (!ALLOWED_TYPES.has(type)) { - throw new Error(`Invalid job type "${type}". Allowed: ${[...ALLOWED_TYPES].join(', ')}`); - } - if (!target || typeof target !== 'string' || target.trim().length === 0) { - throw new Error('Job target must be a non-empty string'); - } - if (type === 'custom' && (!customPrompt || typeof customPrompt !== 'string' || customPrompt.trim().length === 0)) { - throw new Error('Custom jobs require a non-empty customPrompt'); - } - - ensureDirs(cacheDir); - const s = ensureStore(cacheDir); - - const job = { - id: `job-${Date.now()}-${randomBytes(4).toString('hex')}`, - type, - target: target.trim(), - status: 'queued', - createdAt: new Date().toISOString(), - startedAt: null, - completedAt: null, - resultPath: null, - error: null, - priority: typeof priority === 'number' ? priority : 1, - retryCount: 0, - maxRetries: 2, - pid: null, - customPrompt: customPrompt ?? null, - // Fleet fields - worktreePath: null, - branchName: null, - configMethod: null, - }; - - s.update(jobs => [...jobs, job]); - return job; -} - -export function cancelJob(cacheDir, jobId) { - const s = ensureStore(cacheDir); - const job = s.getJob(jobId); - if (!job) throw new Error(`Job "${jobId}" not found`); - - if (job.status === 'queued') { - s.update(jobs => jobs.filter(j => j.id !== jobId)); - return { ok: true }; - } - - if (job.status === 'provisioning' || job.status === 'running') { - // Kill process if running - const agent = activeAgents.get(jobId); - if (agent?.pid) killProcess(agent.pid); - if (agent?.timer) clearTimeout(agent.timer); - if (agent?.warningTimer) clearTimeout(agent.warningTimer); - activeAgents.delete(jobId); - - // Destroy worktree - if (job.worktreePath) { - try { - const root = resolve(job.worktreePath, '..', '..'); - destroyWorktree(root, jobId); - } catch (err) { - console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); - } - } - - s.update(jobs => jobs.map(j => - j.id === jobId - ? { ...j, status: 'cancelled', completedAt: new Date().toISOString(), pid: null } - : j, - )); - setTimeout(() => clearLiveOutput(jobId), 5000); - return { ok: true }; - } - - // Terminal statuses (review, done, failed, cancelled) — just remove from list - if (['review', 'dead', 'cancelled'].includes(job.status)) { - if (job.worktreePath) { - try { - const root = resolve(job.worktreePath, '..', '..'); - destroyWorktree(root, jobId); - } catch (err) { - console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); - } - } - s.update(jobs => jobs.filter(j => j.id !== jobId)); - return { ok: true }; - } - - throw new Error(`Cannot cancel job "${jobId}" with status "${job.status}"`); -} - -export function getJobResult(cacheDir, jobId) { - const resultFile = resolve(cacheDir, RESULTS_DIR, `${jobId}.md`); - if (!existsSync(resultFile)) return { content: null }; - return { content: readFileSync(resultFile, 'utf8') }; -} - -// --------------------------------------------------------------------------- -// Public API — fleet queue processing -// --------------------------------------------------------------------------- - -/** Override CLI command for testing */ -let _spawnCommand = 'claude'; -export function setSpawnCommand(cmd) { - if (process.env.NODE_ENV !== 'test') { - throw new Error('setSpawnCommand is only available in test environments'); - } - if (typeof cmd !== 'string' || cmd.length === 0 || cmd.includes('/') || cmd.includes('\\')) { - throw new Error('setSpawnCommand: cmd must be a simple command name'); - } - _spawnCommand = cmd; -} - -/** Check processing state (for diagnostics) */ -export function getIsProcessing() { return isProcessing; } - -/** - * Process the queue — spawn agents up to MAX_CONCURRENT. - * Returns after spawning; agents run asynchronously. - * - * @param {string} root — project root - * @param {string} cacheDir — cache directory - * @param {function} [onOutput] — callback (jobId, chunk) for streaming - * @param {function} [onWarning] — callback (jobId, message) for timeout warnings - * @returns {Promise<{ launched: number, skipped: string[] }>} - */ -export async function processQueue(root, cacheDir, onOutput, onWarning) { - if (isProcessing) return { launched: 0, skipped: ['locked'] }; - isProcessing = true; - - try { - ensureDirs(cacheDir); - const s = ensureStore(cacheDir); - const jobs = s.get(); - - // How many slots available? - const slotsAvailable = MAX_CONCURRENT - activeAgents.size; - if (slotsAvailable <= 0) { - return { launched: 0, skipped: ['at_capacity'] }; - } - - // Pick highest-priority queued jobs - const queued = jobs - .filter(j => j.status === 'queued') - .sort((a, b) => { - const priDiff = (b.priority ?? 1) - (a.priority ?? 1); - if (priDiff !== 0) return priDiff; - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }) - .slice(0, slotsAvailable); - - if (queued.length === 0) { - return { launched: 0, skipped: [] }; - } - - let launched = 0; - const skipped = []; - - for (const job of queued) { - try { - // Mark provisioning - s.update(jobs => jobs.map(j => - j.id === job.id ? { ...j, status: 'provisioning' } : j, - )); - - // Create worktree - let worktreeInfo; - try { - worktreeInfo = createWorktree(root, job.id); - } catch (wtErr) { - console.error(` [Agent] Worktree creation failed for ${job.id}: ${wtErr.message}`); - // Re-queue or mark dead - const newRetry = (job.retryCount ?? 0) + 1; - const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: nextStatus, error: `Worktree failed: ${wtErr.message}`, retryCount: newRetry, pid: null } - : j, - )); - skipped.push(job.id); - continue; - } - - // Build prompt and spawn agent - const prompt = buildPrompt(job, root); - const timeout = TIMEOUTS[job.type] ?? TIMEOUTS.custom; - const resultPath = resolve(cacheDir, RESULTS_DIR, `${job.id}.md`); - - // Save prompt for debugging - const promptFile = resolve(cacheDir, PROMPTS_DIR, `${job.id}.txt`); - try { writeFileSync(promptFile, prompt, 'utf8'); } catch { /* non-critical */ } - - // Spawn claude in the worktree - const child = spawn(_spawnCommand, [ - '-p', prompt, - '--dangerously-skip-permissions', - '--output-format', 'text', - ], { - cwd: worktreeInfo.worktreePath, - shell: false, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const pid = child.pid ?? null; - if (pid === null) { - console.error(` [Agent] Failed to get PID for ${job.id}`); - destroyWorktree(root, job.id); - const newRetry = (job.retryCount ?? 0) + 1; - const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: nextStatus, error: 'Failed to start process', retryCount: newRetry, pid: null, worktreePath: null } - : j, - )); - skipped.push(job.id); - continue; - } - - // Capture baseline SHA for diff tracking - let baselineSha = null; - try { - baselineSha = gitArgs(['rev-parse', 'HEAD'], worktreeInfo.worktreePath); - } catch { /* non-critical */ } - - // Mark running - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: 'running', - startedAt: new Date().toISOString(), - pid, - worktreePath: worktreeInfo.worktreePath, - branchName: worktreeInfo.branchName, - configMethod: worktreeInfo.configMethod, - baselineSha, - preExistingDirtyFiles: [], - } - : j, - )); - - // Initialize live output - liveOutputBuffers.set(job.id, ''); - let stdout = ''; - - child.stdout.on('data', (chunk) => { - const text = chunk.toString(); - stdout += text; - appendToRingBuffer(job.id, text); - if (typeof onOutput === 'function') { - try { onOutput(job.id, text); } catch { /* non-critical */ } - } - }); - - child.stderr.on('data', (chunk) => { - const text = chunk.toString(); - appendToRingBuffer(job.id, `[stderr] ${text}`); - }); - - // Timeout warning at 80% - const warningTimer = setTimeout(() => { - if (typeof onWarning === 'function') { - try { onWarning(job.id, `Agent approaching timeout (80% of ${timeout / 1000}s)`); } catch { /* */ } - } - }, timeout * 0.8); - - // Hard timeout — SIGTERM then SIGKILL - const timer = setTimeout(() => { - killProcess(pid); - }, timeout); - - // Track active agent - activeAgents.set(job.id, { - pid, - worktreePath: worktreeInfo.worktreePath, - branchName: worktreeInfo.branchName, - child, - timer, - warningTimer, - }); - - // Handle completion - child.on('close', (code) => { - clearTimeout(timer); - clearTimeout(warningTimer); - activeAgents.delete(job.id); - - const currentJob = ensureStore(cacheDir).getJob(job.id); - if (!currentJob) return; - - // If cancelled while running, don't overwrite - if (currentJob.status === 'cancelled') { - setTimeout(() => clearLiveOutput(job.id), 5000); - return; - } - - const timedOut = !isProcessAlive(pid) && code !== 0; - - if (code === 0) { - // Success — save result, mark as review (worktree kept for merge) - writeFileSync(resultPath, stdout, 'utf8'); - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: 'review', completedAt: new Date().toISOString(), resultPath, pid: null } - : j, - )); - } else { - // Failure - const newRetry = (currentJob.retryCount ?? 0) + 1; - const maxRetries = currentJob.maxRetries ?? 2; - const errorMsg = code === null - ? `Timed out after ${timeout / 1000}s` - : `Exited with code ${code}`; - const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; - - // Save partial output - if (stdout.length > 0) { - writeFileSync(resultPath, stdout, 'utf8'); - } - - // Destroy worktree on failure - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: nextStatus, - completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, - startedAt: nextStatus === 'queued' ? null : currentJob.startedAt, - error: errorMsg, - retryCount: newRetry, - pid: null, - resultPath: stdout.length > 0 ? resultPath : null, - worktreePath: null, - branchName: nextStatus === 'queued' ? null : currentJob.branchName, - } - : j, - )); - } - - setTimeout(() => clearLiveOutput(job.id), 5000); - }); - - child.on('error', (err) => { - clearTimeout(timer); - clearTimeout(warningTimer); - activeAgents.delete(job.id); - - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - - const newRetry = (job.retryCount ?? 0) + 1; - const maxRetries = job.maxRetries ?? 2; - const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; - - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: nextStatus, - completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, - error: err.message, - retryCount: newRetry, - pid: null, - worktreePath: null, - } - : j, - )); - - setTimeout(() => clearLiveOutput(job.id), 5000); - }); - - launched += 1; - } catch (err) { - console.error(` [Agent] Unexpected error launching ${job.id}: ${err.message}`); - skipped.push(job.id); - } - } - - return { launched, skipped }; - } finally { - isProcessing = false; - } -} - -// --------------------------------------------------------------------------- -// Public API — orphan recovery -// --------------------------------------------------------------------------- - -export function recoverOrphans(cacheDir, root) { - const s = ensureStore(cacheDir); - const jobs = s.get(); - let recovered = 0; - - const updated = jobs.map((job) => { - if (job.status !== 'running' && job.status !== 'provisioning') return job; - - const pid = job.pid; - const alive = pid && isProcessAlive(pid); - const isClaude = alive && isClaudeProcess(pid); - - if (alive && isClaude) return job; - - // Orphan detected - recovered += 1; - - // Destroy worktree if it exists - if (job.worktreePath && root) { - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - } - - const newRetry = (job.retryCount ?? 0) + 1; - const maxRetries = job.maxRetries ?? 2; - - if (newRetry < maxRetries) { - return { - ...job, - status: 'queued', - startedAt: null, - error: 'Orphaned process — re-queued', - retryCount: newRetry, - pid: null, - worktreePath: null, - branchName: null, - }; - } - - return { - ...job, - status: 'dead', - completedAt: new Date().toISOString(), - error: 'Orphaned process — max retries exceeded', - retryCount: newRetry, - pid: null, - worktreePath: null, - branchName: null, - }; - }); - - if (recovered > 0) { - s.update(() => updated); - } - - // Also clean stale worktree directories - if (root) { - try { - cleanupStaleWorktrees(root, (jobId) => { - const job = jobs.find(j => j.id === jobId); - return job?.status === 'running' && job?.pid && isProcessAlive(job.pid); - }); - } catch { /* best effort */ } - } - - return { recovered }; -} - -// --------------------------------------------------------------------------- -// Public API — result pruning -// --------------------------------------------------------------------------- - -export function pruneResults(cacheDir) { - const dir = resolve(cacheDir, RESULTS_DIR); - if (!existsSync(dir)) return { pruned: 0 }; - - let entries; - try { - entries = readdirSync(dir) - .map((name) => { - const full = join(dir, name); - try { - const stat = statSync(full); - return { name, path: full, mtime: stat.mtimeMs }; - } catch { - return null; - } - }) - .filter(Boolean); - } catch { - return { pruned: 0 }; - } - - const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = Date.now() - thirtyDaysMs; - let pruned = 0; - - const remaining = []; - for (const entry of entries) { - if (entry.mtime < cutoff) { - try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } - } else { - remaining.push(entry); - } - } - - if (remaining.length > 50) { - remaining.sort((a, b) => a.mtime - b.mtime); - const excess = remaining.slice(0, remaining.length - 50); - for (const entry of excess) { - try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } - } - } - - return { pruned }; -} - -// --------------------------------------------------------------------------- -// Legacy exports for backward compatibility (used by existing tests) -// --------------------------------------------------------------------------- - -/** @deprecated Use getIsProcessing() instead */ -export function isQueueLocked() { return isProcessing; } - -/** @deprecated Use internal reset instead */ -export function resetQueueLock() { isProcessing = false; } diff --git a/tools/project-map/lib/backlog-parser.mjs b/tools/project-map/lib/backlog-parser.mjs deleted file mode 100644 index 4a1c9aa3..00000000 --- a/tools/project-map/lib/backlog-parser.mjs +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Backlog parser — reads Backlog.md and extracts open tasks, - * mapping them to project modules by keyword matching. - */ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Keywords that map tasks to modules -const MODULE_KEYWORDS = { - // Server Go packages - 'admin': ['admin', 'admin panel', 'admin api'], - 'api': ['api', 'REST', 'endpoint', 'handler', 'middleware'], - 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], - 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], - 'ws': ['websocket', 'WS', 'hub', 'voice', 'broadcast', 'ringbuffer', 'reconnect'], - 'permissions': ['permission', 'role', 'RBAC'], - 'storage': ['upload', 'file storage', 'attachment'], - 'config': ['config', 'settings'], - // Client areas - 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], - 'stores': ['store', 'state management'], - 'components': ['component', 'UI', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], - 'pages': ['page', 'ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], - // Rust - 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater', 'ptt.rs'], - // Cross-cutting - 'protocol': ['protocol', 'message type', 'payload'], - 'e2e': ['E2E', 'Playwright', 'end-to-end'], - 'livekit': ['LiveKit', 'voice', 'video', 'spatial audio', 'whisper', 'screen sharing', 'streaming'], - // Feature areas - 'gaming': ['game detection', 'game time', 'LAN', 'tournament', 'leaderboard', 'seat map', 'Xfire'], - 'community': ['poll', 'gallery', 'scheduler', 'activity feed', 'pinned notes'], - 'platform': ['theme engine', 'webhook', 'bot', 'plugin', 'backup', 'monitoring'], - 'ai': ['AI', 'noise cancellation', 'translation', 'summarization', 'overlay'], -}; - -function matchModule(description) { - const lower = description.toLowerCase(); - const matches = []; - - for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { - for (const kw of keywords) { - const kwLower = kw.toLowerCase(); - const re = new RegExp(`\\b${kwLower}\\b`); - if (re.test(lower)) { - matches.push(mod); - break; - } - } - } - - return matches.length > 0 ? matches : ['unclassified']; -} - -function extractPhase(sectionHeader) { - if (sectionHeader.includes('Bug')) return 'bug'; - if (sectionHeader.includes('Code Review')) return 'code-review'; - if (sectionHeader.includes('Medium Priority') || sectionHeader.includes('P2')) return 'medium'; - if (sectionHeader.includes('High Priority') || sectionHeader.includes('P0') || sectionHeader.includes('P1')) return 'high'; - if (sectionHeader.includes('Deferred')) return 'deferred'; - if (/\bR1\b/.test(sectionHeader)) return 'roadmap-r1'; - if (/\bR2\b/.test(sectionHeader)) return 'roadmap-r2'; - if (/\bR3\b/.test(sectionHeader)) return 'roadmap-r3'; - if (/\bR4\b/.test(sectionHeader)) return 'roadmap-r4'; - if (/\bR5\b/.test(sectionHeader)) return 'roadmap-r5'; - if (/\bR6\b/.test(sectionHeader)) return 'roadmap-r6'; - return 'other'; -} - -export async function parseBacklog(root) { - const backlogPath = resolve(root, 'docs/brain/02-Tasks/Backlog.md'); - if (!existsSync(backlogPath)) { - console.log(' [Backlog] File not found'); - return { tasks: [], openCount: 0, doneCount: 0, byModule: {}, byPhase: {} }; - } - - const content = readFileSync(backlogPath, 'utf8'); - const lines = content.split('\n'); - - const tasks = []; - let currentSection = ''; - - for (const line of lines) { - // Track section headers - if (line.startsWith('## ') || line.startsWith('### ')) { - currentSection = line.replace(/^#+\s*/, ''); - } - - // Match task lines in two formats: - // - [ ] **T-XXX:** description (colon inside bold) - // - [ ] **T-XXX**: description (colon after bold) - const taskMatch = line.match(/^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/); - if (taskMatch) { - const done = taskMatch[1] === 'x'; - const id = `T-${taskMatch[2]}`; - const description = taskMatch[3].replace(/\s*—\s*\d{4}-\d{2}-\d{2}$/, '').trim(); - const modules = matchModule(description); - const phase = extractPhase(currentSection); - - tasks.push({ id, description, done, modules, phase, section: currentSection }); - } - } - - // Aggregate - const openTasks = tasks.filter(t => !t.done); - const doneTasks = tasks.filter(t => t.done); - - const byModule = {}; - for (const task of openTasks) { - for (const mod of task.modules) { - if (!byModule[mod]) byModule[mod] = []; - byModule[mod].push(task); - } - } - - const byPhase = {}; - for (const task of openTasks) { - if (!byPhase[task.phase]) byPhase[task.phase] = []; - byPhase[task.phase].push(task); - } - - console.log(` [Backlog] ${openTasks.length} open tasks, ${doneTasks.length} done`); - return { tasks, openCount: openTasks.length, doneCount: doneTasks.length, byModule, byPhase }; -} diff --git a/tools/project-map/lib/debt-scanner.mjs b/tools/project-map/lib/debt-scanner.mjs deleted file mode 100644 index cf73a16a..00000000 --- a/tools/project-map/lib/debt-scanner.mjs +++ /dev/null @@ -1,311 +0,0 @@ -import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'; -import { join, relative, sep, posix } from 'node:path'; - -const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i; - -const SCAN_DIRS = [ - { dir: 'Server', ext: '.go', skipFile: '_test.go', skipDirs: ['vendor'] }, - { dir: 'Client/tauri-client/src', ext: '.ts', skipFile: null, skipDirs: ['node_modules', 'dist'] }, - { dir: 'Client/tauri-client/src-tauri/src', ext: '.rs', skipFile: null, skipDirs: ['target'] }, -]; - -const LARGE_FILE_WARNING = 400; -const LARGE_FILE_CRITICAL = 800; -const LONG_FUNCTION_LINES = 50; -const DEEP_NESTING_THRESHOLD = 4; - -/** - * Recursively collect files matching an extension, skipping specified directories. - */ -async function collectFiles(base, ext, skipFile, skipDirs) { - const results = []; - - async function walk(dir) { - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.isDirectory()) { - if (skipDirs.includes(entry.name)) continue; - await walk(join(dir, entry.name)); - } else if (entry.isFile() && entry.name.endsWith(ext)) { - if (skipFile && entry.name.endsWith(skipFile)) continue; - results.push(join(dir, entry.name)); - } - } - } - - await walk(base); - return results; -} - -/** - * Derive the module name from a file path relative to root. - */ -function getModule(relPath) { - const parts = relPath.split(/[/\\]/); - - // Server/{package}/file.go -> package name - if (parts[0] === 'Server' && parts.length >= 2) { - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - // Client/tauri-client/src-tauri/src/file.rs -> "tauri-rust" - if ( - parts[0] === 'Client' && - parts[1] === 'tauri-client' && - parts[2] === 'src-tauri' - ) { - return 'tauri-rust'; - } - - // Client/tauri-client/src/{area}/file.ts -> area name - if ( - parts[0] === 'Client' && - parts[1] === 'tauri-client' && - parts[2] === 'src' - ) { - return parts.length >= 5 ? parts[3] : 'client-root'; - } - - return 'unknown'; -} - -/** - * Normalize path to forward slashes for consistent output. - */ -function normalizePath(p) { - return p.split(sep).join(posix.sep); -} - -/** - * Detect long functions via brace-depth tracking. - * Returns array of { line, name, lines }. - */ -function detectLongFunctions(content, ext) { - const lines = content.split('\n'); - const functions = []; - - // Stack: { name, startLine, depth } - let current = null; - let braceDepth = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trimStart(); - - // Detect function start - let funcName = null; - - if (ext === '.go') { - const match = trimmed.match(/^func\s+(?:\([^)]*\)\s*)?(\w+)/); - if (match) funcName = match[1]; - } else if (ext === '.ts') { - // Named function declaration - const fnMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/); - if (fnMatch) { - funcName = fnMatch[1]; - } else { - // Arrow function: const name = (...) => { - const arrowMatch = trimmed.match(/^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])*=>\s*\{/); - if (arrowMatch) { - funcName = arrowMatch[1]; - } - } - } else if (ext === '.rs') { - const match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/); - if (match) funcName = match[1]; - } - - if (funcName && current === null) { - current = { name: funcName, startLine: i + 1, depth: braceDepth }; - } - - // Track braces - for (const ch of line) { - if (ch === '{') braceDepth++; - if (ch === '}') braceDepth--; - } - if (braceDepth < 0) braceDepth = 0; - - // Check if current function has ended - if (current !== null && braceDepth <= current.depth) { - const funcLines = (i + 1) - current.startLine + 1; - if (funcLines > LONG_FUNCTION_LINES) { - functions.push({ - line: current.startLine, - name: current.name, - lines: funcLines, - }); - } - current = null; - } - } - - return functions; -} - -/** - * Scan a single file for all debt indicators. - */ -function scanFile(content, relPath, ext) { - const lines = content.split('\n'); - const mod = getModule(relPath); - const file = normalizePath(relPath); - - const markers = []; - const deepNesting = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Marker detection - const markerMatch = line.match(MARKER_RE); - if (markerMatch) { - markers.push({ - type: markerMatch[1].toUpperCase(), - file, - line: i + 1, - text: markerMatch[2].trim(), - module: mod, - }); - } - - // Deep nesting detection - const leadingSpaces = line.match(/^(\s*)/)[1]; - const tabCount = (leadingSpaces.match(/\t/g) || []).length; - const spaceCount = leadingSpaces.replace(/\t/g, '').length; - const depth = tabCount + Math.floor(spaceCount / 4); - - if (depth >= DEEP_NESTING_THRESHOLD && line.trim().length > 0) { - deepNesting.push({ - file, - line: i + 1, - depth, - module: mod, - }); - } - } - - // Large file detection - let largeFile = null; - if (lines.length > LARGE_FILE_WARNING) { - largeFile = { - file, - lines: lines.length, - module: mod, - severity: lines.length > LARGE_FILE_CRITICAL ? 'critical' : 'warning', - }; - } - - // Long function detection - const longFunctions = detectLongFunctions(content, ext).map((f) => ({ - file, - line: f.line, - name: f.name, - lines: f.lines, - module: mod, - })); - - return { markers, largeFile, longFunctions, deepNesting }; -} - -/** - * Scan the codebase for technical debt indicators. - * - * @param {string} root - Absolute path to the repository root - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} Technical debt report - */ -export async function scanTechnicalDebt(root, cacheDir, quick) { - const cachePath = join(cacheDir, 'debt-data.json'); - - if (quick) { - try { - const cached = await readFile(cachePath, 'utf-8'); - return JSON.parse(cached); - } catch { - // Cache miss, proceed with full scan - } - } - - const allMarkers = []; - const allLargeFiles = []; - const allLongFunctions = []; - let allDeepNesting = []; - - for (const { dir, ext, skipFile, skipDirs } of SCAN_DIRS) { - const baseDir = join(root, ...dir.split('/')); - const files = await collectFiles(baseDir, ext, skipFile, skipDirs); - - for (const filePath of files) { - let content; - try { - content = await readFile(filePath, 'utf-8'); - } catch { - continue; - } - - const relPath = relative(root, filePath); - const result = scanFile(content, relPath, ext); - - allMarkers.push(...result.markers); - if (result.largeFile) allLargeFiles.push(result.largeFile); - allLongFunctions.push(...result.longFunctions); - allDeepNesting.push(...result.deepNesting); - } - } - - // Keep only top 20 deepest nesting instances - allDeepNesting.sort((a, b) => b.depth - a.depth); - allDeepNesting = allDeepNesting.slice(0, 20); - - // Build per-module summary - const byModule = {}; - const ensureModule = (mod) => { - if (!byModule[mod]) { - byModule[mod] = { markers: 0, largeFiles: 0, longFunctions: 0 }; - } - }; - - for (const m of allMarkers) { - ensureModule(m.module); - byModule[m.module].markers++; - } - for (const f of allLargeFiles) { - ensureModule(f.module); - byModule[f.module].largeFiles++; - } - for (const f of allLongFunctions) { - ensureModule(f.module); - byModule[f.module].longFunctions++; - } - - const report = { - timestamp: new Date().toISOString(), - markers: allMarkers, - largeFiles: allLargeFiles, - longFunctions: allLongFunctions, - deepNesting: allDeepNesting, - summary: { - totalMarkers: allMarkers.length, - totalLargeFiles: allLargeFiles.length, - totalLongFunctions: allLongFunctions.length, - byModule, - }, - }; - - // Write cache - try { - await mkdir(cacheDir, { recursive: true }); - await writeFile(cachePath, JSON.stringify(report, null, 2), 'utf-8'); - } catch { - // Non-fatal: cache write failure is acceptable - } - - return report; -} diff --git a/tools/project-map/lib/file-watcher.mjs b/tools/project-map/lib/file-watcher.mjs deleted file mode 100644 index a5832825..00000000 --- a/tools/project-map/lib/file-watcher.mjs +++ /dev/null @@ -1,115 +0,0 @@ -/** - * File watcher + SSE auto-refresh. - * Watches key directories and notifies connected SSE clients on changes. - */ -import { watch } from 'node:fs'; -import { resolve } from 'node:path'; - -export function createFileWatcher(root, onChangeCallback) { - const watchDirs = [ - resolve(root, 'Server'), - resolve(root, 'Client/tauri-client/src'), - resolve(root, 'Client/tauri-client/src-tauri/src'), - resolve(root, 'docs/brain'), - ]; - - // Debounce: only fire callback once per 2 seconds - let debounceTimer = null; - let destroyed = false; - const debounceMs = 2000; - - function handleChange(eventType, filename) { - if (destroyed) return; - - // Ignore non-source files - if (filename && ( - filename.includes('node_modules') || - filename.includes('.git') || - filename.includes('dist') || - filename.includes('target') || - filename.endsWith('.swp') || - filename.endsWith('~') - )) return; - - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - if (destroyed) return; - try { - onChangeCallback({ eventType, filename, timestamp: new Date().toISOString() }); - } catch (err) { - console.error('[Watch] callback error:', err.message); - } - }, debounceMs); - } - - const watchers = []; - for (const dir of watchDirs) { - try { - const w = watch(dir, { recursive: true }, handleChange); - watchers.push(w); - } catch { - // Directory might not exist — skip silently - } - } - - return { - close() { - destroyed = true; - if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } - for (const w of watchers) { - try { w.close(); } catch { /* ignore */ } - } - }, - }; -} - -/** - * SSE (Server-Sent Events) manager. - * Maintains a list of connected clients and broadcasts events to all. - */ -export function createSSEManager() { - const clients = new Set(); - - const heartbeatInterval = setInterval(() => { - for (const client of clients) { - try { client.write(': ping\n\n'); } catch { clients.delete(client); } - } - }, 30000); - - function handleConnection(req, res) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - - // Send initial connected event - res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() })}\n\n`); - - clients.add(res); - - req.on('close', () => { - clients.delete(res); - }); - } - - function broadcast(event) { - const data = `data: ${JSON.stringify(event)}\n\n`; - for (const client of clients) { - try { client.write(data); } catch { clients.delete(client); } - } - } - - function close() { - clearInterval(heartbeatInterval); - for (const client of clients) { - try { client.end(); } catch { /* ignore */ } - } - clients.clear(); - } - - // Safety net: clean up on process exit to prevent leaked intervals - process.on('exit', () => { clearInterval(heartbeatInterval); }); - - return { handleConnection, broadcast, close, get clientCount() { return clients.size; } }; -} diff --git a/tools/project-map/lib/git-scanner.mjs b/tools/project-map/lib/git-scanner.mjs deleted file mode 100644 index 89b999b9..00000000 --- a/tools/project-map/lib/git-scanner.mjs +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Git history scanner — analyzes recent commits to provide per-module - * activity, file churn, staleness, velocity, and commit-type breakdown. - * - * Zero external dependencies — Node built-ins only. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; - -const WINDOW_DAYS = 30; -const TOP_CHURN = 20; -const RECENT_LIMIT = 15; - -const COMMIT_TYPES = ['feat', 'fix', 'test', 'refactor', 'docs', 'chore', 'perf']; - -/** - * Map a file path to its logical module name. - * Server/{package}/ → package name (e.g. "ws", "db", "auth") - * Client/tauri-client/src/{area}/ → area name (e.g. "components", "lib") - * Client/tauri-client/src-tauri/src/ → "tauri-rust" - * - * NOTE: names are unprefixed to match scanner.mjs, backlog-parser.mjs, - * debt-scanner.mjs, and suggestion-engine.mjs canonical keys. - */ -function resolveModule(filePath) { - const normalized = filePath.replace(/\\/g, '/'); - - if (normalized.startsWith('Server/')) { - const parts = normalized.split('/'); - // parts[1] is a subdirectory only when there are 3+ segments - // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { - return 'tauri-rust'; - } - - if (normalized.startsWith('Client/tauri-client/src/')) { - const parts = normalized.replace('Client/tauri-client/src/', '').split('/'); - return parts.length >= 1 && parts[0] !== '' ? parts[0] : 'client-root'; - } - - if (normalized.startsWith('Client/tauri-client/')) { - return 'client-config'; - } - - if (normalized.startsWith('Client/')) { - return 'client-other'; - } - - // Root-level files (README, .gitignore, etc.) - return 'root'; -} - -/** - * Parse a conventional-commit prefix from a message. - * Returns one of the known types or "other". - */ -function parseCommitType(message) { - const match = message.match(/^(\w+)(?:\(.+?\))?[!]?:/); - if (!match) return 'other'; - const prefix = match[1].toLowerCase(); - return COMMIT_TYPES.includes(prefix) ? prefix : 'other'; -} - -/** - * Run a git command and return stdout as a string. - * Uses execFileSync with argument arrays to avoid shell injection. - */ -function git(args, root) { - return execFileSync('git', args, { - cwd: root, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); -} - -/** - * Parse the raw git log output into structured commit objects. - * - * git log --format="%H|%ai|%s" --name-only produces: - * HASH|DATE|SUBJECT - * (blank line) - * file1 - * file2 - * HASH|DATE|SUBJECT - * (blank line) - * file1 - * ... - * - * We detect header lines by the HASH|DATE|SUBJECT pattern and - * accumulate file lines until the next header. - */ -function parseGitLog(raw) { - const commits = []; - const lines = raw.trim().split('\n'); - - let current = null; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === '') continue; - - // Detect header: 40-char hex hash, then pipe, then date, then pipe, then subject - const pipeIdx1 = trimmed.indexOf('|'); - const pipeIdx2 = pipeIdx1 !== -1 ? trimmed.indexOf('|', pipeIdx1 + 1) : -1; - const isHeader = pipeIdx1 === 40 && pipeIdx2 !== -1; - - if (isHeader) { - // Finalize previous commit - if (current) { - current.modules = [...new Set(current.files.map(resolveModule))]; - commits.push(current); - } - - const hash = trimmed.slice(0, pipeIdx1); - const date = trimmed.slice(pipeIdx1 + 1, pipeIdx2); - const message = trimmed.slice(pipeIdx2 + 1); - const type = parseCommitType(message); - - current = { hash, date, message, type, files: [], modules: [] }; - } else if (current) { - current.files.push(trimmed); - } - } - - // Finalize last commit - if (current) { - current.modules = [...new Set(current.files.map(resolveModule))]; - commits.push(current); - } - - return commits; -} - -/** - * Build per-module commit data from the parsed commits. - */ -function buildCommitsByModule(commits) { - const byModule = {}; - - for (const commit of commits) { - for (const mod of commit.modules) { - if (!byModule[mod]) { - byModule[mod] = { count: 0, lastCommit: commit.date, commits: [] }; - } - byModule[mod].count += 1; - byModule[mod].commits.push({ - hash: commit.hash, - date: commit.date, - message: commit.message, - type: commit.type, - }); - // Keep the most recent date - if (commit.date > byModule[mod].lastCommit) { - byModule[mod].lastCommit = commit.date; - } - } - } - - return byModule; -} - -/** - * Compute file churn — number of commits touching each file. - * Returns top N most churned files. - */ -function buildFileChurn(commits) { - const churnMap = {}; - - for (const commit of commits) { - for (const file of commit.files) { - if (!churnMap[file]) { - churnMap[file] = { file, commits: 0, module: resolveModule(file) }; - } - churnMap[file].commits += 1; - } - } - - return Object.values(churnMap) - .sort((a, b) => b.commits - a.commits) - .slice(0, TOP_CHURN); -} - -/** - * Calculate staleness — days since last commit per module. - */ -function buildStaleness(commitsByModule) { - const now = Date.now(); - const staleness = {}; - - for (const [mod, data] of Object.entries(commitsByModule)) { - const lastDate = new Date(data.lastCommit); - const daysSince = Math.floor((now - lastDate.getTime()) / (1000 * 60 * 60 * 24)); - staleness[mod] = { - daysSinceLastCommit: daysSince, - lastCommitDate: data.lastCommit, - }; - } - - return staleness; -} - -/** - * Build daily velocity and rolling 7-day average. - */ -function buildVelocity(commits) { - // Build a map of date → commit count - const dailyMap = {}; - const now = new Date(); - - for (let i = 0; i < WINDOW_DAYS; i++) { - const d = new Date(now); - d.setDate(d.getDate() - i); - const key = d.toISOString().slice(0, 10); - dailyMap[key] = 0; - } - - for (const commit of commits) { - const key = commit.date.slice(0, 10); - if (key in dailyMap) { - dailyMap[key] += 1; - } - } - - const daily = Object.entries(dailyMap) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, count]) => ({ date, count })); - - // Rolling 7-day average (use last 7 days) - const last7 = daily.slice(-7); - const weeklyAvg = last7.length > 0 - ? Math.round((last7.reduce((s, d) => s + d.count, 0) / last7.length) * 100) / 100 - : 0; - - // Trend: compare first half vs second half of the window - const mid = Math.floor(daily.length / 2); - const firstHalf = daily.slice(0, mid); - const secondHalf = daily.slice(mid); - - const avgFirst = firstHalf.length > 0 - ? firstHalf.reduce((s, d) => s + d.count, 0) / firstHalf.length - : 0; - const avgSecond = secondHalf.length > 0 - ? secondHalf.reduce((s, d) => s + d.count, 0) / secondHalf.length - : 0; - - let trend = 'stable'; - const delta = avgSecond - avgFirst; - if (delta > 0.3) trend = 'accelerating'; - else if (delta < -0.3) trend = 'decelerating'; - - return { daily, weeklyAvg, trend }; -} - -/** - * Build commit type breakdown. - */ -function buildCommitTypes(commits) { - const types = { feat: 0, fix: 0, test: 0, refactor: 0, docs: 0, chore: 0, other: 0 }; - - for (const commit of commits) { - const bucket = commit.type in types ? commit.type : 'other'; - types[bucket] += 1; - } - - return types; -} - -/** - * Main entry point. Scans git history for the last 30 days and returns - * structured data about module activity, churn, staleness, and velocity. - * - * @param {string} root - Repository root directory - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} Structured git history data - */ -// Exported for testing -export { resolveModule }; - -export async function scanGitHistory(root, cacheDir, quick = false) { - const cacheFile = join(cacheDir, 'git-data.json'); - - // Quick mode: return cache if available - if (quick && existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - return cached; - } catch { - // Cache corrupt — fall through to fresh scan - } - } - - // Fetch git log with file names - const raw = git( - ['log', `--since=${WINDOW_DAYS} days ago`, '--format=%H|%ai|%s', '--name-only'], - root, - ); - - const commits = parseGitLog(raw); - const commitsByModule = buildCommitsByModule(commits); - const fileChurn = buildFileChurn(commits); - const staleness = buildStaleness(commitsByModule); - const velocity = buildVelocity(commits); - const commitTypes = buildCommitTypes(commits); - - const recentCommits = commits.slice(0, RECENT_LIMIT).map(c => ({ - hash: c.hash, - date: c.date, - message: c.message, - type: c.type, - modules: c.modules, - })); - - const result = { - timestamp: new Date().toISOString(), - commitsByModule, - fileChurn, - staleness, - velocity, - commitTypes, - recentCommits, - totalCommits30d: commits.length, - }; - - // Persist cache - try { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); - } catch { - // Non-fatal — scanning still succeeds without cache write - } - - return result; -} diff --git a/tools/project-map/lib/go-coverage.mjs b/tools/project-map/lib/go-coverage.mjs deleted file mode 100644 index c890cabe..00000000 --- a/tools/project-map/lib/go-coverage.mjs +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Go test coverage collector. - * Runs `go test ./... -cover -short -json` and parses per-package coverage. - * Caches results to .cache/go-coverage.json. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const CACHE_FILE = 'go-coverage.json'; - -// Extract short package name from full Go module path -// e.g. "github.com/owncord/server/admin" -> "admin" -// e.g. "github.com/owncord/server" -> "root" -function extractPkgName(fullPath) { - const parts = fullPath.split('/'); - const last = parts[parts.length - 1]; - // If the package ends with "server" it's the root package - if (last === 'server') return 'server-root'; - return last; -} - -function parseGoCoverage(jsonLines) { - const coverage = {}; - - for (const line of jsonLines.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - // Look for coverage output lines - if (entry.Action === 'output' && entry.Output) { - // Match: "coverage: 67.3% of statements" - const coverMatch = entry.Output.match(/coverage:\s+([\d.]+)%\s+of\s+statements/); - if (coverMatch && entry.Package) { - const pkg = extractPkgName(entry.Package); - coverage[pkg] = { - percentage: parseFloat(coverMatch[1]), - package: pkg, - }; - } - // Match: "[no test files]" - if (entry.Output.includes('[no test files]') && entry.Package) { - const pkg = extractPkgName(entry.Package); - coverage[pkg] = { - percentage: null, - package: pkg, - noTests: true, - }; - } - } - // Also check for pass/fail - if (entry.Action === 'pass' && entry.Package) { - const pkg = extractPkgName(entry.Package); - if (!coverage[pkg]) { - coverage[pkg] = { percentage: 0, package: pkg }; - } - coverage[pkg].passed = true; - } - if (entry.Action === 'fail' && entry.Package) { - const pkg = extractPkgName(entry.Package); - if (!coverage[pkg]) { - coverage[pkg] = { percentage: 0, package: pkg }; - } - coverage[pkg].failed = true; - } - } catch { /* skip non-JSON lines */ } - } - - return coverage; -} - -export async function collectGoCoverage(root, cacheDir, quick) { - const cacheFile = resolve(cacheDir, CACHE_FILE); - - if (quick && existsSync(cacheFile)) { - console.log(' [Go] Using cached coverage data'); - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - if (cached.hasFailures) { - console.warn(' [Go Coverage] Cached data has test failures — re-running tests'); - // Fall through to full collection instead of returning cached - } else { - return cached; - } - } catch { - console.warn(' [Go] Corrupt cache — re-running tests'); - } - } - - const serverDir = resolve(root, 'Server'); - if (!existsSync(serverDir)) { - console.log(' [Go] Server directory not found, skipping'); - return {}; - } - - console.log(' [Go] Running tests with coverage (this may take a minute)...'); - try { - const output = execFileSync('go', ['test', './...', '-cover', '-short', '-json', '-count=1'], { - cwd: serverDir, - encoding: 'utf8', - timeout: 300_000, // 5 min - maxBuffer: 10 * 1024 * 1024, - }); - - const coverage = parseGoCoverage(output); - - // Cache results - const result = { - timestamp: new Date().toISOString(), - packages: coverage, - }; - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [Go] Coverage collected for ${Object.keys(coverage).length} packages`); - return result; - } catch (err) { - // go test returns non-zero on test failure but still produces output - if (err.stdout) { - const coverage = parseGoCoverage(err.stdout); - const result = { - timestamp: new Date().toISOString(), - packages: coverage, - hasFailures: true, - }; - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [Go] Coverage collected (some tests failed)`); - return result; - } - console.error(` [Go] Failed to collect coverage: ${err.message}`); - return { timestamp: new Date().toISOString(), packages: {}, error: err.message }; - } -} diff --git a/tools/project-map/lib/import-graph.mjs b/tools/project-map/lib/import-graph.mjs deleted file mode 100644 index e6d810a5..00000000 --- a/tools/project-map/lib/import-graph.mjs +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Import-graph builder — parses Go, TypeScript, and Rust source files to - * construct a dependency graph with fan-in/fan-out metrics and cycle detection. - * Zero external dependencies — Node built-ins only. - */ -import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join, resolve, basename, relative } from 'node:path'; - -// --------------------------------------------------------------------------- -// File discovery -// --------------------------------------------------------------------------- - -/** - * Recursively collect files matching `extensions` under `dir`, skipping - * common non-source directories. - */ -function collectFiles(dir, extensions, skipSuffix = []) { - const results = []; - if (!existsSync(dir)) return results; - - function walk(d) { - let entries; - try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (entry.name.startsWith('.') || ['node_modules', 'vendor', 'dist', 'target'].includes(entry.name)) continue; - const full = join(d, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.isFile() - && extensions.some(ext => entry.name.endsWith(ext)) - && !skipSuffix.some(suf => entry.name.endsWith(suf))) { - results.push(full); - } - } - } - - walk(dir); - return results; -} - -// --------------------------------------------------------------------------- -// Go import parsing -// --------------------------------------------------------------------------- - -const GO_MODULE_PREFIX = 'github.com/owncord/server/'; - -/** - * Parse a single Go file and return the set of internal package short-names - * it imports (e.g. "db", "auth", "ws"). - */ -function parseGoImports(content) { - const deps = new Set(); - // Match import blocks: import ( ... ) - const blockRe = /import\s*\(([^)]*)\)/gs; - let blockMatch; - while ((blockMatch = blockRe.exec(content)) !== null) { - const block = blockMatch[1]; - const lineRe = /["']([^"']+)["']/g; - let lineMatch; - while ((lineMatch = lineRe.exec(block)) !== null) { - const path = lineMatch[1]; - if (path.startsWith(GO_MODULE_PREFIX)) { - deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); - } - } - } - // Match single-line imports: import "..." - const singleRe = /import\s+["']([^"']+)["']/g; - let singleMatch; - while ((singleMatch = singleRe.exec(content)) !== null) { - const path = singleMatch[1]; - if (path.startsWith(GO_MODULE_PREFIX)) { - deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); - } - } - return deps; -} - -/** - * Scan Go source files under `serverDir` and return a map of - * package-name -> Set. - */ -function scanGoImports(serverDir) { - const graph = new Map(); - const files = collectFiles(serverDir, ['.go'], ['_test.go']); - - for (const file of files) { - // Determine package from directory name relative to server root - const rel = relative(serverDir, file); - const pkg = rel.includes('/') || rel.includes('\\') - ? rel.split(/[/\\]/)[0] - : '.'; // root-level files (main.go etc.) - - if (pkg === '.' || pkg === 'scripts' || pkg === 'migrations') continue; - - if (!graph.has(pkg)) graph.set(pkg, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseGoImports(content); - const existing = graph.get(pkg); - for (const dep of deps) { - if (dep !== pkg) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// TypeScript import parsing -// --------------------------------------------------------------------------- - -/** Known TS path-alias prefixes that map to source directories. */ -const TS_ALIAS_MAP = { - '@lib': 'lib', - '@stores': 'stores', - '@components': 'components', - '@pages': 'pages', - '@styles': 'styles', - '@types': 'types', -}; - -/** - * Parse a TypeScript file and return the set of internal module names it - * imports (e.g. "lib", "stores", "components"). - */ -function parseTsImports(content) { - const deps = new Set(); - - // Match: import ... from '...' / import '...' (including type imports) - const re = /import\s+(?:type\s+)?(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g; - let m; - while ((m = re.exec(content)) !== null) { - const specifier = m[1]; - - // Check @alias paths: @lib/foo -> "lib" - for (const [alias, dir] of Object.entries(TS_ALIAS_MAP)) { - if (specifier.startsWith(alias + '/') || specifier === alias) { - deps.add(dir); - break; - } - } - - // Check relative paths: ../lib/foo -> "lib", ./sibling stays in same dir - if (specifier.startsWith('../')) { - const segment = specifier.slice(3).split('/')[0]; - if (segment && Object.values(TS_ALIAS_MAP).includes(segment)) { - deps.add(segment); - } - } - } - - return deps; -} - -/** - * Determine which TS "module" a file belongs to based on its directory. - * Files directly in src/ are grouped as "main". - */ -function tsModuleName(file, srcDir) { - const rel = relative(srcDir, file).replace(/\\/g, '/'); - const parts = rel.split('/'); - if (parts.length === 1) return basename(parts[0], '.ts'); // top-level file -> its name - return parts[0]; // first directory segment -} - -/** - * Scan TypeScript files under `srcDir` and return a dependency map. - */ -function scanTsImports(srcDir) { - const graph = new Map(); - const files = collectFiles(srcDir, ['.ts']); - - for (const file of files) { - const mod = tsModuleName(file, srcDir); - if (!graph.has(mod)) graph.set(mod, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseTsImports(content); - const existing = graph.get(mod); - for (const dep of deps) { - if (dep !== mod) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// Rust import parsing -// --------------------------------------------------------------------------- - -/** - * Parse a Rust file and return module names referenced via `mod` declarations - * and `use crate::` paths. - */ -function parseRustImports(content) { - const deps = new Set(); - - // mod declarations: mod foo; - const modRe = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm; - let m; - while ((m = modRe.exec(content)) !== null) { - deps.add(m[1]); - } - - // use crate::foo (possibly ::bar::baz) - const useRe = /use\s+crate::(\w+)/g; - while ((m = useRe.exec(content)) !== null) { - deps.add(m[1]); - } - - return deps; -} - -/** - * Determine Rust module name from filename. - * lib.rs and main.rs -> "lib" / "main"; others -> stem. - */ -function rustModuleName(file) { - const name = basename(file, '.rs'); - return name; -} - -/** - * Scan Rust files and return a dependency map. - */ -function scanRustImports(rustDir) { - const graph = new Map(); - const files = collectFiles(rustDir, ['.rs']); - - for (const file of files) { - const mod = rustModuleName(file); - if (!graph.has(mod)) graph.set(mod, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseRustImports(content); - const existing = graph.get(mod); - for (const dep of deps) { - if (dep !== mod) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// Graph analysis -// --------------------------------------------------------------------------- - -/** - * Merge multiple per-language graphs into a single adjacency list (object). - * Each value is a Map>; we also track which language - * each module belongs to. - */ -function mergeGraphs(goGraph, tsGraph, rustGraph) { - const merged = {}; // module -> string[] - const types = {}; // module -> 'go' | 'typescript' | 'rust' - - for (const [mod, deps] of goGraph) { - const key = `go/${mod}`; - merged[key] = [...deps].map(d => `go/${d}`); - types[key] = 'go'; - } - for (const [mod, deps] of tsGraph) { - const key = `ts/${mod}`; - merged[key] = [...deps].map(d => `ts/${d}`); - types[key] = 'typescript'; - } - for (const [mod, deps] of rustGraph) { - const key = `rs/${mod}`; - merged[key] = [...deps].map(d => `rs/${d}`); - types[key] = 'rust'; - } - - return { merged, types }; -} - -/** - * Compute fan-in (how many modules depend on this one) and fan-out (how many - * modules this one depends on). - */ -function computeMetrics(graph) { - const fanIn = {}; - const fanOut = {}; - - for (const mod of Object.keys(graph)) { - fanOut[mod] = graph[mod].length; - if (!(mod in fanIn)) fanIn[mod] = 0; - for (const dep of graph[mod]) { - fanIn[dep] = (fanIn[dep] || 0) + 1; - } - } - - return { fanIn, fanOut }; -} - -/** - * Detect all cycles in the directed graph using iterative DFS with an - * explicit stack to avoid call-stack overflow on large graphs. - * Returns an array of cycles, each cycle being an array of module names. - */ -function detectCycles(graph) { - const WHITE = 0; // unvisited - const GRAY = 1; // in current path - const BLACK = 2; // fully explored - - const color = {}; - for (const node of Object.keys(graph)) { - color[node] = WHITE; - } - - const cycles = []; - - for (const start of Object.keys(graph)) { - if (color[start] !== WHITE) continue; - - // Stack entries: [node, neighborIndex, pathSoFar] - const stack = [[start, 0, [start]]]; - color[start] = GRAY; - - while (stack.length > 0) { - const top = stack[stack.length - 1]; - const node = top[0]; - const neighbors = graph[node] || []; - - if (top[1] >= neighbors.length) { - // All neighbors explored — backtrack - color[node] = BLACK; - stack.pop(); - continue; - } - - const neighbor = neighbors[top[1]]; - top[1]++; - - if (color[neighbor] === GRAY) { - // Found a cycle — extract the cycle portion from the current path - const fullPath = [...top[2], neighbor]; - const cycleStart = fullPath.indexOf(neighbor); - if (cycleStart !== -1 && cycleStart < fullPath.length - 1) { - cycles.push(fullPath.slice(cycleStart)); - } - } else if (color[neighbor] === WHITE) { - color[neighbor] = GRAY; - stack.push([neighbor, 0, [...top[2], neighbor]]); - } - } - } - - return cycles; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -/** - * Build the full import graph for the OwnCord project. - * - * @param {string} root — project root directory - * @param {string} cacheDir — directory for caching results - * @param {boolean} quick — if true, return cached results when available - * @returns {Promise} — import graph data - */ -export async function buildImportGraph(root, cacheDir, quick) { - const cacheFile = join(cacheDir, 'import-graph.json'); - - // Quick mode: return cache if it exists - if (quick && existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - return cached; - } catch { - // Cache corrupt — rebuild - } - } - - // Scan each language - const serverDir = resolve(root, 'Server'); - const tsSrcDir = resolve(root, 'Client', 'tauri-client', 'src'); - const rustDir = resolve(root, 'Client', 'tauri-client', 'src-tauri', 'src'); - - const goGraph = scanGoImports(serverDir); - const tsGraph = scanTsImports(tsSrcDir); - const rustGraph = scanRustImports(rustDir); - - // Merge into unified adjacency list - const { merged, types } = mergeGraphs(goGraph, tsGraph, rustGraph); - - // Compute metrics - const { fanIn, fanOut } = computeMetrics(merged); - - // Build nodes array - const nodes = Object.keys(merged).map(mod => { - const fi = fanIn[mod] || 0; - const fo = fanOut[mod] || 0; - return { - name: mod, - type: types[mod] || 'go', - fanIn: fi, - fanOut: fo, - coupling: fi * fo, - }; - }); - - // Also include nodes that appear only as dependencies (no outgoing edges) - for (const mod of Object.keys(fanIn)) { - if (!(mod in merged)) { - const fi = fanIn[mod] || 0; - nodes.push({ - name: mod, - type: types[mod] || inferType(mod), - fanIn: fi, - fanOut: 0, - coupling: 0, - }); - } - } - - // Build edges array - const edges = []; - for (const [from, deps] of Object.entries(merged)) { - for (const to of deps) { - edges.push({ from, to }); - } - } - - // Detect cycles - const cycles = detectCycles(merged); - - // Top 5 most coupled - const mostCoupled = [...nodes] - .sort((a, b) => b.coupling - a.coupling) - .slice(0, 5) - .map(({ name, coupling, fanIn, fanOut }) => ({ name, coupling, fanIn, fanOut })); - - const result = { - timestamp: new Date().toISOString(), - graph: merged, - nodes, - edges, - cycles, - mostCoupled, - }; - - // Write cache - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); - } catch { - // Non-fatal — proceed without caching - } - - return result; -} - -/** - * Infer language type from a prefixed module name. - */ -function inferType(mod) { - if (mod.startsWith('go/')) return 'go'; - if (mod.startsWith('ts/')) return 'typescript'; - if (mod.startsWith('rs/')) return 'rust'; - return 'go'; -} diff --git a/tools/project-map/lib/morning-briefing.mjs b/tools/project-map/lib/morning-briefing.mjs deleted file mode 100644 index 0ab209eb..00000000 --- a/tools/project-map/lib/morning-briefing.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Morning Briefing — digest of overnight agent results + session suggestions. - * Composes data from session-parser, agent-manager, suggestion-engine, and backlog-parser. - */ -import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -/** - * Generate a morning briefing digest. - * Shows what happened since the last session (agent results, coverage changes, etc.) - * and suggests what to work on next. - */ -export async function generateBriefing(root, cacheDir, { - sessionData = null, - suggestions = null, - backlog = null, - agentJobs = null, -} = {}) { - const briefing = { - timestamp: new Date().toISOString(), - greeting: '', - agentResults: [], - deadJobs: [], - coverageChanges: [], - suggestedTasks: [], - autoQueueSuggestions: [], - stats: { completedJobs: 0, failedJobs: 0, deadJobs: 0 }, - }; - - // Determine last session date for "since last session" filtering - const lastSessionDate = sessionData?.lastSession?.date - ? new Date(sessionData.lastSession.date) - : null; - - // Greeting - if (lastSessionDate) { - const daysSince = Math.floor((Date.now() - lastSessionDate.getTime()) / 86400000); - if (daysSince === 0) { - briefing.greeting = 'Welcome back! You had a session earlier today.'; - } else if (daysSince === 1) { - briefing.greeting = 'Good morning! Last session was yesterday.'; - } else { - briefing.greeting = `Welcome back! It's been ${daysSince} days since your last session.`; - } - } else { - briefing.greeting = 'Welcome! This is your first time opening the briefing.'; - } - - // Agent results since last session - if (agentJobs?.jobs) { - for (const job of agentJobs.jobs) { - if (job.status === 'completed') { - const completedAt = job.completedAt ? new Date(job.completedAt) : null; - if (!lastSessionDate || (completedAt && completedAt > lastSessionDate)) { - // Read result summary (first 500 chars) - let summary = ''; - const resultPath = resolve(cacheDir, `agent-results/${job.id}.md`); - if (existsSync(resultPath)) { - try { - const content = readFileSync(resultPath, 'utf8'); - summary = content.slice(0, 500).split('\n').slice(0, 10).join('\n'); - if (content.length > 500) summary += '\n...'; - } catch { /* skip */ } - } - briefing.agentResults.push({ - id: job.id, - type: job.type, - target: job.target, - completedAt: job.completedAt, - summary, - }); - briefing.stats.completedJobs++; - } - } - - if (job.status === 'failed') briefing.stats.failedJobs++; - - if (job.status === 'dead') { - briefing.deadJobs.push({ - id: job.id, - type: job.type, - target: job.target, - error: job.error, - retryCount: job.retryCount, - }); - briefing.stats.deadJobs++; - } - } - } - - // Top suggested tasks from suggestion engine - if (suggestions?.suggestions) { - briefing.suggestedTasks = suggestions.suggestions.slice(0, 5).map(s => ({ - module: s.module, - score: s.score, - rationale: s.rationale, - breakdown: s.breakdown, - })); - } - - // Auto-queue suggestions — only when agent jobs context is available - briefing.autoQueueSuggestions = agentJobs - ? generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) - : []; - - return briefing; -} - -/** - * Generate smart auto-queue suggestions. - * Recommends agent jobs based on current project state. - */ -function generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) { - const existing = new Set( - (agentJobs?.jobs || []) - .filter(j => ['queued', 'running', 'completed'].includes(j.status)) - .map(j => `${j.type}:${j.target}`) - ); - - const suggestions_list = []; - - if (!suggestions?.suggestions) return suggestions_list; - - for (const s of suggestions.suggestions.slice(0, 10)) { - // Coverage gap → suggest write-tests - if (s.breakdown?.coverage > 20) { - const key = `write-tests:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'write-tests', - target: s.module, - rationale: `Coverage gap on ${s.module} (score: ${s.breakdown.coverage})`, - priority: 2, - }); - } - } - - // High debt → suggest fix-debt - if (s.breakdown?.debt > 15) { - const key = `fix-debt:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'fix-debt', - target: s.module, - rationale: `Tech debt markers in ${s.module} (score: ${s.breakdown.debt})`, - priority: 3, - }); - } - } - - // Open bugs → suggest code-review - if (s.breakdown?.bugs > 10) { - const key = `code-review:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'code-review', - target: s.module, - rationale: `Open bugs/review items in ${s.module} (score: ${s.breakdown.bugs})`, - priority: 1, - }); - } - } - } - - // Stale modules → suggest research (limit to top 2) - const staleModules = suggestions.suggestions - .filter(s => s.signals?.some(sig => sig.signal === 'stale')) - .slice(0, 2); - - for (const s of staleModules) { - const key = `research:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'research', - target: s.module, - rationale: `Module ${s.module} has been stale — investigate status`, - priority: 4, - }); - } - } - - // Deduplicate and limit to 5 - const seen = new Set(); - return suggestions_list.filter(s => { - const key = `${s.type}:${s.target}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }).slice(0, 5); -} diff --git a/tools/project-map/lib/priority-engine.mjs b/tools/project-map/lib/priority-engine.mjs deleted file mode 100644 index 96c85b0b..00000000 --- a/tools/project-map/lib/priority-engine.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Priority scoring engine. - * Ranks modules by where work is most needed based on: - * - Coverage gap (distance from 80% target) - * - Module size (larger modules = higher impact) - * - Open bug/task count - * - Roadmap position (earlier phases = higher priority) - */ - -const COVERAGE_TARGET = 80; - -const PHASE_WEIGHTS = { - 'bug': 10, - 'high': 8, - 'code-review': 7, - 'medium': 5, - 'deferred': 3, - 'roadmap-r1': 4, - 'roadmap-r2': 3, - 'roadmap-r3': 2, - 'roadmap-r4': 1.5, - 'roadmap-r5': 1, - 'roadmap-r6': 0.5, - 'other': 2, -}; - -function coverageGapScore(coveragePct) { - if (coveragePct === null || coveragePct === undefined) return 50; // no tests = high gap - const gap = Math.max(0, COVERAGE_TARGET - coveragePct); - return gap * 1.5; // 1.5 points per percent below target -} - -function sizeScore(sourceFiles, sourceLines) { - // Larger modules are higher impact - return Math.min(30, sourceFiles * 2 + (sourceLines / 200)); -} - -function taskScore(openTasks) { - if (!openTasks || openTasks.length === 0) return 0; - let score = 0; - for (const task of openTasks) { - score += PHASE_WEIGHTS[task.phase] || 2; - } - return score; -} - -export function scorePriorities(modules, goCoverage, vitestCoverage, backlog) { - const scores = []; - - // Score Go packages - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const coverage = covData?.percentage ?? null; - const openTasks = backlog.byModule[pkg.name] || []; - - const cScore = coverageGapScore(coverage); - const sScore = sizeScore(pkg.sourceFiles, pkg.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: pkg.name, - type: 'go', - path: pkg.path, - coverage, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: getRecommendation(coverage, openTasks, pkg), - }); - } - - // Score TypeScript areas - const tsAreas = vitestCoverage.areas || {}; - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const coverage = areaCov?.statements ?? (tsAreas._total?.statements ?? null); - const openTasks = backlog.byModule[dir.name] || []; - - const cScore = coverageGapScore(coverage); - const sScore = sizeScore(dir.sourceFiles, dir.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: dir.name, - type: 'typescript', - path: dir.path, - coverage, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: getRecommendation(coverage, openTasks, dir), - }); - } - - // Score Rust - for (const rust of modules.rust) { - const openTasks = backlog.byModule['tauri-rust'] || []; - const cScore = coverageGapScore(null); // no Rust tests - const sScore = sizeScore(rust.sourceFiles, rust.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: rust.name, - type: 'rust', - path: rust.path, - coverage: null, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: 'Add Rust unit tests — currently zero test coverage', - }); - } - - // Score cross-cutting areas from backlog - const crossCutting = ['livekit', 'gaming', 'community', 'platform', 'ai', 'protocol']; - for (const area of crossCutting) { - const openTasks = backlog.byModule[area] || []; - if (openTasks.length === 0) continue; - - const tScore = taskScore(openTasks); - scores.push({ - name: area, - type: 'feature-area', - path: '', - coverage: null, - coverageGap: 0, - sizeImpact: 0, - taskWeight: tScore, - totalScore: tScore, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: `${openTasks.length} open task(s) in backlog`, - }); - } - - // Sort by total score descending - scores.sort((a, b) => b.totalScore - a.totalScore); - - return scores; -} - -function getRecommendation(coverage, openTasks, module) { - const parts = []; - - if (coverage === null) { - parts.push('No test coverage'); - } else if (coverage < 60) { - parts.push(`Coverage critically low (${coverage.toFixed(1)}%)`); - } else if (coverage < COVERAGE_TARGET) { - parts.push(`Coverage below target (${coverage.toFixed(1)}% < ${COVERAGE_TARGET}%)`); - } - - if (openTasks.length > 0) { - const bugs = openTasks.filter(t => t.phase === 'bug'); - const reviews = openTasks.filter(t => t.phase === 'code-review'); - if (bugs.length > 0) parts.push(`${bugs.length} open bug(s)`); - if (reviews.length > 0) parts.push(`${reviews.length} code review fix(es)`); - if (parts.length === 0 || (bugs.length === 0 && reviews.length === 0)) { - parts.push(`${openTasks.length} open task(s)`); - } - } - - if (module.sourceFiles > 15) { - parts.push('Large module — high impact'); - } - - return parts.length > 0 ? parts.join('; ') : 'Good shape'; -} diff --git a/tools/project-map/lib/report-generator.mjs b/tools/project-map/lib/report-generator.mjs deleted file mode 100644 index 4331dd5c..00000000 --- a/tools/project-map/lib/report-generator.mjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Markdown report generator. - * Writes the full project map to docs/brain/00-Overview/Project-Map.md. - */ -import { writeFileSync, mkdirSync } from 'node:fs'; -import { dirname } from 'node:path'; - -function badge(coverage) { - if (coverage === null || coverage === undefined) return '---'; - return `${coverage.toFixed(1)}%`; -} - -function statusIcon(coverage) { - if (coverage === null || coverage === undefined) return 'No tests'; - if (coverage >= 80) return 'Above target'; - if (coverage >= 70) return 'Near target'; - if (coverage >= 50) return 'Below target'; - return 'Critical'; -} - -export async function generateReport(reportPath, modules, goCoverage, vitestCoverage, backlog, priorities) { - mkdirSync(dirname(reportPath), { recursive: true }); - - const now = new Date().toISOString().replace('T', ' ').slice(0, 19); - const lines = []; - - lines.push('# OwnCord Project Map'); - lines.push(''); - lines.push(`> Auto-generated on ${now} by \`tools/project-map\``); - lines.push(`> Run \`node tools/project-map/index.mjs\` to regenerate`); - lines.push(''); - - // --- Summary --- - lines.push('## Summary'); - lines.push(''); - lines.push(`| Metric | Value |`); - lines.push(`|--------|-------|`); - lines.push(`| Go packages | ${modules.summary.goPackages} |`); - lines.push(`| Go source files | ${modules.summary.goSourceFiles} |`); - lines.push(`| Go test files | ${modules.summary.goTestFiles} |`); - lines.push(`| TypeScript source files | ${modules.summary.tsSourceFiles} |`); - lines.push(`| TypeScript test files | ${modules.summary.tsTestFiles} |`); - lines.push(`| Rust source files | ${modules.summary.rustSourceFiles} |`); - lines.push(`| Rust test files | ${modules.summary.rustTestFiles} |`); - lines.push(`| Open backlog tasks | ${backlog.openCount} |`); - lines.push(`| Completed tasks | ${backlog.doneCount} |`); - lines.push(`| Completion rate | ${backlog.doneCount > 0 ? ((backlog.doneCount / (backlog.openCount + backlog.doneCount)) * 100).toFixed(1) : 0}% |`); - lines.push(''); - - // --- Server Coverage --- - lines.push('## Server (Go) — Test Coverage'); - lines.push(''); - lines.push('| Package | Source Files | Test Files | Coverage | Status |'); - lines.push('|---------|-------------|------------|----------|--------|'); - - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const cov = covData?.percentage ?? null; - const status = covData?.noTests ? 'No tests' : statusIcon(cov); - const failed = covData?.failed ? ' (FAILING)' : ''; - lines.push(`| \`${pkg.name}/\` | ${pkg.sourceFiles} | ${pkg.testFiles} | ${badge(cov)} | ${status}${failed} |`); - } - lines.push(''); - - // --- Client Coverage --- - lines.push('## Client (TypeScript) — Test Coverage'); - lines.push(''); - const tsAreas = vitestCoverage.areas || {}; - const totalCov = tsAreas._total; - if (totalCov) { - lines.push(`**Overall:** ${totalCov.statements.toFixed(1)}% statements, ${totalCov.branches.toFixed(1)}% branches, ${totalCov.functions.toFixed(1)}% functions`); - lines.push(''); - } - if (vitestCoverage.testCount) { - lines.push(`**Total tests:** ${vitestCoverage.testCount}`); - lines.push(''); - } - - lines.push('| Area | Source Files | Coverage (stmts) | Status |'); - lines.push('|------|-------------|------------------|--------|'); - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const cov = areaCov?.statements ?? null; - lines.push(`| \`${dir.name}/\` | ${dir.sourceFiles} | ${badge(cov)} | ${statusIcon(cov)} |`); - } - lines.push(''); - - // Test file counts - lines.push('| Test Suite | Files |'); - lines.push('|-----------|-------|'); - for (const dir of modules.typescript.filter(d => d.type !== 'typescript')) { - lines.push(`| \`${dir.name}\` | ${dir.testFiles} |`); - } - lines.push(''); - - // --- Rust --- - lines.push('## Client (Rust/Tauri) — Status'); - lines.push(''); - for (const rust of modules.rust) { - lines.push(`| Metric | Value |`); - lines.push(`|--------|-------|`); - lines.push(`| Source files | ${rust.sourceFiles} |`); - lines.push(`| Lines of code | ${rust.sourceLines} |`); - lines.push(`| Test files | ${rust.testFiles} |`); - lines.push(`| Coverage | No test infrastructure |`); - } - lines.push(''); - - // --- Backlog by Phase --- - lines.push('## Open Work — By Phase'); - lines.push(''); - const phaseOrder = ['bug', 'high', 'code-review', 'medium', 'deferred', 'roadmap-r1', 'roadmap-r2', 'roadmap-r3', 'roadmap-r4', 'roadmap-r5', 'roadmap-r6', 'other']; - const phaseLabels = { - 'bug': 'Bugs', 'high': 'High Priority', 'code-review': 'Code Review', - 'medium': 'Medium Priority', 'deferred': 'Deferred Features', - 'roadmap-r1': 'R1: Community Essentials', 'roadmap-r2': 'R2: Gaming DNA', - 'roadmap-r3': 'R3: Voice Power', 'roadmap-r4': 'R4: LAN Party Toolkit', - 'roadmap-r5': 'R5: Platform & Extensibility', 'roadmap-r6': 'R6: Future Vision', - 'other': 'Other', - }; - - for (const phase of phaseOrder) { - const tasks = backlog.byPhase[phase]; - if (!tasks || tasks.length === 0) continue; - lines.push(`### ${phaseLabels[phase] || phase} (${tasks.length})`); - lines.push(''); - for (const task of tasks) { - lines.push(`- **${task.id}:** ${task.description}`); - } - lines.push(''); - } - - // --- Where to Work Next --- - lines.push('## Where to Work Next'); - lines.push(''); - lines.push('Ranked by priority score (coverage gap + module size + open tasks):'); - lines.push(''); - lines.push('| # | Module | Type | Score | Coverage | Open Tasks | Recommendation |'); - lines.push('|---|--------|------|-------|----------|------------|----------------|'); - - const top = priorities.slice(0, 15); - top.forEach((p, i) => { - const cov = p.coverage !== null ? `${p.coverage.toFixed(1)}%` : '---'; - lines.push(`| ${i + 1} | \`${p.name}\` | ${p.type} | ${p.totalScore.toFixed(0)} | ${cov} | ${p.openTaskCount} | ${p.recommendation} |`); - }); - lines.push(''); - - // --- Research --- - lines.push('## Research'); - lines.push(''); - lines.push('Use `node tools/project-map/index.mjs --research` to launch a Claude Code agent'); - lines.push('that investigates a specific area and saves findings to `docs/brain/00-Overview/Research/`.'); - lines.push(''); - - // --- Staleness --- - lines.push('---'); - lines.push(`*Last generated: ${now}*`); - lines.push(''); - - writeFileSync(reportPath, lines.join('\n'), 'utf8'); - console.log(`\n Report written to: ${reportPath}`); -} diff --git a/tools/project-map/lib/research-agent.mjs b/tools/project-map/lib/research-agent.mjs deleted file mode 100644 index 9c254237..00000000 --- a/tools/project-map/lib/research-agent.mjs +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Research agent launcher. - * Spawns a Claude Code subprocess to investigate a specific area - * and saves findings to docs/brain/00-Overview/Research/. - */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { createInterface } from 'node:readline'; - -const RESEARCH_DIR = 'docs/brain/00-Overview/Research'; - -const RESEARCH_AREAS = { - 'server-api': { - label: 'Server API package', - description: 'Analyze Server/api/ for coverage gaps, missing error handling, untested endpoints', - scope: 'Server/api/', - }, - 'server-ws': { - label: 'Server WebSocket package', - description: 'Analyze Server/ws/ for coverage gaps, race conditions, edge cases in voice/chat handlers', - scope: 'Server/ws/', - }, - 'server-auth': { - label: 'Server auth package', - description: 'Analyze Server/auth/ for security gaps, missing test cases, TOTP edge cases', - scope: 'Server/auth/', - }, - 'server-db': { - label: 'Server database package', - description: 'Analyze Server/db/ for missing indexes, query performance, untested queries', - scope: 'Server/db/', - }, - 'server-admin': { - label: 'Server admin package', - description: 'Analyze Server/admin/ for test coverage issues, build-tag gating, missing functionality', - scope: 'Server/admin/', - }, - 'client-livekit': { - label: 'Client LiveKit session', - description: 'Analyze livekitSession.ts and related audio/video code for coverage gaps and edge cases', - scope: 'Client/tauri-client/src/lib/livekitSession.ts', - }, - 'client-stores': { - label: 'Client stores', - description: 'Analyze reactive stores for state management edge cases, race conditions, memory leaks', - scope: 'Client/tauri-client/src/stores/', - }, - 'rust-backend': { - label: 'Tauri Rust backend', - description: 'Analyze Rust backend for missing tests, security gaps in proxy/credential code', - scope: 'Client/tauri-client/src-tauri/src/', - }, - 'e2e-coverage': { - label: 'E2E test coverage', - description: 'Analyze E2E test suite for missing critical user flows, flaky tests, gaps', - scope: 'Client/tauri-client/tests/e2e/', - }, - 'security': { - label: 'Security audit', - description: 'Review codebase for OWASP Top 10 vulnerabilities, auth bypass, injection, XSS', - scope: 'Server/ Client/tauri-client/src/', - }, - 'protocol': { - label: 'Protocol compliance', - description: 'Check server and client code against docs/brain/06-Specs/PROTOCOL.md for drift', - scope: 'Server/ws/ Client/tauri-client/src/lib/dispatcher.ts', - }, -}; - -function buildPrompt(areaKey, area, root) { - const date = new Date().toISOString().slice(0, 10); - const outputFile = `${RESEARCH_DIR}/${areaKey}-${date}.md`; - - return `You are a research agent investigating the OwnCord project. - -## Task -${area.description} - -## Scope -Focus on: ${area.scope} - -## Instructions -1. Read the relevant source files and test files -2. Identify: - - Coverage gaps (functions/branches not tested) - - Potential bugs or edge cases - - Security concerns - - Code quality issues - - Missing functionality vs specs -3. For each finding, note the file path and line number -4. Prioritize findings as CRITICAL, HIGH, MEDIUM, or LOW - -## Output -Write your findings to: ${outputFile} - -Use this format: ---- -# Research: ${area.label} -Date: ${date} -Scope: ${area.scope} - -## Summary -[2-3 sentence overview] - -## Findings - -### CRITICAL -- [finding with file:line reference] - -### HIGH -- [finding with file:line reference] - -### MEDIUM -- [finding with file:line reference] - -### LOW -- [finding with file:line reference] - -## Recommendations -[Prioritized list of what to fix/improve next] ---- - -After writing the file, print a brief summary of what you found.`; -} - -async function promptUser(question) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise(resolve => { - rl.question(question, answer => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -export async function launchResearchAgent(root) { - console.log('\n Research Agent Launcher\n'); - console.log(' Available research areas:\n'); - - const keys = Object.keys(RESEARCH_AREAS); - keys.forEach((key, i) => { - const area = RESEARCH_AREAS[key]; - console.log(` ${i + 1}. ${area.label} — ${area.description.slice(0, 70)}...`); - }); - - console.log(`\n ${keys.length + 1}. Custom (enter your own research prompt)`); - - const choice = await promptUser('\n Enter number (or "q" to quit): '); - - if (choice === 'q' || choice === '') { - console.log(' Cancelled.'); - return; - } - - const num = parseInt(choice, 10); - if (isNaN(num) || num < 1 || num > keys.length + 1) { - console.log(' Invalid choice.'); - return; - } - - // Ensure research directory exists - const researchDir = resolve(root, RESEARCH_DIR); - mkdirSync(researchDir, { recursive: true }); - - let prompt; - let areaKey; - - if (num <= keys.length) { - areaKey = keys[num - 1]; - const area = RESEARCH_AREAS[areaKey]; - prompt = buildPrompt(areaKey, area, root); - console.log(`\n Launching research on: ${area.label}`); - } else { - const customPrompt = await promptUser(' Enter your research prompt: '); - if (!customPrompt) { - console.log(' Cancelled.'); - return; - } - areaKey = 'custom'; - prompt = customPrompt; - console.log(`\n Launching custom research...`); - } - - // Save the prompt for reference - const date = new Date().toISOString().slice(0, 10); - const promptFile = resolve(researchDir, `${areaKey}-${date}-prompt.txt`); - // Boundary check: ensure promptFile stays within researchDir - if (!promptFile.startsWith(resolve(researchDir))) { - console.error(' Error: prompt file path escapes research directory'); - return; - } - writeFileSync(promptFile, prompt, 'utf8'); - - console.log(` Prompt saved to: ${promptFile}`); - console.log(`\n To run the research agent, execute:\n`); - console.log(` claude --print "${promptFile.replace(/\\/g, '/')}"`); - console.log(`\n Or copy the prompt and paste it into a Claude Code session.`); - console.log(` The agent will save findings to: ${RESEARCH_DIR}/${areaKey}-${date}.md\n`); - - // Try to launch claude directly if available - try { - execFileSync('claude', ['--version'], { stdio: 'pipe' }); - const launch = await promptUser(' Claude CLI detected. Launch now? (y/n): '); - if (launch.toLowerCase() === 'y') { - console.log('\n Spawning Claude Code agent...\n'); - // Use prompt file instead of inline prompt to avoid shell injection - const child = spawn('claude', ['--print', promptFile], { - cwd: root, - stdio: 'inherit', - shell: false, - }); - child.on('close', (code) => { - console.log(`\n Research agent exited with code ${code}`); - console.log(` Check ${RESEARCH_DIR}/${areaKey}-${date}.md for findings.\n`); - }); - // Wait for completion - await new Promise(resolve => child.on('close', resolve)); - } - } catch { - // Claude CLI not available — just show instructions - } -} diff --git a/tools/project-map/lib/scanner.mjs b/tools/project-map/lib/scanner.mjs deleted file mode 100644 index a5618253..00000000 --- a/tools/project-map/lib/scanner.mjs +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Module scanner — discovers Go packages, TypeScript directories, and Rust files. - * Returns a structured inventory of the project. - */ -import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -function scanDir(dir, extensions, recursive = true) { - let fileCount = 0; - let lineCount = 0; - - function walk(d) { - if (!existsSync(d)) return; - let entries; - try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === 'dist' || entry.name === 'target') continue; - const full = join(d, entry.name); - if (entry.isDirectory() && recursive) { - walk(full); - } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) { - fileCount++; - try { - lineCount += readFileSync(full, 'utf8').split('\n').length; - } catch { /* skip */ } - } - } - } - walk(dir); - return { fileCount, lineCount }; -} - -function scanGoDir(dir, name, pathPrefix) { - const source = scanDir(dir, ['.go'], false); - const testSource = { fileCount: 0, lineCount: 0 }; - - try { - for (const f of readdirSync(dir)) { - if (f.endsWith('_test.go')) { - testSource.fileCount++; - try { - testSource.lineCount += readFileSync(join(dir, f), 'utf8').split('\n').length; - } catch { /* skip */ } - } - } - } catch { /* skip */ } - - const srcFiles = source.fileCount - testSource.fileCount; - const srcLines = source.lineCount - testSource.lineCount; - - if (srcFiles > 0 || testSource.fileCount > 0) { - return { - name, - type: 'go', - path: pathPrefix, - sourceFiles: srcFiles, - sourceLines: srcLines, - testFiles: testSource.fileCount, - testLines: testSource.lineCount, - }; - } - return null; -} - -function scanGoPackages(serverDir) { - const packages = []; - if (!existsSync(serverDir)) return packages; - - // Scan root-level Go files (main.go, etc.) - const rootPkg = scanGoDir(serverDir, 'server-root', 'Server'); - if (rootPkg) packages.push(rootPkg); - - // Scan subdirectory packages - for (const entry of readdirSync(serverDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'vendor') continue; - const pkg = scanGoDir(join(serverDir, entry.name), entry.name, `Server/${entry.name}`); - if (pkg) packages.push(pkg); - } - return packages; -} - -function scanTSDirectories(clientSrcDir) { - const dirs = []; - if (!existsSync(clientSrcDir)) return dirs; - - const scanAreas = ['lib', 'stores', 'components', 'pages', 'styles']; - for (const area of scanAreas) { - const areaDir = join(clientSrcDir, area); - if (!existsSync(areaDir)) continue; - const source = scanDir(areaDir, ['.ts', '.tsx', '.js', '.jsx'], true); - dirs.push({ - name: area, - type: 'typescript', - path: `Client/tauri-client/src/${area}`, - sourceFiles: source.fileCount, - sourceLines: source.lineCount, - testFiles: 0, // tests are in a separate dir - testLines: 0, - }); - } - - // Count test files - const testDir = resolve(clientSrcDir, '../tests'); - if (existsSync(testDir)) { - for (const subdir of ['unit', 'integration']) { - const td = join(testDir, subdir); - if (!existsSync(td)) continue; - const tests = scanDir(td, ['.ts', '.tsx', '.test.ts', '.test.tsx', '.spec.ts'], true); - dirs.push({ - name: `tests/${subdir}`, - type: 'typescript-tests', - path: `Client/tauri-client/tests/${subdir}`, - sourceFiles: 0, - sourceLines: 0, - testFiles: tests.fileCount, - testLines: tests.lineCount, - }); - } - - // E2E tests - const e2eDir = join(testDir, 'e2e'); - if (existsSync(e2eDir)) { - const e2e = scanDir(e2eDir, ['.ts', '.spec.ts'], true); - dirs.push({ - name: 'tests/e2e', - type: 'e2e-tests', - path: `Client/tauri-client/tests/e2e`, - sourceFiles: 0, - sourceLines: 0, - testFiles: e2e.fileCount, - testLines: e2e.lineCount, - }); - } - } - - return dirs; -} - -function scanRustFiles(rustDir) { - if (!existsSync(rustDir)) return []; - const source = scanDir(rustDir, ['.rs'], true); - const testCount = (() => { - let count = 0; - function walk(d) { - try { - for (const entry of readdirSync(d, { withFileTypes: true })) { - const full = join(d, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('_test.rs') || entry.name === 'tests.rs') count++; - else if (entry.name.endsWith('.rs')) { - // Check for #[cfg(test)] inside the file - try { - const content = readFileSync(full, 'utf8'); - if (content.includes('#[cfg(test)]')) count++; - } catch { /* skip */ } - } - } - } catch { /* skip */ } - } - walk(rustDir); - return count; - })(); - - return [{ - name: 'tauri-rust', - type: 'rust', - path: 'Client/tauri-client/src-tauri/src', - sourceFiles: source.fileCount, - sourceLines: source.lineCount, - testFiles: testCount, - testLines: 0, - }]; -} - -export async function scanModules(root) { - const serverDir = resolve(root, 'Server'); - const clientSrcDir = resolve(root, 'Client/tauri-client/src'); - const rustDir = resolve(root, 'Client/tauri-client/src-tauri/src'); - - const goPackages = scanGoPackages(serverDir); - const tsDirectories = scanTSDirectories(clientSrcDir); - const rustFiles = scanRustFiles(rustDir); - - return { - go: goPackages, - typescript: tsDirectories, - rust: rustFiles, - summary: { - goPackages: goPackages.length, - goSourceFiles: goPackages.reduce((s, p) => s + p.sourceFiles, 0), - goTestFiles: goPackages.reduce((s, p) => s + p.testFiles, 0), - tsSourceFiles: tsDirectories.filter(d => d.type === 'typescript').reduce((s, d) => s + d.sourceFiles, 0), - tsTestFiles: tsDirectories.filter(d => d.type !== 'typescript').reduce((s, d) => s + d.testFiles, 0), - rustSourceFiles: rustFiles.reduce((s, r) => s + r.sourceFiles, 0), - rustTestFiles: rustFiles.reduce((s, r) => s + r.testFiles, 0), - }, - }; -} diff --git a/tools/project-map/lib/session-manager.mjs b/tools/project-map/lib/session-manager.mjs deleted file mode 100644 index d3b40b92..00000000 --- a/tools/project-map/lib/session-manager.mjs +++ /dev/null @@ -1,607 +0,0 @@ -/** - * Session manager — consolidates session planning, tracking, and vault writing. - * - * Manages the full session lifecycle: plan → start → poll → end, with - * automatic vault integration (session logs, task file updates). - * - * Zero external dependencies — Node built-ins only. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -import { generateSuggestions } from './suggestion-engine.mjs'; -import { parseSessionHistory } from './session-parser.mjs'; -import { parseBacklog } from './backlog-parser.mjs'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const SESSION_STATE_FILE = 'session-state.json'; -const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes -const TASK_ID_REGEX = /\b(?:fix|close|resolve|implement|complete)\s+T-(\d+)/gi; -const SHA_RE = /^[0-9a-f]{40}$/i; - -function validateSha(sha) { - if (!SHA_RE.test(sha)) throw new Error(`Invalid git SHA: "${sha}"`); - return sha; -} - -// --------------------------------------------------------------------------- -// Git helpers -// --------------------------------------------------------------------------- - -function git(args, root) { - return execFileSync('git', args, { - cwd: root, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); -} - -function getHeadSha(root) { - return git(['rev-parse', 'HEAD'], root); -} - -// --------------------------------------------------------------------------- -// File path to module mapping (mirrors git-scanner.mjs logic) -// --------------------------------------------------------------------------- - -function fileToModule(filePath) { - const normalized = filePath.replace(/\\/g, '/'); - - if (normalized.startsWith('Server/')) { - const parts = normalized.split('/'); - // parts[1] is a subdirectory only when there are 3+ segments - // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { - return 'tauri-rust'; - } - - if (normalized.startsWith('Client/tauri-client/src/')) { - const area = normalized.replace('Client/tauri-client/src/', '').split('/')[0]; - return area || 'client-root'; - } - - if (normalized.startsWith('Client/tauri-client/')) { - return 'client-config'; - } - - return 'root'; -} - -// --------------------------------------------------------------------------- -// Session state I/O -// --------------------------------------------------------------------------- - -function stateFilePath(cacheDir) { - return resolve(cacheDir, SESSION_STATE_FILE); -} - -function readSessionState(cacheDir) { - const fp = stateFilePath(cacheDir); - if (!existsSync(fp)) return null; - - try { - return JSON.parse(readFileSync(fp, 'utf8')); - } catch { - console.warn(' [Session] Corrupt session-state.json — renaming and treating as inactive'); - try { - const corruptPath = `${fp}.corrupt.${Date.now()}`; - renameSync(fp, corruptPath); - } catch { /* best effort */ } - return null; - } -} - -function writeSessionState(cacheDir, state) { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(stateFilePath(cacheDir), JSON.stringify(state, null, 2)); -} - -// --------------------------------------------------------------------------- -// Safe vault write — temp file → validate → atomic rename → rollback copy -// --------------------------------------------------------------------------- - -function safeWriteFile(targetPath, content) { - const dir = resolve(targetPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const tmpPath = targetPath + '.tmp'; - const backupPath = targetPath + '.bak'; - - // Write to temp file - writeFileSync(tmpPath, content, 'utf8'); - - // Validate: temp file content must match what was written - const written = readFileSync(tmpPath, 'utf8'); - if (written !== content) { - unlinkSync(tmpPath); - throw new Error(`Safe write validation failed: content mismatch at ${tmpPath}`); - } - - // Keep one rollback copy of existing file - if (existsSync(targetPath)) { - try { - if (existsSync(backupPath)) unlinkSync(backupPath); - renameSync(targetPath, backupPath); - } catch { - // Non-fatal — proceed without backup - } - } - - // Atomic rename - renameSync(tmpPath, targetPath); -} - -// --------------------------------------------------------------------------- -// Vault helpers -// --------------------------------------------------------------------------- - -function readVaultFile(filePath) { - try { - if (!existsSync(filePath)) return null; - return readFileSync(filePath, 'utf8'); - } catch (err) { - console.warn(` [Session] Failed to read vault file ${filePath}: ${err.message}`); - return null; - } -} - -function formatDuration(startIso) { - const ms = Date.now() - new Date(startIso).getTime(); - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; -} - -function generateSessionLogPath(root, summary) { - const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); - const today = new Date().toISOString().slice(0, 10); - const slug = summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 40); - - let baseName = `${today}-${slug || 'session'}`; - let filePath = join(sessionsDir, `${baseName}.md`); - let counter = 1; - - while (existsSync(filePath)) { - filePath = join(sessionsDir, `${baseName}-${counter}.md`); - counter++; - } - - return filePath; -} - -function buildSessionLogContent(state, autoMarker = '') { - const today = new Date().toISOString().slice(0, 10); - const tasksCompleted = state.commits - .flatMap(c => extractTaskIds(c)) - .filter((id, i, arr) => arr.indexOf(id) === i); - - const modulesStr = state.modulesTouched.length > 0 - ? state.modulesTouched.join(', ') - : 'none'; - - const commitsList = state.commits.length > 0 - ? state.commits.map(c => `- ${c}`).join('\n') - : '- (no commits)'; - - const filesList = state.filesChanged.length > 0 - ? state.filesChanged.slice(0, 20).map(f => `- ${f}`).join('\n') - : '- (no files changed)'; - - const tasksTable = tasksCompleted.length > 0 - ? tasksCompleted.map(id => `| ${id} | completed | Done |`).join('\n') - : '| — | — | — |'; - - const marker = autoMarker ? ` ${autoMarker}` : ''; - - return `--- -date: ${today} -summary: "Session${marker} — ${state.modulesTouched.slice(0, 3).join(', ') || 'general'}" -tasks-completed: ${tasksCompleted.length} ---- - -# Session — ${today}${marker} - -## Goal - -*Auto-generated session log from session manager* - -## What Was Done - -### Commits -${commitsList} - -### Files Changed (${state.filesChanged.length} total) -${filesList}${state.filesChanged.length > 20 ? `\n- ...and ${state.filesChanged.length - 20} more` : ''} - -### Modules Touched -${modulesStr} - -## Decisions Made - -- *See commit messages for details* - -## Blockers / Issues - -- - -## Next Steps - -- - -## Tasks Touched - -| Task | Action | Status | -| ---- | ------ | ------ | -${tasksTable} -`; -} - -// --------------------------------------------------------------------------- -// Task ID extraction from commit messages -// --------------------------------------------------------------------------- - -function extractTaskIds(commitMessage) { - const ids = []; - let match; - const regex = new RegExp(TASK_ID_REGEX.source, TASK_ID_REGEX.flags); - while ((match = regex.exec(commitMessage)) !== null) { - ids.push(`T-${match[1]}`); - } - return ids; -} - -// --------------------------------------------------------------------------- -// Vault task file updates -// --------------------------------------------------------------------------- - -function updateVaultTasks(root, completedTaskIds, preloadedInProgressContent) { - const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); - const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); - const today = new Date().toISOString().slice(0, 10); - const updated = []; - - const inProgressContent = preloadedInProgressContent ?? readVaultFile(inProgressPath); - const doneContent = readVaultFile(donePath); - - if (!inProgressContent || !doneContent) { - console.warn(' [Session] Cannot update vault tasks — file read failed'); - return updated; - } - - const idSet = new Set(completedTaskIds); - const movedTasks = []; - const remainingLines = []; - - // Parse In Progress.md, extract completed tasks - for (const line of inProgressContent.split('\n')) { - const taskMatch = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); - if (taskMatch && idSet.has(`T-${taskMatch[1]}`)) { - movedTasks.push({ - id: `T-${taskMatch[1]}`, - description: taskMatch[2].trim(), - }); - } else { - remainingLines.push(line); - } - } - - if (movedTasks.length === 0) return updated; - - // Write updated In Progress.md - safeWriteFile(inProgressPath, remainingLines.join('\n')); - - // Append to Done.md - const doneEntries = movedTasks - .map(t => `- [x] **${t.id}:** ${t.description} — completed ${today}`) - .join('\n'); - - const sectionHeader = `\n## Session (${today})\n\n`; - const insertionPoint = doneContent.indexOf('\n## '); - let newDoneContent; - - if (insertionPoint !== -1) { - // Insert after the main heading, before the first section - newDoneContent = doneContent.slice(0, insertionPoint) + - sectionHeader + doneEntries + '\n' + - doneContent.slice(insertionPoint); - } else { - newDoneContent = doneContent + sectionHeader + doneEntries + '\n'; - } - - safeWriteFile(donePath, newDoneContent); - - for (const t of movedTasks) { - updated.push(t.id); - } - - return updated; -} - -// Exported for testing -export { fileToModule }; - -// --------------------------------------------------------------------------- -// Exported functions -// --------------------------------------------------------------------------- - -/** - * Generate a session plan combining suggestions, last session, and backlog. - */ -export async function generatePlan(root, cacheDir) { - // Gather data from existing engines - const [sessionData, backlog] = await Promise.all([ - parseSessionHistory(root, cacheDir, true), - parseBacklog(root), - ]); - - const suggestions = await generateSuggestions(cacheDir, { - backlog, - sessionData, - }); - - // Build greeting - const last = sessionData.lastSession; - const greeting = last.date - ? `Welcome back! Last session: ${last.date} — ${last.summary || 'no summary'}` - : 'Welcome! This appears to be the first session.'; - - // Map suggestions to plan tasks - const tasks = suggestions.suggestions.map((s, i) => { - const topSignals = s.signals || []; - const relatedFiles = topSignals - .filter(sig => sig.signal === 'open-bug' || sig.signal === 'open-task') - .map(sig => sig.value) - .slice(0, 5); - - const score = s.score || 0; - let estimatedFocus = 'small'; - if (score > 100) estimatedFocus = 'large'; - else if (score > 40) estimatedFocus = 'medium'; - - return { - priority: Math.min(i + 1, 5), - title: `Focus on ${s.module}`, - rationale: s.rationale, - estimatedFocus, - relatedFiles, - module: s.module, - }; - }); - - // In-progress tasks - const inProgress = sessionData.inProgress.map(t => ({ - id: t.id, - description: t.description, - })); - - return { - greeting, - tasks, - lastSession: { - date: last.date, - summary: last.summary, - tasksCompleted: last.tasksCompleted, - }, - inProgress, - }; -} - -/** - * Start a new session. Records HEAD SHA and creates session state. - * Rejects if a session is already active. - */ -export function startSession(root, cacheDir) { - const existing = readSessionState(cacheDir); - if (existing && existing.active) { - throw new Error( - 'Session already active (started at ' + existing.startedAt + '). ' + - 'End or recover it before starting a new one.' - ); - } - - const baselineSha = getHeadSha(root); - const state = { - active: true, - startedAt: new Date().toISOString(), - baselineSha, - filesChanged: [], - commits: [], - modulesTouched: [], - }; - - writeSessionState(cacheDir, state); - return { ...state }; -} - -/** - * Get the current session status with live diff stats. - */ -export function getSessionStatus(cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { active: false }; - } - - return { ...state }; -} - -/** - * Poll for changes since session baseline. Updates session state with - * current file changes, commits, and modules touched. - */ -export function pollChanges(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { active: false }; - } - - let filesChanged = []; - let newCommits = []; - - try { - const diffOutput = git(['diff', '--name-only', validateSha(state.baselineSha)], root); - filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : []; - } catch (err) { - throw new Error(`Failed to get git diff: ${err.message}`); - } - - try { - const logOutput = git(['log', '--oneline', `${validateSha(state.baselineSha)}..HEAD`], root); - newCommits = logOutput ? logOutput.split('\n').filter(Boolean) : []; - } catch (err) { - throw new Error(`Failed to get git log: ${err.message}`); - } - - // Derive modules from changed files - const moduleSet = new Set(); - for (const file of filesChanged) { - moduleSet.add(fileToModule(file)); - } - - const updatedState = { - ...state, - filesChanged, - commits: newCommits, - modulesTouched: [...moduleSet], - lastHeartbeat: new Date().toISOString(), - }; - - writeSessionState(cacheDir, updatedState); - return { ...updatedState }; -} - -/** - * End the current session. Generates a vault session log, updates task - * files, and clears session state. - */ -export function endSession(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - throw new Error('No active session to end.'); - } - - // Final poll to capture latest changes - const finalState = pollChanges(root, cacheDir); - - // Generate session log - const logContent = buildSessionLogContent(finalState); - const logPath = generateSessionLogPath( - root, - finalState.modulesTouched.slice(0, 3).join('-') || 'session' - ); - - safeWriteFile(logPath, logContent); - - // Extract task IDs from commit messages and update vault - const allTaskIds = finalState.commits - .flatMap(c => extractTaskIds(c)) - .filter((id, i, arr) => arr.indexOf(id) === i); - - let tasksUpdated = []; - if (allTaskIds.length > 0) { - // Verify task IDs exist in vault before updating - const inProgressContent = readVaultFile( - resolve(root, 'docs/brain/02-Tasks/In Progress.md') - ); - if (inProgressContent) { - const knownIds = allTaskIds.filter(id => inProgressContent.includes(id)); - const unknownIds = allTaskIds.filter(id => !inProgressContent.includes(id)); - - for (const uid of unknownIds) { - console.warn(` [Session] Task ${uid} not found in In Progress — skipping`); - } - - if (knownIds.length > 0) { - tasksUpdated = updateVaultTasks(root, knownIds, inProgressContent); - } - } - } - - // Clear session state - const closedState = { - active: false, - closedAt: new Date().toISOString(), - startedAt: finalState.startedAt, - baselineSha: finalState.baselineSha, - }; - writeSessionState(cacheDir, closedState); - - return { - sessionLog: logPath, - tasksUpdated, - filesChanged: finalState.filesChanged.length, - duration: formatDuration(finalState.startedAt), - }; -} - -/** - * Recover a stale session on dashboard startup. - * If session-state.json shows active=true but last git activity was - * >30 minutes ago, auto-close it with a partial log. - */ -export function recoverStaleSession(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { recovered: false }; - } - - // Use session-specific timestamps: lastHeartbeat > startedAt - // Do NOT use repo-wide git log — that reflects any commit, not this session's activity - const lastActivityTime = new Date(state.lastHeartbeat || state.startedAt).getTime(); - - const elapsed = Date.now() - lastActivityTime; - if (elapsed < STALE_THRESHOLD_MS) { - return { recovered: false }; - } - - // Auto-close the stale session - let finalState; - try { - finalState = pollChanges(root, cacheDir); - } catch { - // If poll fails, use whatever state we have - finalState = { ...state }; - } - - const logContent = buildSessionLogContent(finalState, '[auto-closed]'); - const logPath = generateSessionLogPath(root, 'auto-closed'); - - try { - safeWriteFile(logPath, logContent); - } catch (err) { - console.warn(` [Session] Failed to write auto-close log: ${err.message}`); - } - - // Clear session state - const closedState = { - active: false, - closedAt: new Date().toISOString(), - startedAt: state.startedAt, - baselineSha: state.baselineSha, - autoRecovered: true, - }; - writeSessionState(cacheDir, closedState); - - return { - recovered: true, - log: logPath, - }; -} diff --git a/tools/project-map/lib/session-parser.mjs b/tools/project-map/lib/session-parser.mjs deleted file mode 100644 index 417db167..00000000 --- a/tools/project-map/lib/session-parser.mjs +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Session parser — reads session log files from docs/brain/03-Sessions/, - * builds a timeline, tracks progress over time, calculates streaks, - * and extracts last-session and task-status info. - */ -import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Module keyword map — maps session content to touched modules -// --------------------------------------------------------------------------- - -const MODULE_KEYWORDS = { - 'admin': ['admin panel', 'admin api', 'admin'], - 'api': ['REST', 'endpoint', 'handler', 'middleware', 'api'], - 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], - 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], - 'ws': ['websocket', 'hub', 'broadcast', 'ringbuffer', 'reconnect'], - 'voice': ['voice', 'audio', 'LiveKit', 'livekit', 'WebRTC', 'SFU', 'RTP', 'VAD'], - 'permissions': ['permission', 'role', 'RBAC'], - 'storage': ['upload', 'file storage', 'attachment'], - 'config': ['config', 'settings'], - 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], - 'stores': ['store', 'state management'], - 'components': ['component', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], - 'pages': ['ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], - 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater'], - 'protocol': ['protocol', 'message type', 'payload'], - 'e2e': ['E2E', 'Playwright', 'end-to-end'], - 'tests': ['test', 'coverage', 'vitest', 'unit test', 'integration test'], - 'docs': ['documentation', 'README', 'CLAUDE.md', 'spec', 'docs'], - 'security': ['security', 'XSS', 'CSRF', 'injection', 'audit', 'vulnerability'], - 'ci': ['CI', 'GitHub Actions', 'lint', 'golangci'], -}; - -// --------------------------------------------------------------------------- -// Frontmatter parser -// --------------------------------------------------------------------------- - -/** - * Parse YAML-like frontmatter delimited by --- lines. - * Returns { meta: { key: value }, body: string }. - */ -function parseFrontmatter(content) { - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - if (lines[0].trim() !== '---') { - return { meta: {}, body: content }; - } - - const meta = {}; - let endIdx = -1; - - for (let i = 1; i < lines.length; i++) { - if (lines[i].trim() === '---') { - endIdx = i; - break; - } - const colonIdx = lines[i].indexOf(':'); - if (colonIdx > 0) { - const key = lines[i].slice(0, colonIdx).trim(); - let val = lines[i].slice(colonIdx + 1).trim(); - // Strip surrounding quotes - if ((val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'"))) { - val = val.slice(1, -1); - } - meta[key] = val; - } - } - - if (endIdx === -1) { - return { meta: {}, body: content }; - } - - const body = lines.slice(endIdx + 1).join('\n'); - return { meta, body }; -} - -// --------------------------------------------------------------------------- -// Module detection from session body text -// --------------------------------------------------------------------------- - -function detectModules(body) { - const lower = body.toLowerCase(); - const found = []; - - for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { - for (const kw of keywords) { - if (lower.includes(kw.toLowerCase())) { - found.push(mod); - break; - } - } - } - - return found.length > 0 ? found : ['general']; -} - -// --------------------------------------------------------------------------- -// Task file parsers (Done.md, In Progress.md) -// --------------------------------------------------------------------------- - -function parseDoneFile(filePath) { - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, 'utf8'); - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - const tasks = []; - let currentSection = ''; - - for (const line of lines) { - if (line.startsWith('## ')) { - currentSection = line.replace(/^##\s*/, ''); - } - - // Match both formats: - // - [x] **T-XXX:** description — completed YYYY-MM-DD - // - [x] **T-XXX**: description — YYYY-MM-DD - const match = line.match( - /^- \[x\] \*\*T-(\d+)[:\*]*\*?\*?\s*(.+?)(?:\s*—\s*(?:completed\s+)?(\d{4}-\d{2}-\d{2}))?$/ - ); - if (match) { - const id = `T-${match[1]}`; - const description = match[2] - .replace(/\s*—\s*(?:completed\s+)?\d{4}-\d{2}-\d{2}$/, '') - .trim(); - // Date from the line itself, or try to extract from section header - const date = match[3] || extractDateFromSection(currentSection) || ''; - tasks.push({ id, description, date }); - } - } - - return tasks; -} - -function extractDateFromSection(section) { - const match = section.match(/\((\d{4}-\d{2}-\d{2})\)/); - return match ? match[1] : null; -} - -function parseInProgressFile(filePath) { - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, 'utf8'); - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - const tasks = []; - - for (const line of lines) { - // Match: - [ ] **T-XXX:** description - const match = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); - if (match) { - const id = `T-${match[1]}`; - const description = match[2].trim(); - tasks.push({ id, description }); - } - - // Also match lines without checkbox but with task ID - const altMatch = line.match(/^\*\*T-(\d+):\*\*\s*(.+)/); - if (!match && altMatch) { - const id = `T-${altMatch[1]}`; - const description = altMatch[2].trim(); - tasks.push({ id, description }); - } - } - - return tasks; -} - -// --------------------------------------------------------------------------- -// Streak calculation -// --------------------------------------------------------------------------- - -function calculateStreaks(sortedDates) { - if (sortedDates.length === 0) { - return { current: 0, longest: 0, totalSessions: 0 }; - } - - // Deduplicate dates (multiple sessions on same day count as 1) - const uniqueDates = [...new Set(sortedDates)].sort(); - const totalSessions = sortedDates.length; - - let longest = 1; - let currentStreak = 1; - let streakAtEnd = 1; - - for (let i = 1; i < uniqueDates.length; i++) { - const prev = new Date(uniqueDates[i - 1] + 'T00:00:00Z'); - const curr = new Date(uniqueDates[i] + 'T00:00:00Z'); - const diffMs = curr.getTime() - prev.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 1) { - currentStreak++; - } else { - currentStreak = 1; - } - - if (currentStreak > longest) { - longest = currentStreak; - } - - streakAtEnd = currentStreak; - } - - // Check if the most recent session date is today or yesterday - // to determine if the current streak is still "active" - const lastDate = new Date(uniqueDates[uniqueDates.length - 1] + 'T00:00:00Z'); - const today = new Date(); - today.setUTCHours(0, 0, 0, 0); - const daysSinceLast = Math.round((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); - - const current = daysSinceLast <= 1 ? streakAtEnd : 0; - - return { current, longest, totalSessions }; -} - -// --------------------------------------------------------------------------- -// Main export -// --------------------------------------------------------------------------- - -/** - * Parse all session logs and task files, returning a structured timeline. - * - * @param {string} root - Repository root directory - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} - Parsed session history - */ -export async function parseSessionHistory(root, cacheDir, quick) { - const cachePath = join(cacheDir, 'session-data.json'); - - // Quick mode: return cached data if available - if (quick && existsSync(cachePath)) { - try { - const cached = JSON.parse(readFileSync(cachePath, 'utf8')); - console.log(' [Sessions] Returning cached session data'); - return cached; - } catch { - // Cache corrupt — fall through to fresh parse - } - } - - const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); - const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); - const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); - - // ----------------------------------------------------------------------- - // 1. Parse all session files - // ----------------------------------------------------------------------- - - const sessions = []; - - if (existsSync(sessionsDir)) { - const files = readdirSync(sessionsDir) - .filter(f => f.endsWith('.md') && f !== 'index.md') - .sort(); - - for (const fileName of files) { - const filePath = join(sessionsDir, fileName); - const content = readFileSync(filePath, 'utf8'); - const { meta, body } = parseFrontmatter(content); - - const date = meta.date || fileName.slice(0, 10); - const summary = meta.summary || ''; - const tasksCompleted = parseInt(meta['tasks-completed'] || '0', 10); - const modulesTouched = detectModules(body); - - sessions.push({ - date, - summary, - tasksCompleted, - modulesTouched, - fileName, - }); - } - } - - // Sort by date ascending - sessions.sort((a, b) => a.date.localeCompare(b.date)); - - console.log(` [Sessions] Parsed ${sessions.length} session files`); - - // ----------------------------------------------------------------------- - // 2. Build progress over time (cumulative tasks completed) - // ----------------------------------------------------------------------- - - const progressOverTime = []; - let cumulative = 0; - - for (const session of sessions) { - cumulative += session.tasksCompleted; - progressOverTime.push({ - date: session.date, - cumulativeDone: cumulative, - sessionDone: session.tasksCompleted, - }); - } - - // ----------------------------------------------------------------------- - // 3. Calculate streaks - // ----------------------------------------------------------------------- - - const sessionDates = sessions.map(s => s.date); - const streaks = calculateStreaks(sessionDates); - - // ----------------------------------------------------------------------- - // 4. Extract last session info - // ----------------------------------------------------------------------- - - const lastSessionRaw = sessions.length > 0 ? sessions[sessions.length - 1] : null; - const lastSession = lastSessionRaw - ? { - date: lastSessionRaw.date, - summary: lastSessionRaw.summary, - tasksCompleted: lastSessionRaw.tasksCompleted, - modulesTouched: lastSessionRaw.modulesTouched, - } - : { date: '', summary: '', tasksCompleted: 0, modulesTouched: [] }; - - // ----------------------------------------------------------------------- - // 5. Parse task files - // ----------------------------------------------------------------------- - - const allDone = parseDoneFile(donePath); - // Last 10 completed tasks (file is ordered newest-first by section) - const recentlyDone = allDone.slice(0, 10); - - const inProgress = parseInProgressFile(inProgressPath); - - console.log(` [Sessions] ${allDone.length} done tasks, ${inProgress.length} in-progress`); - - // ----------------------------------------------------------------------- - // 6. Assemble result and cache - // ----------------------------------------------------------------------- - - const result = { - timestamp: new Date().toISOString(), - sessions, - progressOverTime, - streaks, - lastSession, - inProgress, - recentlyDone, - }; - - // Write cache - try { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(cachePath, JSON.stringify(result, null, 2)); - } catch (err) { - console.warn(` [Sessions] Failed to write cache: ${err.message}`); - } - - return result; -} diff --git a/tools/project-map/lib/suggestion-engine.mjs b/tools/project-map/lib/suggestion-engine.mjs deleted file mode 100644 index 3a81b65e..00000000 --- a/tools/project-map/lib/suggestion-engine.mjs +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Smart suggestion engine. - * Combines all signals (coverage, bugs, churn, staleness, coupling, debt, recent work) - * into ranked "Focus Next" recommendations. - */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const CACHE_FILE = 'suggestions.json'; -const PREFS_FILE = 'user-prefs.json'; - -// Signal weights (tunable) -const WEIGHTS = { - coverageGap: 2.0, // per % below 80 - openBugs: 15, // per bug - codeReviewFixes: 10, // per fix - openTasks: 3, // per task - highChurn: 1.5, // per commit in last 30d - staleness: 0.5, // per day since last commit - coupling: 2.0, // per coupling score point - debtMarkers: 1.0, // per TODO/FIXME/HACK - largeFiles: 5, // per large file - longFunctions: 3, // per long function - recentWorkCooldown: -20, // penalty if worked on in last 2 sessions -}; - -function loadPrefs(cacheDir) { - const prefsFile = resolve(cacheDir, PREFS_FILE); - if (existsSync(prefsFile)) { - try { return JSON.parse(readFileSync(prefsFile, 'utf8')); } catch { /* skip */ } - } - return { recentlyWorked: [], strategy: 'balanced' }; -} - -function savePrefs(cacheDir, prefs) { - try { - writeFileSync(resolve(cacheDir, PREFS_FILE), JSON.stringify(prefs, null, 2)); - } catch { /* non-fatal — prefs write failure should not crash the server */ } -} - -export function markWorkedOn(cacheDir, moduleName) { - const prefs = loadPrefs(cacheDir); - prefs.recentlyWorked = [moduleName, ...prefs.recentlyWorked.filter(m => m !== moduleName)].slice(0, 5); - savePrefs(cacheDir, prefs); -} - -export function setStrategy(cacheDir, strategy) { - const prefs = loadPrefs(cacheDir); - prefs.strategy = strategy; - savePrefs(cacheDir, prefs); -} - -export async function generateSuggestions(cacheDir, { - priorities = [], - goCoverage = {}, - vitestCoverage = {}, - backlog = {}, - gitData = null, - debtData = null, - importGraph = null, - sessionData = null, -} = {}) { - const prefs = loadPrefs(cacheDir); - const strategy = prefs.strategy || 'balanced'; - const recentlyWorked = new Set(prefs.recentlyWorked || []); - - // Strategy multipliers - const strategyMult = { - balanced: { coverage: 1, bugs: 1, momentum: 1, debt: 1 }, - 'bugs-first': { coverage: 0.5, bugs: 2.5, momentum: 0.5, debt: 0.5 }, - 'coverage-first': { coverage: 2.5, bugs: 0.5, momentum: 0.5, debt: 0.5 }, - 'momentum-first': { coverage: 0.5, bugs: 0.5, momentum: 2.5, debt: 0.5 }, - 'debt-first': { coverage: 0.5, bugs: 0.5, momentum: 0.5, debt: 2.5 }, - }; - const mult = strategyMult[strategy] || strategyMult.balanced; - - // Build per-module signal aggregation - const moduleSignals = {}; - - function ensureModule(name) { - if (!moduleSignals[name]) { - moduleSignals[name] = { - name, - signals: [], - score: 0, - coverageScore: 0, - bugScore: 0, - momentumScore: 0, - debtScore: 0, - }; - } - return moduleSignals[name]; - } - - // 1. Coverage gaps (from priorities which already have this data) - for (const p of priorities) { - const m = ensureModule(p.name); - if (p.coverage !== null && p.coverage < 80) { - const gap = 80 - p.coverage; - const pts = gap * WEIGHTS.coverageGap * mult.coverage; - m.coverageScore += pts; - m.signals.push({ signal: 'coverage-gap', value: `${p.coverage.toFixed(1)}% (${gap.toFixed(0)}% below target)`, points: pts }); - } else if (p.coverage === null && p.type !== 'feature-area') { - const pts = 80 * WEIGHTS.coverageGap * 0.5 * mult.coverage; // unknown coverage = assume 50% gap - m.coverageScore += pts; - m.signals.push({ signal: 'no-coverage-data', value: 'No test coverage', points: pts }); - } - } - - // 2. Open bugs and tasks - for (const [phase, tasks] of Object.entries(backlog.byPhase || {})) { - for (const task of tasks) { - for (const mod of task.modules) { - const m = ensureModule(mod); - if (phase === 'bug') { - const pts = WEIGHTS.openBugs * mult.bugs; - m.bugScore += pts; - m.signals.push({ signal: 'open-bug', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } else if (phase === 'code-review') { - const pts = WEIGHTS.codeReviewFixes * mult.bugs; - m.bugScore += pts; - m.signals.push({ signal: 'code-review-fix', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } else { - const pts = WEIGHTS.openTasks * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'open-task', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } - } - } - } - - // 3. Git signals (churn, staleness) - if (gitData) { - for (const [mod, data] of Object.entries(gitData.commitsByModule || {})) { - const m = ensureModule(mod); - // High churn = needs attention - if (data.count > 10) { - const pts = (data.count - 10) * WEIGHTS.highChurn * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'high-churn', value: `${data.count} commits in 30d`, points: pts }); - } - } - - for (const [mod, data] of Object.entries(gitData.staleness || {})) { - const m = ensureModule(mod); - if (data.daysSinceLastCommit > 14) { - const pts = data.daysSinceLastCommit * WEIGHTS.staleness * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'stale', value: `${data.daysSinceLastCommit} days since last commit`, points: pts }); - } - } - } - - // 4. Debt signals - if (debtData) { - for (const [mod, data] of Object.entries(debtData.summary?.byModule || {})) { - const m = ensureModule(mod); - if (data.markers > 0) { - const pts = data.markers * WEIGHTS.debtMarkers * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'debt-markers', value: `${data.markers} TODO/FIXME/HACK`, points: pts }); - } - if (data.largeFiles > 0) { - const pts = data.largeFiles * WEIGHTS.largeFiles * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'large-files', value: `${data.largeFiles} oversized file(s)`, points: pts }); - } - if (data.longFunctions > 0) { - const pts = data.longFunctions * WEIGHTS.longFunctions * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'long-functions', value: `${data.longFunctions} long function(s)`, points: pts }); - } - } - } - - // 5. Coupling signals - if (importGraph) { - for (const node of importGraph.nodes || []) { - const m = ensureModule(node.name); - if (node.coupling > 10) { - const pts = node.coupling * WEIGHTS.coupling * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'high-coupling', value: `Coupling score ${node.coupling} (fan-in ${node.fanIn}, fan-out ${node.fanOut})`, points: pts }); - } - } - } - - // 6. Recent work cooldown - for (const [name, m] of Object.entries(moduleSignals)) { - if (recentlyWorked.has(name)) { - const pts = WEIGHTS.recentWorkCooldown; - m.momentumScore += pts; - m.signals.push({ signal: 'recently-worked', value: 'Worked on recently — cooling down', points: pts }); - } - } - - // Compute total scores - for (const m of Object.values(moduleSignals)) { - m.score = m.coverageScore + m.bugScore + m.momentumScore + m.debtScore; - } - - // Sort and take top suggestions - const sorted = Object.values(moduleSignals) - .filter(m => m.score > 0) - .sort((a, b) => b.score - a.score); - - const suggestions = sorted.slice(0, 10).map((m, i) => { - // Build a human-readable rationale from top 3 signals - const topSignals = [...m.signals].sort((a, b) => b.points - a.points).slice(0, 3); - const rationale = topSignals.map(s => s.value).join('; '); - - return { - rank: i + 1, - module: m.name, - score: Math.round(m.score), - rationale, - breakdown: { - coverage: Math.round(m.coverageScore), - bugs: Math.round(m.bugScore), - momentum: Math.round(m.momentumScore), - debt: Math.round(m.debtScore), - }, - signals: m.signals, - }; - }); - - const result = { - timestamp: new Date().toISOString(), - strategy, - suggestions, - recentlyWorked: [...recentlyWorked], - }; - - try { - writeFileSync(resolve(cacheDir, CACHE_FILE), JSON.stringify(result, null, 2)); - } catch { /* non-fatal */ } - return result; -} diff --git a/tools/project-map/lib/terminal-summary.mjs b/tools/project-map/lib/terminal-summary.mjs deleted file mode 100644 index f17944ad..00000000 --- a/tools/project-map/lib/terminal-summary.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Terminal summary — prints a color-coded overview to stdout. - */ - -const RESET = '\x1b[0m'; -const BOLD = '\x1b[1m'; -const DIM = '\x1b[2m'; -const RED = '\x1b[31m'; -const GREEN = '\x1b[32m'; -const YELLOW = '\x1b[33m'; -const CYAN = '\x1b[36m'; -const WHITE = '\x1b[37m'; - -function covColor(pct) { - if (pct === null || pct === undefined) return RED; - if (pct >= 80) return GREEN; - if (pct >= 60) return YELLOW; - return RED; -} - -function bar(pct, width = 20) { - if (pct === null || pct === undefined) return `${RED}${'░'.repeat(width)}${RESET} ---`; - const filled = Math.round((pct / 100) * width); - const empty = width - filled; - const color = covColor(pct); - return `${color}${'█'.repeat(filled)}${'░'.repeat(empty)}${RESET} ${pct.toFixed(1)}%`; -} - -function divider(title) { - const line = '─'.repeat(60); - return `\n ${CYAN}${BOLD}${title}${RESET}\n ${DIM}${line}${RESET}`; -} - -export function printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities) { - console.log(divider('PROJECT OVERVIEW')); - - const total = backlog.openCount + backlog.doneCount; - const pct = total > 0 ? ((backlog.doneCount / total) * 100).toFixed(1) : '0.0'; - console.log(` ${WHITE}Tasks: ${GREEN}${backlog.doneCount} done${RESET} / ${YELLOW}${backlog.openCount} open${RESET} (${pct}% complete)`); - console.log(` ${WHITE}Files: ${modules.summary.goSourceFiles} Go + ${modules.summary.tsSourceFiles} TS + ${modules.summary.rustSourceFiles} Rust${RESET}`); - console.log(` ${WHITE}Tests: ${modules.summary.goTestFiles} Go + ${modules.summary.tsTestFiles} TS + ${modules.summary.rustTestFiles} Rust${RESET}`); - - // Go coverage - console.log(divider('SERVER (GO) COVERAGE')); - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const cov = covData?.percentage ?? null; - const name = `${pkg.name}/`.padEnd(16); - const failed = covData?.failed ? ` ${RED}FAIL${RESET}` : ''; - console.log(` ${WHITE}${name}${RESET} ${bar(cov)}${failed}`); - } - - // TS coverage - console.log(divider('CLIENT (TYPESCRIPT) COVERAGE')); - const tsAreas = vitestCoverage.areas || {}; - const totalCov = tsAreas._total; - if (totalCov) { - console.log(` ${WHITE}${'Overall'.padEnd(16)}${RESET} ${bar(totalCov.statements)}`); - } - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const cov = areaCov?.statements ?? null; - const name = `${dir.name}/`.padEnd(16); - console.log(` ${WHITE}${name}${RESET} ${bar(cov)}`); - } - - // Rust - console.log(divider('CLIENT (RUST) STATUS')); - for (const rust of modules.rust) { - console.log(` ${WHITE}${rust.sourceFiles} files, ${rust.sourceLines} lines${RESET} — ${RED}No test infrastructure${RESET}`); - } - - // Where to work next - console.log(divider('WHERE TO WORK NEXT (TOP 5)')); - const top5 = priorities.slice(0, 5); - top5.forEach((p, i) => { - const num = `${i + 1}.`.padEnd(3); - const name = p.name.padEnd(16); - const cov = p.coverage !== null ? `${p.coverage.toFixed(0)}%`.padEnd(5) : '--- '; - const tasks = p.openTaskCount > 0 ? `${YELLOW}${p.openTaskCount} task(s)${RESET}` : `${GREEN}0 tasks${RESET}`; - console.log(` ${CYAN}${num}${RESET} ${BOLD}${name}${RESET} ${covColor(p.coverage)}${cov}${RESET} ${tasks} — ${DIM}${p.recommendation}${RESET}`); - }); - - // Open bugs - const bugs = backlog.byPhase['bug'] || []; - if (bugs.length > 0) { - console.log(divider('OPEN BUGS')); - for (const bug of bugs) { - console.log(` ${RED}${bug.id}${RESET}: ${bug.description}`); - } - } - - // Code review items - const reviews = backlog.byPhase['code-review'] || []; - if (reviews.length > 0) { - console.log(divider('CODE REVIEW FIXES')); - for (const r of reviews) { - console.log(` ${YELLOW}${r.id}${RESET}: ${r.description}`); - } - } - - console.log(`\n ${DIM}Full report: docs/brain/00-Overview/Project-Map.md${RESET}\n`); -} diff --git a/tools/project-map/lib/vitest-coverage.mjs b/tools/project-map/lib/vitest-coverage.mjs deleted file mode 100644 index 8912277c..00000000 --- a/tools/project-map/lib/vitest-coverage.mjs +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Vitest coverage collector. - * Runs vitest with coverage and parses the Istanbul coverage-final.json. - * Caches results to .cache/vitest-coverage.json. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Coverage data older than this is considered stale and gets a warning flag -const FRESHNESS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - -const CACHE_FILE = 'vitest-coverage.json'; - -function computeFileCoverage(fileData) { - const s = fileData.s || {}; - const b = fileData.b || {}; - const f = fileData.f || {}; - - const stmtTotal = Object.keys(s).length; - const stmtCovered = Object.values(s).filter(v => v > 0).length; - - let branchTotal = 0; - let branchCovered = 0; - for (const branches of Object.values(b)) { - for (const count of branches) { - branchTotal++; - if (count > 0) branchCovered++; - } - } - - const fnTotal = Object.keys(f).length; - const fnCovered = Object.values(f).filter(v => v > 0).length; - - return { - statements: stmtTotal > 0 ? (stmtCovered / stmtTotal) * 100 : 100, - branches: branchTotal > 0 ? (branchCovered / branchTotal) * 100 : 100, - functions: fnTotal > 0 ? (fnCovered / fnTotal) * 100 : 100, - stmtTotal, - stmtCovered, - branchTotal, - branchCovered, - fnTotal, - fnCovered, - }; -} - -function parseCoverageFinal(coverageJsonPath) { - if (!existsSync(coverageJsonPath)) return {}; - - const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); - const byArea = {}; - let totalStmt = 0, totalStmtCov = 0; - let totalBranch = 0, totalBranchCov = 0; - let totalFn = 0, totalFnCov = 0; - - for (const [filePath, fileData] of Object.entries(raw)) { - const normalized = filePath.replace(/\\/g, '/'); - const srcMatch = normalized.match(/src\/(\w+)\//); - const area = srcMatch ? srcMatch[1] : 'other'; - - const cov = computeFileCoverage(fileData); - - if (!byArea[area]) { - byArea[area] = { - files: 0, - stmtTotal: 0, stmtCovered: 0, - branchTotal: 0, branchCovered: 0, - fnTotal: 0, fnCovered: 0, - }; - } - - byArea[area].files++; - byArea[area].stmtTotal += cov.stmtTotal; - byArea[area].stmtCovered += cov.stmtCovered; - byArea[area].branchTotal += cov.branchTotal; - byArea[area].branchCovered += cov.branchCovered; - byArea[area].fnTotal += cov.fnTotal; - byArea[area].fnCovered += cov.fnCovered; - - totalStmt += cov.stmtTotal; - totalStmtCov += cov.stmtCovered; - totalBranch += cov.branchTotal; - totalBranchCov += cov.branchCovered; - totalFn += cov.fnTotal; - totalFnCov += cov.fnCovered; - } - - // Compute percentages - const result = {}; - for (const [area, data] of Object.entries(byArea)) { - result[area] = { - files: data.files, - statements: data.stmtTotal > 0 ? (data.stmtCovered / data.stmtTotal) * 100 : 100, - branches: data.branchTotal > 0 ? (data.branchCovered / data.branchTotal) * 100 : 100, - functions: data.fnTotal > 0 ? (data.fnCovered / data.fnTotal) * 100 : 100, - }; - } - - result._total = { - statements: totalStmt > 0 ? (totalStmtCov / totalStmt) * 100 : 0, - branches: totalBranch > 0 ? (totalBranchCov / totalBranch) * 100 : 0, - functions: totalFn > 0 ? (totalFnCov / totalFn) * 100 : 0, - }; - - return result; -} - -export async function collectVitestCoverage(root, cacheDir, quick) { - const cacheFile = resolve(cacheDir, CACHE_FILE); - - const clientDir = resolve(root, 'Client/tauri-client'); - - if (quick && existsSync(cacheFile)) { - console.log(' [TS] Using cached coverage data'); - let cached; - try { - cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - } catch { - console.warn(' [TS] Corrupt cache — falling through to fresh collection'); - cached = null; - } - if (cached) { - // Still evaluate freshness of the underlying coverage-final.json - const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); - if (existsSync(coveragePath)) { - try { - const covStat = statSync(coveragePath); - const ageMs = Date.now() - covStat.mtimeMs; - if (ageMs > FRESHNESS_MAX_AGE_MS) { - cached.stale = true; - console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); - } - } catch { /* stat failed, leave stale flag as-is */ } - } - return cached; - } - } - if (!existsSync(clientDir)) { - console.log(' [TS] Client directory not found, skipping'); - return {}; - } - - // Check if coverage-final.json already exists (from a previous test run) - const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); - let needsRun = !existsSync(coveragePath); - let stale = false; - - // Check freshness — don't blindly trust old coverage data - if (!needsRun) { - try { - const covStat = statSync(coveragePath); - const ageMs = Date.now() - covStat.mtimeMs; - if (ageMs > FRESHNESS_MAX_AGE_MS) { - stale = true; - console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); - if (!quick) { - needsRun = true; // Full mode re-runs stale coverage - } - } - } catch { /* stat failed, treat as needing a run */ needsRun = true; } - } - - if (needsRun && !quick) { - console.log(' [TS] Running vitest with coverage (this may take a moment)...'); - try { - execFileSync('npx', ['vitest', 'run', '--coverage'], { - cwd: clientDir, - encoding: 'utf8', - timeout: 300_000, - maxBuffer: 10 * 1024 * 1024, - }); - } catch { - // vitest may exit non-zero but still produce coverage - } - } else if (!needsRun) { - console.log(' [TS] Using existing coverage-final.json'); - } - - let coverageData = {}; - if (existsSync(coveragePath)) { - coverageData = parseCoverageFinal(coveragePath); - } - - // Get test count from a quick vitest --reporter=json run if available - let testCount = 0; - const reportPath = resolve(clientDir, 'coverage-report.json'); - if (existsSync(reportPath)) { - try { - const report = JSON.parse(readFileSync(reportPath, 'utf8')); - testCount = report.numTotalTests ?? 0; - } catch { /* skip */ } - } - - // Instead of running vitest again, count files from coverage data - if (testCount === 0 && Object.keys(coverageData).length > 0) { - try { - const coverageJsonPath = resolve(clientDir, 'coverage', 'coverage-final.json'); - if (existsSync(coverageJsonPath)) { - const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); - testCount = Object.keys(raw).length; - } - } catch { /* keep testCount as 0 */ } - } - - const result = { - timestamp: new Date().toISOString(), - areas: coverageData, - testCount, - stale, - }; - - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [TS] Coverage collected for ${Object.keys(coverageData).length} areas, ${testCount} tests`); - return result; -} diff --git a/tools/project-map/lib/worktree-manager.mjs b/tools/project-map/lib/worktree-manager.mjs deleted file mode 100644 index 83d905fd..00000000 --- a/tools/project-map/lib/worktree-manager.mjs +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Worktree Manager — git worktree lifecycle for isolated agent execution. - * - * Each agent job gets its own worktree under .worktrees/. - * Changes stay isolated until explicitly merged back. - * - * Lifecycle: - * createWorktree() → agent runs → mergeWorktree() → destroyWorktree() - * - * State machine: - * queued → provisioning → running → review → merged → archived - * → failed → (retry → queued) - * → cancelled - */ -import { execFileSync } from 'node:child_process'; -import { - existsSync, - mkdirSync, - symlinkSync, - cpSync, - rmSync, - readdirSync, - lstatSync, -} from 'node:fs'; -import { resolve, join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const WORKTREES_DIR = '.worktrees'; -const BRANCH_PREFIX = 'agent/'; -const GIT_TIMEOUT = 30_000; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function git(args, cwd, opts = {}) { - return execFileSync('git', args, { - cwd, - encoding: 'utf8', - timeout: opts.timeout ?? GIT_TIMEOUT, - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }).trim(); -} - -/** - * Link or copy .claude/ and CLAUDE.md into a worktree so agents - * inherit project config, skills, and MCP server settings. - * - * Strategy: try symlink first (works if Developer Mode enabled on Windows). - * If symlink fails (EPERM), fall back to a full copy. - * - * @returns {'symlink'|'copy'|'none'} — method used - */ -function inheritConfig(rootDir, worktreePath) { - let method = 'none'; - - const claudeDir = resolve(rootDir, '.claude'); - const claudeMd = resolve(rootDir, 'CLAUDE.md'); - const targetDir = resolve(worktreePath, '.claude'); - const targetMd = resolve(worktreePath, 'CLAUDE.md'); - - if (existsSync(claudeDir) && !existsSync(targetDir)) { - // Try symlink - try { - symlinkSync(claudeDir, targetDir, 'junction'); // junction works without admin on Windows - method = 'symlink'; - } catch { - // Fallback to copy - try { - cpSync(claudeDir, targetDir, { recursive: true }); - method = 'copy'; - } catch (copyErr) { - console.error(` [Worktree] Failed to copy .claude/: ${copyErr.message}`); - } - } - } - - if (existsSync(claudeMd) && !existsSync(targetMd)) { - try { - symlinkSync(claudeMd, targetMd, 'file'); - } catch { - try { - cpSync(claudeMd, targetMd); - } catch { /* non-critical */ } - } - } - - return method; -} - -/** - * Remove inherited config from a worktree before destruction. - * Must handle both symlinks and copies. - */ -function removeInheritedConfig(worktreePath) { - const targets = [ - resolve(worktreePath, '.claude'), - resolve(worktreePath, 'CLAUDE.md'), - ]; - - for (const target of targets) { - if (!existsSync(target)) continue; - try { - rmSync(target, { recursive: true, force: true }); - } catch (err) { - console.error(` [Worktree] Failed to remove ${target}: ${err.message}`); - } - } -} - -// --------------------------------------------------------------------------- -// Merge mutex — only one merge at a time -// --------------------------------------------------------------------------- - -let mergeInProgress = false; - -export function isMergeInProgress() { - return mergeInProgress; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Create an isolated git worktree for an agent job. - * - * @param {string} root — project root (must be a git repo) - * @param {string} jobId — unique job identifier (used for directory + branch name) - * @returns {{ worktreePath: string, branchName: string, configMethod: string }} - */ -export function createWorktree(root, jobId) { - const worktreesBase = resolve(root, WORKTREES_DIR); - if (!existsSync(worktreesBase)) { - mkdirSync(worktreesBase, { recursive: true }); - } - - const worktreePath = resolve(worktreesBase, jobId); - const branchName = `${BRANCH_PREFIX}${jobId}`; - - if (existsSync(worktreePath)) { - throw new Error(`Worktree already exists for job ${jobId}`); - } - - // Create worktree with a new branch based on current HEAD - git(['worktree', 'add', worktreePath, '-b', branchName], root); - - // Inherit project config - const configMethod = inheritConfig(root, worktreePath); - - return { worktreePath, branchName, configMethod }; -} - -/** - * Destroy a worktree and its branch. - * Handles the Windows-specific cleanup order: remove config → remove dir → prune → delete branch. - * - * @param {string} root — project root - * @param {string} jobId — job identifier - */ -export function destroyWorktree(root, jobId) { - const worktreePath = resolve(root, WORKTREES_DIR, jobId); - const branchName = `${BRANCH_PREFIX}${jobId}`; - - if (existsSync(worktreePath)) { - // Step 1: Remove inherited config (symlinks/copies) first - removeInheritedConfig(worktreePath); - - // Step 2: Try git worktree remove - try { - git(['worktree', 'remove', worktreePath, '--force'], root); - } catch { - // Fallback: force-remove directory + prune - try { - rmSync(worktreePath, { recursive: true, force: true }); - } catch (rmErr) { - console.error(` [Worktree] rm failed for ${jobId}: ${rmErr.message}`); - } - try { - git(['worktree', 'prune'], root); - } catch { /* best effort */ } - } - } else { - // Directory already gone — just prune - try { - git(['worktree', 'prune'], root); - } catch { /* best effort */ } - } - - // Step 3: Delete the branch - try { - git(['branch', '-D', branchName], root); - } catch { - // Branch may already be deleted or never created - } -} - -/** - * List all active worktrees. - * - * @param {string} root — project root - * @returns {Array<{ path: string, branch: string, head: string, jobId: string|null }>} - */ -export function listWorktrees(root) { - let raw; - try { - raw = git(['worktree', 'list', '--porcelain'], root); - } catch { - return []; - } - - const worktrees = []; - let current = {}; - - for (const line of raw.split('\n')) { - if (line.startsWith('worktree ')) { - if (current.path) worktrees.push(current); - current = { path: line.slice(9) }; - } else if (line.startsWith('HEAD ')) { - current.head = line.slice(5); - } else if (line.startsWith('branch ')) { - current.branch = line.slice(7); - } else if (line === '') { - if (current.path) worktrees.push(current); - current = {}; - } - } - if (current.path) worktrees.push(current); - - // Filter to agent worktrees only and extract jobId - return worktrees - .filter(w => w.branch && w.branch.includes(BRANCH_PREFIX)) - .map(w => ({ - ...w, - jobId: w.branch.replace(`refs/heads/${BRANCH_PREFIX}`, ''), - })); -} - -/** - * Merge a worktree's branch back into the current branch. - * - * Guards: - * - Only one merge at a time (mutex) - * - Working tree must be clean - * - * @param {string} root — project root - * @param {string} jobId — job identifier - * @param {string} [message] — optional merge commit message - * @returns {{ success: boolean, filesChanged: number, commitSha: string|null, conflicts: string[] }} - */ -export function mergeWorktree(root, jobId, message) { - if (mergeInProgress) { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'merge_in_progress' }; - } - - mergeInProgress = true; - - try { - // Guard: working tree must be clean - try { - git(['diff', '--quiet'], root); - git(['diff', '--quiet', '--cached'], root); - } catch { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'working_tree_dirty' }; - } - - const branchName = `${BRANCH_PREFIX}${jobId}`; - const commitMsg = message || `agent: merge results from ${jobId}`; - - // Guard: branch must exist - try { - git(['rev-parse', '--verify', branchName], root); - } catch { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'branch_not_found' }; - } - - // Attempt merge - try { - git(['merge', branchName, '--no-ff', '-m', commitMsg], root, { timeout: 60_000 }); - } catch (mergeErr) { - // Check if it's a conflict - try { - const conflictRaw = git(['diff', '--name-only', '--diff-filter=U'], root); - const conflicts = conflictRaw.split('\n').filter(Boolean); - - if (conflicts.length > 0) { - // Abort the merge - try { git(['merge', '--abort'], root); } catch { /* may already be clean */ } - return { success: false, filesChanged: 0, commitSha: null, conflicts }; - } - } catch { /* fall through */ } - - // Not a conflict — some other error - try { git(['merge', '--abort'], root); } catch { /* best effort */ } - throw mergeErr; - } - - // Success — get stats - const commitSha = git(['rev-parse', '--short', 'HEAD'], root); - let filesChanged = 0; - try { - const stat = git(['diff', '--stat', 'HEAD~1..HEAD', '--numstat'], root); - filesChanged = stat.split('\n').filter(Boolean).length; - } catch { /* non-critical */ } - - return { success: true, filesChanged, commitSha, conflicts: [] }; - } finally { - mergeInProgress = false; - } -} - -/** - * Clean up stale worktrees — those whose agent process is dead. - * Call on server startup. - * - * @param {string} root — project root - * @param {function} isJobAlive — callback (jobId) => boolean, checks if the agent process is still running - * @returns {{ cleaned: number, errors: string[] }} - */ -export function cleanupStaleWorktrees(root, isJobAlive) { - const worktreesBase = resolve(root, WORKTREES_DIR); - if (!existsSync(worktreesBase)) return { cleaned: 0, errors: [] }; - - let entries; - try { - entries = readdirSync(worktreesBase); - } catch { - return { cleaned: 0, errors: [] }; - } - - let cleaned = 0; - const errors = []; - - for (const entry of entries) { - const entryPath = resolve(worktreesBase, entry); - try { - const stat = lstatSync(entryPath); - if (!stat.isDirectory()) continue; - } catch { - continue; - } - - // Check if the agent for this worktree is still alive - const alive = typeof isJobAlive === 'function' ? isJobAlive(entry) : false; - if (alive) continue; - - // Dead worktree — clean up - try { - destroyWorktree(root, entry); - cleaned += 1; - } catch (err) { - errors.push(`${entry}: ${err.message}`); - } - } - - return { cleaned, errors }; -} - -/** - * Get disk usage estimate for a worktree directory (in bytes). - * Best-effort — returns 0 on failure. - */ -export function getWorktreeDiskUsage(root, jobId) { - const worktreePath = resolve(root, WORKTREES_DIR, jobId); - if (!existsSync(worktreePath)) return 0; - - try { - // Use git to count the worktree overhead (not the full repo — worktrees share objects) - const countOutput = git(['count-objects', '-vH'], worktreePath); - // Parse "size-pack: 42.00 MiB" or similar - const match = countOutput.match(/size-pack:\s*([\d.]+)\s*(\w+)/); - if (match) { - const size = parseFloat(match[1]); - const unit = match[2].toLowerCase(); - if (unit.startsWith('kib') || unit.startsWith('k')) return Math.round(size * 1024); - if (unit.startsWith('mib') || unit.startsWith('m')) return Math.round(size * 1024 * 1024); - if (unit.startsWith('gib') || unit.startsWith('g')) return Math.round(size * 1024 * 1024 * 1024); - return Math.round(size); - } - } catch { /* best effort */ } - - return 0; -} diff --git a/tools/project-map/package.json b/tools/project-map/package.json deleted file mode 100644 index c849d424..00000000 --- a/tools/project-map/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "owncord-project-map", - "version": "1.0.0", - "private": true, - "type": "module", - "description": "Project map generator for OwnCord — completeness, testing, priorities", - "scripts": { - "map": "node index.mjs", - "map:quick": "node index.mjs --quick", - "map:research": "node index.mjs --research", - "test": "node --test --test-concurrency=1 tests/*.test.mjs" - } -} diff --git a/tools/project-map/server.mjs b/tools/project-map/server.mjs deleted file mode 100644 index e71fc167..00000000 --- a/tools/project-map/server.mjs +++ /dev/null @@ -1,661 +0,0 @@ -#!/usr/bin/env node -/** - * Project Map Web Dashboard - * Run: node tools/project-map/server.mjs - * Open: http://localhost:3333 - */ -import { createServer } from 'node:http'; -import { randomBytes } from 'node:crypto'; -import { readFileSync, writeFileSync as _writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { scanModules } from './lib/scanner.mjs'; -import { collectGoCoverage } from './lib/go-coverage.mjs'; -import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; -import { parseBacklog } from './lib/backlog-parser.mjs'; -import { scorePriorities } from './lib/priority-engine.mjs'; -import { scanGitHistory } from './lib/git-scanner.mjs'; -import { parseSessionHistory } from './lib/session-parser.mjs'; -import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; -import { buildImportGraph } from './lib/import-graph.mjs'; -import { generateSuggestions, markWorkedOn, setStrategy } from './lib/suggestion-engine.mjs'; -import { createFileWatcher, createSSEManager } from './lib/file-watcher.mjs'; -import { generatePlan, startSession, getSessionStatus, pollChanges, endSession, recoverStaleSession } from './lib/session-manager.mjs'; -import { healthCheck, getJobs, createJob, cancelJob, getJobResult, processQueue, recoverOrphans, pruneResults, getLiveOutput, getJobDiff, parseActivityHints, getActiveCount, getMaxConcurrent, setMaxConcurrent, getActiveAgentIds } from './lib/agent-manager.mjs'; -import { mergeWorktree, destroyWorktree, listWorktrees, isMergeInProgress, getWorktreeDiskUsage } from './lib/worktree-manager.mjs'; -import { generateBriefing } from './lib/morning-briefing.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../..'); -const CACHE_DIR = resolve(__dirname, '.cache'); -let PORT = parseInt(process.env.PORT || '3333', 10); -if (isNaN(PORT) || PORT < 1 || PORT > 65535) { console.error(`Invalid PORT "${process.env.PORT}", using 3333`); PORT = 3333; } - -if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); - -// Auth token — generated per process, required for privileged endpoints -const AUTH_TOKEN = process.env.PROJECT_MAP_TOKEN || randomBytes(24).toString('hex'); - -// In-memory cache (mode-aware) -let cachedData = null; -let cachedDataMode = null; // 'quick' or 'full' -let collectInflight = null; - -// Valid job types -const VALID_JOB_TYPES = new Set(['research', 'write-tests', 'code-review', 'security-audit', 'fix-debt', 'custom']); -const JOB_ID_RE = /^job-\d+(-[a-f0-9]+)?$/; - -async function collectData(quick = true) { - console.log(` Collecting data (${quick ? 'quick' : 'full'} mode)...`); - - const modules = await scanModules(ROOT); - const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); - const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); - const backlog = await parseBacklog(ROOT); - const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); - - let gitData = null, sessionData = null, debtData = null, importGraph = null; - try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Git] Error:', e.message); } - try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Session] Error:', e.message); } - try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Debt] Error:', e.message); } - try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Graph] Error:', e.message); } - - let suggestions = null; - try { - suggestions = await generateSuggestions(CACHE_DIR, { - priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, - }); - } catch (e) { console.error(' [Suggestions] Error:', e.message); } - - // Agent jobs - let agentJobs = null; - try { agentJobs = getJobs(CACHE_DIR); } catch (e) { console.error(' [Jobs] Error:', e.message); } - - // Morning briefing - let briefing = null; - try { briefing = await generateBriefing(ROOT, CACHE_DIR, { sessionData, suggestions, backlog, agentJobs }); } catch (e) { console.error(' [Briefing] Error:', e.message); } - - return { - modules, goCoverage, vitestCoverage, backlog, priorities, - gitData, sessionData, debtData, importGraph, suggestions, - agentJobs, briefing, - agentHealth: null, // populated on demand - timestamp: new Date().toISOString(), - }; -} - -// SSE manager for live updates -const sse = createSSEManager(); - -// File watcher for auto-refresh -const watcher = createFileWatcher(ROOT, (change) => { - console.log(` [Watch] Change detected: ${change.filename}`); - cachedData = null; - sse.broadcast({ type: 'file-change', ...change }); -}); - -// Valid strategy values -const VALID_STRATEGIES = new Set(['balanced', 'bugs-first', 'coverage-first', 'momentum-first', 'debt-first']); - -// Parse JSON body from POST requests (with size limit and error handling) -async function parseBody(req) { - return new Promise((resolve) => { - let body = ''; - let destroyed = false; - req.on('data', chunk => { - if (destroyed) return; - body += chunk; - if (body.length > 4096) { destroyed = true; req.destroy(); resolve(null); } - }); - req.on('end', () => { - if (destroyed) return; - try { resolve(JSON.parse(body)); } catch { resolve(null); } - }); - req.on('error', () => { if (!destroyed) resolve(null); }); - }); -} - -function json(res, status, data) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -// Auto-process agent queue on an interval -let processInterval = null; - -// Diff poller — polls git diff every 2s while a job is running -let diffPollerInterval = null; - -function startDiffPoller() { - if (diffPollerInterval) return; - console.log(' [Diff] Starting diff poller (2s interval)'); - diffPollerInterval = setInterval(() => { - try { - const { jobs } = getJobs(CACHE_DIR); - const running = jobs.filter(j => j.status === 'running' && j.baselineSha); - if (running.length === 0) { - stopDiffPoller(); - return; - } - for (const job of running) { - try { - const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); - sse.broadcast({ - type: 'job-diff', - jobId: job.id, - files: diffData.files, - diffs: diffData.diffs, - freshness: diffData.freshness, - }); - } catch (err) { - console.error(` [Diff] Error polling job ${job.id}:`, err.message); - } - } - } catch { /* silent */ } - }, 2000); -} - -function stopDiffPoller() { - if (!diffPollerInterval) return; - console.log(' [Diff] Stopping diff poller'); - clearInterval(diffPollerInterval); - diffPollerInterval = null; -} - -// Check bearer token for privileged endpoints -function isAuthorized(req) { - const auth = req.headers['authorization'] || ''; - return auth === `Bearer ${AUTH_TOKEN}`; -} - -function requireAuth(req, res) { - if (isAuthorized(req)) return true; - json(res, 401, { error: 'Unauthorized — pass Authorization: Bearer ' }); - return false; -} - -const BIND_HOST = process.env.PROJECT_MAP_HOST || '127.0.0.1'; - -const server = createServer(async (req, res) => { - const url = new URL(req.url, `http://localhost:${PORT}`); - res.setHeader('Access-Control-Allow-Origin', `http://localhost:${PORT}`); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - try { - // === EXISTING ROUTES === - - if (url.pathname === '/api/data') { - const quick = url.searchParams.get('full') !== '1'; - const requestedMode = quick ? 'quick' : 'full'; - // Don't reuse quick cache for full requests; always refresh if mode changed or forced - const cacheValid = cachedData && cachedDataMode === requestedMode && !url.searchParams.has('refresh'); - // Also allow full cache to serve quick requests (full is a superset) - const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !url.searchParams.has('refresh'); - if (!cacheValid && !fullCacheForQuick) { - if (!collectInflight) { - collectInflight = collectData(quick).then(data => { - cachedData = data; - cachedDataMode = requestedMode; - collectInflight = null; - return data; - }).catch(err => { - collectInflight = null; - throw err; - }); - } - await collectInflight; - } - // Strip agentJobs and job-derived briefing fields from unauthenticated responses - if (!isAuthorized(req)) { - const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; - if (fullBriefing) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; - publicData.briefing = publicBriefing; - } - json(res, 200, publicData); - } else { - json(res, 200, cachedData); - } - return; - } - - if (url.pathname === '/api/events') { - sse.handleConnection(req, res); - return; - } - - if (url.pathname === '/api/worked-on' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (typeof body.module !== 'string' || !body.module || body.module.length > 100 || !/^[\w:/-]+$/.test(body.module)) { - json(res, 400, { error: 'Invalid module name' }); return; - } - markWorkedOn(CACHE_DIR, body.module); - cachedData = null; cachedDataMode = null; - json(res, 200, { ok: true }); - return; - } - - if (url.pathname === '/api/strategy' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (!VALID_STRATEGIES.has(body.strategy)) { - json(res, 400, { error: `Invalid strategy. Valid: ${[...VALID_STRATEGIES].join(', ')}` }); return; - } - setStrategy(CACHE_DIR, body.strategy); - cachedData = null; cachedDataMode = null; - json(res, 200, { ok: true }); - return; - } - - // === SESSION ROUTES === - - if (url.pathname === '/api/session/plan' && req.method === 'GET') { - // Unauthenticated — returns non-sensitive planning suggestions - const plan = await generatePlan(ROOT, CACHE_DIR); - json(res, 200, plan); - return; - } - - if (url.pathname === '/api/session/start' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const state = startSession(ROOT, CACHE_DIR); - sse.broadcast({ type: 'session-started' }); - json(res, 200, state); - return; - } - - if (url.pathname === '/api/session/status' && req.method === 'GET') { - const status = getSessionStatus(CACHE_DIR); - // If active, poll for latest changes - if (status.active) { - try { pollChanges(ROOT, CACHE_DIR); } catch { /* best effort */ } - const updated = getSessionStatus(CACHE_DIR); - json(res, 200, updated); - } else { - json(res, 200, status); - } - return; - } - - if (url.pathname === '/api/session/end' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const result = endSession(ROOT, CACHE_DIR); - cachedData = null; // Vault changed - sse.broadcast({ type: 'session-ended', ...result }); - json(res, 200, result); - return; - } - - // === AGENT JOB ROUTES === - - if (url.pathname === '/api/agent/health' && req.method === 'GET') { - const health = healthCheck(); - json(res, 200, health); - return; - } - - if (url.pathname === '/api/jobs' && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobs = getJobs(CACHE_DIR); - json(res, 200, jobs); - return; - } - - if (url.pathname === '/api/jobs' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (!VALID_JOB_TYPES.has(body.type)) { - json(res, 400, { error: `Invalid job type. Valid: ${[...VALID_JOB_TYPES].join(', ')}` }); return; - } - if (!body.target || typeof body.target !== 'string' || !/^[\w:/-]+$/.test(body.target) || body.target.length > 100) { - json(res, 400, { error: 'Invalid target: must be alphanumeric with :/-_ only, max 100 chars' }); return; - } - if (body.type === 'custom') { - if (typeof body.customPrompt !== 'string' || body.customPrompt.trim().length === 0) { - json(res, 400, { error: 'customPrompt required for custom jobs' }); return; - } - if (body.customPrompt.length > 8000) { - json(res, 400, { error: 'customPrompt exceeds 8000 char limit' }); return; - } - } - // Health check before allowing job creation - const health = healthCheck(); - if (!health.available) { - json(res, 503, { error: 'Claude CLI not available', details: health.error }); return; - } - const job = createJob(CACHE_DIR, { - type: body.type, - target: body.target, - priority: body.priority, - customPrompt: body.customPrompt, - }); - json(res, 201, job); - return; - } - - // DELETE /api/jobs/:id - const deleteMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/); - if (deleteMatch && req.method === 'DELETE') { - if (!requireAuth(req, res)) return; - const jobId = deleteMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - try { - cancelJob(CACHE_DIR, jobId); - json(res, 200, { ok: true }); - } catch (err) { - json(res, 404, { error: err.message }); - } - return; - } - - // GET /api/jobs/:id/result - const resultMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/result$/); - if (resultMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = resultMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const result = getJobResult(CACHE_DIR, jobId); - json(res, 200, result); - return; - } - - // GET /api/jobs/:id/output — live output for running jobs - const outputMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/output$/); - if (outputMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = outputMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const output = getLiveOutput(jobId); - json(res, 200, { output }); - return; - } - - // GET /api/jobs/:id/diff — current git diff for a job - const diffMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/diff$/); - if (diffMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = diffMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const { jobs } = getJobs(CACHE_DIR); - const job = jobs.find(j => j.id === jobId); - if (!job) { json(res, 404, { error: 'Job not found' }); return; } - if (!job.baselineSha) { json(res, 200, { files: [], diffs: {}, freshness: new Date().toISOString() }); return; } - try { - const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); - json(res, 200, diffData); - } catch (err) { - json(res, 500, { error: 'Failed to compute diff: ' + err.message }); - } - return; - } - - if (url.pathname === '/api/jobs/process' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - startDiffPoller(); - const result = await processQueue(ROOT, CACHE_DIR, - (jobId, chunk) => { - sse.broadcast({ type: 'job-output', jobId, chunk }); - const hints = parseActivityHints(chunk); - for (const hint of hints) { - sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); - } - }, - (jobId, message) => { - sse.broadcast({ type: 'job-warning', jobId, message }); - }, - ); - if (result.launched > 0) { - sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); - const { jobs } = getJobs(CACHE_DIR); - if (!jobs.some(j => j.status === 'running')) stopDiffPoller(); - } - json(res, 200, result); - return; - } - - // POST /api/jobs/:id/merge — merge worktree back to current branch - const mergeMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/merge$/); - if (mergeMatch && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const jobId = mergeMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const { jobs } = getJobs(CACHE_DIR); - const job = jobs.find(j => j.id === jobId); - if (!job) { json(res, 404, { error: 'Job not found' }); return; } - if (job.status !== 'review') { json(res, 400, { error: `Cannot merge job with status "${job.status}" — must be in review` }); return; } - - // Broadcast merge lock - sse.broadcast({ type: 'merge-lock', locked: true, jobId }); - const body = await parseBody(req); - const message = body?.message || undefined; - - const result = mergeWorktree(ROOT, jobId, message); - - if (result.error === 'merge_in_progress') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 423, { error: 'Another merge is in progress' }); - return; - } - if (result.error === 'working_tree_dirty') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 400, { error: 'Working tree has uncommitted changes — commit or stash first' }); - return; - } - if (result.error === 'branch_not_found') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 410, { error: 'Branch no longer exists — worktree was already cleaned up. Use Dismiss to clear this job.' }); - return; - } - if (!result.success && result.conflicts.length > 0) { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 409, { error: 'merge_conflict', conflicts: result.conflicts }); - return; - } - - // Success — destroy worktree and update job status - try { destroyWorktree(ROOT, jobId); } catch { /* best effort */ } - // Update job status to merged - const s = getJobs(CACHE_DIR); // refresh - // Direct queue file update for status change - const allJobs = s.jobs.map(j => - j.id === jobId ? { ...j, status: 'merged', worktreePath: null, branchName: null } : j, - ); - const { writeFileSync: wfs } = await import('node:fs'); - wfs(resolve(CACHE_DIR, 'agent-queue.json'), JSON.stringify(allJobs, null, 2)); - - cachedData = null; - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - sse.broadcast({ type: 'job-update', jobId, status: 'merged' }); - json(res, 200, { success: true, filesChanged: result.filesChanged, commitSha: result.commitSha }); - return; - } - - // === FLEET ROUTES === - - if (url.pathname === '/api/fleet/status' && req.method === 'GET') { - const { jobs } = getJobs(CACHE_DIR); - const active = jobs.filter(j => j.status === 'running' || j.status === 'provisioning').length; - const queued = jobs.filter(j => j.status === 'queued').length; - const review = jobs.filter(j => j.status === 'review').length; - const worktrees = listWorktrees(ROOT).map(w => ({ - jobId: w.jobId, - path: w.path, - branch: w.branch, - diskMB: Math.round(getWorktreeDiskUsage(ROOT, w.jobId) / (1024 * 1024)), - })); - json(res, 200, { active, queued, review, maxConcurrent: getMaxConcurrent(), worktrees }); - return; - } - - if (url.pathname === '/api/fleet/config' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (typeof body.maxConcurrent === 'number') { - if (body.maxConcurrent < 1 || body.maxConcurrent > 8) { - json(res, 400, { error: 'maxConcurrent must be between 1 and 8' }); return; - } - setMaxConcurrent(body.maxConcurrent); - } - // Save to user prefs for persistence - const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); - let prefs = {}; - try { prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); } catch { /* new file */ } - if (body.maxConcurrent) prefs.maxConcurrent = body.maxConcurrent; - if (body.autopilot) prefs.autopilot = { ...prefs.autopilot, ...body.autopilot }; - _writeFileSync(prefsPath, JSON.stringify(prefs, null, 2)); - json(res, 200, { ok: true }); - return; - } - - if (url.pathname === '/api/worktrees' && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const worktrees = listWorktrees(ROOT); - json(res, 200, { worktrees }); - return; - } - - // === BRIEFING ROUTE === - - if (url.pathname === '/api/briefing' && req.method === 'GET') { - // Use cached data if available, otherwise collect fresh - if (!cachedData) cachedData = await collectData(true); - const authenticated = isAuthorized(req); - const briefing = await generateBriefing(ROOT, CACHE_DIR, { - sessionData: cachedData.sessionData, - suggestions: cachedData.suggestions, - backlog: cachedData.backlog, - // Only pass agent jobs for authenticated callers - agentJobs: authenticated ? getJobs(CACHE_DIR) : null, - }); - if (!authenticated) { - // Strip all agent-job-derived fields from unauthenticated responses - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = briefing; - json(res, 200, publicBriefing); - } else { - json(res, 200, briefing); - } - return; - } - - // === DASHBOARD HTML === - - if (url.pathname === '/' || url.pathname === '/index.html') { - const html = readFileSync(resolve(__dirname, 'dashboard.html'), 'utf8'); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); - return; - } - - res.writeHead(404); - res.end('Not found'); - } catch (err) { - console.error(` [Server] ${req.method} ${url.pathname} error:`, err); - json(res, 500, { error: 'Internal server error' }); - } -}); - -// Startup recovery -async function startup() { - console.log('\n Project Map Dashboard'); - console.log(` http://${BIND_HOST}:${PORT}?token=${AUTH_TOKEN}`); - // Also write token to a file for programmatic access - try { - _writeFileSync(resolve(CACHE_DIR, 'token.txt'), AUTH_TOKEN, { mode: 0o600 }); - } catch { /* non-critical */ } - - // Recover stale sessions - try { - const recovery = recoverStaleSession(ROOT, CACHE_DIR); - if (recovery.recovered) console.log(' [Session] Recovered stale session'); - } catch (e) { console.error(' [Session] Recovery error:', e.message); } - - // Recover orphaned agent jobs (fleet-aware — also cleans stale worktrees) - try { - const orphans = recoverOrphans(CACHE_DIR, ROOT); - if (orphans.recovered > 0) console.log(` [Fleet] Recovered ${orphans.recovered} orphaned jobs`); - } catch (e) { console.error(' [Fleet] Orphan recovery error:', e.message); } - - // Load saved fleet config - try { - const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); - if (existsSync(prefsPath)) { - const prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); - if (prefs.maxConcurrent) setMaxConcurrent(prefs.maxConcurrent); - } - } catch { /* use defaults */ } - - // Prune old results - try { - const pruned = pruneResults(CACHE_DIR); - if (pruned.pruned > 0) console.log(` [Agent] Pruned ${pruned.pruned} old results`); - } catch (e) { /* silent */ } - - // Agent health check - try { - const health = healthCheck(); - if (health.available) { - console.log(` [Agent] Claude CLI available (${health.version})`); - } else { - console.log(' [Agent] Claude CLI not available — agent jobs disabled'); - } - } catch { /* silent */ } - - // Auto-process agent queue every 30 seconds (fleet-aware) - processInterval = setInterval(async () => { - try { - const { jobs } = getJobs(CACHE_DIR); - const hasQueued = jobs.some(j => j.status === 'queued'); - const activeCount = getActiveCount(); - if (hasQueued && activeCount < getMaxConcurrent()) { - console.log(` [Fleet] Auto-processing queue (${activeCount}/${getMaxConcurrent()} active)...`); - startDiffPoller(); - const result = await processQueue(ROOT, CACHE_DIR, - (jobId, chunk) => { - sse.broadcast({ type: 'job-output', jobId, chunk }); - const hints = parseActivityHints(chunk); - for (const hint of hints) { - sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); - } - }, - (jobId, message) => { - sse.broadcast({ type: 'job-warning', jobId, message }); - }, - ); - if (result.launched > 0) { - sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); - } - const { jobs: currentJobs } = getJobs(CACHE_DIR); - if (!currentJobs.some(j => j.status === 'running')) stopDiffPoller(); - } - } catch { /* silent */ } - }, 30000); - - console.log(' File watcher active — dashboard auto-refreshes on changes'); - console.log(` Fleet: max ${getMaxConcurrent()} concurrent agents, auto-processes every 30s\n`); -} - -server.listen(PORT, BIND_HOST, startup); - -process.on('SIGINT', () => { - if (processInterval) clearInterval(processInterval); - stopDiffPoller(); - watcher.close(); - server.close(); - process.exit(0); -}); - -// Exports for testing — token only available in test environment -export function getAuthToken() { - if (process.env.NODE_ENV !== 'test') throw new Error('Token access restricted to test environment'); - return AUTH_TOKEN; -} -export { isAuthorized, server, BIND_HOST }; diff --git a/tools/project-map/tests/agent-diff.test.mjs b/tools/project-map/tests/agent-diff.test.mjs deleted file mode 100644 index a0bfd813..00000000 --- a/tools/project-map/tests/agent-diff.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; -import { parseActivityHints, getJobDiff } from '../lib/agent-manager.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const GIT_TEST_DIR = resolve(__dirname, '.test-git-diff'); - -describe('parseActivityHints', () => { - it('extracts Go file paths', () => { - const chunk = 'Reading Server/ws/handler.go for analysis'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'Server/ws/handler.go'); - }); - - it('extracts TypeScript file paths', () => { - const chunk = 'Checking Client/tauri-client/src/stores/voice.ts'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'Client/tauri-client/src/stores/voice.ts'); - }); - - it('extracts Rust file paths', () => { - const chunk = 'Found issue in src-tauri/src/commands.rs'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'src-tauri/src/commands.rs'); - }); - - it('extracts multiple paths from one chunk', () => { - const chunk = 'Comparing Server/ws/handler.go with Server/ws/conn.go'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 2); - }); - - it('returns empty array when no paths found', () => { - const hints = parseActivityHints('Just some regular text here'); - assert.deepEqual(hints, []); - }); - - it('handles null/undefined input', () => { - assert.deepEqual(parseActivityHints(null), []); - assert.deepEqual(parseActivityHints(undefined), []); - assert.deepEqual(parseActivityHints(''), []); - }); -}); - -describe('getJobDiff', () => { - beforeEach(() => { - rmSync(GIT_TEST_DIR, { recursive: true, force: true }); - mkdirSync(GIT_TEST_DIR, { recursive: true }); - execSync('git init', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'original\n'); - execSync('git add . && git commit -m "init"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - }); - - afterEach(() => { - rmSync(GIT_TEST_DIR, { recursive: true, force: true }); - }); - - it('returns empty files when nothing changed', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - assert.deepEqual(result.files, []); - assert.deepEqual(result.diffs, {}); - }); - - it('detects modified files with +/- counts', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\nline2\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - assert.equal(result.files.length, 1); - assert.equal(result.files[0].path, 'file.txt'); - assert.equal(result.files[0].status, 'M'); - assert.ok(result.files[0].additions >= 1); - assert.ok(typeof result.diffs['file.txt'] === 'string'); - assert.ok(result.diffs['file.txt'].includes('modified')); - }); - - it('detects new files', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'brand new\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - const newFile = result.files.find(f => f.path === 'newfile.txt'); - assert.ok(newFile); - assert.equal(newFile.status, 'A'); - }); - - it('excludes pre-existing dirty files', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\n'); - writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'new\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, ['file.txt']); - assert.equal(result.files.length, 1); - assert.equal(result.files[0].path, 'newfile.txt'); - assert.ok(!result.diffs['file.txt']); - }); -}); diff --git a/tools/project-map/tests/agent-queue.test.mjs b/tools/project-map/tests/agent-queue.test.mjs deleted file mode 100644 index bf1b1845..00000000 --- a/tools/project-map/tests/agent-queue.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Tests for agent-manager.mjs — verifies that a spawn failure - * does not wedge the queue (lock must be released). - * - * Uses setSpawnCommand() to inject a guaranteed-nonexistent binary, - * making the test deterministic regardless of whether Claude CLI is installed. - */ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, rmSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - createJob, - processQueue, - getJobs, - isQueueLocked, - resetQueueLock, - setSpawnCommand, -} from '../lib/agent-manager.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEST_CACHE = resolve(__dirname, '.test-cache-agent'); -const FAKE_ROOT = resolve(__dirname, '.test-root-agent'); - -// A binary name that will never exist on any platform -const NONEXISTENT_CMD = '__owncord_test_no_such_binary_12345__'; - -describe('agent queue lock recovery', () => { - beforeEach(() => { - mkdirSync(TEST_CACHE, { recursive: true }); - mkdirSync(FAKE_ROOT, { recursive: true }); - resetQueueLock(); - setSpawnCommand(NONEXISTENT_CMD); - }); - - afterEach(() => { - resetQueueLock(); - setSpawnCommand('claude'); - rmSync(TEST_CACHE, { recursive: true, force: true }); - rmSync(FAKE_ROOT, { recursive: true, force: true }); - }); - - it('releases lock after spawn error (command not found)', async () => { - createJob(TEST_CACHE, { - type: 'research', - target: 'api', - priority: 1, - }); - - assert.equal(isQueueLocked(), false, 'lock should be free before processing'); - - // processQueue spawns the nonexistent command which will ENOENT - const result = await processQueue(FAKE_ROOT, TEST_CACHE); - - assert.equal(result.processed, true, 'should have attempted processing'); - assert.equal(isQueueLocked(), false, 'lock MUST be released after spawn failure'); - }); - - it('allows subsequent jobs after a failed spawn', async () => { - createJob(TEST_CACHE, { type: 'research', target: 'ws', priority: 1 }); - - // First job will fail due to nonexistent command - await processQueue(FAKE_ROOT, TEST_CACHE); - assert.equal(isQueueLocked(), false, 'lock should be free after first failure'); - - // Create and process another job — should not be blocked - createJob(TEST_CACHE, { type: 'code-review', target: 'auth', priority: 1 }); - const result2 = await processQueue(FAKE_ROOT, TEST_CACHE); - - // It should attempt processing (not be stuck on 'locked') - assert.notEqual(result2.reason, 'locked', 'queue should not be wedged'); - }); - - it('marks job as dead after max retries', async () => { - createJob(TEST_CACHE, { type: 'research', target: 'db', priority: 1 }); - - // Exhaust retries (maxRetries defaults to 2) - await processQueue(FAKE_ROOT, TEST_CACHE); - await processQueue(FAKE_ROOT, TEST_CACHE); - - const { jobs } = getJobs(TEST_CACHE); - const job = jobs.find(j => j.target === 'db'); - assert.ok(job, 'job should still exist in queue'); - assert.equal(job.status, 'dead', 'job should be dead after max retries'); - assert.ok(job.error, 'job should have an error message'); - }); -}); diff --git a/tools/project-map/tests/backlog-parser.test.mjs b/tools/project-map/tests/backlog-parser.test.mjs deleted file mode 100644 index 471e4c06..00000000 --- a/tools/project-map/tests/backlog-parser.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Tests for backlog-parser.mjs — verifies both task syntaxes are parsed - * and open/done counts match. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync, existsSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../../..'); - -// Direct regex test (same regex the parser uses) -const TASK_RE = /^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/; - -describe('backlog parser regex', () => { - it('matches colon-inside-bold format: **T-165:**', () => { - const line = '- [x] **T-165:** Fix BUG-046 — wrap voice switchActiveDevice — 2026-03-28'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], 'x'); - assert.equal(m[2], '165'); - assert.ok(m[3].startsWith('Fix BUG-046')); - }); - - it('matches colon-after-bold format: **T-033**:', () => { - const line = '- [x] **T-033**: Fix voice state broadcast silent DB failures — 2026-03-21'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], 'x'); - assert.equal(m[2], '033'); - assert.ok(m[3].startsWith('Fix voice state')); - }); - - it('matches open tasks (unchecked)', () => { - const line = '- [ ] **T-195:** User profile/password/session management endpoints'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], ' '); - assert.equal(m[2], '195'); - }); - - it('does not match non-task lines', () => { - assert.equal('## Some Section'.match(TASK_RE), null); - assert.equal('- Regular bullet point'.match(TASK_RE), null); - assert.equal('- [ ] No bold task id here'.match(TASK_RE), null); - }); -}); - -describe('backlog parser integration', () => { - it('parses actual Backlog.md and counts match', async () => { - const { parseBacklog } = await import('../lib/backlog-parser.mjs'); - const result = await parseBacklog(ROOT); - - // Read the file directly and count with both regexes - const backlogPath = resolve(ROOT, 'docs/brain/02-Tasks/Backlog.md'); - if (!existsSync(backlogPath)) { - // Skip if file doesn't exist in CI - return; - } - const content = readFileSync(backlogPath, 'utf8'); - const lines = content.split('\n'); - - let expectedOpen = 0; - let expectedDone = 0; - for (const line of lines) { - const m = line.match(TASK_RE); - if (m) { - if (m[1] === 'x') expectedDone++; - else expectedOpen++; - } - } - - assert.equal(result.openCount, expectedOpen, `open count mismatch: got ${result.openCount}, expected ${expectedOpen}`); - assert.equal(result.doneCount, expectedDone, `done count mismatch: got ${result.doneCount}, expected ${expectedDone}`); - assert.ok(result.tasks.length > 0, 'should find tasks'); - assert.equal(result.tasks.length, expectedOpen + expectedDone, 'total tasks should match'); - }); -}); diff --git a/tools/project-map/tests/briefing-and-normalization.test.mjs b/tools/project-map/tests/briefing-and-normalization.test.mjs deleted file mode 100644 index 97268b1b..00000000 --- a/tools/project-map/tests/briefing-and-normalization.test.mjs +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Behavioral tests for: - * 1. Root-level Server/*.go files normalize to 'server-root' - * 2. Unauthenticated /api/briefing does not leak agent job data - */ -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; - -// --------------------------------------------------------------------------- -// 1. Root package normalization — behavioral tests calling the real functions -// --------------------------------------------------------------------------- - -describe('root package normalization', () => { - let resolveModule, fileToModule; - - before(async () => { - ({ resolveModule } = await import('../lib/git-scanner.mjs')); - ({ fileToModule } = await import('../lib/session-manager.mjs')); - }); - - const rootFiles = ['Server/main.go', 'Server/go.mod', 'Server/go.sum', 'Server/Makefile']; - const subDirFiles = [ - { path: 'Server/ws/handler.go', expected: 'ws' }, - { path: 'Server/db/models.go', expected: 'db' }, - { path: 'Server/auth/middleware.go', expected: 'auth' }, - ]; - - for (const filePath of rootFiles) { - it(`git-scanner: ${filePath} → server-root`, () => { - assert.equal(resolveModule(filePath), 'server-root'); - }); - - it(`session-manager: ${filePath} → server-root`, () => { - assert.equal(fileToModule(filePath), 'server-root'); - }); - } - - for (const { path, expected } of subDirFiles) { - it(`git-scanner: ${path} → ${expected}`, () => { - assert.equal(resolveModule(path), expected); - }); - - it(`session-manager: ${path} → ${expected}`, () => { - assert.equal(fileToModule(path), expected); - }); - } - - it('git-scanner and session-manager agree on all test paths', () => { - const all = [...rootFiles, ...subDirFiles.map(s => s.path)]; - for (const p of all) { - assert.equal(resolveModule(p), fileToModule(p), - `mismatch on ${p}: git-scanner=${resolveModule(p)}, session-manager=${fileToModule(p)}`); - } - }); -}); - -// --------------------------------------------------------------------------- -// 2. Briefing leak — real HTTP test against the server -// --------------------------------------------------------------------------- - -describe('/api/briefing unauthenticated leak prevention', () => { - let generateBriefing; - - before(async () => { - ({ generateBriefing } = await import('../lib/morning-briefing.mjs')); - }); - - it('generateBriefing with agentJobs=null produces no job data', async () => { - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: null, - }); - assert.deepEqual(briefing.agentResults, [], 'agentResults should be empty'); - assert.deepEqual(briefing.deadJobs, [], 'deadJobs should be empty'); - assert.deepEqual(briefing.autoQueueSuggestions, [], 'autoQueueSuggestions should be empty when agentJobs is null'); - assert.equal(briefing.stats.completedJobs, 0); - assert.equal(briefing.stats.deadJobs, 0); - }); - - it('generateBriefing with agentJobs includes job data', async () => { - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: fakeJobs, - }); - assert.ok(briefing.agentResults.length > 0, 'should have agent results'); - assert.ok(briefing.deadJobs.length > 0, 'should have dead jobs'); - assert.ok(briefing.agentResults[0].id === 'j-1', 'should contain job id'); - }); - - it('server strips job fields from unauthenticated briefing response', async () => { - // Read server source to verify the stripping logic - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - - // The /api/briefing route must check auth and strip fields - const briefingRoute = source.slice( - source.indexOf("url.pathname === '/api/briefing'"), - source.indexOf("url.pathname === '/api/briefing'") + 800 - ); - assert.ok(briefingRoute.includes('isAuthorized(req)'), 'briefing route should check auth'); - assert.ok(briefingRoute.includes('agentJobs: authenticated'), 'should only pass agentJobs when authenticated'); - assert.ok(briefingRoute.includes('agentResults'), 'should strip agentResults'); - assert.ok(briefingRoute.includes('deadJobs'), 'should strip deadJobs'); - assert.ok(briefingRoute.includes('autoQueueSuggestions'), 'should strip autoQueueSuggestions'); - assert.ok(briefingRoute.includes('stats'), 'should strip stats'); - }); - - it('HTTP: unauthenticated briefing has no job fields', async () => { - // Spin up a minimal test server that mimics the briefing route logic - const { generateBriefing } = await import('../lib/morning-briefing.mjs'); - - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - - const TOKEN = 'test-secret-token'; - - const srv = createServer(async (req, res) => { - const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: authenticated ? fakeJobs : null, - }); - let payload; - if (!authenticated) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...pub } = briefing; - payload = pub; - } else { - payload = briefing; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - }); - - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - try { - // Unauthenticated request - const unauthRes = await fetch(`http://127.0.0.1:${port}/api/briefing`); - const unauthData = await unauthRes.json(); - - assert.equal(unauthData.agentResults, undefined, 'agentResults must not be present'); - assert.equal(unauthData.deadJobs, undefined, 'deadJobs must not be present'); - assert.equal(unauthData.stats, undefined, 'stats must not be present'); - assert.equal(unauthData.autoQueueSuggestions, undefined, 'autoQueueSuggestions must not be present'); - assert.ok(unauthData.greeting, 'greeting should still be present'); - assert.ok(unauthData.suggestedTasks !== undefined, 'suggestedTasks should still be present'); - - // Authenticated request — should have all fields - const authRes = await fetch(`http://127.0.0.1:${port}/api/briefing`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - const authData = await authRes.json(); - - assert.ok(Array.isArray(authData.agentResults), 'authenticated should have agentResults'); - assert.ok(Array.isArray(authData.deadJobs), 'authenticated should have deadJobs'); - assert.ok(authData.stats, 'authenticated should have stats'); - assert.ok(authData.agentResults.length > 0, 'authenticated should have job data'); - } finally { - srv.close(); - } - }); - - it('HTTP: unauthenticated /api/data strips job fields from embedded briefing', async () => { - const { generateBriefing } = await import('../lib/morning-briefing.mjs'); - - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - - const TOKEN = 'test-data-token'; - - // Simulate the /api/data route logic with an embedded briefing - const srv = createServer(async (req, res) => { - const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; - - // Build a cachedData-like object with briefing containing job data - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: fakeJobs, - }); - const cachedData = { modules: [], agentJobs: fakeJobs, briefing, timestamp: new Date().toISOString() }; - - let payload; - if (!authenticated) { - const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; - if (fullBriefing) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; - publicData.briefing = publicBriefing; - } - payload = publicData; - } else { - payload = cachedData; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - }); - - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - try { - // Unauthenticated — briefing should be sanitized - const unauthRes = await fetch(`http://127.0.0.1:${port}/api/data`); - const unauthData = await unauthRes.json(); - - assert.equal(unauthData.agentJobs, undefined, 'agentJobs must not be present'); - assert.ok(unauthData.briefing, 'briefing should still exist'); - assert.equal(unauthData.briefing.agentResults, undefined, 'briefing.agentResults must not be present'); - assert.equal(unauthData.briefing.deadJobs, undefined, 'briefing.deadJobs must not be present'); - assert.equal(unauthData.briefing.stats, undefined, 'briefing.stats must not be present'); - assert.equal(unauthData.briefing.autoQueueSuggestions, undefined, 'briefing.autoQueueSuggestions must not be present'); - assert.ok(unauthData.briefing.greeting, 'briefing.greeting should remain'); - - // Authenticated — full data - const authRes = await fetch(`http://127.0.0.1:${port}/api/data`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - const authData = await authRes.json(); - - assert.ok(authData.agentJobs, 'authenticated should have agentJobs'); - assert.ok(authData.briefing.agentResults, 'authenticated briefing should have agentResults'); - assert.ok(authData.briefing.deadJobs, 'authenticated briefing should have deadJobs'); - assert.ok(authData.briefing.stats, 'authenticated briefing should have stats'); - } finally { - srv.close(); - } - }); -}); diff --git a/tools/project-map/tests/cache-mode.test.mjs b/tools/project-map/tests/cache-mode.test.mjs deleted file mode 100644 index 3cd644c1..00000000 --- a/tools/project-map/tests/cache-mode.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Tests for server cache behavior — verifies that quick-mode cached data - * is not incorrectly reused for full-mode requests. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -describe('server cache mode awareness', () => { - it('source code has mode-aware cache logic', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - - // Should track cache mode separately - assert.ok(source.includes('cachedDataMode'), 'should have cachedDataMode variable'); - - // The /api/data handler should compare requested mode with cached mode - assert.ok(source.includes('requestedMode'), 'should compute requestedMode'); - - // Quick cache should not be valid for full requests - assert.ok( - source.includes('cachedDataMode === requestedMode'), - 'should compare cached mode with requested mode' - ); - }); - - it('cache logic prevents quick data from serving full requests', () => { - // Simulate the cache validation logic extracted from server.mjs - function isCacheValid(cachedData, cachedDataMode, requestedMode, hasRefresh) { - const cacheValid = cachedData && cachedDataMode === requestedMode && !hasRefresh; - const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !hasRefresh; - return cacheValid || fullCacheForQuick; - } - - const data = { some: 'data' }; - - // Quick cache should serve quick requests - assert.ok(isCacheValid(data, 'quick', 'quick', false), 'quick cache serves quick request'); - - // Quick cache should NOT serve full requests - assert.ok(!isCacheValid(data, 'quick', 'full', false), 'quick cache must NOT serve full request'); - - // Full cache should serve full requests - assert.ok(isCacheValid(data, 'full', 'full', false), 'full cache serves full request'); - - // Full cache should serve quick requests (superset) - assert.ok(isCacheValid(data, 'full', 'quick', false), 'full cache serves quick request'); - - // No cached data - assert.ok(!isCacheValid(null, null, 'quick', false), 'no cache is not valid'); - - // Refresh flag forces re-fetch - assert.ok(!isCacheValid(data, 'quick', 'quick', true), 'refresh flag invalidates cache'); - }); -}); diff --git a/tools/project-map/tests/module-naming.test.mjs b/tools/project-map/tests/module-naming.test.mjs deleted file mode 100644 index 887151a6..00000000 --- a/tools/project-map/tests/module-naming.test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Tests for module name normalization — verifies that git-scanner, - * scanner, backlog-parser, and debt-scanner all produce the same - * canonical module keys so signals merge correctly. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -// Import the resolveModule function from git-scanner by reading the source -// and extracting the function (git-scanner doesn't export it). -// Instead, we test the expected behavior by checking the patterns. -describe('module naming consistency', () => { - // These are the canonical module names used by scanner.mjs: - // Go packages: 'api', 'ws', 'db', 'auth', 'config', 'admin', etc. - // TS areas: 'lib', 'stores', 'components', 'pages', 'styles' - // Rust: 'tauri-rust' - - // git-scanner resolveModule should produce the SAME names (no prefixes). - // We test this by importing the git-scanner module and checking its output. - - it('git-scanner resolveModule produces unprefixed Go module names', async () => { - // Read the git-scanner source and check that resolveModule for Server paths - // no longer returns "go:" prefixed names - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); - - // Ensure "go:" prefix is NOT used in resolveModule - const resolveModuleFn = source.match(/function resolveModule\([\s\S]*?\n\}/); - assert.ok(resolveModuleFn, 'should find resolveModule function'); - - const fnBody = resolveModuleFn[0]; - assert.ok(!fnBody.includes('`go:'), 'resolveModule should not produce go: prefixed names'); - assert.ok(!fnBody.includes('`ts:'), 'resolveModule should not produce ts: prefixed names'); - assert.ok(!fnBody.includes("'go:"), 'resolveModule should not produce go: prefixed names (single quotes)'); - assert.ok(!fnBody.includes("'ts:"), 'resolveModule should not produce ts: prefixed names (single quotes)'); - }); - - it('session-manager fileToModule matches git-scanner convention', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const sessionSource = readFileSync(resolve(__dirname, '../lib/session-manager.mjs'), 'utf8'); - const gitSource = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); - - // Both should use 'server-root' for Server/ root, not 'go:root' - assert.ok(sessionSource.includes("'server-root'"), 'session-manager should use server-root'); - assert.ok(gitSource.includes("'server-root'"), 'git-scanner should use server-root'); - - // Both should use 'client-config' not 'ts:config' - assert.ok(sessionSource.includes("'client-config'"), 'session-manager should use client-config'); - assert.ok(gitSource.includes("'client-config'"), 'git-scanner should use client-config'); - }); - - it('backlog and scanner module names have no type prefixes', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const backlogSource = readFileSync(resolve(__dirname, '../lib/backlog-parser.mjs'), 'utf8'); - - // Backlog MODULE_KEYWORDS keys should be plain names - const keyMatch = backlogSource.match(/const MODULE_KEYWORDS = \{([\s\S]*?)\n\};/); - assert.ok(keyMatch, 'should find MODULE_KEYWORDS'); - - // Ensure no prefixed keys - assert.ok(!keyMatch[1].includes("'go:"), 'backlog keywords should not have go: prefix'); - assert.ok(!keyMatch[1].includes("'ts:"), 'backlog keywords should not have ts: prefix'); - }); - - it('suggestion engine merges modules from different sources under same key', async () => { - const { generateSuggestions } = await import('../lib/suggestion-engine.mjs'); - const { mkdirSync, rmSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const tmpCache = resolve(__dirname, '.test-cache-naming'); - mkdirSync(tmpCache, { recursive: true }); - - try { - // Simulate data from different sources all using 'components' (not 'ts:components') - const result = await generateSuggestions(tmpCache, { - priorities: [{ name: 'components', coverage: 40, type: 'typescript' }], - backlog: { - byPhase: { - bug: [{ id: 'T-001', description: 'UI button bug', modules: ['components'] }], - }, - }, - gitData: { - commitsByModule: { - components: { count: 15, lastCommit: new Date().toISOString() }, - }, - staleness: {}, - }, - debtData: { - summary: { - byModule: { - components: { markers: 3, largeFiles: 1, longFunctions: 0 }, - }, - }, - }, - }); - - // All signals should merge into a single 'components' entry - const compSuggestion = result.suggestions.find(s => s.module === 'components'); - assert.ok(compSuggestion, 'should have a components suggestion'); - - // It should have signals from coverage, bugs, git, AND debt - const signalTypes = new Set(compSuggestion.signals.map(s => s.signal)); - assert.ok(signalTypes.has('coverage-gap'), 'should have coverage signal'); - assert.ok(signalTypes.has('open-bug'), 'should have bug signal'); - assert.ok(signalTypes.has('high-churn'), 'should have churn signal'); - assert.ok(signalTypes.has('debt-markers'), 'should have debt signal'); - - // Verify NO 'ts:components' entry exists - const prefixed = result.suggestions.find(s => s.module === 'ts:components'); - assert.equal(prefixed, undefined, 'should NOT have ts:components — should be merged into components'); - } finally { - rmSync(tmpCache, { recursive: true, force: true }); - } - }); -}); diff --git a/tools/project-map/tests/server-auth.test.mjs b/tools/project-map/tests/server-auth.test.mjs deleted file mode 100644 index 036ddc20..00000000 --- a/tools/project-map/tests/server-auth.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Tests for server.mjs — verifies that all mutating / job / session - * endpoints require auth and that the server binds to localhost. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -describe('server security configuration', () => { - // Helper — read server source once per test (stateless, no port conflict) - async function readServerSource() { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const __dirname = dirname(fileURLToPath(import.meta.url)); - return readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - } - - it('binds to localhost by default', async () => { - const source = await readServerSource(); - assert.ok(source.includes("'127.0.0.1'"), 'default bind host should be 127.0.0.1'); - assert.ok(source.includes('BIND_HOST'), 'should use BIND_HOST variable'); - assert.ok(source.includes('server.listen(PORT, BIND_HOST'), 'server.listen should include host parameter'); - }); - - it('does not use wildcard CORS', async () => { - const source = await readServerSource(); - const corsLines = source.split('\n').filter(l => l.includes('Access-Control-Allow-Origin')); - for (const line of corsLines) { - assert.ok(!line.includes("'*'"), `CORS should not be wildcard: ${line.trim()}`); - } - }); - - it('generates an auth token on startup', async () => { - const source = await readServerSource(); - assert.ok(source.includes('randomBytes'), 'should use crypto.randomBytes for token generation'); - assert.ok(source.includes('AUTH_TOKEN'), 'should define AUTH_TOKEN'); - assert.ok(source.includes('Bearer'), 'should use Bearer token scheme'); - }); - - // ---- Protected route checks ---- - - const protectedRoutes = [ - { label: 'POST /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'POST'" }, - { label: 'DELETE /api/jobs/:id', pattern: "deleteMatch && req.method === 'DELETE'" }, - { label: 'GET /api/jobs/:id/result', pattern: "resultMatch && req.method === 'GET'" }, - { label: 'POST /api/jobs/process', pattern: "url.pathname === '/api/jobs/process'" }, - { label: 'GET /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'GET'" }, - { label: 'POST /api/worked-on', pattern: "url.pathname === '/api/worked-on'" }, - { label: 'POST /api/strategy', pattern: "url.pathname === '/api/strategy'" }, - { label: 'POST /api/session/start', pattern: "url.pathname === '/api/session/start'" }, - { label: 'POST /api/session/end', pattern: "url.pathname === '/api/session/end'" }, - ]; - - for (const { label, pattern } of protectedRoutes) { - it(`protects ${label} with requireAuth`, async () => { - const source = await readServerSource(); - const idx = source.indexOf(pattern); - assert.ok(idx !== -1, `should have handler for ${label}`); - const routeBlock = source.slice(idx, idx + 300); - assert.ok(routeBlock.includes('requireAuth'), `${label} should call requireAuth`); - }); - } - - it('strips agentJobs and briefing job fields from unauthenticated /api/data responses', async () => { - const source = await readServerSource(); - // The /api/data handler should check isAuthorized and strip agentJobs - assert.ok(source.includes('isAuthorized(req)'), '/api/data should check isAuthorized'); - assert.ok(source.includes('agentJobs: _stripped'), 'should destructure out agentJobs for public response'); - // Should also strip job-derived fields from the embedded briefing - assert.ok(source.includes('briefing: fullBriefing'), 'should destructure out briefing for sanitization'); - assert.ok(source.includes('publicBriefing'), 'should rebuild a public briefing without job fields'); - }); -}); From 21816f52e74bc6ceb21ab5585eb222995e689c91 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:55:24 +0200 Subject: [PATCH 03/29] docs(plans): remediation plan for security-hardening review regressions Adds docs/plans/security-hardening-remediation.md capturing the code-review findings against fix/security-hardening-review and sequencing the fixes into three waves (availability/backend-breaking, behavioral regressions, cleanup). Each item names root cause, right-altitude fix, files, and verification. Co-Authored-By: Claude Opus 4.8 --- docs/plans/security-hardening-remediation.md | 285 +++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/plans/security-hardening-remediation.md diff --git a/docs/plans/security-hardening-remediation.md b/docs/plans/security-hardening-remediation.md new file mode 100644 index 00000000..405290f0 --- /dev/null +++ b/docs/plans/security-hardening-remediation.md @@ -0,0 +1,285 @@ +# Plan: Remediate security-hardening review regressions + +**Status:** design only, not implemented +**Owner:** TBD +**Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17) +**Estimated effort:** 2–4 focused days + +## Why + +The `fix/security-hardening-review` branch lands a broad, well-intentioned +security pass (login timing fix, TOTP TOCTOU, X-Forwarded-For spoofing, +LiveKit webhook signature binding, plugin SSRF/DNS-rebinding, WASM CPU +budgets, attachment IDOR, ban authorization, WAF chunked-body inspection, +update-binary re-verification, and several rate limits). A recall-oriented +code review confirmed that most of it is sound, but that several of the +hardening changes introduced new correctness/availability regressions — +verified against the source, not just the diff. + +This plan sequences the fixes. It is ordered by severity: availability- and +backend-breaking bugs first, then behavioral regressions, then cleanup. Each +item names the root cause, the fix at the right altitude, the files, and the +verification that must pass. Every new fix ships with tests — several of the +regressions below slipped through precisely because the new security code had +no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow"). + +## Non-goals + +- Not a redesign of the plugin runtime, the rate limiter, or the permission + model — each fix stays local and generalizes only where a bandaid would + otherwise recur. +- Not reverting the hardening. The security intent of every change is kept; + only the regressions are corrected. +- Not implementing the Postgres backend beyond what is needed to stop the + attachment path from hard-erroring (see W1-3). + +## Wave 1 — Availability & backend-breaking (HIGH) + +### W1-1. Plugin CPU budget must not permanently brick the module +- **File:** `Server/plugin/sandbox_wazero.go` (~line 224, `invokeCommand`) +- **Root cause:** the runtime is built `WithCloseOnContextDone(true)` + (line 72). The new per-call `context.WithTimeout` wraps `allocate`, + `command_dispatch`, and `deallocate`, so an expired deadline *closes the + module*. `inst.module` is only cleared by `platformDeactivate`, so nothing + re-instantiates it — one over-budget command bricks the plugin for all + users until admin disable/enable or restart. The budget is wall-clock, + floored at 100 ms, so any host HTTP call (`httpTimeout` = 10 s) trips it. +- **Fix:** after a `context.DeadlineExceeded` overrun, mark the instance so + the next dispatch re-instantiates the module (re-run `activateWithRuntime` + lazily), or reset `inst.module = nil` and re-activate on demand. Separately, + scope the deadline to the guest CPU call only — do **not** count host-call + time (host functions should run under the parent ctx, or the budget clock + should pause during host calls). Reconsider the floor so legitimate work + isn't killed. +- **Verify:** new test (build tag `wazero`) that (a) a command exceeding the + budget returns the budget error *and* a subsequent command on the same + plugin still succeeds; (b) a command performing a host HTTP call within + `httpTimeout` is not killed by the CPU budget. + +### W1-2. E2EE key rotation drops peers in 7+ participant calls +- **Files:** `Server/ws/voice_e2ee.go` (~line 151); + `Client/tauri-client/src/lib/livekitSession.ts` (~lines 1317-1349) +- **Root cause:** the `voice_e2ee_offer` limit is 5/sec, but the key holder + loops over every peer sending one offer each, back-to-back, with no pacing + and no retry on `RATE_LIMITED`. With 6+ peers, offers to the 6th+ peer are + rejected and silently dropped — those peers never get the rotated key and + their audio never decrypts. Fires on join/leave and the 5-minute periodic + rotation. +- **Fix (choose one, prefer server-side):** + - Server: exempt the fan-out relay from the tight per-message cap — rate + limit the *rotation event* (one budget per rotation) rather than each + per-peer offer; or scale the limit to channel size. + - Client: add bounded pacing + retry/backoff on `RATE_LIMITED` so all peers + eventually receive the offer. + - Preferred: server distinguishes "offer burst that belongs to one + rotation" from spam, so a single user still can't spam unrelated offers. +- **Verify:** integration test with 8 voice participants confirming every peer + receives the rotated key after a join/leave and after a periodic rotation. + +### W1-3. Attachment-ownership check breaks Postgres and isn't atomic +- **Files:** `Server/service/message.go` (~line 183); + `Server/db/queries/*attachment*.sql` + regenerate `dbgen`/`pgdbgen`; + `Server/store/postgres.go`, `Server/store/sqlite.go` +- **Root cause:** the new per-attachment `GetAttachmentByID` loop (a) calls a + method that returns `ErrPostgresNotImplemented` on `PostgresStore` + (`postgres.go:498`), so every attachment-bearing `SendMessage` hard-errors + on Postgres; (b) is a check-then-link TOCTOU (the race pattern this PR fixes + elsewhere); (c) is an N+1 query on the hot send path; (d) rejects legacy + `uploader_id IS NULL` rows and already-linked attachments on retry (W2-4). +- **Fix (right altitude):** delete the loop. Enforce ownership atomically in + the shared link query — add `AND uploader_id = ?` (and keep + `AND message_id IS NULL`) to `LinkAttachmentsToMessage`, and return + `ErrForbidden` when `RowsAffected != len(AttachmentIDs)`. Edit the SQL in + `Server/db/queries/` and run `make sqlc-generate` (never hand-edit `dbgen`/ + `pgdbgen`); update the SQLite + Postgres migrations as a pair. This makes + the check atomic, one query, and backend-agnostic, and removes the need for + the `MemStore.GetAttachmentByID` `(nil,nil)` contortion. +- **Verify:** service tests (SQLite *and* a Postgres path or a store fake that + implements the link semantics) covering: own unlinked attachment links; + another user's attachment is refused; nonexistent id is skipped; already + linked id is refused; `RowsAffected` mismatch → no message persisted. + +### W1-4. Ban authorization guards dead code +- **Files:** `Server/admin/handlers_users.go` (`handlePatchUser`, ~line 112); + `Server/service/moderation.go` +- **Root cause:** `requireBanAuthority` (BAN_MEMBERS + role hierarchy) is + wired only into `ModerationService.BanUser`/`UnbanUser`, which have no + production callers. The live ban path is `handlePatchUser`, which runs + `UPDATE users SET banned = 1 ...` directly with no BAN_MEMBERS/hierarchy + check, so any admin-panel actor can ban an equal- or higher-ranked user + (including the owner). +- **Fix:** route `handlePatchUser`'s ban/unban branch through + `ModerationService.BanUser`/`UnbanUser` (so the new authorization actually + runs), or lift `requireBanAuthority` into the handler. Keep the + admin-IP/admin-auth perimeter; add the permission + hierarchy check on top. + Move the target-existence check *after* authorization so a caller without + BAN_MEMBERS can't enumerate user ids via NotFound-vs-Forbidden. +- **Verify:** handler test — actor without BAN_MEMBERS is refused; actor of + equal/lower rank than target is refused; owner-rank target can't be banned + by a lower rank; authorized actor succeeds. + +## Wave 2 — Behavioral regressions (MED-HIGH → MED) + +### W2-1. Client-update rate limiter shares the auth bucket +- **File:** `Server/api/router.go` (~line 257) +- **Root cause:** it uses the empty-prefix `RateLimitMiddleware` on the shared + `limiter`, colliding per-IP with `verifyTOTP`, the sensitive endpoints, and + profile/password. A 30/min auto-poll drains the shared per-IP budget → + spurious 429s on 2FA and password change. +- **Fix:** give the client-update route a dedicated key prefix via + `rateLimitMiddlewareWithPrefix(limiter, "client_update:", ...)`, mirroring + the LiveKit proxy's `"livekit_proxy:"` prefix (`router.go:192`). +- **Verify:** test that hammering `/client-update` to its limit does not 429 a + subsequent `verify-totp`/password request from the same IP. + +### W2-2. ChangePassword reports failure after the password is committed +- **Files:** `Server/service/user.go` (~line 60); caller + `Server/api/profile_handler.go` (~line 231) +- **Root cause:** `UpdateUserPassword` commits first; if `DeleteOtherSessions` + then errors, the function returns `ErrInternal`, the caller emits 500, and + the `password_change` audit entry is skipped. The user is told it failed + while the new password is live; retrying with the old password fails and can + trip the password-confirm lockout. +- **Fix:** treat session-revocation failure as a partial success, not a total + failure. Log + audit the password change, return success with a + `sessions_revoked`/warning signal the client can surface, or perform a + bounded compensating retry of `DeleteOtherSessions`. Do not report a 5xx for + an already-committed change; always write the audit row. +- **Verify:** test that when `DeleteOtherSessions` errors, the audit row is + still written and the handler does not return a 5xx that implies the + password is unchanged. + +### W2-3. Plugin activation via RegisterCommand breaks in-place upgrades +- **Files:** `Server/plugin/sandbox_wazero.go` (~line 140); + `Server/plugin/host_commands.go` (~line 36); + `Server/plugin/registry.go` (`installFromDisk`, `InstallFromZip`) +- **Root cause:** `RegisterCommand` refuses when `existing != inst` by + *pointer*, but `installFromDisk` replaces `r.plugins[id]`/`r.byName` with a + fresh `*Instance` without clearing the old command bindings. Re-installing an + enabled plugin leaves stale bindings that block re-registration; dispatch + keeps routing to the orphaned old module until restart. The old + `r.commands[cmd] = inst` overwrote unconditionally. +- **Fix:** compare ownership by plugin identity (name/id), not instance + pointer — allow the same plugin to re-bind its own command — and/or clear a + plugin's stale command bindings during reinstall/deactivation before + re-activation. Preserve the cross-plugin hijack protection (a *different* + plugin still can't claim an owned command). +- **Verify:** test that upgrading an enabled plugin in place rebinds its + commands and dispatch routes to the new module; a different plugin claiming + an owned command is still refused. + +### W2-4. Attachment check rejects legit retries and legacy uploads +- **File:** `Server/service/message.go` (~line 191) +- **Root cause:** `att.MessageID != nil → ErrForbidden` means a client retry of + a send whose first attempt already linked the attachment can never succeed; + the same branch rejects legacy `uploader_id IS NULL` rows. +- **Fix:** subsumed by W1-3 — the atomic link UPDATE skips already-linked/ + non-owned rows instead of failing the whole send. Decide explicit policy for + legacy NULL-uploader rows (migrate/backfill vs. treat as unowned). +- **Verify:** covered by W1-3 tests (already-linked id → skipped, not fatal). + +### W2-5. XFF right-to-left walk collapses/spoofs on broad trusted CIDRs +- **File:** `Server/api/middleware.go` (~line 227, `clientIPWithProxies`) +- **Root cause:** the walk skips every entry inside `trustedCIDRs`. With a + broad config (e.g. `trusted_proxies: 10.0.0.0/8` covering LAN clients), the + real client entry is skipped, the loop exhausts, and it falls back to the + proxy's own `RemoteAddr` — collapsing all clients into one lockout bucket + (one user's failed logins lock out everyone), or letting a client at a + trusted IP forge the key. +- **Fix:** when the walk exhausts without a non-trusted candidate, return the + left-most *valid* XFF entry (the furthest-upstream client) rather than + `RemoteAddr`, so distinct clients keep distinct keys. Document that + `trusted_proxies` should list only proxy hops, and validate config on + startup. Pre-parse `trustedCIDRs` into `[]*net.IPNet` once (see W3-3). +- **Verify:** test with `trusted_proxies=10.0.0.0/8`, proxy at `10.0.0.2`, + two LAN clients `10.5.1.7` / `10.5.1.8` behind it → distinct keys; a spoofed + leftmost entry from an untrusted RemoteAddr is ignored. + +### W2-6. SSRF-hardened dialer loses multi-address fallback +- **File:** `Server/plugin/host_http.go` (~line 115) +- **Root cause:** after validating every resolved IP, it dials only `ips[0]`, + dropping Happy-Eyeballs/next-record fallback. An allowlisted dual-stack or + round-robin host whose first record is down now hard-fails. +- **Fix:** loop over the vetted IPs and try each until one connects, keeping + the "dial only vetted concrete IPs" property. Also remove the redundant + `rejectPrivateAddrs` pre-resolve at line 68 (keep the cheap `hostAllowed` + allowlist) since the dial-time resolve+validate is authoritative — saves a + second DNS round trip (W3-2). +- **Verify:** test that a host resolving to `[unreachable, reachable]` still + connects via the reachable vetted address; a host resolving to a private IP + is still refused. + +### W2-7. Plugin-broadcast gate omits the block check +- **Files:** `Server/ws/handlers_command.go` (~line 107); + `Server/permissions/checker.go` (~line 79) +- **Root cause:** `requireChannelBroadcastAccess` routes through + `RequireChannelAccess`, whose DM branch checks only participant membership, + while the real send path (`checkSendPermission`) also enforces + `IsEitherBlocked`. A blocked user's plugin broadcast can reach the person who + blocked them. It also issues a raw `GetRoleByID` per broadcast, bypassing the + service-layer permission cache. +- **Fix:** route the broadcast gate through the shared service-layer send check + (`MessageService.checkSendPermission` or an extracted equivalent) so DM + block, slow-mode, and future policy apply uniformly and the perm cache is + used. Avoids a fourth hand-rolled permission helper on `Hub`. +- **Verify:** test that a blocked user's plugin broadcast into a DM is refused. + +## Wave 3 — Cleanup, efficiency, hardening depth (LOW-MED) + +### W3-1. Updater text-asset cache: add coalescing + negative caching +- **File:** `Server/updater/updater.go` (~line 729, `FetchTextAssetCached`) +- **Fix:** guard refresh with `golang.org/x/sync/singleflight` so a TTL-expiry + burst issues one outbound fetch; briefly cache errors so an upstream outage + doesn't re-fetch on every request. Evict superseded keys. +- **Verify:** concurrent cold-cache test issues exactly one upstream fetch. + +### W3-2. De-duplicate the update binary hashing +- **File:** `Server/admin/update_handlers.go` (~line 166, `fileSHA256`) +- **Fix:** `fileSHA256` duplicates `updater.VerifyChecksum`'s hashing body and + re-reads the just-verified binary. Export one hashing helper from the updater + package (or have `DownloadAndVerify` return the checksum it already computed) + and reuse it for the TOCTOU snapshot. + +### W3-3. Update TOCTOU guard depth + XFF CIDR pre-parsing +- **Files:** `Server/admin/update_handlers.go` (~line 117); + `Server/api/middleware.go` (`isTrustedProxy`) +- **Fix:** the re-verify narrows but does not close the swap window (verify by + path, then rename+spawn by path). Real closure needs fd-based verification or + `O_EXCL` staging in the updater package. Separately, `isTrustedProxy` + re-parses every CIDR string per call on the request hot path — pre-parse into + `[]*net.IPNet` once at middleware construction. + +### W3-4. Cache-Control header contradiction +- **File:** `Server/api/upload_handler.go` (~line 309) + test at + `upload_handler_test.go` (~line 733) +- **Fix:** `private, max-age=31536000, no-cache` is self-contradictory — + `no-cache` forces revalidation, so the year-long `max-age` is dead weight. + Use `private, no-cache` and update the test assertion. + +### W3-5. Restore test coverage lost to the MemStore change +- **File:** `Server/store/memstore.go` (~line 693) +- **Fix:** subsumed by W1-3 (atomic link removes the need for the `(nil,nil)` + stub). If MemStore keeps attachment stubs, ensure the ownership behavior is + covered by a store that actually tracks `uploader_id`/`message_id` so the + IDOR guard cannot silently regress in tests. + +## Sequencing + +1. **W1** first (availability/backend-breaking). W1-3 unblocks W2-4 and W3-5. +2. **W2** next. W2-5 pairs with the CIDR pre-parse in W3-3. W2-6 pairs with the + redundant-resolve removal. +3. **W3** last, or fold each item into the related Wave-1/2 change. + +## Cross-cutting requirements + +- **Tests:** every fix ships with tests (repo rule: 80%+ coverage, TDD). Add + the missing coverage for the *existing* new security code too: + `requireBanAuthority`, `FetchTextAssetCached`, `requireChannelBroadcastAccess`, + fail-closed `DecryptTOTPSecret`. +- **Build-tag matrix:** W1-1/W2-3 touch `//go:build wazero` code — verify the + default, `otel`, `wazero`, and `otel,wazero` variants all still build. +- **Two backends:** W1-3 changes queries — edit `Server/db/queries/` + both + migration trees and run `make sqlc-generate`; do not hand-edit `dbgen`/ + `pgdbgen`. CI runs `make sqlc-verify`. +- **CI gates:** `go test -race`, `-tags deadlock`, `golangci-lint run`, + `govulncheck ./...` must pass before merge. From 0c093e8403f0ae7945e7eb3162d17bd8117afdc2 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:15:58 +0200 Subject: [PATCH 04/29] chore(server): delete unfinished Postgres scaffolding PostgresStore was 86% stubs behind a build tag nothing enables, pgdbgen carried hand-added build tags that fought sqlc-verify, and the runtime never threaded store.Store through the handler boundary. Single-engine reality shrinks the W1-3 attachment-ownership fix and ends the pgdbgen churn. Removed: store/postgres.go, db/pgdbgen/, db/queries/postgres/, migrations/postgres/, the sqlc postgres block, pgx from go.mod, the startup-refusal branch, and the dead Postgres config surface. Co-Authored-By: Claude Fable 5 --- Server/Makefile | 10 +- Server/config/config.go | 32 +- Server/db/pgdbgen/admin.sql.go | 307 ------- Server/db/pgdbgen/attachments.sql.go | 169 ---- Server/db/pgdbgen/blocks.sql.go | 86 -- Server/db/pgdbgen/channels.sql.go | 381 -------- Server/db/pgdbgen/db.go | 34 - Server/db/pgdbgen/dm.sql.go | 241 ----- Server/db/pgdbgen/events.sql.go | 109 --- Server/db/pgdbgen/invites.sql.go | 140 --- Server/db/pgdbgen/lockouts.sql.go | 74 -- Server/db/pgdbgen/messages.sql.go | 479 ---------- Server/db/pgdbgen/models.go | 218 ----- Server/db/pgdbgen/plugins.sql.go | 175 ---- Server/db/pgdbgen/profile.sql.go | 48 - Server/db/pgdbgen/querier.go | 197 ----- Server/db/pgdbgen/reactions.sql.go | 78 -- Server/db/pgdbgen/roles.sql.go | 165 ---- Server/db/pgdbgen/sessions.sql.go | 221 ----- Server/db/pgdbgen/users.sql.go | 219 ----- Server/db/pgdbgen/voice.sql.go | 371 -------- Server/db/queries/postgres/admin.sql | 55 -- Server/db/queries/postgres/attachments.sql | 27 - Server/db/queries/postgres/blocks.sql | 18 - Server/db/queries/postgres/channels.sql | 69 -- Server/db/queries/postgres/dm.sql | 64 -- Server/db/queries/postgres/events.sql | 19 - Server/db/queries/postgres/invites.sql | 23 - Server/db/queries/postgres/lockouts.sql | 15 - Server/db/queries/postgres/messages.sql | 79 -- Server/db/queries/postgres/plugins.sql | 36 - Server/db/queries/postgres/profile.sql | 9 - Server/db/queries/postgres/reactions.sql | 12 - Server/db/queries/postgres/roles.sql | 29 - Server/db/queries/postgres/sessions.sql | 49 -- Server/db/queries/postgres/users.sql | 51 -- Server/db/queries/postgres/voice.sql | 88 -- Server/go.mod | 4 - Server/go.sum | 15 +- Server/main.go | 29 +- .../postgres/001_initial_schema.sql | 336 ------- Server/migrations/postgres/migrations.go | 17 - Server/plugin/manifest.go | 2 +- Server/plugin/sandbox_wazero.go | 2 +- Server/sqlc.yaml | 20 - Server/store/postgres.go | 833 ------------------ Server/telemetry/telemetry_otel.go | 2 +- 47 files changed, 22 insertions(+), 5635 deletions(-) delete mode 100644 Server/db/pgdbgen/admin.sql.go delete mode 100644 Server/db/pgdbgen/attachments.sql.go delete mode 100644 Server/db/pgdbgen/blocks.sql.go delete mode 100644 Server/db/pgdbgen/channels.sql.go delete mode 100644 Server/db/pgdbgen/db.go delete mode 100644 Server/db/pgdbgen/dm.sql.go delete mode 100644 Server/db/pgdbgen/events.sql.go delete mode 100644 Server/db/pgdbgen/invites.sql.go delete mode 100644 Server/db/pgdbgen/lockouts.sql.go delete mode 100644 Server/db/pgdbgen/messages.sql.go delete mode 100644 Server/db/pgdbgen/models.go delete mode 100644 Server/db/pgdbgen/plugins.sql.go delete mode 100644 Server/db/pgdbgen/profile.sql.go delete mode 100644 Server/db/pgdbgen/querier.go delete mode 100644 Server/db/pgdbgen/reactions.sql.go delete mode 100644 Server/db/pgdbgen/roles.sql.go delete mode 100644 Server/db/pgdbgen/sessions.sql.go delete mode 100644 Server/db/pgdbgen/users.sql.go delete mode 100644 Server/db/pgdbgen/voice.sql.go delete mode 100644 Server/db/queries/postgres/admin.sql delete mode 100644 Server/db/queries/postgres/attachments.sql delete mode 100644 Server/db/queries/postgres/blocks.sql delete mode 100644 Server/db/queries/postgres/channels.sql delete mode 100644 Server/db/queries/postgres/dm.sql delete mode 100644 Server/db/queries/postgres/events.sql delete mode 100644 Server/db/queries/postgres/invites.sql delete mode 100644 Server/db/queries/postgres/lockouts.sql delete mode 100644 Server/db/queries/postgres/messages.sql delete mode 100644 Server/db/queries/postgres/plugins.sql delete mode 100644 Server/db/queries/postgres/profile.sql delete mode 100644 Server/db/queries/postgres/reactions.sql delete mode 100644 Server/db/queries/postgres/roles.sql delete mode 100644 Server/db/queries/postgres/sessions.sql delete mode 100644 Server/db/queries/postgres/users.sql delete mode 100644 Server/db/queries/postgres/voice.sql delete mode 100644 Server/migrations/postgres/001_initial_schema.sql delete mode 100644 Server/migrations/postgres/migrations.go delete mode 100644 Server/store/postgres.go diff --git a/Server/Makefile b/Server/Makefile index 54d2ca89..5eb9012b 100644 --- a/Server/Makefile +++ b/Server/Makefile @@ -1,8 +1,7 @@ # OwnCord Server — developer convenience targets # -# sqlc-generate Regenerate type-safe Go for both the sqlite and postgres -# engines defined in sqlc.yaml (db/dbgen + db/pgdbgen). -# sqlc-verify Fail if either committed dbgen output is stale (used by CI). +# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen). +# sqlc-verify Fail if the committed dbgen output is stale (used by CI). # sqlc-install Install the pinned sqlc version into $GOBIN. # otel-up Start Jaeger + Prometheus for local tracing development. # otel-down Stop and remove the OTel dev containers. @@ -17,13 +16,8 @@ sqlc-install: sqlc-generate: sqlc generate -# Verify only db/dbgen: the committed db/pgdbgen files carry hand-added -# `//go:build postgres` tags that `sqlc generate` strips, so a pgdbgen diff -# is expected noise. pgdbgen is scheduled for removal with the Postgres -# scaffolding; restore it after generating so verify leaves a clean tree. sqlc-verify: sqlc generate - @git checkout -- db/pgdbgen @git diff --exit-code db/dbgen || ( \ echo "ERROR: generated sqlc output is stale. Run 'make sqlc-generate' and commit the result." ; \ exit 1 ; \ diff --git a/Server/config/config.go b/Server/config/config.go index bcba0651..69a32c37 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -123,20 +123,12 @@ type ServerConfig struct { // cause the server to refuse to start with a clear error pointing at the // follow-up work — see Server/main.go. type DatabaseConfig struct { - // Type is "sqlite" or "postgres". Empty defaults to "sqlite". + // Type selects the database backend. "sqlite" (or empty, which defaults + // to it) is the only supported value. Type string `koanf:"type"` - // Path is the SQLite database file path. Only used when Type == "sqlite". + // Path is the SQLite database file path. Path string `koanf:"path"` - - // PostgreSQL connection settings. Only used when Type == "postgres". - Host string `koanf:"host"` - Port int `koanf:"port"` - User string `koanf:"user"` - Password string `koanf:"password"` - Name string `koanf:"name"` - SSLMode string `koanf:"sslmode"` // disable | require | verify-ca | verify-full - MaxConns int `koanf:"max_conns"` // pgxpool max connections; 0 = pgxpool default } // TLSConfig holds TLS/certificate settings. @@ -173,12 +165,8 @@ func defaults() Config { }, }, Database: DatabaseConfig{ - Type: "sqlite", - Path: "data/chatserver.db", - Host: "localhost", - Port: 5432, - Name: "owncord", - SSLMode: "disable", + Type: "sqlite", + Path: "data/chatserver.db", }, TLS: TLSConfig{ Mode: "self_signed", @@ -236,16 +224,8 @@ server: # - "192.168.0.0/16" database: - type: "sqlite" # "sqlite" (default, zero-config) or "postgres" + type: "sqlite" # "sqlite" is the only supported backend path: "data/chatserver.db" - # PostgreSQL settings (only used when type: "postgres"): - # host: "localhost" - # port: 5432 - # user: "owncord" - # password: "" - # name: "owncord" - # sslmode: "disable" # disable | require | verify-ca | verify-full - # max_conns: 0 # pgxpool connection cap (0 = pgx default) tls: mode: "self_signed" # self_signed, acme, manual, off diff --git a/Server/db/pgdbgen/admin.sql.go b/Server/db/pgdbgen/admin.sql.go deleted file mode 100644 index e3e4c257..00000000 --- a/Server/db/pgdbgen/admin.sql.go +++ /dev/null @@ -1,307 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: admin.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const countActiveInvites = `-- name: CountActiveInvites :one -SELECT COUNT(*) FROM invites WHERE revoked = FALSE -` - -func (q *Queries) CountActiveInvites(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countActiveInvites) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countActiveMessages = `-- name: CountActiveMessages :one -SELECT COUNT(*) FROM messages WHERE deleted = FALSE -` - -func (q *Queries) CountActiveMessages(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countActiveMessages) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countChannels = `-- name: CountChannels :one -SELECT COUNT(*) FROM channels -` - -func (q *Queries) CountChannels(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countChannels) - var count int64 - err := row.Scan(&count) - return count, err -} - -const forceLogoutUser = `-- name: ForceLogoutUser :exec -DELETE FROM sessions WHERE user_id = $1 -` - -func (q *Queries) ForceLogoutUser(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, forceLogoutUser, userID) - return err -} - -const getAllSettings = `-- name: GetAllSettings :many -SELECT key, value FROM settings -` - -func (q *Queries) GetAllSettings(ctx context.Context) ([]Setting, error) { - rows, err := q.db.Query(ctx, getAllSettings) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Setting{} - for rows.Next() { - var i Setting - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAuditLog = `-- name: GetAuditLog :many -SELECT a.id, a.actor_id, COALESCE(u.username, '') AS actor_name, a.action, - a.target_type, a.target_id, a.detail, a.created_at -FROM audit_log a -LEFT JOIN users u ON u.id = a.actor_id -ORDER BY a.id DESC -LIMIT $1 OFFSET $2 -` - -type GetAuditLogParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type GetAuditLogRow struct { - ID int64 `json:"id"` - ActorID int64 `json:"actorId"` - ActorName string `json:"actorName"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetAuditLog(ctx context.Context, arg GetAuditLogParams) ([]GetAuditLogRow, error) { - rows, err := q.db.Query(ctx, getAuditLog, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetAuditLogRow{} - for rows.Next() { - var i GetAuditLogRow - if err := rows.Scan( - &i.ID, - &i.ActorID, - &i.ActorName, - &i.Action, - &i.TargetType, - &i.TargetID, - &i.Detail, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSetting = `-- name: GetSetting :one -SELECT value FROM settings WHERE key = $1 -` - -func (q *Queries) GetSetting(ctx context.Context, key string) (string, error) { - row := q.db.QueryRow(ctx, getSetting, key) - var value string - err := row.Scan(&value) - return value, err -} - -const getUserSessions = `-- name: GetUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE user_id = $1 -ORDER BY created_at DESC -` - -func (q *Queries) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) { - rows, err := q.db.Query(ctx, getUserSessions, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Session{} - for rows.Next() { - var i Session - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listAllUsers = `-- name: ListAllUsers :many -SELECT u.id, u.username, u.avatar, u.role_id, - u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, - COALESCE(r.name, '') AS role_name -FROM users u -LEFT JOIN roles r ON r.id = u.role_id -ORDER BY u.id ASC -LIMIT $1 OFFSET $2 -` - -type ListAllUsersParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type ListAllUsersRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - RoleName string `json:"roleName"` -} - -func (q *Queries) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) { - rows, err := q.db.Query(ctx, listAllUsers, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListAllUsersRow{} - for rows.Next() { - var i ListAllUsersRow - if err := rows.Scan( - &i.ID, - &i.Username, - &i.Avatar, - &i.RoleID, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - &i.RoleName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const logAudit = `-- name: LogAudit :exec -INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) -VALUES ($1, $2, $3, $4, $5) -` - -type LogAuditParams struct { - ActorID int64 `json:"actorId"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` -} - -func (q *Queries) LogAudit(ctx context.Context, arg LogAuditParams) error { - _, err := q.db.Exec(ctx, logAudit, - arg.ActorID, - arg.Action, - arg.TargetType, - arg.TargetID, - arg.Detail, - ) - return err -} - -const setSetting = `-- name: SetSetting :exec -INSERT INTO settings (key, value) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value -` - -type SetSettingParams struct { - Key string `json:"key"` - Value string `json:"value"` -} - -func (q *Queries) SetSetting(ctx context.Context, arg SetSettingParams) error { - _, err := q.db.Exec(ctx, setSetting, arg.Key, arg.Value) - return err -} - -const updateUserRole = `-- name: UpdateUserRole :exec -UPDATE users SET role_id = $1 WHERE id = $2 -` - -type UpdateUserRoleParams struct { - RoleID int64 `json:"roleId"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error { - _, err := q.db.Exec(ctx, updateUserRole, arg.RoleID, arg.ID) - return err -} - -const userCount = `-- name: UserCount :one - -SELECT COUNT(*) FROM users -` - -// PostgreSQL variants of the sqlite admin queries. -func (q *Queries) UserCount(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, userCount) - var count int64 - err := row.Scan(&count) - return count, err -} diff --git a/Server/db/pgdbgen/attachments.sql.go b/Server/db/pgdbgen/attachments.sql.go deleted file mode 100644 index f483e264..00000000 --- a/Server/db/pgdbgen/attachments.sql.go +++ /dev/null @@ -1,169 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: attachments.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createAttachment = `-- name: CreateAttachment :exec - -INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -` - -type CreateAttachmentParams struct { - ID string `json:"id"` - UploaderID *int64 `json:"uploaderId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - Width *int32 `json:"width"` - Height *int32 `json:"height"` -} - -// PostgreSQL variants of the sqlite attachments queries. -func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error { - _, err := q.db.Exec(ctx, createAttachment, - arg.ID, - arg.UploaderID, - arg.Filename, - arg.StoredAs, - arg.MimeType, - arg.Size, - arg.Width, - arg.Height, - ) - return err -} - -const deleteAttachment = `-- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = $1 -` - -func (q *Queries) DeleteAttachment(ctx context.Context, id string) error { - _, err := q.db.Exec(ctx, deleteAttachment, id) - return err -} - -const deleteOrphanedAttachments = `-- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < $1 RETURNING stored_as -` - -// Postgres timestamptz comparison — the caller passes a wall-clock time. -func (q *Queries) DeleteOrphanedAttachments(ctx context.Context, uploadedAt pgtype.Timestamptz) ([]string, error) { - rows, err := q.db.Query(ctx, deleteOrphanedAttachments, uploadedAt) - if err != nil { - return nil, err - } - defer rows.Close() - items := []string{} - for rows.Next() { - var stored_as string - if err := rows.Scan(&stored_as); err != nil { - return nil, err - } - items = append(items, stored_as) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAttachmentByID = `-- name: GetAttachmentByID :one -SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id -FROM attachments WHERE id = $1 -` - -type GetAttachmentByIDRow struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - UploaderID *int64 `json:"uploaderId"` -} - -func (q *Queries) GetAttachmentByID(ctx context.Context, id string) (GetAttachmentByIDRow, error) { - row := q.db.QueryRow(ctx, getAttachmentByID, id) - var i GetAttachmentByIDRow - err := row.Scan( - &i.ID, - &i.MessageID, - &i.Filename, - &i.StoredAs, - &i.MimeType, - &i.Size, - &i.UploadedAt, - &i.UploaderID, - ) - return i, err -} - -const getAttachmentWithChannel = `-- name: GetAttachmentWithChannel :one -SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, - a.uploaded_at, a.uploader_id, m.channel_id, c.type -FROM attachments a -LEFT JOIN messages m ON m.id = a.message_id -LEFT JOIN channels c ON c.id = m.channel_id -WHERE a.id = $1 -` - -type GetAttachmentWithChannelRow struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - UploaderID *int64 `json:"uploaderId"` - ChannelID *int64 `json:"channelId"` - Type *string `json:"type"` -} - -func (q *Queries) GetAttachmentWithChannel(ctx context.Context, id string) (GetAttachmentWithChannelRow, error) { - row := q.db.QueryRow(ctx, getAttachmentWithChannel, id) - var i GetAttachmentWithChannelRow - err := row.Scan( - &i.ID, - &i.MessageID, - &i.Filename, - &i.StoredAs, - &i.MimeType, - &i.Size, - &i.UploadedAt, - &i.UploaderID, - &i.ChannelID, - &i.Type, - ) - return i, err -} - -const linkAttachmentToMessage = `-- name: LinkAttachmentToMessage :execrows -UPDATE attachments SET message_id = $1 WHERE id = $2 AND message_id IS NULL -` - -type LinkAttachmentToMessageParams struct { - MessageID *int64 `json:"messageId"` - ID string `json:"id"` -} - -func (q *Queries) LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (int64, error) { - result, err := q.db.Exec(ctx, linkAttachmentToMessage, arg.MessageID, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/blocks.sql.go b/Server/db/pgdbgen/blocks.sql.go deleted file mode 100644 index 345a8812..00000000 --- a/Server/db/pgdbgen/blocks.sql.go +++ /dev/null @@ -1,86 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: blocks.sql - -package pgdbgen - -import ( - "context" -) - -const blockUser = `-- name: BlockUser :exec - -INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2) -ON CONFLICT (blocker_id, blocked_id) DO NOTHING -` - -type BlockUserParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -// PostgreSQL variants of the sqlite user block queries. -// `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. -func (q *Queries) BlockUser(ctx context.Context, arg BlockUserParams) error { - _, err := q.db.Exec(ctx, blockUser, arg.BlockerID, arg.BlockedID) - return err -} - -const isBlocked = `-- name: IsBlocked :one -SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 LIMIT 1 -` - -type IsBlockedParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -func (q *Queries) IsBlocked(ctx context.Context, arg IsBlockedParams) (int32, error) { - row := q.db.QueryRow(ctx, isBlocked, arg.BlockerID, arg.BlockedID) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - -const isEitherBlocked = `-- name: IsEitherBlocked :one -SELECT 1 FROM user_blocks -WHERE (blocker_id = $1 AND blocked_id = $2) - OR (blocker_id = $3 AND blocked_id = $4) -LIMIT 1 -` - -type IsEitherBlockedParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` - BlockerID_2 int64 `json:"blockerId2"` - BlockedID_2 int64 `json:"blockedId2"` -} - -func (q *Queries) IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams) (int32, error) { - row := q.db.QueryRow(ctx, isEitherBlocked, - arg.BlockerID, - arg.BlockedID, - arg.BlockerID_2, - arg.BlockedID_2, - ) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - -const unblockUser = `-- name: UnblockUser :exec -DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 -` - -type UnblockUserParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -func (q *Queries) UnblockUser(ctx context.Context, arg UnblockUserParams) error { - _, err := q.db.Exec(ctx, unblockUser, arg.BlockerID, arg.BlockedID) - return err -} diff --git a/Server/db/pgdbgen/channels.sql.go b/Server/db/pgdbgen/channels.sql.go deleted file mode 100644 index 4b2c96fc..00000000 --- a/Server/db/pgdbgen/channels.sql.go +++ /dev/null @@ -1,381 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: channels.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const adminUpdateChannel = `-- name: AdminUpdateChannel :exec -UPDATE channels -SET name = $1, topic = $2, slow_mode = $3, position = $4, archived = $5 -WHERE id = $6 -` - -type AdminUpdateChannelParams struct { - Name string `json:"name"` - Topic *string `json:"topic"` - SlowMode int32 `json:"slowMode"` - Position int32 `json:"position"` - Archived bool `json:"archived"` - ID int64 `json:"id"` -} - -func (q *Queries) AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error { - _, err := q.db.Exec(ctx, adminUpdateChannel, - arg.Name, - arg.Topic, - arg.SlowMode, - arg.Position, - arg.Archived, - arg.ID, - ) - return err -} - -const archiveChannel = `-- name: ArchiveChannel :exec -UPDATE channels SET archived = $1 WHERE id = $2 -` - -type ArchiveChannelParams struct { - Archived bool `json:"archived"` - ID int64 `json:"id"` -} - -func (q *Queries) ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error { - _, err := q.db.Exec(ctx, archiveChannel, arg.Archived, arg.ID) - return err -} - -const createChannel = `-- name: CreateChannel :one -INSERT INTO channels (name, type, category, topic, position) -VALUES ($1, $2, $3, $4, $5) -RETURNING id -` - -type CreateChannelParams struct { - Name string `json:"name"` - Type string `json:"type"` - Category *string `json:"category"` - Topic *string `json:"topic"` - Position int32 `json:"position"` -} - -func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (int64, error) { - row := q.db.QueryRow(ctx, createChannel, - arg.Name, - arg.Type, - arg.Category, - arg.Topic, - arg.Position, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const deleteChannel = `-- name: DeleteChannel :exec -DELETE FROM channels WHERE id = $1 -` - -func (q *Queries) DeleteChannel(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, deleteChannel, id) - return err -} - -const deleteChannelPermission = `-- name: DeleteChannelPermission :exec -DELETE FROM channel_overrides WHERE channel_id = $1 AND role_id = $2 -` - -type DeleteChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` -} - -func (q *Queries) DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error { - _, err := q.db.Exec(ctx, deleteChannelPermission, arg.ChannelID, arg.RoleID) - return err -} - -const getChannel = `-- name: GetChannel :one -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels WHERE id = $1 -` - -type GetChannelRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -func (q *Queries) GetChannel(ctx context.Context, id int64) (GetChannelRow, error) { - row := q.db.QueryRow(ctx, getChannel, id) - var i GetChannelRow - err := row.Scan( - &i.ID, - &i.Name, - &i.Type, - &i.Category, - &i.Topic, - &i.Position, - &i.SlowMode, - &i.Archived, - &i.CreatedAt, - &i.VoiceMaxUsers, - &i.VoiceQuality, - &i.MixingThreshold, - &i.VoiceMaxVideo, - ) - return i, err -} - -const getChannelPermission = `-- name: GetChannelPermission :one -SELECT allow, deny FROM channel_overrides WHERE channel_id = $1 AND role_id = $2 -` - -type GetChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` -} - -type GetChannelPermissionRow struct { - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) GetChannelPermission(ctx context.Context, arg GetChannelPermissionParams) (GetChannelPermissionRow, error) { - row := q.db.QueryRow(ctx, getChannelPermission, arg.ChannelID, arg.RoleID) - var i GetChannelPermissionRow - err := row.Scan(&i.Allow, &i.Deny) - return i, err -} - -const getRoleChannelPermissions = `-- name: GetRoleChannelPermissions :many -SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = $1 -` - -type GetRoleChannelPermissionsRow struct { - ChannelID int64 `json:"channelId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) { - rows, err := q.db.Query(ctx, getRoleChannelPermissions, roleID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetRoleChannelPermissionsRow{} - for rows.Next() { - var i GetRoleChannelPermissionsRow - if err := rows.Scan(&i.ChannelID, &i.Allow, &i.Deny); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listChannels = `-- name: ListChannels :many - -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels ORDER BY position ASC, id ASC -` - -type ListChannelsRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -// PostgreSQL variants of the sqlite channels queries. -func (q *Queries) ListChannels(ctx context.Context) ([]ListChannelsRow, error) { - rows, err := q.db.Query(ctx, listChannels) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListChannelsRow{} - for rows.Next() { - var i ListChannelsRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Type, - &i.Category, - &i.Topic, - &i.Position, - &i.SlowMode, - &i.Archived, - &i.CreatedAt, - &i.VoiceMaxUsers, - &i.VoiceQuality, - &i.MixingThreshold, - &i.VoiceMaxVideo, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setChannelMixingThreshold = `-- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = $1 WHERE id = $2 -` - -type SetChannelMixingThresholdParams struct { - MixingThreshold *int32 `json:"mixingThreshold"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error { - _, err := q.db.Exec(ctx, setChannelMixingThreshold, arg.MixingThreshold, arg.ID) - return err -} - -const setChannelSlowMode = `-- name: SetChannelSlowMode :exec -UPDATE channels SET slow_mode = $1 WHERE id = $2 -` - -type SetChannelSlowModeParams struct { - SlowMode int32 `json:"slowMode"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error { - _, err := q.db.Exec(ctx, setChannelSlowMode, arg.SlowMode, arg.ID) - return err -} - -const setChannelVoiceMaxUsers = `-- name: SetChannelVoiceMaxUsers :exec -UPDATE channels SET voice_max_users = $1 WHERE id = $2 -` - -type SetChannelVoiceMaxUsersParams struct { - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceMaxUsers, arg.VoiceMaxUsers, arg.ID) - return err -} - -const setChannelVoiceMaxVideo = `-- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = $1 WHERE id = $2 -` - -type SetChannelVoiceMaxVideoParams struct { - VoiceMaxVideo int32 `json:"voiceMaxVideo"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceMaxVideo, arg.VoiceMaxVideo, arg.ID) - return err -} - -const setChannelVoiceQuality = `-- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = $1 WHERE id = $2 -` - -type SetChannelVoiceQualityParams struct { - VoiceQuality *string `json:"voiceQuality"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceQuality, arg.VoiceQuality, arg.ID) - return err -} - -const updateChannel = `-- name: UpdateChannel :exec -UPDATE channels SET name = $1, topic = $2, slow_mode = $3 WHERE id = $4 -` - -type UpdateChannelParams struct { - Name string `json:"name"` - Topic *string `json:"topic"` - SlowMode int32 `json:"slowMode"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) error { - _, err := q.db.Exec(ctx, updateChannel, - arg.Name, - arg.Topic, - arg.SlowMode, - arg.ID, - ) - return err -} - -const upsertChannelPermission = `-- name: UpsertChannelPermission :exec -INSERT INTO channel_overrides (channel_id, role_id, allow, deny) -VALUES ($1, $2, $3, $4) -ON CONFLICT (channel_id, role_id) DO UPDATE SET - allow = EXCLUDED.allow, - deny = EXCLUDED.deny -` - -type UpsertChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error { - _, err := q.db.Exec(ctx, upsertChannelPermission, - arg.ChannelID, - arg.RoleID, - arg.Allow, - arg.Deny, - ) - return err -} diff --git a/Server/db/pgdbgen/db.go b/Server/db/pgdbgen/db.go deleted file mode 100644 index 09808de2..00000000 --- a/Server/db/pgdbgen/db.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -type DBTX interface { - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx pgx.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/Server/db/pgdbgen/dm.sql.go b/Server/db/pgdbgen/dm.sql.go deleted file mode 100644 index ab383391..00000000 --- a/Server/db/pgdbgen/dm.sql.go +++ /dev/null @@ -1,241 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: dm.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const closeDM = `-- name: CloseDM :exec -DELETE FROM dm_open_state WHERE user_id = $1 AND channel_id = $2 -` - -type CloseDMParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) CloseDM(ctx context.Context, arg CloseDMParams) error { - _, err := q.db.Exec(ctx, closeDM, arg.UserID, arg.ChannelID) - return err -} - -const findExistingDMChannel = `-- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = $1 AND dp2.user_id = $2 AND c.type = 'dm' -LIMIT 1 -` - -type FindExistingDMChannelParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) { - row := q.db.QueryRow(ctx, findExistingDMChannel, arg.UserID, arg.UserID_2) - var channel_id int64 - err := row.Scan(&channel_id) - return channel_id, err -} - -const getDMParticipantIDs = `-- name: GetDMParticipantIDs :many -SELECT user_id FROM dm_participants WHERE channel_id = $1 -` - -func (q *Queries) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { - rows, err := q.db.Query(ctx, getDMParticipantIDs, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []int64{} - for rows.Next() { - var user_id int64 - if err := rows.Scan(&user_id); err != nil { - return nil, err - } - items = append(items, user_id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUserDMChannels = `-- name: GetUserDMChannels :many -SELECT - c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, - lm.id AS last_message_id, - COALESCE(lm.content, '') AS last_message, - COALESCE(lm.timestamp, dos.opened_at) AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = FALSE THEN 1 END) AS unread_count -FROM dm_open_state dos -JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != $1 -JOIN users u ON u.id = dp.user_id -LEFT JOIN messages lm ON lm.id = ( - SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = FALSE -) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $2 -WHERE dos.user_id = $3 -GROUP BY c.id, u.id, lm.id, lm.content, lm.timestamp, dos.opened_at -ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC -` - -type GetUserDMChannelsParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` - UserID_3 int64 `json:"userId3"` -} - -type GetUserDMChannelsRow struct { - ChannelID int64 `json:"channelId"` - RecipientID int64 `json:"recipientId"` - RecipientUsername string `json:"recipientUsername"` - RecipientAvatar string `json:"recipientAvatar"` - RecipientStatus string `json:"recipientStatus"` - LastMessageID *int64 `json:"lastMessageId"` - LastMessage string `json:"lastMessage"` - LastMessageAt pgtype.Timestamptz `json:"lastMessageAt"` - UnreadCount int64 `json:"unreadCount"` -} - -// For the "last message at" and "last message content" columns, sqlite -// COALESCEs to ” (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an -// empty string, so we COALESCE to the dm_open_state.opened_at fallback and -// leave conversion to the store wrapper. -func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) { - rows, err := q.db.Query(ctx, getUserDMChannels, arg.UserID, arg.UserID_2, arg.UserID_3) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetUserDMChannelsRow{} - for rows.Next() { - var i GetUserDMChannelsRow - if err := rows.Scan( - &i.ChannelID, - &i.RecipientID, - &i.RecipientUsername, - &i.RecipientAvatar, - &i.RecipientStatus, - &i.LastMessageID, - &i.LastMessage, - &i.LastMessageAt, - &i.UnreadCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const insertDMChannel = `-- name: InsertDMChannel :one - -INSERT INTO channels (name, type) VALUES ('', 'dm') RETURNING id -` - -// PostgreSQL variants of the sqlite DM queries. -// InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses -// :one with RETURNING id. -// `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. -func (q *Queries) InsertDMChannel(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, insertDMChannel) - var id int64 - err := row.Scan(&id) - return id, err -} - -const insertDMOpenState = `-- name: InsertDMOpenState :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2), ($3, $4) -ON CONFLICT (user_id, channel_id) DO NOTHING -` - -type InsertDMOpenStateParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - UserID_2 int64 `json:"userId2"` - ChannelID_2 int64 `json:"channelId2"` -} - -func (q *Queries) InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error { - _, err := q.db.Exec(ctx, insertDMOpenState, - arg.UserID, - arg.ChannelID, - arg.UserID_2, - arg.ChannelID_2, - ) - return err -} - -const insertDMParticipants = `-- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($3, $4) -` - -type InsertDMParticipantsParams struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - ChannelID_2 int64 `json:"channelId2"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error { - _, err := q.db.Exec(ctx, insertDMParticipants, - arg.ChannelID, - arg.UserID, - arg.ChannelID_2, - arg.UserID_2, - ) - return err -} - -const isDMParticipant = `-- name: IsDMParticipant :one -SELECT user_id FROM dm_participants WHERE user_id = $1 AND channel_id = $2 -` - -type IsDMParticipantParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) IsDMParticipant(ctx context.Context, arg IsDMParticipantParams) (int64, error) { - row := q.db.QueryRow(ctx, isDMParticipant, arg.UserID, arg.ChannelID) - var user_id int64 - err := row.Scan(&user_id) - return user_id, err -} - -const openDM = `-- name: OpenDM :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2) -ON CONFLICT (user_id, channel_id) DO NOTHING -` - -type OpenDMParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) OpenDM(ctx context.Context, arg OpenDMParams) error { - _, err := q.db.Exec(ctx, openDM, arg.UserID, arg.ChannelID) - return err -} diff --git a/Server/db/pgdbgen/events.sql.go b/Server/db/pgdbgen/events.sql.go deleted file mode 100644 index d02eb71b..00000000 --- a/Server/db/pgdbgen/events.sql.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: events.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const getEventsSince = `-- name: GetEventsSince :many -SELECT seq, event_type, channel_id, payload, created_at -FROM events -WHERE seq > $1 -ORDER BY seq ASC -LIMIT $2 -` - -type GetEventsSinceParams struct { - Seq int64 `json:"seq"` - Limit int32 `json:"limit"` -} - -type GetEventsSinceRow struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - ChannelID int64 `json:"channelId"` - Payload []byte `json:"payload"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) { - rows, err := q.db.Query(ctx, getEventsSince, arg.Seq, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetEventsSinceRow{} - for rows.Next() { - var i GetEventsSinceRow - if err := rows.Scan( - &i.Seq, - &i.EventType, - &i.ChannelID, - &i.Payload, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMaxEventSeq = `-- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events -` - -func (q *Queries) GetMaxEventSeq(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, getMaxEventSeq) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const persistEvent = `-- name: PersistEvent :exec -INSERT INTO events (seq, event_type, channel_id, payload) -VALUES ($1, $2, $3, $4) -` - -type PersistEventParams struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - ChannelID int64 `json:"channelId"` - Payload []byte `json:"payload"` -} - -// seq is supplied by the hub so the row seq matches the wrapped-payload seq. -// The schema's BIGSERIAL still owns the id column for inserts that omit seq, -// but PersistEvent always supplies an explicit value. -func (q *Queries) PersistEvent(ctx context.Context, arg PersistEventParams) error { - _, err := q.db.Exec(ctx, persistEvent, - arg.Seq, - arg.EventType, - arg.ChannelID, - arg.Payload, - ) - return err -} - -const pruneEventsOlderThan = `-- name: PruneEventsOlderThan :execrows -DELETE FROM events WHERE created_at < $1 -` - -func (q *Queries) PruneEventsOlderThan(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) { - result, err := q.db.Exec(ctx, pruneEventsOlderThan, createdAt) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/invites.sql.go b/Server/db/pgdbgen/invites.sql.go deleted file mode 100644 index 32bac5a1..00000000 --- a/Server/db/pgdbgen/invites.sql.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: invites.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createInvite = `-- name: CreateInvite :exec - -INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES ($1, $2, $3, $4) -` - -type CreateInviteParams struct { - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite invites queries. -// The expiry check uses native timestamp comparison instead of sqlite's -// strftime('%s', …) trick. -func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) error { - _, err := q.db.Exec(ctx, createInvite, - arg.Code, - arg.CreatedBy, - arg.MaxUses, - arg.ExpiresAt, - ) - return err -} - -const getInvite = `-- name: GetInvite :one -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites WHERE code = $1 -` - -type GetInviteRow struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Revoked bool `json:"revoked"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetInvite(ctx context.Context, code string) (GetInviteRow, error) { - row := q.db.QueryRow(ctx, getInvite, code) - var i GetInviteRow - err := row.Scan( - &i.ID, - &i.Code, - &i.CreatedBy, - &i.MaxUses, - &i.UseCount, - &i.ExpiresAt, - &i.Revoked, - &i.CreatedAt, - ) - return i, err -} - -const listInvites = `-- name: ListInvites :many -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites ORDER BY created_at DESC LIMIT 200 -` - -type ListInvitesRow struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Revoked bool `json:"revoked"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) ListInvites(ctx context.Context) ([]ListInvitesRow, error) { - rows, err := q.db.Query(ctx, listInvites) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListInvitesRow{} - for rows.Next() { - var i ListInvitesRow - if err := rows.Scan( - &i.ID, - &i.Code, - &i.CreatedBy, - &i.MaxUses, - &i.UseCount, - &i.ExpiresAt, - &i.Revoked, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const revokeInvite = `-- name: RevokeInvite :exec -UPDATE invites SET revoked = TRUE WHERE code = $1 -` - -func (q *Queries) RevokeInvite(ctx context.Context, code string) error { - _, err := q.db.Exec(ctx, revokeInvite, code) - return err -} - -const useInviteAtomic = `-- name: UseInviteAtomic :execrows -UPDATE invites SET use_count = use_count + 1 -WHERE code = $1 AND revoked = FALSE - AND (max_uses IS NULL OR use_count < max_uses) - AND (expires_at IS NULL OR expires_at > NOW()) -` - -func (q *Queries) UseInviteAtomic(ctx context.Context, code string) (int64, error) { - result, err := q.db.Exec(ctx, useInviteAtomic, code) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/lockouts.sql.go b/Server/db/pgdbgen/lockouts.sql.go deleted file mode 100644 index d98de3ad..00000000 --- a/Server/db/pgdbgen/lockouts.sql.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: lockouts.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const cleanupExpiredLockouts = `-- name: CleanupExpiredLockouts :exec -DELETE FROM rate_lockouts WHERE expires_at <= $1 -` - -func (q *Queries) CleanupExpiredLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) error { - _, err := q.db.Exec(ctx, cleanupExpiredLockouts, expiresAt) - return err -} - -const deleteLockout = `-- name: DeleteLockout :exec -DELETE FROM rate_lockouts WHERE key = $1 -` - -func (q *Queries) DeleteLockout(ctx context.Context, key string) error { - _, err := q.db.Exec(ctx, deleteLockout, key) - return err -} - -const loadActiveLockouts = `-- name: LoadActiveLockouts :many -SELECT key, expires_at FROM rate_lockouts WHERE expires_at > $1 -` - -func (q *Queries) LoadActiveLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) ([]RateLockout, error) { - rows, err := q.db.Query(ctx, loadActiveLockouts, expiresAt) - if err != nil { - return nil, err - } - defer rows.Close() - items := []RateLockout{} - for rows.Next() { - var i RateLockout - if err := rows.Scan(&i.Key, &i.ExpiresAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertLockout = `-- name: UpsertLockout :exec - -INSERT INTO rate_lockouts (key, expires_at) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET expires_at = EXCLUDED.expires_at -` - -type UpsertLockoutParams struct { - Key string `json:"key"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite rate-lockout queries. -// `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. -func (q *Queries) UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error { - _, err := q.db.Exec(ctx, upsertLockout, arg.Key, arg.ExpiresAt) - return err -} diff --git a/Server/db/pgdbgen/messages.sql.go b/Server/db/pgdbgen/messages.sql.go deleted file mode 100644 index bfdf17f1..00000000 --- a/Server/db/pgdbgen/messages.sql.go +++ /dev/null @@ -1,479 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: messages.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createMessage = `-- name: CreateMessage :one - -INSERT INTO messages (channel_id, user_id, content, reply_to) -VALUES ($1, $2, $3, $4) -RETURNING id -` - -type CreateMessageParams struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` -} - -// PostgreSQL variants of the sqlite messages queries. -// `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). -// The FTS search queries are NOT included here: on postgres, messages.fts is -// a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) -// and FTS queries are hand-written in the postgres-specific store dispatch, -// mirroring how sqlite's FTS5 queries live in message_queries.go. -func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (int64, error) { - row := q.db.QueryRow(ctx, createMessage, - arg.ChannelID, - arg.UserID, - arg.Content, - arg.ReplyTo, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const editMessageContent = `-- name: EditMessageContent :exec -UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 -` - -type EditMessageContentParams struct { - Content string `json:"content"` - ID int64 `json:"id"` -} - -func (q *Queries) EditMessageContent(ctx context.Context, arg EditMessageContentParams) error { - _, err := q.db.Exec(ctx, editMessageContent, arg.Content, arg.ID) - return err -} - -const getChannelUnreadCounts = `-- name: GetChannelUnreadCounts :many -SELECT c.id, - COALESCE(MAX(m.id), 0)::BIGINT AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = FALSE THEN 1 END) AS unread -FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = FALSE -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $1 -WHERE c.type = 'text' -GROUP BY c.id -` - -type GetChannelUnreadCountsRow struct { - ID int64 `json:"id"` - LastMsgID int64 `json:"lastMsgId"` - Unread int64 `json:"unread"` -} - -func (q *Queries) GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) { - rows, err := q.db.Query(ctx, getChannelUnreadCounts, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetChannelUnreadCountsRow{} - for rows.Next() { - var i GetChannelUnreadCountsRow - if err := rows.Scan(&i.ID, &i.LastMsgID, &i.Unread); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLatestMessageID = `-- name: GetLatestMessageID :one -SELECT COALESCE(MAX(id), 0)::BIGINT FROM messages WHERE channel_id = $1 AND deleted = FALSE -` - -func (q *Queries) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) { - row := q.db.QueryRow(ctx, getLatestMessageID, channelID) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const getMessage = `-- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp -FROM messages WHERE id = $1 -` - -type GetMessageRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessage(ctx context.Context, id int64) (GetMessageRow, error) { - row := q.db.QueryRow(ctx, getMessage, id) - var i GetMessageRow - err := row.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ) - return i, err -} - -const getMessagesByChannel = `-- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2 -` - -type GetMessagesByChannelParams struct { - ChannelID int64 `json:"channelId"` - Limit int32 `json:"limit"` -} - -type GetMessagesByChannelRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) { - rows, err := q.db.Query(ctx, getMessagesByChannel, arg.ChannelID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelRow{} - for rows.Next() { - var i GetMessagesByChannelRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesByChannelBeforeCursor = `-- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3 -` - -type GetMessagesByChannelBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int32 `json:"limit"` -} - -type GetMessagesByChannelBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) { - rows, err := q.db.Query(ctx, getMessagesByChannelBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelBeforeCursorRow{} - for rows.Next() { - var i GetMessagesByChannelBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesForAPI = `-- name: GetMessagesForAPI :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2 -` - -type GetMessagesForAPIParams struct { - ChannelID int64 `json:"channelId"` - Limit int32 `json:"limit"` -} - -type GetMessagesForAPIRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) { - rows, err := q.db.Query(ctx, getMessagesForAPI, arg.ChannelID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesForAPIRow{} - for rows.Next() { - var i GetMessagesForAPIRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesForAPIBeforeCursor = `-- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3 -` - -type GetMessagesForAPIBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int32 `json:"limit"` -} - -type GetMessagesForAPIBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) { - rows, err := q.db.Query(ctx, getMessagesForAPIBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesForAPIBeforeCursorRow{} - for rows.Next() { - var i GetMessagesForAPIBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getPinnedMessageRows = `-- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.pinned = TRUE AND m.deleted = FALSE -ORDER BY m.id DESC -` - -type GetPinnedMessageRowsRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) { - rows, err := q.db.Query(ctx, getPinnedMessageRows, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetPinnedMessageRowsRow{} - for rows.Next() { - var i GetPinnedMessageRowsRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setMessagePinned = `-- name: SetMessagePinned :execrows -UPDATE messages SET pinned = $1 WHERE id = $2 AND deleted = FALSE -` - -type SetMessagePinnedParams struct { - Pinned bool `json:"pinned"` - ID int64 `json:"id"` -} - -func (q *Queries) SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (int64, error) { - result, err := q.db.Exec(ctx, setMessagePinned, arg.Pinned, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const softDeleteMessage = `-- name: SoftDeleteMessage :exec -UPDATE messages SET deleted = TRUE WHERE id = $1 -` - -func (q *Queries) SoftDeleteMessage(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, softDeleteMessage, id) - return err -} - -const updateReadState = `-- name: UpdateReadState :exec -INSERT INTO read_states (user_id, channel_id, last_message_id) -VALUES ($1, $2, $3) -ON CONFLICT (user_id, channel_id) DO UPDATE SET last_message_id = EXCLUDED.last_message_id -` - -type UpdateReadStateParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - LastMessageID int64 `json:"lastMessageId"` -} - -func (q *Queries) UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error { - _, err := q.db.Exec(ctx, updateReadState, arg.UserID, arg.ChannelID, arg.LastMessageID) - return err -} diff --git a/Server/db/pgdbgen/models.go b/Server/db/pgdbgen/models.go deleted file mode 100644 index c689ed8b..00000000 --- a/Server/db/pgdbgen/models.go +++ /dev/null @@ -1,218 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "github.com/jackc/pgx/v5/pgtype" -) - -type Attachment struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - Width *int32 `json:"width"` - Height *int32 `json:"height"` - UploaderID *int64 `json:"uploaderId"` -} - -type AuditLog struct { - ID int64 `json:"id"` - ActorID int64 `json:"actorId"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Channel struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category *string `json:"category"` - Topic *string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -type ChannelOverride struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -type DmOpenState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - OpenedAt pgtype.Timestamptz `json:"openedAt"` -} - -type DmParticipant struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` -} - -type Emoji struct { - ID int64 `json:"id"` - Shortcode string `json:"shortcode"` - Filename string `json:"filename"` - UploadedBy int64 `json:"uploadedBy"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Event struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - Payload []byte `json:"payload"` - ChannelID int64 `json:"channelId"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Invite struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - RedeemedBy *int64 `json:"redeemedBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - Revoked bool `json:"revoked"` -} - -type LoginAttempt struct { - ID int64 `json:"id"` - IpAddress string `json:"ipAddress"` - Username *string `json:"username"` - Success bool `json:"success"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -type Message struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Fts interface{} `json:"fts"` -} - -type Plugin struct { - ID int64 `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Enabled bool `json:"enabled"` - ManifestJson string `json:"manifestJson"` - InstalledAt pgtype.Timestamptz `json:"installedAt"` -} - -type PluginKv struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` - Value []byte `json:"value"` -} - -type RateLockout struct { - Key string `json:"key"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -type Reaction struct { - ID int64 `json:"id"` - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -type ReadState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - LastMessageID int64 `json:"lastMessageId"` - MentionCount int32 `json:"mentionCount"` -} - -type Role struct { - ID int64 `json:"id"` - Name string `json:"name"` - Color *string `json:"color"` - Permissions int64 `json:"permissions"` - Position int32 `json:"position"` - IsDefault bool `json:"isDefault"` -} - -type Session struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastUsed pgtype.Timestamptz `json:"lastUsed"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -type Setting struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type Sound struct { - ID int64 `json:"id"` - Name string `json:"name"` - Filename string `json:"filename"` - DurationMs int32 `json:"durationMs"` - UploadedBy int64 `json:"uploadedBy"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - TotpSecret *string `json:"totpSecret"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` -} - -type UserBlock struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type VoiceState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` -} diff --git a/Server/db/pgdbgen/plugins.sql.go b/Server/db/pgdbgen/plugins.sql.go deleted file mode 100644 index 3f24e863..00000000 --- a/Server/db/pgdbgen/plugins.sql.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: plugins.sql - -package pgdbgen - -import ( - "context" -) - -const disablePlugin = `-- name: DisablePlugin :exec -UPDATE plugins SET enabled = FALSE WHERE id = $1 -` - -func (q *Queries) DisablePlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, disablePlugin, id) - return err -} - -const enablePlugin = `-- name: EnablePlugin :exec -UPDATE plugins SET enabled = TRUE WHERE id = $1 -` - -func (q *Queries) EnablePlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, enablePlugin, id) - return err -} - -const getPlugin = `-- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1 -` - -func (q *Queries) GetPlugin(ctx context.Context, id int64) (Plugin, error) { - row := q.db.QueryRow(ctx, getPlugin, id) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - -const getPluginByName = `-- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1 -` - -func (q *Queries) GetPluginByName(ctx context.Context, name string) (Plugin, error) { - row := q.db.QueryRow(ctx, getPluginByName, name) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - -const installPlugin = `-- name: InstallPlugin :one -INSERT INTO plugins (name, version, manifest_json) -VALUES ($1, $2, $3) -ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json -RETURNING id -` - -type InstallPluginParams struct { - Name string `json:"name"` - Version string `json:"version"` - ManifestJson string `json:"manifestJson"` -} - -func (q *Queries) InstallPlugin(ctx context.Context, arg InstallPluginParams) (int64, error) { - row := q.db.QueryRow(ctx, installPlugin, arg.Name, arg.Version, arg.ManifestJson) - var id int64 - err := row.Scan(&id) - return id, err -} - -const listPlugins = `-- name: ListPlugins :many -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name -` - -func (q *Queries) ListPlugins(ctx context.Context) ([]Plugin, error) { - rows, err := q.db.Query(ctx, listPlugins) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Plugin{} - for rows.Next() { - var i Plugin - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const pluginKVDelete = `-- name: PluginKVDelete :exec -DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2 -` - -type PluginKVDeleteParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` -} - -func (q *Queries) PluginKVDelete(ctx context.Context, arg PluginKVDeleteParams) error { - _, err := q.db.Exec(ctx, pluginKVDelete, arg.PluginID, arg.Key) - return err -} - -const pluginKVGet = `-- name: PluginKVGet :one -SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2 -` - -type PluginKVGetParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` -} - -func (q *Queries) PluginKVGet(ctx context.Context, arg PluginKVGetParams) ([]byte, error) { - row := q.db.QueryRow(ctx, pluginKVGet, arg.PluginID, arg.Key) - var value []byte - err := row.Scan(&value) - return value, err -} - -const pluginKVSet = `-- name: PluginKVSet :exec -INSERT INTO plugin_kv (plugin_id, key, value) -VALUES ($1, $2, $3) -ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value -` - -type PluginKVSetParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` - Value []byte `json:"value"` -} - -func (q *Queries) PluginKVSet(ctx context.Context, arg PluginKVSetParams) error { - _, err := q.db.Exec(ctx, pluginKVSet, arg.PluginID, arg.Key, arg.Value) - return err -} - -const uninstallPlugin = `-- name: UninstallPlugin :exec -DELETE FROM plugins WHERE id = $1 -` - -func (q *Queries) UninstallPlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, uninstallPlugin, id) - return err -} diff --git a/Server/db/pgdbgen/profile.sql.go b/Server/db/pgdbgen/profile.sql.go deleted file mode 100644 index 4da8d7d3..00000000 --- a/Server/db/pgdbgen/profile.sql.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: profile.sql - -package pgdbgen - -import ( - "context" -) - -const updateUserPassword = `-- name: UpdateUserPassword :exec -UPDATE users SET password = $1 WHERE id = $2 -` - -type UpdateUserPasswordParams struct { - Password string `json:"password"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { - _, err := q.db.Exec(ctx, updateUserPassword, arg.Password, arg.ID) - return err -} - -const updateUserProfile = `-- name: UpdateUserProfile :execrows - -UPDATE users SET username = $1, avatar = $2 WHERE id = $3 -` - -type UpdateUserProfileParams struct { - Username string `json:"username"` - Avatar *string `json:"avatar"` - ID int64 `json:"id"` -} - -// PostgreSQL variants of the sqlite profile queries. -// UpdateUserProfile uses :execrows because postgres has no LastInsertId; -// the caller checks rows-affected for existence. -func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (int64, error) { - result, err := q.db.Exec(ctx, updateUserProfile, arg.Username, arg.Avatar, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/querier.go b/Server/db/pgdbgen/querier.go deleted file mode 100644 index cf97acc1..00000000 --- a/Server/db/pgdbgen/querier.go +++ /dev/null @@ -1,197 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -type Querier interface { - // PostgreSQL variants of the sqlite reactions queries. - AddReaction(ctx context.Context, arg AddReactionParams) error - AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error - ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error - BanUser(ctx context.Context, arg BanUserParams) error - // PostgreSQL variants of the sqlite user block queries. - // `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - BlockUser(ctx context.Context, arg BlockUserParams) error - CleanupExpiredLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) error - ClearAllVoiceStates(ctx context.Context) error - ClearVoiceState(ctx context.Context, userID int64) error - CloseDM(ctx context.Context, arg CloseDMParams) error - CountActiveCameras(ctx context.Context, channelID int64) (int64, error) - CountActiveInvites(ctx context.Context) (int64, error) - CountActiveMessages(ctx context.Context) (int64, error) - CountChannels(ctx context.Context) (int64, error) - CountUsers(ctx context.Context) (int64, error) - CountUsersWithoutTOTP(ctx context.Context) (int64, error) - // PostgreSQL variants of the sqlite attachments queries. - CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error - CreateChannel(ctx context.Context, arg CreateChannelParams) (int64, error) - // PostgreSQL variants of the sqlite invites queries. - // The expiry check uses native timestamp comparison instead of sqlite's - // strftime('%s', …) trick. - CreateInvite(ctx context.Context, arg CreateInviteParams) error - // PostgreSQL variants of the sqlite messages queries. - // `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). - // The FTS search queries are NOT included here: on postgres, messages.fts is - // a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) - // and FTS queries are hand-written in the postgres-specific store dispatch, - // mirroring how sqlite's FTS5 queries live in message_queries.go. - CreateMessage(ctx context.Context, arg CreateMessageParams) (int64, error) - CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) - DeleteAttachment(ctx context.Context, id string) error - DeleteChannel(ctx context.Context, id int64) error - DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error - // Use native timestamp comparison instead of sqlite's strftime trick. - DeleteExpiredSessions(ctx context.Context) error - DeleteLockout(ctx context.Context, key string) error - // Postgres timestamptz comparison — the caller passes a wall-clock time. - DeleteOrphanedAttachments(ctx context.Context, uploadedAt pgtype.Timestamptz) ([]string, error) - DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (int64, error) - DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error - DeleteSessionByToken(ctx context.Context, token string) error - DisablePlugin(ctx context.Context, id int64) error - EditMessageContent(ctx context.Context, arg EditMessageContentParams) error - EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (int64, error) - EnablePlugin(ctx context.Context, id int64) error - // Delete all but the N most recent sessions for a user. Postgres replaces - // sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. - EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error - FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) - ForceLogoutUser(ctx context.Context, userID int64) error - GetAllSettings(ctx context.Context) ([]Setting, error) - GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) - GetAttachmentByID(ctx context.Context, id string) (GetAttachmentByIDRow, error) - GetAttachmentWithChannel(ctx context.Context, id string) (GetAttachmentWithChannelRow, error) - GetAuditLog(ctx context.Context, arg GetAuditLogParams) ([]GetAuditLogRow, error) - GetChannel(ctx context.Context, id int64) (GetChannelRow, error) - GetChannelPermission(ctx context.Context, arg GetChannelPermissionParams) (GetChannelPermissionRow, error) - GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) - GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) - GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) - GetDefaultRole(ctx context.Context) (Role, error) - GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) - GetInvite(ctx context.Context, code string) (GetInviteRow, error) - GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) - GetMaxEventSeq(ctx context.Context) (int64, error) - GetMessage(ctx context.Context, id int64) (GetMessageRow, error) - GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) - GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) - GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) - GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) - GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) - GetPlugin(ctx context.Context, id int64) (Plugin, error) - GetPluginByName(ctx context.Context, name string) (Plugin, error) - GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) - // PostgreSQL variants of the sqlite roles queries. - // `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. - GetRoleByID(ctx context.Context, id int64) (Role, error) - GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) - GetRoleForUser(ctx context.Context, id int64) (Role, error) - GetSessionByTokenHash(ctx context.Context, token string) (Session, error) - GetSessionWithBanStatus(ctx context.Context, token string) (GetSessionWithBanStatusRow, error) - GetSetting(ctx context.Context, key string) (string, error) - GetUserByID(ctx context.Context, id int64) (User, error) - // PostgreSQL variants of the sqlite users queries. - // Differences from sqlite: - // - `?` -> `$1`, `$2`, … - // - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. - // - `datetime('now')` -> `NOW()` - // - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) - // - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) - GetUserByUsername(ctx context.Context, username string) (User, error) - // For the "last message at" and "last message content" columns, sqlite - // COALESCEs to '' (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an - // empty string, so we COALESCE to the dm_open_state.opened_at fallback and - // leave conversion to the store wrapper. - GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) - GetUserSessions(ctx context.Context, userID int64) ([]Session, error) - GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) - GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) - // PostgreSQL variants of the sqlite DM queries. - // InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses - // :one with RETURNING id. - // `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - InsertDMChannel(ctx context.Context) (int64, error) - InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error - InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error - // PostgreSQL variants of the sqlite sessions queries. - InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) - InstallPlugin(ctx context.Context, arg InstallPluginParams) (int64, error) - IsBlocked(ctx context.Context, arg IsBlockedParams) (int32, error) - IsDMParticipant(ctx context.Context, arg IsDMParticipantParams) (int64, error) - IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams) (int32, error) - // PostgreSQL variants of the sqlite voice queries. - // voice_states boolean columns (muted, deafened, speaking, camera, - // screenshare) use FALSE/TRUE instead of 0/1. - JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error - JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (int64, error) - LeaveVoiceChannel(ctx context.Context, userID int64) error - LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (int64, error) - LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (int64, error) - ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) - // PostgreSQL variants of the sqlite channels queries. - ListChannels(ctx context.Context) ([]ListChannelsRow, error) - ListInvites(ctx context.Context) ([]ListInvitesRow, error) - ListMembers(ctx context.Context) ([]ListMembersRow, error) - ListPlugins(ctx context.Context) ([]Plugin, error) - ListRoles(ctx context.Context) ([]Role, error) - ListUserSessions(ctx context.Context, userID int64) ([]Session, error) - LoadActiveLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) ([]RateLockout, error) - LogAudit(ctx context.Context, arg LogAuditParams) error - OpenDM(ctx context.Context, arg OpenDMParams) error - // seq is supplied by the hub so the row seq matches the wrapped-payload seq. - // The schema's BIGSERIAL still owns the id column for inserts that omit seq, - // but PersistEvent always supplies an explicit value. - PersistEvent(ctx context.Context, arg PersistEventParams) error - PluginKVDelete(ctx context.Context, arg PluginKVDeleteParams) error - PluginKVGet(ctx context.Context, arg PluginKVGetParams) ([]byte, error) - PluginKVSet(ctx context.Context, arg PluginKVSetParams) error - PruneEventsOlderThan(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) - RemoveReaction(ctx context.Context, arg RemoveReactionParams) (int64, error) - ResetAllUserStatuses(ctx context.Context) error - RevokeInvite(ctx context.Context, code string) error - SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error - SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error - SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error - SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error - SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error - SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (int64, error) - SetSetting(ctx context.Context, arg SetSettingParams) error - SoftDeleteMessage(ctx context.Context, id int64) error - TouchSession(ctx context.Context, token string) error - UnbanUser(ctx context.Context, id int64) error - UnblockUser(ctx context.Context, arg UnblockUserParams) error - UninstallPlugin(ctx context.Context, id int64) error - UpdateChannel(ctx context.Context, arg UpdateChannelParams) error - UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error - UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error - // PostgreSQL variants of the sqlite profile queries. - // UpdateUserProfile uses :execrows because postgres has no LastInsertId; - // the caller checks rows-affected for existence. - UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (int64, error) - UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error - UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) error - UpdateUserTOTPSecret(ctx context.Context, arg UpdateUserTOTPSecretParams) error - UpdateVoiceCamera(ctx context.Context, arg UpdateVoiceCameraParams) error - UpdateVoiceDeafen(ctx context.Context, arg UpdateVoiceDeafenParams) error - UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error - UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error - UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error - UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error - // PostgreSQL variants of the sqlite rate-lockout queries. - // `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. - UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error - UseInviteAtomic(ctx context.Context, code string) (int64, error) - // PostgreSQL variants of the sqlite admin queries. - UserCount(ctx context.Context) (int64, error) -} - -var _ Querier = (*Queries)(nil) diff --git a/Server/db/pgdbgen/reactions.sql.go b/Server/db/pgdbgen/reactions.sql.go deleted file mode 100644 index 69927d98..00000000 --- a/Server/db/pgdbgen/reactions.sql.go +++ /dev/null @@ -1,78 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: reactions.sql - -package pgdbgen - -import ( - "context" -) - -const addReaction = `-- name: AddReaction :exec - -INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3) -` - -type AddReactionParams struct { - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -// PostgreSQL variants of the sqlite reactions queries. -func (q *Queries) AddReaction(ctx context.Context, arg AddReactionParams) error { - _, err := q.db.Exec(ctx, addReaction, arg.MessageID, arg.UserID, arg.Emoji) - return err -} - -const getReactionCounts = `-- name: GetReactionCounts :many -SELECT emoji, COUNT(*) AS count -FROM reactions WHERE message_id = $1 -GROUP BY emoji -` - -type GetReactionCountsRow struct { - Emoji string `json:"emoji"` - Count int64 `json:"count"` -} - -func (q *Queries) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) { - rows, err := q.db.Query(ctx, getReactionCounts, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetReactionCountsRow{} - for rows.Next() { - var i GetReactionCountsRow - if err := rows.Scan(&i.Emoji, &i.Count); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const removeReaction = `-- name: RemoveReaction :execrows -DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3 -` - -type RemoveReactionParams struct { - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -func (q *Queries) RemoveReaction(ctx context.Context, arg RemoveReactionParams) (int64, error) { - result, err := q.db.Exec(ctx, removeReaction, arg.MessageID, arg.UserID, arg.Emoji) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/roles.sql.go b/Server/db/pgdbgen/roles.sql.go deleted file mode 100644 index 2da88cd1..00000000 --- a/Server/db/pgdbgen/roles.sql.go +++ /dev/null @@ -1,165 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: roles.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const getDefaultRole = `-- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = TRUE LIMIT 1 -` - -func (q *Queries) GetDefaultRole(ctx context.Context) (Role, error) { - row := q.db.QueryRow(ctx, getDefaultRole) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getRoleByID = `-- name: GetRoleByID :one - -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE id = $1 -` - -// PostgreSQL variants of the sqlite roles queries. -// `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. -func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) { - row := q.db.QueryRow(ctx, getRoleByID, id) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getRoleForUser = `-- name: GetRoleForUser :one -SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.id = $1 -` - -func (q *Queries) GetRoleForUser(ctx context.Context, id int64) (Role, error) { - row := q.db.QueryRow(ctx, getRoleForUser, id) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getUserWithRole = `-- name: GetUserWithRole :one -SELECT u.id, u.username, u.password, u.avatar, u.role_id, - u.totp_secret, u.status, u.created_at, u.last_seen, - u.banned, u.ban_reason, u.ban_expires, - r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON r.id = u.role_id -WHERE u.id = $1 -` - -type GetUserWithRoleRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - TotpSecret *string `json:"totpSecret"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - ID_2 int64 `json:"id2"` - Name string `json:"name"` - Color *string `json:"color"` - Permissions int64 `json:"permissions"` - Position int32 `json:"position"` - IsDefault bool `json:"isDefault"` -} - -func (q *Queries) GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) { - row := q.db.QueryRow(ctx, getUserWithRole, id) - var i GetUserWithRoleRow - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - &i.ID_2, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const listRoles = `-- name: ListRoles :many -SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC -` - -func (q *Queries) ListRoles(ctx context.Context) ([]Role, error) { - rows, err := q.db.Query(ctx, listRoles) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Role{} - for rows.Next() { - var i Role - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/Server/db/pgdbgen/sessions.sql.go b/Server/db/pgdbgen/sessions.sql.go deleted file mode 100644 index feeccda1..00000000 --- a/Server/db/pgdbgen/sessions.sql.go +++ /dev/null @@ -1,221 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: sessions.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec -DELETE FROM sessions WHERE expires_at < NOW() -` - -// Use native timestamp comparison instead of sqlite's strftime trick. -func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { - _, err := q.db.Exec(ctx, deleteExpiredSessions) - return err -} - -const deleteOtherSessions = `-- name: DeleteOtherSessions :execrows -DELETE FROM sessions WHERE user_id = $1 AND id != $2 -` - -type DeleteOtherSessionsParams struct { - UserID int64 `json:"userId"` - ID int64 `json:"id"` -} - -func (q *Queries) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteOtherSessions, arg.UserID, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteSessionByID = `-- name: DeleteSessionByID :exec -DELETE FROM sessions WHERE id = $1 AND user_id = $2 -` - -type DeleteSessionByIDParams struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` -} - -func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error { - _, err := q.db.Exec(ctx, deleteSessionByID, arg.ID, arg.UserID) - return err -} - -const deleteSessionByToken = `-- name: DeleteSessionByToken :exec -DELETE FROM sessions WHERE token = $1 -` - -func (q *Queries) DeleteSessionByToken(ctx context.Context, token string) error { - _, err := q.db.Exec(ctx, deleteSessionByToken, token) - return err -} - -const evictOldestSessions = `-- name: EvictOldestSessions :exec -DELETE FROM sessions WHERE id IN ( - SELECT s2.id FROM sessions AS s2 WHERE s2.user_id = $1 - ORDER BY s2.created_at DESC - OFFSET $2 -) -` - -type EvictOldestSessionsParams struct { - UserID int64 `json:"userId"` - Offset int32 `json:"offset"` -} - -// Delete all but the N most recent sessions for a user. Postgres replaces -// sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. -func (q *Queries) EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error { - _, err := q.db.Exec(ctx, evictOldestSessions, arg.UserID, arg.Offset) - return err -} - -const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE token = $1 -` - -func (q *Queries) GetSessionByTokenHash(ctx context.Context, token string) (Session, error) { - row := q.db.QueryRow(ctx, getSessionByTokenHash, token) - var i Session - err := row.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ) - return i, err -} - -const getSessionWithBanStatus = `-- name: GetSessionWithBanStatus :one -SELECT s.id, s.user_id, s.token, s.device, s.ip_address, - s.created_at, s.last_used, s.expires_at, - u.banned, u.ban_reason, u.ban_expires -FROM sessions s -JOIN users u ON s.user_id = u.id -WHERE s.token = $1 -` - -type GetSessionWithBanStatusRow struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastUsed pgtype.Timestamptz `json:"lastUsed"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` -} - -func (q *Queries) GetSessionWithBanStatus(ctx context.Context, token string) (GetSessionWithBanStatusRow, error) { - row := q.db.QueryRow(ctx, getSessionWithBanStatus, token) - var i GetSessionWithBanStatusRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const insertSession = `-- name: InsertSession :one - -INSERT INTO sessions (user_id, token, device, ip_address, expires_at) -VALUES ($1, $2, $3, $4, $5) -RETURNING id -` - -type InsertSessionParams struct { - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite sessions queries. -func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) { - row := q.db.QueryRow(ctx, insertSession, - arg.UserID, - arg.Token, - arg.Device, - arg.IpAddress, - arg.ExpiresAt, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const listUserSessions = `-- name: ListUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions -WHERE user_id = $1 -ORDER BY created_at DESC -` - -func (q *Queries) ListUserSessions(ctx context.Context, userID int64) ([]Session, error) { - rows, err := q.db.Query(ctx, listUserSessions, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Session{} - for rows.Next() { - var i Session - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const touchSession = `-- name: TouchSession :exec -UPDATE sessions SET last_used = NOW() WHERE token = $1 -` - -func (q *Queries) TouchSession(ctx context.Context, token string) error { - _, err := q.db.Exec(ctx, touchSession, token) - return err -} diff --git a/Server/db/pgdbgen/users.sql.go b/Server/db/pgdbgen/users.sql.go deleted file mode 100644 index d4878c0d..00000000 --- a/Server/db/pgdbgen/users.sql.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: users.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const banUser = `-- name: BanUser :exec -UPDATE users SET banned = TRUE, ban_reason = $1, ban_expires = $2 WHERE id = $3 -` - -type BanUserParams struct { - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - ID int64 `json:"id"` -} - -func (q *Queries) BanUser(ctx context.Context, arg BanUserParams) error { - _, err := q.db.Exec(ctx, banUser, arg.BanReason, arg.BanExpires, arg.ID) - return err -} - -const countUsers = `-- name: CountUsers :one -SELECT COUNT(*) FROM users -` - -func (q *Queries) CountUsers(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countUsers) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countUsersWithoutTOTP = `-- name: CountUsersWithoutTOTP :one -SELECT COUNT(*) FROM users WHERE banned = FALSE AND totp_secret IS NULL -` - -func (q *Queries) CountUsersWithoutTOTP(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countUsersWithoutTOTP) - var count int64 - err := row.Scan(&count) - return count, err -} - -const createUser = `-- name: CreateUser :one -INSERT INTO users (username, password, role_id) -VALUES ($1, $2, $3) -RETURNING id -` - -type CreateUserParams struct { - Username string `json:"username"` - Password string `json:"password"` - RoleID int64 `json:"roleId"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) { - row := q.db.QueryRow(ctx, createUser, arg.Username, arg.Password, arg.RoleID) - var id int64 - err := row.Scan(&id) - return id, err -} - -const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE id = $1 -` - -func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { - row := q.db.QueryRow(ctx, getUserByID, id) - var i User - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const getUserByUsername = `-- name: GetUserByUsername :one - -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE username = $1 -` - -// PostgreSQL variants of the sqlite users queries. -// Differences from sqlite: -// - `?` -> `$1`, `$2`, … -// - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. -// - `datetime('now')` -> `NOW()` -// - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) -// - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) -func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { - row := q.db.QueryRow(ctx, getUserByUsername, username) - var i User - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const listMembers = `-- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.banned = FALSE -ORDER BY u.username ASC -LIMIT 1000 -` - -type ListMembersRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Status string `json:"status"` - Lower string `json:"lower"` -} - -func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { - rows, err := q.db.Query(ctx, listMembers) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListMembersRow{} - for rows.Next() { - var i ListMembersRow - if err := rows.Scan( - &i.ID, - &i.Username, - &i.Avatar, - &i.Status, - &i.Lower, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const resetAllUserStatuses = `-- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline' -` - -func (q *Queries) ResetAllUserStatuses(ctx context.Context) error { - _, err := q.db.Exec(ctx, resetAllUserStatuses) - return err -} - -const unbanUser = `-- name: UnbanUser :exec -UPDATE users SET banned = FALSE, ban_reason = NULL, ban_expires = NULL WHERE id = $1 -` - -func (q *Queries) UnbanUser(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, unbanUser, id) - return err -} - -const updateUserStatus = `-- name: UpdateUserStatus :exec -UPDATE users SET status = $1, last_seen = NOW() WHERE id = $2 -` - -type UpdateUserStatusParams struct { - Status string `json:"status"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) error { - _, err := q.db.Exec(ctx, updateUserStatus, arg.Status, arg.ID) - return err -} - -const updateUserTOTPSecret = `-- name: UpdateUserTOTPSecret :exec -UPDATE users SET totp_secret = $1 WHERE id = $2 -` - -type UpdateUserTOTPSecretParams struct { - TotpSecret *string `json:"totpSecret"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserTOTPSecret(ctx context.Context, arg UpdateUserTOTPSecretParams) error { - _, err := q.db.Exec(ctx, updateUserTOTPSecret, arg.TotpSecret, arg.ID) - return err -} diff --git a/Server/db/pgdbgen/voice.sql.go b/Server/db/pgdbgen/voice.sql.go deleted file mode 100644 index 718218c4..00000000 --- a/Server/db/pgdbgen/voice.sql.go +++ /dev/null @@ -1,371 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: voice.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const clearAllVoiceStates = `-- name: ClearAllVoiceStates :exec -DELETE FROM voice_states -` - -func (q *Queries) ClearAllVoiceStates(ctx context.Context) error { - _, err := q.db.Exec(ctx, clearAllVoiceStates) - return err -} - -const clearVoiceState = `-- name: ClearVoiceState :exec -DELETE FROM voice_states WHERE user_id = $1 -` - -func (q *Queries) ClearVoiceState(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, clearVoiceState, userID) - return err -} - -const countActiveCameras = `-- name: CountActiveCameras :one -SELECT COUNT(*) FROM voice_states WHERE channel_id = $1 AND camera = TRUE -` - -func (q *Queries) CountActiveCameras(ctx context.Context, channelID int64) (int64, error) { - row := q.db.QueryRow(ctx, countActiveCameras, channelID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const enableCameraIfUnderLimit = `-- name: EnableCameraIfUnderLimit :execrows -UPDATE voice_states SET camera = TRUE -WHERE voice_states.user_id = $1 AND voice_states.channel_id = $2 - AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $3 AND vs2.camera = TRUE) < $4 -` - -type EnableCameraIfUnderLimitParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - ChannelID_2 int64 `json:"channelId2"` - ChannelID_3 int64 `json:"channelId3"` -} - -func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (int64, error) { - result, err := q.db.Exec(ctx, enableCameraIfUnderLimit, - arg.UserID, - arg.ChannelID, - arg.ChannelID_2, - arg.ChannelID_3, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllVoiceStates = `-- name: GetAllVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -ORDER BY vs.channel_id, vs.joined_at ASC -` - -type GetAllVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) { - rows, err := q.db.Query(ctx, getAllVoiceStates) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetAllVoiceStatesRow{} - for rows.Next() { - var i GetAllVoiceStatesRow - if err := rows.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getChannelVoiceStates = `-- name: GetChannelVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.channel_id = $1 -ORDER BY vs.joined_at ASC -` - -type GetChannelVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) { - rows, err := q.db.Query(ctx, getChannelVoiceStates, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetChannelVoiceStatesRow{} - for rows.Next() { - var i GetChannelVoiceStatesRow - if err := rows.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUserVoiceState = `-- name: GetUserVoiceState :one -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.user_id = $1 -` - -type GetUserVoiceStateRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) { - row := q.db.QueryRow(ctx, getUserVoiceState, userID) - var i GetUserVoiceStateRow - err := row.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ) - return i, err -} - -const joinVoiceChannel = `-- name: JoinVoiceChannel :exec - -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -VALUES ($1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3) -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at -` - -type JoinVoiceChannelParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -// PostgreSQL variants of the sqlite voice queries. -// voice_states boolean columns (muted, deafened, speaking, camera, -// screenshare) use FALSE/TRUE instead of 0/1. -func (q *Queries) JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error { - _, err := q.db.Exec(ctx, joinVoiceChannel, arg.UserID, arg.ChannelID, arg.JoinedAt) - return err -} - -const joinVoiceChannelIfCapacity = `-- name: JoinVoiceChannelIfCapacity :execrows -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -SELECT $1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3 -WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $4) < $5 -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at -` - -type JoinVoiceChannelIfCapacityParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` - ChannelID_2 int64 `json:"channelId2"` - ChannelID_3 int64 `json:"channelId3"` -} - -func (q *Queries) JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (int64, error) { - result, err := q.db.Exec(ctx, joinVoiceChannelIfCapacity, - arg.UserID, - arg.ChannelID, - arg.JoinedAt, - arg.ChannelID_2, - arg.ChannelID_3, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const leaveVoiceChannel = `-- name: LeaveVoiceChannel :exec -DELETE FROM voice_states WHERE user_id = $1 -` - -func (q *Queries) LeaveVoiceChannel(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, leaveVoiceChannel, userID) - return err -} - -const leaveVoiceChannelIfMatch = `-- name: LeaveVoiceChannelIfMatch :execrows -DELETE FROM voice_states WHERE user_id = $1 AND channel_id = $2 AND joined_at = $3 -` - -type LeaveVoiceChannelIfMatchParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (int64, error) { - result, err := q.db.Exec(ctx, leaveVoiceChannelIfMatch, arg.UserID, arg.ChannelID, arg.JoinedAt) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const updateVoiceCamera = `-- name: UpdateVoiceCamera :exec -UPDATE voice_states SET camera = $1 WHERE user_id = $2 -` - -type UpdateVoiceCameraParams struct { - Camera bool `json:"camera"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceCamera(ctx context.Context, arg UpdateVoiceCameraParams) error { - _, err := q.db.Exec(ctx, updateVoiceCamera, arg.Camera, arg.UserID) - return err -} - -const updateVoiceDeafen = `-- name: UpdateVoiceDeafen :exec -UPDATE voice_states SET deafened = $1 WHERE user_id = $2 -` - -type UpdateVoiceDeafenParams struct { - Deafened bool `json:"deafened"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceDeafen(ctx context.Context, arg UpdateVoiceDeafenParams) error { - _, err := q.db.Exec(ctx, updateVoiceDeafen, arg.Deafened, arg.UserID) - return err -} - -const updateVoiceMute = `-- name: UpdateVoiceMute :exec -UPDATE voice_states SET muted = $1 WHERE user_id = $2 -` - -type UpdateVoiceMuteParams struct { - Muted bool `json:"muted"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error { - _, err := q.db.Exec(ctx, updateVoiceMute, arg.Muted, arg.UserID) - return err -} - -const updateVoiceScreenshare = `-- name: UpdateVoiceScreenshare :exec -UPDATE voice_states SET screenshare = $1 WHERE user_id = $2 -` - -type UpdateVoiceScreenshareParams struct { - Screenshare bool `json:"screenshare"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error { - _, err := q.db.Exec(ctx, updateVoiceScreenshare, arg.Screenshare, arg.UserID) - return err -} - -const updateVoiceSpeaking = `-- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = $1 WHERE user_id = $2 -` - -type UpdateVoiceSpeakingParams struct { - Speaking bool `json:"speaking"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error { - _, err := q.db.Exec(ctx, updateVoiceSpeaking, arg.Speaking, arg.UserID) - return err -} diff --git a/Server/db/queries/postgres/admin.sql b/Server/db/queries/postgres/admin.sql deleted file mode 100644 index 614ed13e..00000000 --- a/Server/db/queries/postgres/admin.sql +++ /dev/null @@ -1,55 +0,0 @@ --- PostgreSQL variants of the sqlite admin queries. - --- name: UserCount :one -SELECT COUNT(*) FROM users; - --- name: CountActiveMessages :one -SELECT COUNT(*) FROM messages WHERE deleted = FALSE; - --- name: CountChannels :one -SELECT COUNT(*) FROM channels; - --- name: CountActiveInvites :one -SELECT COUNT(*) FROM invites WHERE revoked = FALSE; - --- name: ListAllUsers :many -SELECT u.id, u.username, u.avatar, u.role_id, - u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, - COALESCE(r.name, '') AS role_name -FROM users u -LEFT JOIN roles r ON r.id = u.role_id -ORDER BY u.id ASC -LIMIT $1 OFFSET $2; - --- name: UpdateUserRole :exec -UPDATE users SET role_id = $1 WHERE id = $2; - --- name: ForceLogoutUser :exec -DELETE FROM sessions WHERE user_id = $1; - --- name: GetUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE user_id = $1 -ORDER BY created_at DESC; - --- name: LogAudit :exec -INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) -VALUES ($1, $2, $3, $4, $5); - --- name: GetAuditLog :many -SELECT a.id, a.actor_id, COALESCE(u.username, '') AS actor_name, a.action, - a.target_type, a.target_id, a.detail, a.created_at -FROM audit_log a -LEFT JOIN users u ON u.id = a.actor_id -ORDER BY a.id DESC -LIMIT $1 OFFSET $2; - --- name: GetSetting :one -SELECT value FROM settings WHERE key = $1; - --- name: SetSetting :exec -INSERT INTO settings (key, value) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; - --- name: GetAllSettings :many -SELECT key, value FROM settings; diff --git a/Server/db/queries/postgres/attachments.sql b/Server/db/queries/postgres/attachments.sql deleted file mode 100644 index cf3e0f06..00000000 --- a/Server/db/queries/postgres/attachments.sql +++ /dev/null @@ -1,27 +0,0 @@ --- PostgreSQL variants of the sqlite attachments queries. - --- name: CreateAttachment :exec -INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8); - --- name: GetAttachmentByID :one -SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id -FROM attachments WHERE id = $1; - --- name: GetAttachmentWithChannel :one -SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, - a.uploaded_at, a.uploader_id, m.channel_id, c.type -FROM attachments a -LEFT JOIN messages m ON m.id = a.message_id -LEFT JOIN channels c ON c.id = m.channel_id -WHERE a.id = $1; - --- name: LinkAttachmentToMessage :execrows -UPDATE attachments SET message_id = $1 WHERE id = $2 AND message_id IS NULL; - --- Postgres timestamptz comparison — the caller passes a wall-clock time. --- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < $1 RETURNING stored_as; - --- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = $1; diff --git a/Server/db/queries/postgres/blocks.sql b/Server/db/queries/postgres/blocks.sql deleted file mode 100644 index 23dda418..00000000 --- a/Server/db/queries/postgres/blocks.sql +++ /dev/null @@ -1,18 +0,0 @@ --- PostgreSQL variants of the sqlite user block queries. --- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - --- name: BlockUser :exec -INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2) -ON CONFLICT (blocker_id, blocked_id) DO NOTHING; - --- name: UnblockUser :exec -DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2; - --- name: IsBlocked :one -SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 LIMIT 1; - --- name: IsEitherBlocked :one -SELECT 1 FROM user_blocks -WHERE (blocker_id = $1 AND blocked_id = $2) - OR (blocker_id = $3 AND blocked_id = $4) -LIMIT 1; diff --git a/Server/db/queries/postgres/channels.sql b/Server/db/queries/postgres/channels.sql deleted file mode 100644 index 0f3c2b6a..00000000 --- a/Server/db/queries/postgres/channels.sql +++ /dev/null @@ -1,69 +0,0 @@ --- PostgreSQL variants of the sqlite channels queries. - --- name: ListChannels :many -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels ORDER BY position ASC, id ASC; - --- name: GetChannel :one -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels WHERE id = $1; - --- name: CreateChannel :one -INSERT INTO channels (name, type, category, topic, position) -VALUES ($1, $2, $3, $4, $5) -RETURNING id; - --- name: UpdateChannel :exec -UPDATE channels SET name = $1, topic = $2, slow_mode = $3 WHERE id = $4; - --- name: SetChannelSlowMode :exec -UPDATE channels SET slow_mode = $1 WHERE id = $2; - --- name: SetChannelVoiceMaxUsers :exec -UPDATE channels SET voice_max_users = $1 WHERE id = $2; - --- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = $1 WHERE id = $2; - --- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = $1 WHERE id = $2; - --- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = $1 WHERE id = $2; - --- name: ArchiveChannel :exec -UPDATE channels SET archived = $1 WHERE id = $2; - --- name: DeleteChannel :exec -DELETE FROM channels WHERE id = $1; - --- name: AdminUpdateChannel :exec -UPDATE channels -SET name = $1, topic = $2, slow_mode = $3, position = $4, archived = $5 -WHERE id = $6; - --- name: UpsertChannelPermission :exec -INSERT INTO channel_overrides (channel_id, role_id, allow, deny) -VALUES ($1, $2, $3, $4) -ON CONFLICT (channel_id, role_id) DO UPDATE SET - allow = EXCLUDED.allow, - deny = EXCLUDED.deny; - --- name: GetChannelPermission :one -SELECT allow, deny FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; - --- name: GetRoleChannelPermissions :many -SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = $1; - --- name: DeleteChannelPermission :exec -DELETE FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; diff --git a/Server/db/queries/postgres/dm.sql b/Server/db/queries/postgres/dm.sql deleted file mode 100644 index 93cfbedc..00000000 --- a/Server/db/queries/postgres/dm.sql +++ /dev/null @@ -1,64 +0,0 @@ --- PostgreSQL variants of the sqlite DM queries. --- InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses --- :one with RETURNING id. --- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - --- name: InsertDMChannel :one -INSERT INTO channels (name, type) VALUES ('', 'dm') RETURNING id; - --- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($3, $4); - --- name: InsertDMOpenState :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2), ($3, $4) -ON CONFLICT (user_id, channel_id) DO NOTHING; - --- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = $1 AND dp2.user_id = $2 AND c.type = 'dm' -LIMIT 1; - --- name: OpenDM :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2) -ON CONFLICT (user_id, channel_id) DO NOTHING; - --- name: CloseDM :exec -DELETE FROM dm_open_state WHERE user_id = $1 AND channel_id = $2; - --- name: IsDMParticipant :one -SELECT user_id FROM dm_participants WHERE user_id = $1 AND channel_id = $2; - --- name: GetDMParticipantIDs :many -SELECT user_id FROM dm_participants WHERE channel_id = $1; - --- For the "last message at" and "last message content" columns, sqlite --- COALESCEs to '' (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an --- empty string, so we COALESCE to the dm_open_state.opened_at fallback and --- leave conversion to the store wrapper. --- name: GetUserDMChannels :many -SELECT - c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, - lm.id AS last_message_id, - COALESCE(lm.content, '') AS last_message, - COALESCE(lm.timestamp, dos.opened_at) AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = FALSE THEN 1 END) AS unread_count -FROM dm_open_state dos -JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != $1 -JOIN users u ON u.id = dp.user_id -LEFT JOIN messages lm ON lm.id = ( - SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = FALSE -) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $2 -WHERE dos.user_id = $3 -GROUP BY c.id, u.id, lm.id, lm.content, lm.timestamp, dos.opened_at -ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC; diff --git a/Server/db/queries/postgres/events.sql b/Server/db/queries/postgres/events.sql deleted file mode 100644 index ec332592..00000000 --- a/Server/db/queries/postgres/events.sql +++ /dev/null @@ -1,19 +0,0 @@ --- name: PersistEvent :exec --- seq is supplied by the hub so the row seq matches the wrapped-payload seq. --- The schema's BIGSERIAL still owns the id column for inserts that omit seq, --- but PersistEvent always supplies an explicit value. -INSERT INTO events (seq, event_type, channel_id, payload) -VALUES ($1, $2, $3, $4); - --- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events; - --- name: GetEventsSince :many -SELECT seq, event_type, channel_id, payload, created_at -FROM events -WHERE seq > $1 -ORDER BY seq ASC -LIMIT $2; - --- name: PruneEventsOlderThan :execrows -DELETE FROM events WHERE created_at < $1; diff --git a/Server/db/queries/postgres/invites.sql b/Server/db/queries/postgres/invites.sql deleted file mode 100644 index cd01e81f..00000000 --- a/Server/db/queries/postgres/invites.sql +++ /dev/null @@ -1,23 +0,0 @@ --- PostgreSQL variants of the sqlite invites queries. --- The expiry check uses native timestamp comparison instead of sqlite's --- strftime('%s', …) trick. - --- name: CreateInvite :exec -INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES ($1, $2, $3, $4); - --- name: GetInvite :one -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites WHERE code = $1; - --- name: UseInviteAtomic :execrows -UPDATE invites SET use_count = use_count + 1 -WHERE code = $1 AND revoked = FALSE - AND (max_uses IS NULL OR use_count < max_uses) - AND (expires_at IS NULL OR expires_at > NOW()); - --- name: RevokeInvite :exec -UPDATE invites SET revoked = TRUE WHERE code = $1; - --- name: ListInvites :many -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites ORDER BY created_at DESC LIMIT 200; diff --git a/Server/db/queries/postgres/lockouts.sql b/Server/db/queries/postgres/lockouts.sql deleted file mode 100644 index 3e607926..00000000 --- a/Server/db/queries/postgres/lockouts.sql +++ /dev/null @@ -1,15 +0,0 @@ --- PostgreSQL variants of the sqlite rate-lockout queries. --- `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. - --- name: UpsertLockout :exec -INSERT INTO rate_lockouts (key, expires_at) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET expires_at = EXCLUDED.expires_at; - --- name: LoadActiveLockouts :many -SELECT key, expires_at FROM rate_lockouts WHERE expires_at > $1; - --- name: CleanupExpiredLockouts :exec -DELETE FROM rate_lockouts WHERE expires_at <= $1; - --- name: DeleteLockout :exec -DELETE FROM rate_lockouts WHERE key = $1; diff --git a/Server/db/queries/postgres/messages.sql b/Server/db/queries/postgres/messages.sql deleted file mode 100644 index 6b73e0ee..00000000 --- a/Server/db/queries/postgres/messages.sql +++ /dev/null @@ -1,79 +0,0 @@ --- PostgreSQL variants of the sqlite messages queries. --- `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). --- The FTS search queries are NOT included here: on postgres, messages.fts is --- a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) --- and FTS queries are hand-written in the postgres-specific store dispatch, --- mirroring how sqlite's FTS5 queries live in message_queries.go. - --- name: CreateMessage :one -INSERT INTO messages (channel_id, user_id, content, reply_to) -VALUES ($1, $2, $3, $4) -RETURNING id; - --- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp -FROM messages WHERE id = $1; - --- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3; - --- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2; - --- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3; - --- name: GetMessagesForAPI :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2; - --- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.pinned = TRUE AND m.deleted = FALSE -ORDER BY m.id DESC; - --- name: EditMessageContent :exec -UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2; - --- name: SoftDeleteMessage :exec -UPDATE messages SET deleted = TRUE WHERE id = $1; - --- name: SetMessagePinned :execrows -UPDATE messages SET pinned = $1 WHERE id = $2 AND deleted = FALSE; - --- name: GetLatestMessageID :one -SELECT COALESCE(MAX(id), 0)::BIGINT FROM messages WHERE channel_id = $1 AND deleted = FALSE; - --- name: UpdateReadState :exec -INSERT INTO read_states (user_id, channel_id, last_message_id) -VALUES ($1, $2, $3) -ON CONFLICT (user_id, channel_id) DO UPDATE SET last_message_id = EXCLUDED.last_message_id; - --- name: GetChannelUnreadCounts :many -SELECT c.id, - COALESCE(MAX(m.id), 0)::BIGINT AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = FALSE THEN 1 END) AS unread -FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = FALSE -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $1 -WHERE c.type = 'text' -GROUP BY c.id; diff --git a/Server/db/queries/postgres/plugins.sql b/Server/db/queries/postgres/plugins.sql deleted file mode 100644 index d0349d86..00000000 --- a/Server/db/queries/postgres/plugins.sql +++ /dev/null @@ -1,36 +0,0 @@ --- name: InstallPlugin :one -INSERT INTO plugins (name, version, manifest_json) -VALUES ($1, $2, $3) -ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json -RETURNING id; - --- name: EnablePlugin :exec -UPDATE plugins SET enabled = TRUE WHERE id = $1; - --- name: DisablePlugin :exec -UPDATE plugins SET enabled = FALSE WHERE id = $1; - --- name: UninstallPlugin :exec -DELETE FROM plugins WHERE id = $1; - --- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1; - --- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1; - --- name: ListPlugins :many -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name; - --- name: PluginKVGet :one -SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2; - --- name: PluginKVSet :exec -INSERT INTO plugin_kv (plugin_id, key, value) -VALUES ($1, $2, $3) -ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value; - --- name: PluginKVDelete :exec -DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2; diff --git a/Server/db/queries/postgres/profile.sql b/Server/db/queries/postgres/profile.sql deleted file mode 100644 index d95a03a4..00000000 --- a/Server/db/queries/postgres/profile.sql +++ /dev/null @@ -1,9 +0,0 @@ --- PostgreSQL variants of the sqlite profile queries. --- UpdateUserProfile uses :execrows because postgres has no LastInsertId; --- the caller checks rows-affected for existence. - --- name: UpdateUserProfile :execrows -UPDATE users SET username = $1, avatar = $2 WHERE id = $3; - --- name: UpdateUserPassword :exec -UPDATE users SET password = $1 WHERE id = $2; diff --git a/Server/db/queries/postgres/reactions.sql b/Server/db/queries/postgres/reactions.sql deleted file mode 100644 index 4980c16f..00000000 --- a/Server/db/queries/postgres/reactions.sql +++ /dev/null @@ -1,12 +0,0 @@ --- PostgreSQL variants of the sqlite reactions queries. - --- name: AddReaction :exec -INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3); - --- name: RemoveReaction :execrows -DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3; - --- name: GetReactionCounts :many -SELECT emoji, COUNT(*) AS count -FROM reactions WHERE message_id = $1 -GROUP BY emoji; diff --git a/Server/db/queries/postgres/roles.sql b/Server/db/queries/postgres/roles.sql deleted file mode 100644 index a52474d9..00000000 --- a/Server/db/queries/postgres/roles.sql +++ /dev/null @@ -1,29 +0,0 @@ --- PostgreSQL variants of the sqlite roles queries. --- `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. - --- name: GetRoleByID :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE id = $1; - --- name: ListRoles :many -SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC; - --- name: GetRoleForUser :one -SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.id = $1; - --- name: GetUserWithRole :one -SELECT u.id, u.username, u.password, u.avatar, u.role_id, - u.totp_secret, u.status, u.created_at, u.last_seen, - u.banned, u.ban_reason, u.ban_expires, - r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON r.id = u.role_id -WHERE u.id = $1; - --- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = TRUE LIMIT 1; diff --git a/Server/db/queries/postgres/sessions.sql b/Server/db/queries/postgres/sessions.sql deleted file mode 100644 index 33845cd7..00000000 --- a/Server/db/queries/postgres/sessions.sql +++ /dev/null @@ -1,49 +0,0 @@ --- PostgreSQL variants of the sqlite sessions queries. - --- name: InsertSession :one -INSERT INTO sessions (user_id, token, device, ip_address, expires_at) -VALUES ($1, $2, $3, $4, $5) -RETURNING id; - --- Delete all but the N most recent sessions for a user. Postgres replaces --- sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. --- name: EvictOldestSessions :exec -DELETE FROM sessions WHERE id IN ( - SELECT s2.id FROM sessions AS s2 WHERE s2.user_id = $1 - ORDER BY s2.created_at DESC - OFFSET $2 -); - --- name: GetSessionByTokenHash :one -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE token = $1; - --- name: GetSessionWithBanStatus :one -SELECT s.id, s.user_id, s.token, s.device, s.ip_address, - s.created_at, s.last_used, s.expires_at, - u.banned, u.ban_reason, u.ban_expires -FROM sessions s -JOIN users u ON s.user_id = u.id -WHERE s.token = $1; - --- name: DeleteSessionByToken :exec -DELETE FROM sessions WHERE token = $1; - --- name: DeleteSessionByID :exec -DELETE FROM sessions WHERE id = $1 AND user_id = $2; - --- name: DeleteOtherSessions :execrows -DELETE FROM sessions WHERE user_id = $1 AND id != $2; - --- Use native timestamp comparison instead of sqlite's strftime trick. --- name: DeleteExpiredSessions :exec -DELETE FROM sessions WHERE expires_at < NOW(); - --- name: TouchSession :exec -UPDATE sessions SET last_used = NOW() WHERE token = $1; - --- name: ListUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions -WHERE user_id = $1 -ORDER BY created_at DESC; diff --git a/Server/db/queries/postgres/users.sql b/Server/db/queries/postgres/users.sql deleted file mode 100644 index 6a244152..00000000 --- a/Server/db/queries/postgres/users.sql +++ /dev/null @@ -1,51 +0,0 @@ --- PostgreSQL variants of the sqlite users queries. --- Differences from sqlite: --- - `?` -> `$1`, `$2`, … --- - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. --- - `datetime('now')` -> `NOW()` --- - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) --- - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) - --- name: GetUserByUsername :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE username = $1; - --- name: GetUserByID :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE id = $1; - --- name: CreateUser :one -INSERT INTO users (username, password, role_id) -VALUES ($1, $2, $3) -RETURNING id; - --- name: UpdateUserStatus :exec -UPDATE users SET status = $1, last_seen = NOW() WHERE id = $2; - --- name: UpdateUserTOTPSecret :exec -UPDATE users SET totp_secret = $1 WHERE id = $2; - --- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline'; - --- name: BanUser :exec -UPDATE users SET banned = TRUE, ban_reason = $1, ban_expires = $2 WHERE id = $3; - --- name: UnbanUser :exec -UPDATE users SET banned = FALSE, ban_reason = NULL, ban_expires = NULL WHERE id = $1; - --- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.banned = FALSE -ORDER BY u.username ASC -LIMIT 1000; - --- name: CountUsers :one -SELECT COUNT(*) FROM users; - --- name: CountUsersWithoutTOTP :one -SELECT COUNT(*) FROM users WHERE banned = FALSE AND totp_secret IS NULL; diff --git a/Server/db/queries/postgres/voice.sql b/Server/db/queries/postgres/voice.sql deleted file mode 100644 index c9db052a..00000000 --- a/Server/db/queries/postgres/voice.sql +++ /dev/null @@ -1,88 +0,0 @@ --- PostgreSQL variants of the sqlite voice queries. --- voice_states boolean columns (muted, deafened, speaking, camera, --- screenshare) use FALSE/TRUE instead of 0/1. - --- name: JoinVoiceChannel :exec -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -VALUES ($1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3) -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at; - --- name: JoinVoiceChannelIfCapacity :execrows -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -SELECT $1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3 -WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $4) < $5 -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at; - --- name: LeaveVoiceChannel :exec -DELETE FROM voice_states WHERE user_id = $1; - --- name: LeaveVoiceChannelIfMatch :execrows -DELETE FROM voice_states WHERE user_id = $1 AND channel_id = $2 AND joined_at = $3; - --- name: GetUserVoiceState :one -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.user_id = $1; - --- name: GetChannelVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.channel_id = $1 -ORDER BY vs.joined_at ASC; - --- name: GetAllVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -ORDER BY vs.channel_id, vs.joined_at ASC; - --- name: UpdateVoiceMute :exec -UPDATE voice_states SET muted = $1 WHERE user_id = $2; - --- name: UpdateVoiceDeafen :exec -UPDATE voice_states SET deafened = $1 WHERE user_id = $2; - --- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = $1 WHERE user_id = $2; - --- name: UpdateVoiceCamera :exec -UPDATE voice_states SET camera = $1 WHERE user_id = $2; - --- name: UpdateVoiceScreenshare :exec -UPDATE voice_states SET screenshare = $1 WHERE user_id = $2; - --- name: EnableCameraIfUnderLimit :execrows -UPDATE voice_states SET camera = TRUE -WHERE voice_states.user_id = $1 AND voice_states.channel_id = $2 - AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $3 AND vs2.camera = TRUE) < $4; - --- name: ClearVoiceState :exec -DELETE FROM voice_states WHERE user_id = $1; - --- name: ClearAllVoiceStates :exec -DELETE FROM voice_states; - --- name: CountActiveCameras :one -SELECT COUNT(*) FROM voice_states WHERE channel_id = $1 AND camera = TRUE; diff --git a/Server/go.mod b/Server/go.mod index 237e64af..48165a87 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -8,7 +8,6 @@ require ( github.com/corazawaf/coraza/v3 v3.6.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.9.1 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -71,9 +70,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jxskiss/base62 v1.1.0 // indirect github.com/kaptinlin/go-i18n v0.1.4 // indirect github.com/kaptinlin/jsonschema v0.4.6 // indirect diff --git a/Server/go.sum b/Server/go.sum index 4bd3b46a..f10fffaa 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -69,6 +69,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -121,18 +123,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcchavezs/mergefs v0.1.0 h1:7oteO7Ocl/fnfFMkoVLJxTveCjrsd//UB0j89xmnpec= github.com/jcchavezs/mergefs v0.1.0/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= @@ -177,6 +173,8 @@ github.com/livekit/server-sdk-go/v2 v2.16.0 h1:xbr6PLprgasruzEk4Qv2sHVcK6r+cebUv github.com/livekit/server-sdk-go/v2 v2.16.0/go.mod h1:+HCKTpzV21b/jvBtu+OmWbquUxaL74kHLI9ZwKmdhKU= github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae h1:yyMUG1VUd6IjV5jonMKpLXgwm9AzkfRsYisdCXc5OVI= github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -287,7 +285,6 @@ github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsB github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= diff --git a/Server/main.go b/Server/main.go index d58b62c3..e983d5c3 100644 --- a/Server/main.go +++ b/Server/main.go @@ -95,30 +95,11 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { printBanner(cfg, version, tlsCfg != nil) // ── 4. Open database + run migrations ───────────────────────────────── - // The Phase A plan calls for two backends (sqlite, postgres) selected via - // config. SQLite is the only backend currently wired through the *db.DB - // type. The PostgreSQL scaffolding is in place (schema under - // Server/migrations/postgres, sqlc query files under - // Server/db/queries/postgres, PostgresStore behind the `postgres` build - // tag in Server/store/postgres.go), but PostgresStore's query methods - // are still stubs and the handler boundary still passes *db.DB directly - // rather than store.Store. Until both of those land, selecting - // type: "postgres" refuses to start with a clear pointer at what's left. - switch dbType := cfg.Database.Type; dbType { - case "", "sqlite": - // fall through to the existing SQLite path - case "postgres": - return fmt.Errorf("database.type=postgres is configured, but the postgres " + - "backend is not yet wired into the runtime. PostgresStore exists at " + - "Server/store/postgres.go behind the `postgres` build tag, with connection " + - "lifecycle fully implemented but query methods stubbed. What's still " + - "pending: (1) run `make sqlc-generate` to produce Server/db/pgdbgen/, " + - "(2) replace the stub query methods in postgres.go with wrappers around " + - "pgdbgen, and (3) refactor api/router.go and this main.go to thread " + - "store.Store through the handler boundary instead of *db.DB. Until those " + - "land, set database.type to \"sqlite\" or omit it to start the server") - default: - return fmt.Errorf("database.type=%q is not recognised; expected \"sqlite\" or \"postgres\"", dbType) + // SQLite is the only supported backend; the unfinished Postgres + // scaffolding (stubbed query layer, never wired into the runtime) was + // removed rather than completed. + if t := cfg.Database.Type; t != "" && t != "sqlite" { + return fmt.Errorf("database.type=%q is not supported; set \"sqlite\" or omit it", t) } database, err := db.Open(cfg.Database.Path) diff --git a/Server/migrations/postgres/001_initial_schema.sql b/Server/migrations/postgres/001_initial_schema.sql deleted file mode 100644 index 07fc8ab5..00000000 --- a/Server/migrations/postgres/001_initial_schema.sql +++ /dev/null @@ -1,336 +0,0 @@ --- Migration 001 (PostgreSQL): Initial schema --- --- This is a consolidated PostgreSQL translation of the SQLite migrations --- 001-013 found in Server/migrations/. PostgreSQL is a fresh-start backend, --- so we collapse the SQLite migration history into a single canonical schema --- file. Future PostgreSQL schema changes should land as 002_*.sql, 003_*.sql, --- etc., mirroring the SQLite numbering convention. --- --- DIFFERENCES FROM SQLITE: --- - INTEGER PRIMARY KEY AUTOINCREMENT -> BIGSERIAL PRIMARY KEY --- - TEXT NOT NULL DEFAULT (datetime('now')) -> TIMESTAMPTZ NOT NULL DEFAULT NOW() --- - INTEGER (used as bool) -> BOOLEAN NOT NULL DEFAULT FALSE --- - INTEGER permission bitfield -> BIGINT NOT NULL DEFAULT 0 --- - FTS5 virtual table -> tsvector column on messages + GIN index + --- trigger to keep tsvector in sync (see "Full-text search" section). --- - SQLite triggers using RAISE(ABORT) -> native CHECK constraints. --- - COLLATE NOCASE -> CITEXT extension on the username column. --- --- The store.MessageStore.SearchMessages implementation must dispatch on --- backend type because the query syntax differs (MATCH vs @@). - -CREATE EXTENSION IF NOT EXISTS citext; - --- ── roles ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS roles ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - color TEXT, - permissions BIGINT NOT NULL DEFAULT 0, - position INTEGER NOT NULL DEFAULT 0, - is_default BOOLEAN NOT NULL DEFAULT FALSE -); - --- Default roles. Permission bitfields match the SQLite seed values. --- Member final value (7779) reflects SQLite migrations 005 and 007 combined. -INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES - (1, 'Owner', '#E74C3C', 2147483647, 100, FALSE), - (2, 'Admin', '#F39C12', 1073741823, 80, FALSE), - (3, 'Moderator', '#3498DB', 1048575, 60, FALSE), - (4, 'Member', NULL, 7779, 40, TRUE) -ON CONFLICT (id) DO NOTHING; - --- Reset the sequence past the seeded rows so user-created roles get IDs >= 5. -SELECT setval('roles_id_seq', GREATEST((SELECT MAX(id) FROM roles), 1)); - --- ── users ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - username CITEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - avatar TEXT, - role_id BIGINT NOT NULL DEFAULT 4 REFERENCES roles(id), - totp_secret TEXT, - status TEXT NOT NULL DEFAULT 'offline', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen TIMESTAMPTZ, - banned BOOLEAN NOT NULL DEFAULT FALSE, - ban_reason TEXT, - ban_expires TIMESTAMPTZ -); - --- ── sessions ──────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS sessions ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - device TEXT, - ip_address TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); -CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); - --- ── channels ──────────────────────────────────────────────────────────────── --- Includes columns from migrations 001 + 004 (voice columns). --- The CHECK constraint replaces SQLite migration 013's trigger. -CREATE TABLE IF NOT EXISTS channels ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text' - CHECK (type IN ('text', 'voice', 'dm')), - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - voice_max_users INTEGER NOT NULL DEFAULT 0, - voice_quality TEXT, - mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 25 -); - --- ── channel_overrides ─────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS channel_overrides ( - id BIGSERIAL PRIMARY KEY, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - allow BIGINT NOT NULL DEFAULT 0, - deny BIGINT NOT NULL DEFAULT 0, - UNIQUE (channel_id, role_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_role - ON channel_overrides(channel_id, role_id); - --- ── messages + full-text search ───────────────────────────────────────────── --- PostgreSQL uses a tsvector column with a GIN index instead of the SQLite --- FTS5 virtual table. The fts column is maintained automatically by a --- trigger so application code doesn't need to set it. -CREATE TABLE IF NOT EXISTS messages ( - id BIGSERIAL PRIMARY KEY, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id), - content TEXT NOT NULL, - reply_to BIGINT REFERENCES messages(id) ON DELETE SET NULL, - edited_at TIMESTAMPTZ, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - pinned BOOLEAN NOT NULL DEFAULT FALSE, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - fts tsvector -); - -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); -CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id); -CREATE INDEX IF NOT EXISTS idx_messages_fts ON messages USING GIN (fts); - -CREATE OR REPLACE FUNCTION messages_fts_update() RETURNS trigger AS $$ -BEGIN - NEW.fts := to_tsvector('simple', COALESCE(NEW.content, '')); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_messages_fts_update ON messages; -CREATE TRIGGER trg_messages_fts_update - BEFORE INSERT OR UPDATE OF content ON messages - FOR EACH ROW - EXECUTE FUNCTION messages_fts_update(); - --- ── attachments ───────────────────────────────────────────────────────────── --- Combines migrations 001 + 008 (width, height) + 010 (uploader_id). -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, - message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE, - filename TEXT NOT NULL, - stored_as TEXT NOT NULL, - mime_type TEXT NOT NULL, - size BIGINT NOT NULL, - uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - width INTEGER, - height INTEGER, - uploader_id BIGINT REFERENCES users(id) -); - -CREATE INDEX IF NOT EXISTS idx_attachments_uploader ON attachments(uploader_id); - --- ── reactions ─────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS reactions ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - emoji TEXT NOT NULL, - UNIQUE (message_id, user_id, emoji) -); - --- ── invites ───────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS invites ( - id BIGSERIAL PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - created_by BIGINT NOT NULL REFERENCES users(id), - redeemed_by BIGINT REFERENCES users(id), - max_uses INTEGER, - use_count INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - revoked BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); - --- ── read_states ───────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS read_states ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - last_message_id BIGINT NOT NULL DEFAULT 0, - mention_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, channel_id) -); - --- ── audit_log ─────────────────────────────────────────────────────────────── --- Phase-6 canonical column names (matches SQLite migration 003). -CREATE TABLE IF NOT EXISTS audit_log ( - id BIGSERIAL PRIMARY KEY, - actor_id BIGINT NOT NULL DEFAULT 0, - action TEXT NOT NULL, - target_type TEXT NOT NULL DEFAULT '', - target_id BIGINT NOT NULL DEFAULT 0, - detail TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); - --- ── login_attempts ────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS login_attempts ( - id BIGSERIAL PRIMARY KEY, - ip_address TEXT NOT NULL, - username TEXT, - success BOOLEAN NOT NULL DEFAULT FALSE, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip_address, timestamp); - --- ── settings ──────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -INSERT INTO settings (key, value) VALUES - ('server_name', 'OwnCord Server'), - ('server_icon', ''), - ('motd', 'Welcome!'), - ('max_upload_bytes', '26214400'), - ('voice_quality', 'high'), - ('require_2fa', '0'), - ('registration_open', '0'), - ('backup_schedule', 'daily'), - ('backup_retention', '7'), - ('schema_version', '1') -ON CONFLICT (key) DO NOTHING; - --- ── emoji ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS emoji ( - id BIGSERIAL PRIMARY KEY, - shortcode TEXT NOT NULL UNIQUE, - filename TEXT NOT NULL, - uploaded_by BIGINT NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── sounds ────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS sounds ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - filename TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - uploaded_by BIGINT NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── voice_states ──────────────────────────────────────────────────────────── --- Combines migrations 002 + 004 (camera, screenshare). -CREATE TABLE IF NOT EXISTS voice_states ( - user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - muted BOOLEAN NOT NULL DEFAULT FALSE, - deafened BOOLEAN NOT NULL DEFAULT FALSE, - speaking BOOLEAN NOT NULL DEFAULT FALSE, - joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - camera BOOLEAN NOT NULL DEFAULT FALSE, - screenshare BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); - --- ── direct messages ───────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS dm_participants ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (channel_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id); - -CREATE TABLE IF NOT EXISTS dm_open_state ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, channel_id) -); - --- ── rate_lockouts ─────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS rate_lockouts ( - key TEXT PRIMARY KEY, - expires_at TIMESTAMPTZ NOT NULL -); - --- ── user_blocks ───────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS user_blocks ( - blocker_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (blocker_id, blocked_id), - CHECK (blocker_id <> blocked_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id, blocker_id); - --- ── events (Phase B Step 7: event persistence) ────────────────────────────── --- Cold-storage replay buffer for WebSocket reconnections that fall outside the --- in-memory ring window. Pruned by a background goroutine after the configured --- retention window (default 24h). -CREATE TABLE IF NOT EXISTS events ( - seq BIGSERIAL PRIMARY KEY, - event_type TEXT NOT NULL, - payload BYTEA NOT NULL, - channel_id BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_events_channel_seq ON events(channel_id, seq); -CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); - --- ── plugins (Phase C Step 9: Wazero plugin runtime) ───────────────────────── -CREATE TABLE IF NOT EXISTS plugins ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - manifest_json TEXT NOT NULL, - installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS plugin_kv ( - plugin_id BIGINT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value BYTEA NOT NULL, - PRIMARY KEY (plugin_id, key) -); diff --git a/Server/migrations/postgres/migrations.go b/Server/migrations/postgres/migrations.go deleted file mode 100644 index 824b8c73..00000000 --- a/Server/migrations/postgres/migrations.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package postgres holds embedded SQL migration files for the PostgreSQL -// backend of the OwnCord server. PostgreSQL is opt-in via the -// `database.type = "postgres"` setting in owncord.yaml. -// -// PostgreSQL migrations are numbered independently from the SQLite migration -// set in Server/migrations/. The two are NOT interchangeable: PostgreSQL -// uses native types (BIGSERIAL, TIMESTAMPTZ, BOOLEAN, tsvector) and a -// consolidated initial schema rather than the historical SQLite migration -// chain. -package postgres - -import "embed" - -// FS holds all PostgreSQL migration SQL files embedded at compile time. -// -//go:embed *.sql -var FS embed.FS diff --git a/Server/plugin/manifest.go b/Server/plugin/manifest.go index 8e0afd62..8eae6faf 100644 --- a/Server/plugin/manifest.go +++ b/Server/plugin/manifest.go @@ -17,7 +17,7 @@ // go get github.com/tetratelabs/wazero // go build -tags wazero ./... // -// This mirrors the postgres / otel build-tag approach used elsewhere in the +// This mirrors the otel / wazero build-tag approach used elsewhere in the // repo so the default build stays self-contained. package plugin diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index 2be24278..c2abeb6f 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -1,7 +1,7 @@ //go:build wazero // Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with -// `-tags wazero`; matches the postgres / otel build-tag pattern used +// `-tags wazero`; matches the otel build-tag pattern used // elsewhere in the repo so the default sqlite-only build does not pull // wazero into go.mod at runtime. // diff --git a/Server/sqlc.yaml b/Server/sqlc.yaml index 60042c41..4abde8d5 100644 --- a/Server/sqlc.yaml +++ b/Server/sqlc.yaml @@ -16,23 +16,3 @@ sql: emit_empty_slices: true emit_json_tags: true json_tags_case_style: "camel" - - # ── PostgreSQL backend (Phase A Step 3) ─────────────────────────────────── - # Postgres is opt-in via database.type = "postgres" in owncord.yaml. - # The generated querier lives in a separate package (pgdbgen) so sqlite - # and postgres code can coexist without import collisions. - - engine: "postgresql" - queries: "db/queries/postgres" - schema: "migrations/postgres" - gen: - go: - package: "pgdbgen" - out: "db/pgdbgen" - sql_package: "pgx/v5" - emit_interface: true - emit_pointers_for_null_types: true - emit_prepared_queries: false - emit_exact_table_names: false - emit_empty_slices: true - emit_json_tags: true - json_tags_case_style: "camel" diff --git a/Server/store/postgres.go b/Server/store/postgres.go deleted file mode 100644 index d99377cf..00000000 --- a/Server/store/postgres.go +++ /dev/null @@ -1,833 +0,0 @@ -//go:build postgres - -// Package store — PostgreSQL backend. -// -// This file is compiled only when the `postgres` build tag is set. The -// default `go build ./...` produces a sqlite-only binary with zero postgres -// dependencies. To enable postgres support: -// -// go get github.com/jackc/pgx/v5 -// go build -tags postgres ./... -// -// The connection lifecycle methods (Open, Close, SQLDb, WithTx) are fully -// implemented against the pgx stdlib driver. Query methods are stubbed — -// they satisfy the Store interface so the type-assertion check at the -// bottom of this file passes, but each returns ErrPostgresNotImplemented at -// runtime. The stubs will be replaced incrementally as Server/db/pgdbgen/ -// is generated from the query files in Server/db/queries/postgres/ via -// `make sqlc-generate`, and PostgresStore methods migrate to wrap the -// generated querier. -// -// Until the store-everywhere refactor lands in main.go / router.go, this -// type is not yet wired into the runtime — see phase-a-foundation.md's -// "Pending" section. Constructing a PostgresStore in isolation works, but -// main.go will still refuse to start with type: "postgres" until the -// boundary refactor lands. - -package store - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - // pgx's stdlib driver exposes pgx as a database/sql driver, letting - // PostgresStore reuse the same *sql.DB patterns as SQLiteStore. Once the - // sqlc-generated pgdbgen package is wired in, this import shifts to the - // native pgxpool API for zero-overhead query execution. - _ "github.com/jackc/pgx/v5/stdlib" - - "github.com/owncord/server/config" - "github.com/owncord/server/db" -) - -// ErrPostgresNotImplemented is returned by every query method that is not -// yet backed by sqlc-generated code. It is a sentinel error so callers and -// tests can detect "postgres path reached but implementation pending". -var ErrPostgresNotImplemented = errors.New("postgres backend: query not yet implemented (awaiting sqlc-generated pgdbgen)") - -// PostgresStore implements store.Store against a PostgreSQL database. -// It wraps a *sql.DB opened with pgx's stdlib driver. The query methods are -// currently stubs; see the package-level comment for the migration path. -type PostgresStore struct { - sqlDB *sql.DB -} - -// NewPostgresStore creates a PostgresStore from an already-open *sql.DB. -// Callers that want a one-step constructor should use OpenPostgres. -func NewPostgresStore(sqlDB *sql.DB) *PostgresStore { - return &PostgresStore{sqlDB: sqlDB} -} - -// OpenPostgres dials a PostgreSQL server using the connection settings in -// cfg and returns a ready-to-use PostgresStore. The caller is responsible -// for calling Close when done. Connection pooling is handled by *sql.DB; -// cfg.MaxConns > 0 caps the pool at that size, otherwise the database/sql -// default is used. -func OpenPostgres(cfg *config.DatabaseConfig) (*PostgresStore, error) { - if cfg == nil { - return nil, errors.New("OpenPostgres: nil config") - } - dsn := fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, cfg.SSLMode, - ) - sqlDB, err := sql.Open("pgx", dsn) - if err != nil { - return nil, fmt.Errorf("OpenPostgres: open: %w", err) - } - if cfg.MaxConns > 0 { - sqlDB.SetMaxOpenConns(cfg.MaxConns) - sqlDB.SetMaxIdleConns(cfg.MaxConns) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := sqlDB.PingContext(ctx); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("OpenPostgres: ping: %w", err) - } - return &PostgresStore{sqlDB: sqlDB}, nil -} - -// Close releases the underlying database connection pool. -func (s *PostgresStore) Close() error { return s.sqlDB.Close() } - -// SQLDb returns the underlying *sql.DB for callers that need raw access -// (backup, migration runners, ad-hoc queries). -func (s *PostgresStore) SQLDb() *sql.DB { return s.sqlDB } - -// WithTx runs fn inside a transaction. The transaction is committed if fn -// returns nil, otherwise rolled back. Postgres supports full transactional -// semantics (unlike SQLite's single-writer model), so concurrent callers -// are safe. -func (s *PostgresStore) WithTx(ctx context.Context, fn func(Store) error) error { - tx, err := s.sqlDB.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("PostgresStore.WithTx: begin: %w", err) - } - if txErr := fn(s); txErr != nil { - _ = tx.Rollback() - return txErr - } - return tx.Commit() -} - -// ── MessageStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessage(id int64) (*db.Message, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) EditMessage(id, userID int64, content string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteMessage(id, userID int64, isMod bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetMessagePinned(id int64, pinned bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) AddReaction(messageID, userID int64, emoji string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) RemoveReaction(messageID, userID int64, emoji string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetReactions(messageID int64) ([]db.ReactionCount, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetLatestMessageID(channelID int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) { - return nil, ErrPostgresNotImplemented -} - -// ── ChannelStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) ListChannels() ([]db.Channel, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannel(id int64) (*db.Channel, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateChannel(id int64, name, topic string, slowMode int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteChannel(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetChannelSlowMode(id int64, slowMode int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { - return 0, 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelTypes(ids []int64) (map[int64]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── UserStore (stubs) ─────────────────────────────────────────────────────── - -func (s *PostgresStore) GetUserByID(id int64) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserByUsername(username string) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateUser(username, passwordHash string, roleID int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserProfile(userID int64, username string, avatar *string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserPassword(userID int64, newPasswordHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserStatus(id int64, status string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserTOTPSecret(id int64, secret *string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserRole(userID, roleID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ResetAllUserStatuses() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteAccount(ctx context.Context, userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListMembers() ([]db.MemberSummary, error) { - return nil, ErrPostgresNotImplemented -} - -// ── SessionStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetSessionByTokenHash(tokenHash string) (*db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteSession(tokenHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteExpiredSessions() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteSessionByID(sessionID, userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) TouchSession(tokenHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListUserSessions(userID int64) ([]db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ForceLogoutUser(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserSessions(userID int64) ([]db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -// ── RoleStore (stubs) ─────────────────────────────────────────────────────── - -func (s *PostgresStore) GetRoleByID(id int64) (*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetRoleForUser(userID int64) (*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { - return nil, nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListRoles() ([]*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -// ── InviteStore (stubs) ───────────────────────────────────────────────────── - -func (s *PostgresStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { - return "", ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetInvite(code string) (*db.Invite, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListInvites() ([]*db.Invite, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UseInviteAtomic(code string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) RevokeInvite(code string) error { - return ErrPostgresNotImplemented -} - -// ── VoiceStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) JoinVoiceChannel(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LeaveVoiceChannel(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetVoiceState(userID int64) (*db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllVoiceStates() ([]db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceMute(userID int64, muted bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceDeafen(userID int64, deafened bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ClearVoiceState(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ClearAllVoiceStates() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountActiveCameras(channelID int64) (int, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceCamera(userID int64, camera bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceScreenshare(userID int64, screenshare bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountChannelVoiceUsers(channelID int64) (int, error) { - return 0, ErrPostgresNotImplemented -} - -// ── DMStore (stubs) ───────────────────────────────────────────────────────── - -func (s *PostgresStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) { - return nil, false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) OpenDM(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CloseDM(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsDMParticipant(userID, channelID int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -// ── BlockStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) BlockUser(blockerID, blockedID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UnblockUser(blockerID, blockedID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsBlocked(blockerID, blockedID int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsEitherBlocked(userA, userB int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListBlockedUsers(blockerID int64) ([]int64, error) { - return nil, ErrPostgresNotImplemented -} - -// ── AttachmentStore (stubs) ───────────────────────────────────────────────── - -func (s *PostgresStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentByID(id string) (*db.Attachment, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── AdminStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) UserCount() (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetServerStats() (*db.ServerStats, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) BanUser(id int64, reason string, expires *time.Time) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UnbanUser(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAuditLog(limit, offset int) ([]db.AuditEntry, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminDeleteChannel(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) BackupTo(path string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) BackupToSafe(path, safeRoot string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountUsersWithoutTOTP() (int, error) { - return 0, ErrPostgresNotImplemented -} - -// ── SettingsStore (stubs) ─────────────────────────────────────────────────── - -func (s *PostgresStore) GetSetting(key string) (string, error) { - return "", ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetSetting(key, value string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllSettings() (map[string]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── EventStore (Phase B Step 7) ────────────────────────────────────────────── - -func (s *PostgresStore) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error { - _, err := s.sqlDB.ExecContext(ctx, - `INSERT INTO events (seq, event_type, channel_id, payload) VALUES ($1, $2, $3, $4)`, - seq, eventType, channelID, payload, - ) - if err != nil { - return fmt.Errorf("PersistEvent: %w", err) - } - return nil -} - -func (s *PostgresStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 - ORDER BY seq ASC - LIMIT $2`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSince: %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) -} - -func (s *PostgresStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { - if len(channelIDs) == 0 { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 AND channel_id = 0 - ORDER BY seq ASC - LIMIT $2`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) - } - - placeholders := make([]string, len(channelIDs)) - args := make([]any, 0, len(channelIDs)+2) - args = append(args, afterSeq) - for i, cid := range channelIDs { - placeholders[i] = fmt.Sprintf("$%d", i+2) - args = append(args, cid) - } - args = append(args, limit) - - query := fmt.Sprintf( - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 - AND (channel_id = 0 OR channel_id IN (%s)) - ORDER BY seq ASC - LIMIT $%d`, - strings.Join(placeholders, ","), - len(channelIDs)+2, - ) - rows, err := s.sqlDB.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) -} - -func (s *PostgresStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - res, err := s.sqlDB.ExecContext(ctx, - `DELETE FROM events WHERE created_at < $1`, - cutoff.UTC(), - ) - if err != nil { - return 0, fmt.Errorf("PruneEventsOlderThan: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("PruneEventsOlderThan RowsAffected: %w", err) - } - return n, nil -} - -func (s *PostgresStore) GetMaxEventSeq(ctx context.Context) (int64, error) { - var maxSeq int64 - err := s.sqlDB.QueryRowContext(ctx, - `SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events`, - ).Scan(&maxSeq) - if err != nil { - return 0, fmt.Errorf("GetMaxEventSeq: %w", err) - } - return maxSeq, nil -} - -// ── PluginStore (Phase C Step 9) ──────────────────────────────────────────── - -func (s *PostgresStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { - var id int64 - err := s.sqlDB.QueryRowContext(ctx, - `INSERT INTO plugins (name, version, manifest_json) - VALUES ($1, $2, $3) - ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json - RETURNING id`, - name, version, manifestJSON, - ).Scan(&id) - if err != nil { - return 0, fmt.Errorf("InstallPlugin: %w", err) - } - return id, nil -} - -func (s *PostgresStore) EnablePlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = TRUE WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) DisablePlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = FALSE WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) UninstallPlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `DELETE FROM plugins WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) { - row := s.sqlDB.QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1`, - id, - ) - return scanPgPluginRow(row) -} - -func (s *PostgresStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) { - row := s.sqlDB.QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1`, - name, - ) - return scanPgPluginRow(row) -} - -func (s *PostgresStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`, - ) - if err != nil { - return nil, fmt.Errorf("ListPlugins: %w", err) - } - defer rows.Close() - var out []db.PluginRow - for rows.Next() { - var p db.PluginRow - if err := rows.Scan(&p.ID, &p.Name, &p.Version, &p.Enabled, &p.ManifestJSON, &p.InstalledAt); err != nil { - return nil, fmt.Errorf("ListPlugins scan: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *PostgresStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { - var v []byte - err := s.sqlDB.QueryRowContext(ctx, - `SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2`, - pluginID, key, - ).Scan(&v) - if err != nil { - return nil, err - } - return v, nil -} - -func (s *PostgresStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { - _, err := s.sqlDB.ExecContext(ctx, - `INSERT INTO plugin_kv (plugin_id, key, value) VALUES ($1, $2, $3) - ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value`, - pluginID, key, value, - ) - return err -} - -func (s *PostgresStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { - _, err := s.sqlDB.ExecContext(ctx, - `DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2`, - pluginID, key, - ) - return err -} - -func (s *PostgresStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT key, value FROM plugin_kv WHERE plugin_id = $1 AND key LIKE $2 ORDER BY key LIMIT $3`, - pluginID, prefix+"%", limit, - ) - if err != nil { - return nil, fmt.Errorf("PluginKVScan: %w", err) - } - defer rows.Close() - out := make(map[string][]byte) - for rows.Next() { - var k string - var v []byte - if err := rows.Scan(&k, &v); err != nil { - return nil, err - } - out[k] = v - } - return out, rows.Err() -} - -// ── postgres scan helpers ──────────────────────────────────────────────────── - -type pgRowScanner interface { - Scan(dest ...any) error -} - -func scanPgPluginRow(row pgRowScanner) (*db.PluginRow, error) { - var p db.PluginRow - if err := row.Scan(&p.ID, &p.Name, &p.Version, &p.Enabled, &p.ManifestJSON, &p.InstalledAt); err != nil { - return nil, err - } - return &p, nil -} - -type pgRowsScanner interface { - Next() bool - Scan(dest ...any) error - Err() error -} - -func scanPgEventRows(rows pgRowsScanner) ([]db.PersistedEvent, error) { - var out []db.PersistedEvent - for rows.Next() { - var e db.PersistedEvent - if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &e.CreatedAt); err != nil { - return nil, fmt.Errorf("scanPgEventRows: %w", err) - } - out = append(out, e) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -// Compile-time interface check — fails to compile if any Store method is -// missing a PostgresStore receiver. -var _ Store = (*PostgresStore)(nil) diff --git a/Server/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go index b4b78822..fd9c3506 100644 --- a/Server/telemetry/telemetry_otel.go +++ b/Server/telemetry/telemetry_otel.go @@ -1,7 +1,7 @@ //go:build otel // Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only -// with `-tags otel`, matching the postgres / wazero build-tag pattern used +// with `-tags otel`, matching the wazero build-tag pattern used // elsewhere in the repo. The default build ships telemetry_default.go with a // no-op provider so sqlite-only binaries do not pull the OTel SDK in. // From b13adf23aa9e0dad1d298166c37f9d35a109105d Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:21:54 +0200 Subject: [PATCH 05/29] fix(plugin): re-instantiate wazero module after CPU-budget overrun (W1-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithCloseOnContextDone(true) closes the module when the per-call budget deadline fires, and nothing ever re-instantiated it — one over-budget command bricked the plugin for every user until an admin disable/enable cycle or a server restart. Now any guest-call failure that closed the module (deadline, trap, parent-context cancellation) releases inst.module, and the next dispatch lazily re-activates the same instance; a concurrent- activation guard keeps double dispatches from leaking modules. Re- instantiation resets guest in-memory state — documented at the budget site. Host-call time exclusion from the budget is documented as a requirement but not implemented: no host imports are wired into the runtime yet, so there is no host-call time to exclude today. Co-Authored-By: Claude Fable 5 --- Server/plugin/sandbox_wazero.go | 67 +++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index c2abeb6f..508ac585 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -133,6 +133,14 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // locking. The plugin must also have declared the `commands` capability in // its manifest, otherwise no binding happens. r.mu.Lock() + if inst.module != nil { + // Lost a concurrent activation race (e.g. two dispatches both saw a + // closed module); keep the winner's module and discard ours. The + // winner already handled command binding. + r.mu.Unlock() + _ = module.Close(ctx) + return nil + } inst.module = module r.mu.Unlock() if inst.Manifest.HasCapability(CapCommands) { @@ -171,10 +179,33 @@ func (r *Registry) platformDeactivate(inst *Instance) { // fall back to the not-found response. A plugin that exports // command_dispatch but lacks allocate returns a user-facing diagnostic. func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) { - if inst == nil || inst.module == nil { + if inst == nil { return nil, false } - mod, ok := inst.module.(api.Module) + r.mu.RLock() + moduleAny := inst.module + enabled := inst.Enabled + r.mu.RUnlock() + if moduleAny == nil { + // A previous CPU-budget overrun or guest trap closed the module + // (releaseClosedModule cleared it). Re-instantiate lazily so one bad + // command doesn't brick the plugin until an admin disable/enable + // cycle or a server restart. Re-instantiation resets the guest's + // in-memory state. Disabled plugins stay dark. + if !enabled { + return nil, false + } + if err := r.activate(ctx, inst); err != nil { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: reactivate: %v", inst.Manifest.Name, err)}, true + } + r.mu.RLock() + moduleAny = inst.module + r.mu.RUnlock() + if moduleAny == nil { + return nil, false + } + } + mod, ok := moduleAny.(api.Module) if !ok { return nil, false } @@ -213,7 +244,16 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch // this deadline instead of the long-lived WebSocket context. The runtime // was created WithCloseOnContextDone(true), so an expired deadline closes // the module and interrupts a runaway guest (e.g. `for {}`) — the Call - // returns an error rather than panicking, which the paths below surface. + // returns an error rather than panicking, which the paths below surface, + // and releaseClosedModule then marks the instance for lazy + // re-instantiation on the next dispatch. + // + // The budget is wall-clock over guest execution. No host imports are + // wired into the runtime yet, so host-call time cannot be attributed to + // the guest today; when host functions (host_http, host_storage, …) land, + // their execution time must be excluded from this budget — otherwise the + // floor would kill any command performing a host HTTP call (httpTimeout + // is 10s against a 100ms floor). budgetMs := inst.Manifest.Resources.CPUBudgetMs if budgetMs <= 0 { budgetMs = r.cfg.CPUBudgetMs @@ -228,6 +268,7 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch size := uint64(len(payload)) ptrs, callErr := allocFn.Call(callCtx, size) if callErr != nil || len(ptrs) == 0 { + r.releaseClosedModule(inst, mod) return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true } ptr := ptrs[0] @@ -245,6 +286,10 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch } if callErr != nil { + // A failed guest call may have closed the module (deadline, trap, + // parent-context cancellation); release it so the next dispatch + // re-instantiates instead of dispatching into a dead module forever. + r.releaseClosedModule(inst, mod) // Surface a CPU-budget overrun as a clean, specific error rather than // leaking the raw "module closed with context deadline exceeded". if callCtx.Err() == context.DeadlineExceeded { @@ -273,6 +318,22 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: dr.Reply}, true } +// releaseClosedModule drops inst.module when the wazero runtime closed it out +// from under us (CPU-budget deadline via WithCloseOnContextDone, a guest +// trap, or parent-context cancellation), so the next dispatch lazily +// re-instantiates the plugin instead of erroring on a dead module forever. +// The pointer guard keeps a concurrent re-activation's fresh module intact. +func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) { + if !mod.IsClosed() { + return + } + r.mu.Lock() + if inst.module == mod { + inst.module = nil + } + r.mu.Unlock() +} + // listExportedCommands calls the plugin's optional `list_commands` export // which returns (ptr u32, len u32) pointing to a JSON array of command name // strings. If the export is absent or returns invalid JSON, an empty slice From a3f7d63f7d3531efd844b0718304d40448555de9 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:21:55 +0200 Subject: [PATCH 06/29] test(plugin): lock CPU-budget overrun recovery (W1-1) Hand-assembled wasm fixture whose command_dispatch spins forever only for payloads over 100 bytes: baseline dispatch succeeds, an over-budget dispatch surfaces the budget error, and the next dispatch on the same plugin succeeds again via lazy re-instantiation. Co-Authored-By: Claude Fable 5 --- Server/plugin/sandbox_wazero_test.go | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 1309559b..5c91dbf3 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -23,6 +23,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/owncord/server/store" @@ -211,6 +212,118 @@ func TestWazeroDisablePluginFreesModule(t *testing.T) { } } +// spinWASM implements the command-dispatch ABI with input-dependent runtime: +// +// (module +// (memory (export "memory") 1) +// (func (export "allocate") (param i32) (result i32) i32.const 8) +// (func (export "deallocate") (param i32 i32)) +// (func (export "command_dispatch") (param i32 i32) (result i32 i32) +// local.get 1 ;; payload length +// i32.const 100 +// i32.gt_u +// if (loop br 0 end) end ;; payloads over 100 bytes spin forever +// i32.const 0 i32.const 0)) +// +// A dispatch with no args stays under 100 payload bytes and returns +// immediately; long args push the JSON payload over 100 bytes and trigger an +// infinite loop, which the CPU budget must interrupt. +var spinWASM = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // \0asm v1 + // type section: (i32)->i32, (i32,i32)->(), (i32,i32)->(i32,i32) + 0x01, 0x12, 0x03, + 0x60, 0x01, 0x7f, 0x01, 0x7f, + 0x60, 0x02, 0x7f, 0x7f, 0x00, + 0x60, 0x02, 0x7f, 0x7f, 0x02, 0x7f, 0x7f, + // function section: 3 funcs using types 0,1,2 + 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, + // memory section: 1 page, no max + 0x05, 0x03, 0x01, 0x00, 0x01, + // export section: memory, allocate, deallocate, command_dispatch + 0x07, 0x35, 0x04, + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, + 0x08, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x00, + 0x0a, 0x64, 0x65, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x01, + 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x00, 0x02, + // code section + 0x0a, 0x1e, 0x03, + // allocate: return 8 + 0x04, 0x00, 0x41, 0x08, 0x0b, + // deallocate: nop + 0x02, 0x00, 0x0b, + // command_dispatch: spin if len>100 else return (0,0) + 0x14, 0x00, + 0x20, 0x01, // local.get 1 + 0x41, 0xe4, 0x00, // i32.const 100 + 0x4b, // i32.gt_u + 0x04, 0x40, // if + 0x03, 0x40, // loop + 0x0c, 0x00, // br 0 + 0x0b, // end loop + 0x0b, // end if + 0x41, 0x00, // i32.const 0 + 0x41, 0x00, // i32.const 0 + 0x0b, // end +} + +// TestWazeroCPUBudgetOverrunDoesNotBrickPlugin locks in the W1-1 fix: an +// over-budget command must return the budget error, and the SAME plugin must +// serve the next command via lazy re-instantiation — not stay dead until an +// admin disable/enable cycle or server restart. +func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + writeTestPlugin(t, dir, "spinner", manifest, spinWASM) + + reg, mem := newWazeroTestRegistry(t, dir) + ctx := context.Background() + if err := reg.LoadAll(ctx); err != nil { + t.Fatal(err) + } + rows, _ := mem.ListPlugins(ctx) + if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil { + t.Fatalf("EnablePlugin: %v", err) + } + reg.mu.RLock() + inst := reg.plugins[rows[0].ID] + reg.mu.RUnlock() + if err := reg.RegisterCommand("spin", inst); err != nil { + t.Fatalf("RegisterCommand: %v", err) + } + + // Baseline: a small payload dispatches fine. + result, ok := reg.DispatchCommand(ctx, 1, 2, "spin", nil) + if !ok || result == nil { + t.Fatalf("baseline dispatch failed: ok=%v result=%+v", ok, result) + } + if strings.Contains(result.Reply, "CPU budget") { + t.Fatalf("baseline dispatch should not hit the budget: %q", result.Reply) + } + + // Overrun: a long arg pushes the payload over the spin threshold; the + // 100ms budget must interrupt it and surface the budget error. + result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", []string{strings.Repeat("x", 200)}) + if !ok || result == nil { + t.Fatalf("overrun dispatch returned no result: ok=%v", ok) + } + if !strings.Contains(result.Reply, "CPU budget") { + t.Fatalf("expected CPU budget error, got %q", result.Reply) + } + + // The plugin must still work: the next small dispatch re-instantiates the + // module lazily instead of dispatching into the closed one forever. + result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", nil) + if !ok || result == nil { + t.Fatalf("post-overrun dispatch failed: ok=%v result=%+v", ok, result) + } + if strings.Contains(result.Reply, "CPU budget") || strings.Contains(result.Reply, "module closed") { + t.Fatalf("plugin still bricked after overrun: %q", result.Reply) + } + if !inst.Enabled { + t.Fatal("overrun must not disable the plugin") + } +} + func TestWazeroInvalidWASMFailsActivation(t *testing.T) { dir := t.TempDir() manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` From 4b37c8024ea69411097a13e5152dc7cd36a2a849 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:29:26 +0200 Subject: [PATCH 07/29] fix(service): enforce attachment ownership atomically in the link UPDATE (W1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-attachment GetAttachmentByID pre-check loop was a check-then-link TOCTOU (the same race pattern this branch fixes elsewhere), an N+1 on the hot send path, and a hard ErrForbidden for legit retries naming an already-linked attachment. Ownership now lives in the one UPDATE that links: `AND message_id IS NULL AND (uploader_id = ? OR uploader_id IS NULL)` — a foreign attachment can never be claimed, legacy NULL-uploader rows stay claimable, and skipped rows (foreign/linked/missing) are logged but never fail the send, so retries can't hard-fail. Subsumes W2-4; the MemStore (nil,nil) GetAttachmentByID contortion is replaced by a real map-backed attachment store so the guard is testable (W3-5). Companion commit updates test callsites and adds ownership coverage. Co-Authored-By: Claude Fable 5 --- Server/db/attachment_queries.go | 19 ++++++++--- Server/service/message.go | 31 +++++------------- Server/store/memstore.go | 48 +++++++++++++++++++++------ Server/store/sqlite.go | 58 ++++++++++++++++++++------------- Server/store/store.go | 2 +- 5 files changed, 97 insertions(+), 61 deletions(-) diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index d8da2d1e..087b4c0c 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -91,23 +91,32 @@ func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { } // LinkAttachmentsToMessage sets message_id on attachments that are currently -// unlinked (message_id IS NULL). Returns the number of rows updated. -// Uses WHERE message_id IS NULL to prevent double-linking in a race. -func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { +// unlinked (message_id IS NULL) and owned by uploaderID. Legacy rows with +// uploader_id IS NULL are treated as unowned and may be claimed by any +// sender. Rows that are already linked, owned by another user, or +// nonexistent are skipped rather than errors, so a client retry of a +// partially-completed send cannot fail the whole message. This single UPDATE +// is the atomic attachment-IDOR guard for message sends: ownership is +// enforced in the same statement that links, so there is no check-then-link +// race. Returns the number of rows updated. +func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { if len(attachmentIDs) == 0 { return 0, nil } placeholders := make([]string, len(attachmentIDs)) - args := make([]any, 0, len(attachmentIDs)+1) + args := make([]any, 0, len(attachmentIDs)+2) args = append(args, messageID) for i, id := range attachmentIDs { placeholders[i] = "?" args = append(args, id) } + args = append(args, uploaderID) query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input - `UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`, + `UPDATE attachments SET message_id = ? + WHERE id IN (%s) AND message_id IS NULL + AND (uploader_id = ? OR uploader_id IS NULL)`, strings.Join(placeholders, ","), ) res, err := d.sqlDB.Exec(query, args...) diff --git a/Server/service/message.go b/Server/service/message.go index 083a16de..4a76a569 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -173,26 +173,6 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } - // Verify attachment ownership before persisting, to prevent hijacking - // another user's unlinked upload (IDOR). Any referenced attachment that - // exists must belong to the sender (uploader_id == p.UserID) and must not - // already be linked to a message (message_id IS NULL). Nonexistent IDs are - // ignored — LinkAttachmentsToMessage silently skips them. Checked before - // CreateMessage so a failed ownership check never persists a message. - for _, aid := range p.AttachmentIDs { - att, attErr := s.st.GetAttachmentByID(aid) - if attErr != nil { - slog.Error("MessageService.SendMessage GetAttachmentByID", "err", attErr, "attachment_id", aid) - return nil, fmt.Errorf("%w: failed to verify attachment ownership", ErrInternal) - } - if att == nil { - continue // nonexistent — the link query will skip it - } - if att.UploaderID == nil || *att.UploaderID != p.UserID || att.MessageID != nil { - return nil, fmt.Errorf("%w: attachment not owned by sender or already linked", ErrForbidden) - } - } - // Persist message. msgID, err := s.st.CreateMessage(p.ChannelID, p.UserID, content, p.ReplyTo) if err != nil { @@ -200,10 +180,13 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( return nil, fmt.Errorf("%w: failed to save message", ErrInternal) } - // Link attachments. + // Link attachments. Ownership is enforced atomically inside the link + // UPDATE itself (uploader match + still unlinked), so another user's + // upload, an already-linked attachment, or a nonexistent id is skipped by + // the statement — no check-then-link race and no N+1 pre-verification. var attachments []db.AttachmentInfo if len(p.AttachmentIDs) > 0 { - linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.AttachmentIDs) + linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.UserID, p.AttachmentIDs) if linkErr != nil { slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) // Cleanup: soft-delete the message. @@ -212,6 +195,10 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) } + if linked < int64(len(p.AttachmentIDs)) { + slog.Warn("MessageService.SendMessage: skipped attachments (not owned, already linked, or missing)", + "msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked) + } if linked > 0 { attMap, attErr := s.st.GetAttachmentsByMessageIDs([]int64{msgID}) if attErr != nil { diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 88c3324f..5a56a9d8 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -39,6 +39,10 @@ type MemStore struct { blocks map[int64]map[int64]bool // userID -> channelID -> lastReadMessageID readStates map[int64]map[int64]int64 + // attachment id -> row. Tracks uploader_id/message_id so the atomic + // link-ownership guard is exercised against a store that really records + // ownership instead of a (nil, nil) stub. + attachments map[string]*db.Attachment // Phase B Step 7 / Phase C Step 9 — events + plugin KV. Lazily initialised // via ensureEvents() so existing tests that constructed a bare MemStore @@ -58,6 +62,7 @@ func NewMemStore() *MemStore { channelOverrides: make(map[int64]map[int64]db.ChannelOverride), reactions: make(map[int64]map[int64]map[string]bool), dmParticipants: make(map[int64]map[int64]bool), + attachments: make(map[string]*db.Attachment), blocks: make(map[int64]map[int64]bool), readStates: make(map[int64]map[int64]int64), } @@ -273,8 +278,26 @@ func (m *MemStore) GetLatestMessageID(channelID int64) (int64, error) { return latest, nil } -func (m *MemStore) LinkAttachmentsToMessage(_ int64, _ []string) (int64, error) { - return 0, nil +// LinkAttachmentsToMessage mirrors the SQL guard in db.LinkAttachmentsToMessage: +// only unlinked attachments owned by uploaderID (or legacy rows with a nil +// uploader) are claimed; everything else is skipped, not an error. +func (m *MemStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var n int64 + for _, id := range attachmentIDs { + att, ok := m.attachments[id] + if !ok || att.MessageID != nil { + continue + } + if att.UploaderID != nil && *att.UploaderID != uploaderID { + continue + } + mid := messageID + att.MessageID = &mid + n++ + } + return n, nil } func (m *MemStore) GetAttachmentsByMessageIDs(_ []int64) (map[int64][]db.AttachmentInfo, error) { @@ -681,16 +704,21 @@ func (m *MemStore) ListBlockedUsers(_ int64) ([]int64, error) { // ---------- AttachmentStore ---------- -func (m *MemStore) CreateAttachment(_ string, _ int64, _, _, _ string, _ int64, _, _ *int) error { - panic("memstore: not implemented: CreateAttachment") +func (m *MemStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, _, _ *int) error { + m.mu.Lock() + defer m.mu.Unlock() + uid := uploaderID + m.attachments[id] = &db.Attachment{ + ID: id, UploaderID: &uid, Filename: filename, + StoredAs: storedAs, MimeType: mimeType, Size: size, + } + return nil } -func (m *MemStore) GetAttachmentByID(_ string) (*db.Attachment, error) { - // MemStore does not track attachments (CreateAttachment is unsupported and - // LinkAttachmentsToMessage is a no-op), so every lookup is "not found". - // Returning (nil, nil) rather than panicking keeps the attachment-ownership - // check in MessageService.SendMessage consistent with the no-op link path. - return nil, nil +func (m *MemStore) GetAttachmentByID(id string) (*db.Attachment, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.attachments[id], nil } func (m *MemStore) GetAttachmentWithChannel(_ string) (*db.AttachmentAccess, error) { diff --git a/Server/store/sqlite.go b/Server/store/sqlite.go index 42062c52..64f7aba5 100644 --- a/Server/store/sqlite.go +++ b/Server/store/sqlite.go @@ -109,8 +109,8 @@ func (s *SQLiteStore) GetChannelUnreadCounts(userID int64) (map[int64]db.Channel func (s *SQLiteStore) GetLatestMessageID(channelID int64) (int64, error) { return s.db.GetLatestMessageID(channelID) } -func (s *SQLiteStore) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { - return s.db.LinkAttachmentsToMessage(messageID, attachmentIDs) +func (s *SQLiteStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { + return s.db.LinkAttachmentsToMessage(messageID, uploaderID, attachmentIDs) } func (s *SQLiteStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) { return s.db.GetAttachmentsByMessageIDs(msgIDs) @@ -118,7 +118,7 @@ func (s *SQLiteStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db // ── ChannelStore ──────────────────────────────────────────────────────────── -func (s *SQLiteStore) ListChannels() ([]db.Channel, error) { return s.db.ListChannels() } +func (s *SQLiteStore) ListChannels() ([]db.Channel, error) { return s.db.ListChannels() } func (s *SQLiteStore) GetChannel(id int64) (*db.Channel, error) { return s.db.GetChannel(id) } func (s *SQLiteStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { return s.db.CreateChannel(name, chanType, category, topic, position) @@ -126,8 +126,10 @@ func (s *SQLiteStore) CreateChannel(name, chanType, category, topic string, posi func (s *SQLiteStore) UpdateChannel(id int64, name, topic string, slowMode int) error { return s.db.UpdateChannel(id, name, topic, slowMode) } -func (s *SQLiteStore) DeleteChannel(id int64) error { return s.db.DeleteChannel(id) } -func (s *SQLiteStore) SetChannelSlowMode(id int64, sm int) error { return s.db.SetChannelSlowMode(id, sm) } +func (s *SQLiteStore) DeleteChannel(id int64) error { return s.db.DeleteChannel(id) } +func (s *SQLiteStore) SetChannelSlowMode(id int64, sm int) error { + return s.db.SetChannelSlowMode(id, sm) +} func (s *SQLiteStore) SetChannelVoiceMaxUsers(id int64, max int) error { return s.db.SetChannelVoiceMaxUsers(id, max) } @@ -192,21 +194,25 @@ func (s *SQLiteStore) DeleteSession(tokenHash string) error { return s.db.Delete func (s *SQLiteStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { return s.db.DeleteOtherSessions(userID, keepSessionID) } -func (s *SQLiteStore) DeleteExpiredSessions() error { return s.db.DeleteExpiredSessions() } -func (s *SQLiteStore) DeleteSessionByID(sid, uid int64) error { return s.db.DeleteSessionByID(sid, uid) } -func (s *SQLiteStore) TouchSession(tokenHash string) error { return s.db.TouchSession(tokenHash) } +func (s *SQLiteStore) DeleteExpiredSessions() error { return s.db.DeleteExpiredSessions() } +func (s *SQLiteStore) DeleteSessionByID(sid, uid int64) error { + return s.db.DeleteSessionByID(sid, uid) +} +func (s *SQLiteStore) TouchSession(tokenHash string) error { return s.db.TouchSession(tokenHash) } func (s *SQLiteStore) ListUserSessions(userID int64) ([]db.Session, error) { return s.db.ListUserSessions(userID) } -func (s *SQLiteStore) ForceLogoutUser(userID int64) error { return s.db.ForceLogoutUser(userID) } +func (s *SQLiteStore) ForceLogoutUser(userID int64) error { return s.db.ForceLogoutUser(userID) } func (s *SQLiteStore) GetUserSessions(userID int64) ([]db.Session, error) { return s.db.GetUserSessions(userID) } // ── RoleStore ─────────────────────────────────────────────────────────────── -func (s *SQLiteStore) GetRoleByID(id int64) (*db.Role, error) { return s.db.GetRoleByID(id) } -func (s *SQLiteStore) GetRoleForUser(userID int64) (*db.Role, error) { return s.db.GetRoleForUser(userID) } +func (s *SQLiteStore) GetRoleByID(id int64) (*db.Role, error) { return s.db.GetRoleByID(id) } +func (s *SQLiteStore) GetRoleForUser(userID int64) (*db.Role, error) { + return s.db.GetRoleForUser(userID) +} func (s *SQLiteStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { return s.db.GetUserWithRole(userID) } @@ -217,10 +223,10 @@ func (s *SQLiteStore) ListRoles() ([]*db.Role, error) { return s.db.ListRoles() func (s *SQLiteStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { return s.db.CreateInvite(createdBy, maxUses, expiresAt) } -func (s *SQLiteStore) GetInvite(code string) (*db.Invite, error) { return s.db.GetInvite(code) } -func (s *SQLiteStore) ListInvites() ([]*db.Invite, error) { return s.db.ListInvites() } -func (s *SQLiteStore) UseInviteAtomic(code string) error { return s.db.UseInviteAtomic(code) } -func (s *SQLiteStore) RevokeInvite(code string) error { return s.db.RevokeInvite(code) } +func (s *SQLiteStore) GetInvite(code string) (*db.Invite, error) { return s.db.GetInvite(code) } +func (s *SQLiteStore) ListInvites() ([]*db.Invite, error) { return s.db.ListInvites() } +func (s *SQLiteStore) UseInviteAtomic(code string) error { return s.db.UseInviteAtomic(code) } +func (s *SQLiteStore) RevokeInvite(code string) error { return s.db.RevokeInvite(code) } // ── VoiceStore ────────────────────────────────────────────────────────────── @@ -240,15 +246,21 @@ func (s *SQLiteStore) GetVoiceState(userID int64) (*db.VoiceState, error) { func (s *SQLiteStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) { return s.db.GetChannelVoiceStates(channelID) } -func (s *SQLiteStore) GetAllVoiceStates() ([]db.VoiceState, error) { return s.db.GetAllVoiceStates() } -func (s *SQLiteStore) UpdateVoiceMute(userID int64, m bool) error { return s.db.UpdateVoiceMute(userID, m) } -func (s *SQLiteStore) UpdateVoiceDeafen(userID int64, d bool) error { return s.db.UpdateVoiceDeafen(userID, d) } -func (s *SQLiteStore) ClearVoiceState(userID int64) error { return s.db.ClearVoiceState(userID) } -func (s *SQLiteStore) ClearAllVoiceStates() error { return s.db.ClearAllVoiceStates() } +func (s *SQLiteStore) GetAllVoiceStates() ([]db.VoiceState, error) { return s.db.GetAllVoiceStates() } +func (s *SQLiteStore) UpdateVoiceMute(userID int64, m bool) error { + return s.db.UpdateVoiceMute(userID, m) +} +func (s *SQLiteStore) UpdateVoiceDeafen(userID int64, d bool) error { + return s.db.UpdateVoiceDeafen(userID, d) +} +func (s *SQLiteStore) ClearVoiceState(userID int64) error { return s.db.ClearVoiceState(userID) } +func (s *SQLiteStore) ClearAllVoiceStates() error { return s.db.ClearAllVoiceStates() } func (s *SQLiteStore) CountActiveCameras(channelID int64) (int, error) { return s.db.CountActiveCameras(channelID) } -func (s *SQLiteStore) UpdateVoiceCamera(userID int64, c bool) error { return s.db.UpdateVoiceCamera(userID, c) } +func (s *SQLiteStore) UpdateVoiceCamera(userID int64, c bool) error { + return s.db.UpdateVoiceCamera(userID, c) +} func (s *SQLiteStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { return s.db.EnableCameraIfUnderLimit(userID, channelID, maxVideo) } @@ -267,7 +279,7 @@ func (s *SQLiteStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, func (s *SQLiteStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) { return s.db.GetUserDMChannels(userID) } -func (s *SQLiteStore) OpenDM(userID, channelID int64) error { return s.db.OpenDM(userID, channelID) } +func (s *SQLiteStore) OpenDM(userID, channelID int64) error { return s.db.OpenDM(userID, channelID) } func (s *SQLiteStore) CloseDM(userID, channelID int64) error { return s.db.CloseDM(userID, channelID) } func (s *SQLiteStore) IsDMParticipant(userID, channelID int64) (bool, error) { return s.db.IsDMParticipant(userID, channelID) @@ -314,7 +326,7 @@ func (s *SQLiteStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) // ── AdminStore ────────────────────────────────────────────────────────────── -func (s *SQLiteStore) UserCount() (int64, error) { return s.db.UserCount() } +func (s *SQLiteStore) UserCount() (int64, error) { return s.db.UserCount() } func (s *SQLiteStore) GetServerStats() (*db.ServerStats, error) { return s.db.GetServerStats() } func (s *SQLiteStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) { return s.db.ListAllUsers(limit, offset) diff --git a/Server/store/store.go b/Server/store/store.go index 925a078b..3d6c9df3 100644 --- a/Server/store/store.go +++ b/Server/store/store.go @@ -58,7 +58,7 @@ type MessageStore interface { UpdateReadState(userID, channelID, lastReadMessageID int64) error GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) GetLatestMessageID(channelID int64) (int64, error) - LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) + LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) } From 95f85e213f121c046447d438f5b5d7b1dba1f75e Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:29:26 +0200 Subject: [PATCH 08/29] test(service): cover atomic attachment-ownership link semantics (W1-3) Mechanical signature updates for LinkAttachmentsToMessage callsites, plus: db-level OwnershipGuard test (owned links, foreign never links, legacy NULL-uploader claimable, nonexistent skipped) and a service-level SendMessage test proving skip semantics end-to-end including the already-linked retry path. Co-Authored-By: Claude Fable 5 --- Server/db/attachment_queries_test.go | 50 +++++++++++++++++++-- Server/db/coverage_boost_test.go | 2 +- Server/service/message_test.go | 66 ++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/Server/db/attachment_queries_test.go b/Server/db/attachment_queries_test.go index ab7339e4..4048b63c 100644 --- a/Server/db/attachment_queries_test.go +++ b/Server/db/attachment_queries_test.go @@ -57,7 +57,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { func TestLinkAttachmentsToMessage_Empty(t *testing.T) { database := openMigratedMemory(t) - n, err := database.LinkAttachmentsToMessage(1, nil) + n, err := database.LinkAttachmentsToMessage(1, 1, nil) if err != nil { t.Fatalf("LinkAttachmentsToMessage(nil): %v", err) } @@ -84,7 +84,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } } - n, err := database.LinkAttachmentsToMessage(msgID, []string{"att-a", "att-b"}) + n, err := database.LinkAttachmentsToMessage(msgID, userID, []string{"att-a", "att-b"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -113,7 +113,7 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { ) // Try to re-link to a different message — should skip (WHERE message_id IS NULL). - n, err := database.LinkAttachmentsToMessage(msg2, []string{"att-linked"}) + n, err := database.LinkAttachmentsToMessage(msg2, userID, []string{"att-linked"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -122,6 +122,50 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { } } +// TestLinkAttachmentsToMessage_OwnershipGuard locks the atomic IDOR guard +// (W1-3): the link UPDATE itself enforces ownership, so a foreign attachment +// can never be claimed, legacy NULL-uploader rows remain claimable, and +// nonexistent ids are skipped without failing the statement. +func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { + database := openMigratedMemory(t) + owner := seedUser(t, database, "att-owner") + other := seedUser(t, database, "att-other") + chID := seedChannel(t, database, "att-owner-ch") + msgID, _ := database.CreateMessage(chID, owner, "attachment carrier", nil) + + if err := database.CreateAttachment("att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { + t.Fatalf("CreateAttachment att-owned: %v", err) + } + if err := database.CreateAttachment("att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { + t.Fatalf("CreateAttachment att-foreign: %v", err) + } + // Legacy row from before uploader tracking: uploader_id IS NULL. + if _, err := database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size) + VALUES ('att-legacy', 'l.txt', 's-l.txt', 'text/plain', 1)`, + ); err != nil { + t.Fatalf("inserting legacy attachment: %v", err) + } + + n, err := database.LinkAttachmentsToMessage(msgID, owner, + []string{"att-owned", "att-foreign", "att-legacy", "att-missing"}) + if err != nil { + t.Fatalf("LinkAttachmentsToMessage: %v", err) + } + if n != 2 { + t.Errorf("expected 2 linked (owned + legacy), got %d", n) + } + if att, _ := database.GetAttachmentByID("att-owned"); att.MessageID == nil || *att.MessageID != msgID { + t.Error("owner's unlinked attachment should link") + } + if att, _ := database.GetAttachmentByID("att-foreign"); att.MessageID != nil { + t.Error("another user's attachment must never link (IDOR guard)") + } + if att, _ := database.GetAttachmentByID("att-legacy"); att.MessageID == nil { + t.Error("legacy NULL-uploader attachment should be claimable") + } +} + // ─── GetAttachmentsByMessageIDs ────────────────────────────────────────────── func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) { diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 4e7c7c9c..6202bafc 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -460,7 +460,7 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { // Create attachment and link it to a message. _ = database.CreateAttachment("linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) - _, _ = database.LinkAttachmentsToMessage(msgID, []string{"linked-1"}) + _, _ = database.LinkAttachmentsToMessage(msgID, userID, []string{"linked-1"}) files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") if err != nil { diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 599164a1..2edd32a3 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -56,6 +56,72 @@ func TestSendMessage_Valid(t *testing.T) { } } +// TestSendMessage_AttachmentOwnershipAtomic locks the W1-3 semantics: the +// link UPDATE itself enforces ownership, so a foreign, already-linked, or +// nonexistent attachment is skipped (never linked) while the message still +// sends — no check-then-link race, and retries cannot hard-fail. +func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AttachFiles, + Position: 1, + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUserRole(2, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice", Status: "online"}) + ms.SeedUser(&db.User{ID: 2, Username: "mallory", Status: "online"}) + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := ms.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { + t.Fatal(err) + } + if err := ms.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { + t.Fatal(err) + } + + result, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", + Content: "with files", + AttachmentIDs: []string{"att-own", "att-foreign", "att-missing"}, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if result.MessageID <= 0 { + t.Fatal("message should persist even when some attachments are skipped") + } + + own, _ := ms.GetAttachmentByID("att-own") + if own.MessageID == nil || *own.MessageID != result.MessageID { + t.Error("sender's own attachment should be linked to the new message") + } + foreign, _ := ms.GetAttachmentByID("att-foreign") + if foreign.MessageID != nil { + t.Error("another user's attachment must never be linked (IDOR guard)") + } + + // A retry naming the now-linked attachment must still send. + retry, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", + Content: "retry", + AttachmentIDs: []string{"att-own"}, + }) + if err != nil { + t.Fatalf("retry with already-linked attachment should still send: %v", err) + } + if retry.MessageID <= 0 { + t.Fatal("retry should persist a message") + } + own2, _ := ms.GetAttachmentByID("att-own") + if own2.MessageID == nil || *own2.MessageID != result.MessageID { + t.Error("already-linked attachment must stay linked to the original message") + } +} + func TestSendMessage_EmptyContent(t *testing.T) { svc, _ := newTestMessageService() From a3459e5f8010412218bf430ecf47b112fe329422 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:41:12 +0200 Subject: [PATCH 09/29] fix(admin): route admin-panel bans through ModerationService (W1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireBanAuthority (BAN_MEMBERS + role hierarchy) was wired only into ModerationService.BanUser/UnbanUser — which had zero production callers. The live path, handlePatchUser, ran a raw UPDATE with no hierarchy check, so any admin-panel actor could ban an equal- or higher-ranked user, including the owner. The ban/unban branch now calls the service (dead code becomes THE code — ban path 1 of 3 consolidated), which also audits as user_ban/user_unban, keeping the historical audit vocabulary. Authorization now runs in permission → existence → hierarchy order: an actor without ban authority sees Forbidden, never NotFound, so the ban path cannot enumerate user ids. The role+ban transaction is gone — the ban leg lives in the service, runs first, and a refusal returns before the role change executes, so a rejected ban never half-applies a PATCH. MemStore gains honest BanUser/UnbanUser so the matrix is testable. Co-Authored-By: Claude Fable 5 --- Server/admin/admin.go | 5 +- Server/admin/api.go | 5 +- Server/admin/handlers_users.go | 110 ++++++++++++++------------------- Server/api/router.go | 2 +- Server/service/moderation.go | 49 +++++++++------ Server/store/memstore.go | 22 +++++-- 6 files changed, 101 insertions(+), 92 deletions(-) diff --git a/Server/admin/admin.go b/Server/admin/admin.go index cf7c0879..b9ecad2b 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/db" + "github.com/owncord/server/service" "github.com/owncord/server/updater" ) @@ -22,11 +23,11 @@ var staticFiles embed.FS // // /api/* — admin REST API (all require ADMINISTRATOR permission) // /* — embedded static files (SPA; index.html for unknown paths) -func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator) http.Handler { +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { r := chi.NewRouter() // Admin REST API mounted at /api - r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator)) + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod)) // Static files — serve from the "static" sub-tree of the embedded FS. // The //go:embed static directive in this package embeds as "static/…", diff --git a/Server/admin/api.go b/Server/admin/api.go index 0e2210ea..276bbb7e 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/service" "github.com/owncord/server/updater" ) @@ -14,7 +15,7 @@ import ( // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes // are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit, // except for the setup endpoints which are unauthenticated. -func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator) http.Handler { +func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { r := chi.NewRouter() // Setup endpoints — unauthenticated, only functional when no users exist. @@ -39,7 +40,7 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Get("/stats", handleGetStats(database, hub)) r.Get("/users", handleListUsers(database)) - r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator)) + r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator, mod)) r.Delete("/users/{id}/sessions", handleForceLogout(database)) r.Get("/channels", handleListChannels(database)) r.Post("/channels", handleCreateChannel(database, hub)) diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index 7b61da73..c3956bd9 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -2,11 +2,13 @@ package admin import ( "encoding/json" + "errors" "fmt" "log/slog" "net/http" "github.com/owncord/server/db" + "github.com/owncord/server/service" ) // ─── User Handlers ─────────────────────────────────────────────────────────── @@ -51,7 +53,21 @@ type patchUserRequest struct { BanReason *string `json:"ban_reason"` } -func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { +// writeModerationErr maps ModerationService errors onto admin API responses. +func writeModerationErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrForbidden): + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + case errors.Is(err, service.ErrNotFound): + writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found") + case errors.Is(err, service.ErrBadRequest): + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation action failed") + } +} + +func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := pathInt64(r, "id") if err != nil { @@ -84,21 +100,38 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis return } - // Wrap role + ban updates in a transaction so both succeed or fail atomically. - tx, txErr := database.Begin() - if txErr != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to begin transaction") - return - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() + // Ban/unban first: it routes through ModerationService, which enforces + // BAN_MEMBERS + role hierarchy (the admin-auth perimeter alone does + // not — any admin-panel actor could previously ban the owner). The + // service also audits and refuses before the role change runs, so a + // rejected ban never leaves a half-applied PATCH behind. + if req.Banned != nil { + if mod == nil { + // Fail closed rather than fall back to an unchecked UPDATE. + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable") + return } - }() + banReason := "" + if req.BanReason != nil { + banReason = *req.BanReason + } + var actionErr error + if *req.Banned { + actionErr = mod.BanUser(actor, id, banReason, nil) + } else { + actionErr = mod.UnbanUser(actor, id) + } + if actionErr != nil { + writeModerationErr(w, actionErr) + return + } + if *req.Banned && hub != nil { + hub.BroadcastMemberBan(id) + } + } if req.RoleID != nil { - if _, err := tx.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { + if _, err := database.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") return } @@ -106,45 +139,6 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if permInvalidator != nil { permInvalidator.InvalidateUser(id) } - } - - banReason := "" - if req.Banned != nil { - if req.BanReason != nil { - banReason = *req.BanReason - } - if *req.Banned { - var expiresStr *string - if _, err := tx.Exec( - `UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`, - banReason, expiresStr, id, - ); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user") - return - } - slog.Warn("user banned", "actor_id", actor, "target_user", user.Username, "reason", banReason) - } else { - if _, err := tx.Exec( - `UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`, - id, - ); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user") - return - } - slog.Info("user unbanned", "actor_id", actor, "target_user", user.Username) - } - } - - if err := tx.Commit(); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit user update") - return - } - committed = true - - // Post-commit side effects: audit logging and broadcasts. - // These run outside the transaction to avoid SQLite write-lock - // contention (LogAudit uses the main *sql.DB, not the tx). - if req.RoleID != nil { _ = database.LogAudit(actor, "role_change", "user", id, fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { @@ -153,18 +147,6 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } } } - if req.Banned != nil { - if *req.Banned { - _ = database.LogAudit(actor, "user_ban", "user", id, - fmt.Sprintf("banned %s: %s", user.Username, banReason)) - if hub != nil { - hub.BroadcastMemberBan(id) - } - } else { - _ = database.LogAudit(actor, "user_unban", "user", id, - fmt.Sprintf("unbanned %s", user.Username)) - } - } updated, err := database.GetUserByID(id) if err != nil { diff --git a/Server/api/router.go b/Server/api/router.go index e6b50dfe..5cbd877c 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -233,7 +233,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Admin panel: static files + REST API (Phase 6). // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo) - adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions) + adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation) r.Group(func(r chi.Router) { r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)) r.Mount("/admin", adminHandler) diff --git a/Server/service/moderation.go b/Server/service/moderation.go index be1b39cd..12742bbf 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -22,29 +22,36 @@ func NewModerationService(st store.Store, perms *PermissionService) *ModerationS return &ModerationService{st: st, perms: perms} } -// requireBanAuthority verifies the actor is allowed to ban/unban the target. -// The actor must hold BAN_MEMBERS (or Administrator, which bypasses permission -// checks) and must outrank the target in the role hierarchy — mirroring the -// position-based hierarchy used elsewhere (see admin/middleware.go and -// permissions.OwnerRolePosition). Returns ErrForbidden when either check fails. -func (s *ModerationService) requireBanAuthority(actorID, targetID int64) error { +// requireBanPermission verifies the actor holds BAN_MEMBERS (or the +// Administrator bypass). It deliberately takes no target: it runs before any +// target lookup so an actor without ban authority always sees Forbidden and +// never NotFound — the ban path cannot be used to enumerate user ids. +func (s *ModerationService) requireBanPermission(actorID int64) error { if s.perms == nil { // No permission service wired — fail closed rather than allow unchecked bans. return fmt.Errorf("%w: permission service unavailable", ErrForbidden) } - actorRole, err := s.perms.GetRoleForUser(actorID) if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } - if !permissions.HasAdmin(actorRole.Permissions) && !permissions.HasPerm(actorRole.Permissions, permissions.BanMembers) { return fmt.Errorf("%w: missing BAN_MEMBERS permission", ErrForbidden) } + return nil +} - // Role hierarchy: the actor must strictly outrank the target so a user - // cannot ban a peer or a higher-ranked user (e.g. the owner). +// requireOutranks enforces the role hierarchy: the actor must strictly +// outrank the target so a user cannot ban a peer or a higher-ranked user +// (e.g. the owner) — mirroring the position-based hierarchy used elsewhere. +// Runs after requireBanPermission and the existence check, so only callers +// that already hold ban authority reach it. +func (s *ModerationService) requireOutranks(actorID, targetID int64) error { + actorRole, err := s.perms.GetRoleForUser(actorID) + if err != nil || actorRole == nil { + return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } targetRole, err := s.perms.GetRoleForUser(targetID) if err != nil || targetRole == nil { return fmt.Errorf("%w: failed to load target role", ErrForbidden) @@ -52,7 +59,6 @@ func (s *ModerationService) requireBanAuthority(actorID, targetID int64) error { if actorRole.Position <= targetRole.Position { return fmt.Errorf("%w: cannot moderate a user of equal or higher rank", ErrForbidden) } - return nil } @@ -77,13 +83,16 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi return fmt.Errorf("%w: cannot ban yourself", ErrBadRequest) } + // Authorization before existence: an actor without ban authority learns + // nothing about which user ids exist. + if err := s.requireBanPermission(actorID); err != nil { + return err + } target, err := s.st.GetUserByID(targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - - // Authorization: actor must hold BAN_MEMBERS and outrank the target. - if err := s.requireBanAuthority(actorID, targetID); err != nil { + if err := s.requireOutranks(actorID, targetID); err != nil { return err } @@ -91,7 +100,7 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi return fmt.Errorf("%w: failed to ban user", ErrInternal) } - if err := s.st.LogAudit(actorID, "ban", "user", targetID, reason); err != nil { + if err := s.st.LogAudit(actorID, "user_ban", "user", targetID, reason); err != nil { slog.Error("failed to log audit entry", "error", err) } @@ -105,13 +114,15 @@ func (s *ModerationService) UnbanUser(actorID, targetID int64) error { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } + // Authorization before existence — see BanUser. + if err := s.requireBanPermission(actorID); err != nil { + return err + } target, err := s.st.GetUserByID(targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - - // Authorization: actor must hold BAN_MEMBERS and outrank the target. - if err := s.requireBanAuthority(actorID, targetID); err != nil { + if err := s.requireOutranks(actorID, targetID); err != nil { return err } @@ -119,7 +130,7 @@ func (s *ModerationService) UnbanUser(actorID, targetID int64) error { return fmt.Errorf("%w: failed to unban user", ErrInternal) } - if err := s.st.LogAudit(actorID, "unban", "user", targetID, ""); err != nil { + if err := s.st.LogAudit(actorID, "user_unban", "user", targetID, ""); err != nil { slog.Error("failed to log audit entry", "error", err) } diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 5a56a9d8..8837b2e9 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -743,12 +743,26 @@ func (m *MemStore) ListAllUsers(_ int, _ int) ([]db.UserWithRole, error) { panic("memstore: not implemented: ListAllUsers") } -func (m *MemStore) BanUser(_ int64, _ string, _ *time.Time) error { - panic("memstore: not implemented: BanUser") +func (m *MemStore) BanUser(userID int64, reason string, _ *time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.Banned = true + r := reason + u.BanReason = &r + } + return nil } -func (m *MemStore) UnbanUser(_ int64) error { - panic("memstore: not implemented: UnbanUser") +func (m *MemStore) UnbanUser(userID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.Banned = false + u.BanReason = nil + u.BanExpires = nil + } + return nil } func (m *MemStore) LogAudit(_ int64, _, _ string, _ int64, _ string) error { From 94d8c2f827e82ff3bd97ce6b5c74701ba98c9593 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:41:12 +0200 Subject: [PATCH 10/29] test(admin): cover ban authorization matrix (W1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service level: BAN_MEMBERS refusal (Forbidden even for nonexistent targets — no id enumeration), equal-rank and owner-target hierarchy refusals, authorized ban/unban round-trip, self-ban rejection. Admin API level: equal-rank owner ban 403s, a lower-positioned ADMINISTRATOR cannot ban the owner, downward bans still work. All existing NewAdminAPI/NewHandler test callsites now inject a real ModerationService so the production authorization runs in every PATCH-user test. Co-Authored-By: Claude Fable 5 --- Server/admin/admin_handler_test.go | 20 +-- Server/admin/api_edge_cases_test.go | 54 +++---- Server/admin/api_test.go | 172 ++++++++++++++++------ Server/admin/handlers_backup_test.go | 26 ++-- Server/admin/handlers_channels_test.go | 14 +- Server/admin/middleware_and_spawn_test.go | 2 +- Server/admin/middleware_coverage_test.go | 6 +- Server/admin/setup_handler_test.go | 14 +- Server/admin/update_handlers_test.go | 24 +-- Server/service/moderation_test.go | 107 ++++++++++++++ 10 files changed, 313 insertions(+), 126 deletions(-) create mode 100644 Server/service/moderation_test.go diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 8ba8ede0..37b68e14 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -19,7 +19,7 @@ import ( // http.Handler with all dependencies wired. func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) if h == nil { t.Fatal("NewHandler returned nil handler") } @@ -29,7 +29,7 @@ func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { // responds with 200 and HTML content (the embedded admin SPA). func TestNewHandler_ServesStaticRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -60,7 +60,7 @@ func TestNewHandler_ServesStaticRoot(t *testing.T) { // Content-Security-Policy header allowing inline scripts and styles. func TestNewHandler_SetsCSPOnRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -76,7 +76,7 @@ func TestNewHandler_SetsCSPOnRoot(t *testing.T) { // through the NewHandler-returned handler (setup/status endpoint is unauthenticated). func TestNewHandler_APIRoutesMounted(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) w := httptest.NewRecorder() @@ -92,7 +92,7 @@ func TestNewHandler_APIRoutesMounted(t *testing.T) { // /api require a valid token. func TestNewHandler_AuthProtectedRoute(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // /api/stats requires authentication req := httptest.NewRequest(http.MethodGet, "/api/stats", nil) @@ -109,7 +109,7 @@ func TestNewHandler_AuthProtectedRoute(t *testing.T) { func TestNewHandler_WithUpdater(t *testing.T) { database := openAdminTestDB(t) u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") - h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database)) if h == nil { t.Fatal("NewHandler with updater returned nil handler") } @@ -148,7 +148,7 @@ func TestHandler_ServesEmbeddedFiles(t *testing.T) { // (position == 100) can reach backup endpoints. func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // createAdminUser creates an Owner-role user (role_id=1, position=100) ownerToken := createAdminUser(t, database) @@ -183,7 +183,7 @@ func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { // (position < 100) cannot reach owner-only endpoints. func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Create admin user (role_id=2, position=80) adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2) @@ -201,7 +201,7 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { // reach owner-only endpoints. func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) memberToken := createMemberUser(t, database) @@ -218,7 +218,7 @@ func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { // rejected before reaching ownerOnlyMiddleware. func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index a730a34d..2060eac4 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -21,7 +21,7 @@ import ( // their own account via the admin panel. func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // The admin user created by createAdminUser has id=1. We try to patch id=1. @@ -37,7 +37,7 @@ func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { // banned user unbans them and returns 200. func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create and ban a target user first. @@ -61,7 +61,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { // TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("invalidbody", "hash", 3) @@ -83,7 +83,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { // "type" field causes the channel to be created with type "text". func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -108,7 +108,7 @@ func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { // TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400. func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json"))) @@ -128,7 +128,7 @@ func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { // the URL returns 400. func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil) @@ -144,7 +144,7 @@ func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0) @@ -166,7 +166,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { // to 500 (testing the queryInt cap branch). func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Passing limit=9999 should be silently capped to 500. @@ -183,7 +183,7 @@ func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { // when no updater is configured. func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -199,7 +199,7 @@ func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { // returns 400. func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil) @@ -215,7 +215,7 @@ func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -231,7 +231,7 @@ func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { // TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work. func TestAdminAPI_AuditLog_Pagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create several audit entries. @@ -262,7 +262,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { // hub is nil (the OnlineCount field defaults to 0). func TestAdminAPI_Stats_NilHub(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -289,7 +289,7 @@ func TestAdminAPI_Stats_NilHub(t *testing.T) { // falls back to the default (testing the queryInt error-fallback branch). func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil) @@ -303,7 +303,7 @@ func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { // the default (testing the n < 1 branch of queryInt). func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // limit=0 triggers the n < 1 fallback in queryInt @@ -321,7 +321,7 @@ func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { // BroadcastMemberBan). func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("ban-nohub", "hash", 3) @@ -346,7 +346,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { database := openAdminTestDB(t) logBuf := admin.NewRingBuffer(8) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database)) token := createAdminUser(t, database) ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil) @@ -441,7 +441,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { // around BroadcastMemberUpdate). func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("role-nohub", "hash", 3) @@ -466,7 +466,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { // providing ban_reason is accepted (reason defaults to empty string). func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("banwithout", "hash", 3) @@ -487,7 +487,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3) @@ -509,7 +509,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { // needs_setup=true when the database has no users. func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -529,7 +529,7 @@ func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { // TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist. func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) _, _ = database.CreateUser("existing", "hash", 1) @@ -550,7 +550,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { // session, channel, and invite. func TestAdminAPI_Setup_Success(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "owner", @@ -581,7 +581,7 @@ func TestAdminAPI_Setup_Success(t *testing.T) { // when users already exist. func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) _, _ = database.CreateUser("existing", "hash", 1) @@ -600,7 +600,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { // username or password returns 400. func TestAdminAPI_Setup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "", @@ -616,7 +616,7 @@ func TestAdminAPI_Setup_MissingFields(t *testing.T) { // TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected. func TestAdminAPI_Setup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "owner", @@ -632,7 +632,7 @@ func TestAdminAPI_Setup_WeakPassword(t *testing.T) { // TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_Setup_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json"))) w := httptest.NewRecorder() diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 30faa90a..3107b3d3 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -13,8 +13,20 @@ import ( "github.com/owncord/server/admin" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/service" + "github.com/owncord/server/store" ) +// newTestModService builds a real ModerationService over the test database so +// PATCH-user ban paths exercise the production authorization (BAN_MEMBERS + +// role hierarchy) instead of a stub. +func newTestModService(database *db.DB) *service.ModerationService { + st := store.NewSQLiteStore(database) + checker := permissions.NewChecker(st) + return service.NewModerationService(st, service.NewPermissionService(st, checker)) +} + // adminSchema is a minimal in-memory schema for admin API tests. var adminSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( @@ -198,7 +210,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b func TestAdminAPI_Stats_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -221,7 +233,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) { func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -232,7 +244,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { func TestAdminAPI_Stats_Forbidden(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createMemberUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -246,7 +258,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) { func TestAdminAPI_ListUsers_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil) @@ -267,7 +279,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) { func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // No query params — should use defaults @@ -280,7 +292,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/users", "", nil) @@ -291,9 +303,77 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { // ─── PATCH /admin/api/users/{id} ───────────────────────────────────────────── +// TestAdminAPI_PatchUser_BanHierarchy locks the W1-4 fix: the admin-auth +// perimeter alone no longer authorizes bans — ModerationService's role +// hierarchy runs on the live PATCH path, so an admin-panel actor cannot ban +// an equal- or higher-ranked user (previously any panel actor could ban the +// owner via the raw UPDATE). +func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + ownerToken := createAdminUser(t, database) // Owner role (pos 100) + + // A second owner-rank user: equal position, cannot be banned. + peerUID, err := database.CreateUser("peerowner", "$2a$12$placeholder", 1) + if err != nil { + t.Fatalf("CreateUser peerowner: %v", err) + } + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(peerUID), ownerToken, + map[string]any{"banned": true}) + if w.Code != http.StatusForbidden { + t.Fatalf("equal-rank ban: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(peerUID); u.Banned { + t.Fatal("equal-rank target must not be banned") + } + + // A lower-positioned role that still holds ADMINISTRATOR (panel access): + // its holder must not be able to ban the higher-ranked owner. + if _, err := database.Exec( + `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'JuniorAdmin', ?, 50, 0)`, + permissions.Administrator, + ); err != nil { + t.Fatalf("inserting junior admin role: %v", err) + } + juniorUID, err := database.CreateUser("junioradmin", "$2a$12$placeholder", 9) + if err != nil { + t.Fatalf("CreateUser junioradmin: %v", err) + } + juniorToken := "junior-token-" + t.Name() + if _, err := database.CreateSession(juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession junior: %v", err) + } + ownerUser, err := database.GetUserByUsername("adminuser") + if err != nil || ownerUser == nil { + t.Fatalf("GetUserByUsername adminuser: %v", err) + } + w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(ownerUser.ID), juniorToken, + map[string]any{"banned": true}) + if w.Code != http.StatusForbidden { + t.Fatalf("junior bans owner: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(ownerUser.ID); u.Banned { + t.Fatal("owner must not be banned by a lower rank") + } + + // Downward ban still works: junior admin (pos 50) bans a member (pos 40). + memberUID, err := database.CreateUser("banme", "$2a$12$placeholder", 3) + if err != nil { + t.Fatalf("CreateUser banme: %v", err) + } + w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(memberUID), juniorToken, + map[string]any{"banned": true, "ban_reason": "spam"}) + if w.Code != http.StatusOK { + t.Fatalf("junior bans member: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(memberUID); !u.Banned { + t.Fatal("member should be banned by higher-ranked actor") + } +} + func TestAdminAPI_PatchUser_BanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user @@ -321,7 +401,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolechange", "hash", 3) @@ -343,7 +423,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { func TestAdminAPI_PatchUser_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"banned": true} @@ -356,7 +436,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) { func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil) @@ -370,7 +450,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { func TestAdminAPI_ForceLogout_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutme", "hash", 3) @@ -390,7 +470,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil) @@ -403,7 +483,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { func TestAdminAPI_ListChannels_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) _, _ = database.AdminCreateChannel("general", "text", "", "", 0) @@ -427,7 +507,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { func TestAdminAPI_CreateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -454,7 +534,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) { func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -471,7 +551,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { func TestAdminAPI_UpdateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) @@ -492,7 +572,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -507,7 +587,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { func TestAdminAPI_DeleteChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0) @@ -521,7 +601,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil) @@ -535,7 +615,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { func TestAdminAPI_AuditLog_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) uid, _ := database.CreateUser("actor", "hash", 1) @@ -558,7 +638,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { func TestAdminAPI_AuditLog_Empty(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil) @@ -578,7 +658,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) { func TestAdminAPI_GetSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/settings", token, nil) @@ -600,7 +680,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -625,7 +705,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json"))) @@ -642,7 +722,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) adminUID, _ := database.CreateUser("adminonly", "hash", 2) @@ -659,7 +739,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) @@ -676,7 +756,7 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { // which logs an audit entry containing the actor_id. func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user to act on. @@ -710,7 +790,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // DELETE /users/{id}/sessions path. func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutctx", "hash", 3) @@ -742,7 +822,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { // returns 400 without writing anything to the database. func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -768,7 +848,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { // containing both valid and invalid keys is rejected entirely (no partial write). func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -812,7 +892,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { for _, key := range whitelistedKeys { t.Run(key, func(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) value := "testvalue" @@ -833,7 +913,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { // (no-op update) is accepted and returns the current settings. func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{} @@ -846,7 +926,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -862,7 +942,7 @@ func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrationClosed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { @@ -882,7 +962,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -901,7 +981,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { // expose the PasswordHash field in any returned user object. func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a second user so the list is non-trivial. @@ -928,7 +1008,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { // expose the TOTPSecret field. func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -947,7 +1027,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { // are still present after the sensitive-field removal. func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -976,7 +1056,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { // not expose PasswordHash in the returned user object. func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) @@ -1004,7 +1084,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { // not expose TOTPSecret in the returned user object. func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchtotp", "hash", 3) @@ -1077,7 +1157,7 @@ func (m *mockHub) ClientCount() int { func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -1100,7 +1180,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) // nil hub: handler must not panic - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "safe-channel", "type": "text"} @@ -1114,7 +1194,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) @@ -1135,7 +1215,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) @@ -1150,7 +1230,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0) @@ -1170,7 +1250,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 748cd903..86299f41 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -40,7 +40,7 @@ func chdirTemp(t *testing.T) string { func TestHandleBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -75,7 +75,7 @@ func TestHandleBackup_Success(t *testing.T) { func TestHandleBackup_RequiresOwner(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("backupadmin", "hash", 2) token := "backup-admin-token" @@ -95,7 +95,7 @@ func TestHandleBackup_RequiresOwner(t *testing.T) { func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/backups", token, nil) @@ -118,7 +118,7 @@ func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a backup first. @@ -160,7 +160,7 @@ func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { func TestHandleDeleteBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a real backup file to delete. @@ -191,7 +191,7 @@ func TestHandleDeleteBackup_Success(t *testing.T) { func TestHandleDeleteBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil) @@ -206,7 +206,7 @@ func TestHandleDeleteBackup_NotFound(t *testing.T) { func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // The chi router URL-decodes the path parameter, so ".." arrives decoded. @@ -224,7 +224,7 @@ func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("deladmin", "hash", 2) token := "del-admin-token" @@ -249,7 +249,7 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { func TestHandleRestoreBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Set up backup and data directories. @@ -293,7 +293,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) { func TestHandleRestoreBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil) @@ -308,7 +308,7 @@ func TestHandleRestoreBackup_NotFound(t *testing.T) { func TestHandleRestoreBackup_InvalidName(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil) @@ -324,7 +324,7 @@ func TestHandleRestoreBackup_InvalidName(t *testing.T) { func TestHandleListBackups_ErrorReadingDir(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create data/ directory but make "backups" a file instead of a directory. @@ -352,7 +352,7 @@ func TestHandleListBackups_ErrorReadingDir(t *testing.T) { func TestHandleRestoreBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("restoreadmin", "hash", 2) token := "restore-admin-token" diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index d8eaf3d9..e8d2c05f 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -12,7 +12,7 @@ import ( func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -28,7 +28,7 @@ func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -44,7 +44,7 @@ func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -60,7 +60,7 @@ func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -83,7 +83,7 @@ func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -99,7 +99,7 @@ func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -115,7 +115,7 @@ func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { func TestCreateChannel_CaseInsensitiveVoiceCategory(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // "VOICE" in uppercase should still be treated as a voice category diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index e1a5bc99..8b287829 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -249,7 +249,7 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) { // role_id has been set to a nonexistent value returns 401. func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) { database := openWhiteboxTestDB(t) - handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil) uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1) if err != nil { diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index 982efe99..2c3a6f69 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -18,7 +18,7 @@ import ( // session has expired is rejected with 401. func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. @@ -52,7 +52,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // Authorization header returns 401. func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -65,7 +65,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { // sessions table returns 401. func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil) diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 1341183f..8bbb57b2 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -11,7 +11,7 @@ import ( func TestSetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -32,7 +32,7 @@ func TestSetupStatus_NeedsSetup(t *testing.T) { func TestSetupStatus_NoSetupNeeded(t *testing.T) { database := openAdminTestDB(t) createAdminUser(t, database) // Create a user first - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -52,7 +52,7 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) { func TestSetup_CreatesOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "myadmin", @@ -97,7 +97,7 @@ func TestSetup_CreatesOwner(t *testing.T) { func TestSetup_BlockedAfterFirstUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // First setup succeeds. rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ @@ -120,7 +120,7 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) { func TestSetup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "admin", @@ -133,7 +133,7 @@ func TestSetup_WeakPassword(t *testing.T) { func TestSetup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "", @@ -148,7 +148,7 @@ func TestSetup_MissingFields(t *testing.T) { // server and asserts that exactly one owner is created (BUG-119). func TestSetup_ConcurrentRace(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) const goroutines = 20 results := make(chan int, goroutines) diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index 31923ed7..d9d3b8b8 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -34,7 +34,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -73,7 +73,7 @@ func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -106,7 +106,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -123,7 +123,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/updates", "", nil) if w.Code != http.StatusUnauthorized { @@ -133,7 +133,7 @@ func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) { func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create admin user (not owner - role 2) adminUID, _ := database.CreateUser("adminonly2", "hash", 2) @@ -153,7 +153,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) { database := openAdminTestDB(t) // nil updater — the endpoint should return 503 - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -166,7 +166,7 @@ func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) { // in the 503 response. func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -198,7 +198,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -229,7 +229,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -257,7 +257,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -278,7 +278,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { // unauthenticated requests to POST /updates/apply. func TestAdminAPI_ApplyUpdate_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil) if w.Code != http.StatusUnauthorized { @@ -345,7 +345,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { // the important thing is that the code path is executed. database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go new file mode 100644 index 00000000..1e6146f7 --- /dev/null +++ b/Server/service/moderation_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "errors" + "fmt" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/store" +) + +// newTestModerationService seeds a MemStore with a role hierarchy: +// owner (pos 100, Administrator) > mod (pos 80, BanMembers) > member (pos 40). +// Users: 1=owner, 2=mod, 3=member, 4=member, 5=mod (equal rank to 2). +func newTestModerationService() (*ModerationService, *store.MemStore) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100}) + ms.SeedRole(&db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80}) + ms.SeedRole(&db.Role{ID: 3, Name: "member", Permissions: permissions.SendMessages, Position: 40}) + for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 3, 5: 2} { + ms.SeedUserRole(userID, roleID) + ms.SeedUser(&db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"}) + } + checker := permissions.NewChecker(ms) + return NewModerationService(ms, NewPermissionService(ms, checker)), ms +} + +func TestBanUser_RequiresBanPermission(t *testing.T) { + svc, _ := newTestModerationService() + + // A member without BAN_MEMBERS is refused. + if err := svc.BanUser(3, 4, "nope", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("member ban attempt: want ErrForbidden, got %v", err) + } + // And gets Forbidden — not NotFound — for a nonexistent target, so the + // ban path cannot be used to enumerate user ids. + if err := svc.BanUser(3, 999, "probe", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err) + } +} + +func TestBanUser_HierarchyEnforced(t *testing.T) { + svc, ms := newTestModerationService() + + // Equal rank: mod cannot ban mod. + if err := svc.BanUser(2, 5, "peer", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("equal-rank ban: want ErrForbidden, got %v", err) + } + // Higher rank target: mod cannot ban the owner. + if err := svc.BanUser(2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("ban owner: want ErrForbidden, got %v", err) + } + owner, _ := ms.GetUserByID(1) + if owner.Banned { + t.Fatal("owner must not be banned") + } +} + +func TestBanUser_AuthorizedSucceeds(t *testing.T) { + svc, ms := newTestModerationService() + + if err := svc.BanUser(2, 3, "spam", nil); err != nil { + t.Fatalf("authorized ban: %v", err) + } + target, _ := ms.GetUserByID(3) + if !target.Banned { + t.Fatal("target should be banned") + } + if target.BanReason == nil || *target.BanReason != "spam" { + t.Fatalf("ban reason not recorded: %v", target.BanReason) + } + + // Authorized actor gets a real NotFound for a missing target. + if err := svc.BanUser(2, 999, "gone", nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("authorized ban of missing id: want ErrNotFound, got %v", err) + } + // Self-ban is a bad request regardless of authority. + if err := svc.BanUser(2, 2, "self", nil); !errors.Is(err, ErrBadRequest) { + t.Fatalf("self ban: want ErrBadRequest, got %v", err) + } +} + +func TestUnbanUser_AuthorizationMatrix(t *testing.T) { + svc, ms := newTestModerationService() + + if err := svc.BanUser(1, 3, "setup", nil); err != nil { + t.Fatalf("setup ban: %v", err) + } + + // No BAN_MEMBERS → Forbidden (member 4 trying to unban member 3). + if err := svc.UnbanUser(4, 3); !errors.Is(err, ErrForbidden) { + t.Fatalf("member unban: want ErrForbidden, got %v", err) + } + // Equal rank → Forbidden. + if err := svc.UnbanUser(2, 5); !errors.Is(err, ErrForbidden) { + t.Fatalf("equal-rank unban: want ErrForbidden, got %v", err) + } + // Authorized → succeeds. + if err := svc.UnbanUser(2, 3); err != nil { + t.Fatalf("authorized unban: %v", err) + } + target, _ := ms.GetUserByID(3) + if target.Banned { + t.Fatal("target should be unbanned") + } +} From 5df452299ba0212b08a83f7ce74baa859d31c06b Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:46:28 +0200 Subject: [PATCH 11/29] fix(ws): key E2EE offer rate limit per sender+target (W1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rotation is a burst of one offer per peer (join/leave and the periodic re-key), but the limiter was keyed per sender at 5/sec — in calls with 6+ participants the 6th+ peer's offer was silently rate-limited, that peer never received the rotated key, and their audio never decrypted again. Keying per (sender, target) admits any rotation burst regardless of channel size while still capping repeated offers at a single victim, which is the abuse the limit exists for (an offer can force the target to re-key or disconnect). Co-Authored-By: Claude Fable 5 --- Server/ws/voice_e2ee.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 88204041..3263eb9b 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -147,7 +147,16 @@ func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, dep offerCmd := cmd.(VoiceE2EEOfferCmd) voiceChID := info.VoiceChannelID - ratKey := fmt.Sprintf("voice_e2ee_offer:%d", info.UserID) + // A legitimate rotation is a burst of one offer per peer (fired on + // join/leave and the periodic re-key), so the budget must not depend on + // channel size — keyed per sender alone, the 6th+ peer's offer was + // silently rate-limited and that peer could never decrypt audio again. + // Keying per (sender, target) admits any single rotation regardless of + // participant count while still capping repeated offers at one victim — + // the abuse this limit exists for, since an offer can force the target to + // re-key or disconnect. Cross-target spray stays bounded per victim and + // requires holding key-holder status in that channel. + ratKey := fmt.Sprintf("voice_e2ee_offer:%d:%d", info.UserID, offerCmd.TargetUserID()) if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} } From 5d6257d848f9866aed4f8aae2409414c59407b8e Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:46:28 +0200 Subject: [PATCH 12/29] test(ws): lock rotation-burst admission for E2EE offers (W1-2) Simulates an 8-participant call: two back-to-back full rotations (7 offers each) must pass the limiter, while same-target spam still trips it. Co-Authored-By: Claude Fable 5 --- Server/ws/handler_v2_voice_e2ee_offer_test.go | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Server/ws/handler_v2_voice_e2ee_offer_test.go b/Server/ws/handler_v2_voice_e2ee_offer_test.go index a2e2be55..eee3843d 100644 --- a/Server/ws/handler_v2_voice_e2ee_offer_test.go +++ b/Server/ws/handler_v2_voice_e2ee_offer_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "strings" "testing" + + "github.com/owncord/server/auth" ) var ( @@ -50,6 +52,47 @@ func TestVoiceE2EEOfferV2_HappyPath(t *testing.T) { } } +// TestVoiceE2EEOfferV2_RotationBurstNotRateLimited locks the W1-2 fix: the +// key holder rotates by sending one offer per peer back-to-back, so the +// limiter is keyed per (sender, target) and must admit an entire rotation +// burst in a large call — while repeated offers at one victim stay capped. +func TestVoiceE2EEOfferV2_RotationBurstNotRateLimited(t *testing.T) { + deps := offerDeps(true) + deps.Limiter = auth.NewRateLimiter() + info := ClientInfo{UserID: 1, VoiceChannelID: 100} + + // 8-participant call: one offer to each of 7 peers, immediately. + for target := int64(2); target <= 8; target++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: target, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + t.Fatalf("rotation offer to peer %d rejected: %v", target, result.Error) + } + } + + // Join/leave churn triggers back-to-back rotations — a second full burst + // must also pass. + for target := int64(2); target <= 8; target++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: target, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + t.Fatalf("second rotation offer to peer %d rejected: %v", target, result.Error) + } + } + + // Spamming a single victim is still limited: 2 offers spent above, the + // per-target budget is 5/sec, so within 4 more attempts one must trip. + var limited bool + for i := 0; i < 4; i++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + limited = true + break + } + } + if !limited { + t.Fatal("same-target offer spam must still hit the rate limit") + } +} + func TestVoiceE2EEOfferV2_NotInVoiceChannel(t *testing.T) { deps := offerDeps(true) cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV} From 26a367a7a10948fe115fd9def3c1bb8ff57d2d77 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:34 +0200 Subject: [PATCH 13/29] fix(api): give client-update polling its own rate-limit bucket (W2-1) The empty-prefix middleware shared per-IP buckets with verify-totp, password change, and the sensitive endpoints, so a client's 30/min auto-poll could 429 its own user's 2FA or password change. Dedicated "client_update:" prefix, mirroring "livekit_proxy:". Co-Authored-By: Claude Fable 5 --- Server/api/router.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Server/api/router.go b/Server/api/router.go index 5cbd877c..a40ef231 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -253,8 +253,12 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Client auto-update endpoint (unauthenticated). Per-IP rate limited to // bound abuse; the signature fetch is cached inside the updater (DoS fix). + // Dedicated key prefix (mirroring "livekit_proxy:"): the empty-prefix + // middleware would share per-IP buckets with verify-totp, password change, + // and the other sensitive endpoints, so a client's 30/min auto-poll could + // 429 its user's own 2FA or password change. MountClientUpdateRoute( - r.With(RateLimitMiddleware(limiter, clientUpdateRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)), + r.With(rateLimitMiddlewareWithPrefix(limiter, "client_update:", clientUpdateRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)), u, ) From f3005572b415b343efe97ac0c0df4f2b0695d871 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:34 +0200 Subject: [PATCH 14/29] test(api): lock client-update bucket isolation (W2-1) Co-Authored-By: Claude Fable 5 --- Server/api/livekit_ratelimit_test.go | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Server/api/livekit_ratelimit_test.go b/Server/api/livekit_ratelimit_test.go index 710d900d..074ff3c4 100644 --- a/Server/api/livekit_ratelimit_test.go +++ b/Server/api/livekit_ratelimit_test.go @@ -13,6 +13,44 @@ func okHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket locks the +// W2-1 fix: exhausting the client-update budget must not 429 the sensitive +// endpoints (verify-totp, password change) that ride the empty-prefix +// bucket for the same IP. +func TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket(t *testing.T) { + limiter := auth.NewRateLimiter() + trustedProxies := []string{"127.0.0.0/8"} + + clientUpdate := rateLimitMiddlewareWithPrefix(limiter, "client_update:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + sensitive := RateLimitMiddleware(limiter, 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + + newReq := func(path string) *http.Request { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.RemoteAddr = "127.0.0.1:9999" + r.Header.Set("X-Forwarded-For", "203.0.113.7") + return r + } + + // Exhaust the client-update bucket for this IP. + rec := httptest.NewRecorder() + clientUpdate.ServeHTTP(rec, newReq("/client-update")) + if rec.Code != http.StatusOK { + t.Fatalf("first client-update status = %d, want 200", rec.Code) + } + rec = httptest.NewRecorder() + clientUpdate.ServeHTTP(rec, newReq("/client-update")) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("second client-update status = %d, want 429", rec.Code) + } + + // The same IP's sensitive-endpoint budget must be untouched. + rec = httptest.NewRecorder() + sensitive.ServeHTTP(rec, newReq("/api/v1/auth/verify-totp")) + if rec.Code != http.StatusOK { + t.Fatalf("sensitive endpoint shares the client-update bucket: status = %d, want 200", rec.Code) + } +} + func TestRateLimitMiddlewareWithPrefix_SeparatesLiveKitBucket(t *testing.T) { limiter := auth.NewRateLimiter() trustedProxies := []string{"127.0.0.0/8"} From e5491c20aac33c32acbb13a844dbdd0a0ec764b6 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:35 +0200 Subject: [PATCH 15/29] fix(service): report password change as partial success when revocation fails (W2-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateUserPassword commits first; when DeleteOtherSessions then errored the handler returned 500 and skipped the audit row — telling the user the change failed while the new password was already live, walking them into retrying with a dead password and tripping the confirm lockout. The committed change now always audits and reports success; revocation gets one bounded compensating retry, and a persistent failure surfaces as a 200 + warning (sessions_revoked count) the client can show. Co-Authored-By: Claude Fable 5 --- Server/api/profile_handler.go | 14 ++++++++++++- Server/service/user.go | 39 ++++++++++++++++++++++++++--------- Server/store/memstore.go | 9 ++++++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 00aaa177..4a26eec2 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -228,10 +228,22 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http keepSessionID = sess.ID } - if _, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID); err != nil { + res, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID) + if err != nil { + // Only reachable when the password itself failed to commit. writeServiceError(w, err) return } + if res.RevokeFailed { + // Partial success: the password IS changed; only revoking the + // other sessions failed. A 5xx here would tell the user to retry + // with a password that no longer works. + writeJSON(w, http.StatusOK, map[string]any{ + "warning": "password changed, but other sessions could not be revoked; revoke them from the sessions list", + "sessions_revoked": res.SessionsRevoked, + }) + return + } w.WriteHeader(http.StatusNoContent) } diff --git a/Server/service/user.go b/Server/service/user.go index a2cac293..4332556d 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -51,24 +51,43 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username return user, nil } +// ChangePasswordResult reports a completed password change. RevokeFailed is +// set when the password committed but other sessions could not be revoked — +// a partial success the caller must surface as a warning, never as a 5xx: +// the old password is already unusable, so telling the user the change +// "failed" walks them into retrying with a dead password and tripping the +// password-confirm lockout. +type ChangePasswordResult struct { + SessionsRevoked int64 + RevokeFailed bool +} + // ChangePassword updates the user's password and revokes other sessions. -// Returns the number of other sessions revoked. -func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (int64, error) { +func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { - return 0, fmt.Errorf("%w: failed to update password", ErrInternal) + return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password", ErrInternal) } + + // The password is committed from here on: every path below reports + // success and writes the audit row. + var res ChangePasswordResult revoked, err := s.st.DeleteOtherSessions(userID, keepSessionID) + res.SessionsRevoked = revoked if err != nil { slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID) - // The password was updated, but other sessions could not be revoked, so - // devices authenticated under the old password remain valid. Surface this - // as a failure instead of silently reporting success — a password change - // is a security action and the caller must be able to warn/retry. - return revoked, fmt.Errorf("%w: password changed but failed to revoke other sessions", ErrInternal) + // One bounded compensating retry: revocation is the security tail of + // the change and a single immediate retry covers transient write-lock + // contention. ponytail: one retry, add backoff only if logs show it. + if revokedRetry, retryErr := s.st.DeleteOtherSessions(userID, keepSessionID); retryErr == nil { + res.SessionsRevoked += revokedRetry + } else { + res.RevokeFailed = true + } } _ = s.st.LogAudit(userID, "password_change", "user", userID, "password changed") - slog.Info("password changed", "user_id", userID, "sessions_revoked", revoked) - return revoked, nil + slog.Info("password changed", "user_id", userID, + "sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed) + return res, nil } // ListSessions returns all active sessions for a user. diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 8837b2e9..da73d39a 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -426,8 +426,13 @@ func (m *MemStore) UpdateUserProfile(_ int64, _ string, _ *string) error { panic("memstore: not implemented: UpdateUserProfile") } -func (m *MemStore) UpdateUserPassword(_ int64, _ string) error { - panic("memstore: not implemented: UpdateUserPassword") +func (m *MemStore) UpdateUserPassword(userID int64, hash string) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.PasswordHash = hash + } + return nil } func (m *MemStore) UpdateUserStatus(id int64, status string) error { From 47663e2be3f9913b787d4a20fad2ab72483130b7 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:35 +0200 Subject: [PATCH 16/29] test(service): cover password-change partial-success contract (W2-2) Revocation failure: no error, audit still written, RevokeFailed set, password committed. Transient failure: absorbed by exactly one retry. Co-Authored-By: Claude Fable 5 --- Server/service/user_test.go | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Server/service/user_test.go diff --git a/Server/service/user_test.go b/Server/service/user_test.go new file mode 100644 index 00000000..34c7674f --- /dev/null +++ b/Server/service/user_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "errors" + "slices" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/store" +) + +// pwStore wraps MemStore with controllable DeleteOtherSessions behavior and +// audit capture, so the committed-password partial-success contract (W2-2) +// is testable. +type pwStore struct { + *store.MemStore + failRevokes int // number of DeleteOtherSessions calls that fail before succeeding + revokeCalls int + audits []string +} + +func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { + f.revokeCalls++ + if f.revokeCalls <= f.failRevokes { + return 0, errors.New("session table locked") + } + return 2, nil +} + +func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { + f.audits = append(f.audits, action) + return nil +} + +// TestChangePassword_RevokeFailureIsPartialSuccess locks the W2-2 contract: +// once the password is committed, revocation failure must never surface as an +// error (the old password is dead; a "failed" report walks the user into the +// confirm lockout), and the audit row must still be written. +func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 99} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("committed password change must not return an error: %v", err) + } + if !res.RevokeFailed { + t.Fatal("RevokeFailed should be set when revocation keeps failing") + } + if u, _ := ms.GetUserByID(7); u.PasswordHash != "newhash" { + t.Fatal("password should be committed") + } + if !slices.Contains(fs.audits, "password_change") { + t.Fatal("audit row must be written even when revocation fails") + } +} + +// TestChangePassword_RetryRecoversRevocation: a single transient revocation +// failure is absorbed by the bounded compensating retry. +func TestChangePassword_RetryRecoversRevocation(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 1} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("ChangePassword: %v", err) + } + if res.RevokeFailed { + t.Fatal("retry should have recovered the revocation") + } + if res.SessionsRevoked != 2 { + t.Fatalf("SessionsRevoked = %d, want 2", res.SessionsRevoked) + } + if fs.revokeCalls != 2 { + t.Fatalf("expected exactly one retry (2 calls), got %d", fs.revokeCalls) + } +} From 2a4b2e1628fd5e96a433c469de426ab24a27815b Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:52:31 +0200 Subject: [PATCH 17/29] fix(plugin): make in-place plugin upgrades rebind commands (W2-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installFromDisk replaced r.plugins/r.byName with a fresh *Instance but left r.commands keyed to the old pointer and the old module running: re-installing an enabled plugin blocked its own command re-registration (RegisterCommand compared ownership by pointer) and kept dispatch routing into the orphaned module until restart. Reinstall now deactivates the old instance and clears its bindings, and RegisterCommand compares ownership by plugin identity (manifest name) — the same plugin re-binds freely, a different plugin still cannot hijack an owned command. Co-Authored-By: Claude Fable 5 --- Server/plugin/host_commands.go | 7 ++++++- Server/plugin/registry.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Server/plugin/host_commands.go b/Server/plugin/host_commands.go index 2cd9b026..fd9b972b 100644 --- a/Server/plugin/host_commands.go +++ b/Server/plugin/host_commands.go @@ -33,7 +33,12 @@ func (r *Registry) RegisterCommand(cmd string, inst *Instance) error { } r.mu.Lock() defer r.mu.Unlock() - if existing, ok := r.commands[cmd]; ok && existing != inst { + // Ownership is compared by plugin identity (manifest name — unique per + // registry), not instance pointer: an in-place upgrade replaces the + // *Instance, and the same plugin must be able to re-bind its own + // commands. A *different* plugin claiming an owned command is still + // refused (cross-plugin command-hijack protection). + if existing, ok := r.commands[cmd]; ok && existing.Manifest.Name != inst.Manifest.Name { return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name) } r.commands[cmd] = inst diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index a7ac9583..4ab01d43 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -183,6 +183,21 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error } r.mu.Lock() defer r.mu.Unlock() + // Re-install: tear down the old instance and drop its command bindings. + // Bindings are keyed to the old *Instance, so leaving them in place both + // blocked the fresh instance from re-registering its own commands and + // kept dispatch routing into the orphaned old module until restart. + if old := r.byName[found.Manifest.Name]; old != nil { + r.platformDeactivate(old) + for cmd, owner := range r.commands { + if owner == old { + delete(r.commands, cmd) + } + } + if old.ID != id { + delete(r.plugins, old.ID) + } + } inst := &Instance{ ID: id, Manifest: found.Manifest, From 92abe7c17364c6e4150a6e4cc97abfdb958a7f00 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:52:31 +0200 Subject: [PATCH 18/29] test(plugin): lock reinstall command rebinding + hijack refusal (W2-3) Co-Authored-By: Claude Fable 5 --- Server/plugin/plugin_test.go | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go index 8f9cc216..518b8be7 100644 --- a/Server/plugin/plugin_test.go +++ b/Server/plugin/plugin_test.go @@ -146,3 +146,70 @@ func TestStorageGatedByCapability(t *testing.T) { t.Fatalf("expected v, got %q", got) } } + +// TestReinstallRebindsCommands locks the W2-3 fix: an in-place upgrade +// replaces the *Instance, so stale command bindings must be cleared on +// reinstall and ownership compared by plugin identity — the same plugin can +// re-bind its own commands while a different plugin still cannot hijack them. +func TestReinstallRebindsCommands(t *testing.T) { + mem := store.NewMemStore() + reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem}) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close(context.Background()) }) + + ctx := context.Background() + manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: manifest, WASMPath: "p.wasm"}); err != nil { + t.Fatalf("install v1: %v", err) + } + reg.mu.RLock() + v1 := reg.byName["upgrader"] + reg.mu.RUnlock() + if err := reg.RegisterCommand("greet", v1); err != nil { + t.Fatalf("RegisterCommand v1: %v", err) + } + + // In-place upgrade: same plugin name, fresh instance. + manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest v2: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: manifest2, WASMPath: "p.wasm"}); err != nil { + t.Fatalf("install v2: %v", err) + } + reg.mu.RLock() + v2 := reg.byName["upgrader"] + _, stillBound := reg.commands["greet"] + reg.mu.RUnlock() + if v2 == v1 { + t.Fatal("reinstall should produce a fresh instance") + } + if stillBound { + t.Fatal("stale command binding survived reinstall") + } + + // The upgraded plugin re-binds its own command. + if err := reg.RegisterCommand("greet", v2); err != nil { + t.Fatalf("RegisterCommand after upgrade: %v", err) + } + + // A different plugin still cannot hijack an owned command. + other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest other: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: other, WASMPath: "o.wasm"}); err != nil { + t.Fatalf("install other: %v", err) + } + reg.mu.RLock() + otherInst := reg.byName["other"] + reg.mu.RUnlock() + if err := reg.RegisterCommand("greet", otherInst); err == nil { + t.Fatal("cross-plugin hijack must still be refused") + } +} From a762dc712d125039f1764914009a898b584a7d3f Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:56:34 +0200 Subject: [PATCH 19/29] fix(api): keep per-client keys distinct under broad trusted_proxies (W2-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With trusted_proxies covering client networks (e.g. 10.0.0.0/8 over LAN clients), the right-to-left XFF walk skipped every entry, exhausted, and fell back to the proxy's RemoteAddr — collapsing all clients into one rate-limit/lockout bucket, so one user's failed logins locked out everyone. On exhaustion the walk now returns the leftmost valid entry (furthest-upstream hop), the best distinct per-client key such a config allows. An untrusted RemoteAddr still never gets its headers honoured. Also (W3-3): the CIDR list parses once per request instead of once per XFF candidate, config load warns about invalid CIDR entries at startup (a silently skipped entry silently un-trusts the proxy), and the sample config documents that trusted_proxies must list only proxy hops. Co-Authored-By: Claude Fable 5 --- Server/api/middleware.go | 51 +++++++++++++++++++++++++++++++++++++--- Server/config/config.go | 23 +++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 054d16d8..b270d4d5 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -205,8 +205,12 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { return remoteHost } - trusted, _ := isTrustedProxy(remoteHost, trustedCIDRs) - if !trusted { + // Parse the CIDR list once per request instead of once per XFF candidate. + // ponytail: parse at middleware construction if this ever shows in a + // profile — it would mean threading a parsed type through every caller. + nets := parseCIDRList(trustedCIDRs) + + if !ipInNets(remoteHost, nets) { return remoteHost } @@ -226,21 +230,62 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { // letting it forge per-IP rate-limit and lockout keys. if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") + leftmostValid := "" for i := len(parts) - 1; i >= 0; i-- { candidate := strings.TrimSpace(parts[i]) if candidate == "" || net.ParseIP(candidate) == nil { continue } - if trusted, _ := isTrustedProxy(candidate, trustedCIDRs); trusted { + leftmostValid = candidate + if ipInNets(candidate, nets) { continue // our own proxy hop, keep walking left } return candidate } + // Every entry fell inside trustedCIDRs — a config that covers client + // networks too (e.g. trusted_proxies: 10.0.0.0/8 with LAN clients). + // Falling back to RemoteAddr here would collapse ALL clients behind + // the proxy into one rate-limit/lockout bucket, so one user's failed + // logins would lock out everyone. The leftmost valid entry is the + // furthest-upstream hop — the best distinct per-client key available + // under such a config. trusted_proxies must list only proxy hops; + // startup validation warns about entries that cannot be proxies. + if leftmostValid != "" { + return leftmostValid + } } return remoteHost } +// parseCIDRList parses CIDR strings, silently skipping invalid entries — a +// misconfigured entry must not crash request handling (config load warns +// about them at startup). +func parseCIDRList(cidrs []string) []*net.IPNet { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + if _, n, err := net.ParseCIDR(c); err == nil { + nets = append(nets, n) + } + } + return nets +} + +// ipInNets reports whether ipStr (a plain IP, no port) falls inside any of +// the parsed networks. +func ipInNets(ipStr string, nets []*net.IPNet) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + // isTrustedProxy reports whether remoteIP (a plain IP string, no port) falls // within any of the provided CIDR ranges. It returns an error if any CIDR is // malformed. diff --git a/Server/config/config.go b/Server/config/config.go index 69a32c37..2bd99919 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "log/slog" + "net" "os" "strings" @@ -215,7 +216,10 @@ server: name: "OwnCord Server" data_dir: "data" # allowed_origins: [] # empty = deny cross-origin; set to ["*"] for dev or specific origins for prod - # trusted_proxies: [] # CIDRs of trusted reverse proxies, e.g. ["10.0.0.0/8"] + # trusted_proxies: [] # CIDRs of the reverse-proxy HOPS only (e.g. ["10.0.0.2/32"]). + # # Never list client networks here: a range that covers + # # clients degrades per-client rate limiting and lets + # # covered clients influence their own rate-limit key. # admin_allowed_cidrs: # CIDRs allowed to access /admin (default: private networks only) # - "127.0.0.0/8" # - "::1/128" @@ -349,9 +353,26 @@ func Load(cfgPath string) (*Config, error) { cfg.Voice.LiveKitAPISecret = "" } + // Invalid CIDR entries are skipped at request time (they must not crash + // handling), which silently un-trusts a misconfigured proxy — warn once + // at startup instead. Common mistake: a bare IP without the /32 mask. + warnInvalidCIDRs("server.trusted_proxies", cfg.Server.TrustedProxies) + warnInvalidCIDRs("server.admin_allowed_cidrs", cfg.Server.AdminAllowedCIDRs) + return &cfg, nil } +// warnInvalidCIDRs logs a startup warning for each list entry that is not +// valid CIDR notation. +func warnInvalidCIDRs(key string, cidrs []string) { + for _, c := range cidrs { + if _, _, err := net.ParseCIDR(c); err != nil { + slog.Warn("config: ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)", + "key", key, "entry", c) + } + } +} + // defaultLiveKitAPIKey and defaultLiveKitAPISecret are the well-known dev // credentials that ship in the default config. They must never be used in // production — NewLiveKitClient rejects them. From 68e24d32d4b9b4d2ddc857dceac2b3443d00ab41 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:56:34 +0200 Subject: [PATCH 20/29] test(api): lock distinct client keys under broad trusted CIDRs (W2-5) Co-Authored-By: Claude Fable 5 --- Server/api/clientip_test.go | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Server/api/clientip_test.go b/Server/api/clientip_test.go index 271bc3c3..9126684f 100644 --- a/Server/api/clientip_test.go +++ b/Server/api/clientip_test.go @@ -4,6 +4,7 @@ package api // These live in package api (not api_test) so they can reach unexported symbols. import ( + "net/http" "net/http/httptest" "testing" ) @@ -153,6 +154,48 @@ func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) { } } +// TestClientIP_BroadTrustedCIDRKeepsClientsDistinct locks the W2-5 fix: with +// a trusted_proxies range broad enough to cover the clients themselves, the +// right-to-left walk exhausts; falling back to RemoteAddr would collapse +// every client into the proxy's own bucket (one user's failed logins would +// lock out everyone). The leftmost valid XFF entry keeps clients distinct. +func TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(t *testing.T) { + trusted := []string{"10.0.0.0/8"} // covers proxy AND LAN clients + + newReq := func(xff string) *http.Request { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.2:9999" // the proxy + req.Header.Set("X-Forwarded-For", xff) + return req + } + + ip1 := clientIPWithProxies(newReq("10.5.1.7"), trusted) + ip2 := clientIPWithProxies(newReq("10.5.1.8"), trusted) + if ip1 != "10.5.1.7" || ip2 != "10.5.1.8" { + t.Fatalf("clients behind broad trusted CIDR collapsed: ip1=%q ip2=%q", ip1, ip2) + } + + // Multi-hop: leftmost valid entry (furthest upstream) wins on exhaustion. + ip3 := clientIPWithProxies(newReq("10.5.1.9, 10.0.0.3"), trusted) + if ip3 != "10.5.1.9" { + t.Fatalf("expected furthest-upstream entry, got %q", ip3) + } +} + +// TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored: an untrusted connecting +// address never gets its forwarded headers honoured, exhaustion fallback or +// not. +func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "203.0.113.9:1234" + req.Header.Set("X-Forwarded-For", "10.5.1.7") + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + if ip != "203.0.113.9" { + t.Fatalf("spoofed XFF from untrusted remote honoured: got %q", ip) + } +} + func TestClientIP_RemoteAddrWithoutPort(t *testing.T) { // RemoteAddr sometimes has no port (e.g. Unix sockets in tests). req := httptest.NewRequest("GET", "/", nil) From 9145f893d1db67b73393404f316d0e8dd0f04dbc Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:58:57 +0200 Subject: [PATCH 21/29] fix(plugin): restore multi-address fallback in the SSRF-guarded dialer (W2-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After validating every resolved IP, the guarded dial connected only to ips[0] — an allowlisted dual-stack or round-robin host whose first record was down hard-failed despite reachable vetted alternatives. The dial now tries each vetted address in order (all records still validated before any dial: one poisoned private record refuses the whole request). Also removes the redundant rejectPrivateAddrs pre-resolves (initial request + redirect hop): the guarded dial is the authoritative check and every path flows through it, so the pre-resolve only cost an extra DNS round trip while re-opening the rebinding TOCTOU it was meant to close. Folds the W3-2-adjacent double-resolve cleanup from the plan. Co-Authored-By: Claude Fable 5 --- Server/plugin/host_http.go | 118 ++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 60 deletions(-) diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index ae240ac1..319a9278 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -65,9 +65,10 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) if !r.hostAllowed(host) { return nil, fmt.Errorf("%w: %s", ErrHTTPHostDenied, host) } - if err := rejectPrivateAddrs(ctx, host); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } + // No pre-resolve here: the transport's guarded dial resolves once, + // validates every address, and dials only vetted IPs — it is the + // authoritative SSRF check, and a second lookup would just cost an extra + // DNS round trip while re-opening the rebinding TOCTOU it exists to close. httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewReader(req.Body)) if err != nil { @@ -78,43 +79,11 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) } // Custom transport with a guarded DialContext: the host is resolved once, // every candidate IP is validated against the blocklist, and the actual - // connection is made to that specific vetted IP — never re-resolved by + // connection is made to a specific vetted IP — never re-resolved by // hostname. This closes the DNS-rebinding TOCTOU window where a second // lookup (the one net.Dialer would perform on a hostname) could return an - // internal IP after rejectPrivateAddrs above had already approved the name. - dialer := &net.Dialer{Timeout: httpTimeout} - transport := &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - h, port, splitErr := net.SplitHostPort(addr) - if splitErr != nil { - return nil, splitErr - } - // IP literal: validate and dial as-is (no resolution happens). - if ip := net.ParseIP(h); ip != nil { - if err := ipAllowed(ip); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } - return dialer.DialContext(ctx, network, addr) - } - // Hostname: resolve once, validate every returned address, then - // dial the concrete vetted IP so the connection target is exactly - // the address that was checked. - resolver := &net.Resolver{} - ips, lookupErr := resolver.LookupIPAddr(ctx, h) - if lookupErr != nil { - return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) - } - if len(ips) == 0 { - return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) - } - for _, resolved := range ips { - if err := ipAllowed(resolved.IP); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } - } - return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) - }, - } + // internal IP after an earlier check had approved the name. + transport := &http.Transport{DialContext: guardedDialContext()} client := &http.Client{ Timeout: httpTimeout, Transport: transport, @@ -128,9 +97,8 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) if !r.hostAllowed(h) { return fmt.Errorf("%w: redirect to %s", ErrHTTPHostDenied, h) } - if err := rejectPrivateAddrs(redirReq.Context(), h); err != nil { - return fmt.Errorf("%w: redirect to private addr: %v", ErrHTTPHostDenied, err) - } + // Address vetting happens in the guarded dial the redirect will + // flow through — no pre-resolve needed here either. return nil }, } @@ -189,29 +157,59 @@ func (r *Registry) hostAllowed(host string) bool { return false } -// rejectPrivateAddrs resolves host and returns an error if any resolved -// address is loopback, link-local, private (RFC1918), or unspecified. -// This prevents an allowlisted hostname from being repointed at internal -// services via DNS. -func rejectPrivateAddrs(ctx context.Context, host string) error { - // If host is already an IP literal, check it directly. - if ip := net.ParseIP(host); ip != nil { - return ipAllowed(ip) +// lookupIPAddr and dialContext are swappable seams so the guarded dial can be +// tested without real DNS or network reachability. +var ( + lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return (&net.Resolver{}).LookupIPAddr(ctx, host) } - resolver := &net.Resolver{} - ips, err := resolver.LookupIPAddr(ctx, host) - if err != nil { - return fmt.Errorf("dns lookup failed: %w", err) + dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + d := &net.Dialer{Timeout: httpTimeout} + return d.DialContext(ctx, network, addr) } - if len(ips) == 0 { - return fmt.Errorf("no addresses for %s", host) - } - for _, addr := range ips { - if err := ipAllowed(addr.IP); err != nil { - return err +) + +// guardedDialContext returns the SSRF-guarded dial used by HTTPDo's +// transport: resolve once, validate every returned address, then dial vetted +// concrete IPs. All addresses are validated before any dial (one poisoned +// record among them refuses the whole request), and every vetted address is +// tried in order — a dual-stack or round-robin host whose first record is +// down must still connect via the next one. +func guardedDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + h, port, splitErr := net.SplitHostPort(addr) + if splitErr != nil { + return nil, splitErr } + // IP literal: validate and dial as-is (no resolution happens). + if ip := net.ParseIP(h); ip != nil { + if err := ipAllowed(ip); err != nil { + return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + } + return dialContext(ctx, network, addr) + } + ips, lookupErr := lookupIPAddr(ctx, h) + if lookupErr != nil { + return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) + } + if len(ips) == 0 { + return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) + } + for _, resolved := range ips { + if err := ipAllowed(resolved.IP); err != nil { + return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + } + } + var dialErr error + for _, resolved := range ips { + conn, err := dialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) + if err == nil { + return conn, nil + } + dialErr = err + } + return nil, dialErr } - return nil } // cgnRange covers RFC6598 carrier-grade NAT (100.64.0.0/10). net.IP.IsPrivate From 7fbfbef5816a93394d63860b8315fe549178f2ba Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:58:57 +0200 Subject: [PATCH 22/29] test(plugin): lock vetted-IP dial fallback and fail-closed vetting (W2-6) Co-Authored-By: Claude Fable 5 --- Server/plugin/host_http_test.go | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Server/plugin/host_http_test.go b/Server/plugin/host_http_test.go index 454a799c..77793617 100644 --- a/Server/plugin/host_http_test.go +++ b/Server/plugin/host_http_test.go @@ -5,6 +5,8 @@ package plugin import ( + "context" + "errors" "net" "testing" ) @@ -114,6 +116,70 @@ func TestIPAllowedAcceptsPublic(t *testing.T) { } } +// TestGuardedDial_FallsBackAcrossVettedIPs locks the W2-6 fix: an allowlisted +// dual-stack/round-robin host whose first record is unreachable must connect +// via the next vetted record instead of hard-failing. +func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) { + origLookup, origDial := lookupIPAddr, dialContext + t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial }) + + lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) { + return []net.IPAddr{ + {IP: net.ParseIP("192.0.2.1")}, // TEST-NET, "down" + {IP: net.ParseIP("192.0.2.2")}, // "reachable" + }, nil + } + var attempts []string + c1, c2 := net.Pipe() + t.Cleanup(func() { _ = c1.Close(); _ = c2.Close() }) + dialContext = func(_ context.Context, _ string, addr string) (net.Conn, error) { + attempts = append(attempts, addr) + if addr == "192.0.2.1:443" { + return nil, errors.New("connection refused") + } + return c1, nil + } + + conn, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + if err != nil { + t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err) + } + if conn != c1 { + t.Fatal("expected the fallback connection") + } + want := []string{"192.0.2.1:443", "192.0.2.2:443"} + if len(attempts) != 2 || attempts[0] != want[0] || attempts[1] != want[1] { + t.Fatalf("dial attempts = %v, want %v", attempts, want) + } +} + +// TestGuardedDial_PrivateRecordRefusesBeforeAnyDial: one private record among +// the resolved set refuses the whole request before a single dial happens. +func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) { + origLookup, origDial := lookupIPAddr, dialContext + t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial }) + + lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) { + return []net.IPAddr{ + {IP: net.ParseIP("192.0.2.1")}, + {IP: net.ParseIP("10.0.0.5")}, // poisoned private record + }, nil + } + dialed := false + dialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) { + dialed = true + return nil, errors.New("must not be reached") + } + + _, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + if !errors.Is(err, ErrHTTPHostDenied) { + t.Fatalf("want ErrHTTPHostDenied, got %v", err) + } + if dialed { + t.Fatal("no dial may happen when any resolved record is private") + } +} + func TestIPAllowedNilRejected(t *testing.T) { if err := ipAllowed(nil); err == nil { t.Fatal("nil IP should be rejected") From b329a61a7c3a8429559d55942240a6828faa7437 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:03:03 +0200 Subject: [PATCH 23/29] fix(ws): route plugin broadcasts through the service-layer send check (W2-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireChannelBroadcastAccess went through RequireChannelAccess, whose DM branch checks only participant membership — a blocked user's plugin broadcast could reach the person who blocked them — and it issued a raw GetRoleByID per broadcast, bypassing the permission cache. The gate now delegates to MessageService.CanPost (extracted over checkSendPermission), so DM blocks, channel permissions, and future posting policy apply from exactly one place; fails closed when no service is wired. First brick of the permission-path unification. MemStore.GetDMRecipient gets an honest implementation so the block path is testable. Co-Authored-By: Claude Fable 5 --- Server/service/message.go | 13 ++++++++++++ Server/store/memstore.go | 9 ++++++++- Server/ws/handlers_command.go | 38 +++++++++++++++++------------------ Server/ws/hub.go | 5 +++++ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/Server/service/message.go b/Server/service/message.go index 4a76a569..9dd0a4d8 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -677,6 +677,19 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) return ids, nil } +// CanPost reports whether userID may post into channelID, applying the same +// checks as a real message send: channel permissions via the cached checker +// for regular channels; participant membership AND block status for DMs. +// Exists so gates outside the send flow (the plugin broadcast path) share +// exactly this policy instead of hand-rolling a weaker copy. +func (s *MessageService) CanPost(userID, channelID int64) error { + ch, err := s.st.GetChannel(channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + return s.checkSendPermission(userID, channelID, ch.Type == "dm") +} + // checkSendPermission validates send permission for DM and non-DM channels. func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error { if isDM { diff --git a/Server/store/memstore.go b/Server/store/memstore.go index da73d39a..1837c54a 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -673,7 +673,14 @@ func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { return ids, nil } -func (m *MemStore) GetDMRecipient(_ int64, _ int64) (*db.User, error) { +func (m *MemStore) GetDMRecipient(channelID, userID int64) (*db.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + for uid := range m.dmParticipants[channelID] { + if uid != userID { + return m.users[uid], nil + } + } return nil, nil } diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 4a8d98f6..618b9bd9 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -11,11 +11,12 @@ package ws import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) const MsgTypeChatCommand = "chat_command" @@ -100,32 +101,31 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } // requireChannelBroadcastAccess reports whether the client may post to -// channelID, mirroring the normal message-send permission path. DM channels are -// validated by participant membership; all other channels require -// READ_MESSAGES|SEND_MESSAGES. On failure it sends an error to the client and -// returns false. Routes through the shared permissions.Checker.RequireChannelAccess -// so DM handling matches the rest of the codebase. +// channelID, by delegating to the SAME service-layer check a real message +// send runs (MessageService.CanPost: cached channel permissions; DM +// membership AND DM blocks). The previous RequireChannelAccess route skipped +// the block check in its DM branch — a blocked user's plugin broadcast could +// reach the person who blocked them — and issued a raw GetRoleByID per +// broadcast, bypassing the permission cache. On failure it sends an error to +// the client and returns false. func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool { if c.user == nil { c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated")) return false } - ch, err := h.db.GetChannel(channelID) - if err != nil || ch == nil { - c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + if h.messageSvc == nil { + // No service wired (bare test hub) — fail closed rather than allow + // an ungated broadcast. + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable")) return false } - role, err := h.db.GetRoleByID(c.user.RoleID) - if err != nil || role == nil { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "role not found")) - return false - } - if accessErr := h.permChecker.RequireChannelAccess( - c.userID, role.Permissions, role.ID, ch.Type, channelID, - permissions.ReadMessages|permissions.SendMessages, - ); accessErr != nil { + if err := h.messageSvc.CanPost(c.userID, channelID); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return false + } slog.Warn("ws plugin broadcast permission denied", - "user_id", c.userID, "channel_id", channelID, "err", accessErr) + "user_id", c.userID, "channel_id", channelID, "err", err) c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel")) return false } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 48deda38..4710547a 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -43,6 +43,10 @@ type Hub struct { lkProcess *LiveKitProcess registry *HandlerRegistry permChecker *permissions.Checker + // messageSvc gates plugin broadcasts through the same posting policy as a + // real message send (permissions, DM membership, DM blocks). Nil only in + // bare test hubs; the broadcast gate fails closed then. + messageSvc *service.MessageService pubsub *PubSub // topic-based pub/sub for O(subscribers) broadcast topicLimiter *TopicRateLimiter // per-topic throughput caps @@ -117,6 +121,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * chatDeps.MessageSvc = svc.Messages presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages + h.messageSvc = svc.Messages } registerChatHandlers(reg, chatDeps) From 9d44942b045b9be587856bcae93d3dfd87e42203 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:03:03 +0200 Subject: [PATCH 24/29] test(service): lock CanPost DM-block and permission refusals (W2-7) Co-Authored-By: Claude Fable 5 --- Server/service/message_test.go | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 2edd32a3..3d9aa170 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -56,6 +56,59 @@ func TestSendMessage_Valid(t *testing.T) { } } +// TestCanPost_DMBlockEnforced locks the W2-7 property: the plugin-broadcast +// gate delegates to CanPost, so a blocked user is refused from posting into +// a DM — the old broadcast gate's DM branch skipped the block check entirely. +func TestCanPost_DMBlockEnforced(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages, Position: 1, + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUserRole(2, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice"}) + ms.SeedUser(&db.User{ID: 2, Username: "bob"}) + ms.SeedChannel(&db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"}) + ms.SeedDMParticipant(50, 1) + ms.SeedDMParticipant(50, 2) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := svc.CanPost(1, 50); err != nil { + t.Fatalf("unblocked DM participant should be allowed: %v", err) + } + ms.SeedBlock(2, 1) // bob blocks alice + if err := svc.CanPost(1, 50); !errors.Is(err, ErrBlocked) { + t.Fatalf("blocked user must be refused: got %v", err) + } + if err := svc.CanPost(3, 50); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-participant must be refused: got %v", err) + } + if err := svc.CanPost(1, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing channel must be NotFound: got %v", err) + } +} + +// TestCanPost_ChannelPermissionRequired: regular channels still require +// READ|SEND via the cached checker. +func TestCanPost_ChannelPermissionRequired(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, Name: "member", + Permissions: permissions.ReadMessages, Position: 1, // no SendMessages + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice"}) + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) { + t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err) + } +} + // TestSendMessage_AttachmentOwnershipAtomic locks the W1-3 semantics: the // link UPDATE itself enforces ownership, so a foreign, already-linked, or // nonexistent attachment is skipped (never linked) while the message still From 2eec831d6a9bf2a1aecfe3c9578fb544393c5d9d Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:04:28 +0200 Subject: [PATCH 25/29] refactor(updater): export FileSHA256 and reuse it for the update snapshot (W3-2) admin's fileSHA256 duplicated VerifyChecksum's hashing body. One exported helper now serves both the TOCTOU snapshot in handleApplyUpdate and VerifyChecksum itself. Co-Authored-By: Claude Fable 5 --- Server/admin/update_handlers.go | 22 +--------------------- Server/updater/updater.go | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 3ed3e013..65368a8b 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -2,9 +2,6 @@ package admin import ( "context" - "crypto/sha256" - "encoding/hex" - "io" "log/slog" "net/http" "os" @@ -90,7 +87,7 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha // Snapshot the hash of the just-verified staged binary. It is re-checked // immediately before rename+spawn to close the TOCTOU window between // verification here and the swap in the background goroutine below. - stagedHash, err := fileSHA256(newPath) + stagedHash, err := updater.FileSHA256(newPath) if err != nil { slog.Error("update: failed to hash staged binary", "err", err) _ = os.Remove(newPath) @@ -159,20 +156,3 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha }() }) } - -// fileSHA256 returns the hex-encoded SHA256 of the file at path. Used to -// snapshot a verified update binary so it can be re-checked (via -// updater.VerifyChecksum) immediately before it is renamed and executed. -func fileSHA256(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() //nolint:errcheck - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 032a75e7..34f71898 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -615,21 +615,30 @@ func assetFilenameFromURL(rawURL string) (string, error) { return filename, nil } -// VerifyChecksum computes the SHA256 hash of the file at filePath and -// compares it (case-insensitive) against expectedHash. -func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { - f, err := os.Open(filePath) +// FileSHA256 returns the hex-encoded SHA256 of the file at path. Exported so +// callers that snapshot a verified binary (the admin update TOCTOU re-check) +// share this exact hashing instead of duplicating it. +func FileSHA256(path string) (string, error) { + f, err := os.Open(path) if err != nil { - return fmt.Errorf("opening file for checksum: %w", err) + return "", fmt.Errorf("opening file for checksum: %w", err) } defer f.Close() //nolint:errcheck h := sha256.New() if _, err := io.Copy(h, f); err != nil { - return fmt.Errorf("computing checksum: %w", err) + return "", fmt.Errorf("computing checksum: %w", err) } + return hex.EncodeToString(h.Sum(nil)), nil +} - actual := hex.EncodeToString(h.Sum(nil)) +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + actual, err := FileSHA256(filePath) + if err != nil { + return err + } if !strings.EqualFold(actual, expectedHash) { return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) } From 0baccb58eede662934e362dbb5d63d49e5e09433 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:33:33 +0200 Subject: [PATCH 26/29] fix(ws): process register/unregister on one ordered channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register and Unregister travelled on two separate channels, and Run's select picks randomly when both are ready: a fast connect/disconnect could process the unregister first (a silent no-op for a not-yet-known client) and then the register — admitting an already-dead connection as a ghost client that held presence and swallowed broadcasts until the stale sweep reaped it minutes later. One tagged event channel preserves each connection's Register→Unregister submission order, making the inversion structurally impossible. Found via TestHub_ConcurrentRegisterUnregister failing the P1 gate under -race on windows-latest (2 ghosts after churn); that test now settles in milliseconds instead of polling out its deadline. Co-Authored-By: Claude Fable 5 --- Server/ws/hub.go | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 4710547a..43453778 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -29,13 +29,18 @@ type broadcastMsg struct { // Hub manages all active WebSocket clients and routes messages between them. // All exported methods are safe to call from multiple goroutines. type Hub struct { - clients map[int64]*Client - mu syncutil.RWMutex - db *db.DB - limiter *auth.RateLimiter - broadcast chan broadcastMsg - register chan *Client - unregister chan *Client + clients map[int64]*Client + mu syncutil.RWMutex + db *db.DB + limiter *auth.RateLimiter + broadcast chan broadcastMsg + // clientEvents carries register AND unregister requests on one channel so + // a connection's Register→Unregister sequence is processed in submission + // order. With two separate channels, Run's select picked randomly between + // them when both were ready — a fast connect/disconnect could process the + // unregister first (a no-op for an unknown client) and then the register, + // admitting an already-dead client as a ghost until the stale sweep. + clientEvents chan clientEvent stop chan struct{} stopOnce sync.Once gracefulOnce sync.Once @@ -94,8 +99,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * db: database, limiter: limiter, broadcast: make(chan broadcastMsg, 1024), - register: make(chan *Client, 32), - unregister: make(chan *Client, 32), + clientEvents: make(chan clientEvent, 64), stop: make(chan struct{}), pubsub: NewPubSub(), topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second), @@ -263,10 +267,12 @@ func (h *Hub) Run() { select { case <-h.stop: return - case c := <-h.register: - h.registerNow(c) - case c := <-h.unregister: - h.unregisterNow(c) + case ev := <-h.clientEvents: + if ev.add { + h.registerNow(ev.c) + } else { + h.unregisterNow(ev.c) + } case bm := <-h.broadcast: h.deliverBroadcast(bm) case <-staleTicker.C: @@ -381,12 +387,19 @@ func (h *Hub) GetClient(userID int64) *Client { // Register queues a client for registration with the hub. func (h *Hub) Register(c *Client) { - h.register <- c + h.clientEvents <- clientEvent{c: c, add: true} } // Unregister queues a client for removal from the hub. func (h *Hub) Unregister(c *Client) { - h.unregister <- c + h.clientEvents <- clientEvent{c: c} +} + +// clientEvent is a register (add=true) or unregister (add=false) request. +// Both kinds share one channel so per-connection ordering is preserved. +type clientEvent struct { + c *Client + add bool } func (h *Hub) registerNow(c *Client) { From 4c2fecbf02e96fcad92c4b36aae1ad0470a05265 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:33:33 +0200 Subject: [PATCH 27/29] test(ws): update emit-test hub literal for the single event channel Co-Authored-By: Claude Fable 5 --- Server/ws/emit_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Server/ws/emit_test.go b/Server/ws/emit_test.go index a49087cb..09fe4774 100644 --- a/Server/ws/emit_test.go +++ b/Server/ws/emit_test.go @@ -28,8 +28,7 @@ func newEmitTestHub() *Hub { return &Hub{ clients: make(map[int64]*Client), broadcast: make(chan broadcastMsg, 64), - register: make(chan *Client, 16), - unregister: make(chan *Client, 16), + clientEvents: make(chan clientEvent, 32), stop: make(chan struct{}), pubsub: NewPubSub(), replayBuf: NewEventRingBuffer(100), From bc7d65ab29585bb3e8fa17c86d241b1a44dc2ee9 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:18:40 +0200 Subject: [PATCH 28/29] fix(service): thread request context through BanUser/UnbanUser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contextcheck (CI lint) flagged the admin handler calling BanUser without the request context — the service opened its telemetry span from context.Background(), detaching the ban from its request trace. Both moderation entrypoints now take ctx; the span joins the caller's trace. Co-Authored-By: Claude Fable 5 --- Server/admin/handlers_users.go | 4 ++-- Server/service/moderation.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index c3956bd9..f3e9d628 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -117,9 +117,9 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } var actionErr error if *req.Banned { - actionErr = mod.BanUser(actor, id, banReason, nil) + actionErr = mod.BanUser(r.Context(), actor, id, banReason, nil) } else { - actionErr = mod.UnbanUser(actor, id) + actionErr = mod.UnbanUser(r.Context(), actor, id) } if actionErr != nil { writeModerationErr(w, actionErr) diff --git a/Server/service/moderation.go b/Server/service/moderation.go index 12742bbf..d5c692a6 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -64,8 +64,8 @@ func (s *ModerationService) requireOutranks(actorID, targetID int64) error { // BanUser bans a target user. Validates the target exists and // prevents self-banning. -func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expires *time.Time) error { - ctx, span := telemetry.GlobalTracer("service/moderation").Start(context.Background(), "ModerationService.BanUser", +func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64, reason string, expires *time.Time) error { + ctx, span := telemetry.GlobalTracer("service/moderation").Start(ctx, "ModerationService.BanUser", telemetry.Int64("actor_id", actorID), telemetry.Int64("target_id", targetID), ) @@ -109,7 +109,7 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi } // UnbanUser removes a ban on a target user. -func (s *ModerationService) UnbanUser(actorID, targetID int64) error { +func (s *ModerationService) UnbanUser(_ context.Context, actorID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } From 963bea9a649b6bb9f7817814273d94c39bd5e3b1 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:18:40 +0200 Subject: [PATCH 29/29] test(service): update moderation callsites for context parameter Co-Authored-By: Claude Fable 5 --- Server/service/moderation_test.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go index 1e6146f7..37b316a3 100644 --- a/Server/service/moderation_test.go +++ b/Server/service/moderation_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "testing" @@ -30,12 +31,12 @@ func TestBanUser_RequiresBanPermission(t *testing.T) { svc, _ := newTestModerationService() // A member without BAN_MEMBERS is refused. - if err := svc.BanUser(3, 4, "nope", nil); !errors.Is(err, ErrForbidden) { + if err := svc.BanUser(context.Background(), 3, 4, "nope", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("member ban attempt: want ErrForbidden, got %v", err) } // And gets Forbidden — not NotFound — for a nonexistent target, so the // ban path cannot be used to enumerate user ids. - if err := svc.BanUser(3, 999, "probe", nil); !errors.Is(err, ErrForbidden) { + if err := svc.BanUser(context.Background(), 3, 999, "probe", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err) } } @@ -44,11 +45,11 @@ func TestBanUser_HierarchyEnforced(t *testing.T) { svc, ms := newTestModerationService() // Equal rank: mod cannot ban mod. - if err := svc.BanUser(2, 5, "peer", nil); !errors.Is(err, ErrForbidden) { + if err := svc.BanUser(context.Background(), 2, 5, "peer", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("equal-rank ban: want ErrForbidden, got %v", err) } // Higher rank target: mod cannot ban the owner. - if err := svc.BanUser(2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { + if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("ban owner: want ErrForbidden, got %v", err) } owner, _ := ms.GetUserByID(1) @@ -60,7 +61,7 @@ func TestBanUser_HierarchyEnforced(t *testing.T) { func TestBanUser_AuthorizedSucceeds(t *testing.T) { svc, ms := newTestModerationService() - if err := svc.BanUser(2, 3, "spam", nil); err != nil { + if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil { t.Fatalf("authorized ban: %v", err) } target, _ := ms.GetUserByID(3) @@ -72,11 +73,11 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { } // Authorized actor gets a real NotFound for a missing target. - if err := svc.BanUser(2, 999, "gone", nil); !errors.Is(err, ErrNotFound) { + if err := svc.BanUser(context.Background(), 2, 999, "gone", nil); !errors.Is(err, ErrNotFound) { t.Fatalf("authorized ban of missing id: want ErrNotFound, got %v", err) } // Self-ban is a bad request regardless of authority. - if err := svc.BanUser(2, 2, "self", nil); !errors.Is(err, ErrBadRequest) { + if err := svc.BanUser(context.Background(), 2, 2, "self", nil); !errors.Is(err, ErrBadRequest) { t.Fatalf("self ban: want ErrBadRequest, got %v", err) } } @@ -84,20 +85,20 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { func TestUnbanUser_AuthorizationMatrix(t *testing.T) { svc, ms := newTestModerationService() - if err := svc.BanUser(1, 3, "setup", nil); err != nil { + if err := svc.BanUser(context.Background(), 1, 3, "setup", nil); err != nil { t.Fatalf("setup ban: %v", err) } // No BAN_MEMBERS → Forbidden (member 4 trying to unban member 3). - if err := svc.UnbanUser(4, 3); !errors.Is(err, ErrForbidden) { + if err := svc.UnbanUser(context.Background(), 4, 3); !errors.Is(err, ErrForbidden) { t.Fatalf("member unban: want ErrForbidden, got %v", err) } // Equal rank → Forbidden. - if err := svc.UnbanUser(2, 5); !errors.Is(err, ErrForbidden) { + if err := svc.UnbanUser(context.Background(), 2, 5); !errors.Is(err, ErrForbidden) { t.Fatalf("equal-rank unban: want ErrForbidden, got %v", err) } // Authorized → succeeds. - if err := svc.UnbanUser(2, 3); err != nil { + if err := svc.UnbanUser(context.Background(), 2, 3); err != nil { t.Fatalf("authorized unban: %v", err) } target, _ := ms.GetUserByID(3)