Compare commits
19 Commits
luna/desig
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
00105d2e19 | |
|
|
115e23e095 | |
|
|
2195b51eec | |
|
|
d475c5a914 | |
|
|
840543b581 | |
|
|
3c4e091e33 | |
|
|
063d2c8cd8 | |
|
|
ab0b5e0717 | |
|
|
7dbbadb62e | |
|
|
cdfa87b8ce | |
|
|
def0c6fb1d | |
|
|
af47846f23 | |
|
|
3be8a683a7 | |
|
|
989f7e5f2b | |
|
|
c082f84109 | |
|
|
12824ddbef | |
|
|
fe9f98a69e | |
|
|
30a904247d | |
|
|
6c2b708c4d |
|
|
@ -0,0 +1 @@
|
|||
agent-tokens.json
|
||||
|
|
@ -20,4 +20,21 @@ If unset, outage logging continues without external notification.
|
|||
### Kuma Monitoring (Optional)
|
||||
Health push to Kuma can be configured via:
|
||||
- `KUMA_PUSH_URL` - Kuma push endpoint
|
||||
If unset, Kuma push is disabled.
|
||||
If unset, Kuma push is disabled.
|
||||
|
||||
## Dispatcher Verification
|
||||
|
||||
### Test: Dispatcher Agent Spawning (Issue #5)
|
||||
**Status:** ✅ PASSED
|
||||
**Date:** 2026-04-09
|
||||
**Agent:** Hans (NOC/Operations)
|
||||
**Domain:** clavis-telemetry
|
||||
|
||||
**Verification:**
|
||||
- [x] Dispatcher correctly identified `clavis-telemetry` domain from issue
|
||||
- [x] Dispatcher correctly assigned to Hans (NOC/Operations agent)
|
||||
- [x] Hans spawned successfully and processed the issue
|
||||
- [x] All telemetry tests pass (`go test -tags commercial ./...`)
|
||||
- [x] No security violations detected in telemetry codebase
|
||||
|
||||
**Result:** The dispatcher flow correctly routes clavis-telemetry domain issues to Hans. The telemetry service is operational with mTLS authentication, tarpit defenses, and comprehensive monitoring.
|
||||
|
|
@ -0,0 +1,581 @@
|
|||
//go:build commercial
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// logCapture captures log output for verification
|
||||
type logCapture struct {
|
||||
original *os.File
|
||||
reader *os.File
|
||||
writer *os.File
|
||||
buffer chan string
|
||||
done chan bool
|
||||
}
|
||||
|
||||
func captureLogs() *logCapture {
|
||||
// Save original stderr
|
||||
original := os.Stderr
|
||||
|
||||
// Create pipe to capture log output
|
||||
r, w, _ := os.Pipe()
|
||||
|
||||
// Redirect log output
|
||||
os.Stderr = w
|
||||
log.SetOutput(w)
|
||||
|
||||
lc := &logCapture{
|
||||
original: original,
|
||||
reader: r,
|
||||
writer: w,
|
||||
buffer: make(chan string, 100),
|
||||
done: make(chan bool),
|
||||
}
|
||||
|
||||
// Start goroutine to read from pipe
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
lc.buffer <- buf.String()
|
||||
close(lc.buffer)
|
||||
lc.done <- true
|
||||
}()
|
||||
|
||||
return lc
|
||||
}
|
||||
|
||||
func (lc *logCapture) restore() string {
|
||||
// Restore original stderr
|
||||
os.Stderr = lc.original
|
||||
log.SetOutput(lc.original)
|
||||
|
||||
// Close writer to signal EOF to reader
|
||||
lc.writer.Close()
|
||||
|
||||
// Wait for reader goroutine to finish
|
||||
<-lc.done
|
||||
|
||||
// Return captured output
|
||||
return <-lc.buffer
|
||||
}
|
||||
|
||||
// TestUpdateSpan_DatabaseErrors verifies ERR-TELEMETRY-010 through ERR-TELEMETRY-014
|
||||
func TestUpdateSpan_DatabaseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupDB func(*sql.DB)
|
||||
expectedErrors []string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ERR-TELEMETRY-010: Maintenance check fails",
|
||||
setupDB: func(testDB *sql.DB) {
|
||||
// Drop maintenance table to cause query failure
|
||||
testDB.Exec(`DROP TABLE IF EXISTS maintenance`)
|
||||
},
|
||||
expectedErrors: []string{"ERR-TELEMETRY-010"},
|
||||
description: "Should log ERR-TELEMETRY-010 when maintenance check fails",
|
||||
},
|
||||
{
|
||||
name: "ERR-TELEMETRY-011: Uptime span query fails",
|
||||
setupDB: func(testDB *sql.DB) {
|
||||
// Drop uptime_spans table to cause query failure
|
||||
testDB.Exec(`DROP TABLE IF EXISTS uptime_spans`)
|
||||
},
|
||||
expectedErrors: []string{"ERR-TELEMETRY-011"},
|
||||
description: "Should log ERR-TELEMETRY-011 when uptime span query fails",
|
||||
},
|
||||
{
|
||||
name: "ERR-TELEMETRY-012: Span extend fails",
|
||||
setupDB: func(testDB *sql.DB) {
|
||||
// Insert a span but make the table read-only by corrupting it
|
||||
testDB.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`,
|
||||
"test-node-err", time.Now().Unix()-30, time.Now().Unix()-30)
|
||||
// Now drop and recreate to cause update to reference non-existent row
|
||||
testDB.Exec(`DELETE FROM uptime_spans WHERE node_id = ?`, "test-node-err")
|
||||
},
|
||||
expectedErrors: []string{}, // May or may not trigger depending on timing
|
||||
description: "Database error handling for span extend",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup test database
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Apply database corruption/setup
|
||||
tt.setupDB(db)
|
||||
|
||||
// Capture logs
|
||||
lc := captureLogs()
|
||||
|
||||
// Call updateSpan which should trigger error logging
|
||||
updateSpan("test-node-err", "test-host", "1.0.0", 50.0, 4096, 8192, 50000, 100000, 0.5, 3600)
|
||||
|
||||
// Restore and get logs
|
||||
logs := lc.restore()
|
||||
|
||||
// Verify expected errors appear in logs
|
||||
for _, errCode := range tt.expectedErrors {
|
||||
if !strings.Contains(logs, errCode) {
|
||||
t.Errorf("%s: expected log to contain %s, but got:\n%s", tt.description, errCode, logs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSpan_MaintenanceModeError specifically tests ERR-TELEMETRY-010
|
||||
func TestUpdateSpan_MaintenanceModeError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Corrupt the database by dropping the maintenance table
|
||||
_, err := db.Exec(`DROP TABLE maintenance`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to drop maintenance table: %v", err)
|
||||
}
|
||||
|
||||
// Call updateSpan
|
||||
updateSpan("test-node-maint", "test-host", "1.0.0", 50.0, 4096, 8192, 50000, 100000, 0.5, 3600)
|
||||
|
||||
// Verify ERR-TELEMETRY-010 appears in logs
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-010") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-010 in logs when maintenance check fails, got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "Failed to check maintenance mode") {
|
||||
t.Errorf("Expected 'Failed to check maintenance mode' message, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSpan_UptimeSpanQueryError specifically tests ERR-TELEMETRY-011
|
||||
func TestUpdateSpan_UptimeSpanQueryError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Corrupt the database by making it read-only via closing and reopening with bad path
|
||||
db.Close()
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reopen database: %v", err)
|
||||
}
|
||||
// Don't create tables - queries will fail
|
||||
|
||||
// Call updateSpan
|
||||
updateSpan("test-node-span", "test-host", "1.0.0", 50.0, 4096, 8192, 50000, 100000, 0.5, 3600)
|
||||
|
||||
// Verify ERR-TELEMETRY-011 appears in logs
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-011") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-011 in logs when uptime span query fails, got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "Failed to query latest uptime span") {
|
||||
t.Errorf("Expected 'Failed to query latest uptime span' message, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendKumaPush_DatabaseError tests ERR-TELEMETRY-033
|
||||
func TestSendKumaPush_DatabaseError(t *testing.T) {
|
||||
// Setup test database then corrupt it
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
// Don't create tables - this will cause queries to fail
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Create a test server that accepts Kuma pushes
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Call sendKumaPush
|
||||
sendKumaPush(server.URL)
|
||||
|
||||
// Verify ERR-TELEMETRY-033 appears in logs (failed to query last telemetry)
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-033") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-033 in logs when telemetry timestamp query fails, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendKumaPush_NetworkError tests ERR-TELEMETRY-030
|
||||
func TestSendKumaPush_NetworkError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Use an invalid URL that will cause network error
|
||||
invalidURL := "http://localhost:1/invalid" // Port 1 is typically invalid/unused
|
||||
|
||||
// Call sendKumaPush with invalid URL
|
||||
sendKumaPush(invalidURL)
|
||||
|
||||
// Verify ERR-TELEMETRY-030 appears in logs
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-030") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-030 in logs when Kuma push fails, got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "Failed to push health status to Kuma") {
|
||||
t.Errorf("Expected 'Failed to push health status to Kuma' message, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendKumaPush_NonOKStatus tests ERR-TELEMETRY-031
|
||||
func TestSendKumaPush_NonOKStatus(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Create a test server that returns non-OK status
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable) // 503
|
||||
w.Write([]byte("Service Unavailable"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Call sendKumaPush
|
||||
sendKumaPush(server.URL)
|
||||
|
||||
// Verify ERR-TELEMETRY-031 appears in logs
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-031") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-031 in logs when Kuma returns non-OK status, got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "non-OK status 503") {
|
||||
t.Errorf("Expected 'non-OK status 503' message, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendKumaPush_ResponseBodyCloseError tests response body handling
|
||||
func TestSendKumaPush_ResponseBodyCloseError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Create a test server that returns OK
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Call sendKumaPush
|
||||
sendKumaPush(server.URL)
|
||||
|
||||
// Should complete without ERR-TELEMETRY-032 (successful close doesn't log error)
|
||||
logs := logBuffer.String()
|
||||
if strings.Contains(logs, "ERR-TELEMETRY-032") {
|
||||
t.Errorf("Did not expect ERR-TELEMETRY-032 for successful response body close, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarpit_EarlyDisconnect tests that tarpit handles client disconnect gracefully
|
||||
func TestTarpit_EarlyDisconnect(t *testing.T) {
|
||||
// Create a custom ResponseWriter that simulates disconnect after first write
|
||||
disconnectWriter := &disconnectSimulatingWriter{
|
||||
headers: make(http.Header),
|
||||
failAfter: 1, // Fail after first write
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/unknown", nil)
|
||||
|
||||
// Use a goroutine since tarpit blocks
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
tarpit(disconnectWriter, req)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for tarpit to complete (should be quick due to disconnect)
|
||||
select {
|
||||
case <-done:
|
||||
// Expected - tarpit returned early due to disconnect
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("tarpit did not return early on client disconnect")
|
||||
}
|
||||
|
||||
// Verify that at least one write was attempted
|
||||
if !disconnectWriter.written {
|
||||
t.Error("tarpit should have attempted at least one write")
|
||||
}
|
||||
}
|
||||
|
||||
// disconnectSimulatingWriter simulates a client that disconnects after N writes
|
||||
type disconnectSimulatingWriter struct {
|
||||
headers http.Header
|
||||
status int
|
||||
written bool
|
||||
writeCount int
|
||||
failAfter int
|
||||
flushCount int
|
||||
}
|
||||
|
||||
func (m *disconnectSimulatingWriter) Header() http.Header {
|
||||
return m.headers
|
||||
}
|
||||
|
||||
func (m *disconnectSimulatingWriter) Write(p []byte) (int, error) {
|
||||
m.written = true
|
||||
m.writeCount++
|
||||
if m.writeCount >= m.failAfter {
|
||||
return 0, io.ErrClosedPipe // Simulate client disconnect
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (m *disconnectSimulatingWriter) WriteHeader(status int) {
|
||||
m.status = status
|
||||
}
|
||||
|
||||
func (m *disconnectSimulatingWriter) Flush() {
|
||||
m.flushCount++
|
||||
}
|
||||
|
||||
// TestErrorCodes_Unique verifies that all error codes are unique
|
||||
func TestErrorCodes_Unique(t *testing.T) {
|
||||
// Read the main.go and kuma.go files to extract error codes
|
||||
mainContent, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read main.go: %v", err)
|
||||
}
|
||||
kumaContent, err := os.ReadFile("kuma.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read kuma.go: %v", err)
|
||||
}
|
||||
|
||||
content := string(mainContent) + string(kumaContent)
|
||||
|
||||
// Extract all ERR-TELEMETRY-XXX codes
|
||||
codes := make(map[string]int)
|
||||
for i := 0; i < len(content)-15; i++ {
|
||||
if content[i:i+14] == "ERR-TELEMETRY-" {
|
||||
// Find the end of the code (3 digits)
|
||||
end := i + 14
|
||||
for end < len(content) && content[end] >= '0' && content[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end > i+14 {
|
||||
code := content[i:end]
|
||||
codes[code]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each code appears only once (unique)
|
||||
for code, count := range codes {
|
||||
if count > 1 {
|
||||
t.Errorf("Error code %s appears %d times - should be unique", code, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify expected codes exist
|
||||
expectedCodes := []string{
|
||||
"ERR-TELEMETRY-001", // Failed to open operations.db
|
||||
"ERR-TELEMETRY-002", // Failed to load CA chain
|
||||
"ERR-TELEMETRY-003", // Invalid certificate
|
||||
"ERR-TELEMETRY-004", // Failed to insert telemetry
|
||||
"ERR-TELEMETRY-005", // Failed to create telemetry table
|
||||
"ERR-TELEMETRY-006", // Failed to create telemetry index
|
||||
"ERR-TELEMETRY-007", // Failed to create telemetry node_latest index
|
||||
"ERR-TELEMETRY-008", // Failed to create uptime_spans table
|
||||
"ERR-TELEMETRY-009", // Failed to create uptime_spans index
|
||||
"ERR-TELEMETRY-010", // Failed to check maintenance mode
|
||||
"ERR-TELEMETRY-011", // Failed to query latest uptime span
|
||||
"ERR-TELEMETRY-012", // Failed to extend uptime span
|
||||
"ERR-TELEMETRY-013", // Failed to extend early-judgment span
|
||||
"ERR-TELEMETRY-014", // Failed to insert new uptime span
|
||||
"ERR-TELEMETRY-015", // Failed to create maintenance table
|
||||
"ERR-TELEMETRY-020", // Failed to create ntfy alert request
|
||||
"ERR-TELEMETRY-021", // Failed to send ntfy alert
|
||||
"ERR-TELEMETRY-022", // Failed to close ntfy response body
|
||||
"ERR-TELEMETRY-030", // Failed to push health to Kuma
|
||||
"ERR-TELEMETRY-031", // Kuma returned non-OK status
|
||||
"ERR-TELEMETRY-032", // Failed to close Kuma response body (OK status)
|
||||
"ERR-TELEMETRY-033", // Failed to query last telemetry timestamp
|
||||
"ERR-TELEMETRY-034", // Failed to close Kuma response body (non-OK status)
|
||||
"ERR-TELEMETRY-040", // tarpit called without Flusher
|
||||
}
|
||||
|
||||
for _, code := range expectedCodes {
|
||||
if _, exists := codes[code]; !exists {
|
||||
t.Errorf("Expected error code %s not found in source", code)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Found %d unique error codes", len(codes))
|
||||
}
|
||||
|
||||
// TestErrorCodes_Format verifies error codes follow the correct format
|
||||
func TestErrorCodes_Format(t *testing.T) {
|
||||
// Read source files
|
||||
mainContent, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read main.go: %v", err)
|
||||
}
|
||||
kumaContent, err := os.ReadFile("kuma.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read kuma.go: %v", err)
|
||||
}
|
||||
|
||||
content := string(mainContent) + string(kumaContent)
|
||||
|
||||
// Check that error messages follow format: ERR-TELEMETRY-XXX: Actionable message
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "ERR-TELEMETRY-") && strings.Contains(line, "log.") {
|
||||
// Verify format includes colon after error code
|
||||
if !strings.Contains(line, "ERR-TELEMETRY-") || !strings.Contains(line, ":") {
|
||||
t.Errorf("Line %d: Error message should follow format 'ERR-TELEMETRY-XXX: message', got: %s", i+1, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTelemetry_DatabaseInsertError tests ERR-TELEMETRY-004
|
||||
func TestHandleTelemetry_DatabaseInsertError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Temporarily disable mTLS
|
||||
oldCAPool := caPool
|
||||
caPool = nil
|
||||
defer func() { caPool = oldCAPool }()
|
||||
|
||||
// Corrupt database by making it read-only
|
||||
db.Exec(`PRAGMA query_only = ON`)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"node_id": "test-node-db-err",
|
||||
"version": "1.0.0",
|
||||
"hostname": "test-host",
|
||||
"uptime_seconds": 3600,
|
||||
"cpu_percent": 25.5,
|
||||
"memory_total_mb": 8192,
|
||||
"memory_used_mb": 4096,
|
||||
"disk_total_mb": 100000,
|
||||
"disk_used_mb": 50000,
|
||||
"load_1m": 0.5,
|
||||
"vault_count": 5,
|
||||
"vault_size_mb": 10.5,
|
||||
"vault_entries": 100,
|
||||
"mode": "commercial",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/telemetry", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
handleTelemetry(w, req)
|
||||
|
||||
// Restore query_only mode
|
||||
db.Exec(`PRAGMA query_only = OFF`)
|
||||
|
||||
// Should return 500 error
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != 500 {
|
||||
t.Errorf("Expected 500 status for database error, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify ERR-TELEMETRY-004 appears in logs
|
||||
logs := logBuffer.String()
|
||||
if !strings.Contains(logs, "ERR-TELEMETRY-004") {
|
||||
t.Errorf("Expected ERR-TELEMETRY-004 in logs when telemetry insert fails, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentErrorHandling verifies error handling is thread-safe
|
||||
func TestConcurrentErrorHandling(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
defer cleanupTestDB()
|
||||
|
||||
// Capture logs
|
||||
var logBuffer bytes.Buffer
|
||||
originalOutput := log.Writer()
|
||||
log.SetOutput(&logBuffer)
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
// Run multiple goroutines that trigger error handling
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
nodeID := fmt.Sprintf("concurrent-node-%d", id)
|
||||
updateSpan(nodeID, "test-host", "1.0.0", 50.0, 4096, 8192, 50000, 100000, 0.5, 3600)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify no panics occurred and logs were written safely
|
||||
logs := logBuffer.String()
|
||||
// Should have OUTAGE SPAN logs for each node
|
||||
for i := 0; i < 10; i++ {
|
||||
expectedNode := fmt.Sprintf("concurrent-node-%d", i)
|
||||
if !strings.Contains(logs, expectedNode) {
|
||||
t.Errorf("Expected logs to contain node %s", expectedNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ func sendKumaPush(kumaURL string) {
|
|||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("ERR-TELEMETRY-031: Kuma returned non-OK status %d from %s", resp.StatusCode, kumaURL)
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Printf("ERR-TELEMETRY-032: Failed to close Kuma response body after non-OK status - %v", err)
|
||||
log.Printf("ERR-TELEMETRY-034: Failed to close Kuma response body after non-OK status - %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ func routeHandler(w http.ResponseWriter, r *http.Request) {
|
|||
handleTelemetry(w, r)
|
||||
case "/health":
|
||||
handleHealth(w, r)
|
||||
case "/metrics":
|
||||
handleMetrics(w, r)
|
||||
default:
|
||||
tarpit(w, r)
|
||||
}
|
||||
|
|
@ -202,28 +204,33 @@ func ensureTables() {
|
|||
started_by TEXT NOT NULL DEFAULT '',
|
||||
ended_by TEXT NOT NULL DEFAULT ''
|
||||
)`); err != nil {
|
||||
log.Fatalf("ERR-TELEMETRY-010: Failed to create maintenance table - %v", err)
|
||||
log.Fatalf("ERR-TELEMETRY-015: Failed to create maintenance table - %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
// Check DB writable
|
||||
// Check DB writable with timing
|
||||
dbStart := time.Now()
|
||||
var one int
|
||||
err := db.QueryRow("SELECT 1").Scan(&one)
|
||||
dbDuration := time.Since(dbStart)
|
||||
RecordDBQueryDuration(dbDuration)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"status":"error","db":"unavailable"}`, 503)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check recent telemetry (any source)
|
||||
var lastBeat int64
|
||||
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","db":"ok","last_telemetry":%d}`, lastBeat)
|
||||
}
|
||||
|
||||
func handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
|
|
@ -337,6 +344,11 @@ func handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||
// Uptime span tracking
|
||||
updateSpan(t.NodeID, t.Hostname, t.Version, t.CPUPercent, t.MemUsedMB, t.MemTotalMB, t.DiskUsedMB, t.DiskTotalMB, t.Load1m, t.UptimeSeconds)
|
||||
|
||||
// Record metrics
|
||||
duration := time.Since(start)
|
||||
RecordRequestDuration(duration)
|
||||
RecordRequest(t.NodeID, "200")
|
||||
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -336,6 +337,215 @@ func TestEnsureTables(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Metrics tests
|
||||
func TestHandleMetrics(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handleMetrics(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("handleMetrics status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/plain") {
|
||||
t.Errorf("handleMetrics content-type = %s, want text/plain", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check for expected metric names
|
||||
expectedMetrics := []string{
|
||||
"# HELP telemetry_requests_total",
|
||||
"# TYPE telemetry_requests_total counter",
|
||||
"# HELP telemetry_request_duration_seconds",
|
||||
"# TYPE telemetry_request_duration_seconds histogram",
|
||||
"# HELP active_connections",
|
||||
"# TYPE active_connections gauge",
|
||||
"# HELP db_query_duration_seconds",
|
||||
"# TYPE db_query_duration_seconds histogram",
|
||||
}
|
||||
|
||||
for _, metric := range expectedMetrics {
|
||||
if !strings.Contains(body, metric) {
|
||||
t.Errorf("handleMetrics response missing: %s", metric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics_MethodNotAllowed(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handleMetrics(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != 405 {
|
||||
t.Errorf("handleMetrics POST status = %d, want 405", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRequest(t *testing.T) {
|
||||
// Clear any existing metrics
|
||||
requestsTotalMu.Lock()
|
||||
requestsTotal = make(map[string]uint64)
|
||||
requestsTotalMu.Unlock()
|
||||
|
||||
// Record some requests
|
||||
RecordRequest("pop-zrh-1", "200")
|
||||
RecordRequest("pop-zrh-1", "200")
|
||||
RecordRequest("pop-zrh-1", "500")
|
||||
RecordRequest("pop-nyc-1", "200")
|
||||
|
||||
// Verify counts
|
||||
requestsTotalMu.RLock()
|
||||
if requestsTotal["pop-zrh-1:200"] != 2 {
|
||||
t.Errorf("pop-zrh-1:200 count = %d, want 2", requestsTotal["pop-zrh-1:200"])
|
||||
}
|
||||
if requestsTotal["pop-zrh-1:500"] != 1 {
|
||||
t.Errorf("pop-zrh-1:500 count = %d, want 1", requestsTotal["pop-zrh-1:500"])
|
||||
}
|
||||
if requestsTotal["pop-nyc-1:200"] != 1 {
|
||||
t.Errorf("pop-nyc-1:200 count = %d, want 1", requestsTotal["pop-nyc-1:200"])
|
||||
}
|
||||
requestsTotalMu.RUnlock()
|
||||
}
|
||||
|
||||
func TestRecordRequestDuration(t *testing.T) {
|
||||
// Reset histogram
|
||||
reqDurationMu.Lock()
|
||||
reqDurationCount = 0
|
||||
reqDurationSum = 0
|
||||
for _, b := range histogramBuckets {
|
||||
reqDurationBuckets[b] = 0
|
||||
}
|
||||
reqDurationMu.Unlock()
|
||||
|
||||
// Record durations
|
||||
RecordRequestDuration(50 * time.Millisecond)
|
||||
RecordRequestDuration(150 * time.Millisecond)
|
||||
RecordRequestDuration(2 * time.Second)
|
||||
|
||||
// Verify
|
||||
count := atomic.LoadUint64(&reqDurationCount)
|
||||
if count != 3 {
|
||||
t.Errorf("reqDurationCount = %d, want 3", count)
|
||||
}
|
||||
|
||||
reqDurationMu.RLock()
|
||||
if reqDurationSum < 2.0 || reqDurationSum > 2.5 {
|
||||
t.Errorf("reqDurationSum = %f, expected around 2.2", reqDurationSum)
|
||||
}
|
||||
// 50ms should be in all buckets >= 0.05 (cumulative histogram)
|
||||
// Buckets: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
|
||||
// 50ms falls into: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
|
||||
if reqDurationBuckets[0.05] != 1 {
|
||||
t.Errorf("bucket 0.05 = %d, want 1", reqDurationBuckets[0.05])
|
||||
}
|
||||
// 2s falls into buckets >= 2.5: 2.5, 5, 10 (cumulative - includes all 3 observations)
|
||||
// All 3 observations (50ms, 150ms, 2s) fall into buckets >= 2.5
|
||||
if reqDurationBuckets[2.5] != 3 {
|
||||
t.Errorf("bucket 2.5 = %d, want 3", reqDurationBuckets[2.5])
|
||||
}
|
||||
reqDurationMu.RUnlock()
|
||||
}
|
||||
|
||||
func TestRecordDBQueryDuration(t *testing.T) {
|
||||
// Reset histogram
|
||||
dbDurationMu.Lock()
|
||||
dbDurationCount = 0
|
||||
dbDurationSum = 0
|
||||
for _, b := range histogramBuckets {
|
||||
dbDurationBuckets[b] = 0
|
||||
}
|
||||
dbDurationMu.Unlock()
|
||||
|
||||
// Record durations
|
||||
RecordDBQueryDuration(5 * time.Millisecond)
|
||||
RecordDBQueryDuration(25 * time.Millisecond)
|
||||
|
||||
// Verify
|
||||
count := atomic.LoadUint64(&dbDurationCount)
|
||||
if count != 2 {
|
||||
t.Errorf("dbDurationCount = %d, want 2", count)
|
||||
}
|
||||
|
||||
dbDurationMu.RLock()
|
||||
if dbDurationBuckets[0.05] != 2 {
|
||||
t.Errorf("db bucket 0.05 = %d, want 2", dbDurationBuckets[0.05])
|
||||
}
|
||||
dbDurationMu.RUnlock()
|
||||
}
|
||||
|
||||
func TestActiveConnections(t *testing.T) {
|
||||
// Reset
|
||||
atomic.StoreInt64(&activeConnections, 0)
|
||||
|
||||
// Test increment/decrement
|
||||
IncrementActiveConnections()
|
||||
IncrementActiveConnections()
|
||||
if GetActiveConnections() != 2 {
|
||||
t.Errorf("activeConnections = %d, want 2", GetActiveConnections())
|
||||
}
|
||||
|
||||
DecrementActiveConnections()
|
||||
if GetActiveConnections() != 1 {
|
||||
t.Errorf("activeConnections = %d, want 1", GetActiveConnections())
|
||||
}
|
||||
|
||||
DecrementActiveConnections()
|
||||
if GetActiveConnections() != 0 {
|
||||
t.Errorf("activeConnections = %d, want 0", GetActiveConnections())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLast(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
sep string
|
||||
expected []string
|
||||
}{
|
||||
{"pop-zrh-1:200", ":", []string{"pop-zrh-1", "200"}},
|
||||
{"pop-zrh-1:status:200", ":", []string{"pop-zrh-1:status", "200"}},
|
||||
{"no-separator", ":", []string{"no-separator"}},
|
||||
{"", ":", []string{""}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := splitLast(tt.input, tt.sep)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("splitLast(%q, %q) = %v, want %v", tt.input, tt.sep, result, tt.expected)
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("splitLast(%q, %q)[%d] = %q, want %q", tt.input, tt.sep, i, result[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{0.005, "0.005"},
|
||||
{1.0, "1"},
|
||||
{2.5, "2.5"},
|
||||
{0.1, "0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatFloat(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatFloat(%f) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test that mTLS enforcement works
|
||||
type mockResponseWriter struct {
|
||||
headers http.Header
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
//go:build commercial
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Prometheus-style metrics for telemetry service
|
||||
// Following KISS principle - no external dependencies, simple text format
|
||||
|
||||
var (
|
||||
// Counters: telemetry_requests_total{pop_id, status}
|
||||
requestsTotalMu sync.RWMutex
|
||||
requestsTotal = make(map[string]uint64) // key: "pop_id:status"
|
||||
|
||||
// Gauge: active_connections
|
||||
activeConnections int64
|
||||
|
||||
// Histogram: telemetry_request_duration_seconds
|
||||
reqDurationMu sync.RWMutex
|
||||
reqDurationCount uint64
|
||||
reqDurationSum float64
|
||||
reqDurationBuckets = make(map[float64]uint64)
|
||||
|
||||
// Histogram: db_query_duration_seconds
|
||||
dbDurationMu sync.RWMutex
|
||||
dbDurationCount uint64
|
||||
dbDurationSum float64
|
||||
dbDurationBuckets = make(map[float64]uint64)
|
||||
)
|
||||
|
||||
// Standard Prometheus histogram buckets
|
||||
var histogramBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||
|
||||
func init() {
|
||||
// Initialize bucket counters
|
||||
for _, b := range histogramBuckets {
|
||||
reqDurationBuckets[b] = 0
|
||||
dbDurationBuckets[b] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest increments the request counter for a given POP and status
|
||||
func RecordRequest(popID, status string) {
|
||||
key := popID + ":" + status
|
||||
requestsTotalMu.Lock()
|
||||
requestsTotal[key]++
|
||||
requestsTotalMu.Unlock()
|
||||
}
|
||||
|
||||
// RecordRequestDuration records a request duration observation
|
||||
func RecordRequestDuration(duration time.Duration) {
|
||||
seconds := duration.Seconds()
|
||||
atomic.AddUint64(&reqDurationCount, 1)
|
||||
|
||||
reqDurationMu.Lock()
|
||||
reqDurationSum += seconds
|
||||
for _, b := range histogramBuckets {
|
||||
if seconds <= b {
|
||||
reqDurationBuckets[b]++
|
||||
}
|
||||
}
|
||||
reqDurationMu.Unlock()
|
||||
}
|
||||
|
||||
// RecordDBQueryDuration records a database query duration observation
|
||||
func RecordDBQueryDuration(duration time.Duration) {
|
||||
seconds := duration.Seconds()
|
||||
atomic.AddUint64(&dbDurationCount, 1)
|
||||
|
||||
dbDurationMu.Lock()
|
||||
dbDurationSum += seconds
|
||||
for _, b := range histogramBuckets {
|
||||
if seconds <= b {
|
||||
dbDurationBuckets[b]++
|
||||
}
|
||||
}
|
||||
dbDurationMu.Unlock()
|
||||
}
|
||||
|
||||
// IncrementActiveConnections increments the active connections gauge
|
||||
func IncrementActiveConnections() {
|
||||
atomic.AddInt64(&activeConnections, 1)
|
||||
}
|
||||
|
||||
// DecrementActiveConnections decrements the active connections gauge
|
||||
func DecrementActiveConnections() {
|
||||
atomic.AddInt64(&activeConnections, -1)
|
||||
}
|
||||
|
||||
// GetActiveConnections returns the current active connections count
|
||||
func GetActiveConnections() int64 {
|
||||
return atomic.LoadInt64(&activeConnections)
|
||||
}
|
||||
|
||||
// handleMetrics serves Prometheus-format metrics
|
||||
func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// telemetry_requests_total counter
|
||||
output.WriteString("# HELP telemetry_requests_total Total number of telemetry requests\n")
|
||||
output.WriteString("# TYPE telemetry_requests_total counter\n")
|
||||
requestsTotalMu.RLock()
|
||||
for key, count := range requestsTotal {
|
||||
parts := splitLast(key, ":")
|
||||
if len(parts) == 2 {
|
||||
output.WriteString(fmt.Sprintf("telemetry_requests_total{pop_id=\"%s\",status=\"%s\"} %d\n", parts[0], parts[1], count))
|
||||
}
|
||||
}
|
||||
requestsTotalMu.RUnlock()
|
||||
output.WriteString("\n")
|
||||
|
||||
// telemetry_request_duration_seconds histogram
|
||||
output.WriteString("# HELP telemetry_request_duration_seconds Request duration in seconds\n")
|
||||
output.WriteString("# TYPE telemetry_request_duration_seconds histogram\n")
|
||||
|
||||
reqCount := atomic.LoadUint64(&reqDurationCount)
|
||||
|
||||
reqDurationMu.RLock()
|
||||
for _, b := range histogramBuckets {
|
||||
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_bucket{le=\"%s\"} %d\n", formatFloat(b), reqDurationBuckets[b]))
|
||||
}
|
||||
reqSum := reqDurationSum
|
||||
reqDurationMu.RUnlock()
|
||||
|
||||
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", reqCount))
|
||||
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_count %d\n", reqCount))
|
||||
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_sum %s\n", formatFloat(reqSum)))
|
||||
output.WriteString("\n")
|
||||
|
||||
// active_connections gauge
|
||||
output.WriteString("# HELP active_connections Current number of active connections\n")
|
||||
output.WriteString("# TYPE active_connections gauge\n")
|
||||
output.WriteString(fmt.Sprintf("active_connections %d\n", GetActiveConnections()))
|
||||
output.WriteString("\n")
|
||||
|
||||
// db_query_duration_seconds histogram
|
||||
output.WriteString("# HELP db_query_duration_seconds Database query duration in seconds\n")
|
||||
output.WriteString("# TYPE db_query_duration_seconds histogram\n")
|
||||
|
||||
dbCount := atomic.LoadUint64(&dbDurationCount)
|
||||
|
||||
dbDurationMu.RLock()
|
||||
for _, b := range histogramBuckets {
|
||||
output.WriteString(fmt.Sprintf("db_query_duration_seconds_bucket{le=\"%s\"} %d\n", formatFloat(b), dbDurationBuckets[b]))
|
||||
}
|
||||
dbSum := dbDurationSum
|
||||
dbDurationMu.RUnlock()
|
||||
|
||||
output.WriteString(fmt.Sprintf("db_query_duration_seconds_bucket{le=\"+Inf\"} %d\n", dbCount))
|
||||
output.WriteString(fmt.Sprintf("db_query_duration_seconds_count %d\n", dbCount))
|
||||
output.WriteString(fmt.Sprintf("db_query_duration_seconds_sum %s\n", formatFloat(dbSum)))
|
||||
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(output.String()))
|
||||
}
|
||||
|
||||
// splitLast splits a string on the last occurrence of sep
|
||||
func splitLast(s, sep string) []string {
|
||||
idx := strings.LastIndex(s, sep)
|
||||
if idx == -1 {
|
||||
return []string{s}
|
||||
}
|
||||
return []string{s[:idx], s[idx+len(sep):]}
|
||||
}
|
||||
|
||||
// formatFloat formats a float without scientific notation
|
||||
func formatFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
|
@ -122,6 +122,37 @@ func (c *tc) reqNoAuth(method, path string, body any) *http.Response {
|
|||
return resp
|
||||
}
|
||||
|
||||
// reqAgent sends a request with CVT wire token authentication (agent).
|
||||
// The wireToken is a type 0x00 CVT token containing L1 + agent_id.
|
||||
func (c *tc) reqAgent(method, path string, body any, wireToken string) *http.Response {
|
||||
c.t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.srv.URL+path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+wireToken)
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatalf("reqAgent %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// mintWireToken creates a CVT wire token (type 0x00) for agent authentication.
|
||||
func (c *tc) mintWireToken(agentID []byte) string {
|
||||
c.t.Helper()
|
||||
token, err := lib.MintWireToken(fakeL0(), fakeL1(), agentID)
|
||||
if err != nil {
|
||||
c.t.Fatalf("mintWireToken: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// must asserts status code and returns parsed JSON object.
|
||||
func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
|
||||
c.t.Helper()
|
||||
|
|
@ -323,29 +354,195 @@ func TestAuditLog(t *testing.T) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) {
|
||||
// AGENT CREDENTIALS NOW GENERATED CLIENT-SIDE
|
||||
// Tests requiring agent tokens need client-side credential generation
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries with different scopes
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Work Entry", "user", "pass", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Personal Entry", "user", "pass", nil)), 201)
|
||||
|
||||
// Create agent with specific scope (work only)
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Work Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{"work"},
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with work scope should see entries (scope filtering happens at data level)
|
||||
// With empty scope_whitelist, agent sees nothing
|
||||
// With ["work"], agent sees work entries
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
// Agent with work scope should see entries (default scope is "0000" which is owner-only)
|
||||
// Since entries have no explicit scope, they default to "0000" (owner-only)
|
||||
// So agent should see 0 entries
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("agent with work scope should see 0 owner-scoped entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create an entry
|
||||
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Test Entry", "user", "pass", nil)), 201)
|
||||
entryID := entry["entry_id"].(string)
|
||||
|
||||
// Create agent with no scope whitelist (cannot access owner-only entries)
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Limited Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{}, // Empty whitelist
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to access owner-only entry
|
||||
resp := c.reqAgent("GET", "/api/entries/"+entryID, nil, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent without scope access should get 403, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_all_access_sees_everything(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user", "pass", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user", "pass", nil)), 201)
|
||||
|
||||
// Create agent with all_access flag
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "All Access Agent",
|
||||
"all_access": true,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with all_access should see all entries
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("all_access agent should see 2 entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Regular Agent",
|
||||
"all_access": false,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to create another agent
|
||||
resp := c.reqAgent("POST", "/api/agents", map[string]any{
|
||||
"name": "New Agent",
|
||||
"all_access": false,
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create agents, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Agent should get 403 when trying to list agents
|
||||
resp = c.reqAgent("GET", "/api/agents", nil, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to list agents, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) {
|
||||
// Agents cannot create entries with system types (agent, scope)
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Regular Agent",
|
||||
"all_access": false,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to create entry with type=agent
|
||||
resp := c.reqAgent("POST", "/api/entries", map[string]any{
|
||||
"title": "Fake Agent",
|
||||
"type": "agent",
|
||||
"data": map[string]any{
|
||||
"title": "Fake Agent",
|
||||
"type": "agent",
|
||||
},
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create agent-type entries, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Agent should get 403 when trying to create entry with type=scope
|
||||
resp = c.reqAgent("POST", "/api/entries", map[string]any{
|
||||
"title": "Fake Scope",
|
||||
"type": "scope",
|
||||
"data": map[string]any{
|
||||
"title": "Fake Scope",
|
||||
"type": "scope",
|
||||
},
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create scope-type entries, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_entries_invisible(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user1", "pass1", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user2", "pass2", nil)), 201)
|
||||
|
||||
// Create agent with limited scope
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Limited Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{"work"},
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with work scope should not see owner-only entries (default scope "0000")
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
// Entries default to "0000" scope (owner-only), so agent sees nothing
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("agent with work scope should see 0 owner-only entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -424,9 +621,32 @@ func TestKeyLeak_L3_never_appears(t *testing.T) {
|
|||
|
||||
func TestKeyLeak_agent_credential_is_opaque(t *testing.T) {
|
||||
// CREDENTIAL GENERATION IS NOW CLIENT-SIDE
|
||||
// Server returns agent_id, web UI generates credential using crypto.js
|
||||
// This test is obsolete - credential opacity is ensured by client-side implementation
|
||||
t.Skip("Credential generation moved to client-side - server no longer returns credential")
|
||||
// Server returns agent_id only, web UI generates credential using crypto.js
|
||||
// This test verifies that the server response contains no key material
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent - server returns only agent_id, no credential
|
||||
body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Leak Check Agent",
|
||||
}), 201)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify response contains agent_id but no credential token
|
||||
if !strings.Contains(bodyStr, "agent_id") {
|
||||
t.Fatal("agent creation response should contain agent_id")
|
||||
}
|
||||
|
||||
// Response should NOT contain cvt_ prefix (credential tokens start with cvt_)
|
||||
if strings.Contains(bodyStr, "cvt_") {
|
||||
t.Fatal("agent creation response should not contain cvt_ credential (now client-side)")
|
||||
}
|
||||
|
||||
// Verify no raw L2 in response
|
||||
l2Hex := hex.EncodeToString(fakeL2())
|
||||
if strings.Contains(bodyStr, l2Hex) {
|
||||
t.Fatal("L2 key (hex) found in agent creation response")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import (
|
|||
// (clavis-cli/src/cvt.c) and never decrypted by Go code — L2 is a hard veto
|
||||
// for the server.
|
||||
const (
|
||||
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
|
||||
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
|
||||
CVTCredentialType byte = 0x01 // Client credential: L2(16) + agent_id(16) + POP(4)
|
||||
)
|
||||
|
||||
const cvtPrefix = "cvt_"
|
||||
|
|
@ -68,6 +69,36 @@ func ParseWireToken(token string) (l0, l1, agentID []byte, err error) {
|
|||
return l0, payload[0:8], payload[8:24], nil
|
||||
}
|
||||
|
||||
// MintCredential creates a type 0x01 client credential token (for testing).
|
||||
// This simulates client-side credential generation that normally happens in browser/CLI.
|
||||
// Payload: L2(16) + agent_id(16) + POP(4) = 36 bytes, encrypted with L0.
|
||||
func MintCredential(l0, l2, agentID, pop []byte) (string, error) {
|
||||
if len(l0) != 4 || len(l2) != 16 || len(agentID) != 16 || len(pop) != 4 {
|
||||
return "", fmt.Errorf("bad lengths: l0=%d l2=%d agent_id=%d pop=%d", len(l0), len(l2), len(agentID), len(pop))
|
||||
}
|
||||
payload := make([]byte, 36)
|
||||
copy(payload[0:16], l2)
|
||||
copy(payload[16:32], agentID)
|
||||
copy(payload[32:36], pop)
|
||||
return cvtEncode(CVTCredentialType, l0, payload)
|
||||
}
|
||||
|
||||
// ParseCredential decrypts a type 0x01 client credential token (for testing).
|
||||
// Returns L0 (4 bytes), L2 (16 bytes), agent_id (16 bytes), and POP (4 bytes).
|
||||
func ParseCredential(token string) (l0, l2, agentID, pop []byte, err error) {
|
||||
typ, l0, payload, err := cvtDecode(token)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
if typ != CVTCredentialType {
|
||||
return nil, nil, nil, nil, ErrCVTBadType
|
||||
}
|
||||
if len(payload) != 36 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("credential payload: got %d bytes, want 36", len(payload))
|
||||
}
|
||||
return l0, payload[0:16], payload[16:32], payload[32:36], nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CVT envelope: type(1) + L0(4) + AES-GCM(derived(L0), payload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -59,3 +59,65 @@ func TestCVT_unique(t *testing.T) {
|
|||
t.Fatal("two tokens with same input should differ (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test client credential (type 0x01) - used for agent testing
|
||||
func TestMintCredential_roundtrip(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xAB}, 16) // 16 bytes test key
|
||||
agentID := make([]byte, 16)
|
||||
for i := range agentID {
|
||||
agentID[i] = byte(0x40 + i)
|
||||
}
|
||||
pop := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
token, err := MintCredential(l0, l2, agentID, pop)
|
||||
if err != nil {
|
||||
t.Fatalf("MintCredential: %v", err)
|
||||
}
|
||||
|
||||
gotL0, gotL2, gotAgentID, gotPOP, err := ParseCredential(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCredential: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotL0, l0) {
|
||||
t.Fatalf("L0 mismatch: got %x, want %x", gotL0, l0)
|
||||
}
|
||||
if !bytes.Equal(gotL2, l2) {
|
||||
t.Fatalf("L2 mismatch: got %x, want %x", gotL2, l2)
|
||||
}
|
||||
if !bytes.Equal(gotAgentID, agentID) {
|
||||
t.Fatalf("agent_id mismatch: got %x, want %x", gotAgentID, agentID)
|
||||
}
|
||||
if !bytes.Equal(gotPOP, pop) {
|
||||
t.Fatalf("POP mismatch: got %x, want %x", gotPOP, pop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCredential_unique(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xCD}, 16)
|
||||
agentID := make([]byte, 16)
|
||||
pop := []byte{0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
t1, _ := MintCredential(l0, l2, agentID, pop)
|
||||
t2, _ := MintCredential(l0, l2, agentID, pop)
|
||||
if t1 == t2 {
|
||||
t.Fatal("two credential tokens with same input should differ (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCredential_tamper_detection(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xEF}, 16)
|
||||
agentID := make([]byte, 16)
|
||||
pop := []byte{0xFF, 0xFF, 0xFF, 0xFF}
|
||||
|
||||
token, _ := MintCredential(l0, l2, agentID, pop)
|
||||
|
||||
// Flip a character in the middle
|
||||
tampered := token[:10] + "X" + token[11:]
|
||||
_, _, _, _, err := ParseCredential(tampered)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on tampered credential token")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,35 @@ code { font-size: 0.875em; }
|
|||
.nav-dropdown-item { display: block; padding: 6px 16px; font-size: 0.825rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; }
|
||||
.nav-dropdown-item:hover { color: var(--text); background: var(--surface); }
|
||||
|
||||
/* === SPLIT LANGUAGE/CURRENCY SELECTORS === */
|
||||
.nav-dropdown--language,
|
||||
.nav-dropdown--currency { display: inline-block; }
|
||||
.nav-dropdown--language .nav-dropdown-trigger,
|
||||
.nav-dropdown--currency .nav-dropdown-trigger { min-width: 60px; justify-content: center; }
|
||||
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 8px; }
|
||||
@media (max-width: 768px) {
|
||||
.nav-dropdown--language,
|
||||
.nav-dropdown--currency { display: block; width: 100%; }
|
||||
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 0; margin-top: 0.5rem; }
|
||||
.nav-dropdown--language .nav-dropdown-menu,
|
||||
.nav-dropdown--currency .nav-dropdown-menu { position: static; right: auto; left: auto; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
|
||||
}
|
||||
|
||||
/* === CURRENCY DROPDOWN SECTIONS === */
|
||||
.dropdown-section {
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dropdown-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* === BUTTONS === */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--font-sans); font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; transition: all 100ms ease; text-align: center; text-decoration: none; }
|
||||
.btn-primary { background: var(--brand-black); color: #ffffff; border-color: var(--brand-black); }
|
||||
|
|
|
|||
|
|
@ -1104,6 +1104,76 @@ Entries:
|
|||
w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
|
||||
// Currencies API — returns top 10 + all currencies for pricing display
|
||||
http.HandleFunc("/api/currencies", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if corpDB == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "ERR-ADMIN-001: Currency data unavailable — corporate DB not connected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Top currencies to display first
|
||||
topCodes := []string{"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "SEK", "NOK", "NZD"}
|
||||
topMap := make(map[string]bool)
|
||||
for _, code := range topCodes {
|
||||
topMap[code] = true
|
||||
}
|
||||
|
||||
rows, err := corpDB.Query(`SELECT code, name, symbol, symbol_position FROM currencies WHERE is_active = 1 ORDER BY code`)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "ERR-ADMIN-002: Failed to query currencies — " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Currency struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Symbol string `json:"symbol"`
|
||||
SymbolPosition string `json:"symbol_position"`
|
||||
}
|
||||
|
||||
var top []Currency
|
||||
var all []Currency
|
||||
|
||||
for rows.Next() {
|
||||
var c Currency
|
||||
if err := rows.Scan(&c.Code, &c.Name, &c.Symbol, &c.SymbolPosition); err != nil {
|
||||
log.Printf("ERR-ADMIN-003: Failed to scan currency row - %v", err)
|
||||
continue
|
||||
}
|
||||
if topMap[c.Code] {
|
||||
top = append(top, c)
|
||||
} else {
|
||||
all = append(all, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure top currencies are in the defined order
|
||||
orderedTop := make([]Currency, 0, len(topCodes))
|
||||
for _, code := range topCodes {
|
||||
for _, c := range top {
|
||||
if c.Code == code {
|
||||
orderedTop = append(orderedTop, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"top": orderedTop,
|
||||
"all": all,
|
||||
})
|
||||
})
|
||||
|
||||
// NOC API — latest telemetry per node
|
||||
nocPin := func(r *http.Request) bool { return r.URL.Query().Get("pin") == "250365" }
|
||||
|
||||
|
|
|
|||
|
|
@ -104,23 +104,24 @@
|
|||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
|
||||
<div class="nav-dropdown nav-dropdown--locale">
|
||||
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
|
||||
<div class="nav-dropdown nav-dropdown--language">
|
||||
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right">
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
|
||||
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
|
||||
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
|
||||
<a href="/fr" class="nav-dropdown-item" data-lang="fr">🇫🇷 Français</a>
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="CHF">CHF</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="GBP">GBP £</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown nav-dropdown--currency">
|
||||
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
|
||||
<!-- Currency options loaded dynamically from /api/currencies -->
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -173,61 +174,140 @@
|
|||
<script>
|
||||
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
|
||||
|
||||
// Locale selector
|
||||
// Language selector state management
|
||||
(function() {
|
||||
const localeTrigger = document.getElementById('localeTrigger');
|
||||
if (!localeTrigger) return;
|
||||
|
||||
const dropdown = localeTrigger.parentElement;
|
||||
const langTrigger = document.getElementById('languageTrigger');
|
||||
if (!langTrigger) return;
|
||||
|
||||
const dropdown = langTrigger.closest('.nav-dropdown--language');
|
||||
const langItems = dropdown.querySelectorAll('[data-lang]');
|
||||
const currencyItems = dropdown.querySelectorAll('[data-currency]');
|
||||
|
||||
// Load saved preferences
|
||||
const saved = JSON.parse(localStorage.getItem('clavitor-locale') || '{}');
|
||||
const currentLang = saved.lang || 'en';
|
||||
const currentCurrency = saved.currency || 'USD';
|
||||
|
||||
function updateDisplay() {
|
||||
const lang = dropdown.querySelector('[data-lang].active')?.dataset.lang || currentLang;
|
||||
const currency = dropdown.querySelector('[data-currency].active')?.dataset.currency || currentCurrency;
|
||||
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
|
||||
localeTrigger.textContent = `${langFlags[lang] || '🌐'} ${lang.toUpperCase()} / ${currency}`;
|
||||
}
|
||||
|
||||
// Set initial active states
|
||||
|
||||
// Load saved preference
|
||||
const savedLang = localStorage.getItem('preferredLanguage') || 'en';
|
||||
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
|
||||
|
||||
// Set initial active state
|
||||
langItems.forEach(el => {
|
||||
if (el.dataset.lang === currentLang) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
if (el.dataset.lang === savedLang) {
|
||||
el.classList.add('active');
|
||||
langTrigger.textContent = langFlags[savedLang] + ' ' + savedLang.toUpperCase();
|
||||
} else {
|
||||
el.classList.remove('active');
|
||||
}
|
||||
});
|
||||
currencyItems.forEach(el => {
|
||||
if (el.dataset.currency === currentCurrency) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
});
|
||||
updateDisplay();
|
||||
|
||||
|
||||
// Handle language selection
|
||||
langItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const lang = el.dataset.lang;
|
||||
const flag = el.textContent.trim().split(' ')[0];
|
||||
|
||||
langTrigger.textContent = flag + ' ' + lang.toUpperCase();
|
||||
langItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: el.dataset.lang, currency: currentCurrency }));
|
||||
updateDisplay();
|
||||
localStorage.setItem('preferredLanguage', lang);
|
||||
|
||||
// Navigate to language path
|
||||
if (el.dataset.lang === 'en') window.location.href = '/';
|
||||
else window.location.href = '/' + el.dataset.lang;
|
||||
}));
|
||||
|
||||
// Handle currency selection
|
||||
currencyItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
currencyItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: currentLang, currency: el.dataset.currency }));
|
||||
updateDisplay();
|
||||
// Refresh page to apply currency (or fetch rates via JS)
|
||||
window.location.reload();
|
||||
if (lang === 'en') window.location.href = '/';
|
||||
else window.location.href = '/' + lang;
|
||||
}));
|
||||
})();
|
||||
|
||||
// Currency selector - fetch from API and render with sections
|
||||
(function() {
|
||||
const currencyTrigger = document.getElementById('currencyTrigger');
|
||||
if (!currencyTrigger) return;
|
||||
|
||||
async function loadCurrencies() {
|
||||
const menu = document.getElementById('currencyMenu');
|
||||
if (!menu) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/currencies');
|
||||
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
|
||||
const data = await response.json();
|
||||
|
||||
// Clear existing content
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Render "Popular" section
|
||||
if (data.top && data.top.length > 0) {
|
||||
const popularHeader = document.createElement('div');
|
||||
popularHeader.className = 'dropdown-section';
|
||||
popularHeader.textContent = 'Popular';
|
||||
menu.appendChild(popularHeader);
|
||||
|
||||
data.top.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, currencyTrigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Divider between sections
|
||||
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
menu.appendChild(divider);
|
||||
}
|
||||
|
||||
// Render "All Currencies" section
|
||||
if (data.all && data.all.length > 0) {
|
||||
const allHeader = document.createElement('div');
|
||||
allHeader.className = 'dropdown-section';
|
||||
allHeader.textContent = 'All Currencies';
|
||||
menu.appendChild(allHeader);
|
||||
|
||||
data.all.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, currencyTrigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// ERR-CURRENCY-002: API unavailable - keep default fallback options
|
||||
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
|
||||
}
|
||||
}
|
||||
|
||||
function createCurrencyItem(currency, trigger) {
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'nav-dropdown-item';
|
||||
item.setAttribute('data-currency', currency.code);
|
||||
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
|
||||
// Set active state based on current selection or saved preference
|
||||
const savedCurrency = localStorage.getItem('preferredCurrency') || 'USD';
|
||||
const currentText = trigger.textContent.trim();
|
||||
if (currentText.includes(currency.code) || savedCurrency === currency.code) {
|
||||
item.classList.add('active');
|
||||
if (savedCurrency === currency.code) {
|
||||
trigger.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
}
|
||||
}
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const code = item.getAttribute('data-currency');
|
||||
const symbol = currency.symbol || '$';
|
||||
trigger.textContent = symbol + ' ' + code;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('preferredCurrency', code);
|
||||
|
||||
// Refresh page to apply currency (or fetch rates via JS)
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Load currencies on page load
|
||||
loadCurrencies();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="hero container">
|
||||
<p class="label accent mb-4"><span class="vaultname">clavitor</span> hosted</p>
|
||||
<h1>Zero cache. Every request hits the vault.</h1>
|
||||
<p class="lead">Clavitor never caches credentials — not in memory, not on disk, not anywhere. Every request is a fresh decrypt from the vault. That's the security model. To make it fast, we run {{len .Pops}} regions across every continent. Your data lives where you choose. <s>$20</s> $12/yr.</p>
|
||||
<p class="lead">Clavitor never caches credentials — not in memory, not on disk, not anywhere. Every request is a fresh decrypt from the vault. That's the security model. To make it fast, we run {{len .Pops}} regions across every continent. Your data lives where you choose. $12/yr.</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
<!-- CTA -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Ready?</h2>
|
||||
<p class="lead mb-6"><s>$20</s> $12/yr. 7-day money-back. Every feature included. <strong>Price for life</strong> — your rate never increases.</p>
|
||||
<p class="lead mb-6">$12/yr. 7-day money-back. Every feature included. <strong>Price for life</strong> — your rate never increases.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/signup" class="btn btn-primary">Get started</a>
|
||||
<a href="/pricing" class="btn btn-ghost">Compare plans →</a>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<h1 class="mb-6">"If you want to keep a secret, you must also hide it from yourself."</h1>
|
||||
<p class="lead mb-6">We did. Your Identity Encryption key is derived in your browser from your WebAuthn authenticator — fingerprint, face, or hardware key. Our servers have never seen it. They could not decrypt your private fields even if they wanted to. Or anybody else.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
<a href="/install" class="btn btn-ghost">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -439,7 +439,7 @@ ghp_a3f8...</pre>
|
|||
<h2 class="mb-4">Your vault needs to be everywhere you are.</h2>
|
||||
<p class="lead mb-3">A password manager that only works on your home network isn't a password manager. Your laptop moves. Your phone moves. Your browser extension needs your vault at the coffee shop, on the plane, at the client's office.</p>
|
||||
<p class="mb-3">Self-hosting that means a server with a public IP, DNS, TLS certificates, uptime monitoring, and backups. That's not a weekend project — that's infrastructure.</p>
|
||||
<p class="mb-8">We run <span class="vaultname">clavitor</span> across {{len .Pops}} regions on every continent. <s>$20</s> $12/yr. Your Identity Encryption keys never leave your browser — we mathematically cannot read your private fields.</p>
|
||||
<p class="mb-8">We run <span class="vaultname">clavitor</span> across {{len .Pops}} regions on every continent. $12/yr. Your Identity Encryption keys never leave your browser — we mathematically cannot read your private fields.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted →</a>
|
||||
<a href="/install" class="btn btn-ghost">Self-host anyway</a>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ WantedBy=multi-user.target</pre></div>
|
|||
<hr class="divider mb-8 mt-4">
|
||||
|
||||
<h2 class="mb-4">Rather not manage it yourself?</h2>
|
||||
<p class="lead mb-6">Same vault, same features. We handle updates, backups, and TLS. <s>$20</s> $12/yr.</p>
|
||||
<p class="lead mb-6">Same vault, same features. We handle updates, backups, and TLS. $12/yr.</p>
|
||||
<a href="/hosted" class="btn btn-primary">See hosted option →</a>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
|
||||
<div class="card mb-8" style="text-align:center">
|
||||
<p class="mb-4">Three-tier encryption. Scoped access. Your AI gets what it needs — nothing more.</p>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
|
||||
<div class="card mb-8" style="text-align:center">
|
||||
<p class="mb-4">Scoped access for every agent. Your secrets stay yours.</p>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -200,7 +200,7 @@
|
|||
|
||||
<div class="card mb-8" style="text-align:center">
|
||||
<p class="mb-4">Multi-agent. Scoped. Encrypted. Built for autonomous workflows.</p>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
|
||||
<div class="card mb-8" style="text-align:center">
|
||||
<p class="mb-4">多智能体。范围限定。加密。为自主工作流构建。</p>
|
||||
<a href="/hosted" class="btn btn-primary">托管服务 — <s>$20</s> $12/年</a>
|
||||
<a href="/hosted" class="btn btn-primary">托管服务 — $12/年</a>
|
||||
<a href="/install" class="btn btn-ghost" style="margin-left:8px">免费自托管 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@
|
|||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Ready to upgrade?</h2>
|
||||
<p class="lead mb-6">Self-host for free, or let us run it for <s>$20</s> $12/yr.</p>
|
||||
<p class="lead mb-6">Self-host for free, or let us run it for $12/yr.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/install" class="btn btn-ghost">Self-host →</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted →</a>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
<div class="hero container">
|
||||
<p class="label accent mb-4">clavitor hosted</p>
|
||||
<h1>Zero cache. Every request hits the vault.</h1>
|
||||
<p class="lead">Clavitor never caches credentials. To make it fast, we run 4 regions across every continent. <s>$20</s> $12/yr.</p>
|
||||
<p class="lead">Clavitor never caches credentials. To make it fast, we run 4 regions across every continent. $12/yr.</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
|
|
|||
|
|
@ -46,20 +46,23 @@
|
|||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link active">Pricing</a>
|
||||
<div class="nav-dropdown nav-dropdown--locale">
|
||||
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
|
||||
<div class="nav-dropdown nav-dropdown--language">
|
||||
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right">
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
|
||||
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
|
||||
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown nav-dropdown--currency">
|
||||
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
|
||||
<!-- Currency options loaded dynamically from /api/currencies -->
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -128,7 +131,104 @@
|
|||
</footer>
|
||||
|
||||
<script>
|
||||
// Toggle dropdown menus
|
||||
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
|
||||
|
||||
// Language selector state management
|
||||
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const lang = item.getAttribute('data-lang');
|
||||
const flag = item.textContent.trim().split(' ')[0];
|
||||
document.getElementById('languageTrigger').textContent = flag + ' ' + lang.toUpperCase();
|
||||
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Currency selector - fetch from API and render with sections
|
||||
async function loadCurrencies() {
|
||||
const menu = document.getElementById('currencyMenu');
|
||||
const trigger = document.getElementById('currencyTrigger');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/currencies');
|
||||
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
|
||||
const data = await response.json();
|
||||
|
||||
// Clear existing content
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Render "Popular" section
|
||||
if (data.top && data.top.length > 0) {
|
||||
const popularHeader = document.createElement('div');
|
||||
popularHeader.className = 'dropdown-section';
|
||||
popularHeader.textContent = 'Popular';
|
||||
menu.appendChild(popularHeader);
|
||||
|
||||
data.top.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, trigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Divider between sections
|
||||
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
menu.appendChild(divider);
|
||||
}
|
||||
|
||||
// Render "All Currencies" section
|
||||
if (data.all && data.all.length > 0) {
|
||||
const allHeader = document.createElement('div');
|
||||
allHeader.className = 'dropdown-section';
|
||||
allHeader.textContent = 'All Currencies';
|
||||
menu.appendChild(allHeader);
|
||||
|
||||
data.all.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, trigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// ERR-CURRENCY-002: API unavailable - keep default fallback options
|
||||
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
|
||||
}
|
||||
}
|
||||
|
||||
function createCurrencyItem(currency, trigger) {
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'nav-dropdown-item';
|
||||
item.setAttribute('data-currency', currency.code);
|
||||
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
|
||||
// Set active state based on current selection
|
||||
const currentText = trigger.textContent.trim();
|
||||
if (currentText.includes(currency.code)) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const code = item.getAttribute('data-currency');
|
||||
const symbol = currency.symbol || '$';
|
||||
trigger.textContent = symbol + ' ' + code;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('preferredCurrency', code);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Load currencies on page load
|
||||
loadCurrencies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -7,8 +7,8 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -21,6 +21,7 @@ const (
|
|||
RepoName = "clavitor"
|
||||
PollInterval = 60 * time.Second
|
||||
TaskDir = "/home/johan/dev/clavitor/.agent-tasks"
|
||||
WorkDir = "/home/johan/dev/clavitor"
|
||||
LogFile = "/home/johan/dev/clavitor/.agent-dispatcher.log"
|
||||
WebPort = "8098"
|
||||
)
|
||||
|
|
@ -53,6 +54,30 @@ type DispatchedTask struct {
|
|||
TaskFile string `json:"task_file"`
|
||||
}
|
||||
|
||||
// Domain to agent mapping (from CLAVITOR-AGENT-HANDBOOK.md Section I)
|
||||
var domainToAgent = map[string]string{
|
||||
"clavis-vault": "sarah",
|
||||
"clavis-cli": "charles",
|
||||
"clavis-crypto": "maria",
|
||||
"clavis-chrome": "james",
|
||||
"clavis-firefox": "james",
|
||||
"clavis-safari": "james",
|
||||
"clavis-android": "xiao",
|
||||
"clavis-ios": "xiao",
|
||||
"clavitor.ai": "emma",
|
||||
"clavis-telemetry": "hans",
|
||||
"operations": "hans",
|
||||
"monitoring": "hans",
|
||||
"noc": "hans",
|
||||
"security": "victoria",
|
||||
"architecture": "arthur",
|
||||
"design": "luna",
|
||||
"docs": "thomas",
|
||||
"legal": "hugo",
|
||||
"qa": "shakib",
|
||||
"test": "shakib",
|
||||
}
|
||||
|
||||
// Global state
|
||||
type Dispatcher struct {
|
||||
mu sync.RWMutex
|
||||
|
|
@ -60,6 +85,7 @@ type Dispatcher struct {
|
|||
lastDispatch time.Time
|
||||
token string
|
||||
logger *log.Logger
|
||||
activeAgents map[string]time.Time // Track agents currently working
|
||||
}
|
||||
|
||||
func NewDispatcher() *Dispatcher {
|
||||
|
|
@ -80,8 +106,9 @@ func NewDispatcher() *Dispatcher {
|
|||
os.MkdirAll(TaskDir, 0755)
|
||||
|
||||
return &Dispatcher{
|
||||
token: token,
|
||||
logger: logger,
|
||||
token: token,
|
||||
logger: logger,
|
||||
activeAgents: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,13 +172,58 @@ func (d *Dispatcher) taskFileExists(agent string, issueNum int) bool {
|
|||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchTask(issue Issue) error {
|
||||
agent := d.getAssignee(issue)
|
||||
if agent == "" {
|
||||
return nil // Skip unassigned
|
||||
// Find agent for issue based on domain
|
||||
func (d *Dispatcher) findAgentForIssue(issue Issue) string {
|
||||
domain := d.getDomain(issue)
|
||||
if domain == "" {
|
||||
return "" // No domain found
|
||||
}
|
||||
|
||||
// Check if already dispatched
|
||||
agent, ok := domainToAgent[domain]
|
||||
if !ok {
|
||||
return "" // Unknown domain
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// Extract domain from issue (body, title, or labels)
|
||||
func (d *Dispatcher) getDomain(issue Issue) string {
|
||||
// Check labels first
|
||||
for _, label := range issue.Labels {
|
||||
if agent, ok := domainToAgent[label.Name]; ok {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
|
||||
// Check title and body for domain keywords
|
||||
text := issue.Title + " " + issue.Body
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Check domains in priority order (more specific first)
|
||||
domains := []string{
|
||||
"clavis-telemetry", "clavis-vault", "clavis-cli", "clavis-crypto",
|
||||
"clavis-chrome", "clavis-firefox", "clavis-safari",
|
||||
"clavis-android", "clavis-ios", "clavitor.ai",
|
||||
"operations", "monitoring", "noc",
|
||||
"security", "architecture", "design", "docs", "legal", "qa", "test",
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
if strings.Contains(text, domain) {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchTask(issue Issue, agent string) error {
|
||||
if agent == "" {
|
||||
return fmt.Errorf("no agent available")
|
||||
}
|
||||
|
||||
// Check if already dispatched to this agent
|
||||
if d.taskFileExists(agent, issue.Number) {
|
||||
return nil // Already dispatched
|
||||
}
|
||||
|
|
@ -263,43 +335,212 @@ func truncate(s string, maxLen int) string {
|
|||
func (d *Dispatcher) pollAndDispatch() {
|
||||
d.log("Polling Gitea for open issues...")
|
||||
|
||||
// Fetch all issues once
|
||||
issues, err := d.fetchOpenIssues()
|
||||
if err != nil {
|
||||
d.log("❌ Failed to fetch issues: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
d.log("Found %d open issues", len(issues))
|
||||
d.log("Found %d total open issues", len(issues))
|
||||
|
||||
// Sort by priority (CRITICAL first)
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
pi := d.getPriority(issues[i])
|
||||
pj := d.getPriority(issues[j])
|
||||
priorityOrder := map[string]int{"CRITICAL": 0, "HIGH": 1, "NORMAL": 2, "LOW": 3}
|
||||
return priorityOrder[pi] < priorityOrder[pj]
|
||||
})
|
||||
|
||||
// Try to dispatch one task
|
||||
dispatched := false
|
||||
// Group issues by assignee
|
||||
byAgent := make(map[string][]Issue)
|
||||
for _, issue := range issues {
|
||||
if d.getAssignee(issue) == "" {
|
||||
continue
|
||||
assignee := d.getAssignee(issue)
|
||||
if assignee == "" {
|
||||
assignee = d.findAgentForIssue(issue) // Fallback to domain mapping
|
||||
}
|
||||
|
||||
err := d.dispatchTask(issue)
|
||||
if err != nil {
|
||||
d.log("⚠️ Skipped Issue #%d: %v", issue.Number, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
dispatched = true
|
||||
break // Only 1 per poll
|
||||
if assignee != "" {
|
||||
byAgent[assignee] = append(byAgent[assignee], issue)
|
||||
}
|
||||
}
|
||||
|
||||
if !dispatched {
|
||||
d.log("ℹ️ No new tasks to dispatch (rate limit or all caught up)")
|
||||
// Spawn agents that have work and aren't already active
|
||||
for agent, agentIssues := range byAgent {
|
||||
if len(agentIssues) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if agent is already working
|
||||
d.mu.Lock()
|
||||
_, isActive := d.activeAgents[agent]
|
||||
d.mu.Unlock()
|
||||
|
||||
if isActive {
|
||||
d.log("ℹ️ Agent %s is already working, skipping", agent)
|
||||
continue
|
||||
}
|
||||
|
||||
// Spawn the agent
|
||||
d.spawnAgent(agent)
|
||||
}
|
||||
}
|
||||
|
||||
// spawnAgent launches opencode for an agent
|
||||
func (d *Dispatcher) spawnAgent(agent string) {
|
||||
d.mu.Lock()
|
||||
d.activeAgents[agent] = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
d.log("🚀 Spawning agent: %s", agent)
|
||||
|
||||
// Build command
|
||||
cmd := exec.Command("opencode", "run",
|
||||
"--agent", agent,
|
||||
"--dangerously-skip-permissions",
|
||||
"Check Gitea for issues assigned to "+agent+" and fix the highest priority one")
|
||||
cmd.Dir = WorkDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GITEA_TOKEN="+d.token,
|
||||
)
|
||||
|
||||
// Run in background
|
||||
go func() {
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
d.log("❌ Agent %s failed: %v\nOutput: %s", agent, err, string(output))
|
||||
} else {
|
||||
d.log("✅ Agent %s completed", agent)
|
||||
}
|
||||
|
||||
// Mark agent as done
|
||||
d.mu.Lock()
|
||||
delete(d.activeAgents, agent)
|
||||
d.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// Webhook handler for Gitea events
|
||||
func (d *Dispatcher) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse webhook payload
|
||||
var payload struct {
|
||||
Action string `json:"action"`
|
||||
Issue *Issue `json:"issue"`
|
||||
PullRequest *struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
Head struct {
|
||||
Ref string `json:"ref"`
|
||||
} `json:"head"`
|
||||
} `json:"pull_request"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
} `json:"repository"`
|
||||
Sender struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"sender"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
d.log("❌ Webhook: Failed to parse payload: %v", err)
|
||||
http.Error(w, "Bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Get event type from header
|
||||
eventType := r.Header.Get("X-Gitea-Event")
|
||||
if eventType == "" {
|
||||
eventType = r.Header.Get("X-GitHub-Event") // Fallback
|
||||
}
|
||||
|
||||
d.log("📨 Webhook received: %s from %s", eventType, payload.Sender.Login)
|
||||
|
||||
// Handle issue events
|
||||
if eventType == "issues" && payload.Issue != nil {
|
||||
switch payload.Action {
|
||||
case "opened", "reopened", "edited", "assigned":
|
||||
d.log("📋 Issue #%d %s - processing dispatch", payload.Issue.Number, payload.Action)
|
||||
d.processSingleIssue(*payload.Issue)
|
||||
case "closed":
|
||||
d.log("✅ Issue #%d closed - checking for next task", payload.Issue.Number)
|
||||
d.markTaskDone(*payload.Issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PR merge (task completed)
|
||||
if eventType == "pull_request" && payload.PullRequest != nil {
|
||||
if payload.Action == "closed" && payload.PullRequest.State == "merged" {
|
||||
d.log("🎉 PR #%d merged by %s - agent completed task",
|
||||
payload.PullRequest.Number, payload.PullRequest.User.Login)
|
||||
// Extract agent from branch name (agent/fix-123)
|
||||
branch := payload.PullRequest.Head.Ref
|
||||
if idx := strings.Index(branch, "/"); idx > 0 {
|
||||
agent := branch[:idx]
|
||||
d.markAgentTaskDone(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprintln(w, "OK")
|
||||
}
|
||||
|
||||
// Process a single issue immediately (for webhooks)
|
||||
func (d *Dispatcher) processSingleIssue(issue Issue) {
|
||||
agent := d.findAgentForIssue(issue)
|
||||
if agent == "" {
|
||||
d.log("ℹ️ Issue #%d: No available agent for domain", issue.Number)
|
||||
return
|
||||
}
|
||||
|
||||
err := d.dispatchTask(issue, agent)
|
||||
if err != nil {
|
||||
d.log("⚠️ Issue #%d: Failed to dispatch to %s: %v", issue.Number, agent, err)
|
||||
} else {
|
||||
d.log("✅ Issue #%d dispatched to %s", issue.Number, agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a task as done when issue is closed
|
||||
func (d *Dispatcher) markTaskDone(issue Issue) {
|
||||
// Find which agent had this task
|
||||
for agent := range domainToAgent {
|
||||
taskFile := filepath.Join(TaskDir, agent, fmt.Sprintf("issue-%d.md", issue.Number))
|
||||
if _, err := os.Stat(taskFile); err == nil {
|
||||
// Rename to .done.md
|
||||
doneFile := filepath.Join(TaskDir, agent, fmt.Sprintf("issue-%d.done.md", issue.Number))
|
||||
os.Rename(taskFile, doneFile)
|
||||
d.log("✅ Task for Issue #%d marked as done for %s", issue.Number, agent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark task done when PR is merged
|
||||
func (d *Dispatcher) markAgentTaskDone(agent string) {
|
||||
agentDir := filepath.Join(TaskDir, agent)
|
||||
files, err := os.ReadDir(agentDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f.Name(), ".md") && !strings.HasSuffix(f.Name(), ".done.md") {
|
||||
taskFile := filepath.Join(agentDir, f.Name())
|
||||
doneFile := filepath.Join(agentDir, f.Name()[:len(f.Name())-3]+".done.md")
|
||||
os.Rename(taskFile, doneFile)
|
||||
d.log("✅ Agent %s task marked as done (PR merged)", agent)
|
||||
|
||||
// Trigger dispatch of next task for this agent
|
||||
go d.pollAndDispatch()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -440,18 +681,21 @@ func main() {
|
|||
d.log("Repo: %s/%s", RepoOwner, RepoName)
|
||||
d.log("Task Dir: %s", TaskDir)
|
||||
d.log("Web UI: http://localhost:%s", WebPort)
|
||||
d.log("Rate Limit: 1 task per minute")
|
||||
d.log("Webhook endpoint: http://localhost:%s/webhook", WebPort)
|
||||
d.log("Mode: Webhook listener + backup polling")
|
||||
d.log("========================================")
|
||||
|
||||
// Start web server
|
||||
// Start web server (includes webhook listener)
|
||||
go func() {
|
||||
http.HandleFunc("/", d.handleStatus)
|
||||
http.HandleFunc("/tasks", d.handleTasks)
|
||||
http.HandleFunc("/webhook", d.handleWebhook) // Gitea webhooks
|
||||
d.log("Web UI available at http://localhost:%s", WebPort)
|
||||
d.log("Webhook endpoint: http://localhost:%s/webhook", WebPort)
|
||||
log.Fatal(http.ListenAndServe(":"+WebPort, nil))
|
||||
}()
|
||||
|
||||
// Immediate first poll
|
||||
// Immediate first poll (backup for any missed webhooks)
|
||||
d.pollAndDispatch()
|
||||
|
||||
// Main loop
|
||||
|
|
|
|||
Loading…
Reference in New Issue