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
+1
View File
@@ -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:
+11
View File
@@ -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) {
+1 -1
View File
@@ -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), "")
}
+143
View File
@@ -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.
+3 -4
View File
@@ -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"
+128
View File
@@ -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
View File
@@ -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))
})
}
+2 -2
View File
@@ -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:
+64 -1
View File
@@ -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()">&times;</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()">&times;</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()">&times;</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;
+10 -10
View File
@@ -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",
})
+1 -1
View File
@@ -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))
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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))
})
+63
View File
@@ -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,
+3 -5
View File
@@ -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() {
+9 -9
View File
@@ -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())
+6 -9
View File
@@ -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")
}
+82
View File
@@ -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
}
+160
View File
@@ -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)
}
})
}
}
+3 -3
View File
@@ -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
}
+2 -7
View File
@@ -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.
+118
View File
@@ -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
}
+187
View File
@@ -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)
}
}
+175
View File
@@ -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
}
+11
View File
@@ -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"`
+19
View File
@@ -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
+14
View File
@@ -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{
+2 -2
View File
@@ -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
+27
View File
@@ -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
+1 -1
View File
@@ -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)
}
+47
View File
@@ -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
View File
@@ -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("", "")
+27
View File
@@ -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);
+2 -3
View File
@@ -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)
+3 -4
View File
@@ -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
}
+205
View File
@@ -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
}
+2 -4
View File
@@ -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()
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
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