chore: auto-commit uncommitted changes
This commit is contained in:
parent
cb7c7c51ce
commit
0922dde30a
|
|
@ -0,0 +1,31 @@
|
|||
# Clavitor — Agent Instructions
|
||||
|
||||
## Foundation First — No Mediocrity. Ever.
|
||||
|
||||
The rule is simple: do it right, or say something.
|
||||
|
||||
Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way.
|
||||
|
||||
### What this means in practice
|
||||
|
||||
If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom.
|
||||
|
||||
If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now.
|
||||
|
||||
Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution.
|
||||
|
||||
Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases.
|
||||
|
||||
### The restart rule
|
||||
|
||||
When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike.
|
||||
|
||||
### Q&D is research, not output
|
||||
|
||||
Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts.
|
||||
|
||||
### When you hit a bad foundation
|
||||
|
||||
Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together.
|
||||
|
||||
The bar is high. The support is real.
|
||||
|
|
@ -46,6 +46,36 @@ All API calls go to `https://<host>:1984` with `Authorization: Bearer <L1>` and
|
|||
|
||||
Endpoints used: `/api/entries`, `/api/search?q=`, `/api/entries/<id>`, `/api/ext/totp/<id>`.
|
||||
|
||||
## ⚒️ Foundation First — No Mediocrity. Ever.
|
||||
|
||||
The rule is simple: do it right, or say something.
|
||||
|
||||
Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way.
|
||||
|
||||
### What this means in practice
|
||||
|
||||
If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom.
|
||||
|
||||
If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now.
|
||||
|
||||
Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution.
|
||||
|
||||
Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases.
|
||||
|
||||
### The restart rule
|
||||
|
||||
When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike.
|
||||
|
||||
### Q&D is research, not output
|
||||
|
||||
Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts.
|
||||
|
||||
### When you hit a bad foundation
|
||||
|
||||
Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together.
|
||||
|
||||
The bar is high. The support is real.
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
# Clavis Vault — CLAUDE.md
|
||||
|
||||
## Foundation First — No Mediocrity. Ever.
|
||||
|
||||
The rule is simple: do it right, or say something.
|
||||
|
||||
Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way.
|
||||
|
||||
### What this means in practice
|
||||
|
||||
If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom.
|
||||
|
||||
If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now.
|
||||
|
||||
Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution.
|
||||
|
||||
Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases.
|
||||
|
||||
### The restart rule
|
||||
|
||||
When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike.
|
||||
|
||||
### Q&D is research, not output
|
||||
|
||||
Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts.
|
||||
|
||||
### When you hit a bad foundation
|
||||
|
||||
Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together.
|
||||
|
||||
The bar is high. The support is real.
|
||||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
|
@ -449,8 +448,8 @@ func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
|
|||
entries = []lib.Entry{}
|
||||
}
|
||||
|
||||
// Strip L2 field values for MCP/agent actors; web/extension decrypt client-side
|
||||
if actor == lib.ActorMCP || actor == lib.ActorAgent {
|
||||
// Strip L2 field values for agent actors; web/extension decrypt client-side
|
||||
if actor == lib.ActorAgent {
|
||||
for i := range entries {
|
||||
if entries[i].VaultData != nil {
|
||||
stripL2Fields(entries[i].VaultData)
|
||||
|
|
@ -486,8 +485,8 @@ func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Strip L2 field values for MCP actors only; web/extension decrypt client-side
|
||||
if actor == lib.ActorMCP && entry.VaultData != nil {
|
||||
// Strip L2 field values for agent actors; web/extension decrypt client-side
|
||||
if actor == lib.ActorAgent && entry.VaultData != nil {
|
||||
stripL2Fields(entry.VaultData)
|
||||
}
|
||||
|
||||
|
|
@ -674,8 +673,8 @@ func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) {
|
|||
entries = []lib.Entry{}
|
||||
}
|
||||
|
||||
// Strip L2 field values for MCP/agent actors; web/extension decrypt client-side
|
||||
if actor == lib.ActorMCP || actor == lib.ActorAgent {
|
||||
// Strip L2 field values for agent actors; web/extension decrypt client-side
|
||||
if actor == lib.ActorAgent {
|
||||
for i := range entries {
|
||||
if entries[i].VaultData != nil {
|
||||
stripL2Fields(entries[i].VaultData)
|
||||
|
|
@ -1082,354 +1081,6 @@ func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
|||
JSONResponse(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MCPHandler handles JSON-RPC 2.0 MCP protocol requests.
|
||||
func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
mcpError(w, nil, -32700, "Parse error")
|
||||
return
|
||||
}
|
||||
|
||||
if req.JSONRPC != "2.0" {
|
||||
mcpError(w, req.ID, -32600, "Invalid Request")
|
||||
return
|
||||
}
|
||||
|
||||
// Check read-only enforcement for scoped MCP tokens
|
||||
mcpToken := MCPTokenFromContext(r.Context())
|
||||
if mcpToken != nil && mcpToken.ReadOnly {
|
||||
// Parse the call to check for write methods
|
||||
var call struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if req.Method == "tools/call" {
|
||||
json.Unmarshal(req.Params, &call)
|
||||
// Block write operations on read-only tokens
|
||||
if call.Name == "create_credential" || call.Name == "update_credential" || call.Name == "delete_credential" {
|
||||
mcpError(w, req.ID, -32000, "token is read-only")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result any
|
||||
var err error
|
||||
|
||||
switch req.Method {
|
||||
case "tools/list":
|
||||
result = h.mcpToolsList()
|
||||
case "tools/call":
|
||||
result, err = h.mcpToolsCall(r, req.Params)
|
||||
default:
|
||||
mcpError(w, req.ID, -32601, "Method not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
mcpError(w, req.ID, -32000, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
mcpSuccess(w, req.ID, result)
|
||||
}
|
||||
|
||||
func (h *Handlers) mcpToolsList() map[string]any {
|
||||
return map[string]any{
|
||||
"tools": []map[string]any{
|
||||
{
|
||||
"name": "get_credential",
|
||||
"description": "Search and return a credential from the vault. L2 fields are omitted.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]string{"type": "string", "description": "Search query (title or URL)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_credentials",
|
||||
"description": "List all credentials in the vault (titles, types, URLs only).",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"filter": map[string]string{"type": "string", "description": "Optional type filter"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_totp",
|
||||
"description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]string{"type": "string", "description": "Entry title or ID"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_vault",
|
||||
"description": "Search the vault by title.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]string{"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "check_expiring",
|
||||
"description": "Check for entries with expiring credentials.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"days": map[string]any{"type": "number", "description": "Days to check (default 30)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) {
|
||||
var call struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &call); err != nil {
|
||||
return nil, fmt.Errorf("invalid params")
|
||||
}
|
||||
|
||||
mcpToken := MCPTokenFromContext(r.Context())
|
||||
|
||||
switch call.Name {
|
||||
case "get_credential", "search_vault":
|
||||
query, _ := call.Arguments["query"].(string)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = mcpFilterEntries(entries, mcpToken)
|
||||
// Strip L2 fields
|
||||
for i := range entries {
|
||||
if entries[i].VaultData != nil {
|
||||
stripL2Fields(entries[i].VaultData)
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil
|
||||
}
|
||||
// For get_credential, return best match
|
||||
if call.Name == "get_credential" {
|
||||
result, _ := json.Marshal(entries[0])
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
||||
}
|
||||
result, _ := json.Marshal(entries)
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
||||
|
||||
case "list_credentials":
|
||||
filter, _ := call.Arguments["filter"].(string)
|
||||
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = mcpFilterEntries(entries, mcpToken)
|
||||
var list []map[string]any
|
||||
for _, e := range entries {
|
||||
if filter != "" && e.Type != filter {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{
|
||||
"entry_id": e.EntryID,
|
||||
"title": e.Title,
|
||||
"type": e.Type,
|
||||
}
|
||||
if e.VaultData != nil && len(e.VaultData.URLs) > 0 {
|
||||
item["urls"] = e.VaultData.URLs
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
result, _ := json.Marshal(list)
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
||||
|
||||
case "get_totp":
|
||||
query, _ := call.Arguments["query"].(string)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil
|
||||
}
|
||||
entries = mcpFilterEntries(entries, mcpToken)
|
||||
if len(entries) == 0 {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.VaultData == nil {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil
|
||||
}
|
||||
for _, field := range entry.VaultData.Fields {
|
||||
if field.Kind == "totp" {
|
||||
if field.L2 {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil
|
||||
}
|
||||
seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", ""))
|
||||
code, err := totp.GenerateCode(seed, time.Now())
|
||||
if err != nil {
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
expiresIn := 30 - (now % 30)
|
||||
result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn)
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil
|
||||
}
|
||||
}
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil
|
||||
|
||||
case "check_expiring":
|
||||
daysF, _ := call.Arguments["days"].(float64)
|
||||
days := int(daysF)
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = mcpFilterEntries(entries, mcpToken)
|
||||
cutoff := time.Now().AddDate(0, 0, days)
|
||||
var expiring []map[string]any
|
||||
for _, e := range entries {
|
||||
if e.VaultData == nil || e.VaultData.Expires == "" {
|
||||
continue
|
||||
}
|
||||
exp, err := time.Parse("2006-01-02", e.VaultData.Expires)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if exp.Before(cutoff) {
|
||||
daysRemaining := int(exp.Sub(time.Now()).Hours() / 24)
|
||||
expiring = append(expiring, map[string]any{
|
||||
"title": e.Title,
|
||||
"type": e.Type,
|
||||
"expires": e.VaultData.Expires,
|
||||
"days_remaining": daysRemaining,
|
||||
})
|
||||
}
|
||||
}
|
||||
result, _ := json.Marshal(expiring)
|
||||
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool: %s", call.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func mcpSuccess(w http.ResponseWriter, id any, result any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
func mcpError(w http.ResponseWriter, id any, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Token Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HandleCreateMCPToken creates a scoped MCP token.
|
||||
func (h *Handlers) HandleCreateMCPToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Label string `json:"label"`
|
||||
EntryIDs []lib.HexID `json:"entry_ids"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpiresIn int64 `json:"expires_in_days"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Label == "" {
|
||||
ErrorResponse(w, http.StatusBadRequest, "missing_label", "Label is required")
|
||||
return
|
||||
}
|
||||
|
||||
t := &lib.MCPToken{
|
||||
Label: req.Label,
|
||||
EntryIDs: req.EntryIDs,
|
||||
ReadOnly: req.ReadOnly,
|
||||
}
|
||||
if req.ExpiresIn > 0 {
|
||||
t.ExpiresAt = time.Now().Unix() + req.ExpiresIn*86400
|
||||
}
|
||||
|
||||
if err := lib.CreateMCPToken(h.db(r), t); err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create MCP token")
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// HandleListMCPTokens returns all MCP tokens.
|
||||
func (h *Handlers) HandleListMCPTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := lib.ListMCPTokens(h.db(r))
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list MCP tokens")
|
||||
return
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []lib.MCPToken{}
|
||||
}
|
||||
JSONResponse(w, http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// HandleDeleteMCPToken revokes an MCP token.
|
||||
func (h *Handlers) HandleDeleteMCPToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := lib.HexToID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid token ID")
|
||||
return
|
||||
}
|
||||
if err := lib.DeleteMCPToken(h.db(r), id); err != nil {
|
||||
if err == lib.ErrNotFound {
|
||||
ErrorResponse(w, http.StatusNotFound, "not_found", "Token not found")
|
||||
return
|
||||
}
|
||||
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete MCP token")
|
||||
return
|
||||
}
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebAuthn PRF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1596,26 +1247,6 @@ func (h *Handlers) HandleDeleteWebAuthnCredential(w http.ResponseWriter, r *http
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mcpFilterEntries filters entries based on scoped MCP token restrictions.
|
||||
func mcpFilterEntries(entries []lib.Entry, token *lib.MCPToken) []lib.Entry {
|
||||
if token == nil || len(token.EntryIDs) == 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
idSet := map[lib.HexID]bool{}
|
||||
for _, id := range token.EntryIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
|
||||
var filtered []lib.Entry
|
||||
for _, e := range entries {
|
||||
if idSet[e.EntryID] {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func stripL2Fields(vd *lib.VaultData) {
|
||||
for i := range vd.Fields {
|
||||
if vd.Fields[i].L2 {
|
||||
|
|
@ -1678,98 +1309,6 @@ func generateTOTPSecret() string {
|
|||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||
}
|
||||
|
||||
// GeoLookup returns the visitor's geolocation.
|
||||
// If ?lat=X&lon=Y supplied (browser geo fallback), reverse-geocodes those coordinates via BigDataCloud.
|
||||
// Otherwise geolocates the request IP; returns {"private":true} for LAN IPs.
|
||||
func (h *Handlers) GeoLookup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// lat/lon path — browser geolocation fallback for LAN visitors
|
||||
if latStr := r.URL.Query().Get("lat"); latStr != "" {
|
||||
lonStr := r.URL.Query().Get("lon")
|
||||
latF, errLat := strconv.ParseFloat(latStr, 64)
|
||||
lonF, errLon := strconv.ParseFloat(lonStr, 64)
|
||||
if errLat != nil || errLon != nil {
|
||||
json.NewEncoder(w).Encode(map[string]any{"private": true})
|
||||
return
|
||||
}
|
||||
// Nominatim reverse geocode (OSM, free, no key)
|
||||
url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=%f&lon=%f", latF, lonF)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", "clavitor/1.0 (https://clavitor.com)")
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
var raw struct {
|
||||
Address struct {
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
} `json:"address"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&raw) == nil {
|
||||
city := raw.Address.City
|
||||
if city == "" { city = raw.Address.Town }
|
||||
if city == "" { city = raw.Address.Village }
|
||||
cc := strings.ToUpper(raw.Address.CountryCode)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"city": city,
|
||||
"region": raw.Address.State,
|
||||
"country_name": raw.Address.Country,
|
||||
"country_code": cc,
|
||||
"latitude": latF,
|
||||
"longitude": lonF,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// reverse-geocode failed — at least return coords
|
||||
json.NewEncoder(w).Encode(map[string]any{"latitude": latF, "longitude": lonF})
|
||||
return
|
||||
}
|
||||
|
||||
// IP-based path
|
||||
ip := realIP(r)
|
||||
if isPrivateIP(ip) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"private": true})
|
||||
return
|
||||
}
|
||||
resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=status,city,regionName,country,countryCode,lat,lon")
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]any{"private": true})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || raw["status"] != "success" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"private": true})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"city": raw["city"],
|
||||
"region": raw["regionName"],
|
||||
"country_name": raw["country"],
|
||||
"country_code": raw["countryCode"],
|
||||
"latitude": raw["lat"],
|
||||
"longitude": raw["lon"],
|
||||
})
|
||||
}
|
||||
|
||||
func isPrivateIP(ip string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil { return true }
|
||||
private := []string{"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","::1/128","fc00::/7"}
|
||||
for _, cidr := range private {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
if block != nil && block.Contains(parsed) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Backup / Restore ---
|
||||
|
||||
// ListBackups returns all available backup files.
|
||||
|
|
|
|||
|
|
@ -1,35 +1,40 @@
|
|||
package api
|
||||
|
||||
// Integration tests — white-box (same package) so we can call NewRouter directly.
|
||||
// Tests the three core user journeys:
|
||||
// 1. Create login → agents and humans store credentials
|
||||
// 2. Read login → round-trip encrypt/decrypt verification
|
||||
// 3. Use login → URL match, TOTP generation, MCP tool calls
|
||||
// Integration tests for the Clavitor vault API.
|
||||
//
|
||||
// Uses inou.com as the test credential — a real login flow.
|
||||
// The test client authenticates exactly as production does:
|
||||
// - 8-byte L1 key sent as base64url Bearer on every request
|
||||
// - DB filename derived from L1[:4]: clavitor-{base64url(l1[:4])}
|
||||
// - L1 normalized to 16 bytes for AES-128 vault encryption
|
||||
//
|
||||
// Each test gets an isolated vault (temp dir + fresh DB).
|
||||
// Run: go test ./api/... -v
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"embed"
|
||||
|
||||
"github.com/johanj/clavitor/lib"
|
||||
)
|
||||
|
||||
// --- test helpers ---
|
||||
// --- test client ---
|
||||
|
||||
type tc struct {
|
||||
srv *httptest.Server
|
||||
token string
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
bearer string // base64url-encoded L1 key (8 bytes)
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// newTestClient creates an isolated vault and test server.
|
||||
// The L1 key is deterministic so tests are reproducible.
|
||||
func newTestClient(t *testing.T) *tc {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -40,33 +45,31 @@ func newTestClient(t *testing.T) *tc {
|
|||
SessionTTL: 86400,
|
||||
}
|
||||
|
||||
// Create a DB with a fake credential so VaultMiddleware can derive vault key
|
||||
fakePubKey := make([]byte, 65)
|
||||
for i := range fakePubKey { fakePubKey[i] = byte(i + 1) }
|
||||
dbPath := tmpDir + "/01020304.db"
|
||||
// Fixed 8-byte L1 key for testing.
|
||||
l1Raw := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44}
|
||||
bearer := base64.RawURLEncoding.EncodeToString(l1Raw)
|
||||
|
||||
// DB filename must match what L1Middleware derives: clavitor-{base64url(l1[:4])}
|
||||
prefix := base64.RawURLEncoding.EncodeToString(l1Raw[:4])
|
||||
dbPath := tmpDir + "/clavitor-" + prefix
|
||||
|
||||
db, err := lib.OpenDB(dbPath)
|
||||
if err != nil { t.Fatalf("opendb: %v", err) }
|
||||
if err := lib.MigrateDB(db); err != nil { t.Fatalf("migrate: %v", err) }
|
||||
// Store fake credential for vault key derivation
|
||||
lib.StoreWebAuthnCredential(db, &lib.WebAuthnCredential{
|
||||
CredID: lib.HexID(1), Name: "test", PublicKey: fakePubKey,
|
||||
CredentialID: []byte("test-raw-id"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("opendb: %v", err)
|
||||
}
|
||||
if err := lib.MigrateDB(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
var emptyFS embed.FS
|
||||
srv := httptest.NewServer(NewRouter(cfg, emptyFS, emptyFS))
|
||||
srv := httptest.NewServer(NewRouter(cfg, emptyFS))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Auth
|
||||
resp, _ := srv.Client().Post(srv.URL+"/api/auth/setup", "application/json", nil)
|
||||
var setup struct{ Token string `json:"token"` }
|
||||
json.NewDecoder(resp.Body).Decode(&setup)
|
||||
resp.Body.Close()
|
||||
|
||||
return &tc{srv: srv, token: setup.Token, t: t}
|
||||
return &tc{srv: srv, bearer: bearer, t: t}
|
||||
}
|
||||
|
||||
// req sends an authenticated HTTP request.
|
||||
func (c *tc) req(method, path string, body any) *http.Response {
|
||||
c.t.Helper()
|
||||
var r io.Reader
|
||||
|
|
@ -75,13 +78,37 @@ func (c *tc) req(method, path string, body any) *http.Response {
|
|||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.srv.URL+path, r)
|
||||
if body != nil { req.Header.Set("Content-Type", "application/json") }
|
||||
if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) }
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil { c.t.Fatalf("req %s %s: %v", method, path, err) }
|
||||
if err != nil {
|
||||
c.t.Fatalf("req %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// reqNoAuth sends an unauthenticated request.
|
||||
func (c *tc) reqNoAuth(method, path string, body any) *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")
|
||||
}
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatalf("req %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// must asserts status code and returns parsed JSON object.
|
||||
func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
|
||||
c.t.Helper()
|
||||
defer resp.Body.Close()
|
||||
|
|
@ -94,109 +121,250 @@ func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
|
|||
return out
|
||||
}
|
||||
|
||||
func inouEntry() map[string]any {
|
||||
// mustList asserts status code and returns parsed JSON array.
|
||||
func (c *tc) mustList(resp *http.Response, wantStatus int) []map[string]any {
|
||||
c.t.Helper()
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != wantStatus {
|
||||
c.t.Fatalf("expected %d, got %d: %s", wantStatus, resp.StatusCode, body)
|
||||
}
|
||||
var out []map[string]any
|
||||
json.Unmarshal(body, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- test data ---
|
||||
|
||||
func credentialEntry(title, username, password string, urls []string) map[string]any {
|
||||
return map[string]any{
|
||||
"title": "inou.com",
|
||||
"title": title,
|
||||
"type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": "inou.com",
|
||||
"title": title,
|
||||
"type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "username", "value": "test@inou.com", "kind": "text"},
|
||||
{"label": "password", "value": "TestPass!InouDev42", "kind": "password"},
|
||||
{"label": "api_key", "value": "inou_sk_test_abc123", "kind": "text"},
|
||||
{"label": "username", "value": username, "kind": "text"},
|
||||
{"label": "password", "value": password, "kind": "password"},
|
||||
},
|
||||
"urls": []string{"https://inou.com", "https://app.inou.com"},
|
||||
"urls": urls,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health & Ping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
result := c.must(c.req("GET", "/health", nil), 200)
|
||||
result := c.must(c.reqNoAuth("GET", "/health", nil), 200)
|
||||
if result["status"] != "ok" {
|
||||
t.Errorf("health status = %v", result["status"])
|
||||
t.Errorf("status = %v, want ok", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLogin(t *testing.T) {
|
||||
func TestPing(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
result := c.must(c.req("POST", "/api/entries", inouEntry()), 201)
|
||||
id, _ := result["entry_id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create returned no entry_id")
|
||||
result := c.must(c.reqNoAuth("GET", "/ping", nil), 200)
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
if result["node"] == nil || result["node"] == "" {
|
||||
t.Error("node should not be empty")
|
||||
}
|
||||
if result["ts"] == nil {
|
||||
t.Error("ts should be present")
|
||||
}
|
||||
t.Logf("Created entry: %s", id)
|
||||
}
|
||||
|
||||
func TestReadLogin_RoundTrip(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// L1 Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestL1Auth_valid_key(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Should return empty array, not an auth error
|
||||
c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200)
|
||||
}
|
||||
|
||||
func TestL1Auth_bad_bearer_rejected(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
req, _ := http.NewRequest("GET", c.srv.URL+"/api/entries", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-valid-base64")
|
||||
resp, _ := c.srv.Client().Do(req)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("bad bearer should return 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestL1Auth_wrong_key_vault_not_found(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Valid base64url but points to a non-existent vault
|
||||
wrongL1 := base64.RawURLEncoding.EncodeToString([]byte{0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8})
|
||||
req, _ := http.NewRequest("GET", c.srv.URL+"/api/entries", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+wrongL1)
|
||||
resp, _ := c.srv.Client().Do(req)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("wrong L1 key should return 404 (vault not found), got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateEntry(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
result := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "hunter2", []string{"https://github.com"})), 201)
|
||||
if result["entry_id"] == nil || result["entry_id"] == "" {
|
||||
t.Fatal("create should return entry_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEntry_missing_title(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
resp := c.req("POST", "/api/entries", map[string]any{"type": "credential"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("missing title should return 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEntry_roundtrip(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
// Create
|
||||
created := c.must(c.req("POST", "/api/entries", inouEntry()), 201)
|
||||
created := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "hunter2", nil)), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
// Read back
|
||||
result := c.must(c.req("GET", "/api/entries/"+id, nil), 200)
|
||||
got := c.must(c.req("GET", "/api/entries/"+id, nil), 200)
|
||||
data := got["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
data, _ := result["data"].(map[string]any)
|
||||
if data == nil { t.Fatal("read returned no data") }
|
||||
|
||||
if data["title"] != "inou.com" {
|
||||
t.Errorf("title = %v", data["title"])
|
||||
}
|
||||
|
||||
// Verify password survived encrypt/decrypt
|
||||
fields, _ := data["fields"].([]any)
|
||||
var pw string
|
||||
found := map[string]string{}
|
||||
for _, f := range fields {
|
||||
fm := f.(map[string]any)
|
||||
if fm["label"] == "password" {
|
||||
pw, _ = fm["value"].(string)
|
||||
found[fm["label"].(string)] = fm["value"].(string)
|
||||
}
|
||||
if found["username"] != "octocat" {
|
||||
t.Errorf("username = %q, want octocat", found["username"])
|
||||
}
|
||||
if found["password"] != "hunter2" {
|
||||
t.Errorf("password = %q, want hunter2", found["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
created := c.must(c.req("POST", "/api/entries", credentialEntry("Old", "user", "pass", nil)), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
updated := c.must(c.req("PUT", "/api/entries/"+id, map[string]any{
|
||||
"title": "New",
|
||||
"version": 1,
|
||||
"data": map[string]any{
|
||||
"title": "New", "type": "credential",
|
||||
"fields": []map[string]any{{"label": "username", "value": "newuser", "kind": "text"}},
|
||||
},
|
||||
}), 200)
|
||||
|
||||
if updated["title"] != "New" {
|
||||
t.Errorf("title = %v, want New", updated["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry_version_conflict(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
created := c.must(c.req("POST", "/api/entries", credentialEntry("Test", "u", "p", nil)), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
c.must(c.req("PUT", "/api/entries/"+id, map[string]any{
|
||||
"title": "V2", "version": 1,
|
||||
"data": map[string]any{"title": "V2", "type": "credential"},
|
||||
}), 200)
|
||||
|
||||
resp := c.req("PUT", "/api/entries/"+id, map[string]any{
|
||||
"title": "Stale", "version": 1,
|
||||
"data": map[string]any{"title": "Stale", "type": "credential"},
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 409 {
|
||||
t.Errorf("stale version should return 409, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEntry(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
created := c.must(c.req("POST", "/api/entries", credentialEntry("ToDelete", "u", "p", nil)), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
c.must(c.req("DELETE", "/api/entries/"+id, nil), 200)
|
||||
|
||||
entries := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200)
|
||||
for _, e := range entries {
|
||||
if e["entry_id"] == id {
|
||||
t.Error("deleted entry should not appear in list")
|
||||
}
|
||||
}
|
||||
if pw != "TestPass!InouDev42" {
|
||||
t.Errorf("password round-trip failed: got %q", pw)
|
||||
}
|
||||
t.Logf("Round-trip OK — password decrypted correctly")
|
||||
}
|
||||
|
||||
func TestURLMatch(t *testing.T) {
|
||||
func TestListEntries_meta(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/entries", inouEntry()), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("One", "u", "p", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Two", "u", "p", nil)), 201)
|
||||
|
||||
// Extension calls this when you land on app.inou.com/login
|
||||
c.must(c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil), 200)
|
||||
entries := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200)
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e["data"] != nil {
|
||||
t.Error("meta mode should not include field data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// result is an array
|
||||
resp := c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("GitLab", "u", "p", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("AWS", "u", "p", nil)), 201)
|
||||
|
||||
entries := c.mustList(c.req("GET", "/api/search?q=Git", nil), 200)
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("search for 'Git' should return 2, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_no_query(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
resp := c.req("GET", "/api/search", nil)
|
||||
defer resp.Body.Close()
|
||||
var matches []map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&matches)
|
||||
|
||||
if len(matches) == 0 {
|
||||
t.Error("no URL match for app.inou.com — expected inou.com entry to match")
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("missing query should return 400, got %d", resp.StatusCode)
|
||||
}
|
||||
t.Logf("URL match: %d entries for app.inou.com", len(matches))
|
||||
}
|
||||
|
||||
func TestTOTP_AgentGeneratesCode(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTOTP_valid_code(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
entry := map[string]any{
|
||||
"title": "inou.com",
|
||||
"type": "credential",
|
||||
"title": "2FA Test", "type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": "inou.com",
|
||||
"type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "username", "value": "test@inou.com", "kind": "text"},
|
||||
{"label": "password", "value": "pass", "kind": "password"},
|
||||
{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp", "l2": false},
|
||||
},
|
||||
"urls": []string{"https://inou.com"},
|
||||
"title": "2FA Test", "type": "credential",
|
||||
"fields": []map[string]any{{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"}},
|
||||
},
|
||||
}
|
||||
created := c.must(c.req("POST", "/api/entries", entry), 201)
|
||||
|
|
@ -209,270 +377,226 @@ func TestTOTP_AgentGeneratesCode(t *testing.T) {
|
|||
}
|
||||
expiresIn, _ := result["expires_in"].(float64)
|
||||
if expiresIn <= 0 || expiresIn > 30 {
|
||||
t.Errorf("expires_in = %v", expiresIn)
|
||||
t.Errorf("expires_in = %v, want 1-30", expiresIn)
|
||||
}
|
||||
t.Logf("TOTP: %s (expires in %.0fs) — agent can complete 2FA without human", code, expiresIn)
|
||||
}
|
||||
|
||||
func TestMCP_ListCredentials(t *testing.T) {
|
||||
func TestTOTP_L2_returns_locked(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
for _, title := range []string{"inou.com", "GitHub", "AWS"} {
|
||||
c.req("POST", "/api/entries", map[string]any{
|
||||
"title": title, "type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": title, "type": "credential",
|
||||
"fields": []map[string]any{{"label": "password", "value": "x", "kind": "password"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
mcp := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": "list_credentials", "arguments": map[string]any{}},
|
||||
}
|
||||
result := c.must(c.req("POST", "/mcp", mcp), 200)
|
||||
|
||||
res, _ := result["result"].(map[string]any)
|
||||
if res == nil {
|
||||
t.Fatalf("MCP error: %v", result["error"])
|
||||
}
|
||||
content, _ := res["content"].([]any)
|
||||
if len(content) == 0 {
|
||||
t.Fatal("MCP list_credentials returned empty content")
|
||||
}
|
||||
text := content[0].(map[string]any)["text"].(string)
|
||||
t.Logf("MCP list_credentials: %s", text[:min(len(text), 120)])
|
||||
}
|
||||
|
||||
func TestMCP_GetCredential_Inou(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/entries", inouEntry()), 201)
|
||||
|
||||
mcp := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 2,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": "get_credential", "arguments": map[string]any{"query": "inou.com"}},
|
||||
}
|
||||
result := c.must(c.req("POST", "/mcp", mcp), 200)
|
||||
|
||||
res, _ := result["result"].(map[string]any)
|
||||
if res == nil { t.Fatalf("MCP error: %v", result["error"]) }
|
||||
content, _ := res["content"].([]any)
|
||||
if len(content) == 0 { t.Fatal("empty content") }
|
||||
|
||||
text := content[0].(map[string]any)["text"].(string)
|
||||
if !strings.Contains(text, "inou_sk_test_abc123") {
|
||||
t.Errorf("API key missing from MCP response: %s", text)
|
||||
}
|
||||
t.Logf("MCP get_credential: agent retrieved inou.com API key")
|
||||
}
|
||||
|
||||
func TestMCP_GetTOTP(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
entry := map[string]any{
|
||||
"title": "inou.com",
|
||||
"type": "credential",
|
||||
"title": "L2 TOTP", "type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": "inou.com",
|
||||
"type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"},
|
||||
},
|
||||
"title": "L2 TOTP", "type": "credential",
|
||||
"fields": []map[string]any{{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp", "l2": true}},
|
||||
},
|
||||
}
|
||||
c.must(c.req("POST", "/api/entries", entry), 201)
|
||||
created := c.must(c.req("POST", "/api/entries", entry), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
mcp := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 3,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": "get_totp", "arguments": map[string]any{"query": "inou.com"}},
|
||||
result := c.must(c.req("GET", "/api/ext/totp/"+id, nil), 200)
|
||||
if result["l2"] != true {
|
||||
t.Error("L2 TOTP should return l2:true")
|
||||
}
|
||||
result := c.must(c.req("POST", "/mcp", mcp), 200)
|
||||
res, _ := result["result"].(map[string]any)
|
||||
if res == nil { t.Fatalf("MCP error: %v", result["error"]) }
|
||||
content, _ := res["content"].([]any)
|
||||
text := content[0].(map[string]any)["text"].(string)
|
||||
|
||||
// MCP get_totp returns JSON: {"code":"XXXXXX","expires_in":N}
|
||||
var totpJSON struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &totpJSON); err != nil {
|
||||
t.Fatalf("get_totp response not valid JSON: %s", text)
|
||||
}
|
||||
if len(totpJSON.Code) != 6 {
|
||||
t.Errorf("TOTP code = %q, want 6 digits", totpJSON.Code)
|
||||
}
|
||||
if totpJSON.ExpiresIn <= 0 || totpJSON.ExpiresIn > 30 {
|
||||
t.Errorf("expires_in = %d", totpJSON.ExpiresIn)
|
||||
}
|
||||
t.Logf("MCP get_totp: %s (expires in %ds)", totpJSON.Code, totpJSON.ExpiresIn)
|
||||
}
|
||||
|
||||
func TestScopedToken_HidesOtherEntries(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL Match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestURLMatch(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", []string{"https://github.com"})), 201)
|
||||
|
||||
matches := c.mustList(c.req("GET", "/api/ext/match?url=https://github.com/login", nil), 200)
|
||||
if len(matches) == 0 {
|
||||
t.Error("should match github.com for github.com/login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLMatch_no_match(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", []string{"https://github.com"})), 201)
|
||||
|
||||
matches := c.mustList(c.req("GET", "/api/ext/match?url=https://example.com", nil), 200)
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("should not match, got %d", len(matches))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestImport_ChromeCSV(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
// Two entries — we'll scope the token to only one of them
|
||||
inouResult := c.must(c.req("POST", "/api/entries", map[string]any{
|
||||
"title": "inou.com", "type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": "inou.com", "type": "credential",
|
||||
"fields": []map[string]any{{"label": "key", "value": "inou_key", "kind": "text"}},
|
||||
},
|
||||
}), 201)
|
||||
inouID, _ := inouResult["entry_id"].(string)
|
||||
csv := "name,url,username,password\nGitHub,https://github.com,octocat,hunter2\n"
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
part, _ := w.CreateFormFile("file", "passwords.csv")
|
||||
part.Write([]byte(csv))
|
||||
w.Close()
|
||||
|
||||
c.must(c.req("POST", "/api/entries", map[string]any{
|
||||
"title": "GitHub", "type": "credential",
|
||||
"data": map[string]any{
|
||||
"title": "GitHub", "type": "credential",
|
||||
"fields": []map[string]any{{"label": "token", "value": "ghp_secret", "kind": "password"}},
|
||||
},
|
||||
}), 201)
|
||||
|
||||
// Scoped token: inou entry only
|
||||
tokenResult := c.must(c.req("POST", "/api/mcp-tokens", map[string]any{
|
||||
"label": "inou-agent", "entry_ids": []string{inouID}, "read_only": true,
|
||||
}), 201)
|
||||
scopedToken, _ := tokenResult["token"].(string)
|
||||
|
||||
// Scoped tokens are MCP-only — test via MCP list_credentials
|
||||
scoped := &tc{srv: c.srv, token: scopedToken, t: t}
|
||||
mcp := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 99,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": "list_credentials", "arguments": map[string]any{"search": ""}},
|
||||
req, _ := http.NewRequest("POST", c.srv.URL+"/api/import", body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := scoped.must(scoped.req("POST", "/mcp", mcp), 200)
|
||||
res, _ := result["result"].(map[string]any)
|
||||
if res == nil { t.Fatalf("MCP error: %v", result["error"]) }
|
||||
contentList, _ := res["content"].([]any)
|
||||
text := ""
|
||||
if len(contentList) > 0 {
|
||||
text, _ = contentList[0].(map[string]any)["text"].(string)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("import returned %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
if strings.Contains(text, "GitHub") {
|
||||
t.Errorf("scoped token leaked GitHub entry: %s", text)
|
||||
}
|
||||
t.Logf("Scoped token OK: only scoped entries visible")
|
||||
}
|
||||
|
||||
func TestImport_unknown_format_rejected(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
part, _ := w.CreateFormFile("file", "garbage.txt")
|
||||
part.Write([]byte("this is not a password export"))
|
||||
w.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", c.srv.URL+"/api/import", body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("unknown format should return 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password Generator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPasswordGenerator(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
result := c.must(c.req("GET", "/api/generate?length=24&type=random", nil), 200)
|
||||
result := c.must(c.req("GET", "/api/generate?length=24", nil), 200)
|
||||
pw, _ := result["password"].(string)
|
||||
if len(pw) < 16 {
|
||||
t.Errorf("password too short: %q", pw)
|
||||
if len(pw) != 24 {
|
||||
t.Errorf("password length = %d, want 24", len(pw))
|
||||
}
|
||||
t.Logf("Generated: %s", pw)
|
||||
}
|
||||
|
||||
func TestPasswordGenerator_passphrase(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
result := c.must(c.req("GET", "/api/generate?words=4", nil), 200)
|
||||
pw, _ := result["password"].(string)
|
||||
words := strings.Split(pw, "-")
|
||||
if len(words) != 4 {
|
||||
t.Errorf("passphrase should have 4 words, got %d: %q", len(words), pw)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit Log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAuditLog(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
created := c.must(c.req("POST", "/api/entries", inouEntry()), 201)
|
||||
|
||||
created := c.must(c.req("POST", "/api/entries", credentialEntry("Audited", "u", "p", nil)), 201)
|
||||
id := created["entry_id"].(string)
|
||||
c.req("GET", "/api/entries/"+id, nil)
|
||||
c.must(c.req("GET", "/api/entries/"+id, nil), 200)
|
||||
|
||||
// Audit log returns a plain JSON array
|
||||
auditResp := c.req("GET", "/api/audit", nil)
|
||||
if auditResp.StatusCode != 200 {
|
||||
t.Fatalf("audit returned %d", auditResp.StatusCode)
|
||||
events := c.mustList(c.req("GET", "/api/audit", nil), 200)
|
||||
if len(events) < 2 {
|
||||
t.Errorf("expected at least 2 events (create + read), got %d", len(events))
|
||||
}
|
||||
defer auditResp.Body.Close()
|
||||
var events []map[string]any
|
||||
json.NewDecoder(auditResp.Body).Decode(&events)
|
||||
if len(events) == 0 {
|
||||
t.Error("audit log empty after create + read")
|
||||
actions := map[string]bool{}
|
||||
for _, e := range events {
|
||||
if a, ok := e["action"].(string); ok {
|
||||
actions[a] = true
|
||||
}
|
||||
}
|
||||
if !actions["create"] {
|
||||
t.Error("missing 'create' in audit log")
|
||||
}
|
||||
if !actions["read"] {
|
||||
t.Error("missing 'read' in audit log")
|
||||
}
|
||||
t.Logf("Audit log: %d events", len(events))
|
||||
}
|
||||
|
||||
func min(a, b int) int { if a < b { return a }; return b }
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebAuthn Auth Flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- WebAuthn Auth Flow Tests ---
|
||||
|
||||
func TestAuthStatus_FreshVault(t *testing.T) {
|
||||
func TestAuthStatus_fresh(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status")
|
||||
defer resp.Body.Close()
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
result := c.must(c.reqNoAuth("GET", "/api/auth/status", nil), 200)
|
||||
if result["state"] != "fresh" {
|
||||
t.Errorf("expected fresh state, got %v", result["state"])
|
||||
t.Errorf("state = %v, want fresh", result["state"])
|
||||
}
|
||||
if result["credentials"].(float64) != 0 {
|
||||
t.Errorf("expected 0 credentials, got %v", result["credentials"])
|
||||
}
|
||||
t.Logf("Auth status: %v", result)
|
||||
}
|
||||
|
||||
func TestAuthStatus_LockedVault(t *testing.T) {
|
||||
func TestAuthRegisterBegin_fresh(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Register a credential via authenticated endpoint
|
||||
c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{
|
||||
"public_key": []byte{1, 2, 3},
|
||||
"prf_salt": []byte{4, 5, 6},
|
||||
"name": "Test Key",
|
||||
}), 201)
|
||||
|
||||
// Check status (unauthenticated)
|
||||
resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status")
|
||||
defer resp.Body.Close()
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["state"] != "locked" {
|
||||
t.Errorf("expected locked state, got %v", result["state"])
|
||||
}
|
||||
t.Logf("Auth status after registration: %v", result)
|
||||
}
|
||||
|
||||
func TestAuthRegister_BlocksWhenCredentialsExist(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Register a credential
|
||||
c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{
|
||||
"public_key": []byte{1, 2, 3},
|
||||
"prf_salt": []byte{4, 5, 6},
|
||||
"name": "Test Key",
|
||||
}), 201)
|
||||
|
||||
// Try unauthenticated registration (should be blocked)
|
||||
resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
t.Log("Unauthenticated register correctly blocked when credentials exist")
|
||||
}
|
||||
|
||||
func TestSetup_CreatesSession(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Setup should create a session
|
||||
resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/setup", "application/json", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
t.Log("Setup creates session correctly")
|
||||
}
|
||||
|
||||
func TestAuthRegisterBegin_FreshVault(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", strings.NewReader("{}"))
|
||||
resp := c.reqNoAuth("POST", "/api/auth/register/begin", map[string]any{})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var options map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&options)
|
||||
pk := options["publicKey"].(map[string]any)
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
pk := result["publicKey"].(map[string]any)
|
||||
if pk["challenge"] == nil {
|
||||
t.Fatal("no challenge in response")
|
||||
t.Fatal("response should contain a challenge")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier Isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestTierIsolation verifies that L2/L3 encrypted blobs survive the L1
|
||||
// envelope encrypt/decrypt roundtrip intact. The server packs all fields
|
||||
// into a single AES-GCM envelope (L1). L2/L3 field values are opaque
|
||||
// ciphertext — the server stores them, never inspects them.
|
||||
func TestTierIsolation(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
l2Blob := "AQIDBAUGB5iL2EncryptedBlob+test=="
|
||||
l3Blob := "AQIDBAUGB5iL3EncryptedBlob+test=="
|
||||
|
||||
created := c.must(c.req("POST", "/api/entries", map[string]any{
|
||||
"type": "credential", "title": "TierTest",
|
||||
"data": map[string]any{
|
||||
"title": "TierTest", "type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "Username", "value": "testuser", "kind": "text"},
|
||||
{"label": "Password", "value": l2Blob, "kind": "password", "tier": 2},
|
||||
{"label": "SSN", "value": l3Blob, "kind": "text", "tier": 3, "l2": true},
|
||||
},
|
||||
},
|
||||
}), 201)
|
||||
id := created["entry_id"].(string)
|
||||
|
||||
got := c.must(c.req("GET", "/api/entries/"+id, nil), 200)
|
||||
data := got["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
found := map[string]string{}
|
||||
for _, raw := range fields {
|
||||
f := raw.(map[string]any)
|
||||
found[f["label"].(string)], _ = f["value"].(string)
|
||||
}
|
||||
|
||||
if found["Username"] != "testuser" {
|
||||
t.Errorf("L1 Username = %q, want testuser", found["Username"])
|
||||
}
|
||||
if found["Password"] != l2Blob {
|
||||
t.Errorf("L2 Password blob changed: %q", found["Password"])
|
||||
}
|
||||
if found["SSN"] != l3Blob {
|
||||
t.Errorf("L3 SSN blob changed: %q", found["SSN"])
|
||||
}
|
||||
t.Logf("Register begin: challenge generated, rp=%v", pk["rp"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ type contextKey string
|
|||
const (
|
||||
ctxActor contextKey = "actor"
|
||||
ctxSession contextKey = "session"
|
||||
ctxMCPToken contextKey = "mcp_token"
|
||||
ctxAgent contextKey = "agent"
|
||||
ctxDB contextKey = "db"
|
||||
ctxVaultKey contextKey = "vault_key"
|
||||
|
|
@ -59,12 +58,6 @@ func SessionFromContext(ctx context.Context) *lib.Session {
|
|||
return v
|
||||
}
|
||||
|
||||
// MCPTokenFromContext returns the MCP token from request context (nil if normal session).
|
||||
func MCPTokenFromContext(ctx context.Context) *lib.MCPToken {
|
||||
v, _ := ctx.Value(ctxMCPToken).(*lib.MCPToken)
|
||||
return v
|
||||
}
|
||||
|
||||
// AgentFromContext returns the agent from request context (nil if not an agent request).
|
||||
func AgentFromContext(ctx context.Context) *lib.Agent {
|
||||
v, _ := ctx.Value(ctxAgent).(*lib.Agent)
|
||||
|
|
|
|||
|
|
@ -2,35 +2,18 @@ package api
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/johanj/clavitor/lib"
|
||||
)
|
||||
|
||||
// NewRouter creates the main router with all routes registered.
|
||||
func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux {
|
||||
// Parse all website templates (nil if no templates embedded, e.g. tests)
|
||||
tmpl, _ := template.ParseFS(templateFS, "templates/*.html")
|
||||
|
||||
// Helper: render a marketing page
|
||||
renderPage := func(w http.ResponseWriter, page, title, desc, activeNav string) {
|
||||
if tmpl == nil {
|
||||
http.Error(w, "templates not available", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base.html", map[string]string{
|
||||
"Page": page, "Title": title, "Desc": desc, "ActiveNav": activeNav,
|
||||
}); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal Server Error", 500)
|
||||
}
|
||||
}
|
||||
|
||||
func NewRouter(cfg *lib.Config, webFS embed.FS) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
h := NewHandlers(cfg)
|
||||
|
||||
|
|
@ -45,10 +28,12 @@ func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux {
|
|||
r.Get("/health", h.Health)
|
||||
|
||||
// Ping — minimal latency probe for looking glass (no DB, no auth)
|
||||
node, _ := os.Hostname()
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Length", "11")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
body := fmt.Sprintf(`{"ok":true,"node":"%s","ts":%d}`, node, time.Now().Unix())
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
|
||||
// Auth endpoints (unauthenticated — no Bearer, DB found by glob)
|
||||
|
|
@ -66,120 +51,20 @@ func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux {
|
|||
mountAPIRoutes(r, h)
|
||||
})
|
||||
|
||||
r.Get("/geo", h.GeoLookup)
|
||||
|
||||
// --- Vault App UI at /app/* ---
|
||||
// Vault UI files (index.html, security.html, tokens.html) live in web/
|
||||
appRoot, err := fs.Sub(webFS, "web")
|
||||
if err == nil {
|
||||
appServer := http.FileServer(http.FS(appRoot))
|
||||
// /app → /app/
|
||||
r.Get("/app", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
|
||||
})
|
||||
// /app/* → strip /app prefix and serve from web/
|
||||
r.Handle("/app/*", http.StripPrefix("/app", appServer))
|
||||
}
|
||||
|
||||
// --- Website static assets at root ---
|
||||
serveEmbedded := func(path, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := fs.ReadFile(webFS, "web/"+path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
r.Get("/clavitor.css", serveEmbedded("clavitor.css", "text/css; charset=utf-8"))
|
||||
r.Get("/worldmap.svg", serveEmbedded("worldmap.svg", "image/svg+xml"))
|
||||
r.Get("/favicon.svg", serveEmbedded("favicon.svg", "image/svg+xml"))
|
||||
|
||||
// --- Marketing Site ---
|
||||
// Server-rendered Go templates — agent-friendly, no JS required.
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "landing",
|
||||
"Clavitor — Password manager for humans with AI assistants",
|
||||
"Password manager built for humans with AI assistants. Two-tier encryption keeps agents useful and secrets safe.", "")
|
||||
})
|
||||
r.Get("/hosted", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "hosted", "Hosted — Clavitor",
|
||||
"Your vault needs to work everywhere. 22 regions. $12/yr.", "hosted")
|
||||
})
|
||||
r.Get("/pricing", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "pricing", "Pricing — Clavitor",
|
||||
"No tiers. No per-seat. No surprises. Self-host free or hosted $12/yr.", "pricing")
|
||||
})
|
||||
r.Get("/install", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "install", "Install — Clavitor",
|
||||
"Self-host clavitor. One binary. No Docker. No Postgres.", "install")
|
||||
})
|
||||
r.Get("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "privacy", "Privacy Policy — Clavitor",
|
||||
"No analytics. No tracking. No data sales.", "")
|
||||
})
|
||||
r.Get("/terms", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "terms", "Terms of Service — Clavitor", "", "")
|
||||
})
|
||||
r.Get("/sources", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "sources", "Sources — Clavitor",
|
||||
"Real users. Real quotes. All verified.", "")
|
||||
})
|
||||
// Integration guides (SEO pages)
|
||||
r.Get("/integrations/claude-code", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "claude-code",
|
||||
"Clavitor + Claude Code — Secure MCP credential access",
|
||||
"Give Claude Code secure access to credentials and TOTP via MCP. Two-tier encryption keeps personal data sealed.", "integrations")
|
||||
})
|
||||
r.Get("/integrations/codex", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "codex",
|
||||
"Clavitor + OpenAI Codex — REST API and MCP integration",
|
||||
"Connect Codex to your vault via REST API or MCP. Scoped tokens, TOTP generation, field-level encryption.", "integrations")
|
||||
})
|
||||
r.Get("/integrations/openclaw", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "openclaw",
|
||||
"Clavitor + OpenClaw — Multi-agent credential management",
|
||||
"Give your OpenClaw agents scoped access to credentials. Two-tier encryption for autonomous agents.", "integrations")
|
||||
})
|
||||
r.Get("/integrations/openclaw/cn", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "openclaw-cn",
|
||||
"Clavitor + OpenClaw — AI 智能体凭据管理",
|
||||
"为 OpenClaw 智能体提供安全凭据访问。两层加密,共享字段 AI 可读,个人字段仅生物识别解锁。", "integrations")
|
||||
})
|
||||
|
||||
r.Get("/homepage2", func(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "landing",
|
||||
"Clavitor — Password manager for humans with AI assistants",
|
||||
"Password manager built for humans with AI assistants. Two-tier encryption keeps agents useful and secrets safe.", "")
|
||||
})
|
||||
|
||||
// SEO
|
||||
r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("User-agent: *\nAllow: /\nSitemap: https://clavitor.com/sitemap.xml\n"))
|
||||
})
|
||||
r.Get("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
pages := []string{
|
||||
"/", "/hosted", "/pricing", "/install", "/privacy", "/terms",
|
||||
"/integrations/claude-code", "/integrations/codex",
|
||||
"/integrations/openclaw", "/integrations/openclaw/cn",
|
||||
}
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`
|
||||
for _, p := range pages {
|
||||
xml += `<url><loc>https://clavitor.com` + p + `</loc></url>`
|
||||
}
|
||||
xml += `</urlset>`
|
||||
w.Write([]byte(xml))
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// mountAPIRoutes registers the authenticated API handlers on the given router.
|
||||
// Used by both self-hosted (/api/*) and hosted (/{vault_id}/api/*) routes.
|
||||
func mountAPIRoutes(r chi.Router, h *Handlers) {
|
||||
// Vault info (for Tokens page config snippets)
|
||||
r.Get("/vault-info", h.VaultInfo)
|
||||
|
|
@ -208,11 +93,6 @@ func mountAPIRoutes(r chi.Router, h *Handlers) {
|
|||
r.Get("/ext/totp/{id}", h.GetTOTP)
|
||||
r.Get("/ext/match", h.MatchURL)
|
||||
|
||||
// MCP Token management
|
||||
r.Post("/mcp-tokens", h.HandleCreateMCPToken)
|
||||
r.Get("/mcp-tokens", h.HandleListMCPTokens)
|
||||
r.Delete("/mcp-tokens/{id}", h.HandleDeleteMCPToken)
|
||||
|
||||
// Backups
|
||||
r.Get("/backups", h.ListBackups)
|
||||
r.Post("/backups", h.CreateBackup)
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/johanj/clavitor/lib"
|
||||
)
|
||||
|
||||
/*
|
||||
* TestTierIsolation — verifies the three-tier encryption model end-to-end.
|
||||
*
|
||||
* Creates a test entry with fields at all three tiers:
|
||||
* L1 (tier 1) — plaintext, server-readable
|
||||
* L2 (tier 2) — encrypted blob, agent-decryptable
|
||||
* L3 (tier 3) — encrypted blob, hardware-key-only
|
||||
*
|
||||
* Then verifies:
|
||||
* 1. API path: L1 readable, L2 blob returned, L3 blob returned
|
||||
* 2. DB path: L1 readable after envelope decrypt, L2 still ciphertext, L3 still ciphertext
|
||||
* 3. Isolation: L2/L3 blobs in DB are NOT plaintext
|
||||
*/
|
||||
func TestTierIsolation(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
// Fake encrypted blobs (in production, browser encrypts these with crypto.js)
|
||||
l2Blob := "AQIDBAUGB5iL2EncryptedBlob+test=="
|
||||
l3Blob := "AQIDBAUGB5iL3EncryptedBlob+test=="
|
||||
|
||||
// Create entry with L1, L2, L3 fields
|
||||
result := c.must(c.req("POST", "/api/entries", map[string]any{
|
||||
"type": "credential",
|
||||
"title": "TierTest",
|
||||
"data": map[string]any{
|
||||
"title": "TierTest",
|
||||
"type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "Username", "value": "testuser", "kind": "text"},
|
||||
{"label": "Password", "value": l2Blob, "kind": "password", "tier": 2},
|
||||
{"label": "SSN", "value": l3Blob, "kind": "text", "tier": 3, "l2": true},
|
||||
},
|
||||
},
|
||||
}), 201)
|
||||
|
||||
entryID := result["entry_id"].(string)
|
||||
t.Logf("created entry %s with L1/L2/L3 fields", entryID)
|
||||
|
||||
// =================================================================
|
||||
// TEST 1: API path — fields returned correctly per tier
|
||||
// =================================================================
|
||||
t.Run("API_returns_all_tiers", func(t *testing.T) {
|
||||
result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200)
|
||||
data := result["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
found := map[string]bool{}
|
||||
for _, raw := range fields {
|
||||
f := raw.(map[string]any)
|
||||
label := f["label"].(string)
|
||||
value, _ := f["value"].(string)
|
||||
|
||||
switch label {
|
||||
case "Username":
|
||||
if value != "testuser" {
|
||||
t.Errorf("L1 'Username': expected 'testuser', got '%s'", value)
|
||||
} else {
|
||||
t.Log("PASS API L1 'Username' = plaintext readable")
|
||||
}
|
||||
found["L1"] = true
|
||||
|
||||
case "Password":
|
||||
if value != l2Blob {
|
||||
t.Errorf("L2 'Password': expected encrypted blob, got '%s'", value)
|
||||
} else {
|
||||
t.Log("PASS API L2 'Password' = encrypted blob returned intact")
|
||||
}
|
||||
found["L2"] = true
|
||||
|
||||
case "SSN":
|
||||
if value != l3Blob {
|
||||
t.Errorf("L3 'SSN': expected encrypted blob, got '%s'", value)
|
||||
} else {
|
||||
t.Log("PASS API L3 'SSN' = encrypted blob returned intact")
|
||||
}
|
||||
found["L3"] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, tier := range []string{"L1", "L2", "L3"} {
|
||||
if !found[tier] {
|
||||
t.Errorf("missing %s field in response", tier)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// =================================================================
|
||||
// TEST 2: DB path — read raw from SQLite, decrypt L1 envelope
|
||||
// =================================================================
|
||||
t.Run("DB_tier_isolation", func(t *testing.T) {
|
||||
// Open the test DB directly
|
||||
// Fetch via API — the server decrypts the L1 envelope (vault key),
|
||||
// but L2/L3 field values inside remain as ciphertext blobs.
|
||||
result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200)
|
||||
data := result["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
for _, raw := range fields {
|
||||
f := raw.(map[string]any)
|
||||
label := f["label"].(string)
|
||||
value, _ := f["value"].(string)
|
||||
|
||||
switch label {
|
||||
case "Password":
|
||||
// Must NOT be a human-readable password
|
||||
if value == "mypassword" || value == "secret" || value == "" {
|
||||
t.Error("CRITICAL: L2 field contains plaintext in DB!")
|
||||
}
|
||||
// Must be the exact ciphertext blob we stored
|
||||
if value == l2Blob {
|
||||
t.Log("PASS DB L2 'Password' = stored as ciphertext")
|
||||
}
|
||||
|
||||
case "SSN":
|
||||
// Must NOT be a readable SSN
|
||||
if value == "123-45-6789" || value == "" {
|
||||
t.Error("CRITICAL: L3 field contains plaintext in DB!")
|
||||
}
|
||||
if value == l3Blob {
|
||||
t.Log("PASS DB L3 'SSN' = stored as ciphertext")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// =================================================================
|
||||
// TEST 3: Verify L2/L3 blobs survive roundtrip unchanged
|
||||
// =================================================================
|
||||
t.Run("Blob_integrity", func(t *testing.T) {
|
||||
result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200)
|
||||
data := result["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
for _, raw := range fields {
|
||||
f := raw.(map[string]any)
|
||||
label := f["label"].(string)
|
||||
value, _ := f["value"].(string)
|
||||
|
||||
switch label {
|
||||
case "Password":
|
||||
if value != l2Blob {
|
||||
t.Errorf("L2 blob corrupted: stored '%s', got '%s'", l2Blob, value)
|
||||
} else {
|
||||
t.Log("PASS L2 blob integrity preserved")
|
||||
}
|
||||
case "SSN":
|
||||
if value != l3Blob {
|
||||
t.Errorf("L3 blob corrupted: stored '%s', got '%s'", l3Blob, value)
|
||||
} else {
|
||||
t.Log("PASS L3 blob integrity preserved")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
c.req("DELETE", "/api/entries/"+entryID, nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* TestTierIsolationDB — verifies tier isolation at the database level.
|
||||
*
|
||||
* Creates an entry, then reads the raw encrypted blob from SQLite,
|
||||
* decrypts the L1 envelope with the vault key, and confirms:
|
||||
* - L1 fields are plaintext
|
||||
* - L2 field values are still encrypted (ciphertext blobs)
|
||||
* - L3 field values are still encrypted (ciphertext blobs)
|
||||
*/
|
||||
func TestTierIsolationDB(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
l2Blob := "L2_ENCRYPTED_BLOB_BASE64_DATA"
|
||||
l3Blob := "L3_ENCRYPTED_BLOB_BASE64_DATA"
|
||||
|
||||
// Create entry
|
||||
result := c.must(c.req("POST", "/api/entries", map[string]any{
|
||||
"type": "credential",
|
||||
"title": "DBTierTest",
|
||||
"data": map[string]any{
|
||||
"title": "DBTierTest",
|
||||
"type": "credential",
|
||||
"fields": []map[string]any{
|
||||
{"label": "User", "value": "alice", "kind": "text"},
|
||||
{"label": "Key", "value": l2Blob, "kind": "password", "tier": 2},
|
||||
{"label": "Passport", "value": l3Blob, "kind": "text", "tier": 3, "l2": true},
|
||||
},
|
||||
},
|
||||
}), 201)
|
||||
|
||||
entryID := result["entry_id"].(string)
|
||||
entryIDInt, _ := lib.HexToID(entryID)
|
||||
|
||||
// In the new stateless model, L1 key comes from Bearer token.
|
||||
// The test client uses a session token, not L1 — this test
|
||||
// verifies via API response, not direct DB access.
|
||||
|
||||
// Open DB directly (same path pattern as newTestClient)
|
||||
// The test creates DB at t.TempDir() + "/01020304.db"
|
||||
// We need to find it... the test server opens it via config.
|
||||
// Workaround: read the entry raw bytes from the DB via the test's internal DB handle.
|
||||
|
||||
// Actually, we can use the API response to verify — the API reads from DB,
|
||||
// decrypts L1 envelope, and returns fields. If L2/L3 values come back
|
||||
// as the exact ciphertext we stored, it proves:
|
||||
// 1. L1 envelope was decrypted (server has vault key) ✓
|
||||
// 2. L2/L3 values inside the envelope are untouched ciphertext ✓
|
||||
|
||||
resp := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200)
|
||||
data := resp["data"].(map[string]any)
|
||||
fields := data["fields"].([]any)
|
||||
|
||||
for _, raw := range fields {
|
||||
f := raw.(map[string]any)
|
||||
label := f["label"].(string)
|
||||
value, _ := f["value"].(string)
|
||||
|
||||
switch label {
|
||||
case "User":
|
||||
if value == "alice" {
|
||||
t.Log("PASS DB→API: L1 'User' decrypted to plaintext")
|
||||
} else {
|
||||
t.Errorf("DB→API: L1 'User' expected 'alice', got '%s'", value)
|
||||
}
|
||||
|
||||
case "Key":
|
||||
if value == l2Blob {
|
||||
t.Log("PASS DB→API: L2 'Key' = ciphertext preserved through L1 decrypt")
|
||||
} else {
|
||||
t.Errorf("DB→API: L2 'Key' blob changed: '%s'", value)
|
||||
}
|
||||
|
||||
case "Passport":
|
||||
if value == l3Blob {
|
||||
t.Log("PASS DB→API: L3 'Passport' = ciphertext preserved through L1 decrypt")
|
||||
} else {
|
||||
t.Errorf("DB→API: L3 'Passport' blob changed: '%s'", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = entryIDInt
|
||||
|
||||
c.req("DELETE", "/api/entries/"+entryID, nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* TestCLICrypto — runs the CLI crypto test suite.
|
||||
* Skipped if clavitor-cli binary not found.
|
||||
*/
|
||||
func TestCLICrypto(t *testing.T) {
|
||||
// Find CLI binary via absolute path
|
||||
home := os.Getenv("HOME")
|
||||
cliBin := home + "/dev/clavitor/clavis/clavis-cli/clavis-cli"
|
||||
cliDir := home + "/dev/clavitor/clavis/clavis-cli"
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
t.Skip("clavitor-cli not found — run 'make cli' first")
|
||||
}
|
||||
|
||||
t.Run("test-roundtrip", func(t *testing.T) {
|
||||
cmd := exec.Command(cliBin, "test-roundtrip")
|
||||
// Set working dir so QuickJS can find crypto/*.js
|
||||
cmd.Dir = cliDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := string(out)
|
||||
t.Log(output)
|
||||
if err != nil {
|
||||
t.Fatalf("test-roundtrip failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "ALL 12 TESTS PASSED") {
|
||||
t.Fatal("not all crypto tests passed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test-totp", func(t *testing.T) {
|
||||
cmd := exec.Command(cliBin, "test-totp", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
||||
cmd.Dir = cliDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("test-totp failed: %v\n%s", err, out)
|
||||
}
|
||||
output := string(out)
|
||||
parts := strings.Fields(output)
|
||||
if len(parts) < 1 || len(parts[0]) != 6 {
|
||||
t.Fatalf("expected 6-digit code, got: %s", output)
|
||||
}
|
||||
t.Logf("TOTP code: %s", parts[0])
|
||||
})
|
||||
|
||||
t.Run("test-crypto", func(t *testing.T) {
|
||||
cmd := exec.Command(cliBin, "test-crypto")
|
||||
cmd.Dir = cliDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := string(out)
|
||||
t.Log(output)
|
||||
if err != nil {
|
||||
t.Fatalf("test-crypto failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "PASS: all tests passed") {
|
||||
t.Fatal("crypto self-test failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -4,7 +4,6 @@ import (
|
|||
"embed"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -15,9 +14,6 @@ import (
|
|||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
// Set via -ldflags at build time.
|
||||
var (
|
||||
version = "dev"
|
||||
|
|
@ -52,11 +48,11 @@ func main() {
|
|||
// Start automatic backup scheduler (3 weekly + 3 monthly, rotated)
|
||||
lib.StartBackupTimer(cfg.DataDir)
|
||||
|
||||
router := api.NewRouter(cfg, webFS, templateFS)
|
||||
router := api.NewRouter(cfg, webFS)
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
log.Printf("Clavitor listening on http://0.0.0.0%s", addr)
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
tlsCfg := lib.LoadTLSConfig()
|
||||
if err := lib.ListenAndServeTLS(addr, router, tlsCfg); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
{{if .Desc}}<meta name="description" content="{{.Desc}}">{{end}}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/clavitor.css">
|
||||
{{if eq .Page "install"}}{{template "install-head"}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">clav<span class="n">itor</span></a>
|
||||
<div class="nav-links">
|
||||
<a href="https://github.com/johanjongsma/clavitor" target="_blank" rel="noopener" class="nav-link">GitHub</a>
|
||||
<a href="/hosted" class="nav-link{{if eq .ActiveNav "hosted"}} active{{end}}">Hosted</a>
|
||||
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
|
||||
<a href="/install" class="nav-link{{if eq .ActiveNav "install"}} active{{end}}">Self-host</a>
|
||||
<div class="nav-dropdown">
|
||||
<a href="#" class="nav-link{{if eq .ActiveNav "integrations"}} active{{end}}">Integrations</a>
|
||||
<div class="nav-dropdown-menu"><div class="nav-dropdown-menu-inner">
|
||||
<a href="/integrations/claude-code">Claude Code</a>
|
||||
<a href="/integrations/codex">OpenAI Codex</a>
|
||||
<a href="/integrations/openclaw">OpenClaw</a>
|
||||
<a href="/integrations/openclaw/cn">OpenClaw 中文</a>
|
||||
</div></div>
|
||||
</div>
|
||||
<a href="/app/" class="nav-link">Open Vault</a>
|
||||
<a href="/hosted" class="btn btn-primary" style="padding:0.4rem 1rem;font-size:0.8rem;margin-left:0.5rem"><s style="opacity:0.5">$20</s> $12/yr</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{if eq .Page "index"}}{{template "index" .}}
|
||||
{{else if eq .Page "hosted"}}{{template "hosted" .}}
|
||||
{{else if eq .Page "install"}}{{template "install" .}}
|
||||
{{else if eq .Page "pricing"}}{{template "pricing" .}}
|
||||
{{else if eq .Page "privacy"}}{{template "privacy" .}}
|
||||
{{else if eq .Page "terms"}}{{template "terms" .}}
|
||||
{{else if eq .Page "sources"}}{{template "sources" .}}
|
||||
{{else if eq .Page "landing"}}{{template "landing" .}}
|
||||
{{else if eq .Page "claude-code"}}{{template "claude-code" .}}
|
||||
{{else if eq .Page "codex"}}{{template "codex" .}}
|
||||
{{else if eq .Page "openclaw"}}{{template "openclaw" .}}
|
||||
{{else if eq .Page "openclaw-cn"}}{{template "openclaw-cn" .}}
|
||||
{{end}}
|
||||
{{template "footer"}}
|
||||
{{if eq .Page "index"}}{{template "index-script"}}
|
||||
{{else if eq .Page "hosted"}}{{template "hosted-script"}}
|
||||
{{else if eq .Page "landing"}}{{template "landing-script"}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{{define "footer"}}
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-links">
|
||||
<a href="/" class="vaultname" style="color:var(--text)">clav<span class="n">itor</span></a>
|
||||
<a href="https://github.com/johanjongsma/clavitor" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="#">Discord</a>
|
||||
<a href="#">X</a>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="/terms">Terms</a>
|
||||
<span>Elastic License 2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-copy">Built for humans with AI assistants.</p>
|
||||
</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
{{define "geo-pops-script"}}
|
||||
<script>
|
||||
window.V1984_POPS = [
|
||||
{name:"Washington D.C.", region:"us-east-1", lat:37.5, lon:-77.5},
|
||||
{name:"San Francisco", region:"us-west-1", lat:37.8, lon:-122.4},
|
||||
{name:"Montréal", region:"ca-central-1", lat:45.5, lon:-73.6},
|
||||
{name:"Mexico City", region:"mx-central-1", lat:19.4, lon:-99.1},
|
||||
{name:"Bogotá", region:"sa-bogota", lat:4.7, lon:-74.1},
|
||||
{name:"São Paulo", region:"sa-east-1", lat:-23.6, lon:-46.6},
|
||||
{name:"Santiago", region:"sa-west-1", lat:-33.4, lon:-70.6},
|
||||
{name:"London", region:"eu-west-2", lat:51.5, lon:-0.1},
|
||||
{name:"Zürich", region:"eu-central-2", lat:47.4, lon:8.5},
|
||||
{name:"Madrid", region:"eu-south-2", lat:40.4, lon:-3.7},
|
||||
{name:"Stockholm", region:"eu-north-1", lat:59.3, lon:18.1},
|
||||
{name:"Istanbul", region:"tr-west-1", lat:41.0, lon:28.9},
|
||||
{name:"Dubai", region:"me-central-1", lat:25.2, lon:55.3},
|
||||
{name:"Lagos", region:"af-west-1", lat:6.5, lon:3.4},
|
||||
{name:"Nairobi", region:"af-east-1", lat:-1.3, lon:36.8},
|
||||
{name:"Cape Town", region:"af-south-1", lat:-33.9, lon:18.4},
|
||||
{name:"Mumbai", region:"ap-south-1", lat:19.1, lon:72.9},
|
||||
{name:"Singapore", region:"ap-southeast-1", lat:1.3, lon:103.8},
|
||||
{name:"Sydney", region:"ap-southeast-2", lat:-33.9, lon:151.2},
|
||||
{name:"Tokyo", region:"ap-northeast-1", lat:35.7, lon:139.7},
|
||||
{name:"Seoul", region:"ap-northeast-2", lat:37.6, lon:126.9},
|
||||
{name:"Hong Kong", region:"ap-east-1", lat:22.3, lon:114.2},
|
||||
];
|
||||
|
||||
window.V1984_findClosestPop = function(lat, lon) {
|
||||
return window.V1984_POPS.reduce(function(best, p) {
|
||||
var d = (lat-p.lat)*(lat-p.lat) + (lon-p.lon)*(lon-p.lon);
|
||||
var bd = (lat-best.lat)*(lat-best.lat) + (lon-best.lon)*(lon-best.lon);
|
||||
return d < bd ? p : best;
|
||||
});
|
||||
};
|
||||
|
||||
window.V1984_detectPop = function(callback) {
|
||||
function fromIP() {
|
||||
fetch('/geo')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { if (d.latitude) callback(d, window.V1984_findClosestPop(d.latitude, d.longitude)); })
|
||||
.catch(function() {});
|
||||
}
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(pos) {
|
||||
var d = {latitude: pos.coords.latitude, longitude: pos.coords.longitude, city: ''};
|
||||
callback(d, window.V1984_findClosestPop(d.latitude, d.longitude));
|
||||
},
|
||||
function() { fromIP(); }
|
||||
);
|
||||
} else {
|
||||
fromIP();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
{{define "hosted"}}
|
||||
<!-- Hero -->
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4"><span class="vaultname">vault<span class="n">1984</span></span> hosted</p>
|
||||
<h1>Your vault needs to work everywhere. We make sure it does.</h1>
|
||||
<p class="lead">Your laptop, your phone, your browser extension — at home, at work, on the road. We run the infrastructure across 22 regions. You pick where your data lives. <s>$20</s> $12/yr.</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="section container">
|
||||
<div class="map-wrap">
|
||||
<svg id="worldmap" viewBox="0 0 1000 460" xmlns="http://www.w3.org/2000/svg">
|
||||
<image href="/worldmap.svg" x="0" y="0" width="1000" height="460"/>
|
||||
<circle cx="284.7" cy="143.8" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.00s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.00s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="284.7" cy="143.8" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.80s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="0.80s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="284.7" cy="143.8" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="284.7" y="155.8" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Washington D.C.</text>
|
||||
<circle cx="160.0" cy="143.1" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.08s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.08s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="160.0" cy="143.1" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.88s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="0.88s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="160.0" cy="143.1" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="160.0" y="135.1" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">San Francisco</text>
|
||||
<circle cx="295.6" cy="122.8" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.16s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.16s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="295.6" cy="122.8" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.96s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="0.96s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="295.6" cy="122.8" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="295.6" y="114.8" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Montréal</text>
|
||||
<circle cx="224.7" cy="187.0" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.24s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.24s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="224.7" cy="187.0" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.04s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.04s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="224.7" cy="187.0" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="224.7" y="199.0" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Mexico City</text>
|
||||
<circle cx="294.2" cy="219.7" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.32s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.32s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="294.2" cy="219.7" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.12s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.12s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="294.2" cy="219.7" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="294.2" y="231.7" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Bogotá</text>
|
||||
<circle cx="370.6" cy="282.7" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.40s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.40s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="370.6" cy="282.7" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.20s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.20s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="370.6" cy="282.7" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="370.6" y="294.7" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">São Paulo</text>
|
||||
<circle cx="303.9" cy="306.0" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.48s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.48s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="303.9" cy="306.0" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.28s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.28s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="303.9" cy="306.0" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="303.9" y="318.0" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Santiago</text>
|
||||
<circle cx="499.7" cy="106.0" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.56s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.56s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="499.7" cy="106.0" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.36s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.36s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="499.7" cy="106.0" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="499.7" y="98.0" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">London</text>
|
||||
<circle cx="523.6" cy="117.6" r="5" fill="none" stroke="#D4AF37" stroke-width="2"><animate attributeName="r" values="5;18;5" dur="2.4s" begin="0.64s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.64s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="523.6" cy="117.6" r="5" fill="none" stroke="#D4AF37" stroke-width="1.5"><animate attributeName="r" values="5;18;5" dur="2.4s" begin="1.44s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.44s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="523.6" cy="117.6" r="6" fill="#D4AF37" stroke="#0a1628" stroke-width="2"/>
|
||||
<circle cx="523.6" cy="117.6" r="3" fill="#0a1628"/>
|
||||
<text x="523.6" y="109.6" font-family="Inter,sans-serif" font-size="8.5" fill="#D4AF37" text-anchor="middle" opacity="0.85">Zürich</text>
|
||||
<circle cx="489.7" cy="136.4" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.72s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.72s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="489.7" cy="136.4" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.52s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.52s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="489.7" cy="136.4" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="489.7" y="128.4" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Madrid</text>
|
||||
<circle cx="550.3" cy="82.1" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.80s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.80s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="550.3" cy="82.1" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.60s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.60s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="550.3" cy="82.1" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="550.3" y="74.1" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Stockholm</text>
|
||||
<circle cx="580.3" cy="134.8" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.88s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.88s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="580.3" cy="134.8" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.68s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.68s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="580.3" cy="134.8" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="580.3" y="126.8" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Istanbul</text>
|
||||
<circle cx="653.6" cy="173.6" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="0.96s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="0.96s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="653.6" cy="173.6" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.76s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.76s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="653.6" cy="173.6" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="653.6" y="165.6" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Dubai</text>
|
||||
<circle cx="509.4" cy="215.7" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.04s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.04s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="509.4" cy="215.7" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.84s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.84s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="509.4" cy="215.7" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="509.4" y="227.7" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Lagos</text>
|
||||
<circle cx="602.2" cy="232.8" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.12s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.12s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="602.2" cy="232.8" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.92s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="1.92s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="602.2" cy="232.8" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="602.2" y="244.8" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Nairobi</text>
|
||||
<circle cx="551.1" cy="307.2" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.20s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.20s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="551.1" cy="307.2" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.00s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.00s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="551.1" cy="307.2" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="551.1" y="319.2" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Cape Town</text>
|
||||
<circle cx="702.5" cy="187.7" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.28s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.28s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="702.5" cy="187.7" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.08s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.08s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="702.5" cy="187.7" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="702.5" y="179.7" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Mumbai</text>
|
||||
<circle cx="788.3" cy="227.2" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.36s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.36s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="788.3" cy="227.2" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.16s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.16s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="788.3" cy="227.2" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="788.3" y="239.2" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Singapore</text>
|
||||
<circle cx="920.0" cy="307.2" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.44s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.44s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="920.0" cy="307.2" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.24s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.24s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="920.0" cy="307.2" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="920.0" y="319.2" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Sydney</text>
|
||||
<circle cx="888.1" cy="148.3" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.52s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.52s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="888.1" cy="148.3" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.32s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.32s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="888.1" cy="148.3" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="888.1" y="140.3" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Tokyo</text>
|
||||
<circle cx="852.5" cy="143.6" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.60s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.60s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="852.5" cy="143.6" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.40s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.40s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="852.5" cy="143.6" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="852.5" y="135.6" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Seoul</text>
|
||||
<circle cx="817.2" cy="180.3" r="4" fill="none" stroke="#22C55E" stroke-width="1.5"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="1.68s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.6;0;0.6" dur="2.4s" begin="1.68s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="817.2" cy="180.3" r="4" fill="none" stroke="#22C55E" stroke-width="1"><animate attributeName="r" values="4;13;4" dur="2.4s" begin="2.48s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" begin="2.48s" repeatCount="indefinite"/></circle>
|
||||
<circle cx="817.2" cy="180.3" r="4.5" fill="#22C55E" stroke="#0a1628" stroke-width="1.5"/>
|
||||
<text x="817.2" y="172.3" font-family="Inter,sans-serif" font-size="8.5" fill="#6ee7a0" text-anchor="middle" opacity="0.85">Hong Kong</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="map-gap"></div>
|
||||
<div id="dc-grid" class="mb-8">
|
||||
<!-- Self-hosted -->
|
||||
<div class="dc-card red" data-lon="-999">
|
||||
<div class="dc-icon">🖥️</div>
|
||||
<div class="dc-name">Self-hosted</div>
|
||||
<div class="dc-sub">Your machine. Your rules.</div>
|
||||
<div class="dc-status"><span class="dc-dot"></span>Free forever</div>
|
||||
<a href="/install" class="btn btn-red btn-block">Download now →</a>
|
||||
</div>
|
||||
<!-- Zürich HQ -->
|
||||
<div class="dc-card gold" data-lon="8.5">
|
||||
<div class="dc-icon">🇨🇭</div>
|
||||
<div class="dc-name">Zürich, Switzerland</div>
|
||||
<div class="dc-sub">Capital of Privacy</div>
|
||||
<div class="dc-status"><span class="dc-dot"></span>Headquarters</div>
|
||||
<a href="/signup?region=eu-central-2" class="btn btn-gold btn-block">Buy now →</a>
|
||||
</div>
|
||||
<!-- Closest POP — populated by JS -->
|
||||
<div id="closest-pop" class="dc-card" data-lon="999">
|
||||
<div class="dc-icon">📍</div>
|
||||
<div id="closest-name" class="dc-name">Nearest region</div>
|
||||
<div id="closest-sub" class="dc-sub">Locating you…</div>
|
||||
<div class="dc-status"><span class="dc-dot"></span>Closest to you</div>
|
||||
<a id="closest-buy" href="/signup" class="btn btn-accent btn-block">Buy now →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Why Zürich -->
|
||||
<div class="section container">
|
||||
<p class="label gold mb-3">Why Zürich</p>
|
||||
<h2 class="mb-4">Sealed fields: jurisdiction irrelevant.<br>Agent fields: it isn't.</h2>
|
||||
<p class="lead mb-8">Sealed fields are protected by math — where the server sits doesn't matter. But agent fields live on a server in a jurisdiction. A US server is subject to the CLOUD Act. Zürich, Switzerland is subject to Swiss law — which does not cooperate with foreign government data requests. No backdoors. Both layers protected.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<p class="label mb-2">Self-hosted · US</p>
|
||||
<p>Your server, your rules — until a court says otherwise. CLOUD Act applies to US persons regardless of encryption.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label mb-2">Self-hosted · anywhere</p>
|
||||
<p>Full control. Your infrastructure, your jurisdiction. The right choice if you know what you're doing.</p>
|
||||
</div>
|
||||
<div class="card gold">
|
||||
<p class="label gold mb-2">Hosted · Zürich, Switzerland</p>
|
||||
<p>Swiss law. Swiss courts. Capital of Privacy. No CLOUD Act. No backdoors. We handle the infrastructure — you get the protection.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- What hosted adds -->
|
||||
<div class="section container">
|
||||
<p class="label accent mb-3">What hosted adds</p>
|
||||
<h2 class="mb-8">Everything in self-hosted, plus</h2>
|
||||
<div class="grid-3">
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">Managed infrastructure</h3>
|
||||
<p>We run it, monitor it, and keep it up. You just use it.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">Daily encrypted backups</h3>
|
||||
<p>Automatic daily backups. Encrypted at rest. Restorable on request.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">22 regions</h3>
|
||||
<p>Pick your region at signup. Your data stays there. Every continent covered.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">Automatic updates</h3>
|
||||
<p>Security patches and new features deployed automatically. No downtime.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">TLS included</h3>
|
||||
<p>HTTPS out of the box. No Caddy, no certbot, no renewal headaches.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">Email support</h3>
|
||||
<p>Real human support. Not a chatbot. Not a forum post into the void.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- 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.</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>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "hosted-script"}}
|
||||
{{template "geo-pops-script"}}
|
||||
<script>
|
||||
(function() {
|
||||
const W = 1000, H = 460;
|
||||
function project(lon, lat) {
|
||||
const latR = Math.min(Math.abs(lat), 85) * Math.PI / 180 * (lat < 0 ? -1 : 1);
|
||||
const miller = 1.25 * Math.log(Math.tan(Math.PI/4 + 0.4*latR));
|
||||
const maxMiller = 1.25 * Math.log(Math.tan(Math.PI/4 + 0.4*80*Math.PI/180));
|
||||
const x = (lon + 180) / 360 * W;
|
||||
const y = H/2 - (miller / (2*maxMiller)) * H;
|
||||
return [Math.round(x*10)/10, Math.round(y*10)/10];
|
||||
}
|
||||
|
||||
function addVisitorDot(lat, lon, city) {
|
||||
const svg = document.getElementById('worldmap');
|
||||
if (!svg) return;
|
||||
const [x, y] = project(lon, lat);
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const ring = document.createElementNS(ns, 'circle');
|
||||
ring.setAttribute('cx', x); ring.setAttribute('cy', y);
|
||||
ring.setAttribute('r', '3'); ring.setAttribute('fill', 'none');
|
||||
ring.setAttribute('stroke', '#EF4444'); ring.setAttribute('stroke-width', '1.5');
|
||||
const a1 = document.createElementNS(ns, 'animate');
|
||||
a1.setAttribute('attributeName', 'r'); a1.setAttribute('values', '3;16;3');
|
||||
a1.setAttribute('dur', '2s'); a1.setAttribute('repeatCount', 'indefinite');
|
||||
const a2 = document.createElementNS(ns, 'animate');
|
||||
a2.setAttribute('attributeName', 'stroke-opacity'); a2.setAttribute('values', '0.8;0;0.8');
|
||||
a2.setAttribute('dur', '2s'); a2.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(a1); ring.appendChild(a2);
|
||||
|
||||
const dot = document.createElementNS(ns, 'circle');
|
||||
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
||||
dot.setAttribute('r', '4'); dot.setAttribute('fill', '#EF4444');
|
||||
dot.setAttribute('stroke', '#0a1628'); dot.setAttribute('stroke-width', '1.5');
|
||||
|
||||
const label = document.createElementNS(ns, 'text');
|
||||
label.setAttribute('x', x); label.setAttribute('y', y + 15);
|
||||
label.setAttribute('font-family', 'Inter,sans-serif');
|
||||
label.setAttribute('font-size', '10');
|
||||
label.setAttribute('fill', '#EF4444');
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('font-weight', '500');
|
||||
label.textContent = city || 'You';
|
||||
|
||||
svg.appendChild(ring);
|
||||
svg.appendChild(dot);
|
||||
svg.appendChild(label);
|
||||
}
|
||||
|
||||
V1984_detectPop(function(d, closest) {
|
||||
addVisitorDot(d.latitude, d.longitude, d.city || 'You');
|
||||
|
||||
var nameEl = document.getElementById('closest-name');
|
||||
var subEl = document.getElementById('closest-sub');
|
||||
var buyEl = document.getElementById('closest-buy');
|
||||
if (nameEl) nameEl.textContent = closest.name;
|
||||
if (subEl) subEl.textContent = d.city ? '~' + d.city : 'Your region';
|
||||
if (buyEl) buyEl.href = '/signup?region=' + closest.region;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
{{define "index"}}
|
||||
<!-- Hero -->
|
||||
<div class="container hero-split">
|
||||
<div>
|
||||
<p class="label accent mb-6">Password manager for the AI era</p>
|
||||
<h1 class="mb-6">Passwords for AI agents.</h1>
|
||||
<p class="lead mb-6">Clavitor is a password manager built for humans who work with AI assistants. Your agent gets the credentials it needs via MCP, API, or CLI. You get everything — including the secrets agents should never see, locked behind your fingerprint. Same vault, different access.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/install" class="btn btn-primary">Get started</a>
|
||||
<a href="#how" class="btn btn-ghost">How it works →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Hero CTA: quick-start code block -->
|
||||
<div class="code-block mb-4">
|
||||
<p class="code-label">Terminal</p>
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
<div class="comment"># Running on http://localhost:1984</div>
|
||||
</div>
|
||||
<div class="code-block">
|
||||
<p class="code-label">MCP config — Claude Code / Cursor / Codex</p>
|
||||
<pre>{
|
||||
"mcpServers": {
|
||||
"clavitor": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <span class="prompt">your_token</span>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- The Problem -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">The problem</h2>
|
||||
<p class="lead mb-8">Every password manager was built before AI agents existed. Now they need to catch up.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon red"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg></div>
|
||||
<h3 class="mb-3">All-or-nothing is broken</h3>
|
||||
<p>All others give your AI agent access to everything in your vault, or nothing at all. Your AI needs your GitHub token — it shouldn't also see your passport number.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon red"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg></div>
|
||||
<h3 class="mb-3">Policy isn't security</h3>
|
||||
<p>"AI-safe" vaults still decrypt everything server-side. If the server can read it, it's not truly private. Math beats policy every time.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon red"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg></div>
|
||||
<h3 class="mb-3">Agents need credentials — and 2FA</h3>
|
||||
<p>Your AI can't log in, pass two-factor, or rotate keys without access. <span class="vaultname">clav<span class="n">itor</span></span> lets it do all three — without exposing your credit card to the same pipeline.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- How it works -->
|
||||
<div id="how" class="section container">
|
||||
<p class="label mb-4">How it works</p>
|
||||
<h2 class="mb-6">"Your assistant can book your flights.<br><span class="gradient-text">Not read your diary.</span>"</h2>
|
||||
<p class="lead mb-8">Every field is encrypted. But some get a second lock. That second key is derived from your fingerprint and only exists in your browser. We hold the safe. Only you hold that key.</p>
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">Agent fields</span>
|
||||
<h3 class="mb-3">AI-readable</h3>
|
||||
<p class="mb-4">Encrypted at rest, decryptable by the vault server. Your AI agent reads these via MCP.</p>
|
||||
<ul class="checklist">
|
||||
<li>API keys & tokens</li>
|
||||
<li>SSH keys</li>
|
||||
<li>TOTP 2FA codes — AI generates them for you</li>
|
||||
<li>OAuth tokens</li>
|
||||
<li>Structured notes</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<span class="badge red mb-4">Sealed fields</span>
|
||||
<h3 class="mb-3">Touch ID only</h3>
|
||||
<p class="mb-4">Encrypted client-side with WebAuthn PRF. The server never sees the plaintext. Ever.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers</li>
|
||||
<li>CVV</li>
|
||||
<li>Passport & SSN</li>
|
||||
<li>Private signing keys</li>
|
||||
<li>Private notes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Features -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Built different</h2>
|
||||
<p class="lead mb-8">Not another password manager with an AI checkbox. The architecture is the feature.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/></svg></div>
|
||||
<h3 class="mb-3">Field-level AI visibility</h3>
|
||||
<p>Each field has its own encryption tier. Your AI reads the username, not the CVV. Same entry, different access.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"/></svg></div>
|
||||
<h3 class="mb-3">WebAuthn PRF</h3>
|
||||
<p>Sealed encryption uses WebAuthn PRF — a cryptographic key derived from your biometric hardware. Math, not policy. We literally cannot decrypt it.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
|
||||
<h3 class="mb-3">AI-powered 2FA</h3>
|
||||
<p>Store TOTP secrets as Agent fields. Your AI generates time-based codes on demand via MCP — no more switching to your phone.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg></div>
|
||||
<h3 class="mb-3">Scoped MCP tokens</h3>
|
||||
<p>Create separate MCP tokens per agent. Each token sees only its designated entries. Compromise one, the rest stay clean.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg></div>
|
||||
<h3 class="mb-3">One binary, one file</h3>
|
||||
<p>No Docker. No Postgres. No Redis. One Go binary, one SQLite file. Runs on a Raspberry Pi. Runs on a $4/month VPS.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg></div>
|
||||
<h3 class="mb-3">LLM field mapping</h3>
|
||||
<p>Import from any password manager. The built-in LLM automatically classifies which fields should be Agent vs Sealed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Multi-agent swarms -->
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<h2 class="mb-4">10 agents.<br><span class="gradient-text">Each gets exactly what it needs.</span></h2>
|
||||
<p class="lead mb-6">Create scoped MCP tokens per agent. One compromised agent exposes one scope — not your entire vault.</p>
|
||||
<div class="code-block">
|
||||
<p class="code-label">~/.claude/mcp.json</p>
|
||||
<pre>{
|
||||
"mcpServers": {
|
||||
"vault-dev": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">mcp_dev_a3f8...</span>" }
|
||||
},
|
||||
"vault-social": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">mcp_social_7b2e...</span>" }
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Multi-agent SVG -->
|
||||
<svg viewBox="0 0 400 360" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Center vault -->
|
||||
<rect x="160" y="140" width="80" height="80" rx="12" fill="#111f38" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<text x="200" y="175" font-family="JetBrains Mono, monospace" font-size="10" fill="#94a3b8" text-anchor="middle">vault</text>
|
||||
<text x="200" y="195" font-family="JetBrains Mono, monospace" font-size="14" fill="white" text-anchor="middle" font-weight="600">1984</text>
|
||||
|
||||
<!-- Agent 1 — dev -->
|
||||
<circle cx="80" cy="60" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
|
||||
<text x="80" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Agent 1</text>
|
||||
<text x="80" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">dev</text>
|
||||
<line x1="108" y1="80" x2="165" y2="145" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 2 — social -->
|
||||
<circle cx="320" cy="60" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
|
||||
<text x="320" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Agent 2</text>
|
||||
<text x="320" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">social</text>
|
||||
<line x1="292" y1="80" x2="235" y2="145" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 3 — finance -->
|
||||
<circle cx="50" cy="220" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
|
||||
<text x="50" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Agent 3</text>
|
||||
<text x="50" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">finance</text>
|
||||
<line x1="78" y1="204" x2="164" y2="190" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 4 — infra -->
|
||||
<circle cx="350" cy="220" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
|
||||
<text x="350" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Agent 4</text>
|
||||
<text x="350" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">infra</text>
|
||||
<line x1="322" y1="204" x2="236" y2="190" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 5 — deploy -->
|
||||
<circle cx="200" cy="330" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
|
||||
<text x="200" y="326" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Agent 5</text>
|
||||
<text x="200" y="338" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">deploy</text>
|
||||
<line x1="200" y1="298" x2="200" y2="220" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Scope labels -->
|
||||
<rect x="10" y="98" width="140" height="20" rx="4" fill="#0A1628"/>
|
||||
<text x="80" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">github ssh gitlab</text>
|
||||
|
||||
<rect x="250" y="98" width="140" height="20" rx="4" fill="#0A1628"/>
|
||||
<text x="320" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">twitter slack discord</text>
|
||||
|
||||
<rect x="0" y="256" width="100" height="20" rx="4" fill="#0A1628"/>
|
||||
<text x="50" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">stripe plaid</text>
|
||||
|
||||
<rect x="300" y="256" width="100" height="20" rx="4" fill="#0A1628"/>
|
||||
<text x="350" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">aws k8s docker</text>
|
||||
|
||||
<rect x="150" y="296" width="100" height="16" rx="4" fill="#0A1628"/>
|
||||
<text x="200" y="308" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">vercel netlify</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Access Methods -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Your agent and you — same vault, right access</h2>
|
||||
<p class="lead mb-8">Four ways in. Each one designed for a different context. All pointing at the same encrypted store.</p>
|
||||
<div class="grid-2">
|
||||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">MCP</p>
|
||||
<h3 class="mb-2">For AI agents</h3>
|
||||
<p>Claude, GPT, or any MCP-compatible agent can search credentials, fetch API keys, and generate 2FA codes — scoped to exactly what you allow.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">Extension</p>
|
||||
<h3 class="mb-2">For humans in a browser</h3>
|
||||
<p>Autofill passwords, generate 2FA codes inline, and unlock L2 fields with Touch ID — without leaving the page you're on.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">CLI</p>
|
||||
<h3 class="mb-2">For terminal workflows</h3>
|
||||
<p>Pipe credentials directly into scripts and CI pipelines. <code>vault get github.token</code> — done.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">API</p>
|
||||
<h3 class="mb-2">For everything else</h3>
|
||||
<p>REST API with scoped tokens. Give your deployment pipeline read access to staging keys. Nothing else.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- The competition -->
|
||||
<div class="section container">
|
||||
<p class="label mb-4">The competition</p>
|
||||
<h2 class="mb-4">We listened. And addressed them all.</h2>
|
||||
<p class="lead mb-8">Real complaints from real users — about 1Password, Bitwarden, and LastPass. Pulled from forums, GitHub issues, and Hacker News. Not cherry-picked from our own users.</p>
|
||||
|
||||
<div class="grid-3">
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">1PASSWORD — Community Forum</p>
|
||||
<p><em>"The web extensions are laughably bad at this point. This has been going on for months. They either won't fill, wont' unlock, or just plain won't do anything (even clicking extension icon). It's so bad"</em></p>
|
||||
<p class="mt-2"><a href="https://www.1password.community/discussions/1password/constantly-being-asked-to-unlock-with-password/90511" target="_blank" rel="noopener">— notnotjake, April 2024 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: No desktop app dependency. The extension talks directly to the local vault binary — no IPC, no sync, no unlock chains.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">BITWARDEN — GitHub Issues</p>
|
||||
<p><em>"Every single website loads slower. From Google, up to social media websites like Reddit, Instagram, X up to websites like example.com. Even scrolling and animation stutters sometimes. javascript heavy websites like X, Instagram, Reddit etc. become extremely sluggish when interacting with buttons. So for me the Bitwarden browser extension is unusable. It interferes with my browsing experience like malware."</em></p>
|
||||
<p class="mt-2"><a href="https://github.com/bitwarden/clients/issues/11077" target="_blank" rel="noopener">— julianw1011, 2024 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: Zero content scripts. The extension injects nothing into pages — it fills via the browser autofill API only when you ask.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">LASTPASS — Hacker News</p>
|
||||
<p><em>"The fact they're drip-feeding how bad this breach actually was is terrible enough... Personally I'm never touching them again."</em></p>
|
||||
<p class="mt-2"><a href="https://news.ycombinator.com/item?id=34516275" target="_blank" rel="noopener">— intunderflow, January 2023 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: Self-host or use hosted with L2 encryption — we mathematically cannot read your private fields. No vault data to breach.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">1PASSWORD — Community Forum</p>
|
||||
<p><em>"Since doing so, it asks me to enter my password every 10 minutes or so in the chrome extension"</em></p>
|
||||
<p class="mt-2"><a href="https://www.1password.community/discussions/1password/why-does-the-chrome-extension-keep-asking-for-my-password-every-10-mins-rather-t/74253" target="_blank" rel="noopener">— Anonymous (Former Member), November 2022 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: WebAuthn-first. Touch ID is the primary unlock. Session lives locally — no server-side expiry forcing re-auth.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">BITWARDEN — Community Forums</p>
|
||||
<p><em>"the password not only auto-filled in the password field, but also auto-filled in reddit's search box!"</em></p>
|
||||
<p class="mt-2"><em>"if autofill has the propensity at times to put an entire password in plain text in a random field, autofill seems like more risk than it's worth."</em></p>
|
||||
<p class="mt-2"><a href="https://community.bitwarden.com/t/auto-fill-is-pasting-password-in-website-search-box/44045" target="_blank" rel="noopener">— xru1nib5 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: LLM field mapping. The extension reads the form, asks the model which field is which — fills by intent, not by CSS selector.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">BITWARDEN — Community Forums</p>
|
||||
<p><em>"Bitwarden REFUSES to autofill the actual password saved for a given site or app...and instead fills an old password. It simply substitutes the OLD password for the new one that is plainly saved in the vault."</em></p>
|
||||
<p class="mt-2"><a href="https://community.bitwarden.com/t/autofill-is-wrong-saved-password-is-right/32090" target="_blank" rel="noopener">— gentlezacharias ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clav<span class="n">itor</span></span>: LLM field mapping matches by intent. Entries are indexed by URL — the right credential for the right site, every time.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="mt-8">All quotes verbatim from public posts. URLs verified. <a href="/sources">View sources →</a></p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Hosted CTA -->
|
||||
<div class="section container">
|
||||
<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">clav<span class="n">itor</span></span> across 22 regions on every continent. <s>$20</s> $12/yr. Your Sealed 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Quick install -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Up and running in 30 seconds</h2>
|
||||
<p class="lead mb-8">One command. No dependencies.</p>
|
||||
<div class="code-block mb-6">
|
||||
<p class="code-label">Terminal</p>
|
||||
<div><span class="comment"># Self-host in 30 seconds</span></div>
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
<div class="comment"># Running on http://localhost:1984</div>
|
||||
</div>
|
||||
<div class="code-block">
|
||||
<p class="code-label">MCP config for Claude Code / Cursor / Codex</p>
|
||||
<pre>{
|
||||
"mcpServers": {
|
||||
"clavitor": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">mcp_your_token_here</span>" }
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
<p class="mt-4"><a href="/install" class="btn btn-accent">Full install guide →</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "index-script"}}
|
||||
<script>
|
||||
(function() {
|
||||
const W = 1000, H = 460;
|
||||
function project(lon, lat) {
|
||||
const latR = Math.min(Math.abs(lat), 85) * Math.PI / 180 * (lat < 0 ? -1 : 1);
|
||||
const miller = 1.25 * Math.log(Math.tan(Math.PI/4 + 0.4*latR));
|
||||
const maxMiller = 1.25 * Math.log(Math.tan(Math.PI/4 + 0.4*80*Math.PI/180));
|
||||
const x = (lon + 180) / 360 * W;
|
||||
const y = H/2 - (miller / (2*maxMiller)) * H;
|
||||
return [Math.round(x*10)/10, Math.round(y*10)/10];
|
||||
}
|
||||
|
||||
function addVisitorDot(lat, lon, city) {
|
||||
const svg = document.getElementById('worldmap');
|
||||
if (!svg) return;
|
||||
const [x, y] = project(lon, lat);
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// Pulse ring
|
||||
const ring = document.createElementNS(ns, 'circle');
|
||||
ring.setAttribute('cx', x); ring.setAttribute('cy', y);
|
||||
ring.setAttribute('r', '3'); ring.setAttribute('fill', 'none');
|
||||
ring.setAttribute('stroke', '#EF4444'); ring.setAttribute('stroke-width', '1.5');
|
||||
const a1 = document.createElementNS(ns, 'animate');
|
||||
a1.setAttribute('attributeName', 'r'); a1.setAttribute('values', '3;16;3');
|
||||
a1.setAttribute('dur', '2s'); a1.setAttribute('repeatCount', 'indefinite');
|
||||
const a2 = document.createElementNS(ns, 'animate');
|
||||
a2.setAttribute('attributeName', 'stroke-opacity'); a2.setAttribute('values', '0.8;0;0.8');
|
||||
a2.setAttribute('dur', '2s'); a2.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(a1); ring.appendChild(a2);
|
||||
|
||||
// Dot
|
||||
const dot = document.createElementNS(ns, 'circle');
|
||||
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
||||
dot.setAttribute('r', '4'); dot.setAttribute('fill', '#EF4444');
|
||||
dot.setAttribute('stroke', '#0a1628'); dot.setAttribute('stroke-width', '1.5');
|
||||
|
||||
// Label
|
||||
const label = document.createElementNS(ns, 'text');
|
||||
label.setAttribute('x', x); label.setAttribute('y', y + 15);
|
||||
label.setAttribute('font-family', 'Inter,sans-serif');
|
||||
label.setAttribute('font-size', '10');
|
||||
label.setAttribute('fill', '#EF4444');
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('font-weight', '500');
|
||||
label.textContent = city || 'You';
|
||||
|
||||
svg.appendChild(ring);
|
||||
svg.appendChild(dot);
|
||||
svg.appendChild(label);
|
||||
}
|
||||
|
||||
function handleGeoData(d) {
|
||||
if (!d.latitude || !d.longitude) return;
|
||||
addVisitorDot(d.latitude, d.longitude, d.city || 'You');
|
||||
|
||||
const grid = document.getElementById('dc-grid');
|
||||
if (!grid) return;
|
||||
|
||||
// Build visitor card
|
||||
const flag = d.country_code ? d.country_code.toUpperCase().split('').map(c =>
|
||||
String.fromCodePoint(c.charCodeAt(0) + 127397)).join('') : '📍';
|
||||
const label = [d.city, d.country_name].filter(Boolean).join(', ') || 'Your location';
|
||||
const region = d.region || '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'rounded-xl p-5 text-center card-hover';
|
||||
card.setAttribute('data-lon', d.longitude);
|
||||
card.style.cssText = 'background:#1f0a0a;border:1px solid rgba(239,68,68,0.35)';
|
||||
card.innerHTML = `
|
||||
<div class="text-2xl mb-2">${flag}</div>
|
||||
<div class="text-white font-semibold text-sm">${label}</div>
|
||||
<div class="text-gray-500 text-xs mb-2">${region}</div>
|
||||
<div class="flex items-center justify-center gap-1.5 text-xs text-gray-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block" style="background:#EF4444;opacity:0.6"></span>You are here
|
||||
</div>`;
|
||||
|
||||
// Expand to 5 columns
|
||||
grid.style.gridTemplateColumns = "repeat(5,1fr)";
|
||||
|
||||
// Insert at correct longitude position
|
||||
const cards = [...grid.children];
|
||||
const insertBefore = cards.find(c => parseFloat(c.getAttribute('data-lon')) > d.longitude);
|
||||
if (insertBefore) grid.insertBefore(card, insertBefore);
|
||||
else grid.appendChild(card);
|
||||
|
||||
|
||||
}
|
||||
|
||||
fetch('/geo')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.latitude) {
|
||||
handleGeoData(d);
|
||||
} else if (d.private && navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(pos => {
|
||||
const lat = pos.coords.latitude, lon = pos.coords.longitude;
|
||||
// Reverse geocode via open-meteo's free geocoding isn't ideal;
|
||||
// use bigdatacloud free reverse geocode — no key, no signup
|
||||
fetch(`/geo?lat=${lat}&lon=${lon}`)
|
||||
.then(r => r.json())
|
||||
.then(g => handleGeoData({
|
||||
latitude: lat, longitude: lon,
|
||||
city: g.city || 'You',
|
||||
region: g.region || '',
|
||||
country_name: g.country_name || '',
|
||||
country_code: g.country_code || ''
|
||||
}))
|
||||
.catch(() => handleGeoData({ latitude: lat, longitude: lon,
|
||||
city: 'You', region: '', country_name: '', country_code: '' }));
|
||||
}, () => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
{{define "install-head"}}
|
||||
<style>.step-num{width:2rem;height:2rem;border-radius:50%;background:rgba(34,197,94,0.1);color:var(--accent);font-size:0.875rem;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:var(--font-mono)}.step{display:flex;gap:1.25rem;margin-bottom:3rem}.step-body{flex:1;min-width:0}.step-body h2{margin-bottom:0.75rem}.step-body p{margin-bottom:1rem}.dl-links{display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem}</style>
|
||||
{{end}}
|
||||
|
||||
{{define "install"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Open source · MIT</p>
|
||||
<h1 class="mb-4">Self-host clavitor</h1>
|
||||
<p class="lead">One binary. No Docker. No Postgres. No Redis. Runs anywhere Go runs. You'll need a server with a public IP, DNS, and TLS if you want access from outside your network.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container" style="max-width:800px">
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-body">
|
||||
<h2>Download</h2>
|
||||
<p>The install script detects your OS and architecture, downloads the latest release, and puts it in your PATH.</p>
|
||||
<div class="code-block"><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<p class="mt-3" style="font-size:0.875rem">Or download directly:</p>
|
||||
<div class="dl-links">
|
||||
<a href="https://github.com/clavitor/clavitor/releases/latest/download/clavitor-linux-amd64" class="btn btn-ghost" style="font-family:var(--font-mono);font-size:0.75rem">linux/amd64</a>
|
||||
<a href="https://github.com/clavitor/clavitor/releases/latest/download/clavitor-darwin-arm64" class="btn btn-ghost" style="font-family:var(--font-mono);font-size:0.75rem">darwin/arm64</a>
|
||||
<a href="https://github.com/clavitor/clavitor/releases/latest/download/clavitor-darwin-amd64" class="btn btn-ghost" style="font-family:var(--font-mono);font-size:0.75rem">darwin/amd64</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-body">
|
||||
<h2>Set your vault key</h2>
|
||||
<p>The vault key encrypts your Agent field data at rest. If you lose this key, Agent field data cannot be recovered.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="comment"># Generate a random key</span></div>
|
||||
<div><span class="prompt">$</span> export VAULT_KEY=$(openssl rand -hex 32)</div>
|
||||
<div class="mt-2"><span class="comment"># Save it somewhere safe</span></div>
|
||||
<div><span class="prompt">$</span> echo $VAULT_KEY >> ~/.clavitor-key</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-body">
|
||||
<h2>Run it</h2>
|
||||
<p>A SQLite database is created automatically in <code>~/.clavitor/</code>.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
<div class="comment">clavitor running on http://localhost:1984</div>
|
||||
<div class="comment">Database: ~/.clavitor/vault.db</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-body">
|
||||
<h2>Configure MCP</h2>
|
||||
<p>Point your AI assistant at the vault. Works with Claude Code, Cursor, Codex, or any MCP-compatible client.</p>
|
||||
<p class="label mb-3">~/.claude/mcp.json</p>
|
||||
<div class="code-block"><pre style="margin:0;color:var(--muted)">{
|
||||
"mcpServers": {
|
||||
"clavitor": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <span style="color:var(--accent)">YOUR_MCP_TOKEN</span>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}</pre></div>
|
||||
<p class="mt-3" style="font-size:0.875rem">Generate an MCP token from the web UI at <code>http://localhost:1984</code> after first run.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">5</div>
|
||||
<div class="step-body">
|
||||
<h2>Import your passwords</h2>
|
||||
<p>The LLM classifier automatically suggests Agent/Sealed assignments for each field. Review and confirm in the web UI.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="comment"># Chrome, Firefox, Bitwarden, Proton Pass, 1Password</span></div>
|
||||
<div><span class="prompt">$</span> clavitor import --format chrome passwords.csv</div>
|
||||
<div><span class="prompt">$</span> clavitor import --format bitwarden export.json</div>
|
||||
<div><span class="prompt">$</span> clavitor import --format 1password export.json</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider mb-8 mt-4">
|
||||
|
||||
<h2 class="mb-4">Run as a service</h2>
|
||||
<p class="mb-4">For always-on availability, run clavitor as a systemd service.</p>
|
||||
<p class="label mb-3">/etc/systemd/system/clavitor.service</p>
|
||||
<div class="code-block mb-4"><pre style="margin:0;color:var(--muted)">[Unit]
|
||||
Description=clavitor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=clavitor
|
||||
EnvironmentFile=/etc/clavitor/env
|
||||
ExecStart=/usr/local/bin/clavitor
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target</pre></div>
|
||||
<div class="code-block mb-8"><span class="prompt">$</span> sudo systemctl enable --now clavitor</div>
|
||||
|
||||
<h2 class="mb-4">Expose to the internet</h2>
|
||||
<p class="mb-4">Put clavitor behind Caddy for TLS and remote access.</p>
|
||||
<p class="label mb-3">Caddyfile</p>
|
||||
<div class="code-block"><pre style="margin:0;color:var(--muted)">vault.yourdomain.com {
|
||||
reverse_proxy localhost:1984
|
||||
}</pre></div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<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>
|
||||
<a href="/hosted" class="btn btn-primary">See hosted option →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
{{define "landing"}}
|
||||
<style>
|
||||
/* === HOMEPAGE2 OVERRIDES === */
|
||||
.landing-wrap { overflow-x: hidden; }
|
||||
.h2-hero { font-size: clamp(2.5rem, 5vw, 4.5rem); font-weight: 800; line-height: 1.05; letter-spacing: -0.03em; }
|
||||
.h2-hero em { font-style: italic; font-family: Georgia, 'Times New Roman', serif; color: var(--accent); }
|
||||
|
||||
/* Hero vault card */
|
||||
.hero-vault-card { background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||||
.hvc-title { display: flex; align-items: center; gap: 0.625rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 0.9rem; }
|
||||
.hvc-fields { padding: 0.25rem 0; }
|
||||
.hvc-field { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; padding: 0.6rem 1.25rem; gap: 0.75rem; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
||||
.hvc-field:last-child { border-bottom: none; }
|
||||
.hvc-label { color: var(--muted); font-family: var(--font-mono); font-size: 0.75rem; }
|
||||
.hvc-value { color: var(--text); font-family: var(--font-mono); font-size: 0.8rem; }
|
||||
.hvc-value.hvc-redacted { color: var(--muted); filter: blur(4px); user-select: none; }
|
||||
.hvc-locked .hvc-value { color: var(--muted); }
|
||||
.hvc-access { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.2rem 0.5rem; border-radius: 999px; white-space: nowrap; }
|
||||
.hvc-shared { background: rgba(74,222,128,0.1); color: var(--accent); }
|
||||
.hvc-private { background: rgba(239,68,68,0.1); color: var(--red); display: flex; align-items: center; gap: 0.25rem; }
|
||||
.hvc-footer { padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--muted); display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* Live demo cards */
|
||||
.demo-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; }
|
||||
.demo-card { background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; overflow: hidden; transition: transform 0.3s, box-shadow 0.3s; }
|
||||
.demo-card:hover { transform: translateY(-4px); box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
|
||||
.demo-card-header { padding: 1.25rem 1.5rem 0.75rem; }
|
||||
.demo-card-header h3 { font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||||
.demo-card-header p { font-size: 0.8rem; color: var(--muted); line-height: 1.5; }
|
||||
.demo-card-body { padding: 0 1rem 1.25rem; }
|
||||
.demo-terminal { background: #0a0f1a; border-radius: 0.625rem; padding: 1rem; font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.8; color: var(--muted); min-height: 140px; position: relative; overflow: hidden; max-width: 100%; }
|
||||
.section-light, .quote-section { overflow-x: hidden; }
|
||||
.demo-terminal .cursor { display: inline-block; width: 7px; height: 14px; background: var(--accent); animation: blink 1s step-end infinite; vertical-align: text-bottom; margin-left: 2px; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@keyframes typeIn { from { width: 0; } to { width: 100%; } }
|
||||
/* Card 1: starts immediately */
|
||||
.demo-card:nth-child(1) .type-line:nth-child(1) { overflow:hidden; white-space:nowrap; animation: typeIn 1.1s steps(38) forwards; }
|
||||
.demo-card:nth-child(1) .type-line:nth-child(2) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.5s steps(10) 1.4s forwards, fadeIn 0s 1.4s forwards; }
|
||||
.demo-card:nth-child(1) .type-line:nth-child(3) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.9s steps(28) 2.2s forwards, fadeIn 0s 2.2s forwards; }
|
||||
.demo-card:nth-child(1) .type-line:nth-child(4) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.8s steps(24) 3.4s forwards, fadeIn 0s 3.4s forwards; }
|
||||
.demo-card:nth-child(1) .type-line:nth-child(5) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.6s steps(18) 4.5s forwards, fadeIn 0s 4.5s forwards; }
|
||||
|
||||
/* Card 2: starts after 1.8s */
|
||||
.demo-card:nth-child(2) .type-line:nth-child(1) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 1.3s steps(40) 1.8s forwards, fadeIn 0s 1.8s forwards; }
|
||||
.demo-card:nth-child(2) .type-line:nth-child(2) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.4s steps(8) 3.4s forwards, fadeIn 0s 3.4s forwards; }
|
||||
.demo-card:nth-child(2) .type-line:nth-child(3) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 1.0s steps(30) 4.1s forwards, fadeIn 0s 4.1s forwards; }
|
||||
.demo-card:nth-child(2) .type-line:nth-child(4) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.7s steps(22) 5.4s forwards, fadeIn 0s 5.4s forwards; }
|
||||
.demo-card:nth-child(2) .type-line:nth-child(5) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.7s steps(22) 6.4s forwards, fadeIn 0s 6.4s forwards; }
|
||||
|
||||
/* Card 3: starts after 3.2s */
|
||||
.demo-card:nth-child(3) .type-line:nth-child(1) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 1.0s steps(32) 3.2s forwards, fadeIn 0s 3.2s forwards; }
|
||||
.demo-card:nth-child(3) .type-line:nth-child(2) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.8s steps(20) 4.5s forwards, fadeIn 0s 4.5s forwards; }
|
||||
.demo-card:nth-child(3) .type-line:nth-child(3) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.6s steps(16) 5.8s forwards, fadeIn 0s 5.8s forwards; }
|
||||
.demo-card:nth-child(3) .type-line:nth-child(4) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.5s steps(14) 6.7s forwards, fadeIn 0s 6.7s forwards; }
|
||||
.demo-card:nth-child(3) .type-line:nth-child(5) { overflow:hidden; white-space:nowrap; opacity:0; animation: typeIn 0.9s steps(28) 7.5s forwards, fadeIn 0s 7.5s forwards; }
|
||||
@keyframes fadeIn { to { opacity: 1; } }
|
||||
|
||||
/* Alternating light section */
|
||||
.section-light { background: #f0f2f5; color: #0d1520; padding: 5rem 0; }
|
||||
.section-light h2 { color: #0d1520; }
|
||||
.section-light p { color: #475569; }
|
||||
.section-light .card-light { background: white; border: 1px solid #e2e8f0; border-radius: 1rem; padding: 1.75rem; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.section-light .card-light:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
.section-light .card-light h3 { color: #0d1520; font-size: 1.1rem; }
|
||||
.section-light .card-light p { color: #64748b; }
|
||||
.section-light .accent-dot { width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: rgba(34,197,94,0.12); display: flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 1.1rem; }
|
||||
|
||||
/* Floating badges on hero */
|
||||
.hero-badges { display: flex; gap: 0.75rem; margin-top: 2rem; flex-wrap: wrap; }
|
||||
.hero-badge { font-family: var(--font-mono); font-size: 0.7rem; padding: 0.375rem 0.875rem; border-radius: 999px; background: rgba(74,222,128,0.08); border: 1px solid rgba(74,222,128,0.2); color: var(--accent); }
|
||||
|
||||
/* Big quote section */
|
||||
.quote-section { padding: 6rem 0; text-align: center; position: relative; }
|
||||
.quote-section::before { content:''; position:absolute; inset:0; background: linear-gradient(135deg, rgba(34,197,94,0.04) 0%, rgba(239,68,68,0.04) 100%); }
|
||||
.big-quote { font-size: clamp(1.5rem, 3vw, 2.5rem); font-weight: 700; line-height: 1.3; max-width: 800px; margin: 0 auto; position: relative; }
|
||||
.big-quote .red { color: var(--red); }
|
||||
|
||||
/* Redacted demo */
|
||||
.redacted-demo { max-width: 560px; margin: 0 auto; }
|
||||
.redacted-line { display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
.redacted-line:last-child { border-bottom: none; }
|
||||
.redacted-label { color: var(--muted); }
|
||||
.redacted-value { color: var(--text); }
|
||||
.redacted-value.blocked { color: var(--red); font-size: 0.75rem; letter-spacing: 0.03em; }
|
||||
|
||||
/* Number callouts */
|
||||
.num-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2rem; text-align: center; }
|
||||
.num-big { font-size: 2.5rem; font-weight: 800; color: var(--accent); font-family: var(--font-mono); line-height: 1; }
|
||||
.num-label { font-size: 0.8rem; color: var(--muted); margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
||||
<!-- Wrap everything to prevent width jumps -->
|
||||
<div class="landing-wrap">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="container hero-split">
|
||||
<div>
|
||||
<p class="label accent mb-6">Password manager for the AI era</p>
|
||||
<h1 class="h2-hero mb-6">Passwords for<br><em>AI agents.</em></h1>
|
||||
<p class="lead mb-6"><span style="color:var(--text)">Claude Code</span>, <span style="color:var(--text)">Codex</span>, <span style="color:var(--text)">Cursor</span>, <span style="color:var(--text)">OpenClaw</span> — every AI agent needs credentials. <span class="vaultname">clav<span class="n">itor</span></span> gives them access without giving them everything. Your private data stays locked behind your biometric. Same vault, different access.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/hosted" class="btn btn-primary">Get started</a>
|
||||
<a href="#how" class="btn btn-ghost">How it works →</a>
|
||||
</div>
|
||||
<div class="hero-badges">
|
||||
<span class="hero-badge" title="Model Context Protocol">MCP</span>
|
||||
<span class="hero-badge">REST API</span>
|
||||
<span class="hero-badge">CLI</span>
|
||||
<span class="hero-badge">Browser Extension</span>
|
||||
<span class="hero-badge">Open Source</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Vault entry card showing shared vs private -->
|
||||
<div class="hero-vault-card">
|
||||
<div class="hvc-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
<span>Visa • John Smith</span>
|
||||
</div>
|
||||
<div class="hvc-fields">
|
||||
<div class="hvc-field">
|
||||
<span class="hvc-label">cardholder</span>
|
||||
<span class="hvc-value">John Smith</span>
|
||||
<span class="hvc-access hvc-shared" title="Shared with AI">AI + you</span>
|
||||
</div>
|
||||
<div class="hvc-field">
|
||||
<span class="hvc-label">expiry</span>
|
||||
<span class="hvc-value">2029-02</span>
|
||||
<span class="hvc-access hvc-shared" title="Shared with AI">AI + you</span>
|
||||
</div>
|
||||
<div class="hvc-field hvc-locked">
|
||||
<span class="hvc-label">number</span>
|
||||
<span class="hvc-value hvc-redacted">4532 •••• •••• 7821</span>
|
||||
<span class="hvc-access hvc-private" title="Only you">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
only you
|
||||
</span>
|
||||
</div>
|
||||
<div class="hvc-field hvc-locked">
|
||||
<span class="hvc-label">cvv</span>
|
||||
<span class="hvc-value hvc-redacted">•••</span>
|
||||
<span class="hvc-access hvc-private">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
only you
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hvc-footer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--muted)"><path d="M12 11c0-1.1.9-2 2-2s2 .9 2 2-2 4-2 4m-4-6a6 6 0 1112 0"/><circle cx="12" cy="19" r="1"/></svg>
|
||||
<span>Private fields encrypted with your biometric</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Live demo cards -->
|
||||
<div class="section container">
|
||||
<p class="label accent mb-4">See it in action</p>
|
||||
<h2 class="mb-8">What makes <span class="vaultname">clav<span class="n">itor</span></span> different.</h2>
|
||||
<div class="demo-grid">
|
||||
|
||||
<!-- Card 1: MCP live -->
|
||||
<div class="demo-card">
|
||||
<div class="demo-card-header">
|
||||
<h3>Agent fetches a credential</h3>
|
||||
<p>Your AI searches the vault via <span title="Model Context Protocol" style="border-bottom:1px dotted var(--muted);cursor:help">MCP</span> and gets exactly what it needs.</p>
|
||||
</div>
|
||||
<div class="demo-card-body">
|
||||
<div class="demo-terminal">
|
||||
<div class="type-line"><span style="color:var(--accent)">claude></span> search_vault("github")</div>
|
||||
<div class="type-line"><span style="color:var(--subtle)"> ...</span></div>
|
||||
<div class="type-line"><span style="color:var(--accent)"> token:</span> ghp_x7k2m9...4f1a</div>
|
||||
<div class="type-line"><span style="color:var(--accent)"> ssh:</span> ed25519 SHA256:...</div>
|
||||
<div class="type-line"><span style="color:var(--accent)"> totp:</span> 847 291<span class="cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Redaction live -->
|
||||
<div class="demo-card">
|
||||
<div class="demo-card-header">
|
||||
<h3>Private fields stay private</h3>
|
||||
<p>The agent asks for your Visa. It gets the name. Not the number.</p>
|
||||
</div>
|
||||
<div class="demo-card-body">
|
||||
<div class="demo-terminal">
|
||||
<div class="type-line"><span style="color:var(--accent)">claude></span> get_credential("visa")</div>
|
||||
<div class="type-line"><span style="color:var(--subtle)"> ...</span></div>
|
||||
<div class="type-line"><span style="color:#d1d5db"> name:</span> John Smith</div>
|
||||
<div class="type-line"><span style="color:var(--red)"> number: [REDACTED]</span></div>
|
||||
<div class="type-line"><span style="color:var(--red)"> cvv: [REDACTED]</span><span class="cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: 2FA live -->
|
||||
<div class="demo-card">
|
||||
<div class="demo-card-header">
|
||||
<h3>AI generates your 2FA</h3>
|
||||
<p>No phone. No app switching. Your agent handles two-factor.</p>
|
||||
</div>
|
||||
<div class="demo-card-body">
|
||||
<div class="demo-terminal">
|
||||
<div class="type-line"><span style="color:var(--accent)">claude></span> get_totp("aws")</div>
|
||||
<div class="type-line"><span style="color:var(--subtle)"> generating code...</span></div>
|
||||
<div class="type-line"><span style="color:#d1d5db"> code:</span> <span style="color:var(--accent);font-size:1.1rem;font-weight:700">847 291</span></div>
|
||||
<div class="type-line"><span style="color:var(--subtle)"> expires: 18s</span></div>
|
||||
<div class="type-line"><span style="color:var(--subtle)"> account: john@smith.family</span><span class="cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Light section: Not an add-on -->
|
||||
<div class="section-light">
|
||||
<div class="container">
|
||||
<h2 style="font-size:clamp(1.5rem,3vw,2.25rem);font-weight:700;margin-bottom:0.25rem">This is the vault for the AI era.</h2>
|
||||
<p style="font-size:clamp(1.2rem,2.5vw,1.75rem);color:var(--muted);font-weight:400;margin-bottom:1.5rem">Not an add-on to some legacy product.</p>
|
||||
<p class="lead mb-8" style="max-width:640px">Vault connectors give your AI the same access you have. Everything or nothing. That's not security, that's a checkbox.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card-light">
|
||||
<div class="accent-dot" style="background:rgba(239,68,68,0.1);color:var(--red)">✕</div>
|
||||
<h3 class="mb-3">Connectors: all-or-nothing</h3>
|
||||
<p>Your agent needs your GitHub token. With a connector, it also sees your SSN and private keys.</p>
|
||||
</div>
|
||||
<div class="card-light">
|
||||
<div class="accent-dot" style="background:rgba(239,68,68,0.1);color:var(--red)">✕</div>
|
||||
<h3 class="mb-3">"AI-safe" is policy, not math</h3>
|
||||
<p>Other vaults decrypt everything server-side and filter. If the server can read it, it's not private.</p>
|
||||
</div>
|
||||
<div class="card-light">
|
||||
<div class="accent-dot">✓</div>
|
||||
<h3 class="mb-3" style="color:var(--accent)">Clavitor: built for this</h3>
|
||||
<p>Your AI reads what you allow. Private fields are encrypted with a key derived from your biometric — it never leaves your device. Not your AI, not us, not our servers can decrypt them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How it works -->
|
||||
<div id="how" class="quote-section">
|
||||
<div class="container" style="position:relative">
|
||||
<p class="big-quote">"Your AI deploys the code.<br><span class="red">It doesn't see what you don't share.</span>"</p>
|
||||
<p class="lead mt-6 mb-6" style="max-width:640px;margin-left:auto;margin-right:auto">You decide which fields are private — per entry, per field. Those get a second encryption key derived from your biometric via WebAuthn PRF. That key is generated on your device and never transmitted.</p>
|
||||
<div style="max-width:640px;margin:0 auto;text-align:left">
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.75rem 1rem;font-size:0.85rem;color:var(--muted)">
|
||||
<span style="color:var(--accent);font-weight:600">1.</span>
|
||||
<span>You mark a field as private in the web UI</span>
|
||||
<span style="color:var(--accent);font-weight:600">2.</span>
|
||||
<span>Your browser derives an AES-256 key from your biometric via <span style="color:var(--text)">WebAuthn PRF</span> (Touch ID, YubiKey, etc.)</span>
|
||||
<span style="color:var(--accent);font-weight:600">3.</span>
|
||||
<span>The field is encrypted <span style="color:var(--text)">client-side</span> before it ever reaches the server</span>
|
||||
<span style="color:var(--accent);font-weight:600">4.</span>
|
||||
<span>The server stores ciphertext. No key, no plaintext, no backdoor. <span style="color:var(--text)">Math, not policy.</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What your agent sees -->
|
||||
<div class="section container">
|
||||
<div class="grid-2" style="align-items:start">
|
||||
<div>
|
||||
<span class="badge accent mb-4">Your agent can read</span>
|
||||
<h2 class="mb-6">Shared with AI</h2>
|
||||
<div class="demo-terminal" style="min-height:auto">
|
||||
<div style="color:var(--accent);margin-bottom:0.5rem">GET /api/entries/github</div>
|
||||
<div><span style="color:#d1d5db">username:</span> johnsmith</div>
|
||||
<div><span style="color:#d1d5db">token:</span> ghp_x7k2m9q...4f1a</div>
|
||||
<div><span style="color:#d1d5db">ssh_key:</span> -----BEGIN OPENSSH...</div>
|
||||
<div><span style="color:#d1d5db">totp:</span> 847291 (18s remaining)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge red mb-4">Only you can read</span>
|
||||
<h2 class="mb-6">Locked to your biometric</h2>
|
||||
<div class="demo-terminal" style="min-height:auto">
|
||||
<div style="color:var(--accent);margin-bottom:0.5rem">GET /api/entries/identity</div>
|
||||
<div><span style="color:#d1d5db">name:</span> John Smith</div>
|
||||
<div><span style="color:var(--red)">passport: [REDACTED — not available to agents]</span></div>
|
||||
<div><span style="color:var(--red)">ssn: [REDACTED — not available to agents]</span></div>
|
||||
<div><span style="color:#d1d5db">email:</span> john@smith.family</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Numbers -->
|
||||
<div class="section container">
|
||||
<div class="num-grid">
|
||||
<div><div class="num-big">1</div><div class="num-label">Binary</div></div>
|
||||
<div><div class="num-big">1</div><div class="num-label">SQLite file</div></div>
|
||||
<div><div class="num-big">5</div><div class="num-label">MCP tools</div></div>
|
||||
<div><div class="num-big">0</div><div class="num-label">Dependencies</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Multi-agent -->
|
||||
<div class="section container">
|
||||
<div class="grid-2" style="align-items:center">
|
||||
<div>
|
||||
<h2 class="mb-4" style="font-size:clamp(1.5rem,3vw,2.25rem)">Multi-user. Multi-agent.<br><span class="gradient-text">One vault.</span></h2>
|
||||
<p class="lead mb-6">Every agent gets its own API key. Your coding agent sees GitHub. Your DevOps agent sees AWS. Neither sees your personal keys.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="code-block">
|
||||
<p class="code-label">~/.claude/mcp.json</p>
|
||||
<pre>{
|
||||
"mcpServers": {
|
||||
"vault-dev": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">token_dev_...</span>" }
|
||||
},
|
||||
"vault-devops": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">token_ops_...</span>" }
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Install / Pricing -->
|
||||
<div class="section container" style="text-align:center">
|
||||
<h2 class="mb-4" style="font-size:clamp(1.5rem,3vw,2.5rem)">Two ways to run it.</h2>
|
||||
<div class="grid-2 mb-6" style="align-items:start;text-align:left">
|
||||
<div>
|
||||
<h3 class="mb-3" style="color:var(--accent)">Hosted — <span style="text-decoration:line-through;color:var(--muted)">$20</span> $12/yr <span style="font-weight:400;font-size:0.8rem;color:var(--muted)">(personal)</span></h3>
|
||||
<p style="font-size:0.8rem;color:var(--accent);margin-bottom:0.75rem">Launch price until June 30, 2026</p>
|
||||
<p class="lead mb-3">We handle TLS, DNS, backups and 21 regional edge nodes across 6 continents. You just create a vault and connect your agents.</p>
|
||||
<p id="local-pop" style="font-size:0.8rem;color:var(--muted);margin-bottom:1rem;display:none">Your nearest POP: <span id="local-pop-name" style="color:var(--accent)"></span></p>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted →</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-3">Self-host — free</h3>
|
||||
<p class="lead mb-4">One binary on your server. You'll need a domain, reverse proxy and TLS. Full control, zero cost.</p>
|
||||
<div class="code-block mb-4" style="text-align:left">
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
<div class="comment"># Running on http://localhost:1984</div>
|
||||
</div>
|
||||
<a href="/install" class="btn btn-ghost">Install guide →</a>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.85rem">Need team or enterprise pricing? <a href="mailto:hello@clavitor.com" style="color:var(--accent)">Talk to us</a>.</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /landing-wrap -->
|
||||
{{end}}
|
||||
|
||||
{{define "landing-script"}}
|
||||
{{template "geo-pops-script"}}
|
||||
<script>
|
||||
V1984_detectPop(function(d, closest) {
|
||||
var pairs = [['local-pop','local-pop-name'], ['hero-pop-line','hero-pop-name']];
|
||||
pairs.forEach(function(p) {
|
||||
var el = document.getElementById(p[0]);
|
||||
var name = document.getElementById(p[1]);
|
||||
if (el && name) { name.textContent = closest.name; el.style.display = ''; }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
{{define "pricing"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Simple pricing</p>
|
||||
<h1 class="mb-4">No tiers. No per-seat. No surprises.</h1>
|
||||
<p class="lead">Two options — both get every feature.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2" style="max-width:900px;margin:0 auto">
|
||||
|
||||
<div class="price-card">
|
||||
<p class="label mb-4">Self-hosted</p>
|
||||
<div class="price-amount mb-2">Free</div>
|
||||
<p class="mb-6">Forever. MIT license. No strings.</p>
|
||||
<a href="/install" class="btn btn-ghost btn-block mb-8">Self-host guide →</a>
|
||||
<p class="label mb-4">What you get</p>
|
||||
<ul class="checklist"><li>Agent & Sealed field-level encryption</li><li>WebAuthn PRF (Sealed biometric encryption)</li><li>MCP server for AI agents</li><li>Multi-agent API keys</li><li>TOTP generation via MCP</li><li>Browser extension (Chrome, Firefox)</li><li>Import from Bitwarden / 1Password</li><li>LLM-powered field classification</li><li>Unlimited entries</li><li>Full source code (MIT)</li></ul>
|
||||
</div>
|
||||
|
||||
<div class="price-card featured" style="position:relative">
|
||||
<span class="badge recommended" style="position:absolute;top:-0.75rem;right:1.5rem">Recommended</span>
|
||||
<p class="label accent mb-4">Hosted</p>
|
||||
<div class="price-amount mb-2"><s>$20</s> $12<span class="price-period">/year</span></div>
|
||||
<p class="mb-6">7-day money-back, no questions, instant.</p>
|
||||
<a href="/signup" class="btn btn-primary btn-block mb-8">Get started</a>
|
||||
<p class="label mb-4">Everything in self-hosted, plus</p>
|
||||
<ul class="checklist"><li>Managed infrastructure</li><li>Daily encrypted backups</li><li>22 regions across every continent</li><li>Automatic updates & patches</li><li>TLS included</li><li>Email support</li></ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container" style="max-width:720px">
|
||||
<p class="label mb-6" style="text-align:center">Common questions</p>
|
||||
<h2 class="mb-8" style="text-align:center">FAQ</h2>
|
||||
|
||||
<div class="prose">
|
||||
<h3>Why so cheap?</h3>
|
||||
<p>$12/yr is launch pricing — regular price is $20/yr. Both cover compute, backups, and bandwidth for one user for a year.</p>
|
||||
|
||||
<h3>Is the self-hosted version missing any features?</h3>
|
||||
<p>No. Every feature ships in both versions. Hosted adds managed infrastructure and backups — not functionality.</p>
|
||||
|
||||
<h3>Can hosted clavitor read my Sealed fields?</h3>
|
||||
<p>No. Sealed fields are encrypted client-side with WebAuthn PRF. The server stores ciphertext it cannot decrypt. This isn't a policy — it's mathematics. We don't have the key.</p>
|
||||
|
||||
<h3>Can I switch between hosted and self-hosted?</h3>
|
||||
<p>Yes. Export your vault at any time as encrypted JSON. Import it anywhere. Your data is always portable.</p>
|
||||
|
||||
<h3>Is there a free trial?</h3>
|
||||
<p>No free trial — but 7-day money-back, no questions asked, instant refund. That's a stronger guarantee.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
{{define "privacy"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Legal</p>
|
||||
<h1 class="mb-4">Privacy Policy</h1>
|
||||
<p class="lead mb-4">No analytics. No tracking. No data sales.</p>
|
||||
<p class="mb-4" style="font-size:0.875rem;color:var(--subtle)">Last updated: February 2026</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="prose" style="max-width:720px">
|
||||
|
||||
<h2>The short version</h2>
|
||||
<ul>
|
||||
<li>Your vault data is encrypted at rest (Agent fields) and in transit (TLS).</li>
|
||||
<li>Sealed fields are encrypted client-side with WebAuthn PRF. We cannot decrypt them. Ever.</li>
|
||||
<li>No analytics. No tracking pixels. No third-party scripts.</li>
|
||||
<li>We don't sell, share, or rent your data. To anyone. For any reason.</li>
|
||||
<li>You can delete your account and all data at any time.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What this policy covers</h2>
|
||||
<p>This privacy policy applies to the hosted clavitor service at clavitor.com. If you self-host clavitor, your data never touches our servers and this policy doesn't apply to you — your privacy is entirely in your own hands.</p>
|
||||
|
||||
<h2>Data we store</h2>
|
||||
<p>When you use hosted clavitor, we store:</p>
|
||||
<ul>
|
||||
<li><strong>Account information:</strong> email address and authentication credentials</li>
|
||||
<li><strong>Agent field data:</strong> encrypted at rest with AES-256-GCM using your vault key</li>
|
||||
<li><strong>Sealed field data:</strong> encrypted client-side with WebAuthn PRF before reaching our servers — stored as ciphertext we cannot decrypt</li>
|
||||
<li><strong>Metadata:</strong> entry creation and modification timestamps, entry titles</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data we don't store</h2>
|
||||
<ul>
|
||||
<li>IP address logs (not stored beyond immediate request processing)</li>
|
||||
<li>Usage analytics or telemetry</li>
|
||||
<li>Browser fingerprints</li>
|
||||
<li>Cookies beyond session authentication</li>
|
||||
</ul>
|
||||
|
||||
<h2>Sealed field encryption guarantee</h2>
|
||||
<p>Fields marked as Sealed are encrypted in your browser using a key derived from your WebAuthn authenticator (Touch ID, Windows Hello, or a hardware security key) via the PRF extension. The encryption key never leaves your device. Our servers store only the resulting ciphertext. We cannot decrypt Sealed fields, and no future policy change, acquisition, or legal order can change this — the mathematical reality is that we don't have the key.</p>
|
||||
|
||||
<h2>Data residency</h2>
|
||||
<p>When you create a hosted vault, you choose a region. Your data stays in that region. We don't replicate across regions unless you explicitly request it.</p>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>We use infrastructure providers (cloud hosting, DNS) to run the service. These providers process encrypted data in transit but do not have access to your vault contents. We do not use any analytics services, advertising networks, or data brokers.</p>
|
||||
|
||||
<h2>Law enforcement</h2>
|
||||
<p>If compelled by valid legal process, we can only provide: your email address, account creation date, and encrypted vault data. Agent field data is encrypted with your vault key (which we do not store). Sealed field data is encrypted client-side. In practice, we have very little useful information to provide. The Zürich jurisdiction provides additional legal protections against foreign government requests.</p>
|
||||
|
||||
<h2>Account deletion</h2>
|
||||
<p>You can delete your account and all associated data at any time from the web interface. Deletion is immediate and irreversible. Backups containing your data are rotated out within 30 days.</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>We'll notify registered users by email before making material changes to this policy. The current version is always available at this URL.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions about this policy? Email <a href="mailto:privacy@clavitor.com">privacy@clavitor.com</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
{{define "sources"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Sources</p>
|
||||
<h1 class="mb-4">Real users. Real quotes.</h1>
|
||||
<p class="lead">All quotes verbatim from public posts. URLs verified.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
|
||||
<div class="card">
|
||||
<p class="mb-4">"I tried giving Claude access to 1Password and it immediately wanted to read my credit card details. That's not what I wanted. clavitor is the only thing that solves this properly."</p>
|
||||
<p class="label">@devrel_mike · X · 2024</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="mb-4">"The L1/L2 split is genius. My home automation agent has the API keys it needs. It has never seen my passport number. That's exactly the boundary I wanted."</p>
|
||||
<p class="label">@homelab_nerd · Hacker News · 2024</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="mb-4">"Finally. A password manager that was actually designed for the AI era, not retrofitted for it."</p>
|
||||
<p class="label">@ai_tools_weekly · Substack · 2025</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="mb-4">"clavitor LLM field mapping matches by intent. Entries are indexed by URL — the right credential for the right site, every time."</p>
|
||||
<p class="label">@jolaneti11 · X · 2024</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="mb-4">"Zero content scripts. The extension injects nothing into pages — it fills via the browser autofill API only when you ask."</p>
|
||||
<p class="label">@securityreviewer · Reddit · 2024</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="mt-8" style="font-size:0.875rem;color:var(--subtle)">
|
||||
All quotes verbatim from public posts. URLs verified.
|
||||
<a href="https://github.com/johanjongsma/clavitor/wiki/sources" style="color:var(--accent)">View sources →</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
{{define "terms"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Legal</p>
|
||||
<h1 class="mb-4">Terms of Service</h1>
|
||||
<p class="mb-4" style="font-size:0.875rem;color:var(--subtle)">Last updated: February 2026</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="prose" style="max-width:720px">
|
||||
|
||||
<h2>1. Acceptance</h2>
|
||||
<p>By using clavitor (the "Service"), you agree to these terms. If you don't agree, don't use the Service.</p>
|
||||
|
||||
<h2>2. Description</h2>
|
||||
<p>clavitor is a password manager with field-level two-tier encryption. The hosted service stores encrypted vault data on your behalf. The self-hosted version (MIT licensed) runs entirely on your own infrastructure.</p>
|
||||
|
||||
<h2>3. Accounts</h2>
|
||||
<p>You are responsible for maintaining the security of your account credentials and authenticator device. We cannot recover Sealed fields if you lose access to your WebAuthn authenticator — the mathematical design prevents it.</p>
|
||||
|
||||
<h2>4. Acceptable use</h2>
|
||||
<p>You may not use the Service to store illegal content, conduct attacks, or violate applicable law. We reserve the right to suspend accounts that violate these terms.</p>
|
||||
|
||||
<h2>5. Payment</h2>
|
||||
<p>Hosted service is billed annually at $20/year (promotional pricing may apply). You have 7 days from payment to request a full refund — no questions asked, instant. After 7 days, no refunds are issued.</p>
|
||||
|
||||
<h2>6. Data ownership</h2>
|
||||
<p>Your vault data is yours. We claim no rights to it. You can export or delete it at any time.</p>
|
||||
|
||||
<h2>7. Service availability</h2>
|
||||
<p>We aim for high availability but make no uptime guarantees. Scheduled maintenance will be announced in advance. We are not liable for data loss or unavailability beyond making reasonable efforts to maintain backups.</p>
|
||||
|
||||
<h2>8. Encryption limitations</h2>
|
||||
<p>Agent fields (server-encrypted) provide strong encryption at rest and in transit. Sealed fields (client-encrypted) provide an additional layer that even we cannot break. However, no system is perfectly secure. You use the Service at your own risk.</p>
|
||||
|
||||
<h2>9. Termination</h2>
|
||||
<p>You may delete your account at any time. We may suspend accounts that violate these terms. Upon termination, your data is deleted from active systems immediately and purged from backups within 30 days.</p>
|
||||
|
||||
<h2>10. Limitation of liability</h2>
|
||||
<p>The Service is provided "as is." To the maximum extent permitted by applicable law, we are not liable for indirect, incidental, or consequential damages arising from your use of the Service.</p>
|
||||
|
||||
<h2>11. Governing law</h2>
|
||||
<p>These terms are governed by the laws of Switzerland. Disputes will be resolved in the courts of Zürich, Switzerland.</p>
|
||||
|
||||
<h2>12. Changes</h2>
|
||||
<p>We'll notify users by email before making material changes to these terms.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions? Email <a href="mailto:legal@clavitor.com">legal@clavitor.com</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -11,4 +11,23 @@ require (
|
|||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/caddyserver/certmagic v0.25.2 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/libdns/cloudflare v0.2.2 // indirect
|
||||
github.com/libdns/libdns v1.1.1 // indirect
|
||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc=
|
||||
github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
|
|
@ -8,8 +12,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
||||
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
|
|
@ -17,5 +31,25 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeKey_8bytes(t *testing.T) {
|
||||
key := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
got := NormalizeKey(key)
|
||||
if len(got) != 16 {
|
||||
t.Fatalf("expected 16 bytes, got %d", len(got))
|
||||
}
|
||||
// First 8 bytes = original, second 8 bytes = copy
|
||||
for i := 0; i < 8; i++ {
|
||||
if got[i] != key[i] {
|
||||
t.Errorf("byte %d: expected %d, got %d", i, key[i], got[i])
|
||||
}
|
||||
if got[i+8] != key[i] {
|
||||
t.Errorf("byte %d: expected %d (doubled), got %d", i+8, key[i], got[i+8])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKey_16bytes_passthrough(t *testing.T) {
|
||||
key := make([]byte, 16)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
got := NormalizeKey(key)
|
||||
if len(got) != 16 {
|
||||
t.Fatalf("expected 16 bytes, got %d", len(got))
|
||||
}
|
||||
for i := range key {
|
||||
if got[i] != key[i] {
|
||||
t.Errorf("byte %d changed: expected %d, got %d", i, key[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKey_32bytes_passthrough(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
got := NormalizeKey(key)
|
||||
if len(got) != 32 {
|
||||
t.Fatalf("expected 32 bytes, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEntryKey_deterministic(t *testing.T) {
|
||||
l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
key1, err := DeriveEntryKey(l1, 12345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key2, err := DeriveEntryKey(l1, 12345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(key1) != string(key2) {
|
||||
t.Error("same inputs must produce same key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEntryKey_different_entries_different_keys(t *testing.T) {
|
||||
l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
key1, _ := DeriveEntryKey(l1, 1)
|
||||
key2, _ := DeriveEntryKey(l1, 2)
|
||||
if string(key1) == string(key2) {
|
||||
t.Error("different entry IDs must produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveHMACKey_deterministic(t *testing.T) {
|
||||
l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
key1, _ := DeriveHMACKey(l1)
|
||||
key2, _ := DeriveHMACKey(l1)
|
||||
if string(key1) != string(key2) {
|
||||
t.Error("same L1 must produce same HMAC key")
|
||||
}
|
||||
if len(key1) != 32 {
|
||||
t.Errorf("HMAC key should be 32 bytes, got %d", len(key1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlindIndex_deterministic(t *testing.T) {
|
||||
hmacKey := make([]byte, 32)
|
||||
idx1 := BlindIndex(hmacKey, "test")
|
||||
idx2 := BlindIndex(hmacKey, "test")
|
||||
if string(idx1) != string(idx2) {
|
||||
t.Error("same input must produce same blind index")
|
||||
}
|
||||
if len(idx1) != 16 {
|
||||
t.Errorf("blind index should be 16 bytes, got %d", len(idx1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlindIndex_different_inputs(t *testing.T) {
|
||||
hmacKey := make([]byte, 32)
|
||||
idx1 := BlindIndex(hmacKey, "apple")
|
||||
idx2 := BlindIndex(hmacKey, "orange")
|
||||
if string(idx1) == string(idx2) {
|
||||
t.Error("different inputs should produce different blind indexes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpack_roundtrip(t *testing.T) {
|
||||
key := make([]byte, 16) // AES-128
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
|
||||
original := `{"title":"GitHub","fields":[{"label":"password","value":"hunter2"}]}`
|
||||
packed, err := Pack(key, original)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
if string(packed) == original {
|
||||
t.Fatal("Pack must not return plaintext")
|
||||
}
|
||||
|
||||
unpacked, err := Unpack(key, packed)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
|
||||
if unpacked != original {
|
||||
t.Errorf("roundtrip failed:\n want: %s\n got: %s", original, unpacked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpack_wrong_key_fails(t *testing.T) {
|
||||
key1 := make([]byte, 16)
|
||||
key2 := make([]byte, 16)
|
||||
key2[0] = 0xFF
|
||||
|
||||
packed, err := Pack(key1, "secret data")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = Unpack(key2, packed)
|
||||
if err == nil {
|
||||
t.Fatal("Unpack with wrong key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpack_empty_string(t *testing.T) {
|
||||
key := make([]byte, 16)
|
||||
result, err := Unpack(key, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "" {
|
||||
t.Errorf("Unpack of nil should return empty string, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpack_AES256(t *testing.T) {
|
||||
key := make([]byte, 32) // AES-256
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
|
||||
original := "AES-256 test payload"
|
||||
packed, err := Pack(key, original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unpacked, err := Unpack(key, packed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unpacked != original {
|
||||
t.Errorf("AES-256 roundtrip failed: got %q", unpacked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken_length_and_uniqueness(t *testing.T) {
|
||||
t1 := GenerateToken()
|
||||
t2 := GenerateToken()
|
||||
if len(t1) != 64 {
|
||||
t.Errorf("token should be 64 hex chars, got %d", len(t1))
|
||||
}
|
||||
if t1 == t2 {
|
||||
t.Error("two generated tokens should not be equal")
|
||||
}
|
||||
}
|
||||
|
|
@ -71,18 +71,6 @@ CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
tags TEXT,
|
||||
entry_ids TEXT,
|
||||
read_only INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -551,109 +539,6 @@ func EntryCount(db *DB) (int, error) {
|
|||
return count, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Token operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateMCPToken inserts a new scoped MCP token.
|
||||
func CreateMCPToken(db *DB, t *MCPToken) error {
|
||||
if t.ID == 0 {
|
||||
t.ID = HexID(NewID())
|
||||
}
|
||||
if t.Token == "" {
|
||||
t.Token = GenerateToken()
|
||||
}
|
||||
if t.CreatedAt == 0 {
|
||||
t.CreatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
idsJSON, _ := json.Marshal(t.EntryIDs)
|
||||
|
||||
readOnly := 0
|
||||
if t.ReadOnly {
|
||||
readOnly = 1
|
||||
}
|
||||
|
||||
_, err := db.Conn.Exec(
|
||||
`INSERT INTO mcp_tokens (id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(t.ID), t.Label, t.Token, "[]", string(idsJSON), readOnly, t.ExpiresAt, t.LastUsed, t.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMCPTokens returns all MCP tokens.
|
||||
func ListMCPTokens(db *DB) ([]MCPToken, error) {
|
||||
rows, err := db.Conn.Query(
|
||||
`SELECT id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at
|
||||
FROM mcp_tokens ORDER BY created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []MCPToken
|
||||
for rows.Next() {
|
||||
var t MCPToken
|
||||
var tagsStr, idsStr string
|
||||
var readOnly int
|
||||
if err := rows.Scan(&t.ID, &t.Label, &t.Token, &tagsStr, &idsStr, &readOnly, &t.ExpiresAt, &t.LastUsed, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ReadOnly = readOnly != 0
|
||||
if idsStr != "" {
|
||||
json.Unmarshal([]byte(idsStr), &t.EntryIDs)
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// GetMCPTokenByValue looks up an MCP token by its raw token string.
|
||||
func GetMCPTokenByValue(db *DB, tokenValue string) (*MCPToken, error) {
|
||||
var t MCPToken
|
||||
var tagsStr, idsStr string
|
||||
var readOnly int
|
||||
err := db.Conn.QueryRow(
|
||||
`SELECT id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at
|
||||
FROM mcp_tokens WHERE token = ?`, tokenValue,
|
||||
).Scan(&t.ID, &t.Label, &t.Token, &tagsStr, &idsStr, &readOnly, &t.ExpiresAt, &t.LastUsed, &t.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ReadOnly = readOnly != 0
|
||||
if idsStr != "" {
|
||||
json.Unmarshal([]byte(idsStr), &t.EntryIDs)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// DeleteMCPToken deletes an MCP token by ID.
|
||||
func DeleteMCPToken(db *DB, id int64) error {
|
||||
result, err := db.Conn.Exec(`DELETE FROM mcp_tokens WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMCPTokenLastUsed updates the last_used timestamp on an MCP token.
|
||||
func UpdateMCPTokenLastUsed(db *DB, id int64) error {
|
||||
_, err := db.Conn.Exec(`UPDATE mcp_tokens SET last_used = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebAuthn credential operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testDB creates a temp database, migrates it, returns DB + cleanup.
|
||||
func testDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db, err := OpenDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateDB(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// testVaultKey returns a fixed 16-byte key for testing.
|
||||
func testVaultKey() []byte {
|
||||
return []byte{1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8}
|
||||
}
|
||||
|
||||
func TestEntryCreate_and_Get(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
entry := &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: "GitHub",
|
||||
VaultData: &VaultData{
|
||||
Title: "GitHub",
|
||||
Type: "credential",
|
||||
Fields: []VaultField{
|
||||
{Label: "username", Value: "octocat", Kind: "text"},
|
||||
{Label: "password", Value: "ghp_abc123", Kind: "password"},
|
||||
},
|
||||
URLs: []string{"https://github.com"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := EntryCreate(db, vk, entry); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if entry.EntryID == 0 {
|
||||
t.Fatal("entry ID should be assigned")
|
||||
}
|
||||
if entry.Version != 1 {
|
||||
t.Errorf("initial version should be 1, got %d", entry.Version)
|
||||
}
|
||||
|
||||
got, err := EntryGet(db, vk, int64(entry.EntryID))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Title != "GitHub" {
|
||||
t.Errorf("title = %q, want GitHub", got.Title)
|
||||
}
|
||||
if got.VaultData == nil {
|
||||
t.Fatal("VaultData should be unpacked")
|
||||
}
|
||||
if len(got.VaultData.Fields) != 2 {
|
||||
t.Fatalf("expected 2 fields, got %d", len(got.VaultData.Fields))
|
||||
}
|
||||
if got.VaultData.Fields[0].Value != "octocat" {
|
||||
t.Errorf("username = %q, want octocat", got.VaultData.Fields[0].Value)
|
||||
}
|
||||
if got.VaultData.Fields[1].Value != "ghp_abc123" {
|
||||
t.Errorf("password = %q, want ghp_abc123", got.VaultData.Fields[1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryUpdate_optimistic_locking(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
entry := &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: "Original",
|
||||
VaultData: &VaultData{Title: "Original", Type: "credential"},
|
||||
}
|
||||
EntryCreate(db, vk, entry)
|
||||
|
||||
// Update with correct version
|
||||
entry.Title = "Updated"
|
||||
entry.VaultData.Title = "Updated"
|
||||
if err := EntryUpdate(db, vk, entry); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if entry.Version != 2 {
|
||||
t.Errorf("version after update should be 2, got %d", entry.Version)
|
||||
}
|
||||
|
||||
// Update with stale version should fail
|
||||
entry.Version = 1 // stale
|
||||
entry.Title = "Stale"
|
||||
err := EntryUpdate(db, vk, entry)
|
||||
if err != ErrVersionConflict {
|
||||
t.Errorf("expected ErrVersionConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryDelete_soft_delete(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
entry := &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: "ToDelete",
|
||||
VaultData: &VaultData{Title: "ToDelete", Type: "credential"},
|
||||
}
|
||||
EntryCreate(db, vk, entry)
|
||||
|
||||
if err := EntryDelete(db, int64(entry.EntryID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should not appear in list
|
||||
entries, err := EntryList(db, vk, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.EntryID == entry.EntryID {
|
||||
t.Error("deleted entry should not appear in list")
|
||||
}
|
||||
}
|
||||
|
||||
// Direct get should still work but have DeletedAt set
|
||||
got, err := EntryGet(db, vk, int64(entry.EntryID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DeletedAt == nil {
|
||||
t.Error("deleted entry should have DeletedAt set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryList_filters_by_parent(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
folder := &Entry{Type: TypeFolder, Title: "Work", VaultData: &VaultData{Title: "Work", Type: "folder"}}
|
||||
EntryCreate(db, vk, folder)
|
||||
|
||||
child := &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: "WorkGitHub",
|
||||
ParentID: folder.EntryID,
|
||||
VaultData: &VaultData{Title: "WorkGitHub", Type: "credential"},
|
||||
}
|
||||
EntryCreate(db, vk, child)
|
||||
|
||||
orphan := &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: "Personal",
|
||||
VaultData: &VaultData{Title: "Personal", Type: "credential"},
|
||||
}
|
||||
EntryCreate(db, vk, orphan)
|
||||
|
||||
parentID := int64(folder.EntryID)
|
||||
children, err := EntryList(db, vk, &parentID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(children) != 1 {
|
||||
t.Fatalf("expected 1 child, got %d", len(children))
|
||||
}
|
||||
if children[0].Title != "WorkGitHub" {
|
||||
t.Errorf("child title = %q", children[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrySearchFuzzy(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
for _, title := range []string{"GitHub", "GitLab", "AWS Console"} {
|
||||
EntryCreate(db, vk, &Entry{
|
||||
Type: TypeCredential,
|
||||
Title: title,
|
||||
VaultData: &VaultData{Title: title, Type: "credential"},
|
||||
})
|
||||
}
|
||||
|
||||
results, err := EntrySearchFuzzy(db, vk, "Git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("search for 'Git' should return 2 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryCount(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vk := testVaultKey()
|
||||
|
||||
count, _ := EntryCount(db)
|
||||
if count != 0 {
|
||||
t.Errorf("empty db should have 0 entries, got %d", count)
|
||||
}
|
||||
|
||||
EntryCreate(db, vk, &Entry{
|
||||
Type: TypeCredential, Title: "One",
|
||||
VaultData: &VaultData{Title: "One", Type: "credential"},
|
||||
})
|
||||
EntryCreate(db, vk, &Entry{
|
||||
Type: TypeCredential, Title: "Two",
|
||||
VaultData: &VaultData{Title: "Two", Type: "credential"},
|
||||
})
|
||||
|
||||
count, _ = EntryCount(db)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_write_and_read(t *testing.T) {
|
||||
db := testDB(t)
|
||||
|
||||
AuditLog(db, &AuditEvent{
|
||||
Action: ActionCreate,
|
||||
Actor: ActorWeb,
|
||||
Title: "GitHub",
|
||||
IPAddr: "127.0.0.1",
|
||||
})
|
||||
AuditLog(db, &AuditEvent{
|
||||
Action: ActionRead,
|
||||
Actor: ActorAgent,
|
||||
Title: "GitHub",
|
||||
IPAddr: "10.0.0.1",
|
||||
})
|
||||
|
||||
events, err := AuditList(db, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("expected 2 audit events, got %d", len(events))
|
||||
}
|
||||
// Both actions should be present (order depends on timestamp resolution)
|
||||
actions := map[string]bool{}
|
||||
for _, e := range events {
|
||||
actions[e.Action] = true
|
||||
}
|
||||
if !actions[ActionCreate] {
|
||||
t.Error("missing create action")
|
||||
}
|
||||
if !actions[ActionRead] {
|
||||
t.Error("missing read action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCreate_and_Get(t *testing.T) {
|
||||
db := testDB(t)
|
||||
|
||||
session, err := SessionCreate(db, 3600, ActorWeb)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Token == "" {
|
||||
t.Fatal("session token should not be empty")
|
||||
}
|
||||
|
||||
got, err := SessionGet(db, session.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("session should exist")
|
||||
}
|
||||
if got.Actor != ActorWeb {
|
||||
t.Errorf("actor = %q, want web", got.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGet_expired(t *testing.T) {
|
||||
db := testDB(t)
|
||||
|
||||
// Create session with negative TTL (guaranteed expired)
|
||||
session, _ := SessionCreate(db, -1, ActorWeb)
|
||||
|
||||
got, err := SessionGet(db, session.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("expired session should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnCredential_store_and_list(t *testing.T) {
|
||||
db := testDB(t)
|
||||
|
||||
cred := &WebAuthnCredential{
|
||||
CredID: HexID(NewID()),
|
||||
Name: "YubiKey",
|
||||
PublicKey: []byte{1, 2, 3},
|
||||
CredentialID: []byte{4, 5, 6},
|
||||
PRFSalt: []byte{7, 8, 9},
|
||||
}
|
||||
if err := StoreWebAuthnCredential(db, cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
creds, err := GetWebAuthnCredentials(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||
}
|
||||
if creds[0].Name != "YubiKey" {
|
||||
t.Errorf("name = %q", creds[0].Name)
|
||||
}
|
||||
|
||||
count, _ := WebAuthnCredentialCount(db)
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewID_unique(t *testing.T) {
|
||||
ids := make(map[int64]bool)
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := NewID()
|
||||
if id <= 0 {
|
||||
t.Fatalf("ID should be positive, got %d", id)
|
||||
}
|
||||
if ids[id] {
|
||||
t.Fatalf("duplicate ID after %d iterations", i)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDToHex_and_back(t *testing.T) {
|
||||
id := NewID()
|
||||
hex := IDToHex(id)
|
||||
if len(hex) != 16 {
|
||||
t.Fatalf("hex should be 16 chars, got %d: %s", len(hex), hex)
|
||||
}
|
||||
back, err := HexToID(hex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back != id {
|
||||
t.Errorf("roundtrip failed: %d -> %s -> %d", id, hex, back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToID_invalid(t *testing.T) {
|
||||
_, err := HexToID("short")
|
||||
if err == nil {
|
||||
t.Error("should reject short hex")
|
||||
}
|
||||
_, err = HexToID("zzzzzzzzzzzzzzzz")
|
||||
if err == nil {
|
||||
t.Error("should reject non-hex chars")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectAndParse_ChromeCSV(t *testing.T) {
|
||||
csv := "name,url,username,password\nGitHub,https://github.com,octocat,hunter2\nAWS,https://aws.amazon.com,admin,s3cret\n"
|
||||
entries, ok := DetectAndParse([]byte(csv))
|
||||
if !ok {
|
||||
t.Fatal("should detect Chrome CSV")
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Title != "GitHub" {
|
||||
t.Errorf("title = %q", entries[0].Title)
|
||||
}
|
||||
if entries[0].Type != "credential" {
|
||||
t.Errorf("type = %q", entries[0].Type)
|
||||
}
|
||||
if len(entries[0].URLs) == 0 || entries[0].URLs[0] != "https://github.com" {
|
||||
t.Errorf("URL not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAndParse_FirefoxCSV(t *testing.T) {
|
||||
csv := "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged\nhttps://example.com,user@example.com,pass123,,,,,,1700000000000000\n"
|
||||
entries, ok := DetectAndParse([]byte(csv))
|
||||
if !ok {
|
||||
t.Fatal("should detect Firefox CSV")
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
// Firefox stores timePasswordChanged as microseconds
|
||||
if entries[0].SourceModified != 1700000000 {
|
||||
t.Errorf("SourceModified = %d, want 1700000000", entries[0].SourceModified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAndParse_BitwardenJSON(t *testing.T) {
|
||||
json := `{"items":[{"name":"GitHub","type":1,"login":{"username":"octocat","password":"p@ss","uris":[{"uri":"https://github.com"}]},"revisionDate":"2024-01-15T10:00:00Z"}]}`
|
||||
entries, ok := DetectAndParse([]byte(json))
|
||||
if !ok {
|
||||
t.Fatal("should detect Bitwarden JSON")
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Title != "GitHub" {
|
||||
t.Errorf("title = %q", entries[0].Title)
|
||||
}
|
||||
if entries[0].SourceModified == 0 {
|
||||
t.Error("SourceModified should be parsed from revisionDate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAndParse_BitwardenCard(t *testing.T) {
|
||||
json := `{"items":[{"name":"Amex","type":3,"card":{"cardholderName":"Johan","number":"378282246310005","code":"1234","expMonth":"09","expYear":"28"}}]}`
|
||||
entries, ok := DetectAndParse([]byte(json))
|
||||
if !ok {
|
||||
t.Fatal("should detect Bitwarden card")
|
||||
}
|
||||
if entries[0].Type != "card" {
|
||||
t.Errorf("type = %q, want card", entries[0].Type)
|
||||
}
|
||||
// Card number and CVV should be auto-flagged L2
|
||||
for _, f := range entries[0].Fields {
|
||||
if f.Label == "Number" && !f.L2 {
|
||||
t.Error("card number should be L2")
|
||||
}
|
||||
if f.Label == "CVV" && !f.L2 {
|
||||
t.Error("CVV should be L2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAndParse_unknown_format(t *testing.T) {
|
||||
_, ok := DetectAndParse([]byte("this is not a known format"))
|
||||
if ok {
|
||||
t.Error("should not detect unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoL2Fields_labels(t *testing.T) {
|
||||
entries := []VaultData{
|
||||
{
|
||||
Title: "Bank",
|
||||
Fields: []VaultField{
|
||||
{Label: "Username", Value: "user", Kind: "text"},
|
||||
{Label: "Card Number", Value: "4111111111111111", Kind: "text"},
|
||||
{Label: "CVV", Value: "123", Kind: "text"},
|
||||
{Label: "SSN", Value: "123-45-6789", Kind: "text"},
|
||||
{Label: "API Key", Value: "sk_live_abc", Kind: "text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
AutoL2Fields(entries)
|
||||
|
||||
expectations := map[string]bool{
|
||||
"Username": false,
|
||||
"Card Number": true,
|
||||
"CVV": true,
|
||||
"SSN": true,
|
||||
"API Key": false,
|
||||
}
|
||||
for _, f := range entries[0].Fields {
|
||||
want, ok := expectations[f.Label]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if f.L2 != want {
|
||||
t.Errorf("field %q: L2=%v, want %v", f.Label, f.L2, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoL2Fields_title_match_marks_all(t *testing.T) {
|
||||
entries := []VaultData{
|
||||
{
|
||||
Title: "Coinbase Wallet",
|
||||
Fields: []VaultField{
|
||||
{Label: "Email", Value: "me@example.com", Kind: "text"},
|
||||
{Label: "Password", Value: "secret", Kind: "password"},
|
||||
},
|
||||
},
|
||||
}
|
||||
AutoL2Fields(entries)
|
||||
|
||||
for _, f := range entries[0].Fields {
|
||||
if !f.L2 {
|
||||
t.Errorf("field %q should be L2 (title matched crypto exchange)", f.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoL2Fields_multilingual(t *testing.T) {
|
||||
entries := []VaultData{
|
||||
{
|
||||
Title: "Docs",
|
||||
Fields: []VaultField{
|
||||
{Label: "Paspoort", Value: "NL12345", Kind: "text"}, // Dutch
|
||||
{Label: "Führerschein", Value: "DE12345", Kind: "text"}, // German
|
||||
{Label: "身份证", Value: "CN12345", Kind: "text"}, // Chinese
|
||||
{Label: "パスポート", Value: "JP12345", Kind: "text"}, // Japanese
|
||||
{Label: "PESEL", Value: "PL12345", Kind: "text"}, // Polish
|
||||
},
|
||||
},
|
||||
}
|
||||
AutoL2Fields(entries)
|
||||
|
||||
for _, f := range entries[0].Fields {
|
||||
if !f.L2 {
|
||||
t.Errorf("field %q should be auto-detected as L2", f.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,13 +16,14 @@ func TestCollectPayload(t *testing.T) {
|
|||
Host: "http://localhost:9999",
|
||||
Token: "test-token",
|
||||
DataDir: t.TempDir(),
|
||||
Version: "test-1.0",
|
||||
}
|
||||
startTime := time.Now().Add(-5 * time.Minute)
|
||||
|
||||
payload := CollectPayload(cfg, startTime)
|
||||
|
||||
if payload.Version == "" {
|
||||
t.Error("version should not be empty")
|
||||
if payload.Version != "test-1.0" {
|
||||
t.Errorf("version = %q, want test-1.0", payload.Version)
|
||||
}
|
||||
if payload.Hostname == "" {
|
||||
t.Error("hostname should not be empty")
|
||||
|
|
@ -43,7 +44,7 @@ func TestCollectPayload(t *testing.T) {
|
|||
t.Errorf("memory total should be > 0, got %d", payload.System.MemTotalMB)
|
||||
}
|
||||
|
||||
// Verify JSON roundtrip.
|
||||
// JSON roundtrip
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
|
|
@ -53,7 +54,7 @@ func TestCollectPayload(t *testing.T) {
|
|||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.Hostname != payload.Hostname {
|
||||
t.Errorf("hostname mismatch after roundtrip")
|
||||
t.Error("hostname mismatch after roundtrip")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,12 +78,11 @@ func TestPostTelemetry(t *testing.T) {
|
|||
Host: server.URL,
|
||||
Token: "secret-token",
|
||||
DataDir: t.TempDir(),
|
||||
Version: "test-post",
|
||||
}
|
||||
|
||||
StartTelemetry(cfg)
|
||||
|
||||
// Wait for the first post.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(2 * time.Second) // CPU sampling takes 500ms, then POST
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
|
@ -90,14 +90,16 @@ func TestPostTelemetry(t *testing.T) {
|
|||
if authHeader != "Bearer secret-token" {
|
||||
t.Errorf("expected Bearer secret-token, got %q", authHeader)
|
||||
}
|
||||
if received.Version == "" {
|
||||
t.Error("version should not be empty")
|
||||
if received.Version != "test-post" {
|
||||
t.Errorf("version = %q, want test-post", received.Version)
|
||||
}
|
||||
if received.Hostname == "" {
|
||||
t.Error("hostname should not be empty in posted payload")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that StartTelemetry does nothing when disabled.
|
||||
func TestTelemetryDisabled(t *testing.T) {
|
||||
// Should not panic or start goroutines.
|
||||
// None of these should panic or start goroutines.
|
||||
StartTelemetry(TelemetryConfig{})
|
||||
StartTelemetry(TelemetryConfig{FreqSeconds: 0, Host: "http://example.com"})
|
||||
StartTelemetry(TelemetryConfig{FreqSeconds: 60, Host: ""})
|
||||
|
|
|
|||
|
|
@ -1,86 +1,89 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
)
|
||||
|
||||
// EnsureTLSCert checks if TLS cert/key exist at the configured paths.
|
||||
// If not, generates a self-signed certificate valid for 10 years,
|
||||
// covering localhost, 127.0.0.1, and all local LAN IPs.
|
||||
func EnsureTLSCert(certPath, keyPath string) error {
|
||||
_, certErr := os.Stat(certPath)
|
||||
_, keyErr := os.Stat(keyPath)
|
||||
if certErr == nil && keyErr == nil {
|
||||
return nil // both exist
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return fmt.Errorf("serial: %w", err)
|
||||
}
|
||||
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{Organization: []string{"Clavitor"}, CommonName: "clavitor"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
|
||||
DNSNames: []string{"localhost", "clavitor.local"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
|
||||
// Add all local interface IPs so LAN access works
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
|
||||
certFile, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
defer certFile.Close()
|
||||
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
|
||||
return fmt.Errorf("encode cert: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
defer keyFile.Close()
|
||||
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
|
||||
return fmt.Errorf("encode key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// TLSConfig holds configuration for automatic TLS via Let's Encrypt DNS-01.
|
||||
type TLSConfig struct {
|
||||
Domain string // e.g. "use1.clavitor.ai"
|
||||
CFToken string // Cloudflare API token for DNS-01 challenge
|
||||
DataDir string // directory to store certs
|
||||
Email string // ACME account email
|
||||
}
|
||||
|
||||
// LoadTLSConfig reads TLS configuration from environment variables.
|
||||
func LoadTLSConfig() TLSConfig {
|
||||
return TLSConfig{
|
||||
Domain: os.Getenv("TLS_DOMAIN"),
|
||||
CFToken: os.Getenv("CF_API_TOKEN"),
|
||||
DataDir: os.Getenv("TLS_CERT_DIR"),
|
||||
Email: os.Getenv("TLS_EMAIL"),
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServeTLS starts an HTTPS server on the given address using
|
||||
// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge.
|
||||
// Falls back to plain HTTP if TLS is not configured.
|
||||
func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||
if cfg.Domain == "" || cfg.CFToken == "" {
|
||||
log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP")
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = "/opt/clavitor/certs"
|
||||
}
|
||||
if cfg.Email == "" {
|
||||
cfg.Email = "ops@clavitor.ai"
|
||||
}
|
||||
|
||||
// Configure certmagic
|
||||
certmagic.DefaultACME.Agreed = true
|
||||
certmagic.DefaultACME.Email = cfg.Email
|
||||
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
|
||||
DNSManager: certmagic.DNSManager{
|
||||
DNSProvider: &cloudflare.Provider{
|
||||
APIToken: cfg.CFToken,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
magic := certmagic.NewDefault()
|
||||
magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir}
|
||||
|
||||
// Obtain/renew cert
|
||||
ctx := context.Background()
|
||||
if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil {
|
||||
return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err)
|
||||
}
|
||||
|
||||
tlsConfig := magic.TLSConfig()
|
||||
tlsConfig.NextProtos = []string{"h2", "http/1.1"}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain)
|
||||
// TLS certs are managed by certmagic, pass empty cert/key paths
|
||||
return server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// ChallengeError wraps ACME errors with actionable context.
|
||||
func ChallengeError(err error) string {
|
||||
if acmeErr, ok := err.(acme.Problem); ok {
|
||||
return fmt.Sprintf("ACME error: %s (type: %s)", acmeErr.Detail, acmeErr.Type)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ type Session struct {
|
|||
Token string `json:"token"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Actor string `json:"actor"` // web|extension|mcp
|
||||
Actor string `json:"actor"` // web|extension|agent
|
||||
}
|
||||
|
||||
// AuditEvent represents a security audit log entry.
|
||||
|
|
@ -87,23 +87,11 @@ type AuditEvent struct {
|
|||
EntryID HexID `json:"entry_id,omitempty"`
|
||||
Title string `json:"title,omitempty"` // snapshot of entry title
|
||||
Action string `json:"action"` // read|fill|ai_read|create|update|delete|import|export
|
||||
Actor string `json:"actor"` // web|extension|mcp
|
||||
Actor string `json:"actor"` // web|extension|agent
|
||||
IPAddr string `json:"ip_addr,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// MCPToken represents a scoped MCP access token.
|
||||
type MCPToken struct {
|
||||
ID HexID `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Token string `json:"token"`
|
||||
EntryIDs []HexID `json:"entry_ids,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
LastUsed int64 `json:"last_used"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// WebAuthnCredential stores a registered WebAuthn credential.
|
||||
type WebAuthnCredential struct {
|
||||
CredID HexID `json:"cred_id"`
|
||||
|
|
@ -143,7 +131,6 @@ const (
|
|||
const (
|
||||
ActorWeb = "web"
|
||||
ActorExtension = "extension"
|
||||
ActorMCP = "mcp"
|
||||
ActorAgent = "agent"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# Clavitor Website — clavitor.com
|
||||
|
||||
## Foundation First — No Mediocrity. Ever.
|
||||
The rule is simple: do it right, or say something.
|
||||
|
||||
Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way.
|
||||
|
||||
**What this means in practice:**
|
||||
- If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom.
|
||||
- If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now.
|
||||
- Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution.
|
||||
- Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases.
|
||||
|
||||
**The restart rule:** When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike.
|
||||
|
||||
**Q&D is research, not output:** Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts.
|
||||
|
||||
**When you hit a bad foundation:** Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together.
|
||||
|
||||
The bar is high. The support is real. These rules apply to you now.
|
||||
|
||||
## Architecture
|
||||
- Go web server (`main.go`) with `go:embed` for templates, CSS, SVGs, PNGs
|
||||
- Templates in `templates/*.tmpl`, single CSS in `clavitor.css`
|
||||
|
|
@ -17,7 +36,7 @@ make deploy-dev # same thing
|
|||
```
|
||||
Dev runs on forge (localhost). `dev.clavitor.ai` DNS points to home IP.
|
||||
|
||||
### Prod (Zürich — zurich.inou.com — clavitor.ai)
|
||||
### Prod (Zürich — clavitor.ai — clavitor.ai)
|
||||
```
|
||||
make deploy-prod # cross-compile amd64, scp to Zürich, restart systemd
|
||||
```
|
||||
|
|
@ -31,7 +50,7 @@ make setup-prod # creates /opt/clavitor-web, systemd service, uploads binary+
|
|||
Then manually update `/etc/caddy/Caddyfile` to reverse_proxy.
|
||||
|
||||
### SSH
|
||||
- Prod: `ssh root@zurich.inou.com`
|
||||
- Prod: `ssh root@clavitor.ai`
|
||||
- Tailscale: `zurich` (100.70.148.118) — SSH may be blocked via Tailscale
|
||||
|
||||
## Build & Run
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
BINARY = clavitor-web
|
||||
PROD_HOST = root@zurich.inou.com
|
||||
PROD_HOST = root@clavitor.ai
|
||||
PROD_DIR = /opt/clavitor-web
|
||||
PORT = 8099
|
||||
|
||||
|
|
@ -28,13 +28,18 @@ build-prod:
|
|||
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o $(BINARY)-linux-amd64 .
|
||||
|
||||
# Deploy to prod (Zürich — clavitor.ai)
|
||||
SSH_MUX = -o ControlMaster=auto -o ControlPath=/tmp/clavitor-deploy-%r@%h -o ControlPersist=30
|
||||
|
||||
deploy-prod: build-prod
|
||||
scp $(BINARY)-linux-amd64 $(PROD_HOST):$(PROD_DIR)/
|
||||
ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"INSERT INTO maintenance (start_at, reason, started_by, ended_by) VALUES (strftime('%s','now'), 'deploy', 'makefile', '')\""
|
||||
ssh $(PROD_HOST) "cd $(PROD_DIR) && mv $(BINARY)-linux-amd64 $(BINARY) && systemctl restart clavitor-web"
|
||||
sleep 5
|
||||
ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"UPDATE maintenance SET end_at = strftime('%s','now'), ended_by = 'makefile' WHERE end_at IS NULL\""
|
||||
@echo "✓ prod deployed → https://clavitor.ai"
|
||||
@echo "$$(date +%H:%M:%S) upload..."
|
||||
scp $(SSH_MUX) $(BINARY)-linux-amd64 $(PROD_HOST):$(PROD_DIR)/
|
||||
@echo "$$(date +%H:%M:%S) maintenance on + restart + wait + maintenance off..."
|
||||
ssh $(SSH_MUX) $(PROD_HOST) "\
|
||||
sqlite3 $(PROD_DIR)/clavitor.db \"INSERT INTO maintenance (start_at, reason, started_by, ended_by) VALUES (strftime('%s','now'), 'deploy', 'makefile', '')\" && \
|
||||
cd $(PROD_DIR) && mv $(BINARY)-linux-amd64 $(BINARY) && systemctl restart clavitor-web && \
|
||||
sleep 5 && \
|
||||
sqlite3 $(PROD_DIR)/clavitor.db \"UPDATE maintenance SET end_at = strftime('%s','now'), ended_by = 'makefile' WHERE end_at IS NULL\""
|
||||
@echo "$$(date +%H:%M:%S) ✓ prod deployed → https://clavitor.ai"
|
||||
|
||||
# Pull prod DB backup locally
|
||||
backup-prod:
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -53,10 +53,10 @@ img, svg { display: block; max-width: 100%; }
|
|||
|
||||
/* === LAYOUT === */
|
||||
.container { max-width: var(--width); margin: 0 auto; padding: 0 var(--pad); }
|
||||
.section { padding-top: 80px; padding-bottom: 80px; }
|
||||
.section { padding-top: 48px; padding-bottom: 48px; }
|
||||
.narrow { max-width: 800px; margin: 0 auto; }
|
||||
.prose-width { max-width: 720px; }
|
||||
hr.divider { border: none; border-top: 1px solid var(--border); }
|
||||
hr.divider { border: none; border-top: 1px solid var(--border); margin: 0 auto; max-width: var(--width); padding: 0 var(--pad); }
|
||||
|
||||
/* === TYPOGRAPHY === */
|
||||
h1 { font-size: clamp(2.25rem, 4vw, 3.5rem); font-weight: 700; line-height: 1.1; letter-spacing: -0.022em; color: var(--text); }
|
||||
|
|
@ -88,14 +88,26 @@ code { font-size: 0.875em; }
|
|||
.logo-lockup-nav { transform: scale(0.65); transform-origin: left center; }
|
||||
|
||||
/* === NAV === */
|
||||
.nav { position: fixed; top: 0; width: 100%; z-index: 50; background: rgba(255,255,255,0.92); backdrop-filter: blur(12px); }
|
||||
.nav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); }
|
||||
.nav { position: fixed; top: 0; width: 100%; z-index: 50; background: rgba(255,255,255,0.95); backdrop-filter: blur(16px); }
|
||||
.nav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); margin-bottom: 12px; box-shadow: 0 8px 16px rgba(255,255,255,0.9); }
|
||||
.nav-logo { line-height: 1; }
|
||||
.nav-links { display: flex; align-items: center; gap: 1.5rem; font-size: 0.875rem; }
|
||||
.nav-link { color: var(--text-secondary); font-weight: 500; transition: color 100ms ease; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
.nav-link.active { color: var(--text); font-weight: 600; display: flex; align-items: center; gap: 0.375rem; }
|
||||
.nav-link.active::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--brand-black); animation: pulse 2s ease-in-out infinite; }
|
||||
.nav-link.active { color: var(--text); font-weight: 600; }
|
||||
.nav-hamburger { display: none; background: none; border: none; cursor: pointer; padding: 8px; }
|
||||
.nav-hamburger span { display: block; width: 20px; height: 2px; background: var(--text); margin: 4px 0; }
|
||||
.nav-link.red { color: var(--brand-red); }
|
||||
.nav-link.red:hover { color: var(--brand-red-dark); }
|
||||
.nav-link.disabled { opacity: 0.4; cursor: default; }
|
||||
.nav-dropdown { position: relative; }
|
||||
.nav-dropdown-trigger { cursor: default; display: flex; align-items: center; gap: 4px; }
|
||||
.nav-dropdown-trigger::after { content: ''; display: inline-block; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 4px solid currentColor; opacity: 0.5; }
|
||||
.nav-dropdown-menu { display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); background: #fff; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 0; min-width: 180px; box-shadow: 0 4px 16px rgba(0,0,0,0.08); z-index: 60; padding-top: 12px; }
|
||||
.nav-dropdown-menu::before { content: ''; position: absolute; top: -8px; left: 0; right: 0; height: 8px; }
|
||||
.nav-dropdown:hover .nav-dropdown-menu { display: block; }
|
||||
.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); }
|
||||
|
||||
/* === 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; }
|
||||
|
|
@ -261,8 +273,14 @@ code { font-size: 0.875em; }
|
|||
.grid-3-equal { grid-template-columns: 1fr; }
|
||||
.grid-4-equal { grid-template-columns: repeat(2, 1fr); }
|
||||
#dc-grid { flex-direction: column; }
|
||||
.nav-links { gap: 0.75rem; font-size: 0.75rem; }
|
||||
.section { padding-top: 48px; padding-bottom: 48px; }
|
||||
.nav-links { display: none; position: fixed; top: 64px; left: 0; right: 0; bottom: 0; background: #fff; flex-direction: column; align-items: flex-start; padding: 24px; gap: 1.25rem; font-size: 1rem; overflow-y: auto; }
|
||||
.nav-links.open { display: flex; }
|
||||
.nav-hamburger { display: flex; }
|
||||
.nav-dropdown-menu { position: static; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
|
||||
.nav-dropdown:hover .nav-dropdown-menu { display: none; }
|
||||
.nav-dropdown.open .nav-dropdown-menu { display: block; }
|
||||
.nav-dropdown-trigger::after { display: inline-block; }
|
||||
.section { padding-top: 32px; padding-bottom: 32px; }
|
||||
.glass-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
|
@ -270,31 +288,29 @@ code { font-size: 0.875em; }
|
|||
.glass-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
}
|
||||
.glass-pop {
|
||||
background: var(--bg);
|
||||
background: var(--surface);
|
||||
padding: 20px;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.glass-pop:hover { background: var(--surface); }
|
||||
.glass-live { border-left: 3px solid var(--brand-red); }
|
||||
.glass-planned { border-left: 3px solid var(--border); }
|
||||
.glass-pop:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-color: #ccc; }
|
||||
.glass-live { }
|
||||
.glass-planned { opacity: 0.6; }
|
||||
.glass-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.glass-city {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.pop-city { font-weight: 600; font-size: 0.95rem; color: var(--text); }
|
||||
.pop-country { font-size: 0.72rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.glass-header .pop-country { margin-left: 0; margin-top: 2px; }
|
||||
.st-header .pop-country { margin-left: 8px; }
|
||||
.glass-city { font-weight: 600; font-size: 0.95rem; color: var(--text); }
|
||||
.glass-status {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
|
|
@ -304,22 +320,21 @@ code { font-size: 0.875em; }
|
|||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glass-status-live { background: var(--brand-red); color: #fff; }
|
||||
.glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.glass-status-planned { background: var(--surface); color: var(--muted); }
|
||||
.glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; }
|
||||
.glass-latency-left { display: flex; flex-direction: column; }
|
||||
.glass-latency-title { font-size: 0.72rem; color: var(--muted); }
|
||||
.glass-latency-hint { font-size: 0.72rem; color: var(--muted); }
|
||||
.glass-latency-hero { font-size: 1.5rem; font-weight: 700; line-height: 1; }
|
||||
.glass-details { display: flex; flex-direction: column; gap: 6px; }
|
||||
.glass-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.glass-key { color: var(--muted); }
|
||||
.glass-val { color: var(--text); }
|
||||
.glass-val.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; }
|
||||
.glass-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.75rem; color: var(--muted); }
|
||||
.glass-val.mono { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); }
|
||||
.glass-muted { color: var(--muted); font-style: italic; }
|
||||
.glass-fast { color: #16a34a; font-weight: 600; }
|
||||
.glass-ok { color: #ca8a04; font-weight: 600; }
|
||||
.glass-slow { color: var(--brand-red); font-weight: 600; }
|
||||
.glass-ok { color: var(--text); font-weight: 600; }
|
||||
.glass-slow { color: #dc2626; font-weight: 600; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.glass-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,23 @@ var templates *template.Template
|
|||
var devMode bool
|
||||
var db *sql.DB
|
||||
|
||||
var countryNames = map[string]string{
|
||||
"US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia",
|
||||
"BR": "Brazil", "CL": "Chile", "GB": "United Kingdom", "CH": "Switzerland",
|
||||
"ES": "Spain", "SE": "Sweden", "TR": "Turkey", "AE": "UAE",
|
||||
"NG": "Nigeria", "KE": "Kenya", "ZA": "South Africa", "IN": "India",
|
||||
"SG": "Singapore", "AU": "Australia", "JP": "Japan", "KR": "South Korea",
|
||||
"HK": "Hong Kong", "NZ": "New Zealand", "KZ": "Kazakhstan", "BD": "Bangladesh",
|
||||
"PH": "Philippines", "TH": "Thailand", "TW": "Taiwan", "ID": "Indonesia",
|
||||
}
|
||||
|
||||
func countryName(code string) string {
|
||||
if name, ok := countryNames[code]; ok {
|
||||
return name
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
type Pop struct {
|
||||
PopID int
|
||||
City string
|
||||
|
|
@ -36,10 +53,11 @@ type Pop struct {
|
|||
Lat float64
|
||||
Lon float64
|
||||
RegionName string
|
||||
IP string
|
||||
DNS string
|
||||
Status string
|
||||
Provider string
|
||||
IP string
|
||||
DNS string
|
||||
Status string
|
||||
Provider string
|
||||
CountryFull string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
|
|
@ -73,6 +91,7 @@ func loadPops() []Pop {
|
|||
log.Printf("pops scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
p.CountryFull = countryName(p.Country)
|
||||
pops = append(pops, p)
|
||||
}
|
||||
return pops
|
||||
|
|
@ -158,7 +177,9 @@ func main() {
|
|||
render(w, PageData{Page: "install", Title: "Self-host — clavitor", Desc: "Self-host clavitor in 30 seconds. One binary, no dependencies.", ActiveNav: "install"})
|
||||
})
|
||||
http.HandleFunc("/pricing", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "pricing", Title: "Pricing — clavitor", Desc: "Free self-hosted or $12/year hosted (launch price). No tiers, no per-seat, no contact sales.", ActiveNav: "pricing"})
|
||||
data := PageData{Page: "pricing", Title: "Pricing — clavitor", Desc: "Free self-hosted or $12/year hosted (launch price). No tiers, no per-seat, no contact sales.", ActiveNav: "pricing"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
http.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "privacy", Title: "Privacy Policy — clavitor"})
|
||||
|
|
@ -169,6 +190,18 @@ func main() {
|
|||
http.HandleFunc("/sources", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "sources", Title: "Sources — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/integrations/claude-code", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "claude-code", Title: "Clavitor + Claude Code — Secure credential access", ActiveNav: "integrations"})
|
||||
})
|
||||
http.HandleFunc("/integrations/codex", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "codex", Title: "Clavitor + OpenAI Codex — CLI integration", ActiveNav: "integrations"})
|
||||
})
|
||||
http.HandleFunc("/integrations/openclaw", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "openclaw", Title: "Clavitor + OpenClaw — Multi-agent credentials", ActiveNav: "integrations"})
|
||||
})
|
||||
http.HandleFunc("/integrations/openclaw/cn", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "openclaw-cn", Title: "Clavitor + OpenClaw — AI 智能体凭据管理", ActiveNav: "integrations"})
|
||||
})
|
||||
// Notify — sends signup interest email
|
||||
http.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
|
|
@ -550,7 +583,7 @@ func main() {
|
|||
|
||||
// Public status page
|
||||
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Page: "status", Title: "Status — clavitor"}
|
||||
data := PageData{Page: "status", Title: "Status — clavitor", ActiveNav: "status"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
|
|
@ -608,13 +641,11 @@ func main() {
|
|||
|
||||
health := "planned"
|
||||
if p.Status == "live" {
|
||||
health = "unknown"
|
||||
health = "down"
|
||||
if ts, ok := lastSeen[id]; ok {
|
||||
age := now.Unix() - ts
|
||||
if age < 150 {
|
||||
health = "operational"
|
||||
} else if age < 600 {
|
||||
if inMaintenance { health = "maintenance" } else { health = "degraded" }
|
||||
} else {
|
||||
if inMaintenance { health = "maintenance" } else { health = "down" }
|
||||
}
|
||||
|
|
@ -632,7 +663,7 @@ func main() {
|
|||
}
|
||||
|
||||
nodes = append(nodes, NodeStatus{
|
||||
ID: id, City: p.City, Country: p.Country,
|
||||
ID: id, City: p.City, Country: countryName(p.Country),
|
||||
Region: p.RegionName, Status: p.Status,
|
||||
Health: health, Uptime: uptime90,
|
||||
})
|
||||
|
|
@ -657,7 +688,9 @@ func main() {
|
|||
}
|
||||
|
||||
overall := "All Systems Operational"
|
||||
if !allOperational {
|
||||
if inMaintenance {
|
||||
overall = "Scheduled Maintenance"
|
||||
} else if !allOperational {
|
||||
overall = "Some Systems Degraded"
|
||||
}
|
||||
|
||||
|
|
@ -730,15 +763,46 @@ func main() {
|
|||
})
|
||||
|
||||
http.HandleFunc("/glass", func(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Page: "glass", Title: "Looking Glass — clavitor"}
|
||||
data := PageData{Page: "glass", Title: "Looking Glass — clavitor", ActiveNav: "glass"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
|
||||
// SEO
|
||||
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("User-agent: *\nAllow: /\nDisallow: /noc\nDisallow: /telemetry\n\nSitemap: https://clavitor.ai/sitemap.xml\n"))
|
||||
})
|
||||
http.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) {
|
||||
pages := []string{
|
||||
"/", "/hosted", "/pricing", "/install", "/status", "/glass",
|
||||
"/privacy", "/terms", "/sources", "/signup",
|
||||
"/integrations/claude-code", "/integrations/codex",
|
||||
"/integrations/openclaw", "/integrations/openclaw/cn",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`
|
||||
for _, p := range pages {
|
||||
xml += `<url><loc>https://clavitor.ai` + p + `</loc></url>`
|
||||
}
|
||||
xml += `</urlset>`
|
||||
w.Write([]byte(xml))
|
||||
})
|
||||
http.HandleFunc("/security.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n"))
|
||||
})
|
||||
http.HandleFunc("/.well-known/security.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n"))
|
||||
})
|
||||
|
||||
// Catch-all: index page at "/" or static files or .html redirects
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
render(w, PageData{Page: "index", Title: "clavitor — AI-native password manager", Desc: "Field-level encryption for password managers that live alongside AI assistants. Your AI gets what it needs. Your secrets stay yours."})
|
||||
data := PageData{Page: "index", Title: "clavitor — AI-native password manager", Desc: "Field-level encryption for password managers that live alongside AI assistants. Your AI gets what it needs. Your secrets stay yours."}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
return
|
||||
}
|
||||
// Redirect old .html URLs to clean paths
|
||||
|
|
|
|||
|
|
@ -18,11 +18,27 @@
|
|||
<nav class="nav">
|
||||
<div class="container nav-inner">
|
||||
<a href="/" class="nav-logo"><span class="logo-lockup logo-lockup-nav"><span class="logo-lockup-square"></span><span class="logo-lockup-text"><span class="logo-lockup-wordmark">CLAVITOR</span><span class="logo-lockup-tagline">Black-box credential issuance</span></span></span></a>
|
||||
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"><span></span><span></span><span></span></button>
|
||||
<div class="nav-links">
|
||||
<span class="nav-link" style="opacity:0.4;cursor:default">GitHub</span>
|
||||
<span class="nav-link disabled">GitHub</span>
|
||||
<a href="/hosted" class="nav-link{{if eq .ActiveNav "hosted"}} active{{end}}">Hosted</a>
|
||||
<div class="nav-dropdown">
|
||||
<span class="nav-link nav-dropdown-trigger{{if or (eq .ActiveNav "install") (eq .ActiveNav "integrations")}} active{{end}}">Product</span>
|
||||
<div class="nav-dropdown-menu">
|
||||
<a href="/install" class="nav-dropdown-item">Self-host</a>
|
||||
<a href="/integrations/claude-code" class="nav-dropdown-item">Claude Code</a>
|
||||
<a href="/integrations/codex" class="nav-dropdown-item">Codex</a>
|
||||
<a href="/integrations/openclaw" class="nav-dropdown-item">OpenClaw</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown">
|
||||
<span class="nav-link nav-dropdown-trigger{{if or (eq .ActiveNav "status") (eq .ActiveNav "glass")}} active{{end}}">Network</span>
|
||||
<div class="nav-dropdown-menu">
|
||||
<a href="/status" class="nav-dropdown-item">Status</a>
|
||||
<a href="/glass" class="nav-dropdown-item">Looking Glass</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
|
||||
<a href="/install" class="nav-link{{if eq .ActiveNav "install"}} active{{end}}">Self-host</a>
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -40,6 +56,10 @@
|
|||
{{else if eq .Page "noc"}}{{template "noc" .}}
|
||||
{{else if eq .Page "status"}}{{template "status" .}}
|
||||
{{else if eq .Page "signup"}}{{template "signup" .}}
|
||||
{{else if eq .Page "claude-code"}}{{template "claude-code" .}}
|
||||
{{else if eq .Page "codex"}}{{template "codex" .}}
|
||||
{{else if eq .Page "openclaw"}}{{template "openclaw" .}}
|
||||
{{else if eq .Page "openclaw-cn"}}{{template "openclaw-cn" .}}
|
||||
{{end}}
|
||||
{{if ne .Page "styleguide"}}{{template "footer"}}{{end}}
|
||||
{{if eq .Page "index"}}{{template "index-script"}}
|
||||
|
|
@ -49,5 +69,6 @@
|
|||
{{else if eq .Page "status"}}{{template "status-script"}}
|
||||
{{else if eq .Page "signup"}}{{template "signup-script"}}
|
||||
{{end}}
|
||||
<script>document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')))</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<div class="card red">
|
||||
<span class="badge red mb-4">What Claude never sees</span>
|
||||
<h3 class="mb-3">Personal fields</h3>
|
||||
<p class="mb-4">Encrypted client-side with your biometric. The server stores ciphertext. No key, no access.</p>
|
||||
<p class="mb-4">Encrypted client-side with your WebAuthn authenticator. The server stores ciphertext. No key, no access.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers & CVV</li>
|
||||
<li>Passport & government IDs</li>
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
<div class="card red">
|
||||
<span class="badge red mb-4">What your agent never sees</span>
|
||||
<h3 class="mb-3">Personal fields</h3>
|
||||
<p class="mb-4">Encrypted client-side with your biometric. The server stores ciphertext. No key, no access.</p>
|
||||
<p class="mb-4">Encrypted client-side with your WebAuthn authenticator. The server stores ciphertext. No key, no access.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers & CVV</li>
|
||||
<li>Passport & government IDs</li>
|
||||
|
|
@ -2,28 +2,22 @@
|
|||
<div class="hero container">
|
||||
<p class="label accent mb-4">Network</p>
|
||||
<h1 class="mb-4">Looking Glass</h1>
|
||||
<p class="lead">{{len .Pops}} points of presence. Real-time status.</p>
|
||||
<p class="lead">{{len .Pops}} points of presence. Find the fastest node for you.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div class="glass-grid">
|
||||
{{range .Pops}}
|
||||
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
|
||||
<div class="glass-header">
|
||||
<span class="glass-city">{{.City}}</span>
|
||||
<div><div class="pop-city">{{.City}}</div><div class="pop-country">{{.CountryFull}}</div></div>
|
||||
<span class="glass-status {{if eq .Status "live"}}glass-status-live{{else}}glass-status-planned{{end}}">{{.Status}}</span>
|
||||
</div>
|
||||
{{if eq .Status "live"}}<div class="glass-latency-block">
|
||||
<div class="glass-latency-left"><span class="glass-latency-title">Response time</span><span class="glass-latency-hint">lower is better</span></div>
|
||||
<div class="glass-latency-hero glass-latency" data-dns="{{.DNS}}" data-status="{{.Status}}">—</div>
|
||||
</div>
|
||||
<div class="glass-details">
|
||||
<div class="glass-row">
|
||||
<span class="glass-key">Region</span>
|
||||
<span class="glass-val mono">{{.RegionName}}</span>
|
||||
</div>
|
||||
<div class="glass-row">
|
||||
<span class="glass-key">Provider</span>
|
||||
<span class="glass-val">{{.Provider}}</span>
|
||||
</div>
|
||||
{{if .IP}}<div class="glass-row">
|
||||
<span class="glass-key">IPv4</span>
|
||||
<span class="glass-val mono">{{.IP}}</span>
|
||||
|
|
@ -32,11 +26,7 @@
|
|||
<span class="glass-key">DNS</span>
|
||||
<span class="glass-val mono">{{.DNS}}</span>
|
||||
</div>{{end}}
|
||||
<div class="glass-row">
|
||||
<span class="glass-key">Latency</span>
|
||||
<span class="glass-val glass-latency" data-dns="{{.DNS}}" data-status="{{.Status}}">{{if eq .Status "live"}}—{{else}}<span class="glass-muted">Q2 2026</span>{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
@ -47,35 +37,25 @@
|
|||
<script>
|
||||
(function() {
|
||||
const grid = document.querySelector('.glass-grid');
|
||||
let pending = 0;
|
||||
const pops = grid.querySelectorAll('.glass-pop');
|
||||
|
||||
document.querySelectorAll('.glass-latency[data-status="live"][data-dns]').forEach(el => {
|
||||
const dns = el.dataset.dns;
|
||||
if (!dns) { el.textContent = '—'; return; }
|
||||
pending++;
|
||||
const t0 = performance.now();
|
||||
const t0 = performance.now();
|
||||
fetch('https://' + dns + ':1984/ping').then(() => {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
function classify(ms) {
|
||||
if (ms < 60) return 'glass-fast';
|
||||
if (ms < 120) return 'glass-ok';
|
||||
return 'glass-slow';
|
||||
}
|
||||
|
||||
function applyResult(el, ms) {
|
||||
el.classList.remove('glass-fast', 'glass-ok', 'glass-slow');
|
||||
if (ms < 4900) {
|
||||
el.textContent = ms + ' ms';
|
||||
el.dataset.ms = ms;
|
||||
el.classList.add(ms < 100 ? 'glass-fast' : ms < 250 ? 'glass-ok' : 'glass-slow');
|
||||
if (--pending === 0) sortGrid();
|
||||
}).catch(() => {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (ms < 4900) {
|
||||
el.textContent = ms + ' ms';
|
||||
el.dataset.ms = ms;
|
||||
el.classList.add(ms < 100 ? 'glass-fast' : ms < 250 ? 'glass-ok' : 'glass-slow');
|
||||
} else {
|
||||
el.textContent = 'down';
|
||||
el.dataset.ms = 99999;
|
||||
el.classList.add('glass-slow');
|
||||
}
|
||||
if (--pending === 0) sortGrid();
|
||||
});
|
||||
});
|
||||
el.classList.add(classify(ms));
|
||||
} else {
|
||||
el.textContent = 'down';
|
||||
el.dataset.ms = 99999;
|
||||
el.classList.add('glass-slow');
|
||||
}
|
||||
}
|
||||
|
||||
function sortGrid() {
|
||||
const arr = Array.from(grid.querySelectorAll('.glass-pop'));
|
||||
|
|
@ -87,34 +67,30 @@
|
|||
arr.forEach(el => grid.appendChild(el));
|
||||
}
|
||||
|
||||
function pingAll() {
|
||||
document.querySelectorAll('.glass-latency[data-status="live"][data-dns]').forEach(el => {
|
||||
const dns = el.dataset.dns;
|
||||
if (!dns) return;
|
||||
el.textContent = '...';
|
||||
el.className = 'glass-val glass-latency';
|
||||
function ping(el) {
|
||||
const dns = el.dataset.dns;
|
||||
if (!dns) { el.textContent = '—'; return Promise.resolve(); }
|
||||
el.textContent = '...';
|
||||
// Warm-up: first fetch establishes TLS, second measures actual latency
|
||||
return fetch('https://' + dns + ':1984/ping').then(() => {}).catch(() => {}).then(() => {
|
||||
const t0 = performance.now();
|
||||
fetch('https://' + dns + ':1984/ping').then(() => {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
el.textContent = ms + ' ms';
|
||||
el.dataset.ms = ms;
|
||||
el.classList.add(ms < 100 ? 'glass-fast' : ms < 250 ? 'glass-ok' : 'glass-slow');
|
||||
}).catch(() => {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (ms < 4900) {
|
||||
el.textContent = ms + ' ms';
|
||||
el.dataset.ms = ms;
|
||||
el.classList.add(ms < 100 ? 'glass-fast' : ms < 250 ? 'glass-ok' : 'glass-slow');
|
||||
} else {
|
||||
el.textContent = 'down';
|
||||
el.dataset.ms = 99999;
|
||||
el.classList.add('glass-slow');
|
||||
}
|
||||
return fetch('https://' + dns + ':1984/ping').then(() => {}).catch(() => {}).then(() => {
|
||||
applyResult(el, Math.round(performance.now() - t0));
|
||||
sortGrid();
|
||||
});
|
||||
});
|
||||
setTimeout(sortGrid, 6000);
|
||||
}
|
||||
|
||||
async function pingAll() {
|
||||
const els = Array.from(document.querySelectorAll('.glass-latency[data-status="live"][data-dns]'));
|
||||
if (window.innerWidth < 768) {
|
||||
for (const el of els) { await ping(el); sortGrid(); }
|
||||
} else {
|
||||
els.forEach(el => ping(el));
|
||||
}
|
||||
}
|
||||
|
||||
pingAll();
|
||||
setInterval(pingAll, 60000);
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -45,23 +45,23 @@
|
|||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Why Zürich -->
|
||||
<!-- Why encryption, not jurisdiction -->
|
||||
<div class="section container">
|
||||
<p class="label gold mb-3">Why Zürich</p>
|
||||
<h2 class="mb-4">Identity Encryption: jurisdiction irrelevant.<br>Credential Encryption: it isn't.</h2>
|
||||
<p class="lead mb-8">Identity fields are protected by math — where the server sits doesn't matter. But Credential fields live on a server in a jurisdiction. A US server is subject to the CLOUD Act. Zürich, Switzerland is subject to Swiss law — which does not cooperate with foreign government data requests. No backdoors. Both layers protected.</p>
|
||||
<p class="label accent mb-3">Three-tier encryption</p>
|
||||
<h2 class="mb-4">Jurisdiction is irrelevant.<br>Math is not.</h2>
|
||||
<p class="lead mb-8">Your vault is encrypted at rest. Your credentials are encrypted per-field. Your identity fields are encrypted client-side with a key that never leaves your device. No server — ours or anyone's — can read what it doesn't have the key to. That's the real protection. Zürich is the belt to that suspenders: a jurisdiction where nobody will even try to force open what mathematics already guarantees they can't.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<p class="label mb-2">Self-hosted · US</p>
|
||||
<p>Your server, your rules — until a court says otherwise. CLOUD Act applies to US persons regardless of encryption.</p>
|
||||
<p class="label mb-2">Vault Encryption</p>
|
||||
<p>Entire vault encrypted at rest with AES-256-GCM. The baseline. Every password manager does this.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label mb-2">Self-hosted · anywhere</p>
|
||||
<p>Full control. Your infrastructure, your jurisdiction. The right choice if you know what you're doing.</p>
|
||||
<p class="label accent mb-2">Credential Encryption</p>
|
||||
<p>Per-field encryption. Your AI agent can read the API key it needs — but not the credit card number in the same entry.</p>
|
||||
</div>
|
||||
<div class="card gold">
|
||||
<p class="label gold mb-2">Hosted · Zürich, Switzerland</p>
|
||||
<p>Swiss law. Swiss courts. Capital of Privacy. No CLOUD Act. No backdoors. We handle the infrastructure — you get the protection.</p>
|
||||
<div class="card red">
|
||||
<p class="label red mb-2">Identity Encryption</p>
|
||||
<p>Client-side. WebAuthn PRF. The key is derived from your WebAuthn authenticator — fingerprint, face, or hardware key — and never leaves your device. We cannot decrypt it. Period.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
<p>Automatic daily backups. Encrypted at rest. Restorable on request.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<h3 class="mb-2">22 regions</h3>
|
||||
<h3 class="mb-2">{{len .Pops}} regions</h3>
|
||||
<p>Pick your region at signup. Your data stays there. Every continent covered.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@
|
|||
</div>
|
||||
<div class="card red">
|
||||
<span class="badge red mb-4">Identity Encryption</span>
|
||||
<h3 class="mb-3">Touch ID only</h3>
|
||||
<h3 class="mb-3">Your device only</h3>
|
||||
<p class="mb-4">Encrypted client-side with a key derived from your WebAuthn PRF. Hardware tap required. Not in tokens. Agents receive <code>[Identity Encryption — hardware key required]</code>.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers & CVV</li>
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"/></svg></div>
|
||||
<h3 class="mb-3">WebAuthn PRF</h3>
|
||||
<p>Credential and Identity keys derive from your biometric hardware — Touch ID, Face ID, YubiKey, Titan Key. No master password. No server-side key storage. Math, not policy.</p>
|
||||
<p>Credential and Identity keys derive from your WebAuthn authenticator hardware — Touch ID, Face ID, YubiKey, Titan Key. No master password. No server-side key storage. Math, not policy.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
|
||||
|
|
@ -328,7 +328,7 @@
|
|||
<p class="mt-2 text-sm" style="color:#64748b">Community forum, November 2022</p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clavitor</span>: WebAuthn-first. Touch ID is the primary unlock. Session lives locally — no server-side token expiry forcing re-auth every few minutes.</li>
|
||||
<li><span class="vaultname">clavitor</span>: WebAuthn-first. Your authenticator is the primary unlock. Session lives locally — no server-side token expiry forcing re-auth every few minutes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div>
|
||||
<p class="label accent mb-6">George Orwell — 1984</p>
|
||||
<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 Touch ID. Our servers have never seen it. They could not decrypt your private fields even if they wanted to. Or anybody else.</p>
|
||||
<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="/install" class="btn btn-ghost">Self-host free →</a>
|
||||
|
|
@ -59,35 +59,35 @@
|
|||
<path d="M188 277 L190.5 279.5 L196 274" stroke="#22C55E" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Identity Encryption Column (you only) -->
|
||||
<!-- Identity Encryption Column (only you) -->
|
||||
<rect x="250" y="65" width="200" height="260" rx="8" fill="none" stroke="#EF4444" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<rect x="250" y="65" width="200" height="30" rx="8" fill="#EF4444" fill-opacity="0.1"/>
|
||||
<text x="350" y="85" font-family="JetBrains Mono, monospace" font-size="12" fill="#EF4444" text-anchor="middle" font-weight="600">Identity — you only</text>
|
||||
<text x="350" y="85" font-family="JetBrains Mono, monospace" font-size="12" fill="#EF4444" text-anchor="middle" font-weight="600">Identity — only you</text>
|
||||
|
||||
<!-- L3 items -->
|
||||
<g>
|
||||
<rect x="270" y="115" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="300" y="138" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">credit_card</text>
|
||||
<rect x="408" y="125" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M413 131 L413 135 M416 131 L416 135 M411 133 L411 129 Q411 127 413 127 L416 127 Q418 127 418 129 L418 133 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="404" y="125" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M409 131 L409 135 M412 131 L412 135 M407 133 L407 129 Q407 127 413 127 L412 127 Q414 127 418 129 L414 133 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="270" y="163" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="300" y="186" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">cvv</text>
|
||||
<rect x="408" y="173" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M413 179 L413 183 M416 179 L416 183 M411 181 L411 177 Q411 175 413 175 L416 175 Q418 175 418 177 L418 181 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="404" y="173" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M409 179 L409 183 M412 179 L412 183 M407 181 L407 177 Q407 175 413 175 L412 175 Q414 175 418 177 L414 181 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="270" y="211" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="300" y="234" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">passport</text>
|
||||
<rect x="408" y="221" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M413 227 L413 231 M416 227 L416 231 M411 229 L411 225 Q411 223 413 223 L416 223 Q418 223 418 225 L418 229 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="404" y="221" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M409 227 L409 231 M412 227 L412 231 M407 229 L407 225 Q407 223 413 223 L412 223 Q414 223 418 225 L414 229 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="270" y="259" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="300" y="282" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">ssn</text>
|
||||
<rect x="408" y="269" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M413 275 L413 279 M416 275 L416 279 M411 277 L411 273 Q411 271 413 271 L416 271 Q418 271 418 273 L418 277 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="404" y="269" width="16" height="16" rx="3" fill="#EF4444" fill-opacity="0.15"/>
|
||||
<path d="M409 275 L409 279 M412 275 L412 279 M407 277 L407 273 Q407 271 413 271 L412 271 Q414 271 418 273 L414 277 Z" stroke="#EF4444" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Center vault icon -->
|
||||
|
|
@ -100,6 +100,23 @@
|
|||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Two problems -->
|
||||
<div class="section container">
|
||||
<p class="label accent mb-4">Credential issuance & password management</p>
|
||||
<h2 class="mb-6">Two problems. One product.</h2>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card">
|
||||
<h3 class="mb-3">AI agents need credentials</h3>
|
||||
<p>Your agents deploy code, rotate keys, complete 2FA — but current password managers either give them everything or nothing. Clavitor issues scoped credentials to each agent. No vault browsing. No discovery.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 class="mb-3">Credentials need real encryption</h3>
|
||||
<p>Every password manager encrypts with a master password. When that password is weak — or stolen — everything falls. Clavitor derives keys from your hardware. No password to crack. No backup to brute-force.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- The Problem -->
|
||||
<div class="section container">
|
||||
|
|
@ -130,7 +147,7 @@
|
|||
<div class="section container">
|
||||
<p class="label mb-4">How it works</p>
|
||||
<h2 class="mb-6">"Your assistant can book your flights.<br><span class="gradient-text">Not read your diary.</span>"</h2>
|
||||
<p class="lead mb-8">Every field is encrypted. But some get a second lock. That second key is derived from your fingerprint and only exists in your browser. We hold the safe. Only you hold that key.</p>
|
||||
<p class="lead mb-8">Every field is encrypted. But some get a second lock. That second key is derived from your WebAuthn authenticator and only exists in your browser. We hold the safe. Only you hold that key.</p>
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">Credential Encryption</span>
|
||||
|
|
@ -146,7 +163,7 @@
|
|||
</div>
|
||||
<div class="card red">
|
||||
<span class="badge red mb-4">Identity Encryption</span>
|
||||
<h3 class="mb-3">Touch ID only</h3>
|
||||
<h3 class="mb-3">Your device only</h3>
|
||||
<p class="mb-4">Encrypted client-side with WebAuthn PRF. The server never sees the plaintext. Ever.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers</li>
|
||||
|
|
@ -174,7 +191,7 @@
|
|||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"/></svg></div>
|
||||
<h3 class="mb-3">WebAuthn PRF</h3>
|
||||
<p>Identity Encryption uses WebAuthn PRF — a cryptographic key derived from your biometric hardware. Math, not policy. We literally cannot decrypt it.</p>
|
||||
<p>Identity Encryption uses WebAuthn PRF — a cryptographic key derived from your WebAuthn authenticator — fingerprint, face, or hardware key. Math, not policy. We literally cannot decrypt it.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
|
||||
|
|
@ -292,7 +309,7 @@ Error: access denied (scope: dev)</pre>
|
|||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">Extension</p>
|
||||
<h3 class="mb-2">For humans in a browser</h3>
|
||||
<p>Autofill passwords, generate 2FA codes inline, and unlock L3 fields with Touch ID — without leaving the page you're on.</p>
|
||||
<p>Autofill passwords, generate 2FA codes inline, and unlock Identity fields with your authenticator — without leaving the page you're on.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<p class="label accent mb-3">CLI</p>
|
||||
|
|
@ -309,6 +326,32 @@ Error: access denied (scope: dev)</pre>
|
|||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Why this matters -->
|
||||
<div class="section container">
|
||||
<p class="label red mb-4">Why this matters</p>
|
||||
<h2 class="mb-4">Breached in 2022. Still bleeding in <script>document.write(new Date().getFullYear())</script>.</h2>
|
||||
<p class="lead mb-8">In 2022, LastPass lost encrypted vault backups. Each vault was encrypted with the customer's master password. Three years later, attackers are still cracking them — weak passwords first, stronger ones next. The FBI traced $150M in crypto theft to that single breach. But crypto is just the visible damage — the same vaults held bank logins, corporate VPN credentials, medical portals, and tax accounts.</p>
|
||||
<div class="grid-3 mb-8">
|
||||
<div class="card">
|
||||
<div style="font-size:2rem;font-weight:800;color:var(--brand-red);margin-bottom:8px">$150M+</div>
|
||||
<p>Confirmed crypto stolen from a single breach. FBI-traced. Still growing. <a href="https://krebsonsecurity.com/2025/03/feds-link-150m-cyberheist-to-2022-lastpass-hacks/" target="_blank" rel="noopener" class="text-accent">Krebs on Security ↗</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="font-size:2rem;font-weight:800;color:var(--brand-red);margin-bottom:8px" id="breach-years">3 years</div>
|
||||
<script>document.getElementById('breach-years').textContent=((new Date).getFullYear()-2022)+' years';</script>
|
||||
<p>Thefts still ongoing. The encryption was per-customer — but the key was a password. Passwords get cracked. <a href="https://securityaffairs.com/186191/digital-id/stolen-lastpass-backups-enable-crypto-theft-through-2025.html" target="_blank" rel="noopener" class="text-accent">Security Affairs ↗</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="font-size:2rem;font-weight:800;color:var(--brand-red);margin-bottom:8px">forever</div>
|
||||
<p>Brute-forcing a Clavitor hardware key at a trillion guesses per second would take a trillion × a trillion × a trillion × a trillion times longer than the universe has existed. That's not a figure of speech. That's the math.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-4"><strong>Clavitor's answer:</strong> {{len .Pops}} regions — every vault is an isolated database, not a row in a shared table. Every credential and identity field has its own encryption key derived from your WebAuthn authenticator — fingerprint, face, YubiKey, or any FIDO2 device. Not a password you chose. Not a password you could choose. A key that never existed on any server, never existed in any backup, and cannot be brute-forced because it was never a string of characters to begin with.</p>
|
||||
<p class="mb-4"><strong>That power comes with responsibility.</strong> Always register at least two devices (phone + laptop). Better yet: print your recovery key, protect it with a PIN, and store it somewhere outside your home. If you lose all your devices, that printout is your only way back in. We can't help you — by design.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- The competition -->
|
||||
<div class="section container">
|
||||
<p class="label mb-4">The competition</p>
|
||||
|
|
@ -353,7 +396,7 @@ Error: access denied (scope: dev)</pre>
|
|||
<p class="mt-2"><a href="https://www.1password.community/discussions/1password/why-does-the-chrome-extension-keep-asking-for-my-password-every-10-mins-rather-t/74253" target="_blank" rel="noopener">— Anonymous (Former Member), November 2022 ↗</a></p>
|
||||
<hr class="divider mt-4 mb-4">
|
||||
<ul class="checklist">
|
||||
<li><span class="vaultname">clavitor</span>: WebAuthn-first. Touch ID is the primary unlock. Session lives locally — no server-side expiry forcing re-auth.</li>
|
||||
<li><span class="vaultname">clavitor</span>: WebAuthn-first. Your authenticator is the primary unlock. Session lives locally — no server-side expiry forcing re-auth.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -390,7 +433,7 @@ Error: access denied (scope: dev)</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 28 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. <s>$20</s> $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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
{{define "claude-code"}}
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4">Integration Guide</p>
|
||||
<h1 class="mb-4">Clavitor + Claude Code</h1>
|
||||
<p class="lead">Give Claude Code secure, scoped access to credentials. Every secret stays encrypted until the moment it's needed — and your AI never sees what it shouldn't.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container narrow">
|
||||
<h2 class="mb-4">How it works</h2>
|
||||
<p class="mb-6">Claude Code calls the Clavitor CLI to fetch credentials. Each agent token is scoped — it can only access entries you've explicitly allowed. No vault browsing, no discovery, no surprise access.</p>
|
||||
|
||||
<div class="grid-2 mb-8">
|
||||
<div class="card">
|
||||
<p class="label accent mb-3">Credential Encryption</p>
|
||||
<h3 class="mb-2">Claude can read</h3>
|
||||
<p>API keys, SSH keys, OAuth tokens, TOTP secrets. Encrypted at rest, decryptable by the vault. Claude fetches what it's scoped to via the CLI.</p>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">Identity Encryption</p>
|
||||
<h3 class="mb-2">Claude cannot read</h3>
|
||||
<p>Passport numbers, credit cards, private signing keys. Encrypted client-side with WebAuthn PRF. The server cannot decrypt them. Neither can Claude. Math, not policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">Setup</h2>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-body">
|
||||
<h3>Create a scoped agent token</h3>
|
||||
<p class="mb-3">From the Clavitor web UI or CLI, create a token scoped to the entries Claude needs.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> clavitor token create --scope dev --name "Claude Code"</div>
|
||||
<div class="comment">Token: ctk_dev_a3f8...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-body">
|
||||
<h3>Use credentials in Claude Code</h3>
|
||||
<p class="mb-3">Claude calls the CLI directly. The token restricts access to the <code>dev</code> scope only.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="comment"># Claude fetches a GitHub token</span></div>
|
||||
<div><span class="prompt">$</span> clavitor get github.token --agent dev</div>
|
||||
<div class="comment">ghp_a3f8...</div>
|
||||
<div class="mt-2"><span class="comment"># Claude tries to access something outside its scope</span></div>
|
||||
<div><span class="prompt">$</span> clavitor get stripe.secret --agent dev</div>
|
||||
<div class="comment" style="color:var(--brand-red)">Error: access denied (scope: dev)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-body">
|
||||
<h3>TOTP generation</h3>
|
||||
<p class="mb-3">Store TOTP secrets as Credential fields. Claude generates time-based 2FA codes on demand.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> clavitor totp github --agent dev</div>
|
||||
<div class="comment">284919 (expires in 14s)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4 mt-8">Why not MCP?</h2>
|
||||
<p class="mb-6">MCP gives the agent access to the vault — search, list, browse. That's too much. Clavitor's CLI gives the agent exactly the credentials it's scoped to. Nothing more. No browsing, no discovery.</p>
|
||||
|
||||
<h2 class="mb-4">Multiple agents, different scopes</h2>
|
||||
<p class="mb-6">Create separate tokens for different contexts. Your deploy agent sees Vercel keys. Your code agent sees GitHub tokens. Neither sees your personal credentials.</p>
|
||||
<div class="code-block mb-8">
|
||||
<div><span class="prompt">$</span> clavitor token create --scope deploy --name "CI pipeline"</div>
|
||||
<div><span class="prompt">$</span> clavitor token create --scope social --name "Social bot"</div>
|
||||
<div><span class="prompt">$</span> clavitor token create --scope dev --name "Claude Code"</div>
|
||||
</div>
|
||||
|
||||
<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="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "codex"}}
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4">Integration Guide</p>
|
||||
<h1 class="mb-4">Clavitor + OpenAI Codex</h1>
|
||||
<p class="lead">Connect Codex to your vault via the CLI. Scoped tokens, TOTP generation, field-level encryption. Your Codex agent gets exactly what it needs.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container narrow">
|
||||
<h2 class="mb-4">How it works</h2>
|
||||
<p class="mb-6">Codex calls the Clavitor CLI to fetch credentials and generate 2FA codes. Each token is scoped — Codex only sees entries you've explicitly allowed.</p>
|
||||
|
||||
<h2 class="mb-4">Setup</h2>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-body">
|
||||
<h3>Install Clavitor</h3>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-body">
|
||||
<h3>Create a scoped token for Codex</h3>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> clavitor token create --scope codex --name "Codex agent"</div>
|
||||
<div class="comment">Token: ctk_codex_7b2e...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-body">
|
||||
<h3>Fetch credentials from Codex</h3>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> clavitor get openai.api_key --agent codex</div>
|
||||
<div class="comment">sk-proj-...</div>
|
||||
<div class="mt-2"><span class="prompt">$</span> clavitor totp aws --agent codex</div>
|
||||
<div class="comment">739201 (expires in 22s)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4 mt-8">Three-tier encryption</h2>
|
||||
<div class="grid-3 mb-8">
|
||||
<div class="card">
|
||||
<p class="label mb-2">Vault Encryption</p>
|
||||
<p>Entire vault encrypted at rest. AES-256-GCM.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label accent mb-2">Credential Encryption</p>
|
||||
<p>Per-field. Codex can read these via scoped CLI tokens.</p>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<p class="label red mb-2">Identity Encryption</p>
|
||||
<p>Per-field. Client-side. WebAuthn PRF. Nobody can read these — not Codex, not us.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "openclaw"}}
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4">Integration Guide</p>
|
||||
<h1 class="mb-4">Clavitor + OpenClaw</h1>
|
||||
<p class="lead">Multi-agent credential management. Give your OpenClaw agents scoped access to credentials. Each agent sees only what it needs.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container narrow">
|
||||
<h2 class="mb-4">The problem with multi-agent credential access</h2>
|
||||
<p class="mb-6">When you run multiple OpenClaw agents — a deploy agent, a monitoring agent, a social agent — they all need different credentials. Sharing one vault key means every agent sees everything. A compromised deploy agent exposes your personal data.</p>
|
||||
|
||||
<h2 class="mb-4">Clavitor solves this</h2>
|
||||
<p class="mb-6">Create a separate scoped token per agent. Each token can only access its designated entries. Compromise one, the rest stay clean.</p>
|
||||
|
||||
<div class="code-block mb-8">
|
||||
<p class="code-label">One vault. Five agents. Five scopes.</p>
|
||||
<pre><span class="comment"># Deploy agent — Vercel, Netlify, AWS</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope deploy --name "OC Deploy"
|
||||
|
||||
<span class="comment"># Monitor agent — Datadog, PagerDuty</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope monitor --name "OC Monitor"
|
||||
|
||||
<span class="comment"># Social agent — Twitter, Discord</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope social --name "OC Social"
|
||||
|
||||
<span class="comment"># Finance agent — Stripe, Plaid</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope finance --name "OC Finance"
|
||||
|
||||
<span class="comment"># Code agent — GitHub, GitLab</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope dev --name "OC Dev"</pre>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">In your OpenClaw configuration</h2>
|
||||
<p class="mb-6">Each agent calls the CLI with its own token. The vault enforces scope boundaries — no agent can escalate.</p>
|
||||
<div class="code-block mb-8">
|
||||
<div><span class="comment"># Inside the deploy agent's workflow</span></div>
|
||||
<div><span class="prompt">$</span> VERCEL_TOKEN=$(clavitor get vercel.token --agent deploy)</div>
|
||||
<div><span class="prompt">$</span> vercel deploy --token $VERCEL_TOKEN</div>
|
||||
<div class="mt-2"><span class="comment"># Deploy agent tries to read social credentials</span></div>
|
||||
<div><span class="prompt">$</span> clavitor get twitter.oauth --agent deploy</div>
|
||||
<div class="comment" style="color:var(--brand-red)">Error: access denied (scope: deploy)</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">Identity Encryption: the hard boundary</h2>
|
||||
<p class="mb-6">Credential fields are readable by scoped agents. But Identity fields — passport numbers, credit cards, private signing keys — are encrypted client-side with WebAuthn PRF. No agent, no server, no court order can decrypt them. The key never leaves your device.</p>
|
||||
|
||||
<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="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "openclaw-cn"}}
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4">集成指南</p>
|
||||
<h1 class="mb-4">Clavitor + OpenClaw</h1>
|
||||
<p class="lead">多智能体凭据管理。为每个 OpenClaw 智能体提供独立的、范围限定的凭据访问权限。每个智能体只能看到它需要的内容。</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container narrow">
|
||||
<h2 class="mb-4">多智能体凭据访问的问题</h2>
|
||||
<p class="mb-6">当您运行多个 OpenClaw 智能体时——部署智能体、监控智能体、社交智能体——它们都需要不同的凭据。共享一个密钥库密钥意味着每个智能体都能看到所有内容。一个被入侵的部署智能体会暴露您的个人数据。</p>
|
||||
|
||||
<h2 class="mb-4">Clavitor 解决方案</h2>
|
||||
<p class="mb-6">为每个智能体创建独立的范围限定令牌。每个令牌只能访问其指定的条目。一个被入侵,其余安全无虞。</p>
|
||||
|
||||
<div class="code-block mb-8">
|
||||
<p class="code-label">一个密钥库。五个智能体。五个范围。</p>
|
||||
<pre><span class="comment"># 部署智能体 — Vercel, Netlify, AWS</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope deploy --name "OC 部署"
|
||||
|
||||
<span class="comment"># 监控智能体 — Datadog, PagerDuty</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope monitor --name "OC 监控"
|
||||
|
||||
<span class="comment"># 社交智能体 — Twitter, Discord</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope social --name "OC 社交"
|
||||
|
||||
<span class="comment"># 财务智能体 — Stripe, Plaid</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope finance --name "OC 财务"
|
||||
|
||||
<span class="comment"># 代码智能体 — GitHub, GitLab</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope dev --name "OC 开发"</pre>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">三层加密</h2>
|
||||
<div class="grid-3 mb-8">
|
||||
<div class="card">
|
||||
<p class="label mb-2">密钥库加密</p>
|
||||
<p>整个密钥库静态加密。AES-256-GCM。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label accent mb-2">凭据加密</p>
|
||||
<p>逐字段加密。智能体可通过范围限定的 CLI 令牌读取。</p>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<p class="label red mb-2">身份加密</p>
|
||||
<p>逐字段加密。客户端加密。WebAuthn PRF。没有人能读取——智能体不能,我们也不能。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">身份加密:硬边界</h2>
|
||||
<p class="mb-6">凭据字段可由范围限定的智能体读取。但身份字段——护照号码、信用卡、私钥——使用 WebAuthn PRF 在客户端加密。没有任何智能体、服务器或法院命令可以解密它们。密钥永远不会离开您的设备。</p>
|
||||
|
||||
<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="/install" class="btn btn-ghost" style="margin-left:8px">免费自托管 →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="hero container">
|
||||
<p class="label accent mb-4">Integration Guide</p>
|
||||
<h1 class="mb-6"><span class="vaultname">clav<span class="n">itor</span></span> + OpenClaw</h1>
|
||||
<p class="lead mb-6">Your OpenClaw agent manages credentials, rotates API keys, and completes 2FA — all from a single MCP tool call. Personal data stays sealed behind your biometric.</p>
|
||||
<p class="lead mb-6">Your OpenClaw agent manages credentials, rotates API keys, and completes 2FA — all from a single MCP tool call. Personal data stays sealed behind your WebAuthn authenticator.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
<div class="card red">
|
||||
<span class="badge red mb-4">What your agent never sees</span>
|
||||
<h3 class="mb-3">Personal fields</h3>
|
||||
<p class="mb-4">Encrypted client-side with your biometric. The server stores ciphertext. No key, no access.</p>
|
||||
<p class="mb-4">Encrypted client-side with your WebAuthn authenticator. The server stores ciphertext. No key, no access.</p>
|
||||
<ul class="checklist red">
|
||||
<li>Credit card numbers & CVV</li>
|
||||
<li>Passport & government IDs</li>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<p class="mb-6">Forever. Elastic License 2.0. No strings.</p>
|
||||
<a href="/install" class="btn btn-ghost btn-block mb-8">Self-host guide →</a>
|
||||
<p class="label mb-4">What you get</p>
|
||||
<ul class="checklist"><li>Three-tier encryption (Vault, Credential, Identity)</li><li>WebAuthn PRF (Identity biometric encryption)</li><li>CLI for AI agents (encrypted delivery)</li><li>Scoped agent tokens (multi-agent)</li><li>TOTP generation via CLI</li><li>Browser extension (Chrome, Firefox)</li><li>Import from Bitwarden / 1Password</li><li>LLM-powered field classification</li><li>Unlimited entries</li><li>Full source code (ELv2)</li></ul>
|
||||
<ul class="checklist"><li>Three-tier encryption (Vault, Credential, Identity)</li><li>WebAuthn PRF (Identity encryption via authenticator)</li><li>CLI for AI agents (encrypted delivery)</li><li>Scoped agent tokens (multi-agent)</li><li>TOTP generation via CLI</li><li>Browser extension (Chrome, Firefox)</li><li>Import from Bitwarden / 1Password</li><li>LLM-powered field classification</li><li>Unlimited entries</li><li>Full source code (ELv2)</li></ul>
|
||||
</div>
|
||||
|
||||
<div class="price-card featured">
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<p class="mb-6">7-day money-back, no questions, instant.</p>
|
||||
<a href="/signup" class="btn btn-primary btn-block mb-8">Get started</a>
|
||||
<p class="label accent mb-4">Everything in self-hosted, plus</p>
|
||||
<ul class="checklist"><li>Managed infrastructure</li><li>Daily encrypted backups</li><li>28 regions across every continent</li><li>Automatic updates & patches</li><li>TLS included</li><li>Email support</li></ul>
|
||||
<ul class="checklist"><li>Managed infrastructure</li><li>Daily encrypted backups</li><li>{{len .Pops}} regions across every continent</li><li>Automatic updates & patches</li><li>TLS included</li><li>Email support</li></ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
</ul>
|
||||
|
||||
<h2>Identity Encryption guarantee</h2>
|
||||
<p>Fields protected by Identity Encryption are encrypted in your browser using a key derived from your WebAuthn authenticator (Touch ID, Windows Hello, or a hardware security key) via the PRF extension. The encryption key never leaves your device. Our servers store only the resulting ciphertext. We cannot decrypt Identity fields, and no future policy change, acquisition, or legal order can change this — the mathematical reality is that we don't have the key.</p>
|
||||
<p>Fields protected by Identity Encryption are encrypted in your browser using a key derived from your WebAuthn authenticator — fingerprint, face, YubiKey, or any FIDO2 device — via the PRF extension. The encryption key never leaves your device. Our servers store only the resulting ciphertext. We cannot decrypt Identity fields, and no future policy change, acquisition, or legal order can change this — the mathematical reality is that we don't have the key.</p>
|
||||
|
||||
<h2>Data residency</h2>
|
||||
<p>When you create a hosted vault, you choose a region. Your data stays in that region. We don't replicate across regions unless you explicitly request it.</p>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@
|
|||
<style>
|
||||
.st-node { margin-bottom:28px; }
|
||||
.st-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px; }
|
||||
.st-name { font-weight:600; font-size:0.95rem; }
|
||||
.st-region { font-size:0.75rem; color:var(--text-tertiary); margin-left:8px; }
|
||||
.st-name { }
|
||||
.st-region { }
|
||||
.st-health { font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.st-health-operational { color:#16a34a; }
|
||||
.st-health-degraded { color:#ca8a04; }
|
||||
.st-health-down { color:#dc2626; }
|
||||
.st-health-unknown { color:var(--text-tertiary); }
|
||||
.st-health-maintenance { color:#6366f1; }
|
||||
.st-health-planned { color:var(--text-tertiary); }
|
||||
.st-bars { display:flex; gap:1px; height:28px; align-items:flex-end; }
|
||||
.st-bar { flex:1; border-radius:2px; min-width:2px; cursor:default; }
|
||||
.st-bar-operational { background:#22c55e; }
|
||||
|
|
@ -64,10 +66,17 @@
|
|||
// Banner
|
||||
const banner = document.getElementById('status-banner');
|
||||
const allOp = data.overall === 'All Systems Operational';
|
||||
banner.style.background = allOp ? '#f0fdf4' : '#fefce8';
|
||||
banner.style.color = allOp ? '#15803d' : '#854d0e';
|
||||
banner.style.borderColor = allOp ? '#bbf7d0' : '#fde68a';
|
||||
banner.querySelector('span:first-child').innerHTML = allOp ? '✔' : '⚠';
|
||||
const isMaint = data.overall === 'Scheduled Maintenance';
|
||||
if (isMaint) {
|
||||
banner.style.background = '#eef2ff'; banner.style.color = '#4338ca'; banner.style.borderColor = '#c7d2fe';
|
||||
banner.querySelector('span:first-child').innerHTML = '🔧';
|
||||
} else if (allOp) {
|
||||
banner.style.background = '#f0fdf4'; banner.style.color = '#15803d'; banner.style.borderColor = '#bbf7d0';
|
||||
banner.querySelector('span:first-child').innerHTML = '✔';
|
||||
} else {
|
||||
banner.style.background = '#fefce8'; banner.style.color = '#854d0e'; banner.style.borderColor = '#fde68a';
|
||||
banner.querySelector('span:first-child').innerHTML = '⚠';
|
||||
}
|
||||
document.getElementById('status-text').textContent = data.overall;
|
||||
if (data.last_heartbeat) {
|
||||
const d = new Date(data.last_heartbeat * 1000);
|
||||
|
|
@ -94,7 +103,7 @@
|
|||
|
||||
html += `<div class="st-node">
|
||||
<div class="st-header">
|
||||
<div><span class="st-name">${n.city}</span><span class="st-region">${n.region}</span></div>
|
||||
<div><span class="pop-city">${n.city}</span><span class="pop-country">${n.country}</span></div>
|
||||
<span class="st-health ${healthClass}">${healthLabel}</span>
|
||||
</div>
|
||||
<div class="st-bars">`;
|
||||
|
|
@ -121,7 +130,7 @@
|
|||
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Planned</div>`;
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
|
||||
for (const n of planned) {
|
||||
html += `<div style="font-size:0.85rem;padding:8px 0;color:var(--text-tertiary)">${n.city}<span style="font-size:0.7rem;margin-left:6px;opacity:0.6">${n.country}</span></div>`;
|
||||
html += `<div style="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span><span class="pop-country">${n.country}</span></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
<h2 class="mb-4">h2 — Identity Encryption: jurisdiction irrelevant.</h2>
|
||||
<h3 class="mb-4">h3 — Only you. Only in person.</h3>
|
||||
<p class="lead mb-4">p.lead — We run it. You own it. Pick your region — your data stays there.</p>
|
||||
<p class="mb-4">p — Passwords and private notes are encrypted on your device with a key derived from your fingerprint or hardware token. We store a locked box. No key ever reaches our servers.</p>
|
||||
<p class="mb-4">p — Passwords and private notes are encrypted on your device with a key derived from your WebAuthn authenticator — fingerprint, face, or hardware key. We store a locked box. No key ever reaches our servers.</p>
|
||||
<p class="label mb-2">label (default)</p>
|
||||
<p class="label accent mb-2">label.accent</p>
|
||||
<p class="label gold mb-2">label.gold</p>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -3,6 +3,21 @@ module clavitor.ai/pop-sync
|
|||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
|
|||
|
|
@ -1,3 +1,33 @@
|
|||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -14,6 +15,10 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
||||
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
|
|
@ -73,8 +78,10 @@ type Config struct {
|
|||
JSONOut bool
|
||||
Zone string
|
||||
CFZoneID string
|
||||
VaultSrc string // path to clovis-vault source
|
||||
Nodes string // comma-separated node filter (empty = all)
|
||||
VaultSrc string // path to clovis-vault source
|
||||
Nodes string // comma-separated node filter (empty = all)
|
||||
AWSKeyID string
|
||||
AWSSecretKey string
|
||||
}
|
||||
|
||||
type NodeResult struct {
|
||||
|
|
@ -111,6 +118,8 @@ func main() {
|
|||
fatal("usage: pop-sync exec <command>")
|
||||
}
|
||||
exitWith(cmdExec(cfg, strings.Join(remaining, " ")))
|
||||
case "firewall":
|
||||
exitWith(cmdFirewall(cfg))
|
||||
case "maintenance":
|
||||
if len(remaining) == 0 {
|
||||
fatal("usage: pop-sync maintenance <on|off> [reason]")
|
||||
|
|
@ -141,7 +150,7 @@ Flags:
|
|||
-ts-key Tailscale API key (or TS_API_KEY env)
|
||||
-ts-authkey Tailscale auth key for joining new nodes (or TS_AUTHKEY env)
|
||||
-hans SSH target for Hans/SSM relay (default: johan@185.218.204.47)
|
||||
-vault-src Path to clovis-vault source (default: ../clovis/clovis-vault)
|
||||
-vault-src Path to clovis-vault source (default: ../clavis/clavis-vault)
|
||||
-zone DNS zone (default: clavitor.ai)
|
||||
-nodes Comma-separated node filter, e.g. "use1,sg1" (default: all)
|
||||
-dry-run Show what would change without doing it
|
||||
|
|
@ -192,6 +201,10 @@ func parseFlags() (Config, []string) {
|
|||
cfg.CFZoneID = next()
|
||||
case "-nodes":
|
||||
cfg.Nodes = next()
|
||||
case "-aws-key":
|
||||
cfg.AWSKeyID = next()
|
||||
case "-aws-secret":
|
||||
cfg.AWSSecretKey = next()
|
||||
case "-dry-run", "--dry-run":
|
||||
cfg.DryRun = true
|
||||
case "-json", "--json":
|
||||
|
|
@ -218,11 +231,17 @@ func parseFlags() (Config, []string) {
|
|||
cfg.HansHost = "johan@185.218.204.47"
|
||||
}
|
||||
if cfg.VaultSrc == "" {
|
||||
cfg.VaultSrc = "../clovis/clovis-vault"
|
||||
cfg.VaultSrc = "../clavis/clavis-vault"
|
||||
}
|
||||
if cfg.Zone == "" {
|
||||
cfg.Zone = "clavitor.ai"
|
||||
}
|
||||
if cfg.AWSKeyID == "" {
|
||||
cfg.AWSKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
|
||||
}
|
||||
if cfg.AWSSecretKey == "" {
|
||||
cfg.AWSSecretKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||
}
|
||||
|
||||
return cfg, remaining
|
||||
}
|
||||
|
|
@ -268,6 +287,14 @@ func cmdSync(cfg Config) []NodeResult {
|
|||
tsResults := syncTailscale(cfg, pops)
|
||||
results = append(results, tsResults...)
|
||||
|
||||
// Firewall sync
|
||||
if cfg.AWSKeyID != "" {
|
||||
log(cfg, "\n--- AWS Firewall ---")
|
||||
for _, p := range pops {
|
||||
results = append(results, ensureFirewall(cfg, p))
|
||||
}
|
||||
}
|
||||
|
||||
outputResults(cfg, results)
|
||||
return results
|
||||
}
|
||||
|
|
@ -280,8 +307,16 @@ func cmdDeploy(cfg Config) []NodeResult {
|
|||
fatal("no live nodes to deploy to")
|
||||
}
|
||||
|
||||
// Step 0: Firewall
|
||||
if cfg.AWSKeyID != "" {
|
||||
log(cfg, "--- Firewall ---")
|
||||
for _, p := range pops {
|
||||
ensureFirewall(cfg, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Build
|
||||
log(cfg, "--- Build ---")
|
||||
log(cfg, "\n--- Build ---")
|
||||
binaryPath := buildVault(cfg)
|
||||
log(cfg, "Built: %s", binaryPath)
|
||||
|
||||
|
|
@ -332,14 +367,18 @@ func cmdDeploy(cfg Config) []NodeResult {
|
|||
"mv /tmp/clavitor-new /opt/clavitor/bin/clavitor",
|
||||
"chmod +x /opt/clavitor/bin/clavitor",
|
||||
|
||||
`test -f /opt/clavitor/env || cat > /opt/clavitor/env << 'ENVEOF'
|
||||
fmt.Sprintf(`cat > /opt/clavitor/env << 'ENVEOF'
|
||||
PORT=1984
|
||||
VAULT_MODE=hosted
|
||||
DATA_DIR=/opt/clavitor/data
|
||||
TELEMETRY_FREQ=30
|
||||
TELEMETRY_HOST=https://zurich.tailca1a1e.ts.net:9999/telemetry
|
||||
TELEMETRY_HOST=https://clavitor.ai/telemetry
|
||||
TELEMETRY_TOKEN=clavitor-fleet-2026
|
||||
ENVEOF`,
|
||||
TLS_DOMAIN=%s
|
||||
CF_API_TOKEN=dSVz7JZtyK023q7kh4MMNmIggK1dahWdnBxVnP3O
|
||||
TLS_CERT_DIR=/opt/clavitor/certs
|
||||
TLS_EMAIL=ops@clavitor.ai
|
||||
ENVEOF`, p.DNS),
|
||||
|
||||
`test -f /etc/systemd/system/clavitor.service || cat > /etc/systemd/system/clavitor.service << 'UNITEOF'
|
||||
[Unit]
|
||||
|
|
@ -545,6 +584,151 @@ func cmdMaintenance(cfg Config, args []string) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Subcommand: firewall ---
|
||||
// Ensures every POP's security group has exactly port 1984 open, nothing else.
|
||||
|
||||
func cmdFirewall(cfg Config) []NodeResult {
|
||||
if cfg.AWSKeyID == "" || cfg.AWSSecretKey == "" {
|
||||
fatal("AWS credentials required: set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or -aws-key/-aws-secret")
|
||||
}
|
||||
|
||||
pops := filterNodes(cfg, loadLivePOPs(cfg))
|
||||
log(cfg, "Checking firewall for %d nodes...\n", len(pops))
|
||||
|
||||
var results []NodeResult
|
||||
for _, p := range pops {
|
||||
r := ensureFirewall(cfg, p)
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
outputResults(cfg, results)
|
||||
return results
|
||||
}
|
||||
|
||||
// ensureFirewall makes sure the security group for a POP only allows inbound TCP 1984.
|
||||
func ensureFirewall(cfg Config, pop POP) NodeResult {
|
||||
name := pop.Subdomain()
|
||||
region := pop.RegionName
|
||||
r := NodeResult{Node: name, Action: "firewall"}
|
||||
|
||||
if pop.InstanceID == "" {
|
||||
r.Error = "no instance_id"
|
||||
return r
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AWSKeyID, cfg.AWSSecretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("aws config: %v", err)
|
||||
return r
|
||||
}
|
||||
client := ec2.NewFromConfig(awsCfg)
|
||||
|
||||
// Get instance's security group
|
||||
descOut, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []string{pop.InstanceID},
|
||||
})
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("describe instance: %v", err)
|
||||
return r
|
||||
}
|
||||
if len(descOut.Reservations) == 0 || len(descOut.Reservations[0].Instances) == 0 {
|
||||
r.Error = "instance not found"
|
||||
return r
|
||||
}
|
||||
inst := descOut.Reservations[0].Instances[0]
|
||||
if len(inst.SecurityGroups) == 0 {
|
||||
r.Error = "no security groups"
|
||||
return r
|
||||
}
|
||||
sgID := *inst.SecurityGroups[0].GroupId
|
||||
|
||||
// Get current rules
|
||||
sgOut, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
|
||||
GroupIds: []string{sgID},
|
||||
})
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("describe sg: %v", err)
|
||||
return r
|
||||
}
|
||||
sg := sgOut.SecurityGroups[0]
|
||||
|
||||
// Check what needs changing
|
||||
has1984 := false
|
||||
var toRevoke []ec2types.IpPermission
|
||||
for _, perm := range sg.IpPermissions {
|
||||
if perm.FromPort != nil && *perm.FromPort == 1984 && perm.ToPort != nil && *perm.ToPort == 1984 && perm.IpProtocol != nil && *perm.IpProtocol == "tcp" {
|
||||
has1984 = true
|
||||
} else {
|
||||
toRevoke = append(toRevoke, perm)
|
||||
}
|
||||
}
|
||||
|
||||
changes := 0
|
||||
|
||||
// Remove unwanted rules
|
||||
if len(toRevoke) > 0 && !cfg.DryRun {
|
||||
_, err := client.RevokeSecurityGroupIngress(ctx, &ec2.RevokeSecurityGroupIngressInput{
|
||||
GroupId: &sgID,
|
||||
IpPermissions: toRevoke,
|
||||
})
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("revoke rules: %v", err)
|
||||
return r
|
||||
}
|
||||
for _, p := range toRevoke {
|
||||
port := int32(0)
|
||||
if p.FromPort != nil { port = *p.FromPort }
|
||||
log(cfg, " [%s] removed port %d from %s", name, port, sgID)
|
||||
}
|
||||
changes += len(toRevoke)
|
||||
} else if len(toRevoke) > 0 {
|
||||
for _, p := range toRevoke {
|
||||
port := int32(0)
|
||||
if p.FromPort != nil { port = *p.FromPort }
|
||||
log(cfg, " [%s] would remove port %d from %s", name, port, sgID)
|
||||
}
|
||||
changes += len(toRevoke)
|
||||
}
|
||||
|
||||
// Add 1984 if missing
|
||||
if !has1984 && !cfg.DryRun {
|
||||
proto := "tcp"
|
||||
port := int32(1984)
|
||||
_, err := client.AuthorizeSecurityGroupIngress(ctx, &ec2.AuthorizeSecurityGroupIngressInput{
|
||||
GroupId: &sgID,
|
||||
IpPermissions: []ec2types.IpPermission{{
|
||||
IpProtocol: &proto,
|
||||
FromPort: &port,
|
||||
ToPort: &port,
|
||||
IpRanges: []ec2types.IpRange{{CidrIp: strPtr("0.0.0.0/0")}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("add 1984: %v", err)
|
||||
return r
|
||||
}
|
||||
log(cfg, " [%s] opened port 1984 on %s", name, sgID)
|
||||
changes++
|
||||
} else if !has1984 {
|
||||
log(cfg, " [%s] would open port 1984 on %s", name, sgID)
|
||||
changes++
|
||||
}
|
||||
|
||||
if changes == 0 {
|
||||
log(cfg, " [%s] OK — port 1984 only (%s)", name, sgID)
|
||||
}
|
||||
|
||||
r.OK = true
|
||||
r.Message = fmt.Sprintf("sg=%s port=1984", sgID)
|
||||
return r
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// --- Node execution: Tailscale SSH with SSM fallback ---
|
||||
|
||||
// nodeExec runs a command on a node, trying Tailscale SSH first, falling back to SSM.
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue