diff --git a/.gitignore b/.gitignore index a61aac2b..b3737552 100644 --- a/.gitignore +++ b/.gitignore @@ -65,8 +65,11 @@ node_modules/ .rust-review-results/ .claude/worktrees/ -# Internal dev tools -tools/ +# Internal dev tools (e.g. tools/livekit-server.exe) are ignored, but the +# owncord-introspect MCP server is a committed, shared dev tool. +tools/* +!tools/mcp-introspect/ +tools/mcp-introspect/node_modules/ .cache/ # Internal dev files (root-level scratch only; .claude/skills/ and CLAUDE.md are committed) diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 24d0e694..0444dfd9 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -955,6 +955,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2118,7 +2129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2495,6 +2506,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2682,9 +2702,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2695,10 +2715,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2716,12 +2736,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2826,6 +2840,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2850,6 +2885,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2920,8 +2987,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -3398,6 +3484,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4757,15 +4856,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.6" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.0", "block2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -4776,13 +4876,14 @@ dependencies = [ "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -4823,9 +4924,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" dependencies = [ "anyhow", "bytes", @@ -4895,16 +4996,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4922,9 +5023,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5207,9 +5308,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -5232,9 +5333,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -5766,9 +5867,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -5780,10 +5881,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6984,9 +7085,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", diff --git a/Server/.golangci.yml b/Server/.golangci.yml index 21c5df82..1d18c594 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -13,6 +13,7 @@ linters: - unparam # finds unused function parameters - wastedassign # finds wasted assignments - staticcheck # advanced static analysis (correctness, performance, deprecation) + - modernize # flags outdated idioms (slices/maps/min/max, range-over-int, any, fmt.Appendf) settings: staticcheck: diff --git a/Server/admin/api.go b/Server/admin/api.go index 8db87f8f..3ef9dbe3 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -50,6 +50,17 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator)) r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator)) r.Get("/audit-log", handleGetAuditLog(database)) + // API tokens — Owner-only. Minting a network-reachable, revocation- + // surviving bearer credential is gated like backups/updates. + r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ownerOnlyMiddleware(database, handleListAPITokens(database)).ServeHTTP(w, req) + })) + r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req) + })) + r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req) + })) r.Get("/settings", handleGetSettings(database)) r.Patch("/settings", handlePatchSettings(database)) r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index d606e1fc..4e6b5e46 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -236,7 +236,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { // Create several audit entries. uid, _ := database.CreateUser(context.Background(), "auditpager", "hash", 1) - for i := 0; i < 5; i++ { + for i := range 5 { _ = database.LogAudit(context.Background(), uid, "TEST", "test", int64(i), "") } diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 5e167d7c..ee9401cf 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -130,6 +130,17 @@ CREATE TABLE IF NOT EXISTS audit_log ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -1276,6 +1287,138 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { } } +// ─── API tokens: /admin/api/tokens ─────────────────────────────────────────── + +func TestAdminAPI_CreateAPIToken_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) // Owner role + + w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"}) + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + raw, _ := resp["token"].(string) + if raw == "" { + t.Fatal("response missing raw token") + } + // The minted token must actually authenticate as the owner it was bound to. + user, _, _, err := auth.ResolveTokenHash(context.Background(), database, auth.HashToken(raw)) + if err != nil || user == nil { + t.Fatalf("minted token does not resolve: user=%v err=%v", user, err) + } + // And it must be listed, without any hash leaking. + tokens, _ := database.ListAPITokens(context.Background()) + if len(tokens) != 1 || tokens[0].Label != "ci-bot" { + t.Fatalf("expected 1 token labelled ci-bot, got %+v", tokens) + } +} + +func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "}) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +func TestAdminAPI_ListAPITokens_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + hash := auth.HashToken("raw-secret-value") + if _, err := database.CreateAPIToken(context.Background(), 1, hash, "seeded", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, "/tokens", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), hash) { + t.Error("GET /tokens leaked the token hash") + } + var tokens []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(tokens) != 1 { + t.Fatalf("expected 1 token, got %d", len(tokens)) + } + if _, ok := tokens[0]["created_at"]; !ok { + t.Error("token row missing snake_case 'created_at' field") + } +} + +func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + hash := auth.HashToken("revoke-me") + id, err := database.CreateAPIToken(context.Background(), 1, hash, "doomed", nil) + if err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, "/tokens/"+itoa(id), token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + // A revoked token must no longer authenticate. + active, _ := database.GetActiveAPIToken(context.Background(), hash) + if active != nil { + t.Error("token still active after revoke") + } +} + +func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// TestAdminAPI_Tokens_RequiresOwner locks the Owner gate: a non-Owner admin can +// authenticate to /admin/api but must not mint API tokens (the credential that +// survives password change + bulk logout). +func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + + adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner + token := "admin-only-token" + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") + + w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "nope"}) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } +} + +func TestAdminAPI_Tokens_Unauthenticated(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + + w := doRequest(t, handler, http.MethodGet, "/tokens", "", nil) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + // ─── helpers ───────────────────────────────────────────────────────────────── // itoa converts an int64 to a string for use in URL paths. diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index 120634c5..cf41b99b 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "slices" "strings" "github.com/owncord/server/db" @@ -49,10 +50,8 @@ func validateCategoryType(channelType, category string) string { return "" } allowed := allowedChannelTypes(category) - for _, t := range allowed { - if t == channelType { - return "" - } + if slices.Contains(allowed, channelType) { + return "" } if isVoiceCategory(category) { return "only voice channels can be created under a voice category" diff --git a/Server/admin/handlers_tokens.go b/Server/admin/handlers_tokens.go new file mode 100644 index 00000000..8b57bf16 --- /dev/null +++ b/Server/admin/handlers_tokens.go @@ -0,0 +1,128 @@ +package admin + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── API Token Handlers ────────────────────────────────────────────────────── +// +// These are the HTTP-panel equivalent of `server token create|list|revoke` +// (token_cli.go). They wrap the same db.*APIToken calls, so behaviour stays in +// sync with the CLI. All three routes are Owner-gated in api.go: minting a +// long-lived bearer credential over the network is the one admin action that, +// via a hijacked session, would outlive a password change and bulk logout +// (API tokens deliberately live outside the session table), so it stays behind +// the Owner role rather than the broad ADMINISTRATOR bit. + +func handleListAPITokens(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tokens, err := database.ListAPITokens(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list tokens") + return + } + writeJSON(w, http.StatusOK, tokens) + } +} + +// createTokenRequest is the JSON body for POST /admin/api/tokens. Username empty +// binds the token to the owner account (the CLI default); ExpiresHours 0 means +// never expires. +type createTokenRequest struct { + Label string `json:"label"` + Username string `json:"username"` + ExpiresHours int `json:"expires_hours"` +} + +// createTokenResponse carries the raw token — shown exactly once, never +// recoverable — plus enough context for the UI to display what was minted. +type createTokenResponse struct { + ID int64 `json:"id"` + Token string `json:"token"` + Label string `json:"label"` + User string `json:"user"` +} + +func handleCreateAPIToken(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req createTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + req.Label = strings.TrimSpace(req.Label) + if req.Label == "" { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "label is required") + return + } + + var user *db.User + var err error + if req.Username != "" { + user, err = database.GetUserByUsername(r.Context(), req.Username) + } else { + user, err = database.GetOwnerUser(r.Context()) + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to look up user") + return + } + if user == nil { + writeErr(w, http.StatusBadRequest, "NOT_FOUND", "user not found") + return + } + + raw, err := auth.GenerateToken() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate token") + return + } + var expiresAt *time.Time + if req.ExpiresHours > 0 { + t := time.Now().Add(time.Duration(req.ExpiresHours) * time.Hour) + expiresAt = &t + } + id, err := database.CreateAPIToken(r.Context(), user.ID, auth.HashToken(raw), req.Label, expiresAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create token") + return + } + + actor := actorFromContext(r) + slog.Info("api token created", "actor_id", actor, "token_id", id, "label", req.Label, "bound_user", user.Username) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "api_token_create", "api_token", id, req.Label) + + writeJSON(w, http.StatusCreated, createTokenResponse{ID: id, Token: raw, Label: req.Label, User: user.Username}) + } +} + +func handleRevokeAPIToken(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid token id") + return + } + affected, err := database.RevokeAPIToken(r.Context(), id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to revoke token") + return + } + if affected == 0 { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "no active token with that id") + return + } + actor := actorFromContext(r) + slog.Warn("api token revoked", "actor_id", actor, "token_id", id) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "api_token_revoke", "api_token", id, "") + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index e8442d85..78f3e74d 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -2,6 +2,7 @@ package admin import ( "context" + "errors" "net/http" "github.com/owncord/server/auth" @@ -31,44 +32,43 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(r.Context(), hash) - if err != nil || sess == nil { - writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") + // Resolve the bearer token: login session first, then API token. An + // API token whose user carries the ADMINISTRATOR bit authenticates + // here too, so /admin/api/* works for headless clients. + user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash) + if err != nil { + switch { + case errors.Is(err, auth.ErrTokenExpired): + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired") + case errors.Is(err, auth.ErrUserNotFound): + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found") + case errors.Is(err, auth.ErrRoleNotFound): + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found") + default: + // ErrTokenNotFound or a wrapped DB error. + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") + } return } - if auth.IsSessionExpired(sess.ExpiresAt) { - writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired") - return - } - - user, err := database.GetUserByID(r.Context(), sess.UserID) - if err != nil || user == nil { - writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found") - return - } - - // Reject effectively-banned users before any further processing, as - // api.AuthMiddleware does: a ban must revoke admin-panel access - // immediately, not only once the session expires. + // F1: reject effectively-banned users before any further processing, + // as api.AuthMiddleware does — a ban must revoke admin-panel access + // immediately, not only once the session expires. Deliberately placed + // AFTER ResolveTokenHash so it also covers the API-token path this + // commit introduces; gating only the session branch would let a + // banned administrator keep working through a bot token. if auth.IsEffectivelyBanned(user) { writeErr(w, http.StatusForbidden, "FORBIDDEN", "your account has been suspended") return } - role, err := database.GetRoleByID(r.Context(), user.RoleID) - if err != nil || role == nil { - writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found") - return - } - if !permissions.HasAdmin(role.Permissions) { writeErr(w, http.StatusForbidden, "FORBIDDEN", "administrator permission required") return } ctx := context.WithValue(r.Context(), adminUserKey, user) - ctx = context.WithValue(ctx, adminSessionKey, sess) + ctx = context.WithValue(ctx, adminSessionKey, sess) // nil for API-token principals; consumers guard nil next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 54331625..eeb19317 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -156,7 +156,7 @@ func TestSetup_ConcurrentRace(t *testing.T) { // Launch goroutines simultaneously. start := make(chan struct{}) - for i := 0; i < goroutines; i++ { + for i := range goroutines { go func(n int) { <-start // wait for the gate rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ @@ -169,7 +169,7 @@ func TestSetup_ConcurrentRace(t *testing.T) { close(start) // release all goroutines at once created := 0 - for i := 0; i < goroutines; i++ { + for range goroutines { code := <-results switch code { case http.StatusCreated: diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 73cca80f..01d3e0f4 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -372,6 +372,7 @@ const NAV=[ {sep:true}, {section:'Configuration'}, {id:'audit',label:'Audit Log',icon:I.audit}, + {id:'tokens',label:'API Tokens',icon:I.lock}, {id:'logs',label:'Server Logs',icon:I.logs}, {id:'settings',label:'Settings',icon:I.settings,unsaved:()=>state.settingsChanged}, {id:'backups',label:'Backups',icon:I.backup}, @@ -408,7 +409,7 @@ function doLogout(){state.logConnectSeq++;if(state.logEventSource){state.logEven /* ═══ Content Router ═══ */ function renderContent(){ const c=document.getElementById('content');if(!c)return;c.scrollTop=0; - const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,audit:renderAudit,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; + const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,audit:renderAudit,tokens:renderTokens,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; c.innerHTML='
Loading...
'; const fn=r[state.section]; if(typeof fn!=='function'){console.error('[Admin] No render function for section: '+state.section);c.innerHTML='
Error

Unknown section: '+esc(state.section)+'

';return} @@ -862,6 +863,68 @@ async function confirmDeleteBackup(name){ try{await fetch('/admin/api/backups/'+encodeURIComponent(name),{method:'DELETE',headers:{'Authorization':'Bearer '+state.token}});showToast('Backup deleted');renderContent()}catch(e){showToast(e.message,'error')} } +/* ═══ API Tokens ═══ */ +function tokenStatus(t){ + if(t.revoked_at)return'Revoked'; + if(t.expires_at&&new Date(t.expires_at)Expired'; + return'Active'; +} +async function renderTokens(){ + let tokens; + try{tokens=await api('GET','/tokens')}catch(e){return'
API Tokens

'+esc(e.message)+'

'} + let html='
API Tokens
Long-lived bearer tokens for bots, CI, and the introspection MCP tool. A token authenticates as its bound user. Owner only.
'; + html+='
'; + html+='

Tokens

'; + if(!tokens||!tokens.length)html+=''; + else tokens.forEach(t=>{ + const revoked=!!t.revoked_at; + html+=''; + html+=''; + html+=''; + html+=''; + html+=''; + html+=''; + }); + html+='
LabelUserCreatedLast UsedExpiresStatusActions
No API tokens
'+esc(t.label||'—')+''+esc(t.username)+''+(t.created_at?new Date(t.created_at).toLocaleString():'')+''+(t.last_used?new Date(t.last_used).toLocaleString():'never')+''+(t.expires_at?new Date(t.expires_at).toLocaleString():'never')+''+tokenStatus(t)+'
'+(revoked?'':'')+'
'; + return html; +} + +function openCreateTokenModal(){ + openModal(''+ + ''+ + ''); +} + +async function createToken(){ + const label=document.getElementById('tokLabel').value.trim(); + const user=document.getElementById('tokUser').value.trim(); + const expires=parseInt(document.getElementById('tokExpires').value,10)||0; + if(!label){showToast('Label is required','error');return} + try{ + const d=await api('POST','/tokens',{label,username:user,expires_hours:expires}); + showTokenOnceModal(d); + }catch(e){showToast(e.message,'error')} +} + +// The raw token is shown exactly once here — it is never recoverable afterward. +function showTokenOnceModal(d){ + openModal(''+ + ''+ + ''); +} +function copyToken(t){navigator.clipboard.writeText(t).then(()=>showToast('Copied!','info'))} + +function confirmRevokeToken(id,label){ + openModal(''); +} +async function revokeToken(id){ + try{await api('DELETE','/tokens/'+id);closeModal();showToast('Token revoked');renderContent()}catch(e){showToast(e.message,'error')} +} + /* ═══ Updates ═══ */ async function renderUpdates(){ let info; diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index 74245ec2..4a093bc3 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -335,7 +335,7 @@ func TestLogin_LockoutUsesTrustedForwardedIP(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouterWithProxies(database, limiter, []string{"127.0.0.0/8"}) - for i := 0; i < 10; i++ { + for range 10 { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(`{"username":"nobody","password":"wrongpass123"}`))) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Forwarded-For", "198.51.100.10") @@ -365,7 +365,7 @@ func TestLogin_UsernameLockoutAcrossDifferentIPs(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") _, _ = database.CreateUser(context.Background(), "lockoutuser", hash, 4) - for i := 0; i < 10; i++ { + for i := range 10 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "lockoutuser", "password": "wrongpassword", @@ -392,7 +392,7 @@ func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") _, _ = database.CreateUser(context.Background(), "lockoutcorrect", hash, 4) - for i := 0; i < 10; i++ { + for i := range 10 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "lockoutcorrect", "password": "wrongpassword", @@ -425,7 +425,7 @@ func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) { // Trip the per-username lockout using the lowercase spelling, from many IPs // so the per-IP limiter is never the binding cap. - for i := 0; i < 10; i++ { + for i := range 10 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "casehunt", "password": "wrongpassword", @@ -515,7 +515,7 @@ func TestLogin_NineFailuresThenCorrectPasswordSucceeds(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") _, _ = database.CreateUser(context.Background(), "boundaryuser", hash, 4) - for i := 0; i < 9; i++ { + for i := range 9 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "boundaryuser", "password": "wrongpassword", @@ -542,7 +542,7 @@ func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") _, _ = database.CreateUser(context.Background(), "resetuser", hash, 4) - for i := 0; i < 8; i++ { + for i := range 8 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "resetuser", "password": "wrongpassword", @@ -560,7 +560,7 @@ func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { t.Fatalf("success status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - for i := 0; i < 3; i++ { + for i := range 3 { rr = postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "resetuser", "password": "wrongpassword", @@ -634,7 +634,7 @@ func TestLogin_UsernameLockoutBlocksTOTPChallenge(t *testing.T) { t.Fatalf("set totp secret: %v", err) } - for i := 0; i < 10; i++ { + for i := range 10 { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ "username": "totplocked", "password": "wrongpassword", @@ -863,7 +863,7 @@ func TestVerifyTotp_ConsumesChallengeAfterRepeatedFailures(t *testing.T) { t.Fatal("expected partial_token from login") } - for i := 0; i < 5; i++ { + for i := range 5 { verify := postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken, map[string]string{"code": "000000"}) if verify.Code != http.StatusUnauthorized { t.Fatalf("attempt %d status = %d, want 401; body = %s", i+1, verify.Code, verify.Body.String()) @@ -1285,7 +1285,7 @@ func TestDeleteAccount_LockoutAfterRepeatedFailures(t *testing.T) { _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // 3 failures should trigger lockout on the 4th attempt. - for i := 0; i < 4; i++ { + for range 4 { deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "wrongPassword1", }) diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 3d8ea400..a8dd681e 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -615,7 +615,7 @@ func TestSearch_TrustedProxyRateLimitUsesForwardedIP(t *testing.T) { api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"}) token := chTestCreateToken(t, database, "proxysearch", 1) - for i := 0; i < 30; i++ { + for i := range 30 { req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1)) diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 84488d7b..97ee1ed3 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -83,7 +83,7 @@ func TestEnableTOTP_AlreadyEnabled(t *testing.T) { t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String()) } - var enableResp map[string]interface{} + var enableResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&enableResp) secret := extractSecretFromURI(t, enableResp["qr_uri"].(string)) @@ -1157,7 +1157,7 @@ func TestSearch_RateLimit(t *testing.T) { // Make many rapid search requests to trigger rate limiting. var lastCode int - for i := 0; i < 25; i++ { + for range 25 { rr := chGet(t, router, "/api/v1/search?q=ratelimittest", token) lastCode = rr.Code if lastCode == http.StatusTooManyRequests { diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index fff016aa..c5925e16 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -145,7 +145,7 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle // Notify via WebSocket so sidebar updates immediately. if broadcaster != nil { - closeMsg := []byte(fmt.Sprintf(`{"type":"dm_channel_close","payload":{"channel_id":%d}}`, channelID)) + closeMsg := fmt.Appendf(nil, `{"type":"dm_channel_close","payload":{"channel_id":%d}}`, channelID) if ok := broadcaster.SendToUser(user.ID, closeMsg); !ok { slog.Debug("handleCloseDM: user not connected", "user_id", user.ID, "channel_id", channelID) } diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 4bc11614..9440c3cd 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -64,7 +64,7 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { // 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), "/") { + for seg := range strings.SplitSeq(strings.ToLower(r.URL.Path), "/") { if blockedSegments[seg] { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", diff --git a/Server/api/middleware.go b/Server/api/middleware.go index e0fe9d88..21a69843 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "fmt" "log/slog" "net" @@ -42,25 +43,13 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(r.Context(), hash) - if err != nil || sess == nil { - if err != nil { - // A DB error here is an outage, not a bad token — log it so - // it's distinguishable from ordinary invalid-token 401s. - slog.ErrorContext(r.Context(), "auth: session lookup failed", "error", err) - } - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", - Message: "invalid or expired session", - }) - return - } - - // Check expiry. - if auth.IsSessionExpired(sess.ExpiresAt) { - // Clean up expired session in background to prevent accumulation. - // The request ctx is cancelled as soon as the 401 below is - // written, so detach cancellation: the deletion must complete. + // Resolve the bearer token to a principal. A login session is matched + // first (existing behavior unchanged); an API token is the fallback. + user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash) + switch { + case errors.Is(err, auth.ErrTokenExpired): + // Clean up the expired login session in the background. The request + // ctx is cancelled once the 401 is written, so detach cancellation. cleanupCtx := context.WithoutCancel(r.Context()) go func(h string) { if err := database.DeleteSession(cleanupCtx, h); err != nil { @@ -72,19 +61,29 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { Message: "session has expired", }) return - } - - // Load user. - user, err := database.GetUserByID(r.Context(), sess.UserID) - if err != nil || user == nil { - if err != nil { - slog.ErrorContext(r.Context(), "auth: user lookup failed", "error", err, "user_id", sess.UserID) - } + case errors.Is(err, auth.ErrUserNotFound): writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "user not found", }) return + case errors.Is(err, auth.ErrRoleNotFound): + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "role not found", + }) + return + case err != nil: + // ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad + // token — log it so it's distinguishable from ordinary 401s. + if !errors.Is(err, auth.ErrTokenNotFound) { + slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err) + } + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid or expired session", + }) + return } // Reject effectively-banned users before any further processing. @@ -96,29 +95,24 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { return } - // Load role for permission checks. - // A dangling role_id returns (nil, nil) from GetRoleByID, so the nil - // check is load-bearing: without it a nil role reaches the context - // and every downstream permission check has to re-guard it. - role, err := database.GetRoleByID(r.Context(), user.RoleID) - if err != nil || role == nil { - if err != nil { - slog.ErrorContext(r.Context(), "auth: role lookup failed", "error", err, "user_id", user.ID, "role_id", user.RoleID) + // Touch last-used — non-fatal. A login session is touched inline as + // before; an API-token principal (sess == nil) is touched off the hot + // path so it never adds latency to bot/CI traffic. + if sess != nil { + if err := database.TouchSession(r.Context(), hash); err != nil { + slog.Warn("failed to touch session", "error", err, "user_id", user.ID) } - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", - Message: "role not found", - }) - return - } - - // Touch session in background — non-fatal if it fails. - if err := database.TouchSession(r.Context(), hash); err != nil { - slog.Warn("failed to touch session", "error", err, "user_id", user.ID) + } else { + touchCtx := context.WithoutCancel(r.Context()) + go func(h string) { + if err := database.TouchAPIToken(touchCtx, h); err != nil { + slog.WarnContext(touchCtx, "failed to touch api token", "error", err) + } + }(hash) } ctx := context.WithValue(r.Context(), UserKey, user) - ctx = context.WithValue(ctx, SessionKey, sess) + ctx = context.WithValue(ctx, SessionKey, sess) // nil for API-token principals; consumers guard nil ctx = context.WithValue(ctx, RoleKey, role) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 8287c6a2..d4b08c48 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -86,6 +86,58 @@ func TestAuthMiddleware_MissingToken(t *testing.T) { } } +func TestAuthMiddleware_ValidAPIToken(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser(context.Background(), "botuser", "hash", 4) + token, _ := auth.GenerateToken() + if _, err := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + var gotUserID int64 + h := api.AuthMiddleware(database)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u, ok := r.Context().Value(api.UserKey).(*db.User); ok && u != nil { + gotUserID = u.ID + } + // An API-token principal has no login session: SessionKey must be nil. + if s, ok := r.Context().Value(api.SessionKey).(*db.Session); ok && s != nil { + t.Errorf("expected nil session for API-token principal, got %+v", s) + } + w.WriteHeader(http.StatusOK) + })) + req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("API token status = %d, want 200", rr.Code) + } + if gotUserID != uid { + t.Errorf("API token authenticated as user %d, want %d", gotUserID, uid) + } +} + +func TestAuthMiddleware_RevokedAPIToken(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser(context.Background(), "botuser2", "hash", 4) + token, _ := auth.GenerateToken() + id, _ := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil) + if _, err := database.RevokeAPIToken(context.Background(), id); err != nil { + t.Fatalf("RevokeAPIToken: %v", err) + } + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("revoked API token status = %d, want 401", rr.Code) + } +} + func TestAuthMiddleware_InvalidToken(t *testing.T) { database := newAPITestDB(t) @@ -1031,6 +1083,17 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, diff --git a/Server/api/router.go b/Server/api/router.go index c278b803..36e73332 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "time" "github.com/go-chi/chi/v5" @@ -272,11 +273,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri ) // Issue 15: Warn if AllowedOrigins contains wildcard. - for _, o := range cfg.Server.AllowedOrigins { - if o == "*" { - slog.Warn("AllowedOrigins contains wildcard '*' — consider restricting to specific origins for production use") - break - } + if slices.Contains(cfg.Server.AllowedOrigins, "*") { + slog.Warn("AllowedOrigins contains wildcard '*' — consider restricting to specific origins for production use") } cleanup := func() { diff --git a/Server/api/totp_handler_test.go b/Server/api/totp_handler_test.go index 84cb64bc..5516079b 100644 --- a/Server/api/totp_handler_test.go +++ b/Server/api/totp_handler_test.go @@ -35,7 +35,7 @@ func TestVerifyTOTP_Success(t *testing.T) { t.Fatalf("login status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var loginResp map[string]interface{} + var loginResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&loginResp) if loginResp["requires_2fa"] != true { t.Fatal("expected requires_2fa=true in login response") @@ -57,7 +57,7 @@ func TestVerifyTOTP_Success(t *testing.T) { t.Errorf("verify-totp status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var verifyResp map[string]interface{} + var verifyResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&verifyResp) if verifyResp["token"] == nil { t.Error("verify-totp response missing session token") @@ -79,7 +79,7 @@ func TestVerifyTOTP_InvalidCode(t *testing.T) { "username": "totpuser2", "password": "Password1!", }) - var loginResp map[string]interface{} + var loginResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&loginResp) partialToken := loginResp["partial_token"].(string) @@ -130,7 +130,7 @@ func TestVerifyTOTP_MalformedBody(t *testing.T) { "username": "totpuser3", "password": "Password1!", }) - var loginResp map[string]interface{} + var loginResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&loginResp) partialToken := loginResp["partial_token"].(string) @@ -165,7 +165,7 @@ func TestVerifyTOTP_ReplayProtection(t *testing.T) { "username": "totpuser4", "password": "Password1!", }) - var resp1 map[string]interface{} + var resp1 map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp1) token1 := resp1["partial_token"].(string) @@ -180,7 +180,7 @@ func TestVerifyTOTP_ReplayProtection(t *testing.T) { "username": "totpuser4", "password": "Password1!", }) - var resp2 map[string]interface{} + var resp2 map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp2) token2 := resp2["partial_token"].(string) @@ -207,7 +207,7 @@ func TestEnableTOTP_Success(t *testing.T) { t.Errorf("enable-totp status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["qr_uri"] == nil || resp["qr_uri"] == "" { t.Error("enable-totp response missing qr_uri") @@ -256,7 +256,7 @@ func TestConfirmTOTP_Success(t *testing.T) { t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String()) } - var enableResp map[string]interface{} + var enableResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&enableResp) qrURI, _ := enableResp["qr_uri"].(string) @@ -344,7 +344,7 @@ func TestDisableTOTP_Success(t *testing.T) { // Enable and confirm TOTP first. rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "Password1!"}) - var enableResp map[string]interface{} + var enableResp map[string]any _ = json.NewDecoder(rr.Body).Decode(&enableResp) secret := extractSecretFromURI(t, enableResp["qr_uri"].(string)) code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) diff --git a/Server/auth/helpers_test.go b/Server/auth/helpers_test.go index e71f217a..8f269000 100644 --- a/Server/auth/helpers_test.go +++ b/Server/auth/helpers_test.go @@ -227,9 +227,6 @@ func TestIsSessionExpired_ExactlyNow(t *testing.T) { // ─── IsEffectivelyBanned ────────────────────────────────────────────────────── -// ptr is a helper to get a pointer to a string literal. -func ptr(s string) *string { return &s } - func TestIsEffectivelyBanned_NotBanned(t *testing.T) { u := &db.User{Banned: false} if auth.IsEffectivelyBanned(u) { @@ -248,7 +245,7 @@ func TestIsEffectivelyBanned_BannedNilExpiry(t *testing.T) { func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) { // Banned with an expiry in the future — still banned. future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05") - u := &db.User{Banned: true, BanExpires: ptr(future)} + u := &db.User{Banned: true, BanExpires: new(future)} if !auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=true, future expiry) = false, want true") } @@ -257,7 +254,7 @@ func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) { func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) { // Banned but the ban expired in the past — should be treated as NOT banned. past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05") - u := &db.User{Banned: true, BanExpires: ptr(past)} + u := &db.User{Banned: true, BanExpires: new(past)} if auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=true, past expiry) = true, want false") } @@ -266,7 +263,7 @@ func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) { func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) { // ISO-8601 format for BanExpires past — should be treated as NOT banned. past := time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05Z") - u := &db.User{Banned: true, BanExpires: ptr(past)} + u := &db.User{Banned: true, BanExpires: new(past)} if auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 past expiry) = true, want false") } @@ -275,7 +272,7 @@ func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) { func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) { // ISO-8601 format for BanExpires in future — still banned. future := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z") - u := &db.User{Banned: true, BanExpires: ptr(future)} + u := &db.User{Banned: true, BanExpires: new(future)} if !auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 future expiry) = false, want true") } @@ -283,7 +280,7 @@ func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) { func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) { // Unparseable expiry string — fail-safe: treat as still banned. - u := &db.User{Banned: true, BanExpires: ptr("not-a-date")} + u := &db.User{Banned: true, BanExpires: new("not-a-date")} if !auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=true, unparseable expiry) = false, want true (fail-safe)") } @@ -292,7 +289,7 @@ func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) { func TestIsEffectivelyBanned_NotBannedIgnoresExpiry(t *testing.T) { // Banned=false even with a future expiry field — should be false. future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05") - u := &db.User{Banned: false, BanExpires: ptr(future)} + u := &db.User{Banned: false, BanExpires: new(future)} if auth.IsEffectivelyBanned(u) { t.Error("IsEffectivelyBanned(Banned=false, future expiry) = true, want false") } diff --git a/Server/auth/resolve.go b/Server/auth/resolve.go new file mode 100644 index 00000000..0b9440b3 --- /dev/null +++ b/Server/auth/resolve.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "errors" + + "github.com/owncord/server/db" +) + +// tokenStore is the DB surface bearer-token resolution needs. *db.DB satisfies +// it directly; tests use a fake. Kept as a tiny interface (like db.Auditor) so +// the security-critical resolution logic is unit-testable without a real DB. +type tokenStore interface { + GetSessionByTokenHash(ctx context.Context, tokenHash string) (*db.Session, error) + GetActiveAPIToken(ctx context.Context, tokenHash string) (*db.APIToken, error) + GetUserByID(ctx context.Context, id int64) (*db.User, error) + GetRoleByID(ctx context.Context, id int64) (*db.Role, error) +} + +// Sentinel outcomes, so each caller can reproduce its existing 401/403 responses +// exactly. A DB outage is NOT one of these — it surfaces as a wrapped error. +var ( + ErrTokenNotFound = errors.New("auth: no matching session or api token") + ErrTokenExpired = errors.New("auth: session expired") + ErrUserNotFound = errors.New("auth: user not found") + ErrRoleNotFound = errors.New("auth: role not found") +) + +// ResolveTokenHash resolves a hashed bearer token to its principal (user + role). +// +// It matches a login session FIRST — so every existing session code path is +// preserved byte-for-byte — and only falls through to an API token when no +// session row matches. The returned *db.Session is nil for an API-token +// principal (downstream consumers already guard a nil session). +// +// A DB error is returned WRAPPED (never a sentinel) so callers can distinguish +// an outage from a bad token and never fall through to API-token lookup on an +// outage. On ErrTokenExpired the matched (expired) session is returned so the +// caller can schedule its cleanup by hash, exactly as the api middleware does today. +func ResolveTokenHash(ctx context.Context, store tokenStore, hash string) (*db.User, *db.Role, *db.Session, error) { + sess, err := store.GetSessionByTokenHash(ctx, hash) + if err != nil { + return nil, nil, nil, err // DB outage — do not fall through to API tokens + } + + var userID int64 + switch { + case sess != nil: + if IsSessionExpired(sess.ExpiresAt) { + return nil, nil, sess, ErrTokenExpired + } + userID = sess.UserID + default: + tok, err := store.GetActiveAPIToken(ctx, hash) + if err != nil { + return nil, nil, nil, err + } + if tok == nil { + return nil, nil, nil, ErrTokenNotFound + } + userID = tok.UserID + } + + user, err := store.GetUserByID(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + if user == nil { + return nil, nil, nil, ErrUserNotFound + } + + // A dangling role_id returns (nil, nil): the nil check is load-bearing so a + // nil role never reaches the context and every downstream permission check. + role, err := store.GetRoleByID(ctx, user.RoleID) + if err != nil { + return nil, nil, nil, err + } + if role == nil { + return nil, nil, nil, ErrRoleNotFound + } + return user, role, sess, nil +} diff --git a/Server/auth/resolve_test.go b/Server/auth/resolve_test.go new file mode 100644 index 00000000..64f3f3f7 --- /dev/null +++ b/Server/auth/resolve_test.go @@ -0,0 +1,160 @@ +package auth_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// fakeStore is a hand-rolled tokenStore so the security-critical resolution +// logic is tested without a real database. It satisfies the (unexported) +// tokenStore interface structurally when passed to auth.ResolveTokenHash. +type fakeStore struct { + sess *db.Session + sessErr error + apiTok *db.APIToken + apiErr error + user *db.User + userErr error + role *db.Role + roleErr error + + apiCalled bool // set when the API-token fallback is consulted +} + +func (f *fakeStore) GetSessionByTokenHash(_ context.Context, _ string) (*db.Session, error) { + return f.sess, f.sessErr +} + +func (f *fakeStore) GetActiveAPIToken(_ context.Context, _ string) (*db.APIToken, error) { + f.apiCalled = true + return f.apiTok, f.apiErr +} + +func (f *fakeStore) GetUserByID(_ context.Context, _ int64) (*db.User, error) { + return f.user, f.userErr +} + +func (f *fakeStore) GetRoleByID(_ context.Context, _ int64) (*db.Role, error) { + return f.role, f.roleErr +} + +func future() string { return time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05Z") } +func past() string { return time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05Z") } + +func TestResolveTokenHash(t *testing.T) { + dbErr := errors.New("db down") + user := &db.User{ID: 7, RoleID: 3} + role := &db.Role{ID: 3} + + tests := []struct { + name string + store *fakeStore + wantErr error // nil = success; dbErr = wrapped (non-sentinel) DB error; else a sentinel + wantUser bool + wantSessionNil bool // only checked on success + wantAPICalled bool + }{ + { + name: "valid session resolves without consulting api tokens", + store: &fakeStore{sess: &db.Session{UserID: 7, ExpiresAt: future()}, user: user, role: role}, + wantErr: nil, + wantUser: true, + }, + { + name: "expired session returns ErrTokenExpired", + store: &fakeStore{sess: &db.Session{UserID: 7, ExpiresAt: past()}}, + wantErr: auth.ErrTokenExpired, + }, + { + name: "session miss falls through to active api token", + store: &fakeStore{sess: nil, apiTok: &db.APIToken{UserID: 7}, user: user, role: role}, + wantErr: nil, + wantUser: true, + wantSessionNil: true, + wantAPICalled: true, + }, + { + name: "no session and no active api token is ErrTokenNotFound", + store: &fakeStore{sess: nil, apiTok: nil}, + wantErr: auth.ErrTokenNotFound, + wantAPICalled: true, + }, + { + name: "api-token user missing is ErrUserNotFound", + store: &fakeStore{sess: nil, apiTok: &db.APIToken{UserID: 7}, user: nil}, + wantErr: auth.ErrUserNotFound, + wantAPICalled: true, + }, + { + name: "missing role is ErrRoleNotFound", + store: &fakeStore{sess: &db.Session{UserID: 7, ExpiresAt: future()}, user: user, role: nil}, + wantErr: auth.ErrRoleNotFound, + }, + { + name: "db error on session lookup does not fall through to api tokens", + store: &fakeStore{sessErr: dbErr}, + wantErr: dbErr, + // wantAPICalled stays false: an outage must never be treated as a session miss. + }, + { + name: "db error on api-token lookup is surfaced, not swallowed", + store: &fakeStore{sess: nil, apiErr: dbErr}, + wantErr: dbErr, + wantAPICalled: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + u, gotRole, sess, err := auth.ResolveTokenHash(context.Background(), tc.store, "hash") + + switch { + case tc.wantErr == nil: + if err != nil { + t.Fatalf("want success, got error %v", err) + } + if gotRole == nil { + t.Fatal("want role on success, got nil") + } + if tc.wantSessionNil && sess != nil { + t.Fatalf("want nil session for api-token principal, got %+v", sess) + } + if !tc.wantSessionNil && sess == nil { + t.Fatal("want session for session principal, got nil") + } + case errors.Is(tc.wantErr, dbErr): + if !errors.Is(err, dbErr) { + t.Fatalf("want wrapped db error, got %v", err) + } + // A DB outage must never masquerade as a sentinel outcome. + for _, s := range []error{auth.ErrTokenNotFound, auth.ErrTokenExpired, auth.ErrUserNotFound, auth.ErrRoleNotFound} { + if errors.Is(err, s) { + t.Fatalf("db error must not be sentinel %v", s) + } + } + default: + if !errors.Is(err, tc.wantErr) { + t.Fatalf("want %v, got %v", tc.wantErr, err) + } + } + + if errors.Is(err, auth.ErrTokenExpired) && sess == nil { + t.Fatal("expired session must be returned so the caller can clean it up") + } + if tc.wantUser && u == nil { + t.Fatal("want user, got nil") + } + if !tc.wantUser && u != nil { + t.Fatalf("want nil user, got %+v", u) + } + if tc.store.apiCalled != tc.wantAPICalled { + t.Fatalf("api-token fallback called = %v, want %v", tc.store.apiCalled, tc.wantAPICalled) + } + }) + } +} diff --git a/Server/config/config.go b/Server/config/config.go index d1b51828..66af6b05 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -510,9 +510,9 @@ func validateYAML(raw []byte) error { // tls_cert_file -> tls.cert_file // upload_max_size_mb -> upload.max_size_mb func envKeyToKoanf(s string) string { - idx := strings.Index(s, "_") - if idx < 0 { + before, after, ok := strings.Cut(s, "_") + if !ok { return s } - return s[:idx] + "." + s[idx+1:] + return before + "." + after } diff --git a/Server/db/account.go b/Server/db/account.go index fad44fab..20eb2899 100644 --- a/Server/db/account.go +++ b/Server/db/account.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/hex" "fmt" + "slices" "strings" ) @@ -64,13 +65,7 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error { return fmt.Errorf("DeleteAccount fetch role: %w", err) } - isAdminClass := false - for _, rid := range adminRoleIDs { - if userRoleID == rid { - isAdminClass = true - break - } - } + isAdminClass := slices.Contains(adminRoleIDs, userRoleID) if isAdminClass { // Build IN clause dynamically for the admin role IDs. diff --git a/Server/db/apitoken_queries.go b/Server/db/apitoken_queries.go new file mode 100644 index 00000000..fafbcfa1 --- /dev/null +++ b/Server/db/apitoken_queries.go @@ -0,0 +1,118 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/owncord/server/db/dbgen" +) + +// ─── API Token Operations ─────────────────────────────────────────────────── +// +// API tokens are long-lived, revocable bearer credentials for headless clients +// (the MCP introspection tool, bots, CI). They live in their own table so the +// per-user session cap, bulk logout, and password/TOTP session wipes never +// touch them. Like sessions, only the SHA-256 hash is stored. + +// CreateAPIToken inserts a new API token and returns its ID. tokenHash must +// already be hashed (never store the raw token). Pass expiresAt = nil for a +// token that never expires. +func (d *DB) CreateAPIToken(ctx context.Context, userID int64, tokenHash, label string, expiresAt *time.Time) (int64, error) { + var expiresStr *string + if expiresAt != nil { + s := expiresAt.UTC().Format("2006-01-02T15:04:05Z") + expiresStr = &s + } + res, err := d.q.CreateAPIToken(ctx, dbgen.CreateAPITokenParams{ + UserID: userID, + TokenHash: tokenHash, + Label: label, + ExpiresAt: expiresStr, + }) + if err != nil { + return 0, fmt.Errorf("CreateAPIToken: %w", err) + } + return res.LastInsertId() +} + +// GetActiveAPIToken returns the non-revoked, non-expired token matching +// tokenHash, or nil if none matches (unknown, revoked, or expired). The query +// itself filters revoked/expired rows, so a returned token is always usable. +func (d *DB) GetActiveAPIToken(ctx context.Context, tokenHash string) (*APIToken, error) { + t, err := d.q.GetActiveAPIToken(ctx, tokenHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetActiveAPIToken: %w", err) + } + return apiTokenFromGen(t), nil +} + +// ListAPITokens returns all API tokens (newest first, capped), without hashes. +func (d *DB) ListAPITokens(ctx context.Context) ([]APITokenListItem, error) { + rows, err := d.q.ListAPITokens(ctx) + if err != nil { + return nil, fmt.Errorf("ListAPITokens: %w", err) + } + out := make([]APITokenListItem, 0, len(rows)) + for _, r := range rows { + out = append(out, APITokenListItem{ + ID: r.ID, + UserID: r.UserID, + Username: r.Username, + Label: r.Label, + CreatedAt: r.CreatedAt, + LastUsed: r.LastUsedAt, + ExpiresAt: r.ExpiresAt, + RevokedAt: r.RevokedAt, + }) + } + return out, nil +} + +// RevokeAPIToken marks the token with the given ID revoked and returns the +// number of rows affected (0 if unknown or already revoked). +func (d *DB) RevokeAPIToken(ctx context.Context, id int64) (int64, error) { + res, err := d.q.RevokeAPIToken(ctx, id) + if err != nil { + return 0, fmt.Errorf("RevokeAPIToken: %w", err) + } + return res.RowsAffected() +} + +// RevokeAPITokenByLabel marks the token(s) with the given label revoked and +// returns the number of rows affected. +func (d *DB) RevokeAPITokenByLabel(ctx context.Context, label string) (int64, error) { + res, err := d.q.RevokeAPITokenByLabel(ctx, label) + if err != nil { + return 0, fmt.Errorf("RevokeAPITokenByLabel: %w", err) + } + return res.RowsAffected() +} + +// TouchAPIToken updates last_used_at for the token with the given hash. +// Best-effort: callers run this off the hot auth path. +func (d *DB) TouchAPIToken(ctx context.Context, tokenHash string) error { + if err := d.q.TouchAPIToken(ctx, tokenHash); err != nil { + return fmt.Errorf("TouchAPIToken: %w", err) + } + return nil +} + +// GetOwnerUser returns the highest-privilege user (the role with the greatest +// position) — the default identity for a CLI-minted API token. Returns nil when +// there are no users yet. +func (d *DB) GetOwnerUser(ctx context.Context) (*User, error) { + u, err := d.q.GetOwnerUser(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetOwnerUser: %w", err) + } + return userFromGen(u), nil +} diff --git a/Server/db/apitoken_queries_test.go b/Server/db/apitoken_queries_test.go new file mode 100644 index 00000000..20146aa6 --- /dev/null +++ b/Server/db/apitoken_queries_test.go @@ -0,0 +1,187 @@ +package db_test + +import ( + "context" + "testing" + "time" + + "github.com/owncord/server/db" +) + +// newTokenTestDB opens an in-memory DB and applies the real embedded migrations +// (which create api_tokens and seed the default roles). +func newTokenTestDB(t *testing.T) *db.DB { + t.Helper() + database := openMemory(t) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + return database +} + +// seedTokenUser creates a user with the given role and returns its ID. +func seedTokenUser(t *testing.T, database *db.DB, name string, roleID int) int64 { + t.Helper() + id, err := database.CreateUser(context.Background(), name, "hash", roleID) + if err != nil { + t.Fatalf("CreateUser(%q): %v", name, err) + } + return id +} + +func TestAPIToken_CreateGetRevoke(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + uid := seedTokenUser(t, database, "owner", 1) + + id, err := database.CreateAPIToken(ctx, uid, "hash_active", "ci-bot", nil) + if err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + // Active token resolves. + tok, err := database.GetActiveAPIToken(ctx, "hash_active") + if err != nil { + t.Fatalf("GetActiveAPIToken: %v", err) + } + if tok == nil { + t.Fatal("expected active token, got nil") + } + if tok.UserID != uid || tok.Label != "ci-bot" { + t.Fatalf("unexpected token %+v", tok) + } + + // After revocation it no longer resolves. + n, err := database.RevokeAPIToken(ctx, id) + if err != nil { + t.Fatalf("RevokeAPIToken: %v", err) + } + if n != 1 { + t.Fatalf("RevokeAPIToken affected %d rows, want 1", n) + } + tok, err = database.GetActiveAPIToken(ctx, "hash_active") + if err != nil { + t.Fatalf("GetActiveAPIToken after revoke: %v", err) + } + if tok != nil { + t.Fatal("revoked token must not resolve") + } + + // Revoking again affects no rows. + if n, _ := database.RevokeAPIToken(ctx, id); n != 0 { + t.Fatalf("second revoke affected %d rows, want 0", n) + } +} + +func TestAPIToken_Expiry(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + uid := seedTokenUser(t, database, "owner", 1) + + pastT := time.Now().Add(-time.Hour) + futureT := time.Now().Add(time.Hour) + + if _, err := database.CreateAPIToken(ctx, uid, "hash_past", "expired", &pastT); err != nil { + t.Fatalf("CreateAPIToken past: %v", err) + } + if _, err := database.CreateAPIToken(ctx, uid, "hash_future", "valid", &futureT); err != nil { + t.Fatalf("CreateAPIToken future: %v", err) + } + if _, err := database.CreateAPIToken(ctx, uid, "hash_never", "never", nil); err != nil { + t.Fatalf("CreateAPIToken never: %v", err) + } + + cases := []struct { + hash string + wantHit bool + }{ + {"hash_past", false}, // already expired + {"hash_future", true}, // not yet expired + {"hash_never", true}, // NULL expiry = never expires + {"hash_absent", false}, // unknown + } + for _, c := range cases { + tok, err := database.GetActiveAPIToken(ctx, c.hash) + if err != nil { + t.Fatalf("GetActiveAPIToken(%q): %v", c.hash, err) + } + if got := tok != nil; got != c.wantHit { + t.Fatalf("GetActiveAPIToken(%q) hit=%v, want %v", c.hash, got, c.wantHit) + } + } +} + +func TestAPIToken_TouchAndList(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + uid := seedTokenUser(t, database, "owner", 1) + if _, err := database.CreateAPIToken(ctx, uid, "hash_touch", "bot", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + if err := database.TouchAPIToken(ctx, "hash_touch"); err != nil { + t.Fatalf("TouchAPIToken: %v", err) + } + + list, err := database.ListAPITokens(ctx) + if err != nil { + t.Fatalf("ListAPITokens: %v", err) + } + if len(list) != 1 { + t.Fatalf("ListAPITokens returned %d, want 1", len(list)) + } + item := list[0] + if item.Username != "owner" || item.Label != "bot" { + t.Fatalf("unexpected list item %+v", item) + } + if item.LastUsed == nil { + t.Fatal("Touch should have set last_used_at") + } +} + +func TestAPIToken_RevokeByLabel(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + uid := seedTokenUser(t, database, "owner", 1) + if _, err := database.CreateAPIToken(ctx, uid, "hash_lbl", "mcp", nil); err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + n, err := database.RevokeAPITokenByLabel(ctx, "mcp") + if err != nil { + t.Fatalf("RevokeAPITokenByLabel: %v", err) + } + if n != 1 { + t.Fatalf("RevokeAPITokenByLabel affected %d rows, want 1", n) + } + tok, _ := database.GetActiveAPIToken(ctx, "hash_lbl") + if tok != nil { + t.Fatal("label-revoked token must not resolve") + } +} + +func TestGetOwnerUser(t *testing.T) { + ctx := context.Background() + database := newTokenTestDB(t) + + // No users yet → nil, nil. + u, err := database.GetOwnerUser(ctx) + if err != nil { + t.Fatalf("GetOwnerUser (empty): %v", err) + } + if u != nil { + t.Fatalf("GetOwnerUser on empty DB = %+v, want nil", u) + } + + // Owner (role 1, position 100) outranks a member (role 4) regardless of id order. + seedTokenUser(t, database, "member", 4) + ownerID := seedTokenUser(t, database, "owner", 1) + + u, err = database.GetOwnerUser(ctx) + if err != nil { + t.Fatalf("GetOwnerUser: %v", err) + } + if u == nil || u.ID != ownerID { + t.Fatalf("GetOwnerUser = %+v, want owner id %d", u, ownerID) + } +} diff --git a/Server/db/dbgen/apitokens.sql.go b/Server/db/dbgen/apitokens.sql.go new file mode 100644 index 00000000..a74ebb5d --- /dev/null +++ b/Server/db/dbgen/apitokens.sql.go @@ -0,0 +1,175 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: apitokens.sql + +package dbgen + +import ( + "context" + "database/sql" +) + +const createAPIToken = `-- name: CreateAPIToken :execresult +INSERT INTO api_tokens (user_id, token_hash, label, expires_at) +VALUES (?, ?, ?, ?) +` + +type CreateAPITokenParams struct { + UserID int64 `json:"userId"` + TokenHash string `json:"tokenHash"` + Label string `json:"label"` + ExpiresAt *string `json:"expiresAt"` +} + +func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (sql.Result, error) { + return q.db.ExecContext(ctx, createAPIToken, + arg.UserID, + arg.TokenHash, + arg.Label, + arg.ExpiresAt, + ) +} + +const getActiveAPIToken = `-- name: GetActiveAPIToken :one +SELECT id, user_id, token_hash, label, created_at, last_used_at, expires_at, revoked_at +FROM api_tokens +WHERE token_hash = ? + AND revoked_at IS NULL + AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now')) +` + +// Auth-hot lookup: returns the token only if it is neither revoked nor expired, +// so a resolved row is always usable. Matches the sessions never-expiring +// convention (expires_at IS NULL). +func (q *Queries) GetActiveAPIToken(ctx context.Context, tokenHash string) (ApiToken, error) { + row := q.db.QueryRowContext(ctx, getActiveAPIToken, tokenHash) + var i ApiToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.ExpiresAt, + &i.RevokedAt, + ) + return i, err +} + +const getOwnerUser = `-- name: GetOwnerUser :one +SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key +FROM users +ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC +` + +// The highest-privilege account (role with the greatest position), used as the +// default identity for `token create`. FROM is users-only (role position is a +// correlated subquery, not a join) so the row maps through userFromGen exactly +// like GetUserByID, so keep this SELECT list identical to GetUserByID's. +// A :one query already reads a single row via QueryRow, so no LIMIT is needed +// (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the +// highest-position role first, so that first row is the owner. +func (q *Queries) GetOwnerUser(ctx context.Context) (User, error) { + row := q.db.QueryRowContext(ctx, getOwnerUser) + 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, + &i.IdentityPublicKey, + ) + return i, err +} + +const listAPITokens = `-- name: ListAPITokens :many +SELECT t.id, t.user_id, COALESCE(u.username, '') AS username, t.label, + t.created_at, t.last_used_at, t.expires_at, t.revoked_at +FROM api_tokens t +LEFT JOIN users u ON u.id = t.user_id +ORDER BY t.id DESC +LIMIT 200 +` + +type ListAPITokensRow struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Username string `json:"username"` + Label string `json:"label"` + CreatedAt string `json:"createdAt"` + LastUsedAt *string `json:"lastUsedAt"` + ExpiresAt *string `json:"expiresAt"` + RevokedAt *string `json:"revokedAt"` +} + +// Admin/CLI listing. Never selects token_hash (unrecoverable; only the raw +// token shown at creation is usable). +func (q *Queries) ListAPITokens(ctx context.Context) ([]ListAPITokensRow, error) { + rows, err := q.db.QueryContext(ctx, listAPITokens) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAPITokensRow{} + for rows.Next() { + var i ListAPITokensRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Username, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.ExpiresAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeAPIToken = `-- name: RevokeAPIToken :execresult +UPDATE api_tokens SET revoked_at = datetime('now') +WHERE id = ? AND revoked_at IS NULL +` + +func (q *Queries) RevokeAPIToken(ctx context.Context, id int64) (sql.Result, error) { + return q.db.ExecContext(ctx, revokeAPIToken, id) +} + +const revokeAPITokenByLabel = `-- name: RevokeAPITokenByLabel :execresult +UPDATE api_tokens SET revoked_at = datetime('now') +WHERE label = ? AND revoked_at IS NULL +` + +func (q *Queries) RevokeAPITokenByLabel(ctx context.Context, label string) (sql.Result, error) { + return q.db.ExecContext(ctx, revokeAPITokenByLabel, label) +} + +const touchAPIToken = `-- name: TouchAPIToken :exec +UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ? +` + +func (q *Queries) TouchAPIToken(ctx context.Context, tokenHash string) error { + _, err := q.db.ExecContext(ctx, touchAPIToken, tokenHash) + return err +} diff --git a/Server/db/dbgen/models.go b/Server/db/dbgen/models.go index 667ff0f2..88799c5d 100644 --- a/Server/db/dbgen/models.go +++ b/Server/db/dbgen/models.go @@ -8,6 +8,17 @@ import ( "time" ) +type ApiToken struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + TokenHash string `json:"tokenHash"` + Label string `json:"label"` + CreatedAt string `json:"createdAt"` + LastUsedAt *string `json:"lastUsedAt"` + ExpiresAt *string `json:"expiresAt"` + RevokedAt *string `json:"revokedAt"` +} + type Attachment struct { ID string `json:"id"` MessageID *int64 `json:"messageId"` diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index b5440b72..f16c5fbc 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -25,6 +25,7 @@ type Querier interface { CountChannels(ctx context.Context) (int64, error) CountUsers(ctx context.Context) (int64, error) CountUsersWithoutTOTP(ctx context.Context) (int64, error) + CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (sql.Result, error) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error CreateChannel(ctx context.Context, arg CreateChannelParams) (sql.Result, error) CreateInvite(ctx context.Context, arg CreateInviteParams) error @@ -44,6 +45,10 @@ type Querier interface { EnablePlugin(ctx context.Context, id int64) error EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error ForceLogoutUser(ctx context.Context, userID int64) error + // Auth-hot lookup: returns the token only if it is neither revoked nor expired, + // so a resolved row is always usable. Matches the sessions never-expiring + // convention (expires_at IS NULL). + GetActiveAPIToken(ctx context.Context, tokenHash string) (ApiToken, error) GetAllSettings(ctx context.Context) ([]Setting, error) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) GetAttachmentByID(ctx context.Context, id string) (GetAttachmentByIDRow, error) @@ -60,6 +65,14 @@ type Querier interface { GetMaxEventSeq(ctx context.Context) (int64, error) GetMessage(ctx context.Context, id int64) (Message, error) GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) + // The highest-privilege account (role with the greatest position), used as the + // default identity for `token create`. FROM is users-only (role position is a + // correlated subquery, not a join) so the row maps through userFromGen exactly + // like GetUserByID, so keep this SELECT list identical to GetUserByID's. + // A :one query already reads a single row via QueryRow, so no LIMIT is needed + // (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the + // highest-position role first, so that first row is the owner. + GetOwnerUser(ctx context.Context) (User, error) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) GetRoleByID(ctx context.Context, id int64) (Role, error) GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) @@ -82,6 +95,9 @@ type Querier interface { JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (sql.Result, error) LeaveVoiceChannel(ctx context.Context, userID int64) error LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (sql.Result, error) + // Admin/CLI listing. Never selects token_hash (unrecoverable; only the raw + // token shown at creation is usable). + ListAPITokens(ctx context.Context) ([]ListAPITokensRow, error) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) ListChannels(ctx context.Context) ([]ListChannelsRow, error) @@ -101,12 +117,15 @@ type Querier interface { PruneEventsOlderThan(ctx context.Context, createdAt time.Time) (int64, error) RemoveReaction(ctx context.Context, arg RemoveReactionParams) (sql.Result, error) ResetAllUserStatuses(ctx context.Context) error + RevokeAPIToken(ctx context.Context, id int64) (sql.Result, error) + RevokeAPITokenByLabel(ctx context.Context, label string) (sql.Result, error) RevokeInvite(ctx context.Context, code string) error SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (sql.Result, error) SetSetting(ctx context.Context, arg SetSettingParams) error SoftDeleteMessage(ctx context.Context, id int64) error + TouchAPIToken(ctx context.Context, tokenHash string) error TouchSession(ctx context.Context, token string) error UnbanUser(ctx context.Context, id int64) error UnblockUser(ctx context.Context, arg UnblockUserParams) error diff --git a/Server/db/mappers.go b/Server/db/mappers.go index 84e7d002..d8a403fa 100644 --- a/Server/db/mappers.go +++ b/Server/db/mappers.go @@ -75,6 +75,20 @@ func userFromGen(u dbgen.User) *User { } } +// apiTokenFromGen maps a generated api_tokens row to the domain APIToken model. +func apiTokenFromGen(t dbgen.ApiToken) *APIToken { + return &APIToken{ + ID: t.ID, + UserID: t.UserID, + TokenHash: t.TokenHash, + Label: t.Label, + CreatedAt: t.CreatedAt, + LastUsed: t.LastUsedAt, + ExpiresAt: t.ExpiresAt, + RevokedAt: t.RevokedAt, + } +} + // sessionFromGen maps a generated session row to the domain Session model. func sessionFromGen(s dbgen.Session) Session { return Session{ diff --git a/Server/db/migrate.go b/Server/db/migrate.go index dc1e80af..d8fcd161 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -235,7 +235,7 @@ func splitStatements(raw string) []string { var buf strings.Builder depth := 0 - for _, line := range strings.Split(raw, "\n") { + for line := range strings.SplitSeq(raw, "\n") { trimmed := strings.TrimSpace(line) // Track BEGIN...END depth for trigger bodies. @@ -310,7 +310,7 @@ func splitStatements(raw string) []string { // isCommentOnly returns true if every line is a SQL comment or blank. func isCommentOnly(s string) bool { - for _, line := range strings.Split(s, "\n") { + for line := range strings.SplitSeq(s, "\n") { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "--") { return false diff --git a/Server/db/models.go b/Server/db/models.go index ebf767bf..5dc9f6e6 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -34,6 +34,33 @@ type Session struct { ExpiresAt string } +// APIToken represents a row in the api_tokens table — a long-lived, revocable +// bearer token that authenticates as UserID with that user's role/permissions. +// Raw tokens are never stored; TokenHash is the SHA-256 hex, like Session. +type APIToken struct { + ID int64 + UserID int64 + TokenHash string `json:"-"` + Label string + CreatedAt string + LastUsed *string + ExpiresAt *string // nil = never expires + RevokedAt *string // nil = active +} + +// APITokenListItem is one row of the admin/CLI token listing. It carries the +// owning user's name for display and deliberately omits the hash. +type APITokenListItem struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Label string `json:"label"` + CreatedAt string `json:"created_at"` + LastUsed *string `json:"last_used"` + ExpiresAt *string `json:"expires_at"` + RevokedAt *string `json:"revoked_at"` +} + // Invite represents a row in the invites table. type Invite struct { ID int64 diff --git a/Server/db/models_test.go b/Server/db/models_test.go index 2aa6ba8c..4e201e6f 100644 --- a/Server/db/models_test.go +++ b/Server/db/models_test.go @@ -71,7 +71,7 @@ func TestRole_NilColor(t *testing.T) { role := db.Role{ID: 1, Name: "member"} data, _ := json.Marshal(role) - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { t.Fatalf("Unmarshal: %v", err) } diff --git a/Server/db/queries/sqlite/apitokens.sql b/Server/db/queries/sqlite/apitokens.sql new file mode 100644 index 00000000..c82357d7 --- /dev/null +++ b/Server/db/queries/sqlite/apitokens.sql @@ -0,0 +1,47 @@ +-- name: CreateAPIToken :execresult +INSERT INTO api_tokens (user_id, token_hash, label, expires_at) +VALUES (?, ?, ?, ?); + +-- name: GetActiveAPIToken :one +-- Auth-hot lookup: returns the token only if it is neither revoked nor expired, +-- so a resolved row is always usable. Matches the sessions never-expiring +-- convention (expires_at IS NULL). +SELECT id, user_id, token_hash, label, created_at, last_used_at, expires_at, revoked_at +FROM api_tokens +WHERE token_hash = ? + AND revoked_at IS NULL + AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now')); + +-- name: ListAPITokens :many +-- Admin/CLI listing. Never selects token_hash (unrecoverable; only the raw +-- token shown at creation is usable). +SELECT t.id, t.user_id, COALESCE(u.username, '') AS username, t.label, + t.created_at, t.last_used_at, t.expires_at, t.revoked_at +FROM api_tokens t +LEFT JOIN users u ON u.id = t.user_id +ORDER BY t.id DESC +LIMIT 200; + +-- name: RevokeAPIToken :execresult +UPDATE api_tokens SET revoked_at = datetime('now') +WHERE id = ? AND revoked_at IS NULL; + +-- name: RevokeAPITokenByLabel :execresult +UPDATE api_tokens SET revoked_at = datetime('now') +WHERE label = ? AND revoked_at IS NULL; + +-- name: TouchAPIToken :exec +UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?; + +-- name: GetOwnerUser :one +-- The highest-privilege account (role with the greatest position), used as the +-- default identity for `token create`. FROM is users-only (role position is a +-- correlated subquery, not a join) so the row maps through userFromGen exactly +-- like GetUserByID, so keep this SELECT list identical to GetUserByID's. +-- A :one query already reads a single row via QueryRow, so no LIMIT is needed +-- (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the +-- highest-position role first, so that first row is the owner. +SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key +FROM users +ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC; diff --git a/Server/main.go b/Server/main.go index 62084944..8d7984ad 100644 --- a/Server/main.go +++ b/Server/main.go @@ -34,6 +34,12 @@ import ( var version = "dev" func main() { + // `server token ...` is a direct-to-DB CLI (mint/list/revoke API tokens) — + // handled before any server/logging setup so it stays quiet and standalone. + if len(os.Args) > 1 && os.Args[1] == "token" { + os.Exit(runTokenCLI(os.Args[2:])) + } + // Create ring buffer for admin log viewer, then build a multi-handler // that tees log records to both stdout (INFO+) and the ring buffer (DEBUG+). logBuf := admin.NewRingBuffer(2000) @@ -321,7 +327,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer, levelVar *slog.LevelVar) er go func() { log.Info("server starting", "addr", addr, "tls", tlsCfg != nil, "version", version) - for attempt := 0; attempt < 20; attempt++ { + for attempt := range 20 { var listenErr error if tlsCfg != nil { listenErr = srv.ListenAndServeTLS("", "") diff --git a/Server/migrations/018_api_tokens.sql b/Server/migrations/018_api_tokens.sql new file mode 100644 index 00000000..6e2653a0 --- /dev/null +++ b/Server/migrations/018_api_tokens.sql @@ -0,0 +1,27 @@ +-- Long-lived, revocable API tokens (bot/service tokens). +-- +-- Unlike login sessions, an API token authenticates a headless client — the +-- MCP introspection tool, future bots, CI — as a specific user, inheriting that +-- user's role and permissions, via an "Authorization: Bearer " header. It +-- lives in its own table (not sessions) so the per-user session cap, bulk logout +-- (ForceLogoutUser), and password/TOTP-change session wipes never touch it. +-- +-- token_hash stores the SHA-256 hex of the raw token, exactly like sessions.token +-- — the raw token is shown once at creation and never persisted. An expires_at of +-- NULL means "never expires" (same convention as invites). revoked_at NULL means +-- the token is active. + +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + +-- token_hash UNIQUE already indexes the auth-hot lookup. This index covers the +-- ON DELETE CASCADE and list-by-user paths. +CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); diff --git a/Server/permissions/checker_test.go b/Server/permissions/checker_test.go index da78fdd0..e4563f7d 100644 --- a/Server/permissions/checker_test.go +++ b/Server/permissions/checker_test.go @@ -3,6 +3,7 @@ package permissions import ( "context" "errors" + "maps" "testing" ) @@ -121,9 +122,7 @@ func TestHasChannelPerm(t *testing.T) { t.Run(tt.name, func(t *testing.T) { db := newMockDB() db.chanErr = tt.chanErr - for k, v := range tt.overrides { - db.channelPerms[k] = v - } + maps.Copy(db.channelPerms, tt.overrides) ck := NewChecker(db) got := ck.HasChannelPerm(context.Background(), tt.rolePerms, tt.roleID, tt.channelID, tt.perm) diff --git a/Server/plugin/manifest.go b/Server/plugin/manifest.go index 922b21a1..aac0f9f5 100644 --- a/Server/plugin/manifest.go +++ b/Server/plugin/manifest.go @@ -26,6 +26,7 @@ import ( "fmt" "path" "regexp" + "slices" "strings" ) @@ -196,10 +197,8 @@ func validateRelativePath(p string) error { if cleaned == "." { return fmt.Errorf("path %q refers to the current directory", p) } - for _, seg := range strings.Split(cleaned, "/") { - if seg == ".." { - return fmt.Errorf("path %q contains parent traversal", p) - } + if slices.Contains(strings.Split(cleaned, "/"), "..") { + return fmt.Errorf("path %q contains parent traversal", p) } return nil } diff --git a/Server/token_cli.go b/Server/token_cli.go new file mode 100644 index 00000000..787f41b9 --- /dev/null +++ b/Server/token_cli.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strconv" + "text/tabwriter" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// runTokenCLI implements `server token `. It operates +// directly against the database — no HTTP, no login — so an operator can mint +// the first API token without any existing credential (the bootstrap path). +// Returns a process exit code. +func runTokenCLI(args []string) int { + if len(args) == 0 { + tokenUsage() + return 2 + } + + cfg, err := config.Load("config.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "error: load config: %v\n", err) + return 1 + } + database, err := db.Open(cfg.Database.Path) + if err != nil { + fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) + return 1 + } + defer database.Close() //nolint:errcheck + // Idempotent: ensures the api_tokens table exists even if the server has + // never started against this database. + if err := db.Migrate(database); err != nil { + fmt.Fprintf(os.Stderr, "error: migrate: %v\n", err) + return 1 + } + + ctx := context.Background() + switch args[0] { + case "create": + return tokenCreate(ctx, database, args[1:]) + case "list": + return tokenList(ctx, database, args[1:]) + case "revoke": + return tokenRevoke(ctx, database, args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown token subcommand %q\n", args[0]) + tokenUsage() + return 2 + } +} + +func tokenUsage() { + fmt.Fprint(os.Stderr, `usage: server token + +Commands: + create --label [--user ] [--expires ] + Mint a new API token. Prints the raw token once to stdout — store it + now, it is never recoverable. Defaults to the owner account and no + expiry. --expires accepts a Go duration, e.g. 720h. + list + List API tokens (never prints raw tokens). + revoke + Revoke a token by numeric id or by label. +`) +} + +func tokenCreate(ctx context.Context, database *db.DB, args []string) int { + fs := flag.NewFlagSet("token create", flag.ContinueOnError) + label := fs.String("label", "", "human-readable label (required)") + username := fs.String("user", "", "username to bind the token to (default: owner)") + expires := fs.Duration("expires", 0, "validity duration, e.g. 720h (default: never)") + if err := fs.Parse(args); err != nil { + return 2 + } + if *label == "" { + fmt.Fprintln(os.Stderr, "error: --label is required") + return 2 + } + + var user *db.User + var err error + if *username != "" { + user, err = database.GetUserByUsername(ctx, *username) + } else { + user, err = database.GetOwnerUser(ctx) + } + if err != nil { + fmt.Fprintf(os.Stderr, "error: look up user: %v\n", err) + return 1 + } + if user == nil { + if *username != "" { + fmt.Fprintf(os.Stderr, "error: no user named %q\n", *username) + } else { + fmt.Fprintln(os.Stderr, "error: no users exist yet — create the owner account first") + } + return 1 + } + + raw, err := auth.GenerateToken() + if err != nil { + fmt.Fprintf(os.Stderr, "error: generate token: %v\n", err) + return 1 + } + var expiresAt *time.Time + if *expires > 0 { + t := time.Now().Add(*expires) + expiresAt = &t + } + id, err := database.CreateAPIToken(ctx, user.ID, auth.HashToken(raw), *label, expiresAt) + if err != nil { + fmt.Fprintf(os.Stderr, "error: create token: %v\n", err) + return 1 + } + db.WriteAudit(ctx, database, user.ID, "api_token_create", "api_token", id, *label) + + // Metadata to stderr, raw token alone to stdout — so `... | tail -1` or a + // capture pipe gets exactly the token. + fmt.Fprintf(os.Stderr, "Created API token #%d for user %q (label %q).\n", id, user.Username, *label) + fmt.Fprintln(os.Stderr, "Store this token now — it is shown only once:") + fmt.Println(raw) + return 0 +} + +func tokenList(ctx context.Context, database *db.DB, args []string) int { + fs := flag.NewFlagSet("token list", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return 2 + } + tokens, err := database.ListAPITokens(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "error: list tokens: %v\n", err) + return 1 + } + if len(tokens) == 0 { + fmt.Println("no API tokens") + return 0 + } + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + // Buffer-write errors surface via tw.Flush() below, which is checked. + _, _ = fmt.Fprintln(tw, "ID\tUSER\tLABEL\tCREATED\tLAST USED\tEXPIRES\tREVOKED") + for _, t := range tokens { + _, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", + t.ID, t.Username, t.Label, t.CreatedAt, + orDash(t.LastUsed), orDash(t.ExpiresAt), orDash(t.RevokedAt)) + } + if err := tw.Flush(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + return 0 +} + +func tokenRevoke(ctx context.Context, database *db.DB, args []string) int { + fs := flag.NewFlagSet("token revoke", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return 2 + } + rest := fs.Args() + if len(rest) != 1 { + fmt.Fprintln(os.Stderr, "error: revoke takes exactly one argument (id or label)") + return 2 + } + arg := rest[0] + + var affected int64 + var err error + if id, perr := strconv.ParseInt(arg, 10, 64); perr == nil { + affected, err = database.RevokeAPIToken(ctx, id) + if err == nil && affected > 0 { + db.WriteAudit(ctx, database, 0, "api_token_revoke", "api_token", id, arg) + } + } else { + affected, err = database.RevokeAPITokenByLabel(ctx, arg) + if err == nil && affected > 0 { + db.WriteAudit(ctx, database, 0, "api_token_revoke", "api_token", 0, arg) + } + } + if err != nil { + fmt.Fprintf(os.Stderr, "error: revoke token: %v\n", err) + return 1 + } + if affected == 0 { + fmt.Fprintf(os.Stderr, "no active token matched %q\n", arg) + return 1 + } + fmt.Printf("revoked %d token(s)\n", affected) + return 0 +} + +// orDash renders a nullable timestamp column for the list table. +func orDash(s *string) string { + if s == nil || *s == "" { + return "-" + } + return *s +} diff --git a/Server/updater/text_asset_cache_test.go b/Server/updater/text_asset_cache_test.go index 94a2ba80..b4961726 100644 --- a/Server/updater/text_asset_cache_test.go +++ b/Server/updater/text_asset_cache_test.go @@ -44,12 +44,10 @@ func TestFetchTextAssetCachedCoalescesConcurrentMisses(t *testing.T) { start := make(chan struct{}) for i := range callers { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { <-start // release all goroutines together to force a real burst results[i], errs[i] = u.FetchTextAssetCached(context.Background(), srv.URL+"/asset") - }() + }) } close(start) wg.Wait() diff --git a/Server/updater/updater.go b/Server/updater/updater.go index afeb2b58..48af7a1c 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -787,8 +787,8 @@ func (s *StagedBinary) Close() error { // ParseChecksumFile parses a sha256sum-format checksum file (lines of // " ") and returns the hash for the given filename. func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { - lines := strings.Split(string(data), "\n") - for _, line := range lines { + lines := strings.SplitSeq(string(data), "\n") + for line := range lines { line = strings.TrimSpace(line) if line == "" { continue diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index f1c5b5b3..48af279a 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -364,7 +364,7 @@ func TestUpdateChecksum_SHA256MatchesChecksumsFile(t *testing.T) { // Same line shape as release workflow: sha256sum prints " ". primaryPath := names[0] - checksumData := []byte(fmt.Sprintf("%s %s\n", expectedHex, primaryPath)) + checksumData := fmt.Appendf(nil, "%s %s\n", expectedHex, primaryPath) parsed, err := u.parseChecksumFileAny(checksumData, names...) if err != nil { @@ -393,7 +393,7 @@ func TestUpdateChecksum_FallbackChecksumLine(t *testing.T) { sum := sha256.Sum256(asset) expectedHex := hex.EncodeToString(sum[:]) // Only "chatserver.exe", no windows/ prefix — second entry in list must match. - checksumData := []byte(fmt.Sprintf("%s chatserver.exe\n", expectedHex)) + checksumData := fmt.Appendf(nil, "%s chatserver.exe\n", expectedHex) names := checksumEntryNamesForGOOS("windows") parsed, err := u.parseChecksumFileAny(checksumData, names...) diff --git a/Server/ws/channel_visibility_agreement_test.go b/Server/ws/channel_visibility_agreement_test.go index c66eeea8..1cadf179 100644 --- a/Server/ws/channel_visibility_agreement_test.go +++ b/Server/ws/channel_visibility_agreement_test.go @@ -9,7 +9,7 @@ package ws_test import ( "context" "encoding/json" - "sort" + "slices" "testing" "github.com/owncord/server/auth" @@ -44,7 +44,7 @@ func sortedKeys(m map[int64]bool) []int64 { for id := range m { out = append(out, id) } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + slices.Sort(out) return out } diff --git a/Server/ws/client.go b/Server/ws/client.go index 3aeb5d8d..87c2eb45 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -55,9 +55,7 @@ type Client struct { // wsConn is the subset of github.com/coder/websocket.Conn used by writePump/readPump. // Defining it as an interface lets us avoid importing github.com/coder/websocket here, // keeping the core hub logic free from that dependency during unit tests. -type wsConn interface { - // intentionally empty — methods used only in serve.go/client_pump.go -} +type wsConn any // newClient creates a real client wrapping a WebSocket connection (set by serve.go). func newClient(hub *Hub, conn wsConn, user *db.User, tokenHash string, lastSeq uint64, ctx context.Context) *Client { diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 6cb541b0..4ff97ba6 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -2833,7 +2833,7 @@ func TestBroadcastToChannel_DropsWhenFull(t *testing.T) { hub, _ := newCoverageHub(t) // Don't start Run() — broadcast channel will fill up. // The broadcast channel capacity is 256. - for i := 0; i < 260; i++ { + for range 260 { hub.BroadcastToChannel(1, []byte(`{"type":"test"}`)) } // With no Run() loop draining, some messages are dropped. @@ -2846,7 +2846,7 @@ func TestBroadcastToChannel_DropsWhenFull(t *testing.T) { func TestBroadcastToAll_DropsWhenFull(t *testing.T) { hub, _ := newCoverageHub(t) - for i := 0; i < 260; i++ { + for range 260 { hub.BroadcastToAll([]byte(`{"type":"test"}`)) } // Hub should still be functional after overflow — verify hub state is intact. diff --git a/Server/ws/event_persister_test.go b/Server/ws/event_persister_test.go index c105fb52..d70807ef 100644 --- a/Server/ws/event_persister_test.go +++ b/Server/ws/event_persister_test.go @@ -37,7 +37,7 @@ func TestEventPersisterFlushesBatch(t *testing.T) { p.Start(ctx) t.Cleanup(func() { p.Stop(ctx) }) - for i := 0; i < 10; i++ { + for i := range 10 { p.Enqueue(int64(i+1), "broadcast", 0, []byte(`{"type":"x"}`)) } @@ -77,7 +77,7 @@ func TestEventPersisterDropsOnFullQueue(t *testing.T) { // flusher won't drain fast enough. p := NewEventPersister(mem, 2, 1024, time.Hour) // NB: Start is intentionally NOT called so the queue stays full. - for i := 0; i < 50; i++ { + for i := range 50 { p.Enqueue(int64(i+1), "broadcast", 0, []byte(`{}`)) } // Stop without Start — must not deadlock. @@ -95,7 +95,7 @@ func TestEventPersisterStopDrains(t *testing.T) { p := NewEventPersister(mem, 256, 100, time.Hour) p.Start(context.Background()) - for i := 0; i < 5; i++ { + for i := range 5 { p.Enqueue(int64(i+1), "broadcast", 0, []byte(`{}`)) } diff --git a/Server/ws/event_pruner.go b/Server/ws/event_pruner.go index 4ae7cd58..1b419076 100644 --- a/Server/ws/event_pruner.go +++ b/Server/ws/event_pruner.go @@ -33,10 +33,7 @@ func StartEventPruner(ctx context.Context, s EventStore, retention, interval tim } // Bound the startup delay by the interval so short test intervals // (e.g. 100ms in event_pruner_test.go) don't wait a full minute. - startupDelayDuration := maxStartupDelay - if interval < startupDelayDuration { - startupDelayDuration = interval - } + startupDelayDuration := min(interval, maxStartupDelay) go func() { // Run once shortly after startup so a tiny dataset stays small. startupDelay := time.NewTimer(startupDelayDuration) diff --git a/Server/ws/event_pruner_test.go b/Server/ws/event_pruner_test.go index a10ae410..da474212 100644 --- a/Server/ws/event_pruner_test.go +++ b/Server/ws/event_pruner_test.go @@ -105,8 +105,7 @@ func TestRunPruneErrorDoesNotPanic(t *testing.T) { func TestStartEventPrunerNilStoreIsNoop(t *testing.T) { // Should not spawn a goroutine, should not panic. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() StartEventPruner(ctx, nil, time.Hour, time.Hour) // If the nil check were missing, calling PruneEventsOlderThan on nil // would panic inside the goroutine — but since we don't spawn one, @@ -145,8 +144,7 @@ func TestStartEventPrunerStartupDelayBoundedByInterval(t *testing.T) { // Copilot-review fix caps the startup delay at min(interval, 1min), // so with interval=20ms the first prune happens within ~20ms. s := &fakeEventStore{pruneSignal: make(chan struct{})} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() start := time.Now() StartEventPruner(ctx, s, time.Hour, 20*time.Millisecond) diff --git a/Server/ws/handler_v2_migration_test.go b/Server/ws/handler_v2_migration_test.go index b2fef015..5577a7f6 100644 --- a/Server/ws/handler_v2_migration_test.go +++ b/Server/ws/handler_v2_migration_test.go @@ -74,7 +74,7 @@ func TestHandleVoiceLeaveV2_RateLimited(t *testing.T) { // voiceLeaveRateLimit (5) per voiceLeaveWindow (1s) — the 6th is rejected. var limited bool - for i := 0; i < voiceLeaveRateLimit+1; i++ { + for range voiceLeaveRateLimit + 1 { res := handleVoiceLeaveV2(context.Background(), cmd, info, deps) if res.Error != nil { ce, ok := res.Error.(ClientError) diff --git a/Server/ws/handler_v2_voice_e2ee_offer_test.go b/Server/ws/handler_v2_voice_e2ee_offer_test.go index da76014d..7971f2b8 100644 --- a/Server/ws/handler_v2_voice_e2ee_offer_test.go +++ b/Server/ws/handler_v2_voice_e2ee_offer_test.go @@ -81,7 +81,7 @@ func TestVoiceE2EEOfferV2_RotationBurstNotRateLimited(t *testing.T) { // 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++ { + for range 4 { cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV} if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { limited = true diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index e253eb66..9896d35d 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -1228,7 +1228,7 @@ func TestChatEdit_RateLimit_ReturnsError(t *testing.T) { time.Sleep(20 * time.Millisecond) // Exhaust the rate limit (chatRateLimit = 10 per second). - for i := 0; i < 11; i++ { + for i := range 11 { hub.HandleMessageForTest(c, chatEditMsg(msgID, fmt.Sprintf("edit-%d", i))) } time.Sleep(50 * time.Millisecond) @@ -1822,7 +1822,6 @@ func TestPresence_InvalidStatus_ReturnsBadRequest(t *testing.T) { func TestPresence_ValidStatus_Broadcasts(t *testing.T) { validStatuses := []string{"online", "idle", "dnd", "offline"} for _, status := range validStatuses { - status := status t.Run(status, func(t *testing.T) { hub, database := newHandlerHub(t) user := seedOwnerUser(t, database, "presence-valid-"+status) diff --git a/Server/ws/origin.go b/Server/ws/origin.go index f6a48228..5ca0db6c 100644 --- a/Server/ws/origin.go +++ b/Server/ws/origin.go @@ -2,6 +2,7 @@ package ws import ( "log/slog" + "slices" "github.com/coder/websocket" ) @@ -23,11 +24,9 @@ func OriginAcceptOptions(allowedOrigins []string) *websocket.AcceptOptions { return &websocket.AcceptOptions{InsecureSkipVerify: false} } - for _, o := range allowedOrigins { - if o == "*" { - slog.Warn("ws: allowed_origins contains wildcard '*' — accepting connections from ANY origin (insecure)") - return &websocket.AcceptOptions{InsecureSkipVerify: true} - } + if slices.Contains(allowedOrigins, "*") { + slog.Warn("ws: allowed_origins contains wildcard '*' — accepting connections from ANY origin (insecure)") + return &websocket.AcceptOptions{InsecureSkipVerify: true} } return &websocket.AcceptOptions{ diff --git a/Server/ws/pubsub_test.go b/Server/ws/pubsub_test.go index bc1ad58e..ecd46d00 100644 --- a/Server/ws/pubsub_test.go +++ b/Server/ws/pubsub_test.go @@ -2,7 +2,7 @@ package ws import ( "bytes" - "sort" + "slices" "sync" "testing" "time" @@ -222,7 +222,7 @@ func TestPubSub_TopicsForClient(t *testing.T) { ps.Subscribe(c, UserTopic(1)) topics := ps.TopicsForClient(1) - sort.Slice(topics, func(i, j int) bool { return topics[i] < topics[j] }) + slices.Sort(topics) expected := []Topic{"channel:10", TopicGlobal, UserTopic(1)} if len(topics) != len(expected) { @@ -264,7 +264,7 @@ func TestPubSub_ConcurrentAccess(t *testing.T) { var wg sync.WaitGroup wg.Add(N * 3) // subscribe + publish + unsubscribe - for i := 0; i < N; i++ { + for i := range N { c := makeTestClient(int64(i)) go func() { defer wg.Done() diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go index c948c09d..a5456d5c 100644 --- a/Server/ws/reconnect_db_test.go +++ b/Server/ws/reconnect_db_test.go @@ -80,7 +80,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { eventStore := openEventStoreDB(t) bgCtx := context.Background() for seq := int64(501); seq <= 600; seq++ { - payload := []byte(fmt.Sprintf(`{"seq":%d,"type":"broadcast"}`, seq)) + payload := fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq) if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil { t.Fatalf("PersistEvent seq=%d: %v", seq, err) } diff --git a/Server/ws/ringbuffer_test.go b/Server/ws/ringbuffer_test.go index db9523db..a3436385 100644 --- a/Server/ws/ringbuffer_test.go +++ b/Server/ws/ringbuffer_test.go @@ -33,7 +33,7 @@ func TestPush_SingleEntry(t *testing.T) { func TestPush_MultipleInOrder(t *testing.T) { rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 5; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("msg-%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "msg-%d", i)) } // afterSeq == oldestSeq (1) → nil (BUG-085: conservative boundary). @@ -60,7 +60,7 @@ func TestPush_WrapsAround(t *testing.T) { // Push 6 events into a buffer with capacity 4 — first two are evicted. for i := uint64(1); i <= 6; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("e%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "e%d", i)) } got := rb.EventsSince(0) @@ -139,7 +139,7 @@ func TestEventsSince_EmptyBuffer(t *testing.T) { func TestEventsSince_AfterSpecificSeq(t *testing.T) { rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 5; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("m%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "m%d", i)) } got := rb.EventsSince(3) @@ -185,7 +185,7 @@ func TestEventsSince_WraparoundOrder(t *testing.T) { // Fill past capacity to force wrap. for i := uint64(1); i <= 7; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("v%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "v%d", i)) } // afterSeq == oldestSeq (4) → nil (BUG-085). @@ -211,7 +211,7 @@ func TestEventsSince_AfterSeqZero_ReturnsBehavior(t *testing.T) { // the server can't confirm the buffer covers everything the client missed. rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 3; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("a%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "a%d", i)) } got := rb.EventsSince(0) @@ -250,7 +250,7 @@ func TestEventsSince_AfterSeqEqualsOldest_ReturnsNil(t *testing.T) { // Push 6 events: buffer holds seq 3,4,5,6. Oldest = 3. for i := uint64(1); i <= 6; i++ { - rb.Push(i, 0, []byte(fmt.Sprintf("e%d", i))) + rb.Push(i, 0, fmt.Appendf(nil, "e%d", i)) } if oldest := rb.OldestSeq(); oldest != 3 { @@ -317,26 +317,24 @@ func TestConcurrent_PushAndEventsSince(t *testing.T) { var wg sync.WaitGroup // Concurrent writers. - for w := 0; w < writers; w++ { + for w := range writers { wg.Add(1) go func(base uint64) { defer wg.Done() - for i := uint64(0); i < pushes; i++ { + for i := range uint64(pushes) { rb.Push(base+i, 0, []byte("data")) } }(uint64(w) * pushes) } // Concurrent readers. - for r := 0; r < readers; r++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < reads; i++ { + for range readers { + wg.Go(func() { + for range reads { _ = rb.EventsSince(0) _ = rb.OldestSeq() } - }() + }) } wg.Wait() @@ -438,7 +436,7 @@ func TestEventsSince_CapacityBoundaries(t *testing.T) { t.Run(tc.name, func(t *testing.T) { rb := ws.NewEventRingBuffer(tc.cap) for i := 1; i <= tc.pushes; i++ { - rb.Push(uint64(i), 0, []byte(fmt.Sprintf("e%d", i))) + rb.Push(uint64(i), 0, fmt.Appendf(nil, "e%d", i)) } got := rb.EventsSince(tc.afterSeq) diff --git a/Server/ws/voice_e2ee_test.go b/Server/ws/voice_e2ee_test.go index 6411b1c6..072a86f1 100644 --- a/Server/ws/voice_e2ee_test.go +++ b/Server/ws/voice_e2ee_test.go @@ -455,12 +455,12 @@ func TestE2EE_ConcurrentPubKeyAccess(t *testing.T) { // Concurrent set/get of e2eePubKey should not race. done := make(chan struct{}) go func() { - for i := 0; i < 100; i++ { + for i := range 100 { ws.SetClientE2EEPubKeyForTest(c, "key-"+string(rune('A'+i%26))) } close(done) }() - for i := 0; i < 100; i++ { + for range 100 { _ = ws.GetClientE2EEPubKeyForTest(c) } <-done diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 47e13e5a..353ab00e 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -485,7 +485,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { if writeErr := conn.Write(ctx, websocket.MessageText, raw); writeErr != nil { t.Fatalf("write auth: %v", writeErr) } - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, readErr := conn.Read(ctx); readErr != nil { t.Fatalf("read handshake message %d: %v", i, readErr) } @@ -575,7 +575,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { t.Fatalf("write auth: %v", writeErr) } // Read auth_ok + ready - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, readErr := conn.Read(ctx); readErr != nil { t.Fatalf("read handshake message %d: %v", i, readErr) } @@ -731,7 +731,7 @@ func TestServeWS_Reconnect_AuthorizedVoiceClientKeepsChannelStream(t *testing.T) t.Fatalf("write auth: %v", writeErr) } // Read auth_ok + first following message. - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, readErr := conn.Read(ctx); readErr != nil { t.Fatalf("read handshake message %d: %v", i, readErr) } @@ -884,7 +884,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { t.Fatalf("write auth: %v", writeErr) } // Read auth_ok + ready - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, readErr := conn.Read(ctx); readErr != nil { t.Fatalf("read handshake message %d: %v", i, readErr) } @@ -1085,7 +1085,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { _ = conn.Write(ctx, websocket.MessageText, raw) // Drain auth_ok and ready. - for i := 0; i < 2; i++ { + for range 2 { _, _, err := conn.Read(ctx) if err != nil { t.Fatalf("drain initial messages: %v", err) @@ -1197,7 +1197,7 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { t.Fatalf("%s write auth: %v", label, writeErr) } // Drain auth_ok + ready. - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, readErr := conn.Read(ctx); readErr != nil { t.Fatalf("%s drain initial msg %d: %v", label, i, readErr) } @@ -1325,7 +1325,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { t.Fatalf("write auth: %v", err) } // Drain auth_ok and ready (these are direct writes, not broadcasts). - for i := 0; i < 2; i++ { + for i := range 2 { if _, _, err := conn.Read(ctx); err != nil { t.Fatalf("drain msg %d: %v", i, err) } @@ -1342,7 +1342,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { var seqs []float64 readCtx, readCancel := context.WithTimeout(ctx, 3*time.Second) defer readCancel() - for i := 0; i < 10; i++ { + for range 10 { _, raw, readErr := conn.Read(readCtx) if readErr != nil { break diff --git a/docs/mcp-introspect.md b/docs/mcp-introspect.md new file mode 100644 index 00000000..b2876674 --- /dev/null +++ b/docs/mcp-introspect.md @@ -0,0 +1,248 @@ +# OwnCord Introspection MCP Server + +`tools/mcp-introspect/` is a small [Model Context Protocol](https://modelcontextprotocol.io) +(MCP) server that lets an AI agent — Claude Code — inspect a **locally running** OwnCord +instance: read its logs, query any REST endpoint, and tail the desktop client's log file. + +It is a **development tool**, not part of the shipped product. It ships no data of its own and +adds nothing to the server binary — it is a thin wrapper over OwnCord's existing REST API plus +the client's on-disk log. + +- **Code:** `tools/mcp-introspect/index.mjs` (one file, ~230 lines) +- **Runtime:** Node ≥ 20, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`) +- **Registration:** `/.mcp.json` (committed) and `.claude/settings.local.json` (local) + +--- + +## How it works + +```mermaid +flowchart LR + CC["Claude Code
(MCP client)"] -->|"stdio
JSON-RPC"| MCP["owncord-introspect
index.mjs"] + MCP -->|"Bearer API token
over pinned TLS"| SRV["OwnCord server
https://127.0.0.1:8443"] + SRV -->|"REST JSON / SSE"| MCP + MCP -->|"readFile"| LOG[("owncord-client.log")] +``` + +Claude Code launches `index.mjs` as a child process (`node tools/mcp-introspect/index.mjs`) and +speaks MCP over stdin/stdout. When you (or the agent) call one of its tools, the server makes a +request to the local OwnCord instance — or reads a file — and returns the result. + +### Authentication + +OwnCord has no static API key. The only credential its API accepts is a **bearer token**, and +until recently the only way to mint one was a username/password login. This tool instead uses a +long-lived **API token** (added alongside this tool — see [API tokens](#api-tokens-server-side)): + +- You mint a token once with `server token create` (writes directly to the DB, no login). +- The tool sends it as `Authorization: Bearer ` on every request (`OWNCORD_API_TOKEN`). +- Server-side, `auth.ResolveTokenHash` resolves the bearer token: it checks login **sessions + first** (unchanged behavior), then falls back to API tokens. The token authenticates as the + user it was bound to (the owner by default), inheriting that user's role — which is why it can + reach `/admin/api/*` and the log stream. + +The tool never sends an `Origin` header: the server treats a missing `Origin` as a non-browser +client and skips CSRF/Origin checks, so a headless client is never blocked on that axis. + +### TLS (cert pinning, not CA trust) + +OwnCord serves HTTPS with a **self-signed certificate that has no SAN** (Subject Alternative +Name). Trusting it as a CA is not enough — hostname verification against `127.0.0.1` still fails. +So the tool uses `node:https` and: + +1. **pins** the exact certificate bytes (`ca: Server/data/cert.pem`), and +2. **skips hostname matching** (`checkServerIdentity: () => undefined`). + +Identity is proven by the pin — a MITM would need the identical cert. This is the same +trust-on-first-use model the desktop client's proxy already uses. (`client_logs` needs neither +the token nor the cert.) + +### The three tools + +**`api_request`** — a single generic passthrough that covers the *entire* REST API. It issues one +`https.request` to `https://127.0.0.1:` with the bearer token and returns +`{ status, headers, body }` (body is JSON-parsed when possible, else raw text). Any HTTP method is +allowed, including destructive admin routes. + +**`server_logs`** — the server keeps its last 2000 log records in an in-memory ring buffer, exposed +over **Server-Sent Events** (an `EventSource`-style stream can't send an auth header, so it's +guarded by a single-use ticket). The tool runs that flow: + +```mermaid +sequenceDiagram + participant T as MCP tool + participant S as OwnCord server + T->>S: POST /admin/api/logs/ticket (Bearer) + S-->>T: { "ticket": "" } (single-use, 30s TTL) + T->>S: GET /admin/api/logs/stream?ticket= + S-->>T: data: {ts,level,msg,source,attrs} (backfill of ring buffer) + S-->>T: data: {...} (live records, if follow_ms > 0) + T->>T: filter by level/source, apply limit, close +``` + +With `follow_ms: 0` (default) it returns after the backfill burst goes quiet; with `follow_ms > 0` +it keeps reading live records for that long. Filtering by `level`/`source` and the `limit` are +applied client-side. + +**`client_logs`** — reads the desktop client's rotating log file directly +(`%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log`); no server involved. Returns the last +N lines with optional level/substring filtering, and degrades gracefully if the file doesn't exist +yet. + +--- + +## Setup + +### 1. Install dependencies + +```bash +cd tools/mcp-introspect +npm install +``` + +### 2. Mint an API token + +```bash +cd Server +./server.exe token create --label mcp-introspect +# prints the raw token ONCE — copy it now, it is never recoverable +``` + +The token defaults to the **owner** account (so it can reach the admin API and log stream). Bind it +to a different user with `--user `, or set an expiry with `--expires 720h`. Manage tokens with +`./server.exe token list` and `./server.exe token revoke `. + +> The running server must be built from the current source for API-token auth to work — the feature +> is compiled into the server binary. Rebuild (`go build -o server.exe .`) and restart if needed. + +### 3. Put the token in your environment + +`.mcp.json` passes `${OWNCORD_API_TOKEN}` through to the tool, so set it once: + +```powershell +setx OWNCORD_API_TOKEN # Windows user env — open a new shell afterwards +``` + +### 4. Enable in Claude Code + +`owncord-introspect` is already listed in `.claude/settings.local.json` under +`enabledMcpjsonServers`. Restart Claude Code so it picks up the new MCP server. + +### Verify + +```bash +cd tools/mcp-introspect +npx @modelcontextprotocol/inspector node index.mjs +``` + +The three tools should appear. Call `api_request` with `{ "method": "GET", "path": "/health" }` and +expect `{status:200, body:{...}}`. + +--- + +## Tool reference + +### `api_request` + +| Param | Type | Notes | +|-------|------|-------| +| `method` | string | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` | +| `path` | string | Path beginning with `/` (e.g. `/admin/api/stats`) or a full URL | +| `query` | object? | Query-string params | +| `body` | any? | JSON body (object or string) | +| `headers` | object? | Extra request headers | + +Returns `{ status, headers, body }`. + +```jsonc +// request +{ "method": "GET", "path": "/api/v1/metrics" } +// → { "status": 200, "headers": {...}, "body": { "uptime_seconds": 1820, +// "goroutines": 42, "connected_users": 1, "voice_sessions": 0, ... } } +``` + +Useful read-only endpoints: `/health`, `/api/v1/metrics` (runtime/process stats), +`/admin/api/stats` (user/message/channel counts), `/api/v1/diagnostics/connectivity`, +`/admin/api/users`, `/admin/api/channels`, `/admin/api/audit-log`. + +### `server_logs` + +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `level` | string? | — | `DEBUG` \| `INFO` \| `WARN` \| `ERROR` | +| `source` | string? | — | `websocket`, `http`, `admin`, `auth`, `database`, `storage`, `updater`, `config`, `server` | +| `limit` | number? | 500 | Max records returned | +| `follow_ms` | number? | 0 | `0` = backfill only; `>0` = also stream live for that many ms | + +Returns an array of `{ ts, level, msg, source, attrs }` (`attrs` is parsed from its JSON string when present; `req_id`/`trace_id` appear inside `attrs`). + +```jsonc +{ "level": "ERROR", "limit": 50 } // last 50 ERROR records from the ring buffer +{ "source": "websocket", "follow_ms": 3000 } // ws logs, backfill + 3s of live tail +``` + +### `client_logs` + +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `lines` | number? | 200 | Trailing lines to return | +| `level` | string? | — | Keep only lines tagged `[LEVEL]` | +| `grep` | string? | — | Keep only lines containing this substring | + +Returns `{ path, found: true, lines: [...] }`, or `{ path, found: false, note }` if the client has +not run yet. + +--- + +## Configuration + +All optional except the token (which only the two server-backed tools need). + +| Env var | Default | Purpose | +|---------|---------|---------| +| `OWNCORD_API_TOKEN` | *(required for `api_request`/`server_logs`)* | Bearer token from `server token create`. | +| `OWNCORD_BASE_URL` | `https://127.0.0.1:` | Override the whole base URL (e.g. a non-TLS endpoint). Port is read from `Server/config.yaml`. | +| `OWNCORD_CERT_PATH` | `Server/data/cert.pem` | Self-signed cert to pin. | +| `OWNCORD_CLIENT_LOG` | `%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log` | Desktop client log path. | + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---------|-------------| +| `OWNCORD_API_TOKEN is not set` | Mint a token and set the env var; restart the shell/Claude Code so it's inherited. | +| `OwnCord cert not found at …` | Start the server once to generate `Server/data/cert.pem`, or set `OWNCORD_CERT_PATH` / `OWNCORD_BASE_URL`. | +| `api_request` returns `401` | Token missing/revoked/expired, or (for `/admin/*`) the token's user lacks ADMINISTRATOR. Mint a fresh owner-bound token. | +| `api_request` returns `403` on `/admin/*` | The request didn't come from an allowed IP — the tool must run on the same host as the server (localhost is allowed by default). | +| `server_logs` fails at the ticket step | The token can't reach `/admin/api/*` (needs ADMINISTRATOR), or the server isn't the current build. | +| `client_logs` → `found: false` | The desktop client hasn't run yet, or the path differs — set `OWNCORD_CLIENT_LOG`. | + +--- + +## Security notes + +- **Local-only by design.** The tool talks to `127.0.0.1` and relies on the server's localhost admin + IP gate. Do not expose it or point it at a remote host without understanding the trust model. +- **`api_request` is full read-write.** It can call any endpoint with any method, including + destructive admin routes (delete users/channels, restore backups, apply updates). There is no + write allowlist — it relies on the operator/agent's discretion. +- **The API token is a real credential.** It never expires by default and inherits the owner's + permissions. Keep it out of version control (it lives in your environment, not `.mcp.json`), and + revoke it with `server token revoke` if leaked. + +--- + +## API tokens (server side) + +The MCP tool depends on a server feature added at the same time: revocable, long-lived **API +tokens**. See the `api_tokens` table in [`schema.md`](schema.md) and the `server token` +subcommands. Key points: + +- Stored hashed (SHA-256), like sessions; the raw token is shown once at creation. +- Resolved by the same middleware as sessions (`auth.ResolveTokenHash`) — **sessions are matched + first**, so existing login behavior is unchanged; API tokens are a fallback. +- `expires_at IS NULL` means never expires; `revoked_at IS NULL` means active. Revocation takes + effect immediately. +- Kept in a separate table from `sessions`, so bulk logout and the per-user session cap never + affect them. diff --git a/docs/schema.md b/docs/schema.md index de49c0ae..8212df2f 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS schema_versions ( | `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime | | `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` | | `017_user_identity_key.sql` | Adds `users.identity_public_key` (long-term E2EE identity key for voice TOFU) | +| `018_api_tokens.sql` | Adds `api_tokens` — long-lived, revocable bearer tokens for headless clients (bot/service auth) | --- @@ -138,6 +139,30 @@ Session TTL: 30 days. Token is stored as SHA-256 hash. --- +### api_tokens + +```sql +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); +``` + +Long-lived, revocable bearer tokens for headless clients (bots, CI, the introspection +MCP tool). A token authenticates as `user_id`, inheriting that user's role/permissions, +and is resolved by the same middleware as sessions (see `auth.ResolveTokenHash`). Only the +SHA-256 hash is stored; the raw token is shown once at creation. `expires_at` NULL = never +expires; `revoked_at` NULL = active. Mint/list/revoke via `server token …`. Separate from +`sessions` so bulk logout and the per-user session cap never affect these. + +--- + ### channels ```sql diff --git a/tools/mcp-introspect/README.md b/tools/mcp-introspect/README.md new file mode 100644 index 00000000..ab50dbec --- /dev/null +++ b/tools/mcp-introspect/README.md @@ -0,0 +1,31 @@ +# owncord-introspect (MCP dev tool) + +A small [MCP](https://modelcontextprotocol.io) server that lets Claude Code introspect a **locally +running** OwnCord instance. Three tools: + +| Tool | What it does | +|------|--------------| +| `api_request` | Read-write passthrough to any OwnCord REST endpoint (`/api/v1/*`, `/admin/api/*`). | +| `server_logs` | The server's in-memory ring-buffer logs (admin SSE ticket→stream). | +| `client_logs` | Tails the desktop client's on-disk log file. | + +## Quickstart + +```bash +# 1. install +cd tools/mcp-introspect && npm install + +# 2. mint a token (prints it once) +cd ../../Server && ./server.exe token create --label mcp-introspect + +# 3. set it, then restart Claude Code +setx OWNCORD_API_TOKEN +``` + +`owncord-introspect` is already enabled in `.claude/settings.local.json`. + +## Full documentation + +See **[`docs/mcp-introspect.md`](../../docs/mcp-introspect.md)** for how it works (auth, cert +pinning, the log-stream flow), the complete tool reference, configuration, troubleshooting, and +security notes. diff --git a/tools/mcp-introspect/index.mjs b/tools/mcp-introspect/index.mjs new file mode 100644 index 00000000..0a9c88a9 --- /dev/null +++ b/tools/mcp-introspect/index.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// OwnCord introspection MCP server (dev tool for Claude Code). +// +// Exposes three tools over stdio: +// api_request — full read-write passthrough to any OwnCord REST endpoint +// server_logs — the admin ring-buffer log stream (SSE ticket -> stream) +// client_logs — tail the desktop client's on-disk log file +// +// Auth: a long-lived OwnCord API token in OWNCORD_API_TOKEN, sent as a bearer +// header (mint one with `server token create --label mcp-introspect`). +// TLS: pins the server's self-signed cert (Server/data/cert.pem). The cert has +// no SAN, so hostname verification is intentionally skipped — identity is proven +// by the pin. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import https from "node:https"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +// tools/mcp-introspect -> repo root +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const TOKEN = process.env.OWNCORD_API_TOKEN || ""; +const CERT_PATH = process.env.OWNCORD_CERT_PATH || join(REPO_ROOT, "Server", "data", "cert.pem"); +const CLIENT_LOG = + process.env.OWNCORD_CLIENT_LOG || + join(process.env.LOCALAPPDATA || "", "com.owncord.client", "logs", "owncord-client.log"); + +// Base URL: explicit override, else https://127.0.0.1:. +function readServerPort() { + try { + const text = readFileSync(join(REPO_ROOT, "Server", "config.yaml"), "utf8"); + // Scope to the top-level `server:` block so we don't grab a voice/livekit port. + const m = text.match(/^server:\s*$[\s\S]*?^\s+port:\s*(\d+)/m); + if (m) return Number(m[1]); + } catch { + /* fall through to default */ + } + return 8443; +} +const BASE_URL = process.env.OWNCORD_BASE_URL || `https://127.0.0.1:${readServerPort()}`; + +// Lazily built so client_logs works even when the cert/server is absent. Only +// https bases need the pinned agent; an http override (rare) uses none. +let _agent; +function httpsAgent() { + if (!BASE_URL.startsWith("https:")) return undefined; + if (_agent) return _agent; + let ca; + try { + ca = readFileSync(CERT_PATH); + } catch { + throw new Error( + `OwnCord cert not found at ${CERT_PATH}. Start the server once to generate it, ` + + `or set OWNCORD_CERT_PATH (or OWNCORD_BASE_URL for a non-TLS endpoint).`, + ); + } + // Pin the exact self-signed cert; skip hostname check (the cert has no SAN). + _agent = new https.Agent({ ca, checkServerIdentity: () => undefined }); + return _agent; +} + +function requireToken() { + if (!TOKEN) throw new Error("OWNCORD_API_TOKEN is not set — mint one with `server token create`."); +} + +// One request helper backs api_request and is reused by the log flow. Never +// sends an Origin header: the server treats a missing Origin as a non-browser +// client and skips CSRF/Origin checks. +function request(method, path, { query, body, headers } = {}) { + return new Promise((resolve, reject) => { + const url = new URL(/^https?:/.test(path) ? path : BASE_URL + path); + if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v)); + const payload = body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body); + const h = { + Authorization: `Bearer ${TOKEN}`, + ...(payload !== undefined ? { "Content-Type": "application/json" } : {}), + ...(headers || {}), + }; + const req = https.request(url, { method, agent: httpsAgent(), headers: h }, (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (data += c)); + res.on("end", () => { + let parsed = data; + try { + parsed = JSON.parse(data); + } catch { + /* leave as raw text */ + } + resolve({ status: res.statusCode, headers: res.headers, body: parsed }); + }); + }); + req.on("error", reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +// Fetch the server ring-buffer logs. The stream is SSE guarded by a single-use +// ticket (EventSource can't send auth headers). Read the backfill burst, then +// optionally keep reading for follow_ms, filter client-side, and return. +async function collectLogs({ level, source, limit = 500, follow_ms = 0 } = {}) { + const ticketRes = await request("POST", "/admin/api/logs/ticket"); + if (ticketRes.status !== 200 || !ticketRes.body?.ticket) { + throw new Error(`log ticket request failed: HTTP ${ticketRes.status} ${JSON.stringify(ticketRes.body)}`); + } + const url = new URL(`${BASE_URL}/admin/api/logs/stream`); + url.searchParams.set("ticket", ticketRes.body.ticket); + + const records = await new Promise((resolve, reject) => { + const req = https.request( + url, + { method: "GET", agent: httpsAgent(), headers: { Authorization: `Bearer ${TOKEN}`, Accept: "text/event-stream" } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`log stream failed: HTTP ${res.statusCode}`)); + return; + } + const out = []; + let buf = ""; + let idle; + let hard; + const done = () => { + clearTimeout(idle); + clearTimeout(hard); + req.destroy(); + resolve(out); + }; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, i); + buf = buf.slice(i + 2); + const line = frame.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; // keepalive comment or blank + try { + out.push(JSON.parse(line.slice(6))); + } catch { + /* skip malformed frame */ + } + } + // In backfill-only mode, finish once the initial burst goes quiet. + if (follow_ms === 0) { + clearTimeout(idle); + idle = setTimeout(done, 300); + } + }); + res.on("end", done); + res.on("error", reject); + if (follow_ms === 0) idle = setTimeout(done, 300); + else hard = setTimeout(done, follow_ms); + }, + ); + req.on("error", reject); + req.end(); + }); + + let rows = records; + if (level) rows = rows.filter((r) => (r.level || "").toUpperCase() === level.toUpperCase()); + if (source) rows = rows.filter((r) => r.source === source); + if (rows.length > limit) rows = rows.slice(-limit); + // attrs arrives as a JSON string on the wire; parse it for readability. + return rows.map((r) => (r.attrs ? { ...r, attrs: safeParse(r.attrs) } : r)); +} + +function safeParse(s) { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +function clientLogs({ lines = 200, level, grep } = {}) { + let text; + try { + text = readFileSync(CLIENT_LOG, "utf8"); + } catch (e) { + return { path: CLIENT_LOG, found: false, note: `client log not found (client may not have run yet): ${e.code}` }; + } + let rows = text.split(/\r?\n/).filter(Boolean); + if (level) rows = rows.filter((l) => l.toUpperCase().includes(`[${level.toUpperCase()}]`)); + if (grep) rows = rows.filter((l) => l.includes(grep)); + return { path: CLIENT_LOG, found: true, lines: rows.slice(-lines) }; +} + +// ─── MCP wiring ───────────────────────────────────────────────────────────── +const server = new McpServer({ name: "owncord-introspect", version: "0.1.0" }); +const ok = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }); +const fail = (e) => ({ content: [{ type: "text", text: String(e?.message || e) }], isError: true }); + +server.registerTool( + "api_request", + { + description: + "Read-write passthrough to any OwnCord REST endpoint (/api/v1/*, /admin/api/*, plugins). " + + "Returns {status, headers, body}. Any HTTP method is allowed, including destructive admin routes.", + inputSchema: { + method: z.string().describe("HTTP method: GET, POST, PATCH, PUT, DELETE"), + path: z.string().describe("Path beginning with / (e.g. /admin/api/stats) or a full URL"), + query: z.record(z.string()).optional().describe("Query params"), + body: z.any().optional().describe("JSON body (object or string)"), + headers: z.record(z.string()).optional().describe("Extra request headers"), + }, + }, + async (a) => { + try { + requireToken(); + return ok(await request(a.method, a.path, a)); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "server_logs", + { + description: + "Fetch the server's in-memory ring-buffer logs (SSE backfill, optionally follow). " + + "Each record is {ts, level, msg, source, attrs}. Filters by level/source and applies a limit.", + inputSchema: { + level: z.string().optional().describe("DEBUG | INFO | WARN | ERROR"), + source: z.string().optional().describe("websocket|http|admin|auth|database|storage|updater|config|server"), + limit: z.number().int().positive().optional().describe("Max records to return (default 500)"), + follow_ms: z.number().int().nonnegative().optional().describe("0 = backfill only (default); >0 keeps streaming that long"), + }, + }, + async (a) => { + try { + requireToken(); + return ok(await collectLogs(a)); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "client_logs", + { + description: + "Tail the desktop client's log file directly (no server needed). Optional level filter / substring grep.", + inputSchema: { + lines: z.number().int().positive().optional().describe("How many trailing lines (default 200)"), + level: z.string().optional().describe("Filter to lines tagged with this level, e.g. ERROR"), + grep: z.string().optional().describe("Keep only lines containing this substring"), + }, + }, + async (a) => { + try { + return ok(clientLogs(a)); + } catch (e) { + return fail(e); + } + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/tools/mcp-introspect/package-lock.json b/tools/mcp-introspect/package-lock.json new file mode 100644 index 00000000..42193264 --- /dev/null +++ b/tools/mcp-introspect/package-lock.json @@ -0,0 +1,1182 @@ +{ + "name": "owncord-mcp-introspect", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "owncord-mcp-introspect", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/tools/mcp-introspect/package.json b/tools/mcp-introspect/package.json new file mode 100644 index 00000000..6c62cc79 --- /dev/null +++ b/tools/mcp-introspect/package.json @@ -0,0 +1,17 @@ +{ + "name": "owncord-mcp-introspect", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Local MCP server to introspect a running OwnCord instance (dev tool for Claude Code).", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node index.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.23.8" + } +}