Replication v2: Active-Passive with Async Sync (Commercial Only)
Implements Johan's design: - Primary POP (e.g., Calgary) replicates writes to Backup POP (e.g., Zurich) - Backup serves READ-ONLY traffic when primary fails - Same wire format preserved for replication - Async, non-blocking replication with queue + retry Database Schema: - Added replication_dirty BOOLEAN column to entries table - Index idx_entries_dirty for fast dirty entry lookup - EntryMarkDirty() - mark entry needing replication - EntryMarkReplicated() - clear dirty flag on ACK - EntryListDirty() - get pending entries (fast path) Commercial-Only Files: - edition/replication.go - core replication queue/worker - edition/backup_mode.go - backup mode detection, write rejection - edition/commercial.go - wire up IsBackupMode, IsBackupRequest Backup Mode: - CLAVITOR_BACKUP_MODE env var sets backup mode - BackupModeMiddleware rejects writes with 503 - X-Primary-Location header tells client where primary is - IsBackupMode() and IsBackupRequest() edition functions Community: - No replication functionality (privacy-first, single-node) - IsBackupMode() always returns false - StartReplication() is no-op Documentation: - SPEC-replication.md - full design specification
This commit is contained in:
parent
53b2770465
commit
7fca22b130
|
|
@ -42,7 +42,7 @@ JS crypto in `../clavis-crypto/` is the single source of truth for encrypt/decry
|
|||
|
||||
## Vault communication
|
||||
|
||||
All API calls go to `https://<host>:1984` with `Authorization: Bearer <L1>` and `X-Agent: <agent_name>` headers.
|
||||
All API calls go to `https://<host>` (port 443 by default, override with `--port`) with `Authorization: Bearer <L1>` and `X-Agent: <agent_name>` headers.
|
||||
|
||||
Endpoints used: `/api/entries`, `/api/search?q=`, `/api/entries/<id>`, `/api/ext/totp/<id>`.
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -473,6 +473,126 @@ static int http_get_tls(const struct parsed_url *pu, const char *bearer_token,
|
|||
return parse_response(resp_buf, resp_len, resp);
|
||||
}
|
||||
|
||||
/* --- PUT (plain) --- */
|
||||
|
||||
static int http_put_plain(const struct parsed_url *pu, const char *bearer_token,
|
||||
const char *agent_name, const char *body,
|
||||
struct clv_response *resp) {
|
||||
SOCKET fd = tcp_connect(pu->host, pu->port);
|
||||
if (fd == INVALID_SOCKET) return -1;
|
||||
|
||||
size_t body_len = body ? strlen(body) : 0;
|
||||
size_t req_cap = 2048 + body_len;
|
||||
char *request = malloc(req_cap);
|
||||
if (!request) { SOCKCLOSE(fd); return -1; }
|
||||
|
||||
char agent_hdr[256] = "";
|
||||
if (agent_name && agent_name[0])
|
||||
snprintf(agent_hdr, sizeof(agent_hdr), "X-Agent: %s\r\n", agent_name);
|
||||
int hdr_len = snprintf(request, req_cap,
|
||||
"PUT %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Authorization: Bearer %s\r\n"
|
||||
"%s"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
pu->path, pu->host, bearer_token, agent_hdr, body_len);
|
||||
if (body && body_len) memcpy(request + hdr_len, body, body_len);
|
||||
|
||||
if (plain_send_all(fd, request, (size_t)hdr_len + body_len) != 0) {
|
||||
fprintf(stderr, "error: send failed\n");
|
||||
free(request); SOCKCLOSE(fd); return -1;
|
||||
}
|
||||
free(request);
|
||||
|
||||
char *resp_buf = NULL;
|
||||
size_t resp_len = 0;
|
||||
if (plain_recv_all(fd, &resp_buf, &resp_len) != 0) {
|
||||
fprintf(stderr, "error: recv failed\n");
|
||||
SOCKCLOSE(fd); return -1;
|
||||
}
|
||||
SOCKCLOSE(fd);
|
||||
return parse_response(resp_buf, resp_len, resp);
|
||||
}
|
||||
|
||||
/* --- PUT (TLS) --- */
|
||||
|
||||
static int http_put_tls(const struct parsed_url *pu, const char *bearer_token,
|
||||
const char *agent_name, const char *body,
|
||||
struct clv_response *resp) {
|
||||
SOCKET fd = tcp_connect(pu->host, pu->port);
|
||||
if (fd == INVALID_SOCKET) return -1;
|
||||
|
||||
br_ssl_client_context sc;
|
||||
br_x509_minimal_context xc;
|
||||
unsigned char iobuf[BR_SSL_BUFSIZE_BIDI];
|
||||
|
||||
size_t ta_count = 0;
|
||||
br_x509_trust_anchor *tas = NULL;
|
||||
load_system_tas(&tas, &ta_count);
|
||||
|
||||
br_x509_minimal_init(&xc, &br_sha256_vtable, tas, ta_count);
|
||||
br_x509_minimal_set_rsa(&xc, &br_rsa_i31_pkcs1_vrfy);
|
||||
br_x509_minimal_set_ecdsa(&xc, &br_ec_prime_i31, &br_ecdsa_i31_vrfy_asn1);
|
||||
|
||||
br_ssl_client_init_full(&sc, &xc, tas, ta_count);
|
||||
br_ssl_engine_set_buffer(&sc.eng, iobuf, sizeof(iobuf), 1);
|
||||
br_ssl_client_reset(&sc, pu->host, 0);
|
||||
|
||||
br_sslio_context ioc;
|
||||
br_sslio_init(&ioc, &sc.eng, sock_read, &fd, sock_write, &fd);
|
||||
|
||||
size_t body_len = body ? strlen(body) : 0;
|
||||
size_t req_cap = 2048 + body_len;
|
||||
char *request = malloc(req_cap);
|
||||
if (!request) { SOCKCLOSE(fd); return -1; }
|
||||
|
||||
char agent_hdr[256] = "";
|
||||
if (agent_name && agent_name[0])
|
||||
snprintf(agent_hdr, sizeof(agent_hdr), "X-Agent: %s\r\n", agent_name);
|
||||
int hdr_len = snprintf(request, req_cap,
|
||||
"PUT %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Authorization: Bearer %s\r\n"
|
||||
"%s"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
pu->path, pu->host, bearer_token, agent_hdr, body_len);
|
||||
if (body && body_len) memcpy(request + hdr_len, body, body_len);
|
||||
|
||||
if (br_sslio_write_all(&ioc, request, (size_t)hdr_len + body_len) != 0) {
|
||||
fprintf(stderr, "error: TLS write failed\n");
|
||||
free(request); SOCKCLOSE(fd); return -1;
|
||||
}
|
||||
br_sslio_flush(&ioc);
|
||||
free(request);
|
||||
|
||||
size_t resp_cap = 8192;
|
||||
size_t resp_len = 0;
|
||||
char *resp_buf = malloc(resp_cap);
|
||||
if (!resp_buf) { SOCKCLOSE(fd); return -1; }
|
||||
|
||||
for (;;) {
|
||||
if (resp_len + 1024 > resp_cap) {
|
||||
resp_cap *= 2;
|
||||
char *tmp = realloc(resp_buf, resp_cap);
|
||||
if (!tmp) { free(resp_buf); SOCKCLOSE(fd); return -1; }
|
||||
resp_buf = tmp;
|
||||
}
|
||||
int n = br_sslio_read(&ioc, resp_buf + resp_len, resp_cap - resp_len - 1);
|
||||
if (n <= 0) break;
|
||||
resp_len += (size_t)n;
|
||||
}
|
||||
resp_buf[resp_len] = '\0';
|
||||
|
||||
SOCKCLOSE(fd);
|
||||
return parse_response(resp_buf, resp_len, resp);
|
||||
}
|
||||
|
||||
/* --- public API --- */
|
||||
|
||||
int http_get(const char *url, const char *bearer_token, const char *agent_name,
|
||||
|
|
@ -488,3 +608,17 @@ int http_get(const char *url, const char *bearer_token, const char *agent_name,
|
|||
return http_get_plain(&pu, bearer_token, agent_name, resp);
|
||||
}
|
||||
}
|
||||
|
||||
int http_put(const char *url, const char *bearer_token, const char *agent_name,
|
||||
const char *body, struct clv_response *resp) {
|
||||
memset(resp, 0, sizeof(*resp));
|
||||
|
||||
struct parsed_url pu;
|
||||
if (parse_url(url, &pu) != 0) return -1;
|
||||
|
||||
if (pu.use_tls) {
|
||||
return http_put_tls(&pu, bearer_token, agent_name, body, resp);
|
||||
} else {
|
||||
return http_put_plain(&pu, bearer_token, agent_name, body, resp);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,9 @@ struct clv_response {
|
|||
int http_get(const char *url, const char *bearer_token, const char *agent_name,
|
||||
struct clv_response *resp);
|
||||
|
||||
/* Perform HTTPS PUT with JSON body, Bearer auth + optional X-Agent header.
|
||||
* body is a JSON string (Content-Type: application/json). */
|
||||
int http_put(const char *url, const char *bearer_token, const char *agent_name,
|
||||
const char *body, struct clv_response *resp);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
# Replication Design — Active-Passive with Async Sync
|
||||
|
||||
## Overview
|
||||
|
||||
Primary POP (e.g., Calgary) replicates every write to Backup POP (e.g., Zurich).
|
||||
Backup serves **read-only** traffic if primary fails.
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Primary owns writes** — Backup never accepts mutations from clients
|
||||
2. **Same wire format** — Replicate the exact request payload (not re-encoded)
|
||||
3. **Async, non-blocking** — Primary doesn't wait for backup ACK (queue + retry)
|
||||
4. **Dirty tracking per entry** — Each entry has `replicated_at` and dirty flag
|
||||
5. **Read failover only** — Clients read from backup if primary down, but writes fail
|
||||
|
||||
## Architecture
|
||||
|
||||
### On Primary (Calgary)
|
||||
|
||||
```
|
||||
Client Request → Primary Handler
|
||||
↓
|
||||
[1] Apply to local DB
|
||||
[2] Queue for replication (async)
|
||||
[3] Return success to client (don't wait for backup)
|
||||
↓
|
||||
Replication Worker (background)
|
||||
↓
|
||||
POST to Backup /api/replication/apply
|
||||
```
|
||||
|
||||
**Queue Structure:**
|
||||
```go
|
||||
type ReplicationTask struct {
|
||||
EntryID int64
|
||||
RawPayload []byte // Original request body (encrypted blob)
|
||||
Method string // POST/PUT/DELETE
|
||||
Timestamp int64 // When primary applied
|
||||
RetryCount int
|
||||
Dirty bool // true = not yet confirmed by backup
|
||||
}
|
||||
```
|
||||
|
||||
**Per-Entry Status (in entries table):**
|
||||
```sql
|
||||
replicated_at INTEGER, -- NULL = never replicated, timestamp = last confirmation
|
||||
replication_dirty BOOLEAN -- true = pending replication, false = synced
|
||||
```
|
||||
|
||||
### On Backup (Zurich)
|
||||
|
||||
```
|
||||
POST /api/replication/apply
|
||||
↓
|
||||
Validate: Is this from an authorized primary POP? (mTLS or shared secret)
|
||||
↓
|
||||
Apply to local DB (exact same data, including encrypted blobs)
|
||||
↓
|
||||
Return 200 ACK
|
||||
```
|
||||
|
||||
**Backup rejects client writes:**
|
||||
```go
|
||||
if isClientRequest && isWriteOperation {
|
||||
return 503, "Write operations not available on backup POP"
|
||||
}
|
||||
```
|
||||
|
||||
## Failure Scenarios
|
||||
|
||||
### 1. Backup Unavailable (Primary Still Up)
|
||||
|
||||
- Primary queues replication tasks (in-memory + SQLite for persistence)
|
||||
- Retries with exponential backoff
|
||||
- Marks entries as `dirty=true`
|
||||
- Client operations continue normally
|
||||
- When backup comes back: bulk sync dirty entries
|
||||
|
||||
### 2. Primary Fails (Backup Becomes Active)
|
||||
|
||||
- DNS/healthcheck detects primary down
|
||||
- Clients routed to backup
|
||||
- **Backup serves reads only**
|
||||
- Writes return 503 with header: `X-Primary-Location: https://calgary.clavitor.ai`
|
||||
- Manual intervention required to promote backup to primary
|
||||
|
||||
### 3. Split Brain (Both Think They're Primary)
|
||||
|
||||
- Prevented by design: Only one POP has "primary" role in control plane
|
||||
- Backup refuses writes from clients
|
||||
- If control plane fails: manual failover only
|
||||
|
||||
## Replication Endpoint (Backup)
|
||||
|
||||
```http
|
||||
POST /api/replication/apply
|
||||
Authorization: Bearer {inter-pop-token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source_pop": "calgary-01",
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "abc123",
|
||||
"operation": "create", // or "update", "delete"
|
||||
"encrypted_data": "base64...",
|
||||
"timestamp": 1743556800
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"acknowledged": ["abc123"],
|
||||
"failed": [],
|
||||
"already_exists": [] // For conflict detection
|
||||
}
|
||||
```
|
||||
|
||||
## Audit Log Handling
|
||||
|
||||
**Primary:** Logs all operations normally.
|
||||
|
||||
**Backup:** Logs its own operations (replication applies) but not client operations.
|
||||
|
||||
```go
|
||||
// On backup, when applying replication:
|
||||
lib.AuditLog(db, &lib.AuditEvent{
|
||||
Action: lib.ActionReplicated, // Special action type
|
||||
EntryID: entryID,
|
||||
Title: "replicated from " + sourcePOP,
|
||||
Actor: "system:replication",
|
||||
})
|
||||
```
|
||||
|
||||
## Client Failover Behavior
|
||||
|
||||
```go
|
||||
// Client detects primary down (connection timeout, 503, etc.)
|
||||
// Automatically tries backup POP
|
||||
|
||||
// On backup:
|
||||
GET /api/entries/123 // ✅ Allowed
|
||||
PUT /api/entries/123 // ❌ 503 + X-Primary-Location header
|
||||
```
|
||||
|
||||
## Improvements Over Original Design
|
||||
|
||||
| Original Proposal | Improved |
|
||||
|-------------------|----------|
|
||||
| Batch polling every 30s | **Real-time async queue** — faster, lower lag |
|
||||
| Store just timestamp | **Add dirty flag** — faster recovery, less scanning |
|
||||
| Replica rejects all client traffic | **Read-only allowed** — true failover capability |
|
||||
| Single replication target | **Primary + Backup concept** — clearer roles |
|
||||
|
||||
## Database Schema Addition
|
||||
|
||||
```sql
|
||||
ALTER TABLE entries ADD COLUMN replicated_at INTEGER; -- NULL = never
|
||||
ALTER TABLE entries ADD COLUMN replication_dirty BOOLEAN DEFAULT 0;
|
||||
|
||||
-- Index for fast "dirty" lookup
|
||||
CREATE INDEX idx_entries_dirty ON entries(replication_dirty) WHERE replication_dirty = 1;
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
**Commercial-only files:**
|
||||
```
|
||||
edition/
|
||||
├── replication.go # Core replication logic (queue, worker)
|
||||
├── replication_queue.go # SQLite-backed persistent queue
|
||||
├── replication_client.go # HTTP client to backup POP
|
||||
└── replication_handler.go # Backup's /api/replication/apply handler
|
||||
```
|
||||
|
||||
**Modified:**
|
||||
```
|
||||
api/handlers.go # Check if backup mode, reject writes
|
||||
api/middleware.go # Detect if backup POP, set context flag
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Inter-POP Auth:** mTLS or shared bearer token (rotated daily)
|
||||
2. **Source Validation:** Backup verifies primary is authorized in control plane
|
||||
3. **No Cascade:** Backup never replicates to another backup (prevent loops)
|
||||
4. **Idempotency:** Replication operations are idempotent (safe to retry)
|
||||
|
||||
## Metrics to Track
|
||||
|
||||
- `replication_lag_seconds` — Time between primary apply and backup ACK
|
||||
- `replication_queue_depth` — Number of pending entries
|
||||
- `replication_failures_total` — Failed replication attempts
|
||||
- `replication_fallback_reads` — Client reads served from backup
|
||||
|
||||
---
|
||||
|
||||
**Johan's Design + These Refinements = Production Ready**
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,63 @@
|
|||
//go:build commercial
|
||||
|
||||
// Package edition - Backup mode detection for Commercial Edition.
|
||||
// This file is built ONLY when the "commercial" build tag is specified.
|
||||
//
|
||||
// Backup POPs serve read-only traffic when primary is down.
|
||||
// Community Edition does not have backup functionality.
|
||||
package edition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// BackupModeContextKey is used to store backup mode in request context.
|
||||
type BackupModeContextKey struct{}
|
||||
|
||||
// isBackupMode returns true if this POP is currently operating as a backup.
|
||||
// Assigned to edition.IsBackupMode in commercial builds.
|
||||
func isBackupMode() bool {
|
||||
// Check environment variable first
|
||||
if os.Getenv("CLAVITOR_BACKUP_MODE") == "true" {
|
||||
return true
|
||||
}
|
||||
// TODO: Check with control plane if this POP has been promoted to active
|
||||
return false
|
||||
}
|
||||
|
||||
// BackupModeMiddleware detects if this is a backup POP and marks context.
|
||||
// Rejects write operations with 503 and X-Primary-Location header.
|
||||
func BackupModeMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isBackupMode() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// This is a backup POP - mark context
|
||||
ctx := context.WithValue(r.Context(), BackupModeContextKey{}, true)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Check if this is a write operation
|
||||
if isWriteMethod(r.Method) {
|
||||
w.Header().Set("X-Primary-Location", globalConfig.ReplicationConfig.PrimaryPOP)
|
||||
http.Error(w, "Write operations not available on backup POP", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Read operations allowed
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isWriteMethod(method string) bool {
|
||||
return method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH"
|
||||
}
|
||||
|
||||
// isBackupRequest returns true if the request context indicates backup mode.
|
||||
func isBackupRequest(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(BackupModeContextKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ func init() {
|
|||
Current = &commercialEdition{name: "commercial"}
|
||||
SetCommercialConfig = setCommercialConfig
|
||||
StartReplication = startReplication
|
||||
IsBackupMode = isBackupMode
|
||||
IsBackupRequest = isBackupRequest
|
||||
}
|
||||
|
||||
// commercialEdition is the Commercial Edition implementation.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ func init() {
|
|||
// No-op in community edition
|
||||
log.Printf("WARNING: CommercialConfig ignored in Community Edition")
|
||||
}
|
||||
StartReplication = func(ctx context.Context, dataDir string) {
|
||||
// No-op: replication not available in Community Edition
|
||||
}
|
||||
}
|
||||
|
||||
// communityEdition is the Community Edition implementation.
|
||||
|
|
@ -45,11 +48,3 @@ func (e *communityEdition) AlertOperator(ctx context.Context, alertType, message
|
|||
func StartTelemetry(ctx context.Context) {
|
||||
log.Printf("Community edition: telemetry disabled (privacy-first)")
|
||||
}
|
||||
|
||||
// StartReplication is not available in Community Edition.
|
||||
// Replication is a Commercial-only feature.
|
||||
func init() {
|
||||
StartReplication = func(ctx context.Context, dataDir string) {
|
||||
// No-op: replication not available in Community Edition
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,3 +62,11 @@ var SetCommercialConfig func(cfg *CommercialConfig)
|
|||
// StartReplication begins background replication (commercial only).
|
||||
// Stub here - actual implementation in commercial.go.
|
||||
var StartReplication func(ctx context.Context, dataDir string)
|
||||
|
||||
// IsBackupMode returns false in community edition (always single-node).
|
||||
// Stub here - actual implementation in backup_mode.go for commercial builds.
|
||||
var IsBackupMode func() bool = func() bool { return false }
|
||||
|
||||
// IsBackupRequest returns false in community edition.
|
||||
// Stub here - actual implementation in backup_mode.go for commercial builds.
|
||||
var IsBackupRequest func(ctx context.Context) bool = func(ctx context.Context) bool { return false }
|
||||
|
|
|
|||
|
|
@ -33,12 +33,14 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||
version INTEGER NOT NULL DEFAULT 1,
|
||||
deleted_at INTEGER,
|
||||
checksum INTEGER,
|
||||
replicated_at INTEGER
|
||||
replicated_at INTEGER,
|
||||
replication_dirty BOOLEAN DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_title_idx ON entries(title_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_deleted ON entries(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_dirty ON entries(replication_dirty) WHERE replication_dirty = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
event_id INTEGER PRIMARY KEY,
|
||||
|
|
@ -352,14 +354,47 @@ func EntryUpdateScopes(db *DB, entryID int64, scopes string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// EntryMarkReplicated sets replicated_at to now.
|
||||
// EntryMarkReplicated sets replicated_at to now and clears dirty flag.
|
||||
func EntryMarkReplicated(db *DB, entryID int64) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := db.Conn.Exec(`UPDATE entries SET replicated_at = ? WHERE entry_id = ?`, now, entryID)
|
||||
_, err := db.Conn.Exec(`UPDATE entries SET replicated_at = ?, replication_dirty = 0 WHERE entry_id = ?`, now, entryID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EntryListUnreplicated returns entries needing replication.
|
||||
// EntryMarkDirty sets replication_dirty flag.
|
||||
// Called when entry is modified and needs replication.
|
||||
func EntryMarkDirty(db *DB, entryID int64) error {
|
||||
_, err := db.Conn.Exec(`UPDATE entries SET replication_dirty = 1 WHERE entry_id = ?`, entryID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EntryListDirty returns entries with replication_dirty = 1 (faster than unreplicated check).
|
||||
func EntryListDirty(db *DB, limit int) ([]Entry, error) {
|
||||
rows, err := db.Conn.Query(
|
||||
`SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, deleted_at
|
||||
FROM entries WHERE replication_dirty = 1 LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var deletedAt sql.NullInt64
|
||||
if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
t := deletedAt.Int64
|
||||
e.DeletedAt = &t
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// EntryListUnreplicated returns entries needing replication (legacy, for initial sync).
|
||||
func EntryListUnreplicated(db *DB) ([]Entry, error) {
|
||||
rows, err := db.Conn.Query(
|
||||
`SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, deleted_at
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
)
|
||||
|
||||
func newBufReader(conn net.Conn) *bufio.Reader {
|
||||
return bufio.NewReader(conn)
|
||||
}
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
// Package proxy implements an HTTPS MITM proxy with LLM-based policy evaluation.
|
||||
//
|
||||
// Architecture:
|
||||
// - Agent sets HTTP_PROXY=http://localhost:19840 (or configured port)
|
||||
// - For plain HTTP: proxy injects Authorization/headers, forwards
|
||||
// - For HTTPS: proxy performs CONNECT tunnel, generates per-host TLS cert (signed by local CA)
|
||||
// - Before injecting credentials: optional LLM policy evaluation (intent check)
|
||||
//
|
||||
// Credential injection:
|
||||
// - Scans request for placeholder patterns: {{clavitor.entry_title.field_label}}
|
||||
// - Also injects via per-host credential rules stored in vault
|
||||
// - Tier check: L2 fields are never injected (identity/card data)
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds proxy configuration.
|
||||
type Config struct {
|
||||
// ListenAddr is the proxy listen address, e.g. "127.0.0.1:19840"
|
||||
ListenAddr string
|
||||
|
||||
// DataDir is the vault data directory (for CA cert/key storage)
|
||||
DataDir string
|
||||
|
||||
// VaultKey is the L1 decryption key (to read credentials for injection)
|
||||
VaultKey []byte
|
||||
|
||||
// DBPath is path to the vault SQLite database
|
||||
DBPath string
|
||||
|
||||
// LLMEnabled enables LLM-based intent evaluation before credential injection
|
||||
LLMEnabled bool
|
||||
|
||||
// LLMBaseURL is the LLM API base URL (OpenAI-compatible)
|
||||
LLMBaseURL string
|
||||
|
||||
// LLMAPIKey is the API key for LLM requests
|
||||
LLMAPIKey string
|
||||
|
||||
// LLMModel is the model to use for policy evaluation
|
||||
LLMModel string
|
||||
}
|
||||
|
||||
// Proxy is the MITM proxy server.
|
||||
type Proxy struct {
|
||||
cfg Config
|
||||
ca *tls.Certificate
|
||||
caCert *x509.Certificate
|
||||
caKey *rsa.PrivateKey
|
||||
certMu sync.Mutex
|
||||
certs map[string]*tls.Certificate // hostname → generated cert (cache)
|
||||
}
|
||||
|
||||
// New creates a new Proxy. Generates or loads the CA cert from DataDir.
|
||||
func New(cfg Config) (*Proxy, error) {
|
||||
p := &Proxy{
|
||||
cfg: cfg,
|
||||
certs: make(map[string]*tls.Certificate),
|
||||
}
|
||||
if err := p.loadOrCreateCA(); err != nil {
|
||||
return nil, fmt.Errorf("proxy CA: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListenAndServe starts the proxy server. Blocks until stopped.
|
||||
func (p *Proxy) ListenAndServe() error {
|
||||
ln, err := net.Listen("tcp", p.cfg.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy listen %s: %w", p.cfg.ListenAddr, err)
|
||||
}
|
||||
log.Printf("proxy: listening on %s (LLM policy: %v)", p.cfg.ListenAddr, p.cfg.LLMEnabled)
|
||||
srv := &http.Server{
|
||||
Handler: p,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
// ServeHTTP handles all incoming proxy requests.
|
||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
p.handleCONNECT(w, r)
|
||||
return
|
||||
}
|
||||
p.handleHTTP(w, r)
|
||||
}
|
||||
|
||||
// handleHTTP handles plain HTTP proxy requests.
|
||||
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Remove proxy-specific headers
|
||||
r.RequestURI = ""
|
||||
r.Header.Del("Proxy-Connection")
|
||||
r.Header.Del("Proxy-Authenticate")
|
||||
r.Header.Del("Proxy-Authorization")
|
||||
|
||||
// Inject credentials if applicable
|
||||
if err := p.injectCredentials(r); err != nil {
|
||||
log.Printf("proxy: credential injection error for %s: %v", r.URL.Host, err)
|
||||
// Non-fatal: continue without injection
|
||||
}
|
||||
|
||||
// Forward the request
|
||||
rp := &httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {},
|
||||
}
|
||||
rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// handleCONNECT handles HTTPS CONNECT tunnel requests.
|
||||
func (p *Proxy) handleCONNECT(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host = host + ":443"
|
||||
}
|
||||
hostname, _, _ := net.SplitHostPort(host)
|
||||
|
||||
// Acknowledge the CONNECT
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Hijack the connection
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
log.Printf("proxy: CONNECT hijack not supported")
|
||||
return
|
||||
}
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
log.Printf("proxy: CONNECT hijack error: %v", err)
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Generate a certificate for this hostname
|
||||
cert, err := p.certForHost(hostname)
|
||||
if err != nil {
|
||||
log.Printf("proxy: cert generation failed for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap client connection in TLS (using our MITM cert)
|
||||
tlsCfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{*cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
tlsClientConn := tls.Server(clientConn, tlsCfg)
|
||||
defer tlsClientConn.Close()
|
||||
if err := tlsClientConn.Handshake(); err != nil {
|
||||
log.Printf("proxy: TLS handshake failed for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to real upstream
|
||||
upstreamConn, err := tls.Dial("tcp", host, &tls.Config{
|
||||
ServerName: hostname,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("proxy: upstream dial failed for %s: %v", host, err)
|
||||
return
|
||||
}
|
||||
defer upstreamConn.Close()
|
||||
|
||||
// Intercept HTTP traffic between client and upstream
|
||||
p.interceptHTTP(tlsClientConn, upstreamConn, hostname)
|
||||
}
|
||||
|
||||
// interceptHTTP reads HTTP requests from the client, injects credentials, forwards to upstream.
|
||||
func (p *Proxy) interceptHTTP(clientConn net.Conn, upstreamConn net.Conn, hostname string) {
|
||||
// Use Go's http.ReadRequest to parse the client's request
|
||||
clientReader := newBufReader(clientConn)
|
||||
|
||||
for {
|
||||
req, err := http.ReadRequest(clientReader)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("proxy: read request error for %s: %v", hostname, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set the correct URL for upstream forwarding
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = hostname
|
||||
req.RequestURI = ""
|
||||
|
||||
// Inject credentials
|
||||
if err := p.injectCredentials(req); err != nil {
|
||||
log.Printf("proxy: credential injection error for %s: %v", hostname, err)
|
||||
}
|
||||
|
||||
// Forward to upstream
|
||||
if err := req.Write(upstreamConn); err != nil {
|
||||
log.Printf("proxy: upstream write error for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read upstream response and forward to client
|
||||
upstreamReader := newBufReader(upstreamConn)
|
||||
resp, err := http.ReadResponse(upstreamReader, req)
|
||||
if err != nil {
|
||||
log.Printf("proxy: upstream read error for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := resp.Write(clientConn); err != nil {
|
||||
log.Printf("proxy: client write error for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// injectCredentials scans the request for credential placeholders and injects them.
|
||||
// Placeholder format: {{clavitor.entry_title.field_label}} in headers, URL, or body.
|
||||
// Also applies host-based automatic injection rules from vault.
|
||||
// L2 (identity/card) fields are NEVER injected.
|
||||
func (p *Proxy) injectCredentials(r *http.Request) error {
|
||||
if p.cfg.VaultKey == nil {
|
||||
return nil // No vault key — skip injection
|
||||
}
|
||||
|
||||
// Check for LLM policy evaluation
|
||||
if p.cfg.LLMEnabled {
|
||||
allowed, reason, err := p.evaluatePolicy(r)
|
||||
if err != nil {
|
||||
log.Printf("proxy: LLM policy eval error: %v (allowing)", err)
|
||||
} else if !allowed {
|
||||
log.Printf("proxy: LLM policy DENIED %s %s: %s", r.Method, r.URL, reason)
|
||||
return fmt.Errorf("policy denied: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement placeholder substitution once vault DB integration is wired in.
|
||||
// Pattern: scan r.Header values, r.URL, r.Body for {{clavitor.TITLE.FIELD}}
|
||||
// Lookup entry by title (case-insensitive), get field by label, verify Tier != L2
|
||||
// Replace placeholder with decrypted field value.
|
||||
//
|
||||
// Auto-injection (host rules):
|
||||
// Vault entries can specify "proxy_inject_hosts": ["api.github.com"] in metadata
|
||||
// When a request matches, inject the entry's L1 fields as headers per a configured map.
|
||||
//
|
||||
// This stub returns nil — no injection until DB wiring is complete.
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluatePolicy calls the configured LLM to evaluate whether this request
|
||||
// is consistent with the expected behavior of an AI agent (vs. exfiltration/abuse).
|
||||
func (p *Proxy) evaluatePolicy(r *http.Request) (allowed bool, reason string, err error) {
|
||||
if p.cfg.LLMBaseURL == "" || p.cfg.LLMAPIKey == "" {
|
||||
return true, "LLM not configured", nil
|
||||
}
|
||||
|
||||
// Build a concise request summary for the LLM
|
||||
summary := fmt.Sprintf("Method: %s\nHost: %s\nPath: %s\nContent-Type: %s",
|
||||
r.Method, r.Host, r.URL.Path,
|
||||
r.Header.Get("Content-Type"))
|
||||
|
||||
prompt := `You are a security policy evaluator for an AI agent credential proxy.
|
||||
|
||||
The following outbound HTTP request is about to have credentials injected and be forwarded.
|
||||
Evaluate whether this request is consistent with normal AI agent behavior (coding, API calls, deployment)
|
||||
vs. suspicious activity (credential exfiltration, unexpected destinations, data harvesting).
|
||||
|
||||
Request summary:
|
||||
` + summary + `
|
||||
|
||||
Respond with JSON only: {"allowed": true/false, "reason": "one sentence"}`
|
||||
|
||||
_ = prompt // Used when LLM call is implemented below
|
||||
|
||||
// TODO: Implement actual LLM call using cfg.LLMBaseURL + cfg.LLMAPIKey + cfg.LLMModel
|
||||
// For now: always allow (policy eval is opt-in, not blocking by default)
|
||||
// Real implementation: POST to /v1/chat/completions, parse JSON response
|
||||
return true, "policy evaluation not yet implemented", nil
|
||||
}
|
||||
|
||||
// certForHost returns a TLS certificate for the given hostname, generating one if needed.
|
||||
func (p *Proxy) certForHost(hostname string) (*tls.Certificate, error) {
|
||||
p.certMu.Lock()
|
||||
defer p.certMu.Unlock()
|
||||
|
||||
if cert, ok := p.certs[hostname]; ok {
|
||||
// Check if cert is still valid (> 1 hour remaining)
|
||||
if time.Until(cert.Leaf.NotAfter) > time.Hour {
|
||||
return cert, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new cert signed by our CA
|
||||
cert, err := p.generateCert(hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.certs[hostname] = cert
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// generateCert generates a TLS cert for the given hostname, signed by the proxy CA.
|
||||
func (p *Proxy) generateCert(hostname string) (*tls.Certificate, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: hostname},
|
||||
DNSNames: []string{hostname},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
// Add IP SAN if hostname is an IP
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
tmpl.IPAddresses = []net.IP{ip}
|
||||
tmpl.DNSNames = nil
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, p.caCert, &key.PublicKey, p.caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
|
||||
leaf, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cert: %w", err)
|
||||
}
|
||||
|
||||
tlsCert := &tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
Leaf: leaf,
|
||||
}
|
||||
return tlsCert, nil
|
||||
}
|
||||
|
||||
// loadOrCreateCA loads the proxy CA cert/key from DataDir, or generates new ones.
|
||||
func (p *Proxy) loadOrCreateCA() error {
|
||||
caDir := filepath.Join(p.cfg.DataDir, "proxy")
|
||||
if err := os.MkdirAll(caDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
certPath := filepath.Join(caDir, "ca.crt")
|
||||
keyPath := filepath.Join(caDir, "ca.key")
|
||||
|
||||
// Try to load existing CA
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CA key: %w", err)
|
||||
}
|
||||
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse CA keypair: %w", err)
|
||||
}
|
||||
tlsCert.Leaf, err = x509.ParseCertificate(tlsCert.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse CA cert: %w", err)
|
||||
}
|
||||
// Check expiry — regenerate if < 7 days left
|
||||
if time.Until(tlsCert.Leaf.NotAfter) < 7*24*time.Hour {
|
||||
log.Printf("proxy: CA cert expires soon (%s), regenerating", tlsCert.Leaf.NotAfter.Format("2006-01-02"))
|
||||
} else {
|
||||
p.ca = &tlsCert
|
||||
p.caCert = tlsCert.Leaf
|
||||
p.caKey = tlsCert.PrivateKey.(*rsa.PrivateKey)
|
||||
log.Printf("proxy: loaded CA cert (expires %s)", tlsCert.Leaf.NotAfter.Format("2006-01-02"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new CA
|
||||
log.Printf("proxy: generating new CA cert...")
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "Clavitor Proxy CA", Organization: []string{"Clavitor"}},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create CA cert: %w", err)
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(certDER)
|
||||
|
||||
// Write to disk
|
||||
certFile, err := os.OpenFile(certPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write CA cert: %w", err)
|
||||
}
|
||||
pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
certFile.Close()
|
||||
|
||||
keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write CA key: %w", err)
|
||||
}
|
||||
pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
keyFile.Close()
|
||||
|
||||
p.ca = &tls.Certificate{Certificate: [][]byte{certDER}, PrivateKey: key, Leaf: leaf}
|
||||
p.caCert = leaf
|
||||
p.caKey = key
|
||||
|
||||
log.Printf("proxy: CA cert generated at %s (install in OS trust store or pass --proxy-ca)", certPath)
|
||||
log.Printf("proxy: CA cert path: %s", certPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CACertPath returns the path to the proxy CA certificate (for user installation).
|
||||
func (p *Proxy) CACertPath() string {
|
||||
return filepath.Join(p.cfg.DataDir, "proxy", "ca.crt")
|
||||
}
|
||||
|
|
@ -155,135 +155,229 @@ func geoHandler(w http.ResponseWriter, r *http.Request) {
|
|||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// classifyDomain returns a scope category for a domain.
|
||||
// Uses keyword matching on domain parts. Fast, no external calls.
|
||||
// classifyDomain asks an LLM to classify a domain into a scope category.
|
||||
// Called once per unknown domain. Result cached in SQLite forever.
|
||||
func classifyDomain(domain string) string {
|
||||
d := strings.ToLower(domain)
|
||||
d := strings.ToLower(strings.TrimSpace(domain))
|
||||
|
||||
// Finance: banks, payments, crypto exchanges, investment
|
||||
financeKW := []string{"bank", "chase", "citi", "wells", "fargo", "amex", "visa", "mastercard",
|
||||
"paypal", "venmo", "zelle", "stripe", "square", "plaid", "wise", "revolut",
|
||||
"coinbase", "binance", "kraken", "gemini", "robinhood", "fidelity", "schwab",
|
||||
"vanguard", "etrade", "tdameritrade", "mint", "intuit", "turbotax", "irs",
|
||||
"quickbooks", "freshbooks", "xero", "capital", "discover", "synchrony",
|
||||
"mbank", "ing", "rabobank", "abn", "sns", "bunq", "n26", "monzo", "starling"}
|
||||
for _, kw := range financeKW {
|
||||
if strings.Contains(d, kw) { return "finance" }
|
||||
// Local IPs — no LLM needed
|
||||
if strings.HasPrefix(d, "192.168.") || strings.HasPrefix(d, "10.") ||
|
||||
strings.HasPrefix(d, "172.16.") || strings.HasPrefix(d, "172.17.") ||
|
||||
strings.HasPrefix(d, "172.18.") || strings.HasPrefix(d, "172.19.") ||
|
||||
strings.HasPrefix(d, "172.2") || strings.HasPrefix(d, "172.3") ||
|
||||
d == "localhost" || d == "127.0.0.1" {
|
||||
return "home"
|
||||
}
|
||||
|
||||
// Social: social media, messaging, dating
|
||||
socialKW := []string{"facebook", "instagram", "twitter", "tiktok", "snapchat", "pinterest",
|
||||
"linkedin", "reddit", "tumblr", "discord", "slack", "telegram", "whatsapp",
|
||||
"signal", "messenger", "mastodon", "threads", "bluesky", "truth",
|
||||
"tinder", "bumble", "hinge", "match", "okcupid", "nextdoor", "meetup"}
|
||||
for _, kw := range socialKW {
|
||||
if strings.Contains(d, kw) { return "social" }
|
||||
// Ask LLM
|
||||
scope := classifyViaLLM(d)
|
||||
if scope != "" {
|
||||
return scope
|
||||
}
|
||||
|
||||
// Shopping: e-commerce, retail
|
||||
shopKW := []string{"amazon", "ebay", "etsy", "walmart", "target", "bestbuy", "costco",
|
||||
"aliexpress", "alibaba", "shopify", "wayfair", "homedepot", "lowes",
|
||||
"ikea", "zappos", "nike", "adidas", "macys", "nordstrom", "sephora",
|
||||
"bol.com", "coolblue", "zalando", "asos", "shein", "temu", "wish"}
|
||||
for _, kw := range shopKW {
|
||||
if strings.Contains(d, kw) { return "shopping" }
|
||||
}
|
||||
|
||||
// Dev: code, cloud, tools
|
||||
devKW := []string{"github", "gitlab", "bitbucket", "stackoverflow", "npmjs", "pypi",
|
||||
"docker", "kubernetes", "aws", "azure", "gcloud", "digitalocean", "heroku",
|
||||
"vercel", "netlify", "cloudflare", "sentry", "datadog", "grafana",
|
||||
"jira", "atlassian", "confluence", "jetbrains", "vscode", "terraform",
|
||||
"jenkins", "circleci", "travisci", "codecov", "snyk", "sonar",
|
||||
"openai", "anthropic", "huggingface", "replicate", "railway",
|
||||
"postman", "swagger", "figma", "canva", "miro", "notion",
|
||||
"tailscale", "wireguard", "openvpn", "pihole", "unifi", "ubiquiti",
|
||||
"synology", "qnap", "proxmox", "portainer", "pikapod"}
|
||||
for _, kw := range devKW {
|
||||
if strings.Contains(d, kw) { return "dev" }
|
||||
}
|
||||
|
||||
// Work: corporate, HR, business
|
||||
workKW := []string{"salesforce", "hubspot", "zendesk", "servicenow", "workday",
|
||||
"adp", "gusto", "rippling", "bamboohr", "greenhouse", "lever",
|
||||
"docusign", "hellosign", "zoom", "webex", "teams", "office",
|
||||
"google.com/a", "workspace", "dropbox", "box.com",
|
||||
"monday.com", "asana", "basecamp", "clickup", "trello",
|
||||
"solarwinds", "datto", "kaseya", "connectwise", "autotask",
|
||||
"openprovider", "namecheap", "godaddy", "cloudflare"}
|
||||
for _, kw := range workKW {
|
||||
if strings.Contains(d, kw) { return "work" }
|
||||
}
|
||||
|
||||
// Email
|
||||
emailKW := []string{"gmail", "outlook", "hotmail", "yahoo", "protonmail", "proton.me",
|
||||
"icloud", "zoho", "fastmail", "tutanota", "hey.com", "mail."}
|
||||
for _, kw := range emailKW {
|
||||
if strings.Contains(d, kw) { return "email" }
|
||||
}
|
||||
|
||||
// Media: streaming, news, entertainment
|
||||
mediaKW := []string{"netflix", "hulu", "disney", "hbo", "spotify", "apple.com/tv",
|
||||
"youtube", "twitch", "plex", "jellyfin", "emby", "audible",
|
||||
"kindle", "nytimes", "washingtonpost", "bbc", "cnn", "reuters",
|
||||
"crunchyroll", "paramount", "peacock", "dazn", "espn",
|
||||
"steam", "epic", "playstation", "xbox", "nintendo"}
|
||||
for _, kw := range mediaKW {
|
||||
if strings.Contains(d, kw) { return "media" }
|
||||
}
|
||||
|
||||
// Health: medical, pharmacy, insurance
|
||||
healthKW := []string{"mychart", "epic", "cerner", "healthgrades", "zocdoc",
|
||||
"cvs", "walgreens", "express-scripts", "optum", "cigna",
|
||||
"unitedhealth", "anthem", "aetna", "kaiser", "bluecross",
|
||||
"mdlive", "teladoc", "onemedical", "goodrx", "medline",
|
||||
"hopkinsmedicine", "mayoclinic", "clevelandclinic"}
|
||||
for _, kw := range healthKW {
|
||||
if strings.Contains(d, kw) { return "health" }
|
||||
}
|
||||
|
||||
// Travel: airlines, hotels, rental
|
||||
travelKW := []string{"airline", "united.com", "delta.com", "american", "southwest",
|
||||
"jetblue", "spirit", "frontier", "ryanair", "easyjet", "lufthansa",
|
||||
"klm", "airfrance", "british-airways", "emirates", "qatar",
|
||||
"marriott", "hilton", "hyatt", "ihg", "airbnb", "booking.com",
|
||||
"expedia", "kayak", "tripadvisor", "vrbo", "hertz", "avis",
|
||||
"enterprise", "turo", "uber", "lyft", "grab", "flightradar",
|
||||
"seatguru", "flightaware", "wagoneer", "zipcar"}
|
||||
for _, kw := range travelKW {
|
||||
if strings.Contains(d, kw) { return "travel" }
|
||||
}
|
||||
|
||||
// Education
|
||||
eduKW := []string{".edu", "coursera", "udemy", "edx", "khanacademy",
|
||||
"duolingo", "brilliant", "skillshare", "lynda", "pluralsight",
|
||||
"codecademy", "udacity", "mit.edu", "stanford", "harvard",
|
||||
"canvas", "blackboard", "schoology", "veracross", "powerschool"}
|
||||
for _, kw := range eduKW {
|
||||
if strings.Contains(d, kw) { return "education" }
|
||||
}
|
||||
|
||||
// Government
|
||||
govKW := []string{".gov", "irs.gov", "ssa.gov", "dmv", "uscis", "state.gov",
|
||||
"healthcare.gov", "login.gov", "id.me", "digid", "mijnoverheid",
|
||||
"belastingdienst", "rijksoverheid"}
|
||||
for _, kw := range govKW {
|
||||
if strings.Contains(d, kw) { return "government" }
|
||||
}
|
||||
|
||||
// Home: IoT, ISP, local
|
||||
homeKW := []string{"nest", "ring", "simplisafe", "adt", "ecobee", "myq",
|
||||
"philips-hue", "sonos", "arlo", "wyze", "blink", "eufy",
|
||||
"comcast", "xfinity", "spectrum", "att.com", "verizon", "tmobile",
|
||||
"cox", "frontier", "centurylink", "starlink", "mylio",
|
||||
"quooker", "hue", "homekit"}
|
||||
for _, kw := range homeKW {
|
||||
if strings.Contains(d, kw) { return "home" }
|
||||
}
|
||||
|
||||
return "misc"
|
||||
}
|
||||
|
||||
// classifyBatch classifies all domains in one LLM call.
|
||||
// Returns map[domain]scope. Domains that fail get "misc".
|
||||
func classifyBatch(domains []string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// Handle local IPs without LLM
|
||||
var toLLM []string
|
||||
for _, d := range domains {
|
||||
if strings.HasPrefix(d, "192.168.") || strings.HasPrefix(d, "10.") ||
|
||||
strings.HasPrefix(d, "172.") || d == "localhost" || d == "127.0.0.1" {
|
||||
result[d] = "home"
|
||||
} else {
|
||||
toLLM = append(toLLM, d)
|
||||
}
|
||||
}
|
||||
if len(toLLM) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// Build domain list for prompt
|
||||
domainList := strings.Join(toLLM, "\n")
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
`Classify each domain into exactly ONE category. Reply with ONLY lines in format: domain=category
|
||||
No explanations. One line per domain. Pick the single MOST specific category.
|
||||
|
||||
Categories: finance, social, shopping, work, dev, email, media, health, travel, home, education, government
|
||||
|
||||
Domains:
|
||||
%s`, domainList)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": "You classify internet domains. Reply ONLY with lines in format: domain=category. No explanations, no reasoning, no markdown."},
|
||||
{"role": "user", "content": prompt},
|
||||
},
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0,
|
||||
})
|
||||
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
if apiKey == "" {
|
||||
log.Printf("classifyBatch: OPENROUTER_API_KEY not set")
|
||||
return result
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("classifyBatch: LLM call error: %v", err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var llmResult struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &llmResult); err != nil || len(llmResult.Choices) == 0 {
|
||||
snippet := string(body)
|
||||
if len(snippet) > 300 { snippet = snippet[:300] }
|
||||
log.Printf("classifyBatch: LLM error (status=%d): %s", resp.StatusCode, snippet)
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse response lines: "domain=scope1,scope2"
|
||||
content := llmResult.Choices[0].Message.Content
|
||||
log.Printf("classifyBatch: raw LLM response (%d chars): %s", len(content), content)
|
||||
// Strip thinking tags
|
||||
if idx := strings.Index(content, "</think>"); idx >= 0 {
|
||||
content = content[idx+8:]
|
||||
}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// Try multiple separators: =, :, -
|
||||
var domain, scopes string
|
||||
for _, sep := range []string{"=", ": ", " - "} {
|
||||
parts := strings.SplitN(line, sep, 2)
|
||||
if len(parts) == 2 {
|
||||
domain = strings.TrimSpace(parts[0])
|
||||
scopes = strings.TrimSpace(parts[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if domain == "" || scopes == "" {
|
||||
continue
|
||||
}
|
||||
// Strip backticks, quotes, markdown
|
||||
domain = strings.Trim(domain, "`\"'*- ")
|
||||
scopes = strings.Trim(scopes, "`\"'*- ")
|
||||
// Validate each scope
|
||||
var valid []string
|
||||
for _, s := range strings.Split(scopes, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if validScopes[s] {
|
||||
valid = append(valid, s)
|
||||
}
|
||||
}
|
||||
if len(valid) > 0 {
|
||||
result[domain] = strings.Join(valid, ",")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("classifyBatch: classified %d/%d domains", len(result), len(domains))
|
||||
return result
|
||||
}
|
||||
|
||||
var validScopes = map[string]bool{
|
||||
"finance": true, "social": true, "shopping": true, "work": true,
|
||||
"dev": true, "email": true, "media": true, "health": true,
|
||||
"travel": true, "home": true, "education": true, "government": true,
|
||||
"misc": true,
|
||||
}
|
||||
|
||||
func classifyViaLLM(domain string) string {
|
||||
prompt := fmt.Sprintf(
|
||||
`Classify this internet domain into one or more categories. Reply with ONLY the category names, comma-separated, nothing else. Pick the most specific ones. Use "misc" only if nothing else fits.
|
||||
|
||||
Categories: finance, social, shopping, work, dev, email, media, health, travel, home, education, government
|
||||
|
||||
Domain: %s`, domain)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
},
|
||||
"max_tokens": 200,
|
||||
"temperature": 0,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
log.Printf("classify LLM req error: %v", err)
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
if apiKey == "" {
|
||||
log.Printf("classify: OPENROUTER_API_KEY not set")
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("classify LLM call error: %v", err)
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil || len(result.Choices) == 0 {
|
||||
snippet := string(body)
|
||||
if len(snippet) > 500 { snippet = snippet[:500] }
|
||||
log.Printf("classify LLM error (status=%d): %s", resp.StatusCode, snippet)
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := strings.ToLower(strings.TrimSpace(result.Choices[0].Message.Content))
|
||||
// Strip thinking tags
|
||||
if idx := strings.Index(raw, "</think>"); idx >= 0 {
|
||||
raw = strings.TrimSpace(raw[idx+8:])
|
||||
}
|
||||
// Validate each scope
|
||||
var valid []string
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if validScopes[s] {
|
||||
valid = append(valid, s)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
log.Printf("classify: %s → invalid response %q, defaulting to misc", domain, raw)
|
||||
return "misc"
|
||||
}
|
||||
scope := strings.Join(valid, ",")
|
||||
log.Printf("classify: %s → %s", domain, scope)
|
||||
return scope
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := os.Stat("templates"); err == nil {
|
||||
devMode = true
|
||||
|
|
@ -353,13 +447,29 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Classify uncached domains using built-in rules
|
||||
for _, d := range uncached {
|
||||
scope := classifyDomain(d)
|
||||
result[d] = scope
|
||||
// Cache it
|
||||
db.Exec("INSERT OR REPLACE INTO domain_scopes (domain, scope, created_at) VALUES (?, ?, ?)",
|
||||
d, scope, time.Now().Unix())
|
||||
// Classify uncached domains in chunks (max 200 per LLM call to stay within output token limits)
|
||||
if len(uncached) > 0 {
|
||||
chunkSize := 200
|
||||
for i := 0; i < len(uncached); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(uncached) {
|
||||
end = len(uncached)
|
||||
}
|
||||
chunk := uncached[i:end]
|
||||
batch := classifyBatch(chunk)
|
||||
for _, d := range chunk {
|
||||
scope := batch[d]
|
||||
if scope == "" || scope == "misc" {
|
||||
result[d] = "misc"
|
||||
} else {
|
||||
result[d] = scope
|
||||
db.Exec("INSERT OR REPLACE INTO domain_scopes (domain, scope, created_at) VALUES (?, ?, ?)",
|
||||
d, scope, time.Now().Unix())
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("classify: %d uncached domains in %d chunks, %d cached total",
|
||||
len(uncached), (len(uncached)+chunkSize-1)/chunkSize, len(result))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -451,6 +561,9 @@ func main() {
|
|||
http.HandleFunc("/upgrade", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "upgrade", Title: "Upgrade to Clavitor — clavitor", ActiveNav: "upgrade"})
|
||||
})
|
||||
http.HandleFunc("/developers", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "developers", Title: "Developers — clavitor", ActiveNav: "developers"})
|
||||
})
|
||||
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"})
|
||||
})
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,106 +0,0 @@
|
|||
# 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`
|
||||
- SQLite DB: `clavitor.db` (pops, telemetry, uptime, incidents, accounts, vaults, sessions)
|
||||
- Dev mode: auto-detected when `templates/` dir exists on disk — reloads templates per request, but CSS/SVGs require rebuild (`go:embed`)
|
||||
- Port 8099
|
||||
- License: Elastic License 2.0 (NOT MIT)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Dev (forge = 192.168.1.16, Florida — dev.clavitor.ai)
|
||||
```
|
||||
make dev # build + restart locally
|
||||
make deploy-dev # same thing
|
||||
```
|
||||
Dev runs on forge (localhost). `dev.clavitor.ai` DNS points to home IP.
|
||||
|
||||
### Prod (Zürich — clavitor.ai — clavitor.ai)
|
||||
```
|
||||
make deploy-prod # cross-compile amd64, scp to Zürich, restart systemd
|
||||
```
|
||||
Prod runs at `/opt/clavitor-web/` as systemd service `clavitor-web`.
|
||||
Caddy reverse proxies `clavitor.ai`, `clavitor.com`, `www.clavitor.ai`, `www.clavitor.com` → `localhost:8099`.
|
||||
|
||||
### First-time setup (already done)
|
||||
```
|
||||
make setup-prod # creates /opt/clavitor-web, systemd service, uploads binary+db
|
||||
```
|
||||
Then manually update `/etc/caddy/Caddyfile` to reverse_proxy.
|
||||
|
||||
### SSH
|
||||
- Prod: `ssh root@clavitor.ai`
|
||||
- Tailscale: `zurich` (100.70.148.118) — SSH may be blocked via Tailscale
|
||||
|
||||
## Build & Run
|
||||
```
|
||||
CGO_ENABLED=1 go build -o clavitor-web .
|
||||
./clavitor-web
|
||||
```
|
||||
CSS and SVG changes require rebuild (embedded at compile time). Template changes reload in dev mode.
|
||||
|
||||
## Brand & Design
|
||||
- Light mode only. Single source of truth: `clavitor.css`
|
||||
- Logo: the black square (`#0A0A0A`). favicon.svg = black square
|
||||
- Colors: black `#0A0A0A` (brand), red `#DC2626` (accent), light red `#F5B7B7` (planned/secondary), grayscale
|
||||
- No purple. No green (except inherited SVG diagrams). Red is the only accent.
|
||||
- Square shapes for permanent UI elements. Circles only for transient animations (pulses, "You" dot)
|
||||
- Fonts: Figtree (body), JetBrains Mono (code/monospace)
|
||||
- No inline styles, no CSS in templates. Everything in clavitor.css.
|
||||
- Always capitalize "Clavitor" in prose. Lowercase in code/paths/commands.
|
||||
|
||||
## Encryption Terminology
|
||||
- **Vault Encryption** — whole vault at rest
|
||||
- **Credential Encryption** — per-field, server-side (AI agents can read via CLI)
|
||||
- **Identity Encryption** — per-field, client-side via WebAuthn PRF (Touch ID only, server cannot decrypt)
|
||||
- Never use "sealed fields", "agent fields", "L1", "L2", "L3"
|
||||
- Agents use CLI, NOT MCP (MCP exposes plaintext; CLI is scoped)
|
||||
|
||||
## POPs (Points of Presence)
|
||||
- Stored in `pops` table in clavitor.db — the single source of truth
|
||||
- Map on /hosted is generated dynamically from DB via JavaScript
|
||||
- Zürich = HQ, black dot, larger (11×11). Live POPs = red. Planned = light red.
|
||||
- "You" visitor dot = circle (not square — "you" is not clavitor)
|
||||
|
||||
## Key URLs
|
||||
- `/hosted` — hosted product page with dynamic world map
|
||||
- `/glass` — looking glass (latency from user's browser)
|
||||
- `/noc?pin=250365` — NOC dashboard (telemetry, read-only, hardcoded PIN)
|
||||
- `/telemetry` — POST endpoint for POP agent heartbeats (no auth)
|
||||
- `/ping` — server-side TCP ping (for diagnostics)
|
||||
|
||||
## Vault Binary
|
||||
- Source: `~/dev/clavitor/clovis/clovis-vault/`
|
||||
- Build for ARM64: `cd ~/dev/clavitor/clovis/clovis-vault && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o clavitor-linux-arm64 ./cmd/clavitor`
|
||||
- All POPs are ARM64 (AWS t4g.micro)
|
||||
- Vault runs on port 1984 with TLS
|
||||
- Has `/ping` endpoint (11 bytes, no DB, CORS via middleware) for looking glass
|
||||
- Has `/health` endpoint (heavier, queries DB)
|
||||
|
||||
## Providers
|
||||
- AWS: most POPs (free tier t4g.micro)
|
||||
- LightNode: Santiago, Bogotá, Manila, Dhaka
|
||||
- ishosting: Istanbul, Almaty
|
||||
- HostAfrica: Lagos, Nairobi
|
||||
- Rackmill: Perth
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
BINARY = clavitor-web
|
||||
PROD_HOST = root@clavitor.ai
|
||||
PROD_DIR = /opt/clavitor-web
|
||||
PORT = 8099
|
||||
|
||||
.PHONY: build dev deploy-dev deploy-prod setup-prod
|
||||
|
||||
# Build for local (dev on forge/localhost)
|
||||
build:
|
||||
CGO_ENABLED=1 go build -o $(BINARY) .
|
||||
|
||||
# Run locally (dev mode — templates reload from disk)
|
||||
dev: build
|
||||
pkill -f ./$(BINARY) 2>/dev/null || true
|
||||
sleep 0.5
|
||||
./$(BINARY) &
|
||||
@echo "→ dev.clavitor.ai / http://localhost:$(PORT)"
|
||||
|
||||
# Deploy to dev (forge = localhost, dev.clavitor.ai points here)
|
||||
deploy-dev: build
|
||||
pkill -f ./$(BINARY) 2>/dev/null || true
|
||||
sleep 0.5
|
||||
./$(BINARY) &
|
||||
@echo "✓ dev deployed → dev.clavitor.ai / http://localhost:$(PORT)"
|
||||
|
||||
# Build for prod (linux/amd64 for Zürich)
|
||||
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
|
||||
@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:
|
||||
scp $(PROD_HOST):$(PROD_DIR)/clavitor.db backups/clavitor-$(shell date +%Y%m%d-%H%M%S).db
|
||||
@echo "✓ prod DB backed up"
|
||||
|
||||
# One-time: push local DB to prod (DESTRUCTIVE — overwrites prod data)
|
||||
push-db:
|
||||
@echo "⚠ This overwrites the prod database. Press Ctrl+C to cancel."
|
||||
@sleep 3
|
||||
scp clavitor.db $(PROD_HOST):$(PROD_DIR)/
|
||||
ssh $(PROD_HOST) "systemctl restart clavitor-web"
|
||||
@echo "✓ DB pushed to prod"
|
||||
|
||||
# First-time prod setup (already done)
|
||||
setup-prod:
|
||||
ssh $(PROD_HOST) "mkdir -p $(PROD_DIR)"
|
||||
scp $(BINARY)-linux-amd64 clavitor.db $(PROD_HOST):$(PROD_DIR)/
|
||||
ssh $(PROD_HOST) "mv $(PROD_DIR)/$(BINARY)-linux-amd64 $(PROD_DIR)/$(BINARY)"
|
||||
ssh $(PROD_HOST) 'cat > /etc/systemd/system/clavitor-web.service << EOF\n\
|
||||
[Unit]\n\
|
||||
Description=clavitor-web\n\
|
||||
After=network.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
WorkingDirectory=$(PROD_DIR)\n\
|
||||
ExecStart=$(PROD_DIR)/$(BINARY)\n\
|
||||
Restart=always\n\
|
||||
RestartSec=5\n\
|
||||
Environment=PORT=$(PORT)\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n\
|
||||
EOF'
|
||||
ssh $(PROD_HOST) "systemctl daemon-reload && systemctl enable --now clavitor-web"
|
||||
@echo "✓ prod setup complete"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
|
||||
## Styleguide
|
||||
|
||||
**clavitor.css** is the single global stylesheet for all clavitor web surfaces — marketing site (`clavitor-web`) and the app UI (`clavitor`).
|
||||
|
||||
- Live: https://clavitor.com/styleguide.html
|
||||
- Source: `clavitor-web/clavitor.css`
|
||||
|
||||
### Rules (no exceptions)
|
||||
1. **One stylesheet.** `clavitor.css` only. No Tailwind, no inline styles, no `<style>` blocks.
|
||||
2. **One rule per class.** If you need a variant, add a modifier class (e.g. `.card.gold`), not a new inline style.
|
||||
3. **One width.** `--width: 1280px` via `.container`. Never hardcode a max-width anywhere else.
|
||||
4. **One padding.** `--pad: 2rem` via `.container`. Never hardcode horizontal padding.
|
||||
5. **CSS variables for everything.** Colors, spacing, radius, fonts — all via `var(--*)`.
|
||||
|
||||
### To use in clavitor app
|
||||
Copy or symlink `clavitor.css` into `clavitor/web/` and embed it. The token set (colors, fonts, radius) is shared — app UI should feel identical to the marketing site.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 408 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="80" height="80" role="img" aria-label="Clavitor">
|
||||
<title>Clavitor</title>
|
||||
<rect x="5" y="5" width="90" height="90" fill="#0A0A0A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 212 B |
Binary file not shown.
Binary file not shown.
|
|
@ -1,355 +0,0 @@
|
|||
/* ============================================================
|
||||
CLAVITOR — clavitor.com stylesheet
|
||||
Single source of truth. Design system tokens + website components.
|
||||
============================================================ */
|
||||
|
||||
/* === TOKENS (from design-system/styleguide.css) === */
|
||||
:root {
|
||||
/* Brand */
|
||||
--brand-black: #0A0A0A;
|
||||
--brand-red: #DC2626;
|
||||
--brand-red-light: #EF4444;
|
||||
--brand-red-dark: #B91C1C;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg: #FFFFFF;
|
||||
--bg-secondary: #F5F5F5;
|
||||
--bg-tertiary: #E5E5E5;
|
||||
--bg-inverse: #0A0A0A;
|
||||
|
||||
/* Text */
|
||||
--text: #171717;
|
||||
--text-secondary: #525252;
|
||||
--text-tertiary: #737373;
|
||||
--text-inverse: #FFFFFF;
|
||||
|
||||
/* Borders */
|
||||
--border: #E5E5E5;
|
||||
--border-strong: #D4D4D4;
|
||||
|
||||
/* Semantic */
|
||||
--success: #16A34A;
|
||||
--warning: #CA8A04;
|
||||
--gold: #D4AF37;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: "Figtree", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* Layout */
|
||||
--width: 1280px;
|
||||
--pad: 24px;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--gap: 24px;
|
||||
}
|
||||
|
||||
/* === RESET === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; scrollbar-gutter: stable; }
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 16px; line-height: 1.6; -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
|
||||
/* === LAYOUT === */
|
||||
.container { max-width: var(--width); margin: 0 auto; padding: 0 var(--pad); }
|
||||
.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); 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); }
|
||||
h2 { font-size: clamp(1.5rem, 3vw, 2.25rem); font-weight: 600; line-height: 1.2; letter-spacing: -0.022em; color: var(--text); }
|
||||
h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; color: var(--text); }
|
||||
p { color: var(--text-secondary); font-size: 1rem; line-height: 1.75; }
|
||||
p.lead { font-size: 1.125rem; }
|
||||
pre, code { font-family: var(--font-mono); }
|
||||
code { font-size: 0.875em; }
|
||||
|
||||
/* === LABELS === */
|
||||
.label { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-tertiary); }
|
||||
.label.accent { color: var(--brand-black); }
|
||||
.label.gold { color: var(--gold); }
|
||||
.label.red { color: var(--brand-red); }
|
||||
|
||||
/* === EMPHASIS TEXT === */
|
||||
.gradient-text { color: var(--brand-black); font-weight: 800; }
|
||||
|
||||
/* === BRAND / VAULTNAME === */
|
||||
.vaultname { font-family: var(--font-mono); font-weight: 700; color: var(--text); }
|
||||
|
||||
/* === LOGO LOCKUP === */
|
||||
.logo-lockup { display: inline-flex; gap: 20px; align-items: stretch; }
|
||||
.logo-lockup-square { width: 80px; height: 80px; background: var(--brand-black); flex-shrink: 0; }
|
||||
.logo-lockup-text { display: flex; flex-direction: column; justify-content: space-between; height: 80px; }
|
||||
.logo-lockup-wordmark { font-family: var(--font-sans); font-size: 56px; font-weight: 700; letter-spacing: 0.25em; text-transform: uppercase; color: var(--brand-black); line-height: 1; }
|
||||
.logo-lockup-tagline { font-size: 16px; font-weight: 500; color: var(--text-tertiary); letter-spacing: 0.22em; text-transform: uppercase; line-height: 1; margin-bottom: -2px; }
|
||||
.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.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; }
|
||||
.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; }
|
||||
.btn-primary { background: var(--brand-black); color: #ffffff; border-color: var(--brand-black); }
|
||||
.btn-primary:hover { background: #262626; }
|
||||
.btn-accent { background: var(--brand-red); color: #ffffff; border-color: var(--brand-red); }
|
||||
.btn-accent:hover { background: var(--brand-red-light); }
|
||||
.btn-ghost { background: transparent; color: var(--text); border-color: var(--border-strong); }
|
||||
.btn-ghost:hover { background: var(--bg-secondary); }
|
||||
.btn-block { display: flex; width: 100%; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.btn-sm { height: 32px; padding: 0 12px; font-size: 14px; }
|
||||
.btn-lg { height: 48px; padding: 0 20px; }
|
||||
.btn-mono { font-family: var(--font-mono); font-size: 0.75rem; }
|
||||
|
||||
/* === HERO === */
|
||||
.hero { padding-top: 100px; padding-bottom: 4rem; text-align: center; }
|
||||
.hero h1 { margin-bottom: 1rem; }
|
||||
.hero p.lead { max-width: 600px; margin-left: auto; margin-right: auto; }
|
||||
.hero-split { padding-top: 100px; padding-bottom: 4rem; display: grid; grid-template-columns: 1fr 1fr; gap: 4rem; align-items: center; }
|
||||
|
||||
/* === CARDS === */
|
||||
.card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.75rem; }
|
||||
.card.alt { background: rgba(10,10,10,0.03); border-color: rgba(10,10,10,0.1); }
|
||||
.card.gold { background: rgba(212,175,55,0.06); border-color: rgba(212,175,55,0.2); }
|
||||
.card.red { background: rgba(220,38,38,0.04); border-color: rgba(220,38,38,0.15); }
|
||||
.card-hover { transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card-hover:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.06); }
|
||||
|
||||
/* === GRIDS === */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap); }
|
||||
.grid-2-equal { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; }
|
||||
.grid-3-equal { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||
.grid-4-equal { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; text-align: center; }
|
||||
|
||||
/* === TABLES === */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); }
|
||||
.data-table th:first-child, .data-table td:first-child { padding-left: 0; }
|
||||
.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* === CODE BLOCKS === */
|
||||
.code-block { background: var(--bg-inverse); border-radius: var(--radius); padding: 1.5rem; font-family: var(--font-mono); font-size: 0.875rem; overflow-x: auto; line-height: 1.7; color: #d1d5db; }
|
||||
.code-block .prompt { color: #fca5a5; }
|
||||
.code-block .comment { color: #737373; }
|
||||
.code-block .highlight { color: #fca5a5; }
|
||||
.code-label { font-size: 0.75rem; color: var(--text-tertiary); margin-bottom: 0.75rem; font-family: var(--font-sans); }
|
||||
.code-block pre { margin: 0; color: #d1d5db; }
|
||||
|
||||
/* === FEATURE ICONS === */
|
||||
.feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0; background: var(--brand-black); display: flex; align-items: center; justify-content: center; margin-bottom: 1.25rem; flex-shrink: 0; }
|
||||
.feature-icon svg { width: 1.25rem; height: 1.25rem; color: #ffffff; stroke: #ffffff; }
|
||||
.feature-icon.red { background: var(--brand-red); border-radius: 0; }
|
||||
.feature-icon.red svg { color: #ffffff; stroke: #ffffff; }
|
||||
|
||||
/* === CHECKLISTS === */
|
||||
.checklist { list-style: none; }
|
||||
.checklist li { display: flex; align-items: flex-start; gap: 0.75rem; font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.75rem; }
|
||||
.checklist li::before { content: ''; width: 0.5rem; height: 0.5rem; flex-shrink: 0; background: var(--brand-black); margin-top: 0.375rem; }
|
||||
.checklist.red li::before { background: var(--brand-red); }
|
||||
|
||||
/* === BADGES === */
|
||||
.badge { display: inline-block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; padding: 0.25rem 0.625rem; border-radius: 9999px; }
|
||||
.badge.accent { background: rgba(10,10,10,0.08); color: var(--brand-black); border: 1px solid rgba(10,10,10,0.15); }
|
||||
.badge.gold { background: rgba(212,175,55,0.1); color: var(--gold); border: 1px solid rgba(212,175,55,0.2); }
|
||||
.badge.red { background: rgba(220,38,38,0.08); color: var(--brand-red); border: 1px solid rgba(220,38,38,0.2); }
|
||||
.badge.recommended { background: var(--brand-black); color: #ffffff; }
|
||||
|
||||
/* === PILLS === */
|
||||
.pill-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.pill { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; white-space: nowrap; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 9999px; font-size: 14px; font-weight: 500; }
|
||||
.pill-accent { background: var(--brand-red); color: white; border-color: var(--brand-red); }
|
||||
|
||||
/* === PRICING === */
|
||||
.price-card { border-radius: var(--radius); border: 1px solid var(--border); padding: 2.5rem; background: var(--bg-secondary); }
|
||||
.price-card.featured { border-color: rgba(10,10,10,0.2); background: rgba(10,10,10,0.02); position: relative; }
|
||||
.price-amount { font-size: 3rem; font-weight: 800; color: var(--text); line-height: 1; }
|
||||
.price-period { font-size: 1rem; color: var(--text-secondary); font-weight: 400; }
|
||||
.price-grid { max-width: 900px; margin: 0 auto; }
|
||||
.price-badge { position: absolute; top: -0.75rem; right: 1.5rem; }
|
||||
|
||||
/* === STATS === */
|
||||
.stat-number { font-size: 72px; font-weight: 700; color: var(--brand-black); line-height: 1; margin-bottom: 8px; }
|
||||
.stat-label { font-size: 14px; color: var(--text-secondary); }
|
||||
|
||||
/* === MAP === */
|
||||
.map-wrap { border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); }
|
||||
.map-wrap svg { display: block; width: 100%; background: var(--bg-secondary); }
|
||||
|
||||
/* === DC GRID (hosted page datacenter cards) === */
|
||||
#dc-grid { display: flex; gap: var(--gap); }
|
||||
#dc-grid .dc-card { flex: 1; min-width: 0; border-radius: var(--radius); padding: 1rem; text-align: center; background: var(--bg-secondary); border: 1px solid var(--border); }
|
||||
#dc-grid .dc-card.gold { background: rgba(212,175,55,0.06); border-color: rgba(212,175,55,0.3); }
|
||||
#dc-grid .dc-card.red { background: rgba(239,68,68,0.04); border-color: rgba(239,68,68,0.15); }
|
||||
#dc-grid .dc-icon { font-size: 1.5rem; margin-bottom: 0.375rem; }
|
||||
#dc-grid .dc-name { font-size: 0.875rem; font-weight: 600; color: var(--text); margin-bottom: 0.25rem; }
|
||||
#dc-grid .dc-sub { font-size: 0.75rem; color: var(--text-tertiary); margin-bottom: 0.625rem; }
|
||||
#dc-grid .dc-status { display: flex; align-items: center; justify-content: center; gap: 0.375rem; font-size: 0.75rem; color: var(--text-tertiary); margin-bottom: 0.75rem; }
|
||||
#dc-grid .dc-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||
|
||||
/* === VISITOR CARD (geo JS) === */
|
||||
.visitor-card { border-radius: var(--radius); padding: 1rem; text-align: center; background: rgba(239,68,68,0.04); border: 1px solid rgba(239,68,68,0.2); }
|
||||
.visitor-flag { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.visitor-label { font-weight: 600; font-size: 0.875rem; color: var(--text); }
|
||||
.visitor-region { font-size: 0.75rem; color: var(--text-tertiary); margin-bottom: 0.5rem; }
|
||||
.visitor-status { display: flex; align-items: center; justify-content: center; gap: 0.375rem; font-size: 0.75rem; color: var(--text-tertiary); }
|
||||
.visitor-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-red); display: inline-block; }
|
||||
|
||||
/* === INSTALL STEPS === */
|
||||
.step { display: flex; gap: 1.25rem; margin-bottom: 3rem; }
|
||||
.step-num { width: 2rem; height: 2rem; border-radius: 0; background: var(--brand-black); color: #ffffff; font-size: 0.875rem; font-weight: 600; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-family: var(--font-mono); }
|
||||
.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; }
|
||||
|
||||
/* === PROSE (legal pages) === */
|
||||
.prose h2 { font-size: 1.375rem; font-weight: 700; color: var(--text); margin: 2.5rem 0 1rem; }
|
||||
.prose h3 { font-size: 1.1rem; font-weight: 600; color: var(--text); margin: 1.75rem 0 0.75rem; }
|
||||
.prose p { color: var(--text-secondary); line-height: 1.8; margin-bottom: 1rem; }
|
||||
.prose ul { color: var(--text-secondary); padding-left: 1.5rem; margin-bottom: 1rem; line-height: 1.8; }
|
||||
.prose a { color: var(--brand-red); }
|
||||
.prose a:hover { text-decoration: underline; }
|
||||
|
||||
/* === FOOTER === */
|
||||
.footer { padding: 1.5rem 0; }
|
||||
.footer-inner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem; }
|
||||
.footer-links { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; color: var(--text-tertiary); }
|
||||
.footer-links a { color: var(--text-tertiary); transition: color 100ms ease; }
|
||||
.footer-links a:hover { color: var(--text-secondary); }
|
||||
.footer-copy { text-align: center; font-size: 0.75rem; color: var(--text-tertiary); margin-top: 0.75rem; }
|
||||
|
||||
/* === UTILITIES: Spacing === */
|
||||
.mt-2 { margin-top: 0.5rem; } .mb-2 { margin-bottom: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; } .mb-3 { margin-bottom: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; } .mb-4 { margin-bottom: 1rem; }
|
||||
.mt-6 { margin-top: 1.5rem; } .mb-6 { margin-bottom: 1.5rem; }
|
||||
.mt-8 { margin-top: 2rem; } .mb-8 { margin-bottom: 2rem; }
|
||||
.mt-12 { margin-top: 3rem; } .mb-12 { margin-bottom: 3rem; }
|
||||
.mt-16 { margin-top: 4rem; } .mb-16 { margin-bottom: 4rem; }
|
||||
|
||||
/* === UTILITIES: Text === */
|
||||
.text-center { text-align: center; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-accent { color: var(--brand-red); }
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
|
||||
/* === UTILITIES: Layout === */
|
||||
.w-full { width: 100%; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* === ANIMATIONS === */
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.3; transform: scale(1.8); } }
|
||||
@keyframes pulseDot { 0%,100% { transform: scale(1); } 50% { transform: scale(1.15); } }
|
||||
@keyframes pulseRing { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
|
||||
.pulse-dot { animation: pulseDot 2s ease-in-out infinite; }
|
||||
.pulse-ring { animation: pulseRing 2s ease-out infinite; }
|
||||
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 768px) {
|
||||
.hero-split { grid-template-columns: 1fr; gap: 2rem; padding-top: 5rem; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.grid-3 { grid-template-columns: 1fr; }
|
||||
.grid-2-equal { grid-template-columns: 1fr; }
|
||||
.grid-3-equal { grid-template-columns: 1fr; }
|
||||
.grid-4-equal { grid-template-columns: repeat(2, 1fr); }
|
||||
#dc-grid { flex-direction: column; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* ---- Looking Glass ---- */
|
||||
.glass-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.glass-pop {
|
||||
background: var(--surface);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.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-status-outage { background: #dc2626; color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.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.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: var(--text); font-weight: 600; }
|
||||
.glass-slow { color: #dc2626; font-weight: 600; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.glass-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.glass-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.glass-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="80" height="80" role="img" aria-label="Clavitor">
|
||||
<title>Clavitor</title>
|
||||
<rect x="5" y="5" width="90" height="90" fill="#0A0A0A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 212 B |
|
|
@ -1,5 +0,0 @@
|
|||
module github.com/clavitor/clavitor-web
|
||||
|
||||
go 1.23.6
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
|
|
@ -1,936 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
var tmplFS embed.FS
|
||||
|
||||
//go:embed *.svg *.css *.png
|
||||
var static embed.FS
|
||||
|
||||
var templates *template.Template
|
||||
var devMode bool
|
||||
var db *sql.DB
|
||||
var processStartTime = time.Now().Unix()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func fmtInt(n int) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var result []byte
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
result = append(result, ',')
|
||||
}
|
||||
result = append(result, byte(c))
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
type Pop struct {
|
||||
PopID int
|
||||
City string
|
||||
Country string
|
||||
Lat float64
|
||||
Lon float64
|
||||
RegionName string
|
||||
IP string
|
||||
DNS string
|
||||
Status string
|
||||
Provider string
|
||||
CountryFull string
|
||||
BackupCity string
|
||||
BackupDistanceKM int
|
||||
BackupDistFmt string
|
||||
BackupDistMiFmt string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Page string
|
||||
Title string
|
||||
Desc string
|
||||
ActiveNav string
|
||||
Pops []Pop
|
||||
PopsByCity []Pop
|
||||
}
|
||||
|
||||
func loadTemplates() {
|
||||
if devMode {
|
||||
templates = template.Must(template.ParseGlob("templates/*.tmpl"))
|
||||
} else {
|
||||
sub, _ := fs.Sub(tmplFS, "templates")
|
||||
templates = template.Must(template.ParseFS(sub, "*.tmpl"))
|
||||
}
|
||||
}
|
||||
|
||||
func loadPops() []Pop {
|
||||
rows, err := db.Query("SELECT pop_id, city, country, lat, lon, region_name, ip, dns, status, provider, backup_city, backup_distance_km FROM pops ORDER BY CASE status WHEN 'live' THEN 0 ELSE 1 END, lon DESC")
|
||||
if err != nil {
|
||||
log.Printf("pops query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var pops []Pop
|
||||
for rows.Next() {
|
||||
var p Pop
|
||||
if err := rows.Scan(&p.PopID, &p.City, &p.Country, &p.Lat, &p.Lon, &p.RegionName, &p.IP, &p.DNS, &p.Status, &p.Provider, &p.BackupCity, &p.BackupDistanceKM); err != nil {
|
||||
log.Printf("pops scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
p.CountryFull = countryName(p.Country)
|
||||
p.BackupDistFmt = fmtInt(p.BackupDistanceKM)
|
||||
p.BackupDistMiFmt = fmtInt(int(float64(p.BackupDistanceKM) * 0.621371))
|
||||
pops = append(pops, p)
|
||||
}
|
||||
return pops
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, data PageData) {
|
||||
if devMode {
|
||||
loadTemplates()
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal error", 500)
|
||||
}
|
||||
}
|
||||
|
||||
func geoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if i := strings.LastIndex(ip, ":"); i >= 0 {
|
||||
ip = ip[:i]
|
||||
}
|
||||
ip = strings.Trim(ip, "[]")
|
||||
|
||||
resp, err := http.Get("https://ipapi.co/" + ip + "/json/")
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"geo failed"}`, 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := os.Stat("templates"); err == nil {
|
||||
devMode = true
|
||||
log.Println("dev mode: templates loaded from disk")
|
||||
}
|
||||
loadTemplates()
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", "clavitor.db")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open clavitor.db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Migrations
|
||||
db.Exec(`ALTER TABLE pops ADD COLUMN backup_city TEXT DEFAULT ''`)
|
||||
db.Exec(`ALTER TABLE pops ADD COLUMN backup_distance_km INTEGER DEFAULT 0`)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8099"
|
||||
}
|
||||
|
||||
http.HandleFunc("/geo", geoHandler)
|
||||
|
||||
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
if host == "" {
|
||||
http.Error(w, `{"error":"missing host"}`, 400)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", host+":1984", 5*time.Second)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"unreachable"}`))
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
ms := time.Since(start).Milliseconds()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"ms":%d}`, ms)
|
||||
})
|
||||
|
||||
http.HandleFunc("/hosted", func(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Page: "hosted", Title: "clavitor — Hosted", ActiveNav: "hosted"}
|
||||
data.Pops = loadPops()
|
||||
sorted := make([]Pop, len(data.Pops))
|
||||
copy(sorted, data.Pops)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].City < sorted[j].City })
|
||||
data.PopsByCity = sorted
|
||||
render(w, data)
|
||||
})
|
||||
http.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
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"})
|
||||
})
|
||||
http.HandleFunc("/terms", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "terms", Title: "Terms of Service — clavitor"})
|
||||
})
|
||||
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" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"invalid email"}`))
|
||||
return
|
||||
}
|
||||
smtpUser := os.Getenv("SMTP_USER")
|
||||
smtpPass := os.Getenv("SMTP_PASS")
|
||||
if smtpUser == "" || smtpPass == "" {
|
||||
log.Printf("notify: SMTP not configured, email from %s", req.Email)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: johan@clavitor.ai\r\nSubject: Clavitor signup interest: %s\r\n\r\n%s wants to be notified when signups open.\r\n", smtpUser, req.Email, req.Email)
|
||||
auth := smtp.PlainAuth("", smtpUser, smtpPass, "smtp.protonmail.ch")
|
||||
if err := smtp.SendMail("smtp.protonmail.ch:587", auth, smtpUser, []string{"johan@clavitor.ai"}, []byte(msg)); err != nil {
|
||||
log.Printf("notify: smtp error: %v", err)
|
||||
} else {
|
||||
log.Printf("notify: sent for %s", req.Email)
|
||||
}
|
||||
}()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
|
||||
http.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "signup", Title: "Sign up — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/styleguide", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "styleguide", Title: "clavitor — Styleguide"})
|
||||
})
|
||||
// NOC telemetry ingest — agents POST here
|
||||
// Accepts both flat format (node_id, cpu_percent, ...) and nested vault format
|
||||
// (hostname, system.cpu_percent, vaults.count, ...)
|
||||
http.HandleFunc("/telemetry", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
var t struct {
|
||||
// Flat fields (legacy/direct)
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemTotalMB int64 `json:"memory_total_mb"`
|
||||
MemUsedMB int64 `json:"memory_used_mb"`
|
||||
DiskTotalMB int64 `json:"disk_total_mb"`
|
||||
DiskUsedMB int64 `json:"disk_used_mb"`
|
||||
Load1m float64 `json:"load_1m"`
|
||||
VaultCount int `json:"vault_count"`
|
||||
VaultSizeMB float64 `json:"vault_size_mb"`
|
||||
VaultEntries int `json:"vault_entries"`
|
||||
Mode string `json:"mode"`
|
||||
// Nested fields (clovis-vault TelemetryPayload)
|
||||
System struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
CPUs int `json:"cpus"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemTotalMB int64 `json:"memory_total_mb"`
|
||||
MemUsedMB int64 `json:"memory_used_mb"`
|
||||
DiskTotalMB int64 `json:"disk_total_mb"`
|
||||
DiskUsedMB int64 `json:"disk_used_mb"`
|
||||
Load1m float64 `json:"load_1m"`
|
||||
} `json:"system"`
|
||||
Vaults struct {
|
||||
Count int `json:"count"`
|
||||
TotalSizeMB int64 `json:"total_size_mb"`
|
||||
TotalEntries int64 `json:"total_entries"`
|
||||
} `json:"vaults"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
http.Error(w, `{"error":"bad payload"}`, 400)
|
||||
return
|
||||
}
|
||||
// Use hostname as node_id if node_id not provided
|
||||
if t.NodeID == "" {
|
||||
t.NodeID = t.Hostname
|
||||
}
|
||||
if t.NodeID == "" {
|
||||
http.Error(w, `{"error":"missing node_id or hostname"}`, 400)
|
||||
return
|
||||
}
|
||||
// Merge nested fields into flat fields if flat is zero
|
||||
if t.CPUPercent == 0 && t.System.CPUPercent != 0 {
|
||||
t.CPUPercent = t.System.CPUPercent
|
||||
}
|
||||
if t.MemTotalMB == 0 { t.MemTotalMB = t.System.MemTotalMB }
|
||||
if t.MemUsedMB == 0 { t.MemUsedMB = t.System.MemUsedMB }
|
||||
if t.DiskTotalMB == 0 { t.DiskTotalMB = t.System.DiskTotalMB }
|
||||
if t.DiskUsedMB == 0 { t.DiskUsedMB = t.System.DiskUsedMB }
|
||||
if t.Load1m == 0 { t.Load1m = t.System.Load1m }
|
||||
if t.VaultCount == 0 { t.VaultCount = int(t.Vaults.Count) }
|
||||
if t.VaultSizeMB == 0 { t.VaultSizeMB = float64(t.Vaults.TotalSizeMB) }
|
||||
if t.VaultEntries == 0 { t.VaultEntries = int(t.Vaults.TotalEntries) }
|
||||
|
||||
db.Exec(`INSERT INTO telemetry (node_id, version, hostname, uptime_seconds, cpu_percent, memory_total_mb, memory_used_mb, disk_total_mb, disk_used_mb, load_1m, vault_count, vault_size_mb, vault_entries, mode) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
|
||||
|
||||
// Uptime span tracking: extend existing span or create new one
|
||||
// Skip span updates if we just restarted — we can't judge gaps yet
|
||||
now := time.Now().Unix()
|
||||
if (now - processStartTime) >= 60 {
|
||||
var inMaint bool
|
||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
|
||||
var spanID int64
|
||||
var spanEnd int64
|
||||
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
|
||||
if err == nil && (inMaint || (now-spanEnd) <= 60) {
|
||||
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
|
||||
} else if !inMaint {
|
||||
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy daily uptime (kept for backwards compat)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
db.Exec(`INSERT OR REPLACE INTO uptime (node_id, date, status) VALUES (?, ?, 'operational')`, t.NodeID, today)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
|
||||
// NOC API — latest telemetry per node
|
||||
nocPin := func(r *http.Request) bool { return r.URL.Query().Get("pin") == "250365" }
|
||||
|
||||
http.HandleFunc("/noc/api/telemetry", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !nocPin(r) { http.NotFound(w, r); return }
|
||||
rows, err := db.Query(`SELECT t.node_id, t.received_at, t.version, t.hostname, t.uptime_seconds, t.cpu_percent, t.memory_total_mb, t.memory_used_mb, t.disk_total_mb, t.disk_used_mb, t.load_1m, t.vault_count, t.vault_size_mb, t.vault_entries, t.mode FROM telemetry t INNER JOIN (SELECT node_id, MAX(id) as max_id FROM telemetry GROUP BY node_id) latest ON t.id = latest.max_id`)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"query failed"}`, 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type Tel struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ReceivedAt int64 `json:"received_at"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
UptimeSec int64 `json:"uptime_seconds"`
|
||||
CPU float64 `json:"cpu_percent"`
|
||||
MemTotal int64 `json:"memory_total_mb"`
|
||||
MemUsed int64 `json:"memory_used_mb"`
|
||||
DiskTotal int64 `json:"disk_total_mb"`
|
||||
DiskUsed int64 `json:"disk_used_mb"`
|
||||
Load1m float64 `json:"load_1m"`
|
||||
VaultCount int `json:"vault_count"`
|
||||
VaultSizeMB float64 `json:"vault_size_mb"`
|
||||
VaultEntries int `json:"vault_entries"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
var list []Tel
|
||||
for rows.Next() {
|
||||
var t Tel
|
||||
rows.Scan(&t.NodeID, &t.ReceivedAt, &t.Version, &t.Hostname, &t.UptimeSec, &t.CPU, &t.MemTotal, &t.MemUsed, &t.DiskTotal, &t.DiskUsed, &t.Load1m, &t.VaultCount, &t.VaultSizeMB, &t.VaultEntries, &t.Mode)
|
||||
list = append(list, t)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"telemetry": list})
|
||||
})
|
||||
|
||||
http.HandleFunc("/noc/api/nodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !nocPin(r) { http.NotFound(w, r); return }
|
||||
pops := loadPops()
|
||||
type N struct {
|
||||
ID string `json:"ID"`
|
||||
City string `json:"City"`
|
||||
Country string `json:"Country"`
|
||||
Status string `json:"Status"`
|
||||
}
|
||||
var nodes []N
|
||||
for _, p := range pops {
|
||||
id := p.DNS
|
||||
if idx := strings.Index(id, "."); idx > 0 {
|
||||
id = id[:idx] // "use1.clavitor.ai" -> "use1"
|
||||
}
|
||||
if id == "" {
|
||||
id = p.City
|
||||
}
|
||||
nodes = append(nodes, N{ID: id, City: p.City, Country: countryName(p.Country), Status: p.Status})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
|
||||
})
|
||||
|
||||
http.HandleFunc("/noc/api/telemetry/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !nocPin(r) { http.NotFound(w, r); return }
|
||||
node := r.URL.Query().Get("node")
|
||||
limit := r.URL.Query().Get("limit")
|
||||
if limit == "" { limit = "60" }
|
||||
rows, err := db.Query(`SELECT received_at, cpu_percent, memory_used_mb, memory_total_mb FROM telemetry WHERE node_id = ? ORDER BY id DESC LIMIT ?`, node, limit)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"query failed"}`, 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type H struct {
|
||||
TS int64 `json:"ts"`
|
||||
CPU float64 `json:"cpu"`
|
||||
MemUsed int64 `json:"mem_used_mb"`
|
||||
MemTotal int64 `json:"mem_total_mb"`
|
||||
}
|
||||
var hist []H
|
||||
for rows.Next() {
|
||||
var h H
|
||||
rows.Scan(&h.TS, &h.CPU, &h.MemUsed, &h.MemTotal)
|
||||
hist = append(hist, h)
|
||||
}
|
||||
// Reverse so oldest first
|
||||
for i, j := 0, len(hist)-1; i < j; i, j = i+1, j-1 {
|
||||
hist[i], hist[j] = hist[j], hist[i]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"history": hist})
|
||||
})
|
||||
|
||||
// NOC dashboard — hardcoded PIN, read-only, not a security boundary
|
||||
http.HandleFunc("/noc", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("pin") != "250365" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := PageData{Page: "noc", Title: "NOC — clavitor"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
|
||||
// --- Uptime rollup helper ---
|
||||
// Calculates uptime % for a node on a given date from spans + maintenance windows.
|
||||
// Caches result in uptime_daily. Only recalculates if date is today (ongoing) or not cached.
|
||||
rollupDay := func(nodeID, date string) float64 {
|
||||
// Check cache (skip today — always recalculate)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if date != today {
|
||||
var cached float64
|
||||
if db.QueryRow(`SELECT uptime_pct FROM uptime_daily WHERE node_id = ? AND date = ?`, nodeID, date).Scan(&cached) == nil {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
// Parse day boundaries
|
||||
dayStart, _ := time.Parse("2006-01-02", date)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
dsUnix := dayStart.Unix()
|
||||
deUnix := dayEnd.Unix()
|
||||
|
||||
// If day is in the future, return -1 (no data)
|
||||
if dsUnix > time.Now().Unix() {
|
||||
return -1
|
||||
}
|
||||
// Cap end to now if today
|
||||
if deUnix > time.Now().Unix() {
|
||||
deUnix = time.Now().Unix()
|
||||
}
|
||||
totalSeconds := deUnix - dsUnix
|
||||
if totalSeconds <= 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Find when this node first came online (first span ever)
|
||||
var firstEver int64
|
||||
db.QueryRow(`SELECT MIN(start_at) FROM uptime_spans WHERE node_id = ?`, nodeID).Scan(&firstEver)
|
||||
|
||||
// If the node didn't exist yet on this day, no data
|
||||
if firstEver == 0 || firstEver >= deUnix {
|
||||
return -1
|
||||
}
|
||||
|
||||
// If the node came online partway through this day, start counting from then
|
||||
if firstEver > dsUnix {
|
||||
dsUnix = firstEver
|
||||
totalSeconds = deUnix - dsUnix
|
||||
if totalSeconds <= 0 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// Sum span overlap with this day
|
||||
var upSeconds int64
|
||||
var hasSpans bool
|
||||
var lastSpanEnd int64
|
||||
if rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`,
|
||||
nodeID, dsUnix, deUnix); err == nil {
|
||||
for rows.Next() {
|
||||
hasSpans = true
|
||||
var s, e int64
|
||||
rows.Scan(&s, &e)
|
||||
if s < dsUnix { s = dsUnix }
|
||||
if e > deUnix { e = deUnix }
|
||||
if e > s { upSeconds += e - s }
|
||||
lastSpanEnd = e
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
// If the trailing gap to now is within heartbeat interval, count it as up
|
||||
if lastSpanEnd > 0 && (deUnix-lastSpanEnd) <= 60 {
|
||||
upSeconds += deUnix - lastSpanEnd
|
||||
}
|
||||
|
||||
// No spans at all for this day = no data (node didn't exist yet)
|
||||
if !hasSpans {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Subtract maintenance windows from denominator
|
||||
var maintSeconds int64
|
||||
if mRows, err := db.Query(`SELECT start_at, COALESCE(end_at, ?) FROM maintenance WHERE end_at IS NULL OR (end_at >= ? AND start_at <= ?)`,
|
||||
deUnix, dsUnix, deUnix); err == nil {
|
||||
for mRows.Next() {
|
||||
var s, e int64
|
||||
mRows.Scan(&s, &e)
|
||||
if s < dsUnix { s = dsUnix }
|
||||
if e > deUnix { e = deUnix }
|
||||
if e > s { maintSeconds += e - s }
|
||||
}
|
||||
mRows.Close()
|
||||
}
|
||||
|
||||
effectiveTotal := totalSeconds - maintSeconds
|
||||
if effectiveTotal <= 0 {
|
||||
effectiveTotal = 1
|
||||
upSeconds = 1
|
||||
}
|
||||
|
||||
pct := float64(upSeconds) / float64(effectiveTotal) * 100
|
||||
if pct > 100 { pct = 100 }
|
||||
|
||||
// Cache (don't cache today since it changes)
|
||||
if date != today {
|
||||
db.Exec(`INSERT OR REPLACE INTO uptime_daily (node_id, date, up_seconds, total_seconds, uptime_pct) VALUES (?,?,?,?,?)`,
|
||||
nodeID, date, upSeconds, effectiveTotal, pct)
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// --- Maintenance API ---
|
||||
http.HandleFunc("/noc/api/maintenance", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !nocPin(r) { http.NotFound(w, r); return }
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
var req struct {
|
||||
Action string `json:"action"` // "start" or "stop"
|
||||
Reason string `json:"reason"`
|
||||
By string `json:"by"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, 400)
|
||||
return
|
||||
}
|
||||
if req.Action == "start" {
|
||||
db.Exec(`INSERT INTO maintenance (reason, started_by) VALUES (?, ?)`, req.Reason, req.By)
|
||||
w.Write([]byte(`{"ok":true,"status":"maintenance started"}`))
|
||||
} else if req.Action == "stop" {
|
||||
now := time.Now().Unix()
|
||||
db.Exec(`UPDATE maintenance SET end_at = ?, ended_by = ? WHERE end_at IS NULL`, now, req.By)
|
||||
w.Write([]byte(`{"ok":true,"status":"maintenance ended"}`))
|
||||
} else {
|
||||
http.Error(w, `{"error":"action must be start or stop"}`, 400)
|
||||
}
|
||||
case "GET":
|
||||
rows, _ := db.Query(`SELECT id, start_at, end_at, reason, started_by, ended_by FROM maintenance ORDER BY id DESC LIMIT 20`)
|
||||
type M struct {
|
||||
ID int `json:"id"`
|
||||
StartAt int64 `json:"start_at"`
|
||||
EndAt *int64 `json:"end_at"`
|
||||
Reason string `json:"reason"`
|
||||
StartBy string `json:"started_by"`
|
||||
EndBy string `json:"ended_by"`
|
||||
}
|
||||
var list []M
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m M
|
||||
rows.Scan(&m.ID, &m.StartAt, &m.EndAt, &m.Reason, &m.StartBy, &m.EndBy)
|
||||
list = append(list, m)
|
||||
}
|
||||
}
|
||||
// Check if currently in maintenance
|
||||
var active bool
|
||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&active)
|
||||
json.NewEncoder(w).Encode(map[string]any{"active": active, "windows": list})
|
||||
}
|
||||
})
|
||||
|
||||
// Public status page
|
||||
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Page: "status", Title: "Status — clavitor", ActiveNav: "status"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
|
||||
// Status API — public, no PIN needed
|
||||
http.HandleFunc("/status/api", func(w http.ResponseWriter, r *http.Request) {
|
||||
pops := loadPops()
|
||||
|
||||
type DayUptime struct {
|
||||
Date string `json:"date"`
|
||||
Pct float64 `json:"pct"` // 0-100, -1 = no data
|
||||
}
|
||||
type NodeStatus struct {
|
||||
ID string `json:"id"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
Health string `json:"health"`
|
||||
Uptime []DayUptime `json:"uptime_90"`
|
||||
}
|
||||
|
||||
// Get latest telemetry per node
|
||||
tRows, _ := db.Query(`SELECT t.node_id, t.received_at FROM telemetry t INNER JOIN (SELECT node_id, MAX(id) as max_id FROM telemetry GROUP BY node_id) latest ON t.id = latest.max_id`)
|
||||
lastSeen := map[string]int64{}
|
||||
if tRows != nil {
|
||||
defer tRows.Close()
|
||||
for tRows.Next() {
|
||||
var nid string
|
||||
var ts int64
|
||||
tRows.Scan(&nid, &ts)
|
||||
lastSeen[nid] = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Build 90-day date list
|
||||
now := time.Now()
|
||||
var dates []string
|
||||
for i := 89; i >= 0; i-- {
|
||||
dates = append(dates, now.AddDate(0, 0, -i).Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Check maintenance status
|
||||
var inMaintenance bool
|
||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaintenance)
|
||||
|
||||
var nodes []NodeStatus
|
||||
allOperational := true
|
||||
for _, p := range pops {
|
||||
id := p.DNS
|
||||
if idx := strings.Index(id, "."); idx > 0 {
|
||||
id = id[:idx]
|
||||
}
|
||||
if id == "" { id = p.City }
|
||||
|
||||
health := "planned"
|
||||
if p.Status == "live" {
|
||||
health = "down"
|
||||
if ts, ok := lastSeen[id]; ok {
|
||||
age := now.Unix() - ts
|
||||
if age < 150 {
|
||||
health = "operational"
|
||||
} else {
|
||||
if inMaintenance { health = "maintenance" } else { health = "down" }
|
||||
}
|
||||
}
|
||||
if health != "operational" && health != "maintenance" {
|
||||
allOperational = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build 90-day uptime from spans
|
||||
uptime90 := make([]DayUptime, 90)
|
||||
for i, d := range dates {
|
||||
pct := rollupDay(id, d)
|
||||
uptime90[i] = DayUptime{Date: d, Pct: pct}
|
||||
}
|
||||
|
||||
nodes = append(nodes, NodeStatus{
|
||||
ID: id, City: p.City, Country: countryName(p.Country),
|
||||
Region: p.RegionName, Status: p.Status,
|
||||
Health: health, Uptime: uptime90,
|
||||
})
|
||||
}
|
||||
|
||||
// Get recent incidents
|
||||
type Incident struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
var incidents []Incident
|
||||
iRows, _ := db.Query(`SELECT id, title, status, date FROM incidents ORDER BY id DESC LIMIT 10`)
|
||||
if iRows != nil {
|
||||
defer iRows.Close()
|
||||
for iRows.Next() {
|
||||
var inc Incident
|
||||
iRows.Scan(&inc.ID, &inc.Title, &inc.Status, &inc.Date)
|
||||
incidents = append(incidents, inc)
|
||||
}
|
||||
}
|
||||
|
||||
// Outages
|
||||
type Outage struct {
|
||||
ID int `json:"id"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
NodeID string `json:"node_id"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
var outages []Outage
|
||||
oRows, _ := db.Query(`SELECT id, start_at, COALESCE(end_at,''), node_id, status, description FROM outages ORDER BY id DESC`)
|
||||
if oRows != nil {
|
||||
defer oRows.Close()
|
||||
for oRows.Next() {
|
||||
var o Outage
|
||||
oRows.Scan(&o.ID, &o.StartAt, &o.EndAt, &o.NodeID, &o.Status, &o.Description)
|
||||
outages = append(outages, o)
|
||||
}
|
||||
}
|
||||
|
||||
overall := "All Systems Operational"
|
||||
if inMaintenance {
|
||||
overall = "Scheduled Maintenance"
|
||||
} else if !allOperational || len(outages) > 0 {
|
||||
overall = "Some Systems Degraded"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
var lastBeat int64
|
||||
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"overall": overall,
|
||||
"nodes": nodes,
|
||||
"incidents": incidents,
|
||||
"outages": outages,
|
||||
"dates": dates,
|
||||
"last_heartbeat": lastBeat,
|
||||
})
|
||||
})
|
||||
|
||||
// Status API — day spans for tooltip
|
||||
http.HandleFunc("/status/api/spans", func(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.URL.Query().Get("node")
|
||||
date := r.URL.Query().Get("date")
|
||||
if node == "" || date == "" {
|
||||
http.Error(w, `{"error":"missing node or date"}`, 400)
|
||||
return
|
||||
}
|
||||
dayStart, _ := time.Parse("2006-01-02", date)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
dsUnix := dayStart.Unix()
|
||||
deUnix := dayEnd.Unix()
|
||||
if deUnix > time.Now().Unix() {
|
||||
deUnix = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Don't count time before the node first came online
|
||||
var firstEver int64
|
||||
db.QueryRow(`SELECT MIN(start_at) FROM uptime_spans WHERE node_id = ?`, node).Scan(&firstEver)
|
||||
if firstEver > 0 && firstEver > dsUnix {
|
||||
dsUnix = firstEver
|
||||
}
|
||||
|
||||
type Span struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Type string `json:"type"` // "up" or "down"
|
||||
}
|
||||
var spans []Span
|
||||
rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`, node, dsUnix, deUnix)
|
||||
if err == nil {
|
||||
prev := dsUnix
|
||||
for rows.Next() {
|
||||
var s, e int64
|
||||
rows.Scan(&s, &e)
|
||||
if s < dsUnix { s = dsUnix }
|
||||
if e > deUnix { e = deUnix }
|
||||
if s > prev {
|
||||
spans = append(spans, Span{Start: prev, End: s, Type: "down"})
|
||||
}
|
||||
spans = append(spans, Span{Start: s, End: e, Type: "up"})
|
||||
prev = e
|
||||
}
|
||||
rows.Close()
|
||||
// Only mark trailing gap as "down" if it's significant (>60s)
|
||||
// Gaps within heartbeat interval are just "not yet reported"
|
||||
if prev < deUnix && (deUnix-prev) > 60 {
|
||||
spans = append(spans, Span{Start: prev, End: deUnix, Type: "down"})
|
||||
} else if prev < deUnix {
|
||||
// Extend last up span to now (within heartbeat window)
|
||||
if len(spans) > 0 && spans[len(spans)-1].Type == "up" {
|
||||
spans[len(spans)-1].End = deUnix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
json.NewEncoder(w).Encode(map[string]any{"spans": spans, "day_start": dsUnix, "day_end": deUnix})
|
||||
})
|
||||
|
||||
http.HandleFunc("/glass", func(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Page: "glass", Title: "Looking Glass — clavitor", ActiveNav: "glass"}
|
||||
data.Pops = loadPops()
|
||||
render(w, data)
|
||||
})
|
||||
|
||||
// Downloads — direct file serving, no traversal
|
||||
http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/download/")
|
||||
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") || strings.HasPrefix(name, ".") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join("downloads", name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
http.ServeFile(w, r, path)
|
||||
})
|
||||
|
||||
// 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\nDisallow: /glass\n\nSitemap: https://clavitor.ai/sitemap.xml\n"))
|
||||
})
|
||||
http.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) {
|
||||
type PageInfo struct {
|
||||
Path string
|
||||
Priority string
|
||||
Change string
|
||||
}
|
||||
pages := []PageInfo{
|
||||
{"/", "1.0", "weekly"},
|
||||
{"/hosted", "0.9", "weekly"},
|
||||
{"/pricing", "0.9", "weekly"},
|
||||
{"/install", "0.8", "monthly"},
|
||||
{"/integrations/claude-code", "0.7", "monthly"},
|
||||
{"/integrations/codex", "0.7", "monthly"},
|
||||
{"/integrations/openclaw", "0.7", "monthly"},
|
||||
{"/integrations/openclaw/cn", "0.6", "monthly"},
|
||||
{"/privacy", "0.3", "yearly"},
|
||||
{"/terms", "0.3", "yearly"},
|
||||
{"/sources", "0.3", "yearly"},
|
||||
}
|
||||
lastmod := time.Now().Format("2006-01-02")
|
||||
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">` + "\n"
|
||||
for _, p := range pages {
|
||||
xml += fmt.Sprintf("<url><loc>https://clavitor.ai%s</loc><lastmod>%s</lastmod><changefreq>%s</changefreq><priority>%s</priority></url>\n", p.Path, lastmod, p.Change, p.Priority)
|
||||
}
|
||||
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 == "/" {
|
||||
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
|
||||
if strings.HasSuffix(r.URL.Path, ".html") {
|
||||
clean := strings.TrimSuffix(r.URL.Path, ".html")
|
||||
if clean == "/index" {
|
||||
clean = "/"
|
||||
}
|
||||
http.Redirect(w, r, clean, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
http.FileServer(http.FS(static)).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("clavitor-web starting on :%s", port)
|
||||
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +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}}
|
||||
<meta property="og:site_name" content="clavitor">
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
{{if .Desc}}<meta property="og:description" content="{{.Desc}}">{{end}}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://clavitor.ai{{if ne .Page "index"}}/{{.Page}}{{end}}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<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=Figtree:wght@400..700&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}}
|
||||
{{if eq .Page "styleguide"}}{{template "styleguide-head"}}{{end}}
|
||||
{{if eq .Page "index"}}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "clavitor",
|
||||
"url": "https://clavitor.ai",
|
||||
"description": "AI-native password manager with field-level encryption. Your AI gets what it needs. Your secrets stay yours.",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://clavitor.ai?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "clavitor",
|
||||
"url": "https://clavitor.ai",
|
||||
"logo": "https://clavitor.ai/favicon.svg",
|
||||
"sameAs": []
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{if or (eq .Page "hosted") (eq .Page "pricing")}}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "clavitor",
|
||||
"applicationCategory": "SecurityApplication",
|
||||
"operatingSystem": "Any",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "12",
|
||||
"priceCurrency": "USD",
|
||||
"priceValidUntil": "2026-12-31",
|
||||
"description": "$12/year hosted (launch price)"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "1"
|
||||
},
|
||||
"featureList": "Field-level encryption, WebAuthn PRF, Scoped agent tokens, AI-powered 2FA, LLM field mapping"
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<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 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="#" 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>
|
||||
</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 "styleguide"}}{{template "styleguide" .}}
|
||||
{{else if eq .Page "glass"}}{{template "glass" .}}
|
||||
{{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"}}
|
||||
{{else if eq .Page "hosted"}}{{template "hosted-script" .}}
|
||||
{{else if eq .Page "glass"}}{{template "glass-script"}}
|
||||
{{else if eq .Page "noc"}}{{template "noc-script"}}
|
||||
{{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>
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
{{define "claude-code"}}
|
||||
<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> + Claude Code</h1>
|
||||
<p class="lead mb-6">Give your Claude agent secure access to credentials, TOTP codes, and API keys — without exposing card numbers, passports, or recovery codes.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">What Claude sees</span>
|
||||
<h3 class="mb-3">Shared fields</h3>
|
||||
<p class="mb-4">Claude reads these via MCP tools to help you code, deploy, and authenticate.</p>
|
||||
<ul class="checklist">
|
||||
<li>API keys (GitHub, AWS, Stripe, OpenAI…)</li>
|
||||
<li>SSH host credentials</li>
|
||||
<li>Database connection strings</li>
|
||||
<li>TOTP seeds — Claude generates 2FA codes autonomously</li>
|
||||
<li>Service account passwords</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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 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>
|
||||
<li>Recovery codes & seed phrases</li>
|
||||
<li>Social security numbers</li>
|
||||
<li>Bank account details</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Setup in 2 minutes</h2>
|
||||
<p class="lead mb-8">Create a token in <span class="vaultname">clav<span class="n">itor</span></span>, connect it to Claude, done.</p>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">1. Create a token</h3>
|
||||
<p class="mb-4">Open your vault → <strong>Tokens</strong> → <strong>Create</strong>. Give it a label like “Claude Code”. Copy the token — it’s shown only once.</p>
|
||||
<p style="font-size:0.8125rem;color:var(--muted)">Each token is an independent API key. Create one per agent or project.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">2. Connect Claude Code</h3>
|
||||
<p class="mb-4">In your terminal, run:</p>
|
||||
<div class="code-block">claude mcp add clavitor --transport http --url http://localhost:1984/mcp \
|
||||
--header "Authorization: Bearer clavitor_your_token_here"</div>
|
||||
<p class="mt-4" style="font-size:0.8125rem;color:var(--muted)">That’s it. Claude Code picks up the new server automatically.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">3. Connect Claude Desktop</h3>
|
||||
<p class="mb-4">Open Claude Desktop → <strong>☰</strong> menu → <strong>Settings</strong> → <strong>Integrations</strong> → <strong>Add More</strong>.</p>
|
||||
<div class="code-block mb-4"><pre>Name: clavitor
|
||||
URL: http://localhost:1984/mcp</pre></div>
|
||||
<p class="mb-4">Click <strong>Add</strong>, then expand the entry and add a header:</p>
|
||||
<div class="code-block"><pre>Authorization: Bearer clavitor_your_token_here</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your MCP URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">You don’t have to do anything</h2>
|
||||
<p class="lead mb-8">Once connected, Claude handles credentials automatically. Need to deploy? It looks up your SSH key. Need to log in? It fetches the password and generates the 2FA code. You just ask for what you want done.</p>
|
||||
|
||||
<div class="grid-2 mb-6">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Deploy to production”</h3>
|
||||
<p>Claude looks up your server credentials, SSH key, and any required API tokens — then does the deployment.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Log in to GitHub and check the CI”</h3>
|
||||
<p>Claude finds the credential, generates a live TOTP code, and completes the 2FA flow. No phone needed.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3 mb-6">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Find my database credentials”</h3>
|
||||
<p>Full-text search across all entries — titles, URLs, usernames, notes.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“What’s expiring soon?”</h3>
|
||||
<p>Claude checks for credentials, cards, or documents expiring within any timeframe.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Show me everything”</h3>
|
||||
<p>List all entries the agent has access to. Useful for inventory or onboarding a new project.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Save this API key”</h3>
|
||||
<p>Claude stores new credentials, notes, and configuration directly in your vault. Sign up for a service, generate an API key, or jot down a config snippet — Claude saves it immediately. No copy-pasting into a separate app.</p>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Remember this for later”</h3>
|
||||
<p>License keys, server configs, migration plans, recovery instructions — anything you tell Claude to remember goes straight into your vault, encrypted and searchable.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">One vault, multiple agents</h2>
|
||||
<p class="lead mb-8">Running Claude on different projects? Create a separate API key for each.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Work agent</h3>
|
||||
<p>Its own API key for GitHub, AWS, Jira, and Slack</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Personal agent</h3>
|
||||
<p>Its own API key for email, social media, and cloud storage</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Deploy agent</h3>
|
||||
<p>Its own API key for SSH keys, database creds, and API tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Every access is logged</h2>
|
||||
<p class="lead mb-8">The audit log records which agent accessed which credential, when, and from where.</p>
|
||||
<div class="code-block"><pre><span class="comment">TIME ACTION ENTRY ACTOR</span>
|
||||
2026-03-08 10:23:14 read github.com mcp:claude-desktop
|
||||
2026-03-08 10:23:15 totp github.com mcp:claude-desktop
|
||||
2026-03-08 11:45:02 read aws-production mcp:deploy-agent
|
||||
2026-03-08 14:12:33 search "database" mcp:claude-code</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="text-align:center">
|
||||
<h2 class="mb-4">Get started</h2>
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a href="/install" class="btn btn-primary">Self-host (free)</a>
|
||||
<a href="/hosted" class="btn btn-gold">Hosted ($12/yr)</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
{{define "codex"}}
|
||||
<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> + OpenAI Codex</h1>
|
||||
<p class="lead mb-6">Give your Codex agent access to credentials and 2FA codes via REST API or MCP — without exposing card numbers, passports, or recovery codes.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">What your agent sees</span>
|
||||
<h3 class="mb-3">Shared fields</h3>
|
||||
<p class="mb-4">Your agent reads these to help you code, deploy, and authenticate.</p>
|
||||
<ul class="checklist">
|
||||
<li>API keys (GitHub, AWS, Stripe, OpenAI…)</li>
|
||||
<li>SSH host credentials</li>
|
||||
<li>Database connection strings</li>
|
||||
<li>TOTP seeds — live 2FA codes on demand</li>
|
||||
<li>Service account passwords</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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 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>
|
||||
<li>Recovery codes & seed phrases</li>
|
||||
<li>Social security numbers</li>
|
||||
<li>Bank account details</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Two ways to connect</h2>
|
||||
<p class="lead mb-8">MCP for native tool integration, or REST API for function calling from any model.</p>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card mb-6">
|
||||
<span class="badge accent mb-4">Option A</span>
|
||||
<h3 class="mb-4">MCP</h3>
|
||||
<p class="mb-4">Codex supports MCP natively. Add <span class="vaultname">clav<span class="n">itor</span></span> to your <code>~/.codex/config.toml</code> (or <code>.codex/config.toml</code> in your project):</p>
|
||||
<div class="code-block"><pre>[mcp_servers.clavitor]
|
||||
url = "http://localhost:1984/mcp"
|
||||
|
||||
[mcp_servers.clavitor.headers]
|
||||
Authorization = "Bearer clavitor_your_token_here"</pre></div>
|
||||
</div>
|
||||
<div class="card mb-6">
|
||||
<span class="badge mb-4">Option B</span>
|
||||
<h3 class="mb-4">REST API + Function Calling</h3>
|
||||
<p class="mb-4">Define <span class="vaultname">clav<span class="n">itor</span></span> endpoints as functions. Works with any LLM that supports function calling.</p>
|
||||
<div class="code-block"><pre>curl http://localhost:1984/api/search?q=github \
|
||||
-H "Authorization: Bearer clavitor_your_token_here"
|
||||
|
||||
# Returns entries with credentials, URLs, TOTP codes
|
||||
# Personal fields return: {"value":"[REDACTED]","l2":true}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code> or <code>.../<em>your_vault_id</em>/api/</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">API endpoints</h2>
|
||||
<p class="lead mb-8">Simple REST. Bearer token auth. JSON responses.</p>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="code-block"><pre>GET /api/entries # list all entries
|
||||
GET /api/entries/{id} # get single entry
|
||||
GET /api/search?q=github # search by query
|
||||
GET /api/ext/totp/{id} # get live TOTP code
|
||||
GET /api/generate?length=32 # generate random password</pre></div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.875rem;color:var(--muted)">All endpoints require <code>Authorization: Bearer clavitor_...</code></p>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">One vault, multiple agents</h2>
|
||||
<p class="lead mb-8">Running agents on different projects? Create a separate API key for each.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Work agent</h3>
|
||||
<p>Its own API key for GitHub, AWS, Jira, and Slack</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Personal agent</h3>
|
||||
<p>Its own API key for email, social media, and cloud storage</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Deploy agent</h3>
|
||||
<p>Its own API key for SSH keys, database creds, and API tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Every access is logged</h2>
|
||||
<p class="lead mb-8">The audit log records which agent accessed which credential, when, and from where.</p>
|
||||
<div class="code-block"><pre><span class="comment">TIME ACTION ENTRY ACTOR</span>
|
||||
2026-03-08 10:23:14 read github.com mcp:codex-agent
|
||||
2026-03-08 10:23:15 totp github.com mcp:codex-agent
|
||||
2026-03-08 11:45:02 read aws-production mcp:deploy-agent
|
||||
2026-03-08 14:12:33 search "database" api:codex</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="text-align:center">
|
||||
<h2 class="mb-4">Get started</h2>
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a href="/install" class="btn btn-primary">Self-host (free)</a>
|
||||
<a href="/hosted" class="btn btn-gold">Hosted ($12/yr)</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{{define "footer"}}
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-links">
|
||||
<a href="/" style="font-family:var(--font-sans);font-weight:700;letter-spacing:0.15em;text-transform:uppercase;font-size:0.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:var(--brand-black);border-radius:2px"></span>clavitor</a>
|
||||
<span style="opacity:0.4">GitHub</span>
|
||||
<a href="#">Discord</a>
|
||||
<a href="https://x.com/clavitorai">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,101 +0,0 @@
|
|||
{{define "glass"}}
|
||||
<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. Find the fastest node for you.</p>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;font-size:0.85rem;color:#854d0e">
|
||||
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Dubai (me-central-1)</strong><br>
|
||||
The AWS UAE region remains offline following drone strikes on March 1, 2026. No customer data was affected. We are awaiting updates from AWS on restoration, expected in the coming weeks.
|
||||
</div>
|
||||
<div class="glass-grid">
|
||||
{{range .Pops}}
|
||||
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
|
||||
<div class="glass-header">
|
||||
<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 if eq .Status "outage"}}glass-status-outage{{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">
|
||||
{{if .IP}}<div class="glass-row">
|
||||
<span class="glass-key">IPv4</span>
|
||||
<span class="glass-val mono">{{.IP}}</span>
|
||||
</div>{{end}}
|
||||
{{if .DNS}}<div class="glass-row">
|
||||
<span class="glass-key">DNS</span>
|
||||
<span class="glass-val mono">{{.DNS}}</span>
|
||||
</div>{{end}}
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "glass-script"}}
|
||||
<script>
|
||||
(function() {
|
||||
const grid = document.querySelector('.glass-grid');
|
||||
|
||||
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(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'));
|
||||
arr.sort((a, b) => {
|
||||
const aMs = parseInt(a.querySelector('.glass-latency')?.dataset.ms || 99998);
|
||||
const bMs = parseInt(b.querySelector('.glass-latency')?.dataset.ms || 99998);
|
||||
return aMs - bMs;
|
||||
});
|
||||
arr.forEach(el => grid.appendChild(el));
|
||||
}
|
||||
|
||||
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();
|
||||
return fetch('https://' + dns + ':1984/ping').then(() => {}).catch(() => {}).then(() => {
|
||||
applyResult(el, Math.round(performance.now() - t0));
|
||||
sortGrid();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
{{end}}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
{{define "hosted"}}
|
||||
<!-- Hero -->
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4"><span class="vaultname">clavitor</span> hosted</p>
|
||||
<h1>Zero cache. Every request hits the vault.</h1>
|
||||
<p class="lead">Clavitor never caches credentials — not in memory, not on disk, not anywhere. Every request is a fresh decrypt from the vault. That's the security model. To make it fast, we run {{len .Pops}} regions across every continent. Your data lives where you choose. <s>$20</s> $12/yr.</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="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"/>
|
||||
<text x="500" y="440" font-family="Figtree,sans-serif" font-size="18" font-weight="700" fill="#0A0A0A" text-anchor="middle" opacity="0.35" letter-spacing="0.3em">CLAVITOR GLOBAL PRESENCE</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-4" style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap">
|
||||
<p class="text-sm text-tertiary" style="margin:0">We have strategically chosen our datacenter locations so that almost every place on the planet gets an answer within 60 ms. If your location is slower than that, <a href="mailto:support@clavitor.com">reach out</a> and we will work on a solution.</p>
|
||||
<button id="backup-toggle" class="btn btn-ghost" style="white-space:nowrap;font-size:0.75rem;padding:6px 12px">Show backup routes</button>
|
||||
</div>
|
||||
<div class="mt-12"></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 encryption, not jurisdiction -->
|
||||
<div class="section container">
|
||||
<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">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 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 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>
|
||||
|
||||
<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">{{len .Pops}} 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">
|
||||
|
||||
<!-- Backup strategy -->
|
||||
<div class="section container">
|
||||
<p class="label accent mb-3">Disaster recovery</p>
|
||||
<h2 class="mb-4">Your backup is on the other side of the world.</h2>
|
||||
<p class="lead mb-8">Every vault is automatically replicated to an inland backup location — no coastline, no tsunami risk, no storm surge. Your data is encrypted end-to-end — nobody but you can read it.</p>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card">
|
||||
<h3 class="mb-2">Calgary, Canada</h3>
|
||||
<p class="mb-4">Deep in the Canadian prairies, over 1,000 km from the nearest ocean. No earthquakes, no hurricanes, no volcanoes — the most geologically stable terrain in North America. Canadian privacy law.</p>
|
||||
<p class="label mb-3">Backs up</p>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{{range .PopsByCity}}{{if eq .BackupCity "Calgary"}}<tr><td>{{.City}}{{if eq .City "Dubai"}} <span class="text-tertiary text-sm">(down)</span>{{end}}</td><td class="mono text-tertiary">{{.BackupDistFmt}} km <span class="text-sm">({{.BackupDistMiFmt}} mi)</span></td></tr>
|
||||
{{end}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 class="mb-2">Zürich, Switzerland</h3>
|
||||
<p class="mb-4">Landlocked in the center of Europe, surrounded by the Alps. Swiss data protection — among the strongest in the world. Politically neutral for over 200 years.</p>
|
||||
<p class="label mb-3">Backs up</p>
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{{range .PopsByCity}}{{if eq .BackupCity "Zürich"}}<tr><td>{{.City}}</td><td class="mono text-tertiary">{{.BackupDistFmt}} km <span class="text-sm">({{.BackupDistMiFmt}} mi)</span></td></tr>
|
||||
{{end}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 mb-4 text-sm text-tertiary"><strong>Why not Almaty?</strong> Almaty is also inland and well-positioned in Central Asia — but it sits on an active seismic fault line. It serves as a great user-facing region for the area, but we chose Calgary and Zürich as backup sites specifically for their geological stability.</p>
|
||||
<div class="card" style="margin-top:24px;border-left:3px solid var(--brand-red)">
|
||||
<p class="label red mb-2">Dubai (me-central-1) — temporarily unavailable</p>
|
||||
<p class="mb-3">On March 1, 2026, drone strikes physically damaged two of three AWS availability zones in the UAE. Both AWS Middle East regions (UAE and Bahrain) remain offline. We are waiting for AWS to restore service before we can offer Dubai as a region again. No customer data was affected — this is exactly why we replicate every vault to an inland backup site on the other side of the world.</p>
|
||||
<p>If you were planning to use Dubai, the nearest alternatives are <strong>Mumbai</strong>, <strong>Istanbul</strong>, and <strong>Almaty</strong>. If you need to transfer an existing vault to a different region, we will do it for free — contact <a href="mailto:support@clavitor.ai">support@clavitor.ai</a>.</p>
|
||||
</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"}}
|
||||
<script>
|
||||
(function() {
|
||||
const W = 1000, H = 460;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
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 addPopDot(svg, pop, delay) {
|
||||
const [x, y] = project(pop.lon, pop.lat);
|
||||
const isHQ = pop.city === 'Zürich' || pop.city === 'Calgary';
|
||||
const isLive = pop.status === 'live';
|
||||
const isDubai = pop.city === 'Dubai';
|
||||
const dotColor = isDubai ? '#EA580C' : isHQ ? '#0A0A0A' : isLive ? '#DC2626' : '#F5B7B7';
|
||||
const textColor = isDubai ? '#EA580C' : isHQ ? '#0A0A0A' : isLive ? '#B91C1C' : '#777777';
|
||||
const pulseColor = isHQ ? '#0A0A0A' : isLive ? '#DC2626' : '#F5B7B7';
|
||||
const dotSize = isHQ ? 11 : 9;
|
||||
const pulseR = isHQ ? 5 : 4;
|
||||
const pulseMax = isHQ ? 18 : 13;
|
||||
const tooltip = isDubai ? 'Dubai · Down — AWS me-central-1 damaged by drone strikes (March 2026)' : isLive ? pop.city + ' · Live' : pop.city + ' · Planned';
|
||||
|
||||
// Pulse rings — only for live/HQ pops
|
||||
let r1, r2;
|
||||
if (isLive || isHQ) {
|
||||
r1 = document.createElementNS(ns, 'circle');
|
||||
r1.setAttribute('cx', x); r1.setAttribute('cy', y);
|
||||
r1.setAttribute('r', pulseR); r1.setAttribute('fill', 'none');
|
||||
r1.setAttribute('stroke', pulseColor); r1.setAttribute('stroke-width', isHQ ? '2' : '1.5');
|
||||
const a1 = document.createElementNS(ns, 'animate');
|
||||
a1.setAttribute('attributeName', 'r'); a1.setAttribute('values', pulseR+';'+pulseMax+';'+pulseR);
|
||||
a1.setAttribute('dur', '2.4s'); a1.setAttribute('begin', delay+'s'); a1.setAttribute('repeatCount', 'indefinite');
|
||||
const a2 = document.createElementNS(ns, 'animate');
|
||||
a2.setAttribute('attributeName', 'stroke-opacity'); a2.setAttribute('values', '0.6;0;0.6');
|
||||
a2.setAttribute('dur', '2.4s'); a2.setAttribute('begin', delay+'s'); a2.setAttribute('repeatCount', 'indefinite');
|
||||
r1.appendChild(a1); r1.appendChild(a2);
|
||||
|
||||
r2 = document.createElementNS(ns, 'circle');
|
||||
r2.setAttribute('cx', x); r2.setAttribute('cy', y);
|
||||
r2.setAttribute('r', pulseR); r2.setAttribute('fill', 'none');
|
||||
r2.setAttribute('stroke', pulseColor); r2.setAttribute('stroke-width', isHQ ? '1.5' : '1');
|
||||
const a3 = document.createElementNS(ns, 'animate');
|
||||
a3.setAttribute('attributeName', 'r'); a3.setAttribute('values', pulseR+';'+pulseMax+';'+pulseR);
|
||||
a3.setAttribute('dur', '2.4s'); a3.setAttribute('begin', (delay+0.8)+'s'); a3.setAttribute('repeatCount', 'indefinite');
|
||||
const a4 = document.createElementNS(ns, 'animate');
|
||||
a4.setAttribute('attributeName', 'stroke-opacity'); a4.setAttribute('values', '0.4;0;0.4');
|
||||
a4.setAttribute('dur', '2.4s'); a4.setAttribute('begin', (delay+0.8)+'s'); a4.setAttribute('repeatCount', 'indefinite');
|
||||
r2.appendChild(a3); r2.appendChild(a4);
|
||||
}
|
||||
|
||||
// Square dot
|
||||
const half = dotSize / 2;
|
||||
const dot = document.createElementNS(ns, 'rect');
|
||||
dot.setAttribute('x', x - half); dot.setAttribute('y', y - half);
|
||||
dot.setAttribute('width', dotSize); dot.setAttribute('height', dotSize);
|
||||
dot.setAttribute('fill', dotColor); dot.setAttribute('stroke', '#F5F5F5'); dot.setAttribute('stroke-width', '1.5');
|
||||
const title = document.createElementNS(ns, 'title');
|
||||
title.textContent = tooltip;
|
||||
dot.appendChild(title);
|
||||
|
||||
// Label
|
||||
const label = document.createElementNS(ns, 'text');
|
||||
label.setAttribute('x', x); label.setAttribute('y', y - half - 4);
|
||||
label.setAttribute('font-family', 'Inter,sans-serif');
|
||||
label.setAttribute('font-size', '8.5');
|
||||
label.setAttribute('fill', textColor);
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('opacity', '0.85');
|
||||
label.textContent = pop.city;
|
||||
const labelTitle = document.createElementNS(ns, 'title');
|
||||
labelTitle.textContent = tooltip;
|
||||
label.appendChild(labelTitle);
|
||||
|
||||
if (r1) svg.appendChild(r1);
|
||||
if (r2) svg.appendChild(r2);
|
||||
svg.appendChild(dot);
|
||||
svg.appendChild(label);
|
||||
|
||||
// Down indicator for Dubai
|
||||
if (isDubai) {
|
||||
const bg = document.createElementNS(ns, 'circle');
|
||||
bg.setAttribute('cx', x); bg.setAttribute('cy', y);
|
||||
bg.setAttribute('r', '12'); bg.setAttribute('fill', '#EA580C'); bg.setAttribute('fill-opacity', '0.15');
|
||||
bg.setAttribute('stroke', '#EA580C'); bg.setAttribute('stroke-width', '1.5'); bg.setAttribute('stroke-opacity', '0.4');
|
||||
svg.appendChild(bg);
|
||||
const sz = 4;
|
||||
const cross = document.createElementNS(ns, 'g');
|
||||
cross.setAttribute('stroke', '#ffffff'); cross.setAttribute('stroke-width', '2.5'); cross.setAttribute('stroke-linecap', 'round');
|
||||
const l1 = document.createElementNS(ns, 'line');
|
||||
l1.setAttribute('x1', x - sz); l1.setAttribute('y1', y - sz);
|
||||
l1.setAttribute('x2', x + sz); l1.setAttribute('y2', y + sz);
|
||||
const l2 = document.createElementNS(ns, 'line');
|
||||
l2.setAttribute('x1', x + sz); l2.setAttribute('y1', y - sz);
|
||||
l2.setAttribute('x2', x - sz); l2.setAttribute('y2', y + sz);
|
||||
cross.appendChild(l1); cross.appendChild(l2);
|
||||
const crossTitle = document.createElementNS(ns, 'title');
|
||||
crossTitle.textContent = tooltip;
|
||||
cross.appendChild(crossTitle);
|
||||
svg.appendChild(cross);
|
||||
}
|
||||
}
|
||||
|
||||
function addVisitorDot(lat, lon, city) {
|
||||
const svg = document.getElementById('worldmap');
|
||||
if (!svg) return;
|
||||
const [x, y] = project(lon, lat);
|
||||
|
||||
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', '#0A0A0A'); 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', '#0A0A0A');
|
||||
dot.setAttribute('stroke', '#ffffff'); 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', '#0A0A0A');
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('font-weight', '500');
|
||||
label.textContent = city || 'You';
|
||||
|
||||
svg.appendChild(ring);
|
||||
svg.appendChild(dot);
|
||||
svg.appendChild(label);
|
||||
}
|
||||
|
||||
const POPS = [{{range .Pops}}
|
||||
{city:"{{.City}}", country:"{{.Country}}", region:"{{.RegionName}}", lat:{{.Lat}}, lon:{{.Lon}}, status:"{{.Status}}", provider:"{{.Provider}}"},{{end}}
|
||||
];
|
||||
|
||||
// Render all POP dots from DB data
|
||||
const svg = document.getElementById('worldmap');
|
||||
if (svg) {
|
||||
POPS.forEach((pop, i) => addPopDot(svg, pop, (i * 0.08).toFixed(2)));
|
||||
}
|
||||
|
||||
// Backup route lines
|
||||
const backupToZurich = new Set(['JP','KR','AU','SG','HK','US','CA','MX','BR','CO']);
|
||||
function getBackupCity(pop) {
|
||||
if (pop.city === 'Zürich') return 'Calgary';
|
||||
if (pop.city === 'Calgary') return 'Zürich';
|
||||
return backupToZurich.has(pop.country) ? 'Zürich' : 'Calgary';
|
||||
}
|
||||
|
||||
let backupLines = [];
|
||||
let backupVisible = false;
|
||||
const backupBtn = document.getElementById('backup-toggle');
|
||||
if (backupBtn) {
|
||||
backupBtn.addEventListener('click', function() {
|
||||
backupVisible = !backupVisible;
|
||||
if (backupVisible) {
|
||||
backupBtn.textContent = 'Hide backup routes';
|
||||
const livePops = POPS.filter(p => p.status === 'live');
|
||||
livePops.forEach(pop => {
|
||||
const backupCity = getBackupCity(pop);
|
||||
const target = livePops.find(p => p.city === backupCity);
|
||||
if (!target || target.city === pop.city) return;
|
||||
const [x1, y1] = project(pop.lon, pop.lat);
|
||||
const [x2, y2] = project(target.lon, target.lat);
|
||||
const line = document.createElementNS(ns, 'line');
|
||||
line.setAttribute('x1', x1); line.setAttribute('y1', y1);
|
||||
line.setAttribute('x2', x2); line.setAttribute('y2', y2);
|
||||
line.setAttribute('stroke', '#DC2626'); line.setAttribute('stroke-width', '0.8');
|
||||
line.setAttribute('stroke-opacity', '0.3'); line.setAttribute('stroke-dasharray', '4 3');
|
||||
svg.insertBefore(line, svg.querySelector('rect'));
|
||||
backupLines.push(line);
|
||||
});
|
||||
} else {
|
||||
backupBtn.textContent = 'Show backup routes';
|
||||
backupLines.forEach(l => l.remove());
|
||||
backupLines = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findClosestPop(lat, lon) {
|
||||
return POPS.reduce((best, p) => {
|
||||
const d = (lat-p.lat)**2 + (lon-p.lon)**2;
|
||||
const bd = (lat-best.lat)**2 + (lon-best.lon)**2;
|
||||
return d < bd ? p : best;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGeoData(d) {
|
||||
if (!d.latitude || !d.longitude) return;
|
||||
addVisitorDot(d.latitude, d.longitude, d.city || 'You');
|
||||
|
||||
const closest = findClosestPop(d.latitude, d.longitude);
|
||||
const nameEl = document.getElementById('closest-name');
|
||||
const subEl = document.getElementById('closest-sub');
|
||||
const buyEl = document.getElementById('closest-buy');
|
||||
if (nameEl) nameEl.textContent = closest.city;
|
||||
if (subEl) subEl.textContent = d.city ? `~${d.city}` : 'Your region';
|
||||
if (buyEl) buyEl.href = `/signup?region=${closest.region}`;
|
||||
}
|
||||
|
||||
// Ask browser geolocation first (accurate, triggers permission prompt)
|
||||
// Fall back to server-side IP lookup if denied or unavailable
|
||||
function tryIPGeo() {
|
||||
fetch('/geo')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.latitude) handleGeoData(d); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos => {
|
||||
handleGeoData({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
city: '', region: '', country_name: '', country_code: ''
|
||||
});
|
||||
},
|
||||
() => tryIPGeo() // denied — fall back to IP
|
||||
);
|
||||
} else {
|
||||
tryIPGeo();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,586 +0,0 @@
|
|||
{{define "index"}}
|
||||
<!-- Hero -->
|
||||
<div class="container hero-split">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Hero SVG: Credential/Identity encryption diagram -->
|
||||
<svg viewBox="0 0 480 380" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect x="0" y="0" width="480" height="380" rx="12" fill="#f5f5f5"/>
|
||||
|
||||
<!-- Top labels -->
|
||||
<text x="130" y="35" font-family="JetBrains Mono, monospace" font-size="11" fill="#737373" text-anchor="middle">AI Agent</text>
|
||||
<text x="350" y="35" font-family="JetBrains Mono, monospace" font-size="11" fill="#737373" text-anchor="middle">You only</text>
|
||||
|
||||
<!-- Arrows -->
|
||||
<path d="M130 42 L130 58" stroke="#22C55E" stroke-width="1.5" marker-end="url(#arrowGreen)"/>
|
||||
<path d="M350 42 L350 58" stroke="#EF4444" stroke-width="1.5" marker-end="url(#arrowRed)"/>
|
||||
<defs>
|
||||
<marker id="arrowGreen" markerWidth="8" markerHeight="6" refX="4" refY="3" orient="auto"><path d="M0,0 L4,3 L0,6" fill="none" stroke="#22C55E" stroke-width="1.5"/></marker>
|
||||
<marker id="arrowRed" markerWidth="8" markerHeight="6" refX="4" refY="3" orient="auto"><path d="M0,0 L4,3 L0,6" fill="none" stroke="#EF4444" stroke-width="1.5"/></marker>
|
||||
</defs>
|
||||
|
||||
<!-- Credential Encryption Column (AI-readable) -->
|
||||
<rect x="30" y="65" width="200" height="260" rx="8" fill="none" stroke="#22C55E" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<rect x="30" y="65" width="200" height="30" rx="8" fill="#22C55E" fill-opacity="0.1"/>
|
||||
<text x="130" y="85" font-family="JetBrains Mono, monospace" font-size="12" fill="#22C55E" text-anchor="middle" font-weight="600">Credential — AI can read</text>
|
||||
|
||||
<!-- L2 items -->
|
||||
<g>
|
||||
<rect x="50" y="115" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="80" y="138" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">github_token</text>
|
||||
<circle cx="192" cy="133" r="8" fill="#22C55E" fill-opacity="0.15"/>
|
||||
<path d="M188 133 L190.5 135.5 L196 130" stroke="#22C55E" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="50" y="163" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="80" y="186" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">ssh_key</text>
|
||||
<circle cx="192" cy="181" r="8" fill="#22C55E" fill-opacity="0.15"/>
|
||||
<path d="M188 181 L190.5 183.5 L196 178" stroke="#22C55E" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="50" y="211" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="80" y="234" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">totp_github</text>
|
||||
<circle cx="192" cy="229" r="8" fill="#22C55E" fill-opacity="0.15"/>
|
||||
<path d="M188 229 L190.5 231.5 L196 226" stroke="#22C55E" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="50" y="259" width="160" height="36" rx="6" fill="#ffffff"/>
|
||||
<text x="80" y="282" font-family="JetBrains Mono, monospace" font-size="11" fill="#525252">oauth_slack</text>
|
||||
<circle cx="192" cy="277" r="8" fill="#22C55E" fill-opacity="0.15"/>
|
||||
<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 (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 — 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="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="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="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="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 -->
|
||||
<rect x="224" y="340" width="32" height="28" rx="4" fill="#f5f5f5" stroke="#737373" stroke-width="1"/>
|
||||
<circle cx="240" cy="352" r="3" fill="none" stroke="#737373" stroke-width="1"/>
|
||||
<line x1="240" y1="355" x2="240" y2="360" stroke="#737373" stroke-width="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">clavitor</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 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 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>
|
||||
<h3 class="mb-3">AI-readable</h3>
|
||||
<p class="mb-4">Encrypted at rest, decryptable by the vault server. Your AI agent accesses these via the CLI.</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">Identity Encryption</span>
|
||||
<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>
|
||||
<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>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>
|
||||
<h3 class="mb-3">AI-powered 2FA</h3>
|
||||
<p>Store TOTP secrets as Credential fields. Your AI generates time-based codes on demand via the CLI — 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 agent tokens</h3>
|
||||
<p>Create separate 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 Credential vs Identity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Multi-agent -->
|
||||
<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 CLI tokens per agent. One compromised agent exposes one scope — not your entire vault.</p>
|
||||
<p class="mb-4">Why not MCP? Because 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, no surprise access.</p>
|
||||
<div class="code-block">
|
||||
<p class="code-label">Agent workflow</p>
|
||||
<pre><span class="comment"># Agent fetches credential — encrypted, never plaintext</span>
|
||||
<span class="prompt">$</span> clavitor get github.token --agent dev --format env
|
||||
GITHUB_TOKEN=ghp_a3f8...
|
||||
|
||||
<span class="comment"># Scoped: dev agent can't see social credentials</span>
|
||||
<span class="prompt">$</span> clavitor get twitter.oauth --agent dev
|
||||
Error: access denied (scope: dev)</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="#f5f5f5" stroke="#737373" stroke-width="1.5"/>
|
||||
<rect x="175" y="152" width="20" height="20" rx="0" fill="#0A0A0A"/>
|
||||
<text x="200" y="190" font-family="Figtree, sans-serif" font-size="10" fill="#0A0A0A" text-anchor="middle" font-weight="700" letter-spacing="0.25em">CLAVITOR</text>
|
||||
|
||||
<!-- Agent 1 — dev -->
|
||||
<circle cx="80" cy="60" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
<text x="80" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#0A0A0A" text-anchor="middle">Agent 1</text>
|
||||
<text x="80" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#737373" text-anchor="middle">dev</text>
|
||||
<line x1="108" y1="80" x2="165" y2="145" stroke="#0A0A0A" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 2 — social -->
|
||||
<circle cx="320" cy="60" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
<text x="320" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#0A0A0A" text-anchor="middle">Agent 2</text>
|
||||
<text x="320" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#737373" text-anchor="middle">social</text>
|
||||
<line x1="292" y1="80" x2="235" y2="145" stroke="#0A0A0A" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 3 — finance -->
|
||||
<circle cx="50" cy="220" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
<text x="50" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#0A0A0A" text-anchor="middle">Agent 3</text>
|
||||
<text x="50" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#737373" text-anchor="middle">finance</text>
|
||||
<line x1="78" y1="204" x2="164" y2="190" stroke="#0A0A0A" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 4 — infra -->
|
||||
<circle cx="350" cy="220" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
<text x="350" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#0A0A0A" text-anchor="middle">Agent 4</text>
|
||||
<text x="350" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#737373" text-anchor="middle">infra</text>
|
||||
<line x1="322" y1="204" x2="236" y2="190" stroke="#0A0A0A" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
|
||||
|
||||
<!-- Agent 5 — deploy -->
|
||||
<circle cx="200" cy="330" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
<text x="200" y="326" font-family="JetBrains Mono, monospace" font-size="9" fill="#0A0A0A" text-anchor="middle">Agent 5</text>
|
||||
<text x="200" y="338" font-family="JetBrains Mono, monospace" font-size="8" fill="#737373" text-anchor="middle">deploy</text>
|
||||
<line x1="200" y1="298" x2="200" y2="220" stroke="#0A0A0A" 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="#ffffff"/>
|
||||
<text x="80" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#737373" text-anchor="middle">github ssh gitlab</text>
|
||||
|
||||
<rect x="250" y="98" width="140" height="20" rx="4" fill="#ffffff"/>
|
||||
<text x="320" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#737373" text-anchor="middle">twitter slack discord</text>
|
||||
|
||||
<rect x="0" y="256" width="100" height="20" rx="4" fill="#ffffff"/>
|
||||
<text x="50" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#737373" text-anchor="middle">stripe plaid</text>
|
||||
|
||||
<rect x="300" y="256" width="100" height="20" rx="4" fill="#ffffff"/>
|
||||
<text x="350" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#737373" text-anchor="middle">aws k8s docker</text>
|
||||
|
||||
<rect x="150" y="296" width="100" height="16" rx="4" fill="#ffffff"/>
|
||||
<text x="200" y="308" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#737373" 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">CLI</p>
|
||||
<h3 class="mb-2">For AI agents</h3>
|
||||
<p>Agents call the CLI to fetch credentials — scoped per agent. Each agent sees only what it's been granted. No vault browsing, no discovery.</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 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>
|
||||
<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">
|
||||
|
||||
<!-- 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>
|
||||
<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">clavitor</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">clavitor</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">clavitor</span>: Self-host or use hosted with L3 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">clavitor</span>: WebAuthn-first. Your authenticator 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">clavitor</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">clavitor</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">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>
|
||||
</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">Agent access — scoped, encrypted</p>
|
||||
<pre><span class="comment"># Create a scoped token for your deploy agent</span>
|
||||
<span class="prompt">$</span> clavitor token create --scope deploy --name "CI pipeline"
|
||||
Token: <span class="highlight">ctk_deploy_9f2a...</span>
|
||||
|
||||
<span class="comment"># Agent fetches only what it's scoped to</span>
|
||||
<span class="prompt">$</span> clavitor get vercel.token --agent deploy
|
||||
VERCEL_TOKEN=tV3r...</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', '#0A0A0A'); 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', '#0A0A0A');
|
||||
dot.setAttribute('stroke', '#ffffff'); 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', '#0A0A0A');
|
||||
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 = 'visitor-card card-hover';
|
||||
card.setAttribute('data-lon', d.longitude);
|
||||
card.innerHTML = `
|
||||
<div class="visitor-flag">${flag}</div>
|
||||
<div class="visitor-label">${label}</div>
|
||||
<div class="visitor-region">${region}</div>
|
||||
<div class="visitor-status">
|
||||
<span class="visitor-dot"></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"}}{{end}}
|
||||
|
||||
{{define "install"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Open source · Elastic License 2.0</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">
|
||||
|
||||
<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 text-sm">Or download directly:</p>
|
||||
<div class="dl-links">
|
||||
<span class="btn btn-ghost btn-sm btn-mono" style="opacity:0.4;cursor:default">linux/amd64</span>
|
||||
<span class="btn btn-ghost btn-sm btn-mono" style="opacity:0.4;cursor:default">darwin/arm64</span>
|
||||
<span class="btn btn-ghost btn-sm btn-mono" style="opacity:0.4;cursor:default">darwin/amd64</span>
|
||||
</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 Credential fields at rest. If you lose this key, Credential 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 agent access</h2>
|
||||
<p>Create a scoped token for each AI agent. Agents use the CLI to fetch credentials — encrypted in transit, never exposed in plaintext.</p>
|
||||
<div class="code-block">
|
||||
<div><span class="comment"># Create a scoped agent token</span></div>
|
||||
<div><span class="prompt">$</span> clavitor token create --scope dev --name "Claude Code"</div>
|
||||
<div class="comment">Token: <span class="highlight">ctk_dev_a3f8...</span></div>
|
||||
<div class="mt-2"><span class="comment"># Agent fetches credentials via CLI</span></div>
|
||||
<div><span class="prompt">$</span> clavitor get github.token --agent dev</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm">Manage tokens 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 Credential/Identity 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>[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>vault.yourdomain.com {
|
||||
reverse_proxy localhost:1984
|
||||
}</pre></div>
|
||||
|
||||
<hr class="divider mb-8 mt-4">
|
||||
|
||||
<h2 class="mb-4">Self-hosting and AI agents</h2>
|
||||
<p class="mb-4">Your vault should not run on the same machine as your AI agents.</p>
|
||||
<p class="mb-4">AI agents have shell access. If the vault database is on the same filesystem, an agent can read it directly — bypassing the API, the audit log, and the encryption model.</p>
|
||||
<p class="mb-4">Run the vault on a separate device. A Raspberry Pi, a NAS, a VM, or a VPS — anything the agent can reach over HTTPS but cannot SSH into. Even a $5/month VPS works, though <a href="/hosted">hosted Clavitor</a> does the same thing for $1/month.</p>
|
||||
<p>If you must run on the same machine: create a dedicated system user for the vault, restrict the database file to that user (<code>chmod 600</code>), and run the vault as a systemd service under that account. This is not equivalent to network isolation, but it raises the bar.</p>
|
||||
|
||||
<hr class="divider mb-8 mt-4">
|
||||
|
||||
<h2 class="mb-4">Rather not manage it yourself?</h2>
|
||||
<p class="lead mb-6">Same vault, same features. We handle updates, backups, and TLS. <s>$20</s> $12/yr.</p>
|
||||
<a href="/hosted" class="btn btn-primary">See hosted option →</a>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
{{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}}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
{{define "noc"}}
|
||||
<div class="hero container" style="padding-bottom:0">
|
||||
<p class="label accent mb-4">Network Operations</p>
|
||||
<h1 class="mb-4">Clavitor NOC</h1>
|
||||
<p class="lead" style="margin-bottom:0">Real-time telemetry from {{len .Pops}} points of presence.</p>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div id="noc-summary" class="glass-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:24px">
|
||||
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Nodes reporting</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-nodes">—</div></div>
|
||||
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Avg CPU</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-cpu">—</div></div>
|
||||
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Avg Mem</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-mem">—</div></div>
|
||||
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Total Vaults</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-vaults">—</div></div>
|
||||
</div>
|
||||
<div id="noc-error" style="display:none;color:var(--brand-red);font-size:0.85rem;margin-bottom:16px"></div>
|
||||
<div id="noc-cards" class="glass-grid" style="grid-template-columns:repeat(3,1fr)"></div>
|
||||
<p class="mt-4 text-sm text-tertiary" id="noc-updated">Loading...</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "noc-script"}}
|
||||
<script>
|
||||
(function() {
|
||||
const pin = new URLSearchParams(window.location.search).get('pin') || '';
|
||||
const API = '/noc';
|
||||
const P = '?pin=' + encodeURIComponent(pin);
|
||||
function apiUrl(path, extra) { return API + path + P + (extra ? '&' + extra : ''); }
|
||||
const REFRESH_MS = 30000;
|
||||
const history = {};
|
||||
|
||||
function colorClass(pct, w=60, c=85) { return pct >= c ? 'glass-slow' : pct >= w ? 'glass-ok' : 'glass-fast'; }
|
||||
function barColor(pct, w=60, c=85) { return pct >= c ? 'var(--brand-red)' : pct >= w ? '#ca8a04' : '#16a34a'; }
|
||||
function fmtUptime(s) {
|
||||
if (!s) return '—';
|
||||
const d=Math.floor(s/86400), h=Math.floor((s%86400)/3600), m=Math.floor((s%3600)/60);
|
||||
return d > 0 ? d+'d '+h+'h' : h > 0 ? h+'h '+m+'m' : m+'m';
|
||||
}
|
||||
function fmtAgo(ts) {
|
||||
const s = Math.round(Date.now()/1000 - ts);
|
||||
if (s < 5) return 'just now';
|
||||
if (s < 60) return s+'s ago';
|
||||
if (s < 3600) return Math.floor(s/60)+'m ago';
|
||||
return Math.floor(s/3600)+'h ago';
|
||||
}
|
||||
function drawSpark(canvas, points, key, color) {
|
||||
const W = canvas.clientWidth || 320, H = 40;
|
||||
canvas.width = W * devicePixelRatio; canvas.height = H * devicePixelRatio;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
if (!points || points.length < 2) return;
|
||||
const vals = points.map(p => p[key]);
|
||||
const dataMax = Math.max(...vals);
|
||||
// Snap ceiling to nice round number above data
|
||||
const max = dataMax <= 5 ? 5 : dataMax <= 10 ? 10 : dataMax <= 25 ? 25 : dataMax <= 50 ? 50 : 100;
|
||||
const xs = i => (i / (points.length-1)) * W;
|
||||
const ys = v => H - 2 - (v/max) * (H-4);
|
||||
// Guide lines at 25%, 50%, 75% of ceiling
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.06)'; ctx.lineWidth = 1;
|
||||
for (const f of [0.25, 0.5, 0.75]) {
|
||||
const y = ys(max * f);
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1.5;
|
||||
ctx.moveTo(xs(0), ys(vals[0]));
|
||||
for (let i=1;i<vals.length;i++) ctx.lineTo(xs(i), ys(vals[i]));
|
||||
ctx.stroke();
|
||||
ctx.lineTo(xs(vals.length-1), H); ctx.lineTo(xs(0), H); ctx.closePath();
|
||||
ctx.fillStyle = color.replace(')', ',0.08)').replace('rgb','rgba');
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderCard(t, hist) {
|
||||
if (t._pending) return `
|
||||
<div class="glass-pop" style="opacity:.5">
|
||||
<div class="glass-header"><div><span class="pop-city">${t._city || t.node_id}</span><span class="pop-country">${t._country || ''}</span></div><span class="glass-status glass-status-planned">PENDING</span></div>
|
||||
<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:20px 0">Awaiting telemetry</div>
|
||||
</div>`;
|
||||
const ageS = Math.round(Date.now()/1000 - t.received_at);
|
||||
const stale = ageS > 150, offline = ageS > 300;
|
||||
const memPct = t.memory_total_mb ? Math.round(t.memory_used_mb/t.memory_total_mb*100) : 0;
|
||||
const diskPct = t.disk_total_mb ? Math.round(t.disk_used_mb/t.disk_total_mb*100) : 0;
|
||||
const statusClass = offline ? 'glass-status-planned' : stale ? 'glass-status-planned' : 'glass-status-live';
|
||||
const statusText = offline ? 'OFFLINE' : stale ? 'STALE' : 'LIVE';
|
||||
const borderColor = offline ? 'var(--brand-red)' : stale ? '#ca8a04' : '#16a34a';
|
||||
return `
|
||||
<div class="glass-pop" style="border-left:3px solid ${borderColor}">
|
||||
<div class="glass-header">
|
||||
<div><span class="pop-city">${t._city || t.node_id}</span><span class="pop-country">${t._country || ''}</span></div>
|
||||
<span class="glass-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div style="font-size:0.72rem;color:var(--muted);margin-bottom:10px">${t.hostname || ''} · v${t.version || ''}</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
|
||||
<div><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em">CPU</div><div class="${colorClass(t.cpu_percent)}" style="font-size:1.3rem;font-weight:700">${t.cpu_percent.toFixed(1)}%</div><div style="font-size:0.7rem;color:var(--muted)">load ${t.load_1m.toFixed(2)}</div></div>
|
||||
<div style="text-align:right"><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em">Memory</div><div class="${colorClass(memPct)}" style="font-size:1.3rem;font-weight:700">${memPct}%</div><div style="font-size:0.7rem;color:var(--muted)">${t.memory_used_mb} / ${t.memory_total_mb} MB</div></div>
|
||||
</div>
|
||||
<div style="margin-bottom:8px"><div style="display:flex;justify-content:space-between;font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:3px"><span>Disk</span><span>${diskPct}% · ${t.disk_used_mb} / ${t.disk_total_mb} MB</span></div><div style="background:var(--border);border-radius:2px;height:4px"><div style="height:4px;border-radius:2px;width:${diskPct}%;background:${barColor(diskPct,70,90)}"></div></div></div>
|
||||
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:8px"><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px">CPU % — last 30 min</div><canvas class="noc-spark" id="spark-cpu-${t.node_id}" style="width:100%;height:40px;display:block"></canvas></div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:10px;padding-top:8px;border-top:1px solid var(--border);font-size:0.7rem;color:var(--muted)">
|
||||
<span>↑ ${fmtUptime(t.uptime_seconds)}</span>
|
||||
<span style="color:var(--brand-red);font-weight:600">◈ ${t.vault_count} vaults · ${t.vault_size_mb.toFixed(1)} MB</span>
|
||||
<span title="${new Date(t.received_at*1000).toISOString()}">⏱ ${fmtAgo(t.received_at)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function fetchHistory(nodeId) {
|
||||
try {
|
||||
const r = await fetch(apiUrl('/api/telemetry/history', 'node='+nodeId+'&limit=60'), {cache:'no-cache'});
|
||||
const d = await r.json();
|
||||
if (d.history) history[nodeId] = d.history.map(h => ({ts:h.ts, cpu:h.cpu, mem_pct: h.mem_total_mb ? Math.round(h.mem_used_mb/h.mem_total_mb*100) : 0}));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [tRes, nRes] = await Promise.all([
|
||||
fetch(apiUrl('/api/telemetry'), {cache:'no-cache'}),
|
||||
fetch(apiUrl('/api/nodes'), {cache:'no-cache'}),
|
||||
]);
|
||||
const tData = await tRes.json(), nData = await nRes.json();
|
||||
const tMap = {};
|
||||
for (const t of (tData.telemetry || [])) tMap[t.node_id] = t;
|
||||
const liveNodes = (nData.nodes || []).filter(n => n.Status === 'live');
|
||||
const nodes = liveNodes.map(n => {
|
||||
const t = tMap[n.ID] || {node_id:n.ID, _pending:true};
|
||||
t._city = n.City || n.ID;
|
||||
t._country = n.Country || '';
|
||||
return t;
|
||||
});
|
||||
document.getElementById('noc-error').style.display = 'none';
|
||||
|
||||
document.getElementById('s-nodes').textContent = nodes.length;
|
||||
if (nodes.length) {
|
||||
const live = nodes.filter(n => !n._pending);
|
||||
const avgCPU = live.reduce((a,n)=>a+n.cpu_percent,0)/live.length;
|
||||
const avgMem = live.reduce((a,n)=>a+(n.memory_total_mb?n.memory_used_mb/n.memory_total_mb*100:0),0)/live.length;
|
||||
const totalVaults = live.reduce((a,n)=>a+n.vault_count,0);
|
||||
document.getElementById('s-cpu').textContent = avgCPU.toFixed(1)+'%';
|
||||
document.getElementById('s-mem').textContent = avgMem.toFixed(1)+'%';
|
||||
document.getElementById('s-vaults').textContent = totalVaults;
|
||||
}
|
||||
|
||||
await Promise.all(nodes.map(n => fetchHistory(n.node_id)));
|
||||
document.getElementById('noc-cards').innerHTML = nodes.map(t => renderCard(t, history[t.node_id])).join('');
|
||||
nodes.forEach(t => {
|
||||
const hist = history[t.node_id] || [];
|
||||
const cpu = document.getElementById('spark-cpu-'+t.node_id);
|
||||
if (cpu) drawSpark(cpu, hist, 'cpu', 'rgb(220,38,38)');
|
||||
});
|
||||
document.getElementById('noc-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch(e) {
|
||||
const err = document.getElementById('noc-error');
|
||||
err.textContent = 'Fetch error: ' + e.message;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
{{define "openclaw-cn"}}
|
||||
<div class="hero container">
|
||||
<p class="label accent mb-4">集成指南</p>
|
||||
<h1 class="mb-6"><span class="vaultname">clav<span class="n">itor</span></span> + OpenClaw</h1>
|
||||
<p class="lead mb-6">为你的 OpenClaw 智能体提供安全的凭据访问。API 密钥、SSH、TOTP 自动化 — 银行卡号和身份证永远加密在你的设备上。</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">共享字段 — AI 可读</span>
|
||||
<h3 class="mb-3">智能体可以访问</h3>
|
||||
<p class="mb-4">智能体通过 MCP 工具读取这些字段来完成部署、认证和自动化。</p>
|
||||
<ul class="checklist">
|
||||
<li>API 密钥 (GitHub, AWS, OpenAI…)</li>
|
||||
<li>SSH 凭据</li>
|
||||
<li>数据库连接信息</li>
|
||||
<li>TOTP 种子 — 自动生成验证码</li>
|
||||
<li>服务密码</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<span class="badge red mb-4">个人字段 — 仅生物识别</span>
|
||||
<h3 class="mb-3">智能体永远无法访问</h3>
|
||||
<p class="mb-4">客户端加密,密钥来自你的生物识别。服务器只存储密文,无法解密。</p>
|
||||
<ul class="checklist red">
|
||||
<li>银行卡号和 CVV</li>
|
||||
<li>身份证号码</li>
|
||||
<li>护照信息</li>
|
||||
<li>恢复代码和助记词</li>
|
||||
<li>社保号码</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">快速开始</h2>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">1. 安装 ClawHub 技能</h3>
|
||||
<div class="code-block">claw install clavitor</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">2. 配置令牌</h3>
|
||||
<p class="mb-4">在 <span class="vaultname">clav<span class="n">itor</span></span> 网页界面创建令牌,然后配置:</p>
|
||||
<div class="code-block"><pre>claw config set clavitor.url "http://localhost:1984/mcp"
|
||||
claw config set clavitor.token "clavitor_your_token_here"</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">3. 在技能中使用</h3>
|
||||
<div class="code-block"><pre># 获取凭据
|
||||
result = clavitor.get_credential("github")
|
||||
|
||||
# 获取实时 TOTP 验证码
|
||||
totp = clavitor.get_totp("aws")
|
||||
|
||||
# 搜索保管库
|
||||
keys = clavitor.search_vault("ssh")</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">使用托管版 <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">你的 MCP URL 包含唯一的保管库标识。注册后可在<strong>账户信息</strong>页面找到完整 URL。</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">格式: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">你什么都不用做</h2>
|
||||
<p class="lead mb-8">连接后,智能体自动处理凭据。需要部署?它查找 SSH 密钥。需要登录?它获取密码并生成验证码。你只需说你想做什么。</p>
|
||||
|
||||
<div class="grid-2 mb-6">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“部署到生产环境”</h3>
|
||||
<p>智能体查找服务器凭据、SSH 密钥和所需的 API 令牌 — 然后执行部署。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“登录 GitHub 检查 CI”</h3>
|
||||
<p>智能体找到凭据,生成实时 TOTP 验证码,完成双因素认证。不需要手机。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3 mb-6">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“找到数据库凭据”</h3>
|
||||
<p>全文搜索所有条目 — 标题、URL、用户名、备注。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“什么快过期了?”</h3>
|
||||
<p>检查即将过期的凭据、银行卡或文档。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“列出所有条目”</h3>
|
||||
<p>列出智能体可见的所有条目。适合盘点或项目交接。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“保存这个 API 密钥”</h3>
|
||||
<p>智能体直接将新凭据、备注和配置存储到你的保管库。注册服务或生成 API 密钥 — 立即保存。</p>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“记住这个”</h3>
|
||||
<p>许可证密钥、服务器配置、迁移计划、恢复说明 — 智能体需要记住的一切都存入保管库,加密且可搜索。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">多智能体支持</h2>
|
||||
<p class="lead mb-8">运行 OpenClaw 智能体集群?每个智能体获得独立的 API 密钥。</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">部署智能体</h3>
|
||||
<p>独立 API 密钥: SSH 密钥、服务器凭据、API 令牌</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">计费智能体</h3>
|
||||
<p>独立 API 密钥: Stripe、支付网关、发票</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">开发智能体</h3>
|
||||
<p>独立 API 密钥: GitHub、CI/CD、数据库凭据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">每次访问都有记录</h2>
|
||||
<p class="lead mb-8">审计日志记录哪个智能体访问了哪个凭据,何时,从哪里。</p>
|
||||
<div class="code-block"><pre><span class="comment">时间 操作 条目 来源</span>
|
||||
2026-03-08 10:23:14 read github.com mcp:claw-deploy
|
||||
2026-03-08 10:23:15 totp github.com mcp:claw-deploy
|
||||
2026-03-08 11:45:02 read aws-production mcp:claw-billing
|
||||
2026-03-08 14:12:33 search "database" mcp:claw-dev</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="text-align:center">
|
||||
<h2 class="mb-4">开始使用</h2>
|
||||
<p class="lead mb-6">自托管永远免费。托管版 $12/年,全球 22 个节点。</p>
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a href="/install" class="btn btn-primary">自托管 (免费)</a>
|
||||
<a href="/hosted" class="btn btn-gold">托管版 ($12/年)</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
{{define "openclaw"}}
|
||||
<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 WebAuthn authenticator.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="grid-2">
|
||||
<div class="card alt">
|
||||
<span class="badge accent mb-4">What your agent sees</span>
|
||||
<h3 class="mb-3">Shared fields</h3>
|
||||
<p class="mb-4">Your agent reads these to authenticate, deploy, and automate.</p>
|
||||
<ul class="checklist">
|
||||
<li>API keys (GitHub, AWS, Stripe, OpenAI…)</li>
|
||||
<li>SSH host credentials</li>
|
||||
<li>Database connection strings</li>
|
||||
<li>TOTP seeds — live 2FA codes on demand</li>
|
||||
<li>Service account passwords</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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 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>
|
||||
<li>Recovery codes & seed phrases</li>
|
||||
<li>Social security numbers</li>
|
||||
<li>Bank account details</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Connect in 60 seconds</h2>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">1. Install the ClawHub skill</h3>
|
||||
<div class="code-block">claw install clavitor</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">2. Configure your token</h3>
|
||||
<p class="mb-4">Create a token in the <span class="vaultname">clav<span class="n">itor</span></span> web UI, then set it in your OpenClaw config:</p>
|
||||
<div class="code-block"><pre>claw config set clavitor.url "http://localhost:1984/mcp"
|
||||
claw config set clavitor.token "clavitor_your_token_here"</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<h3 class="mb-4">3. Use it in your skills</h3>
|
||||
<div class="code-block"><pre># In any OpenClaw skill:
|
||||
result = clavitor.get_credential("github")
|
||||
totp = clavitor.get_totp("aws")
|
||||
keys = clavitor.search_vault("ssh")</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your MCP URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">You don’t have to do anything</h2>
|
||||
<p class="lead mb-8">Once connected, your OpenClaw agent handles credentials automatically. It looks up what it needs, generates 2FA codes, and authenticates — you just describe what you want done.</p>
|
||||
|
||||
<div class="grid-2 mb-6">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Deploy to production”</h3>
|
||||
<p>Your agent looks up server credentials, SSH key, and any required API tokens — then does the deployment.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Log in to GitHub and check the CI”</h3>
|
||||
<p>Your agent finds the credential, generates a live TOTP code, and completes the 2FA flow. No phone needed.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3 mb-6">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Find my database credentials”</h3>
|
||||
<p>Full-text search across all entries — titles, URLs, usernames, notes.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“What’s expiring soon?”</h3>
|
||||
<p>Check for credentials, cards, or documents expiring within any timeframe.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Show me everything”</h3>
|
||||
<p>List all entries the agent has access to. Useful for inventory or onboarding.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Save this API key”</h3>
|
||||
<p>Your agent stores new credentials, notes, and configuration directly in your vault. Sign up for a service, generate an API key — it saves it immediately.</p>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Remember this for later”</h3>
|
||||
<p>License keys, server configs, migration plans, recovery instructions — anything your agent needs to remember goes straight into your vault, encrypted and searchable.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Multi-agent swarm support</h2>
|
||||
<p class="lead mb-8">Running a swarm of OpenClaw agents? Each gets its own API key.</p>
|
||||
<div class="grid-3">
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Deploy agent</h3>
|
||||
<p>Its own API key for SSH keys, server creds, and API tokens</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Billing agent</h3>
|
||||
<p>Its own API key for Stripe, payment gateways, and invoicing</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">Dev agent</h3>
|
||||
<p>Its own API key for GitHub, CI/CD, and database credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Every access is logged</h2>
|
||||
<p class="lead mb-8">The audit log records which agent accessed which credential, when, and from where.</p>
|
||||
<div class="code-block"><pre><span class="comment">TIME ACTION ENTRY ACTOR</span>
|
||||
2026-03-08 10:23:14 read github.com mcp:claw-deploy
|
||||
2026-03-08 10:23:15 totp github.com mcp:claw-deploy
|
||||
2026-03-08 11:45:02 read aws-production mcp:claw-billing
|
||||
2026-03-08 14:12:33 search "database" mcp:claw-dev</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="text-align:center">
|
||||
<h2 class="mb-4">Get started</h2>
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a href="/install" class="btn btn-primary">Self-host (free)</a>
|
||||
<a href="/hosted" class="btn btn-gold">Hosted ($12/yr)</a>
|
||||
</div>
|
||||
</div>
|
||||
{{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 price-grid">
|
||||
|
||||
<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. 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 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">
|
||||
<span class="badge recommended price-badge">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 accent mb-4">Everything in self-hosted, plus</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container prose-width">
|
||||
<p class="label mb-6 text-center">Common questions</p>
|
||||
<h2 class="mb-8 text-center">FAQ</h2>
|
||||
|
||||
<div class="prose">
|
||||
<h3>Why so cheap?</h3>
|
||||
<p>AI agents are everywhere — and so are the security risks. We set a price that's within reach for everyone, whether you're in the US, Nigeria, or the Philippines. $12/yr is launch pricing; regular price is $20/yr. Join now and your price is locked for life.</p>
|
||||
|
||||
<h3>Does self-hosted get every feature?</h3>
|
||||
<p>Yes. Every feature ships in both versions. Hosted adds managed infrastructure and backups — not functionality.</p>
|
||||
|
||||
<h3>Are my Identity fields safe on the hosted server?</h3>
|
||||
<p>Yes. Identity 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>Can I get a refund?</h3>
|
||||
<p>Yes. 7-day money-back, no questions asked, instant refund.</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 text-sm text-tertiary">Last updated: February 2026</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="prose prose-width">
|
||||
|
||||
<h2>The short version</h2>
|
||||
<ul>
|
||||
<li>Your vault is protected by three encryption layers: Vault Encryption (at rest), Credential Encryption (per-field), and Identity Encryption (client-side). All data is encrypted in transit (TLS).</li>
|
||||
<li>Identity 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>Credential fields:</strong> encrypted at rest with AES-256-GCM using your vault key</li>
|
||||
<li><strong>Identity fields:</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>Identity Encryption guarantee</h2>
|
||||
<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>
|
||||
|
||||
<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. Credential fields are encrypted with your vault key (which we do not store). Identity fields are 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,43 +0,0 @@
|
|||
{{define "signup"}}
|
||||
<div class="hero container" style="min-height:60vh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center">
|
||||
<div class="logo-lockup-square" style="width:64px;height:64px;margin-bottom:2rem"></div>
|
||||
<h1 class="mb-4">Coming soon</h1>
|
||||
<p class="lead mb-6">We're not quite ready yet. Leave your email and we'll notify you when signups open.</p>
|
||||
<form id="notify-form" style="display:flex;gap:8px;max-width:400px;width:100%">
|
||||
<input type="email" name="email" required placeholder="you@example.com" style="flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
<button type="submit" class="btn btn-primary">Notify me</button>
|
||||
</form>
|
||||
<p id="notify-msg" class="mt-4" style="display:none"></p>
|
||||
<p class="mt-8"><a href="/" class="btn btn-ghost">← Back to home</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "signup-script"}}
|
||||
<script>
|
||||
document.getElementById('notify-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const email = this.email.value;
|
||||
const msg = document.getElementById('notify-msg');
|
||||
const btn = this.querySelector('button');
|
||||
btn.disabled = true; btn.textContent = 'Sending...';
|
||||
try {
|
||||
const r = await fetch('/notify', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({email})});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
msg.textContent = 'Got it. We\'ll let you know.';
|
||||
msg.style.color = '#16a34a';
|
||||
this.style.display = 'none';
|
||||
} else {
|
||||
msg.textContent = d.error || 'Something went wrong.';
|
||||
msg.style.color = 'var(--brand-red)';
|
||||
btn.disabled = false; btn.textContent = 'Notify me';
|
||||
}
|
||||
} catch(err) {
|
||||
msg.textContent = 'Connection error. Try again.';
|
||||
msg.style.color = 'var(--brand-red)';
|
||||
btn.disabled = false; btn.textContent = 'Notify me';
|
||||
}
|
||||
msg.style.display = 'block';
|
||||
});
|
||||
</script>
|
||||
{{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 Credential/Identity 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 text-sm text-tertiary">
|
||||
All quotes verbatim from public posts. URLs verified.
|
||||
<a href="https://github.com/johanjongsma/clavitor/wiki/sources" class="text-accent">View sources →</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
{{define "status"}}
|
||||
<div class="hero container" style="padding-bottom:0">
|
||||
<p class="label accent mb-4">System Status</p>
|
||||
<h1 class="mb-4">Clavitor Status</h1>
|
||||
</div>
|
||||
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div id="status-banner" style="padding:16px 20px;border-radius:var(--radius-sm);margin-bottom:32px;font-weight:600;font-size:0.95rem;display:flex;align-items:center;gap:10px;background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0">
|
||||
<span style="font-size:1.2rem">✔</span>
|
||||
<span id="status-text">Loading...</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-tertiary" style="margin-bottom:16px;display:flex;justify-content:space-between">
|
||||
<span>Uptime over the past 90 days. All times UTC. Last heartbeat: <span id="last-beat">—</span></span>
|
||||
<span id="utc-clock"></span>
|
||||
</p>
|
||||
|
||||
<div id="status-nodes"></div>
|
||||
|
||||
<div id="status-incidents" style="margin-top:48px"></div>
|
||||
|
||||
<p class="mt-4 text-sm text-tertiary" id="status-updated"></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "status-script"}}
|
||||
<style>
|
||||
.st-node { margin-bottom:28px; }
|
||||
.st-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px; }
|
||||
.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; }
|
||||
.st-bar-down { background:#ef4444; }
|
||||
.st-bar-degraded { background:#eab308; }
|
||||
.st-bar-unknown { background:#e5e7eb; }
|
||||
.st-range { display:flex; justify-content:space-between; font-size:0.7rem; color:var(--text-tertiary); margin-top:4px; }
|
||||
.st-uptime-pct { font-size:0.7rem; color:var(--text-tertiary); text-align:center; margin-top:2px; }
|
||||
.st-incident { border-left:3px solid var(--border); padding:12px 16px; margin-bottom:12px; }
|
||||
.st-incident-title { font-weight:600; font-size:0.9rem; }
|
||||
.st-incident-meta { font-size:0.75rem; color:var(--text-tertiary); margin-top:2px; }
|
||||
.st-tooltip { display:none; position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); background:#fff; border:1px solid var(--border); border-radius:6px; padding:10px 14px; min-width:220px; box-shadow:0 4px 12px rgba(0,0,0,0.1); z-index:10; font-size:0.75rem; }
|
||||
.st-tooltip-date { font-weight:600; margin-bottom:6px; }
|
||||
.st-tooltip-bar { display:flex; height:8px; border-radius:2px; overflow:hidden; margin-bottom:6px; }
|
||||
.st-tooltip-bar .up { background:#22c55e; }
|
||||
.st-tooltip-bar .down { background:#ef4444; }
|
||||
.st-tooltip-spans { color:var(--text-tertiary); line-height:1.6; }
|
||||
.st-bar { position:relative; }
|
||||
.st-bar:hover .st-tooltip { display:block; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch('/status/api', {cache:'no-cache'});
|
||||
const data = await res.json();
|
||||
|
||||
// Banner
|
||||
const banner = document.getElementById('status-banner');
|
||||
const allOp = data.overall === 'All Systems Operational';
|
||||
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);
|
||||
document.getElementById('last-beat').textContent = d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
|
||||
}
|
||||
|
||||
// Nodes
|
||||
const nodes = data.nodes || [];
|
||||
const dates = data.dates || [];
|
||||
let html = '';
|
||||
// Separate live, planned, and outage
|
||||
const live = nodes.filter(n => n.status === 'live');
|
||||
const planned = nodes.filter(n => n.status === 'planned');
|
||||
const outageNodes = nodes.filter(n => n.status === 'outage');
|
||||
|
||||
for (const n of live) {
|
||||
const days = n.uptime_90 || [];
|
||||
const withData = days.filter(d => d.pct >= 0);
|
||||
const avgPct = withData.length > 0
|
||||
? (withData.reduce((a, d) => a + d.pct, 0) / withData.length).toFixed(2)
|
||||
: '--';
|
||||
|
||||
const healthClass = 'st-health-' + n.health;
|
||||
const healthLabel = n.health.charAt(0).toUpperCase() + n.health.slice(1);
|
||||
|
||||
html += `<div class="st-node">
|
||||
<div class="st-header">
|
||||
<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">`;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const d = days[i];
|
||||
const isToday = d.date === today;
|
||||
let cls, h;
|
||||
if (d.pct < 0) { cls = 'unknown'; h = '40%'; }
|
||||
else if (d.pct === 100) { cls = 'operational'; h = '100%'; }
|
||||
else if (d.pct >= 99) { cls = 'degraded'; h = '100%'; }
|
||||
else if (d.pct > 0) { cls = 'down'; h = '100%'; }
|
||||
else { cls = 'down'; h = '100%'; }
|
||||
html += `<div class="st-bar st-bar-${cls}" data-node="${n.id}" data-date="${d.date}" data-pct="${d.pct}" style="height:${h}" onmouseenter="showDayTooltip(this)"><div class="st-tooltip"></div></div>`;
|
||||
}
|
||||
|
||||
html += `</div>
|
||||
<div class="st-range"><span>90 days ago</span><span>${avgPct}% uptime</span><span>Today</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (outageNodes.length > 0) {
|
||||
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)">Awaiting Recovery</div>`;
|
||||
for (const n of outageNodes) {
|
||||
html += `<div class="st-node" style="opacity:0.6"><div class="st-header"><div><span class="pop-city">${n.city}</span> <span class="pop-country">${n.country}</span></div><span class="st-health st-health-down">Outage</span></div></div>`;
|
||||
}
|
||||
}
|
||||
if (planned.length > 0) {
|
||||
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="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>`;
|
||||
}
|
||||
document.getElementById('status-nodes').innerHTML = html;
|
||||
|
||||
// Outages
|
||||
const outages = data.outages || [];
|
||||
if (outages.length > 0) {
|
||||
let ohtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Incidents</h3>';
|
||||
ohtml += '<table class="data-table"><thead><tr><th>Start</th><th>End</th><th>Node</th><th>Status</th><th>Description</th></tr></thead><tbody>';
|
||||
for (const o of outages) {
|
||||
const statusColor = o.status === 'resolved' ? '#16a34a' : o.status === 'ongoing' ? '#dc2626' : '#ca8a04';
|
||||
ohtml += `<tr>
|
||||
<td style="white-space:nowrap">${o.start_at}</td>
|
||||
<td style="white-space:nowrap">${o.end_at || '—'}</td>
|
||||
<td>${o.node_id}</td>
|
||||
<td><span style="color:${statusColor};font-weight:600;font-size:0.8rem;text-transform:uppercase">${o.status}</span></td>
|
||||
<td style="color:var(--text-secondary)">${o.description}</td>
|
||||
</tr>`;
|
||||
}
|
||||
ohtml += '</tbody></table>';
|
||||
document.getElementById('status-incidents').innerHTML = ohtml;
|
||||
} else {
|
||||
document.getElementById('status-incidents').innerHTML =
|
||||
'<p class="text-sm text-tertiary" style="margin-top:32px">No incidents.</p>';
|
||||
}
|
||||
|
||||
document.getElementById('status-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch(e) {
|
||||
console.error('status refresh error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
document.getElementById('utc-clock').textContent = 'now: ' + now.toISOString().slice(0, 10) + ' ' + now.toISOString().slice(11, 19) + ' UTC';
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
refresh();
|
||||
// Align refresh to :00 and :30 of each minute
|
||||
function scheduleRefresh() {
|
||||
const now = new Date();
|
||||
const s = now.getSeconds();
|
||||
const next = s < 30 ? 30 - s : 60 - s;
|
||||
setTimeout(() => { refresh(); scheduleRefresh(); }, next * 1000);
|
||||
}
|
||||
scheduleRefresh();
|
||||
|
||||
const spanCache = {};
|
||||
window.showDayTooltip = async function(bar) {
|
||||
const tip = bar.querySelector('.st-tooltip');
|
||||
const node = bar.dataset.node;
|
||||
const date = bar.dataset.date;
|
||||
const pct = parseFloat(bar.dataset.pct);
|
||||
const key = node + ':' + date;
|
||||
|
||||
if (pct < 0) {
|
||||
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">No data</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Loading...</div>`;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (!spanCache[key] || date === today) {
|
||||
try {
|
||||
const r = await fetch('/status/api/spans?node=' + encodeURIComponent(node) + '&date=' + date);
|
||||
spanCache[key] = await r.json();
|
||||
} catch(e) {
|
||||
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Error loading</div>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = spanCache[key];
|
||||
const spans = data.spans || [];
|
||||
const total = data.day_end - data.day_start;
|
||||
if (!total || !spans.length) {
|
||||
tip.innerHTML = `<div class="st-tooltip-date">${date} — ${pct.toFixed(1)}% uptime</div><div style="color:var(--text-tertiary)">No spans</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build visual bar
|
||||
let barHtml = '<div class="st-tooltip-bar">';
|
||||
for (const s of spans) {
|
||||
const w = ((s.end - s.start) / total * 100).toFixed(1);
|
||||
barHtml += `<div class="${s.type}" style="width:${w}%"></div>`;
|
||||
}
|
||||
barHtml += '</div>';
|
||||
|
||||
// Build text list
|
||||
let listHtml = '<div class="st-tooltip-spans">';
|
||||
for (const s of spans) {
|
||||
const start = new Date(s.start * 1000).toISOString().slice(11, 16);
|
||||
const end = new Date(s.end * 1000).toISOString().slice(11, 16);
|
||||
const dur = Math.round((s.end - s.start) / 60);
|
||||
const icon = s.type === 'up' ? '●' : '○';
|
||||
const color = s.type === 'up' ? '#16a34a' : '#dc2626';
|
||||
listHtml += `<div><span style="color:${color}">${icon}</span> ${start}–${end} UTC (${dur}m ${s.type})</div>`;
|
||||
}
|
||||
listHtml += '</div>';
|
||||
|
||||
tip.innerHTML = `<div class="st-tooltip-date">${date} — ${pct.toFixed(1)}% uptime</div>${barHtml}${listHtml}`;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
{{define "styleguide-head"}}
|
||||
<style>
|
||||
.sg-section { padding: 3rem 0; border-top: 1px solid var(--border); }
|
||||
.sg-title { font-family: var(--font-mono); font-size: 0.65rem; letter-spacing: 0.15em; text-transform: uppercase; color: var(--subtle); margin-bottom: 1.5rem; }
|
||||
.swatch { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
.swatch-box { width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.1); }
|
||||
.swatch-name{ font-family: var(--font-mono); font-size: 0.8rem; color: var(--text); }
|
||||
.swatch-val { font-family: var(--font-mono); font-size: 0.75rem; color: var(--subtle); }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "styleguide"}}
|
||||
<div class="container" style="padding-top:80px">
|
||||
<div style="padding: 5rem 0 2rem">
|
||||
<p class="label accent mb-3">Design System</p>
|
||||
<h1>Clavitor Styleguide</h1>
|
||||
<p class="lead mt-4">Single source of truth. One stylesheet: <code style="font-family:var(--font-mono);color:var(--accent)">clavitor.css</code>. No inline styles in HTML.</p>
|
||||
</div>
|
||||
|
||||
<!-- COLORS -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Colors</p>
|
||||
<div class="grid-3">
|
||||
<div>
|
||||
<p class="label mb-4">Backgrounds</p>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--bg)"></div><div><div class="swatch-name">--bg</div><div class="swatch-val">#0A1628</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--surface)"></div><div><div class="swatch-name">--surface</div><div class="swatch-val">#0d1627</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--surface-alt)"></div><div><div class="swatch-name">--surface-alt</div><div class="swatch-val">#0a1a0a</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--surface-gold)"></div><div><div class="swatch-name">--surface-gold</div><div class="swatch-val">#2a1f00</div></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label mb-4">Text</p>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--text)"></div><div><div class="swatch-name">--text</div><div class="swatch-val">#f1f5f9</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--muted)"></div><div><div class="swatch-name">--muted</div><div class="swatch-val">#94a3b8</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--subtle)"></div><div><div class="swatch-name">--subtle</div><div class="swatch-val">#64748b</div></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label mb-4">Accent</p>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--accent)"></div><div><div class="swatch-name">--accent</div><div class="swatch-val">#22C55E</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--gold)"></div><div><div class="swatch-name">--gold</div><div class="swatch-val">#D4AF37</div></div></div>
|
||||
<div class="swatch"><div class="swatch-box" style="background:var(--red)"></div><div><div class="swatch-name">--red</div><div class="swatch-val">#EF4444</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TYPOGRAPHY -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Typography</p>
|
||||
<h1 class="mb-4">h1 — Your vault. Wherever you want it.</h1>
|
||||
<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 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>
|
||||
<p class="label red mb-2">label.red</p>
|
||||
<p class="mt-4"><span class="vaultname">clavitor</span> — vaultname in body text</p>
|
||||
</div>
|
||||
|
||||
<!-- CARDS -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Cards</p>
|
||||
<div class="grid-3 mb-8">
|
||||
<div class="card">
|
||||
<p class="label accent mb-3">card (default)</p>
|
||||
<h3 class="mb-3">Default surface</h3>
|
||||
<p>Use for neutral content. Background is --surface.</p>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<p class="label accent mb-3">card.alt</p>
|
||||
<h3 class="mb-3">Credential fields</h3>
|
||||
<p>Green-tinted surface. Use for credential layer content.</p>
|
||||
</div>
|
||||
<div class="card gold">
|
||||
<p class="label gold mb-3">card.gold</p>
|
||||
<h3 class="mb-3">Zürich, Switzerland</h3>
|
||||
<p>Gold-tinted surface. Use exclusively for Zürich/HQ.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3">
|
||||
<div class="card red">
|
||||
<p class="label red mb-3">card.red</p>
|
||||
<h3 class="mb-3">Self-hosted</h3>
|
||||
<p>Red-tinted surface. Use for self-hosted / warning contexts.</p>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<p class="label mb-3">card + card-hover</p>
|
||||
<h3 class="mb-3">Hover state</h3>
|
||||
<p>Hover this card — lifts on hover. Add to any clickable card.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUTTONS -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Buttons</p>
|
||||
<div class="btn-row mb-6">
|
||||
<a href="#" class="btn btn-primary">btn-primary</a>
|
||||
<a href="#" class="btn btn-ghost">btn-ghost</a>
|
||||
<a href="#" class="btn btn-accent">btn-accent</a>
|
||||
<a href="#" class="btn btn-gold">btn-gold</a>
|
||||
<a href="#" class="btn btn-red">btn-red</a>
|
||||
</div>
|
||||
<p class="label mb-4">btn-row — flex wrap container for button groups</p>
|
||||
<div class="grid-3">
|
||||
<a href="#" class="btn btn-accent btn-block">btn-accent btn-block</a>
|
||||
<a href="#" class="btn btn-gold btn-block">btn-gold btn-block</a>
|
||||
<a href="#" class="btn btn-red btn-block">btn-red btn-block</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BADGES -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Badges</p>
|
||||
<div class="btn-row mb-4">
|
||||
<span class="badge accent">badge.accent</span>
|
||||
<span class="badge gold">badge.gold</span>
|
||||
<span class="badge red">badge.red</span>
|
||||
<span class="badge recommended">badge.recommended</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO SPLIT -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Hero split</p>
|
||||
<p class="label mb-4">hero-split — two-column hero with text left, visual right. Use on .container instead of .hero.</p>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<p class="label accent mb-2">Left column</p>
|
||||
<p>Text, heading, lead paragraph, btn-row.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="label accent mb-2">Right column</p>
|
||||
<p>SVG diagram or visual. Vertically centered via align-items.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CODE BLOCKS -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Code blocks</p>
|
||||
<div class="code-block mb-6">
|
||||
<p class="code-label">Terminal</p>
|
||||
<div><span class="comment"># comment</span></div>
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
</div>
|
||||
<div class="code-block">
|
||||
<p class="code-label">JSON — code-block pre resets margin and sets --muted</p>
|
||||
<pre>{
|
||||
"mcpServers": {
|
||||
"clavitor": {
|
||||
"url": "http://localhost:1984/mcp",
|
||||
"headers": { "Authorization": "Bearer <span class="prompt">token_here</span>" }
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GRID -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Grid</p>
|
||||
<p class="label mb-4">grid-2</p>
|
||||
<div class="grid-2 mb-8">
|
||||
<div class="card"><p class="label mb-2">col 1</p><p>Always 1fr.</p></div>
|
||||
<div class="card"><p class="label mb-2">col 2</p><p>Always 1fr.</p></div>
|
||||
</div>
|
||||
<p class="label mb-4">grid-3</p>
|
||||
<div class="grid-3">
|
||||
<div class="card"><p class="label mb-2">col 1</p><p>Always 1fr.</p></div>
|
||||
<div class="card"><p class="label mb-2">col 2</p><p>Always 1fr.</p></div>
|
||||
<div class="card"><p class="label mb-2">col 3</p><p>Always 1fr.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPACING -->
|
||||
<div class="sg-section">
|
||||
<p class="sg-title">Spacing scale</p>
|
||||
<p class="mb-4">All spacing via utility classes: <code style="font-family:var(--font-mono);color:var(--accent)">.mt-2 .mt-3 .mt-4 .mt-6 .mt-8 .mt-12</code> and matching <code style="font-family:var(--font-mono);color:var(--accent)">.mb-*</code>.</p>
|
||||
<p class="label mb-3">Container: max-width 1100px, padding 2rem each side. Used everywhere.</p>
|
||||
<p class="label">Section: padding 4rem top/bottom. Separated by hr.divider.</p>
|
||||
</div>
|
||||
|
||||
<div style="padding:4rem 0;text-align:center">
|
||||
<p class="label">End of styleguide</p>
|
||||
</div>
|
||||
</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 text-sm text-tertiary">Last updated: February 2026</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<div class="prose prose-width">
|
||||
|
||||
<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 three-tier encryption: Vault Encryption (at rest), Credential Encryption (per-field, server-side), and Identity Encryption (per-field, client-side). The hosted service stores encrypted vault data on your behalf. The self-hosted version (Elastic License 2.0) 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 Identity 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>Credential fields (server-encrypted) provide strong encryption at rest and in transit. Identity 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}}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 130 KiB |
Binary file not shown.
Loading…
Reference in New Issue