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:
J3vb
2026-07-29 13:25:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 77ae0d21a8
commit 58005c9c6f
64 changed files with 3562 additions and 234 deletions
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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.
+3 -3
View File
@@ -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(`{}`))
}
+1 -4
View File
@@ -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)
+2 -4
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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{
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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)
}
+13 -15
View File
@@ -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)
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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