mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.8
parent
77ae0d21a8
commit
58005c9c6f
+5
-2
@@ -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)
|
||||
|
||||
Generated
+132
-31
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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), "")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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='<div class="page-title">Loading...</div>';
|
||||
const fn=r[state.section];
|
||||
if(typeof fn!=='function'){console.error('[Admin] No render function for section: '+state.section);c.innerHTML='<div class="page-title">Error</div><p style="color:var(--red)">Unknown section: '+esc(state.section)+'</p><button class="btn btn-accent" onclick="navigateTo(\'dashboard\')">Back to Dashboard</button>';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'<span class="badge badge-red">Revoked</span>';
|
||||
if(t.expires_at&&new Date(t.expires_at)<new Date())return'<span class="badge badge-yellow">Expired</span>';
|
||||
return'<span class="badge badge-green">Active</span>';
|
||||
}
|
||||
async function renderTokens(){
|
||||
let tokens;
|
||||
try{tokens=await api('GET','/tokens')}catch(e){return'<div class="page-title">API Tokens</div><p style="color:var(--red)">'+esc(e.message)+'</p>'}
|
||||
let html='<div class="page-title">API Tokens</div><div class="page-desc">Long-lived bearer tokens for bots, CI, and the introspection MCP tool. A token authenticates as its bound user. Owner only.</div>';
|
||||
html+='<div style="margin-bottom:16px"><button class="btn btn-accent" onclick="openCreateTokenModal()">'+I.plus+' Create Token</button></div>';
|
||||
html+='<div class="section-card"><div class="section-card-header"><h3>Tokens</h3></div><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Label</th><th>User</th><th>Created</th><th>Last Used</th><th>Expires</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead><tbody>';
|
||||
if(!tokens||!tokens.length)html+='<tr><td colspan="7" style="text-align:center;color:var(--text-faint);padding:24px">No API tokens</td></tr>';
|
||||
else tokens.forEach(t=>{
|
||||
const revoked=!!t.revoked_at;
|
||||
html+='<tr><td>'+esc(t.label||'—')+'</td><td>'+esc(t.username)+'</td>';
|
||||
html+='<td>'+(t.created_at?new Date(t.created_at).toLocaleString():'')+'</td>';
|
||||
html+='<td>'+(t.last_used?new Date(t.last_used).toLocaleString():'<span style="color:var(--text-faint)">never</span>')+'</td>';
|
||||
html+='<td>'+(t.expires_at?new Date(t.expires_at).toLocaleString():'<span style="color:var(--text-faint)">never</span>')+'</td>';
|
||||
html+='<td>'+tokenStatus(t)+'</td>';
|
||||
html+='<td><div class="act-group" style="justify-content:flex-end">'+(revoked?'':'<button class="act-btn danger" title="Revoke" onclick="confirmRevokeToken('+t.id+',\''+jsq(t.label)+'\')">'+I.trash+'</button>')+'</div></td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function openCreateTokenModal(){
|
||||
openModal('<div class="modal-header"><h3>Create API Token</h3><button class="modal-close" onclick="closeModal()">×</button></div>'+
|
||||
'<div class="modal-body"><div class="form-group"><label class="form-label">Label</label><input id="tokLabel" class="form-input" placeholder="ci-bot" autofocus></div>'+
|
||||
'<div class="form-group"><label class="form-label">User <span style="color:var(--text-faint)">(optional)</span></label><input id="tokUser" class="form-input" placeholder="owner (default)"></div>'+
|
||||
'<div class="form-group"><label class="form-label">Expires in hours <span style="color:var(--text-faint)">(0 = never)</span></label><input id="tokExpires" class="form-input" type="number" min="0" value="0"></div></div>'+
|
||||
'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="createToken()">Create</button></div>');
|
||||
}
|
||||
|
||||
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('<div class="modal-header"><h3>Token Created</h3><button class="modal-close" onclick="closeModal();renderContent()">×</button></div>'+
|
||||
'<div class="modal-body"><p style="color:var(--text-muted)">Store this token now — it is shown only once and cannot be recovered. Bound to <strong style="color:white">'+esc(d.user)+'</strong>.</p>'+
|
||||
'<div style="display:flex;gap:8px;margin-top:12px"><code style="flex:1;font-family:var(--font-mono);font-size:12px;background:var(--bg-active);padding:10px;border-radius:var(--radius-sm);word-break:break-all">'+esc(d.token)+'</code>'+
|
||||
'<button class="btn btn-ghost" onclick="copyToken(\''+jsq(d.token)+'\')">Copy</button></div></div>'+
|
||||
'<div class="modal-footer"><button class="btn btn-accent" onclick="closeModal();renderContent()">Done</button></div>');
|
||||
}
|
||||
function copyToken(t){navigator.clipboard.writeText(t).then(()=>showToast('Copied!','info'))}
|
||||
|
||||
function confirmRevokeToken(id,label){
|
||||
openModal('<div class="modal-header"><h3>Revoke Token</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Revoke <strong style="color:white">'+esc(label||('#'+id))+'</strong>? Any client using it will immediately lose access. This cannot be undone.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="revokeToken('+id+')">Revoke</button></div>');
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+40
-46
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+7
-1
@@ -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("", "")
|
||||
|
||||
@@ -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 <token>" 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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <create|list|revoke>`. 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 <command>
|
||||
|
||||
Commands:
|
||||
create --label <name> [--user <username>] [--expires <dur>]
|
||||
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 <id|label>
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -787,8 +787,8 @@ func (s *StagedBinary) Close() error {
|
||||
// ParseChecksumFile parses a sha256sum-format checksum file (lines of
|
||||
// "<hash> <filename>") 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
|
||||
|
||||
@@ -364,7 +364,7 @@ func TestUpdateChecksum_SHA256MatchesChecksumsFile(t *testing.T) {
|
||||
|
||||
// Same line shape as release workflow: sha256sum prints "<hash> <path>".
|
||||
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...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(`{}`))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-5
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<br/>(MCP client)"] -->|"stdio<br/>JSON-RPC"| MCP["owncord-introspect<br/>index.mjs"]
|
||||
MCP -->|"Bearer API token<br/>over pinned TLS"| SRV["OwnCord server<br/>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 <token>` 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:<port><path>` 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": "<hex>" } (single-use, 30s TTL)
|
||||
T->>S: GET /admin/api/logs/stream?ticket=<hex>
|
||||
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 <name>`, or set an expiry with `--expires 720h`. Manage tokens with
|
||||
`./server.exe token list` and `./server.exe token revoke <id|label>`.
|
||||
|
||||
> 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 <paste-raw-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:<server.port>` | 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 <paste-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.
|
||||
@@ -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:<server.port from config.yaml>.
|
||||
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());
|
||||
Generated
+1182
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user