Compare commits

...

14 Commits

Author SHA1 Message Date
Yurii 00105d2e19 Merge origin/master 2026-04-09 11:46:59 -04:00
Yurii 115e23e095 Add PR merge stage to dispatcher workflow 2026-04-09 11:45:42 -04:00
Johan Jongsma 2195b51eec Merge pull request 'Emma: Add /api/currencies endpoint for currency dropdown' (#13) from emma/fix-11 into master 2026-04-09 15:40:16 +00:00
James d475c5a914 clavitor.ai: Add error logging for currency row scan failures
Fixes Cardinal Rule violation - every if needs an else.
Adds unique error code ERR-ADMIN-003 for scan failures.

fixes #13
2026-04-09 11:00:23 -04:00
Johan Jongsma 840543b581 Merge pull request 'Emma: Remove strikethrough pricing — always show $12/yr' (#15) from emma/fix-9 into master 2026-04-09 14:22:45 +00:00
Johan Jongsma 3c4e091e33 Merge pull request 'Hans: Add Prometheus metrics endpoint to telemetry service' (#12) from hans/fix-8 into master 2026-04-09 14:20:52 +00:00
Johan Jongsma 063d2c8cd8 Merge branch 'master' into hans/fix-8 2026-04-09 14:20:45 +00:00
Johan Jongsma ab0b5e0717 Merge pull request 'Luna: Add currency dropdown sections with Popular/All Currencies split' (#18) from luna/design-11 into master 2026-04-09 14:20:14 +00:00
James 7dbbadb62e Add detailed task assignment logging to dispatcher 2026-04-09 04:56:12 -04:00
James cdfa87b8ce web: Add currency dropdown sections with Popular/All Currencies split
Implements frontend styling for issue #11:
- Add .dropdown-section CSS for section headers (Popular, All Currencies)
- Add .dropdown-divider CSS for visual separation between sections
- Update test-index.html with dynamic currency loading from /api/currencies
- Update base.tmpl with split language/currency selectors
- JavaScript fetches currencies and renders with section headers
- Maintains localStorage persistence for currency preference
- Error handling with ERR-CURRENCY-001/002 codes

Design Requirements Met:
- Section headers use 11px uppercase with 0.08em letter-spacing
- Divider uses 1px border with 8px vertical margins
- Dropdown maintains existing hover/click behavior
- Mobile responsive (stacks in hamburger menu)

fixes #11

Author: Luna <luna-20250409-001>
2026-04-09 03:46:42 -04:00
James def0c6fb1d test: rewrite agent credential tests for client-side generation
Rewrites 7 skipped integration tests to work with client-side credential generation:

- TestScopedAccess_agent_sees_only_scoped_entries
- TestScopedAccess_agent_forbidden_on_unscoped
- TestScopedAccess_all_access_sees_everything
- TestScopedAccess_agent_cannot_manage_agents
- TestScopedAccess_agent_cannot_create_system_types (renamed from _modify_scopes)
- TestScopedAccess_agent_entries_invisible
- TestKeyLeak_agent_credential_is_opaque

Adds MintCredential/ParseCredential test helpers to lib/cvt.go for creating
type 0x01 client credential tokens in tests. These simulate the client-side
credential generation that normally happens in browser/CLI.

Adds test helper methods to integration_test.go:
- reqAgent(): sends requests with CVT wire token authentication
- mintWireToken(): creates type 0x00 wire tokens for agent auth

Security boundaries tested:
- Agents with limited scope cannot access owner-only entries
- Agents with all_access can see all entries
- Agents cannot manage other agents (create/list)
- Agents cannot create system-type entries (agent, scope)
- Server responses never contain raw L2/L3 key material

Fixes #14
2026-04-09 03:45:42 -04:00
James 3be8a683a7 ui: remove strikethrough pricing — always show $12/yr
Removes crossed-out "$20" pricing from all templates and test files.
The hosted plan is now permanently $12/year.

Files modified:
- test-index.html: Header CTA button
- test-hosted.html: Header CTA and hero text
- base.tmpl: Header CTA button
- hosted.tmpl: Hero text and CTA section
- index.tmpl: Hero button and hosted CTA section
- upgrade.tmpl: Pricing comparison text
- integrations.tmpl: All 4 CTA buttons (English + Chinese)
- install.tmpl: Hosted option section

fixes #9

Author: Emma <emma-20250409-001>
2026-04-09 03:36:59 -04:00
James c082f84109 test: Add comprehensive error handling tests for clavis-telemetry
Adds test coverage for issues #002, #003, #004:
- TestUpdateSpan_MaintenanceModeError: Verifies ERR-TELEMETRY-010 logging
- TestUpdateSpan_UptimeSpanQueryError: Verifies ERR-TELEMETRY-011 logging
- TestSendKumaPush_DatabaseError: Verifies ERR-TELEMETRY-033 logging
- TestSendKumaPush_NetworkError: Verifies ERR-TELEMETRY-030 logging
- TestSendKumaPush_NonOKStatus: Verifies ERR-TELEMETRY-031 logging
- TestTarpit_EarlyDisconnect: Verifies tarpit handles client disconnect
- TestErrorCodes_Unique: Verifies all error codes are unique (caught duplicates!)
- TestErrorCodes_Format: Verifies error message format compliance
- TestHandleTelemetry_DatabaseInsertError: Verifies ERR-TELEMETRY-004
- TestConcurrentErrorHandling: Verifies thread-safe error logging

Also fixes duplicate error codes found by tests:
- ERR-TELEMETRY-010 -> ERR-TELEMETRY-015 for maintenance table creation
- Added ERR-TELEMETRY-034 for Kuma response body close after non-OK status

fixes #002
fixes #003
fixes #004

Author: Shakib <shakib-20250409-001>
2026-04-09 03:35:27 -04:00
James 12824ddbef api: add /api/currencies endpoint for currency dropdown
Implements currency API per issue #11 requirements:
- Returns top 10 currencies (USD, EUR, GBP, JPY, CAD, AUD, CHF, SEK, NOK, NZD)
- Returns all remaining active currencies alphabetically
- Queries corporate.db currencies table
- Proper error handling with unique error codes (ERR-ADMIN-001, ERR-ADMIN-002)
- CORS enabled for frontend access

fixes #11

Author: Emma <emma-20250409-001>
2026-04-09 03:33:54 -04:00
11 changed files with 1211 additions and 78 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
agent-tokens.json

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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")
}
}
// ---------------------------------------------------------------------------

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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")
}
}

View File

@ -147,6 +147,21 @@ code { font-size: 0.875em; }
.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); }

View File

@ -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" }

View File

@ -104,19 +104,20 @@
</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>
@ -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>

View File

@ -55,7 +55,8 @@
</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">
<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>
@ -145,17 +146,89 @@ document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(
});
});
// Currency selector state management
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const currency = item.getAttribute('data-currency');
const symbol = item.textContent.trim().split(' ')[0];
document.getElementById('currencyTrigger').textContent = symbol + ' ' + currency;
document.querySelectorAll('.nav-dropdown--currency .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.