Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2f8913a
feat: add api_tokens migration (036)
thebtf Mar 18, 2026
8ceeeef
feat: add TokenStore for client API tokens
thebtf Mar 18, 2026
b72754f
feat: add auth handlers (login, logout, tokens CRUD)
thebtf Mar 18, 2026
4146791
feat: update middleware for token hierarchy + session cookies
thebtf Mar 18, 2026
d10c807
feat: register auth routes and wire TokenStore
thebtf Mar 18, 2026
37fb2b4
feat: add buffered token stats increment
thebtf Mar 18, 2026
af6ae68
feat: add vault REST endpoints (AD-1)
thebtf Mar 18, 2026
f6db53f
feat: add tag REST endpoints (AD-1)
thebtf Mar 18, 2026
b149125
feat: add session REST endpoints (AD-1)
thebtf Mar 18, 2026
e5b65c3
feat: add maintenance REST endpoints (AD-1)
thebtf Mar 18, 2026
7e392bb
feat: add analytics trends REST endpoint (AD-1)
thebtf Mar 18, 2026
de66b70
feat: register vault/tag/session/maintenance/analytics routes
thebtf Mar 18, 2026
41b8682
feat: install vue-router 4, Fira fonts
thebtf Mar 19, 2026
a350641
feat: add dashboard frontend layout — router, sidebar, auth, placehol…
thebtf Mar 19, 2026
78074ad
feat: add observation composables (useObservation, usePagination)
thebtf Mar 19, 2026
4ddc9f8
feat: implement ObservationsView with pagination and filters
thebtf Mar 19, 2026
42fec94
feat: implement ObservationDetailView with inline edit
thebtf Mar 19, 2026
75be7a3
feat: add search components and SearchView with decision mode
thebtf Mar 19, 2026
9debdfa
feat: integrate SearchBar into AppHeader
thebtf Mar 19, 2026
6b882d5
feat: implement VaultView and TokensView with credential management
thebtf Mar 19, 2026
847c003
feat: implement LogsView with SSE stream, bulk operations, and TagEditor
thebtf Mar 19, 2026
d32d7fb
feat: implement AnalyticsView with search miss tracking and stats
thebtf Mar 19, 2026
a0bcc87
feat: implement GraphView, PatternsView, SessionsView, and SystemView
thebtf Mar 19, 2026
481742a
fix: resolve all PR #21 review comments (42 threads)
thebtf Mar 19, 2026
5d07adf
merge: resolve all conflicts — keep Phase 6-9 implementations
thebtf Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions internal/db/gorm/token_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,19 @@ func (s *TokenStore) List(ctx context.Context) ([]APIToken, error) {
return tokens, err
}

// FindByPrefix looks up a non-revoked token by its prefix for auth middleware.
func (s *TokenStore) FindByPrefix(ctx context.Context, prefix string) (*APIToken, error) {
var token APIToken
// FindByPrefix looks up all non-revoked tokens matching the given prefix.
// Multiple tokens may share a prefix in the non-unique index, so callers
// must iterate over the returned slice and compare bcrypt hashes to find the
// matching token.
func (s *TokenStore) FindByPrefix(ctx context.Context, prefix string) ([]APIToken, error) {
var tokens []APIToken
err := s.db.WithContext(ctx).
Where("token_prefix = ? AND NOT revoked", prefix).
First(&token).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
Find(&tokens).Error
if err != nil {
return nil, err
}
return &token, nil
return tokens, nil
}

// Revoke marks a token as revoked.
Expand Down
19 changes: 10 additions & 9 deletions internal/worker/handlers_analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package worker
import (
"fmt"
"net/http"
"slices"
"strconv"
"time"

Expand Down Expand Up @@ -57,7 +58,11 @@ func (s *Service) handleGetTrends(w http.ResponseWriter, r *http.Request) {
}
}

// Get observations for analysis (rough estimate of limit)
// Get observations for analysis. The multiplier of 50 is a heuristic: we
// assume at most ~50 observations are created per day on average. For
// deployments with higher throughput the analytics may be slightly
// incomplete; a future improvement would be to filter by created_at >= cutoff
// at the DB level rather than applying a fetch limit here.
obs, err := s.observationStore.GetRecentObservations(r.Context(), project, days*50)
if err != nil {
log.Error().Err(err).Msg("get observations for trends failed")
Expand Down Expand Up @@ -119,17 +124,13 @@ func (s *Service) handleGetTrends(w http.ResponseWriter, r *http.Request) {
name string
count int
}
var topConcepts []conceptEntry
topConcepts := make([]conceptEntry, 0, len(conceptCounts))
for name, count := range conceptCounts {
topConcepts = append(topConcepts, conceptEntry{name, count})
}
for i := 0; i < len(topConcepts) && i < 10; i++ {
for j := i + 1; j < len(topConcepts); j++ {
if topConcepts[j].count > topConcepts[i].count {
topConcepts[i], topConcepts[j] = topConcepts[j], topConcepts[i]
}
}
}
slices.SortFunc(topConcepts, func(a, b conceptEntry) int {
return b.count - a.count // descending by count
})
if len(topConcepts) > 10 {
topConcepts = topConcepts[:10]
}
Expand Down
18 changes: 11 additions & 7 deletions internal/worker/handlers_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"

"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"golang.org/x/crypto/bcrypt"
)

Expand Down Expand Up @@ -98,6 +101,7 @@ func (s *Service) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: sessionMaxAge,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})

Expand Down Expand Up @@ -317,7 +321,11 @@ func (s *Service) handleRevokeToken(w http.ResponseWriter, r *http.Request) {

if err := tokenStore.Revoke(r.Context(), id); err != nil {
log.Error().Err(err).Str("token_id", id).Msg("auth: failed to revoke token")
http.Error(w, "not found", http.StatusNotFound)
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}

Expand Down Expand Up @@ -358,12 +366,8 @@ func isDuplicateKeyError(err error) bool {
// containsDuplicateKey checks error message for duplicate key indicators.
func containsDuplicateKey(msg string) bool {
for _, s := range []string{"duplicate key", "23505", "UNIQUE constraint"} {
if len(msg) >= len(s) {
for i := 0; i <= len(msg)-len(s); i++ {
if msg[i:i+len(s)] == s {
return true
}
}
if strings.Contains(msg, s) {
return true
}
}
return false
Expand Down
5 changes: 4 additions & 1 deletion internal/worker/handlers_maintenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package worker

import (
"context"
"encoding/json"
"net/http"

Expand Down Expand Up @@ -85,7 +86,9 @@ func (s *Service) handleRunMaintenance(w http.ResponseWriter, r *http.Request) {
return
}

s.maintenanceService.RunNow(r.Context())
// Use background context: the request context is cancelled after the
// response is sent, which would prematurely abort the background job.
s.maintenanceService.RunNow(context.Background())

writeJSON(w, map[string]any{
"status": "triggered",
Expand Down
14 changes: 9 additions & 5 deletions internal/worker/handlers_sessions_rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
"github.com/thebtf/engram/internal/sessions"
)

const (
maxSessionsLimit = 200 // Server-side cap to prevent huge response payloads.
)

// handleListIndexedSessions godoc
// @Summary List indexed sessions
// @Description Returns indexed Claude Code sessions with optional project and workstation filters.
Expand All @@ -18,7 +22,7 @@ import (
// @Security ApiKeyAuth
// @Param project query string false "Filter by project ID"
// @Param workstation query string false "Filter by workstation ID"
// @Param limit query int false "Number of results (default 20)"
// @Param limit query int false "Number of results (default 20, max 200)"
// @Param offset query int false "Pagination offset"
// @Success 200 {array} object
// @Failure 500 {string} string "internal error"
Expand All @@ -32,7 +36,7 @@ func (s *Service) handleListIndexedSessions(w http.ResponseWriter, r *http.Reque
limit := 20
if val := r.URL.Query().Get("limit"); val != "" {
if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 {
limit = parsed
limit = min(parsed, maxSessionsLimit)
}
}
offset := 0
Expand All @@ -52,7 +56,7 @@ func (s *Service) handleListIndexedSessions(w http.ResponseWriter, r *http.Reque
list, err := s.sessionIdxStore.ListSessions(r.Context(), opts)
if err != nil {
log.Error().Err(err).Msg("list indexed sessions failed")
http.Error(w, "list sessions: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "internal error", http.StatusInternalServerError)
return
Comment thread
thebtf marked this conversation as resolved.
}

Expand Down Expand Up @@ -117,14 +121,14 @@ func (s *Service) handleSearchIndexedSessions(w http.ResponseWriter, r *http.Req
limit := 10
if val := r.URL.Query().Get("limit"); val != "" {
if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 {
limit = parsed
limit = min(parsed, maxSessionsLimit)
}
}

results, err := s.sessionIdxStore.SearchSessions(r.Context(), query, limit)
if err != nil {
log.Error().Err(err).Str("query", query).Msg("search indexed sessions failed")
http.Error(w, "search sessions: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

Expand Down
15 changes: 11 additions & 4 deletions internal/worker/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,19 +276,26 @@ func (ta *TokenAuth) authenticateClientToken(w http.ResponseWriter, r *http.Requ
}
prefix := rawToken[4:12]

token, err := store.FindByPrefix(r.Context(), prefix)
candidates, err := store.FindByPrefix(r.Context(), prefix)
if err != nil {
log.Error().Err(err).Msg("auth: token store lookup failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
if token == nil {
if len(candidates) == 0 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return true
}

// Verify bcrypt hash
if err := bcrypt.CompareHashAndPassword([]byte(token.TokenHash), []byte(rawToken)); err != nil {
// Find the matching token by bcrypt comparison (handles prefix collisions).
var token *gormdb.APIToken
for i := range candidates {
if bcrypt.CompareHashAndPassword([]byte(candidates[i].TokenHash), []byte(rawToken)) == nil {
token = &candidates[i]
break
}
}
if token == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return true
}
Expand Down
19 changes: 10 additions & 9 deletions internal/worker/token_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,27 @@ func (s *Service) startTokenStatsFlusher(ctx context.Context) {
for {
select {
case <-ctx.Done():
// Final flush on shutdown
s.flushTokenStats(pending)
// Final flush on shutdown: use a bounded timeout so the goroutine
// does not block indefinitely after cancellation.
flushCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
s.flushTokenStats(flushCtx, pending)
cancel()
return

case tokenID := <-ch:
pending[tokenID]++

case <-ticker.C:
s.flushTokenStats(pending)
// Reset the map
for k := range pending {
delete(pending, k)
}
s.flushTokenStats(ctx, pending)
// Reset map by allocating a fresh one (cheaper than deleting in a loop).
pending = make(map[string]int)
}
}
}()
}

// flushTokenStats writes accumulated token usage counts to the database.
func (s *Service) flushTokenStats(counts map[string]int) {
func (s *Service) flushTokenStats(ctx context.Context, counts map[string]int) {
if len(counts) == 0 {
return
}
Expand All @@ -67,7 +68,7 @@ func (s *Service) flushTokenStats(counts map[string]int) {
return
}

if err := store.BatchIncrementStats(context.Background(), counts); err != nil {
if err := store.BatchIncrementStats(ctx, counts); err != nil {
log.Warn().Err(err).Int("tokens", len(counts)).Msg("auth: failed to flush token stats")
}
}
9 changes: 6 additions & 3 deletions ui/src/components/layout/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ function isActive(item: NavItem): boolean {
if (item.path === '/') {
return route.path === '/'
}
return route.path.startsWith(item.path)
return route.path === item.path || route.path.startsWith(item.path + '/')
}

async function handleLogout() {
await logout()
router.push({ name: 'login' })
try {
await logout()
} finally {
router.push({ name: 'login' })
}
}
</script>

Expand Down
31 changes: 28 additions & 3 deletions ui/src/components/layout/ConfirmDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
defineProps<{
import { ref, watch } from 'vue'

const props = defineProps<{
show: boolean
title: string
message: string
Expand All @@ -12,6 +14,22 @@ const emit = defineEmits<{
confirm: []
cancel: []
}>()

const cancelButtonRef = ref<HTMLButtonElement | null>(null)

// Focus the cancel button when the dialog opens for keyboard accessibility.
watch(() => props.show, (visible) => {
if (visible) {
// Defer to next tick so the DOM is rendered.
setTimeout(() => cancelButtonRef.value?.focus(), 0)
}
})

function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('cancel')
}
}
</script>

<template>
Expand All @@ -22,12 +40,19 @@ const emit = defineEmits<{
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="emit('cancel')" />

<!-- Dialog -->
<div class="relative glass border border-white/10 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<h3 class="text-lg font-semibold text-white mb-2">{{ title }}</h3>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="'confirm-dialog-title'"
class="relative glass border border-white/10 rounded-xl p-6 max-w-sm w-full shadow-2xl"
@keydown="handleKeydown"
>
<h3 id="confirm-dialog-title" class="text-lg font-semibold text-white mb-2">{{ title }}</h3>
<p class="text-sm text-slate-400 mb-6">{{ message }}</p>

<div class="flex items-center justify-end gap-3">
<button
ref="cancelButtonRef"
class="px-4 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800/50 transition-colors"
@click="emit('cancel')"
>
Expand Down
7 changes: 3 additions & 4 deletions ui/src/components/observation/ObservationEditor.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Observation, ObservationType, ObservationScope } from '@/types'
import { TYPE_CONFIG, OBSERVATION_TYPES } from '@/types/observation'
import type { Observation, ObservationScope } from '@/types'

const props = defineProps<{
observation: Observation
Expand Down Expand Up @@ -44,7 +43,7 @@ const hasChanges = computed(() => {
subtitle.value !== (props.observation.subtitle || '') ||
narrative.value !== (props.observation.narrative || '') ||
scope.value !== (props.observation.scope || 'project') ||
JSON.stringify(concepts.value.sort()) !== JSON.stringify([...(props.observation.concepts || [])].sort())
JSON.stringify([...concepts.value].sort()) !== JSON.stringify([...(props.observation.concepts || [])].sort())
})

function handleSave() {
Expand All @@ -53,7 +52,7 @@ function handleSave() {
if (subtitle.value !== (props.observation.subtitle || '')) updates.subtitle = subtitle.value
if (narrative.value !== (props.observation.narrative || '')) updates.narrative = narrative.value
if (scope.value !== (props.observation.scope || 'project')) updates.scope = scope.value
if (JSON.stringify(concepts.value.sort()) !== JSON.stringify([...(props.observation.concepts || [])].sort())) {
if (JSON.stringify([...concepts.value].sort()) !== JSON.stringify([...(props.observation.concepts || [])].sort())) {
updates.concepts = concepts.value
}
emit('save', updates)
Expand Down
Loading
Loading