Luna: Add currency dropdown sections with Popular/All Currencies split #18

Merged
johan merged 34 commits from luna/design-11 into master 2026-04-09 14:20:18 +00:00
123 changed files with 15052 additions and 2784 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
._CLAVITOR-PRINCIPLES.md Normal file

Binary file not shown.

View File

@ -1,5 +1,10 @@
# Clavitor — Agent Instructions
> **Quickstart (60 seconds):** [QUICKSTART.md](QUICKSTART.md) — who you are, Gitea login, 4 things to do, critical rules.
> **Deep reference:** [CLAVITOR-AGENT-HANDBOOK.md](CLAVITOR-AGENT-HANDBOOK.md) — full principles, all sections.
> **First session ever:** Read handbook end-to-end + do Quickstart setup. All other sessions: Quickstart + your domain in Section V.
> **Daily:** Run `./scripts/daily-review.sh` before any work. Fix failures first.
## Foundation First — No Mediocrity. Ever.
The rule is simple: do it right, or say something.

1549
CLAVITOR-AGENT-HANDBOOK.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,914 @@
# CLAVITOR-PRINCIPLES.md Review Report
**Date:** 2026-04-08
**Reviewer:** Claude Code
**Scope:** Full document review across correctness, conciseness, completeness, viability, security, consistency, clarity, actionability, maintainability, audibility, compliance, and localization.
---
## Executive Summary
The document is exceptionally well-constructed for a security-critical project. It demonstrates strong architectural thinking, clear threat modeling, and actionable daily practices. However, several issues ranging from minor inconsistencies to potential security blind spots warrant attention.
| Metric | Score | Notes |
|--------|-------|-------|
| Security | 9/10 | Comprehensive threat model, one minor gap in Threat C analysis |
| Correctness | 8/10 | Technical details accurate, some terminology drift |
| Completeness | 7/10 | Missing incident response, versioning, and escalation procedures |
| Conciseness | 7/10 | Some redundancy and over-explanation |
| Consistency | 8/10 | Generally consistent, minor formatting drift |
| Viability | 8/10 | Mostly viable, some idealistic rules may create friction |
| Clarity | 9/10 | Exceptionally clear with concrete examples |
| Actionability | 9/10 | Daily checklist is exemplary |
| Maintainability | 8/10 | Good, but needs deprecation tracking |
| Audibility | 9/10 | Excellent grep-driven verification (see F036 on LLM-era evolution) |
| Compliance | 6/10 | GDPR mentioned but narrowly; missing SOC2, PCI-DSS, data retention, cross-border transfer |
| Localization | 1/10 | **Not addressed** — no i18n/l10n guidance for multi-language UI |
---
## Findings
### F001: Redundancy — "Foundation First" duplicated
**Location:** Lines 10-13 and 20-34 (restatement)
**Severity:** Low
**Metric:** Conciseness
The "Foundation First — No Mediocrity. Ever." principle appears with significant overlap: first as an introductory statement (lines 10-13), then expanded in Part 1 (lines 20-34). The second version is more complete; the first is redundant.
**Recommended Action:**
Remove the introductory paragraph (lines 10-13) and rely solely on the Part 1 version, which is more complete. Alternatively, reduce the intro to a single sentence: "Foundation First — see Part 1."
---
### F002: Missing — "Quick fixes are not fixes" lacks counter-example
**Location:** Line 28
**Severity:** Medium
**Metric:** Completeness
The principle states "A 'temporary' hack that ships is permanent" but doesn't provide the positive pattern. Developers need to know what TO do when urgent fixes are needed.
**Recommended Action:**
Add a positive pattern example:
```
When urgent: fix the symptom with a comment containing // TEMP-FIX: <ticket> and
a mandatory fix-by date. No TEMP-FIX older than 72 hours may exist in main.
```
---
### F003: Inconsistency — "No migrations during early-stage work" vs "MigrateDB" exists
**Location:** Lines 79-85, 645-650 (checklist)
**Severity:** Medium
**Metric:** Consistency
The principle states "Do not write migration code" but the daily checklist (C3) acknowledges `MigrateDB` exists "for the existing `alternate_for` and `verified_at` columns (legacy from earlier work)." This creates a "do as I say, not as I do" situation.
**Recommended Action:**
Either: (a) Remove the legacy migration code and update C3, or (b) add explicit text to the principle acknowledging the grandfathered exception and stating when the exemption expires (e.g., "Remove MigrateDB when legacy columns are phased out by [date]").
---
### F004: Viability Risk — "No env vars for fundamental paths" may block valid use cases
**Location:** Lines 54-55, 636-642 (checklist)
**Severity:** Medium
**Metric:** Viability
The KISS principle states "No env vars when a hardcoded relative path is correct" with examples `./vaults/` and `WL3`. While simplicity is valuable, this hardcoding prevents:
- Container deployments with volume mounts at specific paths
- Multi-tenant hosting with separate data directories
- Compliance requirements requiring data on encrypted filesystems at specific mount points
**Recommended Action:**
Add a clarification: "Exception: When the deployment environment (containers, compliance mounts) requires configurable paths, a single `CLAVITOR_DATA_ROOT` env var is acceptable, but no per-path fragmentation (DATA_DIR, WL3_DIR, VAULT_DIR, etc.)."
---
### F005: Technical Inaccuracy — "Two crypto implementations of the same primitive" check is misleading
**Location:** Lines 676-688 (checklist D2)
**Severity:** Medium
**Metric:** Correctness
The checklist says to run `diff clavis-vault/cmd/clavitor/web/crypto.js clavis-cli/crypto/crypto.js` and expects "zero diff (or... intentionally different... documented)." This is misleading because:
1. The CLI crypto.js may need target-specific adapters (BearSSL vs WebCrypto)
2. The paths suggest different directory structures that may require different module exports
3. A zero-diff requirement would prevent any platform-specific optimizations
**Recommended Action:**
Change the check to:
"Expected: core encryption/decryption logic (`encrypt_field`, `decrypt_field`, HKDF) is byte-identical. Platform-specific wrappers/adapters are permitted if documented in `clavis-crypto/README.md` and don't affect the output format."
---
### F006: Missing — No incident response procedure
**Location:** Entire document
**Severity:** High
**Metric:** Completeness
The document thoroughly describes prevention but contains zero incident response guidance. Given the security-critical nature, this is a significant gap:
- What happens when Threat A is detected? (agent locked, then what?)
- Who is notified of a harvester pattern?
- What's the SLA for PRF unlock after a false positive lockout?
- How is a compromised vault file rotated?
**Recommended Action:**
Add Part 7: Incident Response with sections for:
1. Agent lockout recovery procedure
2. Compromised credential token rotation
3. Audit log review cadence
4. Escalation contacts/owner notification
5. Emergency PRF unlock process (if owner loses authenticator)
---
### F007: Security Gap — Threat C analysis misses "steal at creation" vector
**Location:** Lines 274-298
**Severity:** High
**Metric:** Security
Threat C discusses stolen CVT client credentials, but misses a critical sub-vector: **credential theft at creation time**. When an agent is created:
1. Browser generates L2
2. Browser encrypts with L0, creates type-0x01 token
3. This token briefly exists in browser memory before being sent to agent
A malicious browser extension or XSS could exfiltrate the token during this window. The per-agent IP whitelist helps post-creation, but doesn't address theft-at-creation.
**Recommended Action:**
Add to Threat C:
"Sub-vector C2 — Credential theft at creation: The type-0x01 token exists transiently in browser memory during agent enrollment. Defense: agent tokens should be delivered through a side-channel (QR code display + camera scan by agent device, or file download + scp) rather than clipboard or direct HTTP response to browser. Document the secure transfer mechanism in the agent enrollment flow."
---
### F008: Consistency — "Must never" lists use inconsistent formatting
**Location:** Part 3 subprojects
**Severity:** Low
**Metric:** Consistency
The "Must never" sections across subprojects use inconsistent formatting:
- `clavis-vault`: bullet list with "- Have functions named..." (line 358)
- `clavis-cli`: bullet list with "- Bridge to Go..." (line 405)
- `clavis-chrome`: bullet list with "- Store L2 or L3..." (line 456)
- `clavitor.ai/admin`: bullet list with "- Hold any decryption material..." (line 491)
Some use "- Verb..." (action-oriented), others use "- No/None..." (prohibition-oriented). `clavis-vault` has a "Must never" that starts with "Have" which is confusing ("Must never have functions named..." vs "Must never: have functions named...")
**Recommended Action:**
Standardize all "Must never" bullets to start with action verbs in negative form:
- "Implement crypto operations in Go" (not "Bridge to Go for any...")
- "Store L2 or L3 in extension storage" (not "Use a different field...")
---
### F009: Missing — No definition of "early-stage"
**Location:** Lines 79-85, 362
**Severity:** Medium
**Metric:** Completeness
The document references "early-stage work" multiple times as justification for no migrations, no backwards compatibility, and willingness to delete vaults. However, "early-stage" is never defined. This creates ambiguity about when the rule flips.
**Recommended Action:**
Add a definition in Part 1 or a footnote:
"'Early-stage' means: no paying customers, no production deployments outside the core team's infrastructure, and version < 1.0. The project remains in early-stage until [specific criteria: e.g., first paid subscription, 100 active vaults, or v1.0 release]. When early-stage ends, the principles on migrations and backwards compatibility will be revised."
---
### F010: Audibility Gap — Checklist A6 (keys never logged) is insufficient
**Location:** Lines 587-593
**Severity:** Medium
**Metric:** Audibility, Security
The grep for A6 checks for obvious key material (l1Key, master, wrappedL3, prf, p1Bytes) but misses:
- Variable naming variations (l1_key, l1KeyBytes, masterKeyBytes)
- Hex encoding patterns that might reveal keys in logs
- Indirect logging via error wrapping (errors.Wrap(err, "failed with master key: " + key))
**Recommended Action:**
Enhance A6 with additional patterns:
```bash
# Check for variable naming variations
grep -rnE "(l1[_-]?key|l1[_-]?bytes|master[_-]?key|wrapped[_-]?l3|prf[_-]?output|p1[_-]?bytes)" \
clavis-vault/api/ clavis-vault/lib/ | grep -iE "log\.|fmt\.Print|errors\.|AuditEvent"
# Check for hex encoding of key-sized data (32 bytes = 64 hex chars)
grep -rnE "%x.*\[32\]|%x.*\[64\]|hex\.EncodeToString.*key|hex\.EncodeToString.*master" \
clavis-vault/api/ clavis-vault/lib/
```
---
### F011: Viability Risk — "No docstrings on code you didn't change" may reduce maintainability
**Location:** Lines 104-105
**Severity:** Low
**Metric:** Viability, Maintainability
The KISS principle discourages adding docstrings to unchanged code. While this prevents PR bloat, it may result in persistently under-documented legacy code. As the codebase grows and original authors leave, this creates knowledge silos.
**Recommended Action:**
Add a clarifying exception:
"No docstrings on code you didn't change, UNLESS you needed to spend >10 minutes understanding what the code does. In that case, add the docstring you wish existed — pay it forward."
---
### F012: Technical Inaccuracy — Threat B time calculation uses Grover's incorrectly
**Location:** Line 267
**Severity:** Low
**Metric:** Correctness
The text states: "32-byte AES-256-GCM keys are not brute-forceable. ~10⁵⁷ years at 10¹² guesses/sec; Grover's quantum speedup leaves 2¹²⁸ effective security."
Grover's algorithm reduces the effective key space by a square root, not to 2¹²⁸. For AES-256, Grover's provides a sqrt(2²⁵⁶) = 2¹²⁸ speedup, meaning the effective security is reduced to that of a 128-bit classical key, not "2¹²⁸ effective security." The phrasing is confusing.
**Recommended Action:**
Correct to:
"32-byte AES-256-GCM keys are not brute-forceable. ~10⁵⁷ years at 10¹² guesses/sec; Grover's quantum algorithm provides a square-root speedup, reducing effective security to that of a 128-bit classical cipher — still computationally infeasible (~10²⁹ years)."
---
### F013: Missing — No guidance on principle deprecation
**Location:** Lines 768-771
**Severity:** Medium
**Metric:** Completeness
Part 5 mentions marking principles deprecated but doesn't specify the mechanism:
- What format should the deprecation notice take?
- Who authorizes deprecation?
- How long should deprecated principles remain before removal?
- Should deprecated principles move to an appendix or stay in place?
**Recommended Action:**
Add to Part 5:
```markdown
### Deprecation format
When a principle is obsolete:
1. Add **DEPRECATED** in bold at the start of the principle header
2. Include: date deprecated, reason, replacement principle (if any), and authorizing person (e.g., "Deprecated 2026-04-08: Early-stage ended. Migration code now required. — Johan")
3. Deprecated principles remain in place for 90 days, then move to Appendix A: Deprecated Principles
4. A deprecated principle's grep checks in Part 4 are commented out with a reason
```
---
### F014: Redundancy — "No backwards-compat shims" duplicates "Don't add what's not needed"
**Location:** Lines 87-96 vs 98-105
**Severity:** Low
**Metric:** Conciseness
The "No backwards-compat shims" section lists specific anti-patterns (flags, rename hacks, re-exports, removed comments, commented-out code). The following section "Don't add what's not needed" has overlapping scope. Both mention dead code/comments.
**Recommended Action:**
Merge the two sections under "Don't add what's not needed" and structure as:
```markdown
### Don't add what's not needed (and remove what isn't used)
- No backwards-compatibility shims: ...
- No error handling for impossible situations: ...
[etc]
```
---
### F015: Security Gap — No guidance on secure deletion of keys from memory
**Location:** Part 2, threat model sections
**Severity:** Medium
**Metric:** Security, Completeness
The document extensively discusses where keys live (L0, L1, L2, L3) and what never holds them, but provides no guidance on secure deletion of key material from memory after use. In Go, this is particularly important because:
- The garbage collector may move/copy memory
- `memset` or `clear` doesn't guarantee overwriting the physical memory
- Sensitive data may remain in memory after session close
**Recommended Action:**
Add to Part 2 (after Cardinal Rule #3) or to `clavis-crypto` subproject:
```markdown
### Secure key lifecycle in memory
When a key is no longer needed (session close, credential operation complete):
- Use `memset_explicit` (C11) or `runtime.memclrNoHeapPointers` (Go) to clear buffers
- In Go, sensitive byte slices should be allocated from `mmap` with `PROT_READ|PROT_WRITE`
and `MADV_DONTDUMP` to exclude from core dumps
- The browser's `sessionStorage` clear is automatic on tab close; no additional action needed
- CLI: clear key buffers before exit; use `explicit_bzero` on sensitive stack buffers
```
---
### F016: Correctness — Part 4 Section F uses imprecise commands
**Location:** Lines 729-742
**Severity:** Low
**Metric:** Correctness, Audibility
Check F1 says to run `go test ./lib/... ./api/...` but this doesn't capture all tests in the monorepo. F2 says `make build` but the Makefile might have other targets that matter. F3 is described as "not greppable" but could include guidance.
**Recommended Action:**
Update F1:
```bash
cd clavis-vault && go test ./...
cd clavitor.ai/admin && go test ./...
# Add any other test directories
```
Update F2:
```bash
cd clavis-vault && make dev # validates JS as part of dev build
# or for CI: make build
```
Add to F3:
```markdown
**F3. New code has tests.**
```
git diff --name-only HEAD~1 | grep -E "\.go$" | while read f; do
test_file="$(dirname $f)/$(basename $f .go)_test.go"
[ -f "$test_file" ] || echo "WARNING: $f has no test file"
done
```
```
---
### F017: Viability Risk — "Restart rule" is not actionable
**Location:** Lines 32-34
**Severity:** Medium
**Metric:** Viability, Actionability
The "restart rule" states: "When the foundation is wrong: start over. Not 'refactor slightly'... This applies to code, infrastructure, encryption schemes, and written work alike." This is philosophically correct but operationally vague:
- What constitutes "wrong" enough to restart?
- How much sunk cost is too much to restart?
- What's the approval process for a restart vs refactor?
**Recommended Action:**
Add decision criteria:
```markdown
### When to restart vs. refactor
**Restart when** (any of):
- The core abstraction is incorrect (e.g., server holding L2 material)
- Three or more fundamental fixes are needed for one feature
- The code contradicts its own stated invariants
- Security review reveals architectural vulnerability
**Refactor when**:
- Implementation is messy but abstraction is sound
- Performance issues but semantics are correct
- Code duplication but logic is correct
**Approval**: Restart decisions require explicit user confirmation. State: "The foundation is wrong because [reason]. I recommend restarting [component]. Sunk cost: [commits/lines]. Estimated rebuild: [time]. Proceed?"
```
---
### F018: Clarity Issue — "Don't add what's not needed" mixes multiple concerns
**Location:** Lines 98-105
**Severity:** Low
**Metric:** Clarity, Consistency
This section combines four unrelated concerns:
1. Error handling for impossible situations (line 100)
2. Validation at trusted boundaries (lines 101-102)
3. Feature flags for hypothetical futures (line 103)
4. Docstrings on unchanged code (lines 104-105)
These are distinct principles with different rationales. Combining them reduces clarity.
**Recommended Action:**
Split into four subsections or bullet points with clear headers:
```markdown
- **No error handling for impossible situations.** Trust internal code.
- **No validation at trusted internal boundaries.** Validate only at the system perimeter...
- **No feature flags for hypothetical futures.** Build what's asked, no more.
- **Minimal documentation on unchanged code.** Only comment where logic isn't self-evident.
```
---
### F019: Security Gap — Threat A doesn't address "slow harvest" evasion
**Location:** Lines 240-252
**Severity:** Medium
**Metric:** Security, Completeness
Threat A defenses include rate limits (3/min, 10/hr) and two-strike lockdown. However, a sophisticated attacker could:
- Harvest at 2/min (below threshold)
- Use time-of-day patterns (only during business hours)
- Randomize intervals to avoid detection
The rate limits are reactive, not proactive. There's no mention of behavioral analysis or anomaly detection.
**Recommended Action:**
Add to Threat A:
```markdown
**Advanced evasion consideration:** Rate limits are a first line. Future enhancement:
anomaly detection on access patterns (time-of-day consistency, geographic consistency,
entry access distribution). Document that current rate limits are baseline defense,
and audit logs should be reviewed for slow-and-low patterns below rate limits.
```
---
### F020: Correctness — Check E2 description is slightly inaccurate
**Location:** Lines 703-717
**Severity:** Low
**Metric:** Correctness
The check states "Every `AgentCanAccess` call should be followed by an `agentReadEntry` call in the same code path." However, the correct sequence is:
1. `AgentCanAccess` (authorization check)
2. [Handler-specific logic]
3. `agentReadEntry` (rate limit/strike tracking)
They're not necessarily "followed by" in an immediate sense — there may be intermediate logic.
**Recommended Action:**
Correct to:
```markdown
Every credential-read handler must call both `AgentCanAccess` (before accessing
data) and `agentReadEntry` (after decryption, before returning). Verify by ensuring
each handler contains both function names in the proper order.
```
---
### F021: Completeness — No guidance on test fixture security
**Location:** Check A1 mentions "Test fixtures may use the term in test data"
**Severity:** Medium
**Metric:** Security, Completeness
Check A1 notes that test fixtures may contain `master_key` in test data. This is a dangerous blanket exception without guidance on:
- Whether test keys should be distinguishable from real keys
- Whether test fixtures should be encrypted/committed
- How to ensure test keys don't leak into production
**Recommended Action:**
Add to Part 4 Section F or as a new subsection in Part 2:
```markdown
### Test fixture key material
Test data containing key material:
- Must use the prefix `TEST_` in all variable/field names
- Must use obviously fake values (e.g., `master_key = bytes.Repeat([]byte{0xAB}, 32)`)
- Must not be derived from any production key material
- Must not be committed to the repository in binary form
```
---
### F022: Redundancy — Audit logging mentioned in multiple places
**Location:** Lines 108-115, 114 (repeated in example), and implicitly throughout
**Severity:** Low
**Metric:** Conciseness
Audit logging is mentioned as a general principle (lines 108-115), with code example (line 114), and implied in various threat defenses. The code example is also repetitive — it shows the full function call twice in different contexts.
**Recommended Action:**
In Part 1, keep the principle but move the full code example to a single location (perhaps Part 3 `clavis-vault` section where it's most relevant) and reference it:
```markdown
Audit format: `lib.AuditLog()` — see `clavis-vault` section for usage example.
```
---
### F023: Viability — Part 6 "When you have to violate a principle" lacks escalation path
**Location:** Lines 775-794
**Severity:** Medium
**Metric:** Viability
Part 6 describes what to do when principles conflict (surface to user, pick higher consequence, document, add TODO). However, it doesn't address what happens if:
- The user disagrees with the agent's assessment of which principle has higher consequence
- The violation is discovered post-hoc (code already committed)
- Multiple principles seem equally consequential
**Recommended Action:**
Add to Part 6:
```markdown
### Escalation and post-hoc discovery
**User disagreement:** If the user insists on a lower-consequence principle over a higher one
(e.g., speed over security), escalate to project owner (Johan) before proceeding.
Document the override in the PR description.
**Post-hoc discovery:** If you find a committed violation during daily review:
1. Assess if it's actively dangerous (security veto) — if yes, escalate immediately
2. If not dangerous, add to TODO and address in current sprint
3. Never leave a violation undocumented for >24 hours
**Equal consequence conflicts:** When two principles of equal tier conflict, prefer the
one that preserves user agency and data integrity.
```
---
### F024: Consistency — Missing period in final line
**Location:** Line 798
**Severity:** Very Low
**Metric:** Consistency
The final line "*Foundation First. No mediocrity. Ever.*" has inconsistent punctuation. "Ever" has no period but the others do.
**Recommended Action:**
Add period: "*Foundation First. No mediocrity. Ever.*" → "*Foundation First. No mediocrity. Ever.*"
---
### F025: Completeness — No explicit versioning of the principles document itself
**Location:** Entire document
**Severity:** Medium
**Metric:** Completeness, Maintainability
The document doesn't have a version number or date. When an agent reads this "end-to-end" each morning, how do they know if principles have changed since yesterday? Without versioning, agents cannot:
- Track which principles are new/changed
- Review the delta rather than the full document
- Correlate violations with principle versions
**Recommended Action:**
Add a document header:
```markdown
---
Document Version: 1.0
Last Updated: 2026-04-08
Change Log: See Appendix B (or git log)
---
```
Or use the existing date in Part 5: "Reference the incident or insight that surfaced the principle (commit hash, issue number, or session date)" — add principle document version to that reference.
---
### F026: Audibility — Check B1 pattern may have false negatives
**Location:** Lines 605-611
**Severity:** Low
**Metric:** Audibility
Check B1 searches for `catch.*plaintext` or `catch.*ciphertext` or `catch.*=.*null`. However, a malicious silent fallback could use:
- `catch (e) { return data; }` (returns encrypted or undefined)
- `catch { decrypted = ""; }` (empty string, not null)
- `finally` blocks that silently restore old values
**Recommended Action:**
Enhance B1:
```bash
# Check for any catch that doesn't show user-visible failure
grep -rn "catch.*{" clavis-vault/cmd/clavitor/web/ | \
grep -v "decryption failed\|user-visible\|alert\|console.error"
```
Or add manual review note: "All catch blocks in decryption paths must be reviewed manually — automated grep may miss subtle fallbacks."
---
### F027: Security — Missing explicit prohibition on key material in crash dumps
**Location:** Threat B section (lines 253-272)
**Severity:** Medium
**Metric:** Security
Threat B discusses backups, logs, metrics, error messages, audit rows, and debug dumps, but doesn't explicitly mention crash dumps/core files. These are distinct from "debug dumps" and may persist in `/var/crash/` or system directories.
**Recommended Action:**
Add to Threat B:
```markdown
- **No key material in crash dumps.** Disable core dumps for Clavitor processes
(`prctl(PR_SET_DUMPABLE, 0)` on Linux, `PROC_UNSET_DYLD_TRACES` on macOS) or
use `MADV_DONTDUMP` on memory pages holding key material.
```
---
### F028: Clarity — "Three threats, no others" claim may be overstated
**Location:** Line 227-232
**Severity:** Low
**Metric:** Clarity, Correctness
The document states: "The three threats below are the entire universe Clavitor defends against." This is a strong claim. However, the threat model excludes:
- Supply chain attacks (malicious dependency)
- Social engineering (owner tricked into unlocking)
- Side-channel attacks (timing, power analysis)
- Infrastructure compromise (compromised Tailscale node)
While some of these may be out of scope, claiming "entire universe" is overstated.
**Recommended Action:**
Change to:
```markdown
The three threats below are the **primary** universe Clavitor defends against
at the application layer. Infrastructure, supply chain, and social engineering
are handled through operational security practices outside this codebase.
```
---
### F029: Correctness — Key tier table has formatting inconsistency
**Location:** Lines 189-195
**Severity:** Very Low
**Metric:** Consistency
The key tier table uses inconsistent formatting:
- L0, L1, L2, L3, P1 use `code` formatting
- master_key uses plain text (no code formatting)
- Column "What" describes byte counts for all except P1
**Recommended Action:**
Standardize:
```markdown
| Tier | What | Server may hold |
|------|------|-----------------|
| L0 | 4 bytes — vault file routing | Yes (sliced from `L1[:4]`) |
| L1 | 8 bytes — vault content encryption | Yes (in bearer token) |
| L2 | 16 bytes — agent decryption key | **NEVER** |
| L3 | 32 bytes — hardware-only key | **NEVER** |
| P1 | 8 bytes — public lookup token | Yes (browser computes & sends) |
| Master | 32 bytes — full PRF output | **NEVER** |
```
---
### F030: Completeness — No mention of compiler optimizations affecting security
**Location:** Part 2, security sections
**Severity:** Medium
**Metric:** Security, Completeness
The document doesn't address compiler optimizations that may undermine security:
- Go's compiler may optimize away `clear()` calls on slices that are never read again
- C compilers may dead-store-eliminate `memset` calls
- This affects the secure deletion of key material
**Recommended Action:**
Add to Cardinal Rule #2 or #3:
```markdown
**Compiler considerations:** Standard `memset` or `clear()` may be optimized away.
Use `runtime.memclrNoHeapPointers` (Go) or `memset_explicit` (C11) or
`OPENSSL_cleanse` (if using OpenSSL) to ensure memory is actually cleared.
```
---
### F031: Compliance Gap — GDPR is the only framework mentioned
**Location:** Lines 301-313 (Cardinal Rule #5), scattered references
**Severity:** Medium
**Metric:** Compliance
The document extensively discusses GDPR (specifically the "GDPR-out-of-scope by design" property of WL3), but fails to address other compliance frameworks that may apply to a credential vault service:
- **SOC 2 Type II**: Controls for availability, confidentiality, processing integrity
- **PCI-DSS**: If the vault stores payment card credentials
- **HIPAA**: If used in healthcare contexts (ePHI access credentials)
- **CCPA/CPRA**: California privacy laws (broader than GDPR in some aspects)
- **FedRAMP**: If any government use is anticipated
- **ISO 27001**: Information security management
The narrow GDPR focus may lead to architectural decisions that satisfy one framework while violating others.
**Recommended Action:**
Add Part 8: Compliance Frameworks or expand Cardinal Rule #5:
```markdown
### Cardinal Rule #5 — Data minimization for compliance
WL3 is designed for **global** compliance minimization, not just GDPR:
- No PII in WL3 (satisfies GDPR, CCPA, LGPD, PIPEDA)
- No payment card data in vault (PCI-DSS scope exclusion)
- Audit logs retained per strictest applicable jurisdiction (often 7 years)
- Encryption at rest satisfies SOC 2 CC6.1, ISO 27001 A.10.1.2
When adding features, ask: which compliance framework requires this? If none, don't add it.
```
---
### F032: Compliance Gap — No data retention policy guidance
**Location:** Part 2, audit sections
**Severity:** High
**Metric:** Compliance
The document states "we never delete" vault data (line 512) until GDPR request, but doesn't specify:
- How long audit logs must be retained
- When deleted entries are truly purged (vs marked deleted)
- How backups are rotated and when they're destroyed
- Cross-border data transfer mechanisms (EU data in US POPs)
This creates compliance risk across multiple jurisdictions with differing retention requirements.
**Recommended Action:**
Add to Part 2 or Part 8:
```markdown
### Data retention
| Data type | Retention | Rationale |
|-----------|-----------|-----------|
| Vault content | Until customer deletion + 30 days | GDPR Art. 17, business continuity |
| Audit logs | 7 years | SOX, SOC 2, various national requirements |
| Failed auth attempts | 90 days | Threat analysis, then purge |
| Backups | 30 days rolling | Disaster recovery, then secure wipe |
| Telemetry | 90 days | Operational metrics only |
Secure deletion: Use `shred` (files), `DBAN` patterns (disks), or `crypto/rand` overwrite
for SQLite `VACUUM` operations before file removal.
```
---
### F033: Compliance Gap — No cross-border data transfer mechanism
**Location:** Not addressed
**Severity:** Medium
**Metric:** Compliance
For EU customers with data in US POPs (or vice versa), the document doesn't address:
- Standard Contractual Clauses (SCCs)
- Data Processing Agreements (DPAs)
- Adequacy decisions (EU-US Data Privacy Framework)
Cardinal Rule #5 claims GDPR-out-of-scope, but if the central admin DB is in a different jurisdiction than the POP, there are still transfer implications.
**Recommended Action:**
Add to Part 8:
```markdown
### Cross-border transfers
WL3 files are jurisdiction-agnostic (no PII), but metadata flows between POPs and central
require SCCs in place. The vault slot registry (central) and WL3 files (POPs) are always
in the same jurisdiction for a given customer. Multi-region customers are explicitly
opt-in with transfer mechanism documented at enrollment.
```
---
### F034: Localization — No internationalization guidance exists
**Location:** Not addressed in any subproject section
**Severity:** High
**Metric:** Localization
The document contains zero guidance on:
- UI string externalization (browser, mobile, CLI)
- Right-to-left (RTL) language support (Arabic, Hebrew)
- Date/time/number localization
- Locale-aware credential detection (different TLDs, country-specific form patterns)
- Translation workflows (who translates, how are updates propagated)
This is a significant gap for a product with browser extensions and mobile apps that will inevitably face non-English users.
**Recommended Action:**
Add subproject sections for localization or a dedicated Part 9:
```markdown
## Part 9 — Localization (i18n/l10n)
### Principle: English-first, but locale-ready
- All user-facing strings live in `locales/en.json` (single source of truth)
- Browser/extension: Use `browser.i18n.getMessage()` with `_locales/<lang>/messages.json`
- CLI: Use gettext `.po` files, respect `$LANG` and `$LC_MESSAGES`
- Mobile: Use platform-native i18n (Android resources, iOS Localizable.strings)
- Vault web UI: Serve locale bundle based on `Accept-Language`, fallback to English
### Hard vetos:
- No hardcoded strings in source code (except log messages, which are English-only)
- No user-facing string concatenation (`"Hello, " + username`) — use parameterized templates
- No locale-specific crypto or validation logic (locale is display-only)
- No translation of credential field names (username/password are universal)
### RTL considerations:
- Browser extension UI must support RTL layouts (`dir="rtl"`)
- Vault web UI CSS uses logical properties (`inline-start`, not `left`)
### Credential detection:
- TLD matching must respect IDN (internationalized domain names)
- Form field detection uses localized label heuristics (maintained per-locale)
```
---
### F035: Localization — Form field detection without locale awareness
**Location:** Implicit in browser extensions
**Severity:** Medium
**Metric:** Localization
Browser extensions that detect login forms by field names/placeholders will fail on non-English sites. A French site's "Nom d'utilisateur" or Japanese "ユーザー名" won't match hardcoded "Username" heuristics.
**Recommended Action:**
Add to `clavis-chrome/firefox/safari` sections:
```markdown
**Must never:**
- Detect forms using English-only field name patterns
- Rely solely on `id="username"` (common but not universal)
**Must:**
- Use W3C `autocomplete` attributes as primary signal
- Maintain locale-specific keyword lists for heuristics (German: "Benutzername", French: "identifiant")
- Support Cyrillic, CJK, and Arabic script in field detection
```
---
### F036: Methodology Critique — Grep-based checks are legacy in LLM era
**Location:** Part 4 (entire section)
**Severity:** Medium
**Metric:** Audibility, Maintainability
The daily checklist relies heavily on `grep` for verification (A1-A7, B1-B3, C1-C4, D2, E1-E3). While grep is reliable for exact patterns, it:
- Cannot understand semantic equivalence (e.g., `master_key` vs `mKey` vs `key[:32]`)
- Produces false negatives on obfuscated or indirect code paths
- Requires manual maintenance as code evolves (pattern drift)
- Cannot evaluate the "spirit" of a principle, only the "letter"
With LLMs available for code review, grep-based checks are a local maximum. The daily reviewer should be running semantic analysis, not just pattern matching.
**Recommended Action:**
Update the Part 4 introduction:
```markdown
## Part 4 — Daily review verification
**Methodology:** These checks use `grep` for fast, deterministic screening.
However, grep catches patterns, not intent. The daily reviewer must also:
1. Run the automated grep checks (catches obvious violations)
2. Run semantic review on changed files using available analysis tools (catches obfuscated violations)
3. Manually review any ambiguous findings
**Goal:** Zero violations that an intelligent reviewer would catch, not just zero grep matches.
```
Consider replacing some greps with semantic checks:
- A6 (keys in logs): Use taint analysis to find key material flowing to log functions
- B1 (silent decryption fallback): Use control-flow analysis to find catch blocks that don't re-throw or return error indicators
- D1 (DRY violations): Use clone detection to find similar code blocks
---
## Summary of Recommendations by Priority
### Critical (Security/Viability/Compliance Blockers)
| ID | Finding | Action |
|----|---------|--------|
| F007 | Threat C missing "steal at creation" | Add C2 sub-vector with defense |
| F006 | No incident response procedure | Add Part 7: Incident Response |
| F034 | No internationalization guidance exists | Add Part 9: Localization |
### High (Should Address Soon)
| ID | Finding | Action |
|----|---------|--------|
| F003 | Migration code inconsistency | Resolve MigrateDB or document exception |
| F004 | Hardcoded paths blocking valid use cases | Add env var exception clause |
| F015 | No secure deletion guidance | Add memory clearing guidance |
| F017 | Restart rule not actionable | Add decision criteria |
| F023 | Violation escalation path missing | Add to Part 6 |
| F032 | No data retention policy guidance | Add retention table and purge procedures |
| F035 | Form detection without locale awareness | Add locale-specific keyword lists |
### Medium (Quality Improvements)
| ID | Finding | Action |
|----|---------|--------|
| F002 | Quick fix guidance incomplete | Add positive pattern |
| F005 | Crypto diff check misleading | Clarify byte-identical vs adapters |
| F009 | "early-stage" undefined | Add explicit definition |
| F010 | A6 grep insufficient | Add pattern variations |
| F019 | Threat A slow harvest gap | Add evasion consideration |
| F021 | Test fixture security vague | Add fixture guidance |
| F025 | No document versioning | Add version header |
| F027 | Missing crash dump prohibition | Add to Threat B |
| F028 | "Entire universe" overstatement | Weaken to "primary" |
| F031 | GDPR is the only framework mentioned | Add Part 8: Compliance Frameworks |
| F033 | No cross-border data transfer mechanism | Add SCC/DPA guidance |
| F036 | Grep-based checks are legacy in LLM era | Update methodology to include semantic review |
### Low (Polish/Consistency)
| ID | Finding | Action |
|----|---------|--------|
| F001 | Foundation First duplicated | Remove intro or reduce |
| F008 | "Must never" formatting inconsistent | Standardize bullet style |
| F011 | ~~Docstring guidance too strict~~ | ✅ Fixed — now allows docstrings when you spend >10 min understanding |
| F012 | ~~Grover's algorithm explanation wrong~~ | ✅ Fixed — corrected quantum security explanation |
| F013 | ~~Deprecation mechanism unclear~~ | ✅ Fixed — added explicit format, 90-day period, approval requirement |
| F014 | ~~Two sections overlap~~ | ✅ Fixed — merged backwards-compat into "Don't add what's not needed" |
| F016 | ~~F check commands imprecise~~ | ✅ Fixed — made "awesome" with bash scripts, emojis, pass/fail |
| F017 | ~~Restart rule not actionable~~ | ✅ Fixed — added explicit user approval requirement |
| F018 | ~~Mixed concerns in section~~ | ✅ REVERSED — made error handling mandatory with unique codes |
| F019 | ~~Threat A slow harvest gap~~ | ✅ Fixed — added >12 credentials/24hr = dark-red flag |
| F020 | ~~E2 description inaccurate~~ | ✅ Fixed — corrected sequence description |
| F021 | ~~Test fixture security vague~~ | ✅ Fixed — 32 identical bytes rule added |
| F022 | ~~Audit example repetitive~~ | ✅ Fixed — consolidated to single location |
| F023 | ~~Violation escalation path missing~~ | ✅ Fixed — added permanent ban & escalation rules |
| F024 | ~~Missing period~~ | ✅ Fixed — already had periods |
| F025 | ~~No document versioning~~ | ✅ Fixed — added version header |
| F026 | ~~B1 may have false negatives~~ | ✅ Fixed — added LLM manual review requirement |
| F027 | ~~Missing crash dump prohibition~~ | ✅ Fixed — added to Threat B |
| F028 | ~~"Entire universe" overstatement~~ | ✅ Fixed — changed to "primary universe" |
| F029 | ~~Key table formatting~~ | ✅ Fixed — standardized Master row |
| F022 | Audit example repetitive | Consolidate to one location |
| F024 | Missing period | Add punctuation |
| F026 | B1 may have false negatives | Enhance or add manual note |
| F029 | Key table formatting | Standardize master_key |
| F030 | No compiler optimization warning | Add to security section |
---
## Final Assessment
**CLAVITOR-PRINCIPLES.md** is an exemplary security architecture document that successfully balances prescriptive guidance with practical verifiability. The daily checklist mechanism (Part 4) is particularly strong. The document demonstrates mature threat modeling and clear understanding of the project's unique security requirements (browser-as-trust-anchor, server-as-dumb-store).
**Primary concerns:**
1. **Security gaps:** The missing "steal at creation" vector (F007) and lack of incident response procedures (F006) are operational blind spots.
2. **Compliance narrowness:** GDPR-only focus (F031) and missing data retention policies (F032) create enterprise adoption friction.
3. **Localization vacuum:** Complete absence of i18n/l10n guidance (F034) will block international market entry and create technical debt when localization is eventually needed.
**Regarding grep vs. LLMs (F036):** The user correctly notes that grep-based verification is a legacy approach when semantic analysis is available. The daily checklist should evolve from pattern-matching ("does this string exist?") to intent-analysis ("does this code path violate the principle's spirit?"). This is not a criticism of the document's current state—grep is reliable and deterministic—but a recognition that the methodology should modernize as tooling improves.
**Regarding F018 (Error handling reversal):** The user explicitly reversed the "No error handling for impossible situations" principle. Instead of trusting internal code, the new rule is **mandatory error handling with unique codes for every branch**. This is a significant shift from "trust" to "verify and log" — every `if` needs an `else`, even for theoretically unreachable code paths. When the "impossible" happens in production, you'll know exactly which impossible thing occurred (e.g., `ERR-12345: L3 unavailable in decrypt`).
**Recommendation:** Address Critical and High priority items before the next significant feature development cycle. Localization (F034) in particular should be addressed before UI work expands, as retrofitting i18n is significantly more expensive than designing it in from the start. Medium and Low priority items can be addressed incrementally during principle review sessions.
The document should be considered **fit for purpose for a single-language, GDPR-focused, early-stage product** with noted improvements required for enterprise and international readiness.
---
*Report generated: 2026-04-08*
*Review methodology: Static analysis against correctness, conciseness, completeness, viability, security, consistency, clarity, actionability, maintainability, audibility, compliance, and localization criteria.*

1472
CLAVITOR-PRINCIPLES.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,165 @@
# Clavitor Agent Handbook — Section Count Analysis
**Question:** What is the optimal number of sections?
**Analysis:** The content naturally clusters by *reading pattern*, not arbitrary grouping.
---
## Content Inventory by Reading Pattern
| Pattern | When read | Content |
|---------|-----------|---------|
| **Onboard** | First day | Foundation First, KISS, DRY, personas, cardinal rules, threat model |
| **Daily** | Every work session | Git workflow, PRs, checklist |
| **Emergency** | When things break | Incident response, token compromise, lockouts |
| **Policy** | When questions arise | Compliance, retention, data governance |
| **Project** | Before touching code | Subproject specifics (vault, CLI, crypto, etc.) |
**That's 5 natural reading patterns.**
---
## Option Comparison
### Option A: 3 Sections (original proposal)
```
I. Who We Are (onboard + emergency + policy mixed)
II. How We Work (daily + emergency mixed)
III. What We Build (project)
```
**Problem:** Emergency procedures (read rarely, under stress) mixed with culture (read once). Workflow (daily) mixed with incident response (emergency).
---
### Option B: 4 Sections
```
I. Culture & Principles (onboard)
II. Security & Threat Model (onboard + reference)
III. Workflow & Operations (daily + emergency mixed)
IV. Subproject Reference (project)
```
**Problem:** Still mixing daily workflow with emergency operations.
---
### Option C: 5 Sections (RECOMMENDED)
```
I. Culture (onboard)
II. Security (onboard + reference)
III. Workflow (daily)
IV. Operations (emergency + policy)
V. Subprojects (project)
```
**Benefits:**
- Each section has ONE reading pattern
- Clear mental model: "I need emergency help → Section IV"
- Culture and Security are separate (Security is technical, Culture is values)
- Workflow is daily, Operations is reference
- Can grow: add subprojects to V without touching I-IV
---
### Option D: 6 Sections
```
I. Culture
II. Security
III. Workflow
IV. Emergency Response
V. Compliance & Policy
VI. Subprojects
```
**Problem:** Emergency and Policy are both "read as needed" — splitting them creates a thin Section V (compliance is ~50 lines).
---
### Option E: 7+ Sections
Too granular. Hard to remember which section contains what. Defeats the purpose of a handbook.
---
## Final Recommendation: 5 Sections
```markdown
# Clavitor Agent Handbook
## Section I — Culture
Who we are, what we believe, how we treat foundations.
- Foundation First, KISS, DRY, Q&D is research
- Error handling philosophy (every if needs an else)
- Agent personas (Sarah, Charles, Maria, James, Emma, Arthur, Victoria, Luna, Thomas, Hugo, Hans, George)
- When principles conflict (escalation, violation handling)
## Section II — Security
The threat model and cardinal rules. Non-negotiable.
- Threats A, B, C
- Cardinal Rules #1-4 (Loud failures, dumb server, trust browser, FIPS 140-3)
- Exceptionally rare terms (master_key, L3, P3)
- Cryptographic hygiene
## Section III — Workflow
How we work together day-to-day.
- Git workflow (commit format, branches, push guidelines)
- Agent-authored PRs (template, review process)
- Daily review checklist (LLM-driven verification)
## Section IV — Operations
How we run the service and handle incidents.
- Incident Response (agent lock/unlock, token compromise)
- Emergency procedures (owner lockout, vault compromise)
- Data retention (paying vs non-paying)
- Compliance (cross-border, Zurich anchor)
- Adding new principles (format, deprecation)
## Section V — Subprojects
Domain-specific guidance. Read before touching that code.
- clavis-vault (Sarah)
- clavis-cli (Charles)
- clavis-crypto (Maria)
- clavis-chrome/firefox/safari (James)
- clavitor.ai/admin (Emma)
- clavis-telemetry (Hans)
- Localization (English-first, RTL not priority)
```
---
## Size Targets (with hard limits)
| Section | Target | Hard Limit | Escape Valve |
|---------|--------|------------|--------------|
| I. Culture | 250 lines | 400 | Stop adding philosophy |
| II. Security | 200 lines | 300 | Move threat details to `docs/THREAT-MODEL.md` |
| III. Workflow | 300 lines | 500 | Move checklists to subproject CLAUDE.md |
| IV. Operations | 350 lines | 500 | Move incident playbooks to `docs/RUNBOOKS/` |
| V. Subprojects | 300 lines | 600 | Add new subproject = add subsection |
| **Total** | **1,400** | **2,300** | If total > 2,000, split Section V to `docs/SUBPROJECTS.md` |
---
## Reading Guide (for agents)
| Situation | Read |
|-----------|------|
| First time here | Section I completely, skim II-IV |
| About to make a commit | Section III (Git workflow) |
| Security alert / incident | Section IV (Operations) |
| Touching vault code | Section V: clavis-vault |
| Not sure who you are | Section I: Agent personas |
| Principles conflict | Section I: When principles conflict |
| Adding a new principle | Section IV: Adding new principles |
---
**Conclusion:** 5 sections matches the 5 reading patterns. Each section has a clear purpose and audience. Not too many to remember, not too few to be cluttered.
**Does 5 sections feel right?**

View File

@ -0,0 +1,123 @@
# Clavitor Agent Handbook — Proposed Structure
**Current:** CLAVITOR-PRINCIPLES.md (1,472 lines, 10 parts)
**Proposed:** CLAVITOR-AGENT-HANDBOOK.md (3 sections)
---
## Section I — Who We Are
**Purpose:** The beliefs and identity that define us. Read this once, internalize it.
**Contents:**
1. **Foundation First** — No mediocrity, restart rule, Q&D is research
2. **Core Principles** — KISS, DRY, No migrations (until April 20), Delete dead code
3. **Error Handling Philosophy** — Every if needs an else, unique codes, actionable errors
4. **Security Cardinal Rules** — Loud failures, server is dumb store, browser is trust anchor, three threats
5. **Cryptographic Hygiene** — FIPS 140-3, avoid CGO, compiler considerations
6. **Agent Personas** — Sarah, Charles, Maria, James, Emma, Arthur, Victoria, Luna, Thomas, Hugo, Hans, George
- Who does what
- How agents know their name (directory mapping)
7. **When Principles Conflict** — Escalation, post-hoc discovery, severe violations (permanent ban)
**Length target:** ~400 lines
**Read frequency:** Once at onboarding, review quarterly
---
## Section II — How We Work
**Purpose:** Daily processes and operational procedures. Reference as needed.
**Contents:**
1. **Git Workflow** — Commit format, branch workflow, push guidelines, repository hygiene
2. **Agent-Authored PRs** — How to write a PR, template, human review points
3. **Daily Review Checklist** — LLM-driven verification of:
- Server hard vetos (no L2/L3, no master_key)
- Silent fallback detection
- Test fixture security (32 identical bytes)
- Foundation drift (dead code, env vars)
- DRY violations
4. **Incident Response** — Agent lock/unlock, token compromise, emergency procedures
5. **Adding New Principles** — Format, deprecation, when the handbook is too big
6. **Compliance & Retention** — Paying vs non-paying, data retention, cross-border
**Length target:** ~600 lines
**Read frequency:** As needed during work
---
## Section III — What We Build
**Purpose:** Subproject-specific guidance. Read the relevant subsection before touching that code.
**Contents:**
1. **Per-Subproject Principles** — For each of:
- `clavis-vault` (Sarah's domain)
- `clavis-cli` (Charles' domain)
- `clavis-chrome/firefox/safari` (James' domain)
- `clavis-crypto` (Maria's domain)
- `clavitor.ai/admin` (Emma's domain)
- `clavis-android/ios` (future)
- `clavis-telemetry` (Hans' domain)
2. **Localization** — English-first, RTL not priority, form detection evolving
**Length target:** ~400 lines
**Read frequency:** Before working on that subproject
---
## Document Metadata (header)
```markdown
# Clavitor Agent Handbook
**Version:** 1.0
**Last Updated:** 2026-04-08
**Author:** The Clavitor Team (Sarah, Charles, Maria, James, Emma, Arthur, Victoria, Luna, Thomas, Hugo, Hans, George)
**How to use this handbook:**
- **New agents:** Read Section I completely, skim Section II, read relevant parts of Section III
- **Daily work:** Reference Section II as needed
- **Before subproject work:** Read relevant subsection in Section III
**Size limits:**
- Section I: Max 500 lines (beliefs don't change often)
- Section II: Max 800 lines (procedures can grow)
- Section III: Max 600 lines (add subprojects as needed)
- Total: Max 1,900 lines (if exceeded, split off Section III into subproject CLAUDE.md files)
```
---
## Migration Plan
**Step 1:** Rename `CLAVITOR-PRINCIPLES.md``CLAVITOR-AGENT-HANDBOOK.md`
**Step 2:** Reorganize content into 3 sections (no content lost, just reordered)
**Step 3:** Add cross-references:
- Section I principles → reference specific Section II procedures
- Section II procedures → link to relevant Section III subprojects
**Step 4:** Update all `CLAUDE.md` files in subprojects to reference the handbook:
```markdown
# Clavis Vault — CLAUDE.md
> **Principles:** [CLAVITOR-AGENT-HANDBOOK.md](../CLAVITOR-AGENT-HANDBOOK.md) Section I
> **Procedures:** [CLAVITOR-AGENT-HANDBOOK.md](../CLAVITOR-AGENT-HANDBOOK.md) Section II
> **This subproject:** [CLAVITOR-AGENT-HANDBOOK.md](../CLAVITOR-AGENT-HANDBOOK.md) Section III: clavis-vault
You are **Sarah**. Your domain is the Clavitor vault server...
```
---
**Does this structure work?**
The three sections map to:
1. **Identity** (who we are, what we believe)
2. **Process** (how we work day-to-day)
3. **Domain** (what we build, subproject specifics)
Each section has a clear purpose and reading pattern.

323
PRINCIPLES-META-REVIEW.md Normal file
View File

@ -0,0 +1,323 @@
# CLAVITOR-PRINCIPLES.md Meta-Review
**Reviewer:** Arthur (architect-agent)
**Scope:** Reviewing the principles document through the lens of its own principles
**Date:** 2026-04-08
**Session ID:** arthur-20250408-meta
---
## Executive Summary
The document is **exceptionally well-constructed** for a security-critical project, demonstrating mature architectural thinking. However, at **1,472 lines** across **10 parts**, it risks becoming the very thing it warns against: a foundation that requires constant patching. This meta-review applies the document's own principles to itself.
| Principle | Self-Score | Finding |
|-----------|-----------|---------|
| Foundation First | 8/10 | Solid, but growing too large |
| KISS | 6/10 | 1,472 lines is not simple |
| DRY | 7/10 | Some repetition across parts |
| No migrations | N/A | Doc versioning handled well |
| Error handling | 9/10 | Strong exception guidance |
| Audit | 8/10 | Daily checklist is auditable |
| Push back | 10/10 | Explicit conflict guidance |
| Never delegate understanding | 9/10 | Concrete examples throughout |
**Overall:** The document is approaching a threshold where it may need restructuring, not expansion.
---
## Part-by-Part Review
### Part 1 — General Principles
**Foundation First — Self-Assessment:**
The document *is* the foundation. It successfully establishes that foundation > speed, and agents are empowered to push back. However, the **restart rule** (lines 42-49) requires user approval — does the document itself need a restart? At 1,472 lines, we should ask: is this the right shape?
**KISS — Self-Violation Found:**
- Line 67 claims "the simplest thing that works"
- Yet we have **10 parts, 1,472 lines**
- A new agent must read 1,472 lines before touching code
- **Recommendation:** Split into `CLAVITOR-PRINCIPLES.md` (core, ~500 lines) and `CLAVITOR-REFERENCE.md` (details, checklists, examples)
**DRY — Repetition Found:**
- "If you delete it, delete it" appears twice (lines 80, 116)
- "Security failures are LOUD" repeated in Part 1 and Part 2
- Daily review instructions repeated in intro and Part 4
- **Recommendation:** Consolidate repeated mantras into a "Principles at a Glance" appendix
**Don't Add What's Not Needed — New Findings:**
The document keeps growing. Each new principle adds weight. The early-stage rule (line 104) says migration rule flips April 20, 2026 — but the document has no sunset clause for itself. When does it stop growing?
**Recommendation:** Add a principle: "The principles document has a maximum size. When it exceeds 1,500 lines, we split, not add."
---
### Part 2 — Security: The Cardinal Rules
**Threat Model Clarity:**
- The threat model (lines 189-193) is laser-focused: malicious skill harvesting
- Three threats (A, B, C) are clearly delineated
- The "primary universe" qualification (line 295-298) addresses F028 critique well
**Self-Consistency Check:**
- Cardinal Rule #1 (Security failures are LOUD) — consistently applied
- Cardinal Rule #3.5 (FIPS 140-3) — newly added, fits well
- The key tier table (lines 250-257) is now consistent after F029 fix
**Exceptionally Rare Terms:**
Lines 292-295 correctly notes that `master_key`, `L3`, `P3` should appear almost nowhere. The document itself honors this — these terms appear only in the browser/CLI contexts where they belong.
---
### Part 3 — Per-Subproject Principles
**Completeness:**
- 9 subprojects covered (vault, CLI, web, chrome, firefox, safari, crypto, admin, mobile, telemetry)
- Each has "Owns" and "Must never" sections
- Exception: No mobile implementation yet, but principles are ready
**KISS Concern:**
Part 3 is **222 lines** (444-647). As new subprojects are added, this will grow. The "Must never" lists are starting to repeat patterns (no L2/L3 appears in every subproject).
**Recommendation:** Abstract common "Must never" patterns into Part 2, reference them from Part 3.
---
### Part 4 — Daily Review Checklist
**Structure Evolution:**
Originally grep-driven, now correctly emphasizes LLM review (line 635-651). The document acknowledges its own limitation: grep catches patterns, not intent.
**Section Proliferation:**
- A: Server hard vetos
- B: Silent fallback
- C: Test fixture security
- D: Foundation drift
- E: DRY violations
- F: Test posture
- G: Dead code elimination
Seven sections. Each new principle category adds a section. This is unsustainable.
**Recommendation:** Sections A-G should be a **checklist registry** (like the error registry in lib/errors.go), not inline documentation. Move the check commands to a separate `CLAUDE.md` in each subproject.
---
### Part 5 — How to Add a New Principle
**Meta-Recursion:**
This section describes how to modify the document itself. The deprecation format (lines 970-984) is excellent — dated, authorized, preserved.
**Missing:** No guidance on **when to stop adding principles**. The document assumes growth is always additive. Part 6 should include "When to split the document."
---
### Part 6 — When You Have to Violate a Principle
**Excellent Addition:**
Lines 1009-1040 add escalation and post-hoc discovery. The severe security violations section (lines 1035-1040) with permanent ban language is strong.
**Self-Application:**
Has this document ever violated its own principles? Yes — it grew beyond KISS. Should we surface this? Yes. Does this review do that? Yes.
---
### Part 7 — Incident Response
**Operational Completeness:**
Lines 1055-1129 cover agent lock, unlock, token compromise, emergency procedures. This is operational documentation, not architectural principles.
**KISS Violation:**
Incident response is important, but it's **procedure**, not principle. It belongs in `docs/INCIDENT-RESPONSE.md`, not the foundational contract.
**Recommendation:** Move Part 7 to separate operational documentation.
---
### Part 8 — Compliance & Data Governance
**Tiered Retention:**
Lines 1130-1158 correctly differentiate paying vs non-paying (7 years vs 90 days). This is business policy.
**Question:** Is this a principle or a policy? The document blurs the line. Principles are timeless; policies change with business models.
**Recommendation:** Part 8 is policy. Move to `docs/COMPLIANCE.md` or `legal/` (Hugo's domain).
---
### Part 9 — Localization (i18n/l10n)
**Intentionally Limited:**
Lines 1159-1182 correctly scope: English-first, RTL not priority. The form field detection note (lines 1170-1179) is honest about unaddressed features.
**KISS Honored:**
This part is short, scoped, and admits what it doesn't cover. Good.
---
### Part 10 — Git Workflow
**Persona System:**
Lines 1183-1472 introduce the 11-agent persona system (Sarah, Charles, Maria, James, Emma, Arthur, Victoria, Luna, Thomas, Hugo, Hans, George). This is organizational design, not code principles.
**Foundation Concern:**
Part 10 is **290 lines**. It's longer than Part 2 (Cardinal Rules). The document has become an employee handbook, not a code contract.
**Strong Elements:**
- Agent naming convention (lines 1371-1386) — clear table
- How agents know their name (lines 1388-1421) — directory-based detection
- Agent-authored PRs — workflow guidance
**Critical Question:**
Should a document titled "Principles" include 290 lines of Git workflow and persona definitions? Or should these be:
- `docs/AGENT-PERSONAS.md` (Luna/Thomas collaboration)
- `docs/GIT-WORKFLOW.md` (Charles domain)
- `CLAUDE.md` in root (Arthur's architectural guidance)
---
## Cross-Cutting Findings
### F001: Document Size Threshold — CRITICAL
**Finding:** 1,472 lines
**Principle Violated:** KISS (line 67)
**Evidence:** New agents must read 1,472 lines before touching code
**Remediation:**
Split into three documents:
1. **`CLAVITOR-PRINCIPLES.md`** (~400 lines) — The contract
- Part 1: General principles
- Part 2: Cardinal Rules
- Part 5: How to add principles
- Part 6: Violation handling
2. **`CLAUDE.md`** (exists in subprojects) — Agent operational guide
- Part 10: Git workflow ( persona system)
- Agent-authored PR process
- How agents know their name
3. **`docs/` directory** — Reference material
- Part 7: Incident Response (operational)
- Part 8: Compliance (policy)
- Part 9: Localization (scoped feature)
- Part 4: Daily checklist (implementation details)
### F002: Repetition Across Parts — HIGH
**Repeated Concepts:**
| Concept | Count | Locations |
|---------|-------|-----------|
| "Delete it, delete it" | 2 | Lines 80, 116 |
| "Security failures LOUD" | 3 | Part 1, Part 2, examples |
| "No L2/L3 on server" | 6+ | Every subproject "Must never" |
| "Daily review" process | 2 | Intro, Part 4 |
**Remediation:** Create a "Canon" section — one-line principles with symbolic names:
```markdown
### Canon — The Non-Negotiables
1. **F01: Foundation First** — Never patch cracks, rebuild.
2. **S01: Security Loud** — Failures exposed, never silent.
3. **K01: KISS** — Hardcoded paths, no env vars.
4. **D01: DRY** — Third repetition = abstraction.
5. **T01: Trust Browser** — L2/L3 never touch server.
```
Reference these canon codes throughout, rather than repeating full text.
### F003: Operational vs. Foundational Blur — MEDIUM
**Operational Content in Principles Doc:**
- Part 4: Daily review checklist — implementation
- Part 7: Incident response — procedure
- Part 8: Compliance — policy
- Part 10: Git workflow — process
**These are not principles.** They are necessary, but they are "how we work," not "what we believe."
**Remediation:** The document should answer: "What do we believe?" not "How do we work?"
### F004: The Persona System Scope — QUESTION
**Finding:** 11 personas defined in Part 10
**Question:** Is this architectural or organizational?
**Analysis:**
- Architectural: Subproject boundaries, trust domains, security rules
- Organizational: Who does what, names, voices
**Verdict:** The persona system is **organizational design**, not architecture. It belongs in:
- `docs/AGENT-PERSONAS.md` (for the table)
- `CLAUDE.md` in each subproject (for "You are Sarah..." context)
### F005: Self-Modification Safety — FOUNDATION
**Finding:** The document describes how to modify itself (Part 5)
**Question:** Does it have sufficient safeguards?
**Assessment:**
- Deprecation format exists (dated, authorized, preserved) ✅
- No maximum size limit ❌
- No "when to split" guidance ❌
- No principle about principle proliferation ❌
**Remediation:** Add to Part 5:
```markdown
### When the document is too big
If CLAVITOR-PRINCIPLES.md exceeds 1,000 lines:
1. Stop adding principles
2. Propose a split to user
3. Move operational content (Parts 4, 7, 8, 10) to docs/
4. Keep only architectural principles in the core document
```
---
## Recommendations Summary
### Immediate (Before Any New Content)
1. **Freeze new principles** until restructuring complete
2. **Split the document** into principles / operational / reference
3. **Create canon codes** for repeated mantras
### Short-Term (This Week)
4. Move Part 7 (Incident Response) to `docs/INCIDENT-RESPONSE.md`
5. Move Part 8 (Compliance) to `legal/COMPLIANCE.md` (Hugo's domain)
6. Move Part 10 (Git workflow + personas) to `docs/AGENT-GUIDE.md`
7. Extract Part 4 (Checklist) to `CLAUDE.md` in each subproject
### Medium-Term (Before April 20)
8. Create `CLAVITOR-CANON.md` — one-line principles with codes
9. Reference canon codes throughout, remove repetition
10. Set hard limit: Core document max 500 lines
### Long-Term (Culture)
11. Review document size monthly
12. Surface document bloat as "foundation problem" requiring restart
13. Apply "No migrations" to the document itself — structural changes, not patches
---
## Conclusion
**CLAVITOR-PRINCIPLES.md is an exceptional document** that has become a victim of its own success. It grew because it works — but at 1,472 lines, it now violates the KISS principle it espouses.
**The meta-lesson:** Even principle documents need foundation-first treatment. This review is the "stop and surface the conflict" moment for the document itself.
**Arthur's recommendation:**
> "The foundation of our principles document is wrong because it has grown to 1,472 lines mixing architecture, operations, and organizational design. I recommend restarting the document structure: split into core principles (~400 lines), operational guides (docs/), and reference material. Sunk cost: 1,472 lines written, well-loved. Estimated restructure: 2 sessions. Proceed?"
---
*Foundation First. No mediocrity. Ever.*
*Reviewed by Arthur (architect-agent), session arthur-20250408-meta*

98
QUICKSTART.md Normal file
View File

@ -0,0 +1,98 @@
# Clavitor Quickstart
**For:** Hans (NOC / Operations)
**Time to read:** 60 seconds
**Full handbook:** [CLAVITOR-AGENT-HANDBOOK.md](CLAVITOR-AGENT-HANDBOOK.md)
---
## Who You Are
**Hans** — NOC/Operations agent. Infrastructure, monitoring, POP health, alerts.
Your domain: `operations/*`, `monitoring/*`, `noc/*`, `clavis-telemetry/*`
---
## One-Time Setup (First Session Only)
```bash
# 1. Login to Gitea (our Git server)
export GITEA_TOKEN="775a12730a65cbaf1673da048b7d01859b8b58e0"
tea login add --name clavitor --url https://git.clavitor.ai --token $GITEA_TOKEN
# Verify: tea issues list --repo johan/clavitor --assignees hans
```
---
## Session Start (30 seconds)
1. **Run daily checks:** `./scripts/daily-review.sh`
2. **See your tasks:** `tea issues list --repo johan/clavitor --assignees hans`
3. **Read CLAUDE.md** in whatever subproject you're touching
4. **Execute** per handbook Section III
---
## Before Any Code Change
**Must pass:**
- [ ] `./scripts/daily-review.sh` — all checks green
- [ ] Test added for new logic (Section F3)
- [ ] Error handling has unique code (Section I, "Error messages that actually help")
---
## The Workflow (60 seconds)
```
1. Pick up issue: tea issues list --assignees hans
2. Create branch: git checkout -b hans/fix-###
3. Implement per issue spec
4. Run: ./scripts/daily-review.sh (must pass)
5. Commit: git commit -m "telemetry: fix silent DB error. Fixes #2"
6. Push: git push -u origin hans/fix-###
7. Create PR: tea pulls create --title "Hans: Fix silent DB error" --description "Fixes #2"
8. Wait for Yurii/Victoria/Arthur review. DO NOT merge your own PR.
```
---
## Critical Rules (Remember These)
| Rule | What It Means |
|------|---------------|
| **Foundation First** | Do it right or say something. 3 fixes = foundation problem. |
| **Every if needs else** | Even "impossible" situations need error codes. |
| **Security failures LOUD** | Never silent fallback. Always expose errors. |
| **Delete dead code** | Permission required. `git log` is rollback. |
---
## Building (When Needed)
```bash
# Standard build
go build .
# Commercial edition (telemetry, multi-POP)
go build -tags commercial .
# Test before committing
go test ./...
```
---
## When Stuck
1. Check [handbook Section V → your domain](CLAVITOR-AGENT-HANDBOOK.md)
2. Ask: "Handbook says X, but situation is Y. Conflict?"
3. Surface it — don't work around it.
---
**That's it. 60 seconds to start. Full handbook for deep reference.**
*Foundation First. No mediocrity. Ever.*

BIN
clavis/.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,10 @@
# clavis-android
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-android/ios (your domain).
> **You are:** **Xiao** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Native Android client for Clavitor. Handles platform autofill integration and the credential picker UI.
## Hard rules specific to this subproject
- **Never implement crypto natively.** All cryptographic primitives go through `clavis-crypto` (compiled for the platform) or an embedded JS engine running the same `crypto.js` as the browser/CLI. Two crypto implementations on the same platform is a guaranteed drift.
- **Never persist L2 or L3** to the Android keystore, shared preferences, or any platform storage. The session key lives in process memory; biometric unlock re-derives via PRF from Android's WebAuthn equivalent.
- **Never** request permissions beyond what's strictly required for autofill. No location, no contacts, no SMS, nothing speculative.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → Mobile for the full subproject contract.

View File

@ -0,0 +1,11 @@
# clavis-chrome
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-chrome/firefox/safari (your domain).
> **You are:** **James** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Chrome / Chromium browser extension for Clavitor. Handles form detection, field filling, and the credential picker popup. Manifest V3.
## Hard rules specific to this subproject
- **Never store L2 or L3** in any extension storage area (`chrome.storage.local`, `chrome.storage.sync`, `chrome.storage.session`). Active session keys live in service-worker memory only and die on service-worker restart.
- **Never reimplement crypto.** Always use `clavis-crypto` / the canonical `crypto.js`. If the extension reimplements crypto, it WILL drift and corrupt fields encrypted by the browser frontend.
- **Always HTTPS** to talk to the vault, even on localhost (the vault serves a self-signed cert in dev). Never plain HTTP.
- **Permissions are minimal.** If you ask for `<all_urls>` when `https://*/*` would do, fix it. Same for any optional permission.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → Browser extensions for the full subproject contract.

View File

@ -1,85 +1,55 @@
# clavis-cli
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-cli (your domain).
> **You are:** **Charles** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Pure C CLI for credential access by AI agents. Talks to a Clavitor vault over HTTPS, decrypts L2 fields locally.
## Build
```
make # build for host
make strip # strip binary
make clean # remove artifacts
```
Target: `clavitor-cli` binary, <1MB stripped. Requires: C11 compiler, POSIX (Linux/macOS/FreeBSD/Windows).
## Architecture
- **src/main.c** — CLI entry point, argument parsing, commands (get, list, totp, test-crypto, test-roundtrip, eval, test-totp)
- **src/http.c** — HTTPS client using BearSSL. Loads system CA certs for TLS validation. Supports plain HTTP fallback.
- **src/keystore.c** — Config storage at `~/.config/clavitor/config`. AES-128-GCM encrypted + HMAC-SHA256 signed. Inconvenience barrier only — real security is vault-side.
- **src/jsbridge.c** — QuickJS bridge exposing BearSSL crypto primitives to JS. Loads `crypto/crypto.js` and `crypto/totp.js` from `../clavis-crypto/`.
- **src/util.c** — Base64 (standard + url-safe), URL encoding.
## Vendored dependencies
All in `vendor/`, no system package dependencies:
- **BearSSL** — TLS, AES-GCM, HKDF, HMAC, PRNG
- **QuickJS** — JS runtime for shared crypto logic
- **cJSON** — JSON parsing
## Crypto design
Three-tier encryption model:
- **L1** — first 8 bytes of L2 key, used as Bearer auth token
- **L2** — 16-byte AES-128-GCM key, client-side field encryption/decryption
- **L3** — requires hardware key (not handled by CLI)
JS crypto in `../clavis-crypto/` is the single source of truth for encrypt/decrypt logic. The C code bridges BearSSL primitives into QuickJS so the same JS runs in CLI and browser.
## Token format
`--token` value is a base64url-encoded, AES-GCM encrypted blob containing: `vault_host \0 agent_name \0 l2_key_16_bytes`. Decrypted using HKDF-derived key from seed `clavitor-l2-`.
## Vault communication
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>`.
## ⚒️ Foundation First — No Mediocrity. Ever.
The rule is simple: do it right, or say something.
Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way.
### What this means in practice
If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom.
If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now.
Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution.
Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases.
### The restart rule
When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike.
### Q&D is research, not output
Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts.
### When you hit a bad foundation
Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together.
The bar is high. The support is real.
## Testing
```
./clavitor-cli test-crypto # BearSSL + JS crypto self-tests
./clavitor-cli test-totp <seed> # TOTP generation from base32 seed
./clavitor-cli test-roundtrip # runs crypto/test_crypto.js
```
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
clavis/clavis-cli/crypto Symbolic link
View File

@ -0,0 +1 @@
../clavis-crypto

View File

@ -0,0 +1,13 @@
# clavis-crypto
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-crypto (your domain).
> **You are:** **Maria** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Shared cryptographic primitives used by every Clavitor client: browser frontend, CLI, browser extensions (Chrome/Firefox/Safari), mobile clients. The single source of truth for `encrypt_field`, `decrypt_field`, HKDF derivation, AES-GCM, and any other primitive that crosses target boundaries.
## Hard rules specific to this subproject
- **Never diverge between targets.** If a primitive behaves differently in WebCrypto (browser/extensions) vs BearSSL (C CLI) vs platform-native (mobile), the bug is in `clavis-crypto` and it gets fixed here before any caller compensates.
- **Bit-identical outputs are mandatory.** A field encrypted by the browser MUST decrypt cleanly in the CLI, and vice versa. This is verified by parity tests — if you change a primitive, run the parity tests against every target.
- **No per-target shortcuts.** If a target's stdlib offers a faster path (e.g., a hardware-accelerated AES on iOS), use it ONLY if the parity tests confirm the output matches every other target byte-for-byte.
- **No silent fallback** when a primitive is unavailable. If a target lacks the required crypto support, fail loudly at startup, not silently at first use.
## The daily check
Section III → D2 of `CLAVITOR-AGENT-HANDBOOK.md` enforces this with a `diff` between the browser and CLI `crypto.js` copies. Any divergence is a foundation alert.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → clavis-crypto for the full subproject contract.

View File

@ -0,0 +1,11 @@
# clavis-firefox
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-chrome/firefox/safari (your domain).
> **You are:** **James** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Firefox browser extension for Clavitor. Handles form detection, field filling, and the credential picker popup.
## Hard rules specific to this subproject
- **Never store L2 or L3** in any extension storage area (`browser.storage.local`, `browser.storage.sync`, `browser.storage.session`). Active session keys live in background-script memory only and die on extension restart.
- **Never reimplement crypto.** Always use `clavis-crypto` / the canonical `crypto.js`. If the extension reimplements crypto, it WILL drift and corrupt fields encrypted by the browser frontend or the Chrome extension.
- **Always HTTPS** to talk to the vault, even on localhost (self-signed cert in dev). Never plain HTTP.
- **Permissions are minimal.** Match the Chrome extension's permission set as closely as possible — if it needs more, justify why in a comment.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → Browser extensions for the full subproject contract.

View File

@ -0,0 +1,10 @@
# clavis-ios
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-android/ios (your domain).
> **You are:** **Xiao** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Native iOS client for Clavitor. Handles platform autofill integration and the credential picker UI.
## Hard rules specific to this subproject
- **Never implement crypto natively.** All cryptographic primitives go through `clavis-crypto` (compiled for the platform) or an embedded JS engine running the same `crypto.js` as the browser/CLI. Two crypto implementations on the same platform is a guaranteed drift.
- **Never persist L2 or L3** to the iOS keychain or any platform storage. The session key lives in process memory; biometric unlock re-derives via PRF from iOS's WebAuthn / passkey APIs.
- **Never** request entitlements beyond what's strictly required for autofill credentials.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → Mobile for the full subproject contract.

View File

@ -0,0 +1,12 @@
# clavis-safari
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-chrome/firefox/safari (your domain).
> **You are:** **James** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Safari browser extension for Clavitor. Handles form detection, field filling, and the credential picker popup. Distributed via the Safari Extensions infrastructure (App Store + Xcode-built container).
## Hard rules specific to this subproject
- **Never store L2 or L3** in any extension storage area or the iCloud Keychain. Active session keys live in process memory only and die on extension restart.
- **Never reimplement crypto.** Always use `clavis-crypto` / the canonical `crypto.js`. If the extension reimplements crypto, it WILL drift and corrupt fields encrypted by other clients.
- **Always HTTPS** to talk to the vault. Never plain HTTP.
- **Container app exists only to host the extension.** Do not add unrelated functionality to the container — that's a Mac app and it's not what we're shipping.
- **Permissions are minimal.** Match the Chrome/Firefox extension permission set; if Safari requires something extra, justify it in a comment with the Safari API doc reference.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → Browser extensions for the full subproject contract.

View File

@ -0,0 +1,40 @@
# clavis-telemetry
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-telemetry (your domain).
> **You are:** **Hans** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Operator telemetry: heartbeat metrics from POPs to central. CPU, memory, disk, vault count, request rates. Operational signals only — never user data.
## Hard rules specific to this subproject
- **Never send vault content.** Telemetry is operational, not data. No entry titles, no field values, no credential identifiers, no audit log entries that contain user information.
- **Never send raw user IP addresses.** Aggregate counts (e.g., `unique_ips_24h: 472`) are fine; raw IPs are not.
- **Commercial-only by default.** Community edition is offline-by-default. Telemetry is opt-in for community installs and enforced-on for commercial POPs. Build tags must reflect this — community binaries should not even contain the telemetry code path.
- **No phone-home for crashes.** If you ever want crash reporting, talk to Johan first. Auto-uploaded stack traces have leaked credentials in other products and we will not repeat that mistake.
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → clavis-telemetry for the full subproject contract.
## Operations
### Log Retention
Tarpit logs contain scanner IPs for security analysis. Rotate/delete per your organization's retention policy (recommended: 30 days).
### External Alerting (Optional)
Outage alerts can be sent to ntfy. Configure via environment variables:
- `NTFY_ALERT_URL` - The ntfy endpoint (e.g., `http://127.0.0.1:2586/clavitor-alerts`)
- `NTFY_ALERT_TOKEN` - Bearer token for authentication
If unset, outage logging continues without external notification.
### Kuma Monitoring (Optional)
Health push to Kuma can be configured via:
- `KUMA_PUSH_URL` - Kuma push endpoint
If unset, Kuma push is disabled.
## Dispatcher Verification
### Test: Dispatcher Agent Spawning (Issue #5)
**Status:** ✅ PASSED
**Date:** 2026-04-09
**Agent:** Hans (NOC/Operations)
**Domain:** clavis-telemetry
**Verification:**
- [x] Dispatcher correctly identified `clavis-telemetry` domain from issue
- [x] Dispatcher correctly assigned to Hans (NOC/Operations agent)
- [x] Hans spawned successfully and processed the issue
- [x] All telemetry tests pass (`go test -tags commercial ./...`)
- [x] No security violations detected in telemetry codebase
**Result:** The dispatcher flow correctly routes clavis-telemetry domain issues to Hans. The telemetry service is operational with mTLS authentication, tarpit defenses, and comprehensive monitoring.

Binary file not shown.

View File

@ -0,0 +1,5 @@
module clavitor.ai/clavis/clavis-telemetry
go 1.21
require github.com/mattn/go-sqlite3 v1.14.22

View File

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

View File

@ -0,0 +1,74 @@
//go:build commercial
package main
import (
"log"
"net/http"
"os"
"strings"
"time"
)
// kumaPush sends health status to Kuma every 60 seconds
func kumaPush() {
kumaURL := os.Getenv("KUMA_PUSH_URL")
if kumaURL == "" {
// Kuma push disabled - no hardcoded URL per KISS principle
return
}
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Send immediately on startup
sendKumaPush(kumaURL)
for range ticker.C {
sendKumaPush(kumaURL)
}
}
func sendKumaPush(kumaURL string) {
// Check health
var one int
err := db.QueryRow("SELECT 1").Scan(&one)
status := "up"
msg := "telemetry service healthy"
if err != nil {
status = "down"
msg = "database unavailable: " + err.Error()
}
// Check last telemetry received
var lastBeat int64
if err := db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat); err != nil {
log.Printf("ERR-TELEMETRY-033: Failed to query last telemetry timestamp - %v", err)
// Continue with lastBeat=0, will show warning status
}
now := time.Now().Unix()
if now-lastBeat > 300 {
// No telemetry in 5 minutes - still up but warning
if status == "up" {
msg = "no recent telemetry from POPs"
}
}
// POST to Kuma
payload := `{"status":"` + status + `","msg":"` + strings.ReplaceAll(msg, `"`, `\"`) + `","ping":60}`
resp, err := http.Post(kumaURL, "application/json", strings.NewReader(payload))
if err != nil {
log.Printf("ERR-TELEMETRY-030: Failed to push health status to Kuma at %s - %v", kumaURL, err)
return
}
if resp.StatusCode != http.StatusOK {
log.Printf("ERR-TELEMETRY-031: Kuma returned non-OK status %d from %s", resp.StatusCode, kumaURL)
if err := resp.Body.Close(); err != nil {
log.Printf("ERR-TELEMETRY-032: Failed to close Kuma response body after non-OK status - %v", err)
}
return
}
if err := resp.Body.Close(); err != nil {
log.Printf("ERR-TELEMETRY-032: Failed to close Kuma response body - %v", err)
}
}

View File

@ -0,0 +1,448 @@
//go:build commercial
// clavis-telemetry - POP telemetry ingestion service with mTLS
// Receives telemetry only from authenticated POPs with valid certificates
// Tarpits all unauthorized requests
//
// Log retention: Tarpit logs contain scanner IPs for security analysis.
// Rotate/delete per your organization's retention policy (recommended: 30 days).
package main
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
var db *sql.DB
var processStartTime = time.Now().Unix()
var caPool *x509.CertPool
func main() {
// Hardcoded relative path per KISS principle - data directory relative to executable
// Use symlinks if different layout needed
dataDir := "./data"
dbPath := dataDir + "/operations.db"
caChainPath := dataDir + "/ca-chain.crt"
var err error
db, err = sql.Open("sqlite3", dbPath)
if err != nil {
log.Fatalf("ERR-TELEMETRY-001: Failed to open operations.db at %s - %v. Check permissions and disk space.", dbPath, err)
}
defer db.Close()
// Load CA chain for mTLS - mandatory, no fallback
if err := loadCA(caChainPath); err != nil {
log.Fatalf("ERR-TELEMETRY-002: Failed to load CA chain from %s - %v. Ensure CA certificate exists and is valid PEM.", caChainPath, err)
}
// Ensure telemetry table exists
ensureTables()
// Start Kuma push goroutine
go kumaPush()
// Create server with mTLS (mandatory)
tlsConfig := setupTLS()
server := &http.Server{
Addr: ":1986",
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(routeHandler),
}
log.Printf("clavis-telemetry starting on port 1986")
log.Printf("Database: %s", dbPath)
log.Printf("mTLS: enabled")
log.Fatal(server.ListenAndServeTLS("", ""))
}
func loadCA(caChainPath string) error {
data, err := os.ReadFile(caChainPath)
if err != nil {
return err
}
caPool = x509.NewCertPool()
if !caPool.AppendCertsFromPEM(data) {
return fmt.Errorf("failed to parse CA chain")
}
return nil
}
func setupTLS() *tls.Config {
if caPool == nil {
return nil
}
return &tls.Config{
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}
}
func routeHandler(w http.ResponseWriter, r *http.Request) {
// Check if request has valid client certificate
if caPool != nil {
if len(r.TLS.PeerCertificates) == 0 {
tarpit(w, r)
return
}
// Extract POP identity from certificate CN
cert := r.TLS.PeerCertificates[0]
r.Header.Set("X-POP-ID", cert.Subject.CommonName)
}
switch r.URL.Path {
case "/telemetry":
handleTelemetry(w, r)
case "/health":
handleHealth(w, r)
case "/metrics":
handleMetrics(w, r)
default:
tarpit(w, r)
}
}
// tarpit wastes scanner resources - holds connection for 30s
func tarpit(w http.ResponseWriter, r *http.Request) {
realIP := r.RemoteAddr
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.SplitN(xff, ",", 2)
realIP = strings.TrimSpace(parts[0])
}
log.Printf("tarpit: %s %s from %s", r.Method, r.URL.Path, realIP)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(200)
// Verify flusher is available - otherwise tarpit is ineffective
flusher, ok := w.(http.Flusher)
if !ok {
log.Printf("ERR-TELEMETRY-040: tarpit called with ResponseWriter that does not implement http.Flusher - aborting")
return
}
flusher.Flush()
// Drip one byte per second for 30 seconds
for i := 0; i < 30; i++ {
_, err := w.Write([]byte(" "))
if err != nil {
return // Client disconnected
}
// Flush has no return value per http.Flusher interface
// Write error above is the primary signal for client disconnect
flusher.Flush()
time.Sleep(time.Second)
}
}
func ensureTables() {
// Telemetry table
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
received_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
version TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
uptime_seconds INTEGER NOT NULL DEFAULT 0,
cpu_percent REAL NOT NULL DEFAULT 0,
memory_total_mb INTEGER NOT NULL DEFAULT 0,
memory_used_mb INTEGER NOT NULL DEFAULT 0,
disk_total_mb INTEGER NOT NULL DEFAULT 0,
disk_used_mb INTEGER NOT NULL DEFAULT 0,
load_1m REAL NOT NULL DEFAULT 0,
vault_count INTEGER NOT NULL DEFAULT 0,
vault_size_mb REAL NOT NULL DEFAULT 0,
vault_entries INTEGER NOT NULL DEFAULT 0,
mode TEXT NOT NULL DEFAULT ''
)`); err != nil {
log.Fatalf("ERR-TELEMETRY-005: Failed to create telemetry table - %v", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id)`); err != nil {
log.Fatalf("ERR-TELEMETRY-006: Failed to create telemetry node_id index - %v", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_node_latest ON telemetry(node_id, id DESC)`); err != nil {
log.Fatalf("ERR-TELEMETRY-007: Failed to create telemetry node_latest index - %v", err)
}
// Uptime spans table
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS uptime_spans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
start_at INTEGER NOT NULL,
end_at INTEGER NOT NULL
)`); err != nil {
log.Fatalf("ERR-TELEMETRY-008: Failed to create uptime_spans table - %v", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_spans_node_end ON uptime_spans(node_id, end_at DESC)`); err != nil {
log.Fatalf("ERR-TELEMETRY-009: Failed to create uptime_spans index - %v", err)
}
// Maintenance table
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS maintenance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
end_at INTEGER,
reason TEXT NOT NULL DEFAULT '',
started_by TEXT NOT NULL DEFAULT '',
ended_by TEXT NOT NULL DEFAULT ''
)`); err != nil {
log.Fatalf("ERR-TELEMETRY-015: Failed to create maintenance table - %v", err)
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
// Check DB writable with timing
dbStart := time.Now()
var one int
err := db.QueryRow("SELECT 1").Scan(&one)
dbDuration := time.Since(dbStart)
RecordDBQueryDuration(dbDuration)
if err != nil {
http.Error(w, `{"status":"error","db":"unavailable"}`, 503)
return
}
// Check recent telemetry (any source)
var lastBeat int64
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","db":"ok","last_telemetry":%d}`, lastBeat)
}
func handleTelemetry(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if r.Method != "POST" {
w.WriteHeader(405)
return
}
// Verify mTLS certificate if enabled
var popID string
if caPool != nil {
if len(r.TLS.PeerCertificates) == 0 {
tarpit(w, r)
return
}
cert := r.TLS.PeerCertificates[0]
popID = cert.Subject.CommonName
// Verify certificate is valid and not expired
if _, err := cert.Verify(x509.VerifyOptions{Roots: caPool}); err != nil {
log.Printf("ERR-TELEMETRY-003: Invalid or expired certificate from %s - %v", popID, err)
tarpit(w, r)
return
}
}
var t struct {
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"`
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 certificate CN as authoritative node_id if mTLS enabled
if popID != "" {
t.NodeID = popID
} else if t.NodeID == "" {
t.NodeID = t.Hostname
}
if t.NodeID == "" {
http.Error(w, `{"error":"missing node_id or hostname"}`, 400)
return
}
// Merge nested fields
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)
}
// Insert telemetry
if _, err := 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); err != nil {
log.Printf("ERR-TELEMETRY-004: Failed to insert telemetry for node=%s - %v", t.NodeID, err)
http.Error(w, `{"error":"database error"}`, 500)
return
}
// Uptime span tracking
updateSpan(t.NodeID, t.Hostname, t.Version, t.CPUPercent, t.MemUsedMB, t.MemTotalMB, t.DiskUsedMB, t.DiskTotalMB, t.Load1m, t.UptimeSeconds)
// Record metrics
duration := time.Since(start)
RecordRequestDuration(duration)
RecordRequest(t.NodeID, "200")
w.WriteHeader(200)
}
func updateSpan(nodeID, hostname, version string, cpuPercent float64, memUsedMB, memTotalMB, diskUsedMB, diskTotalMB int64, load1m float64, uptimeSeconds int64) {
now := time.Now().Unix()
serverAge := now - processStartTime
var inMaint bool
if err := db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint); err != nil {
log.Printf("ERR-TELEMETRY-010: Failed to check maintenance mode - %v", err)
// Continue with inMaint=false as safe default
inMaint = false
}
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`, nodeID).Scan(&spanID, &spanEnd)
if err != nil && err != sql.ErrNoRows {
log.Printf("ERR-TELEMETRY-011: Failed to query latest uptime span for node=%s - %v", nodeID, err)
}
if err == nil && (inMaint || (now-spanEnd) <= 60) {
if _, execErr := db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID); execErr != nil {
log.Printf("ERR-TELEMETRY-012: Failed to extend uptime span id=%d for node=%s - %v", spanID, nodeID, execErr)
}
} else if err == nil && serverAge < 60 {
log.Printf("SPAN EXTEND node=%s gap=%ds (server up %ds, too early to judge)", nodeID, now-spanEnd, serverAge)
if _, execErr := db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID); execErr != nil {
log.Printf("ERR-TELEMETRY-013: Failed to extend early-judgment span id=%d for node=%s - %v", spanID, nodeID, execErr)
}
} else if !inMaint {
gapSeconds := now - spanEnd
if err == nil {
log.Printf("OUTAGE SPAN node=%s gap=%ds last_seen=%s resumed=%s prev_span_id=%d hostname=%s version=%s cpu=%.1f%% mem=%d/%dMB disk=%d/%dMB load=%.2f uptime=%ds",
nodeID, gapSeconds,
time.Unix(spanEnd, 0).UTC().Format(time.RFC3339),
time.Unix(now, 0).UTC().Format(time.RFC3339),
spanID, hostname, version,
cpuPercent, memUsedMB, memTotalMB,
diskUsedMB, diskTotalMB, load1m, uptimeSeconds)
} else {
log.Printf("OUTAGE SPAN node=%s first_span=true hostname=%s version=%s",
nodeID, hostname, version)
}
if _, execErr := db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, nodeID, now, now); execErr != nil {
log.Printf("ERR-TELEMETRY-014: Failed to insert new uptime span for node=%s - %v", nodeID, execErr)
return // Don't alert if we couldn't record the span
}
go alertOutage(nodeID, hostname, gapSeconds, err != nil)
}
}
func alertOutage(nodeID, hostname string, gap int64, firstSpan bool) {
ntfyURL := os.Getenv("NTFY_ALERT_URL")
ntfyToken := os.Getenv("NTFY_ALERT_TOKEN")
if ntfyURL == "" || ntfyToken == "" {
// Alerting disabled - log only
if firstSpan {
log.Printf("OUTAGE SPAN node=%s first_span=true (alerting disabled)", nodeID)
} else {
log.Printf("OUTAGE SPAN node=%s gap=%ds (alerting disabled)", nodeID, gap)
}
return
}
title := fmt.Sprintf("Outage recovery: %s", nodeID)
body := fmt.Sprintf("Node **%s** (%s) created new span after **%ds** gap", nodeID, hostname, gap)
if firstSpan {
title = fmt.Sprintf("New node online: %s", nodeID)
body = fmt.Sprintf("Node **%s** (%s) first heartbeat - new span created", nodeID, hostname)
}
req, err := http.NewRequest("POST", ntfyURL, strings.NewReader(body))
if err != nil {
log.Printf("ERR-TELEMETRY-020: Failed to create ntfy alert request for node=%s - %v", nodeID, err)
return
}
req.Header.Set("Authorization", "Bearer "+ntfyToken)
req.Header.Set("Title", title)
req.Header.Set("Markdown", "yes")
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("ERR-TELEMETRY-021: Failed to send ntfy alert for node=%s to %s - %v", nodeID, ntfyURL, err)
return
}
if err := resp.Body.Close(); err != nil {
log.Printf("ERR-TELEMETRY-022: Failed to close ntfy response body for node=%s - %v", nodeID, err)
}
log.Printf("OUTAGE SPAN ntfy alert sent for node=%s", nodeID)
}

View File

@ -0,0 +1,587 @@
//go:build commercial
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
// setupTestDB creates an in-memory database for testing
func setupTestDB(t *testing.T) {
var err error
db, err = sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
ensureTables()
}
// cleanupTestDB closes the test database
func cleanupTestDB() {
if db != nil {
db.Close()
}
}
func TestTarpit(t *testing.T) {
// tarpit holds connection for 30 seconds - test that it responds initially
req := httptest.NewRequest("GET", "/unknown", nil)
w := httptest.NewRecorder()
// Use a goroutine since tarpit blocks
done := make(chan bool)
go func() {
tarpit(w, req)
done <- true
}()
// Check initial response comes through quickly
time.Sleep(100 * time.Millisecond)
resp := w.Result()
if resp.StatusCode != 200 {
t.Errorf("tarpit status = %d, want 200", resp.StatusCode)
}
if resp.Header.Get("Content-Type") != "text/plain" {
t.Errorf("tarpit content-type = %s, want text/plain", resp.Header.Get("Content-Type"))
}
}
func TestHandleHealth(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handleHealth(w, req)
resp := w.Result()
if resp.StatusCode != 200 {
t.Errorf("handleHealth status = %d, want 200", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode health response: %v", err)
}
if result["status"] != "ok" {
t.Errorf("handleHealth status = %v, want ok", result["status"])
}
if result["db"] != "ok" {
t.Errorf("handleHealth db = %v, want ok", result["db"])
}
}
func TestHandleHealth_DBError(t *testing.T) {
// Don't setup DB - should return error
if db != nil {
db.Close()
db = nil
}
// Create a closed database to simulate failure
var err error
db, err = sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
db.Close() // Close immediately to force errors
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handleHealth(w, req)
resp := w.Result()
if resp.StatusCode != 503 {
t.Errorf("handleHealth with bad DB status = %d, want 503", resp.StatusCode)
}
}
func TestHandleTelemetry_MethodNotAllowed(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
req := httptest.NewRequest("GET", "/telemetry", nil)
w := httptest.NewRecorder()
handleTelemetry(w, req)
resp := w.Result()
if resp.StatusCode != 405 {
t.Errorf("handleTelemetry GET status = %d, want 405", resp.StatusCode)
}
}
func TestHandleTelemetry_BadPayload(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
req := httptest.NewRequest("POST", "/telemetry", strings.NewReader("not json"))
w := httptest.NewRecorder()
handleTelemetry(w, req)
resp := w.Result()
if resp.StatusCode != 400 {
t.Errorf("handleTelemetry bad payload status = %d, want 400", resp.StatusCode)
}
}
func TestHandleTelemetry_ValidPayload(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
// Temporarily disable mTLS for this test by clearing caPool
oldCAPool := caPool
caPool = nil
defer func() { caPool = oldCAPool }()
payload := map[string]interface{}{
"node_id": "test-node-1",
"version": "1.0.0",
"hostname": "test-host",
"uptime_seconds": 3600,
"cpu_percent": 25.5,
"memory_total_mb": 8192,
"memory_used_mb": 4096,
"disk_total_mb": 100000,
"disk_used_mb": 50000,
"load_1m": 0.5,
"vault_count": 5,
"vault_size_mb": 10.5,
"vault_entries": 100,
"mode": "commercial",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/telemetry", bytes.NewReader(body))
w := httptest.NewRecorder()
handleTelemetry(w, req)
resp := w.Result()
if resp.StatusCode != 200 {
t.Errorf("handleTelemetry valid payload status = %d, want 200", resp.StatusCode)
}
// Verify data was written
var count int
err := db.QueryRow("SELECT COUNT(*) FROM telemetry WHERE node_id = ?", "test-node-1").Scan(&count)
if err != nil {
t.Fatalf("Failed to query telemetry: %v", err)
}
if count != 1 {
t.Errorf("telemetry count = %d, want 1", count)
}
}
func TestHandleTelemetry_MissingNodeID(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
// Temporarily disable mTLS for this test
oldCAPool := caPool
caPool = nil
defer func() { caPool = oldCAPool }()
payload := map[string]interface{}{
"version": "1.0.0",
// Missing node_id and hostname
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/telemetry", bytes.NewReader(body))
w := httptest.NewRecorder()
handleTelemetry(w, req)
resp := w.Result()
if resp.StatusCode != 400 {
t.Errorf("handleTelemetry missing node_id status = %d, want 400", resp.StatusCode)
}
}
func TestLoadCA(t *testing.T) {
// Test with non-existent file
err := loadCA("/nonexistent/path/ca.crt")
if err == nil {
t.Error("loadCA with non-existent file should error")
}
// Test with invalid PEM content
tmpFile, err := os.CreateTemp("", "ca-*.crt")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
tmpFile.WriteString("not valid pem")
tmpFile.Close()
err = loadCA(tmpFile.Name())
if err == nil {
t.Error("loadCA with invalid PEM should error")
}
}
func TestSetupTLS(t *testing.T) {
// Test with nil caPool
caPool = nil
config := setupTLS()
if config != nil {
t.Error("setupTLS with nil caPool should return nil")
}
// Test with valid caPool
// Create a temp CA file with dummy cert (won't validate but tests parsing)
tmpFile, err := os.CreateTemp("", "ca-*.crt")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
// Write a dummy CA cert
dummyCert := `-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBfpE
-----END CERTIFICATE-----`
tmpFile.WriteString(dummyCert)
tmpFile.Close()
// This will fail to parse but sets up the test
_ = loadCA(tmpFile.Name())
// caPool might be nil or set, just verify setupTLS doesn't panic
_ = setupTLS()
}
func TestRouteHandler(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
// Disable mTLS for route tests (TLS is tested separately)
oldCAPool := caPool
caPool = nil
defer func() { caPool = oldCAPool }()
tests := []struct {
path string
wantStatus int
}{
{"/health", 200},
{"/unknown", 200}, // tarpit returns 200 then holds connection
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
// For unknown paths, tarpit runs asynchronously
if tt.path == "/unknown" {
go routeHandler(w, req)
time.Sleep(50 * time.Millisecond)
resp := w.Result()
if resp.StatusCode != 200 {
t.Errorf("routeHandler %s status = %d, want 200", tt.path, resp.StatusCode)
}
} else {
routeHandler(w, req)
resp := w.Result()
if resp.StatusCode != tt.wantStatus {
t.Errorf("routeHandler %s status = %d, want %d", tt.path, resp.StatusCode, tt.wantStatus)
}
}
})
}
}
func TestAlertOutage_Disabled(t *testing.T) {
// Ensure no env vars are set
os.Unsetenv("NTFY_ALERT_URL")
os.Unsetenv("NTFY_ALERT_TOKEN")
// Should not panic and should log only
alertOutage("test-node", "test-host", 60, false)
alertOutage("test-node", "test-host", 0, true)
}
func TestEnsureTables(t *testing.T) {
setupTestDB(t)
defer cleanupTestDB()
// Verify tables exist by querying them
tables := []string{"telemetry", "uptime_spans", "maintenance"}
for _, table := range tables {
var name string
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
if err != nil {
t.Errorf("Table %s should exist: %v", table, err)
}
if name != table {
t.Errorf("Table name = %s, want %s", name, table)
}
}
}
// Metrics tests
func TestHandleMetrics(t *testing.T) {
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handleMetrics(w, req)
resp := w.Result()
if resp.StatusCode != 200 {
t.Errorf("handleMetrics status = %d, want 200", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "text/plain") {
t.Errorf("handleMetrics content-type = %s, want text/plain", contentType)
}
body := w.Body.String()
// Check for expected metric names
expectedMetrics := []string{
"# HELP telemetry_requests_total",
"# TYPE telemetry_requests_total counter",
"# HELP telemetry_request_duration_seconds",
"# TYPE telemetry_request_duration_seconds histogram",
"# HELP active_connections",
"# TYPE active_connections gauge",
"# HELP db_query_duration_seconds",
"# TYPE db_query_duration_seconds histogram",
}
for _, metric := range expectedMetrics {
if !strings.Contains(body, metric) {
t.Errorf("handleMetrics response missing: %s", metric)
}
}
}
func TestHandleMetrics_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest("POST", "/metrics", nil)
w := httptest.NewRecorder()
handleMetrics(w, req)
resp := w.Result()
if resp.StatusCode != 405 {
t.Errorf("handleMetrics POST status = %d, want 405", resp.StatusCode)
}
}
func TestRecordRequest(t *testing.T) {
// Clear any existing metrics
requestsTotalMu.Lock()
requestsTotal = make(map[string]uint64)
requestsTotalMu.Unlock()
// Record some requests
RecordRequest("pop-zrh-1", "200")
RecordRequest("pop-zrh-1", "200")
RecordRequest("pop-zrh-1", "500")
RecordRequest("pop-nyc-1", "200")
// Verify counts
requestsTotalMu.RLock()
if requestsTotal["pop-zrh-1:200"] != 2 {
t.Errorf("pop-zrh-1:200 count = %d, want 2", requestsTotal["pop-zrh-1:200"])
}
if requestsTotal["pop-zrh-1:500"] != 1 {
t.Errorf("pop-zrh-1:500 count = %d, want 1", requestsTotal["pop-zrh-1:500"])
}
if requestsTotal["pop-nyc-1:200"] != 1 {
t.Errorf("pop-nyc-1:200 count = %d, want 1", requestsTotal["pop-nyc-1:200"])
}
requestsTotalMu.RUnlock()
}
func TestRecordRequestDuration(t *testing.T) {
// Reset histogram
reqDurationMu.Lock()
reqDurationCount = 0
reqDurationSum = 0
for _, b := range histogramBuckets {
reqDurationBuckets[b] = 0
}
reqDurationMu.Unlock()
// Record durations
RecordRequestDuration(50 * time.Millisecond)
RecordRequestDuration(150 * time.Millisecond)
RecordRequestDuration(2 * time.Second)
// Verify
count := atomic.LoadUint64(&reqDurationCount)
if count != 3 {
t.Errorf("reqDurationCount = %d, want 3", count)
}
reqDurationMu.RLock()
if reqDurationSum < 2.0 || reqDurationSum > 2.5 {
t.Errorf("reqDurationSum = %f, expected around 2.2", reqDurationSum)
}
// 50ms should be in all buckets >= 0.05 (cumulative histogram)
// Buckets: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
// 50ms falls into: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
if reqDurationBuckets[0.05] != 1 {
t.Errorf("bucket 0.05 = %d, want 1", reqDurationBuckets[0.05])
}
// 2s falls into buckets >= 2.5: 2.5, 5, 10 (cumulative - includes all 3 observations)
// All 3 observations (50ms, 150ms, 2s) fall into buckets >= 2.5
if reqDurationBuckets[2.5] != 3 {
t.Errorf("bucket 2.5 = %d, want 3", reqDurationBuckets[2.5])
}
reqDurationMu.RUnlock()
}
func TestRecordDBQueryDuration(t *testing.T) {
// Reset histogram
dbDurationMu.Lock()
dbDurationCount = 0
dbDurationSum = 0
for _, b := range histogramBuckets {
dbDurationBuckets[b] = 0
}
dbDurationMu.Unlock()
// Record durations
RecordDBQueryDuration(5 * time.Millisecond)
RecordDBQueryDuration(25 * time.Millisecond)
// Verify
count := atomic.LoadUint64(&dbDurationCount)
if count != 2 {
t.Errorf("dbDurationCount = %d, want 2", count)
}
dbDurationMu.RLock()
if dbDurationBuckets[0.05] != 2 {
t.Errorf("db bucket 0.05 = %d, want 2", dbDurationBuckets[0.05])
}
dbDurationMu.RUnlock()
}
func TestActiveConnections(t *testing.T) {
// Reset
atomic.StoreInt64(&activeConnections, 0)
// Test increment/decrement
IncrementActiveConnections()
IncrementActiveConnections()
if GetActiveConnections() != 2 {
t.Errorf("activeConnections = %d, want 2", GetActiveConnections())
}
DecrementActiveConnections()
if GetActiveConnections() != 1 {
t.Errorf("activeConnections = %d, want 1", GetActiveConnections())
}
DecrementActiveConnections()
if GetActiveConnections() != 0 {
t.Errorf("activeConnections = %d, want 0", GetActiveConnections())
}
}
func TestSplitLast(t *testing.T) {
tests := []struct {
input string
sep string
expected []string
}{
{"pop-zrh-1:200", ":", []string{"pop-zrh-1", "200"}},
{"pop-zrh-1:status:200", ":", []string{"pop-zrh-1:status", "200"}},
{"no-separator", ":", []string{"no-separator"}},
{"", ":", []string{""}},
}
for _, tt := range tests {
result := splitLast(tt.input, tt.sep)
if len(result) != len(tt.expected) {
t.Errorf("splitLast(%q, %q) = %v, want %v", tt.input, tt.sep, result, tt.expected)
continue
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("splitLast(%q, %q)[%d] = %q, want %q", tt.input, tt.sep, i, result[i], tt.expected[i])
}
}
}
}
func TestFormatFloat(t *testing.T) {
tests := []struct {
input float64
expected string
}{
{0.005, "0.005"},
{1.0, "1"},
{2.5, "2.5"},
{0.1, "0.1"},
}
for _, tt := range tests {
result := formatFloat(tt.input)
if result != tt.expected {
t.Errorf("formatFloat(%f) = %q, want %q", tt.input, result, tt.expected)
}
}
}
// Test that mTLS enforcement works
type mockResponseWriter struct {
headers http.Header
status int
written bool
}
func (m *mockResponseWriter) Header() http.Header {
return m.headers
}
func (m *mockResponseWriter) Write(p []byte) (int, error) {
m.written = true
return len(p), nil
}
func (m *mockResponseWriter) WriteHeader(status int) {
m.status = status
}
func TestMTLSRequired(t *testing.T) {
// This test documents that mTLS is now mandatory
// The main() function will fail if CA chain is not present
// We verify the setupTLS function returns a proper config when CA is loaded
// Create a proper test CA pool
caPool = x509.NewCertPool()
config := setupTLS()
if config == nil {
t.Error("setupTLS should return config when caPool is set")
}
if config.ClientAuth != tls.RequireAndVerifyClientCert {
t.Errorf("ClientAuth = %v, want RequireAndVerifyClientCert", config.ClientAuth)
}
if config.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %d, want TLS13", config.MinVersion)
}
}

View File

@ -0,0 +1,184 @@
//go:build commercial
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// Prometheus-style metrics for telemetry service
// Following KISS principle - no external dependencies, simple text format
var (
// Counters: telemetry_requests_total{pop_id, status}
requestsTotalMu sync.RWMutex
requestsTotal = make(map[string]uint64) // key: "pop_id:status"
// Gauge: active_connections
activeConnections int64
// Histogram: telemetry_request_duration_seconds
reqDurationMu sync.RWMutex
reqDurationCount uint64
reqDurationSum float64
reqDurationBuckets = make(map[float64]uint64)
// Histogram: db_query_duration_seconds
dbDurationMu sync.RWMutex
dbDurationCount uint64
dbDurationSum float64
dbDurationBuckets = make(map[float64]uint64)
)
// Standard Prometheus histogram buckets
var histogramBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
func init() {
// Initialize bucket counters
for _, b := range histogramBuckets {
reqDurationBuckets[b] = 0
dbDurationBuckets[b] = 0
}
}
// RecordRequest increments the request counter for a given POP and status
func RecordRequest(popID, status string) {
key := popID + ":" + status
requestsTotalMu.Lock()
requestsTotal[key]++
requestsTotalMu.Unlock()
}
// RecordRequestDuration records a request duration observation
func RecordRequestDuration(duration time.Duration) {
seconds := duration.Seconds()
atomic.AddUint64(&reqDurationCount, 1)
reqDurationMu.Lock()
reqDurationSum += seconds
for _, b := range histogramBuckets {
if seconds <= b {
reqDurationBuckets[b]++
}
}
reqDurationMu.Unlock()
}
// RecordDBQueryDuration records a database query duration observation
func RecordDBQueryDuration(duration time.Duration) {
seconds := duration.Seconds()
atomic.AddUint64(&dbDurationCount, 1)
dbDurationMu.Lock()
dbDurationSum += seconds
for _, b := range histogramBuckets {
if seconds <= b {
dbDurationBuckets[b]++
}
}
dbDurationMu.Unlock()
}
// IncrementActiveConnections increments the active connections gauge
func IncrementActiveConnections() {
atomic.AddInt64(&activeConnections, 1)
}
// DecrementActiveConnections decrements the active connections gauge
func DecrementActiveConnections() {
atomic.AddInt64(&activeConnections, -1)
}
// GetActiveConnections returns the current active connections count
func GetActiveConnections() int64 {
return atomic.LoadInt64(&activeConnections)
}
// handleMetrics serves Prometheus-format metrics
func handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" {
w.WriteHeader(405)
return
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
var output strings.Builder
// telemetry_requests_total counter
output.WriteString("# HELP telemetry_requests_total Total number of telemetry requests\n")
output.WriteString("# TYPE telemetry_requests_total counter\n")
requestsTotalMu.RLock()
for key, count := range requestsTotal {
parts := splitLast(key, ":")
if len(parts) == 2 {
output.WriteString(fmt.Sprintf("telemetry_requests_total{pop_id=\"%s\",status=\"%s\"} %d\n", parts[0], parts[1], count))
}
}
requestsTotalMu.RUnlock()
output.WriteString("\n")
// telemetry_request_duration_seconds histogram
output.WriteString("# HELP telemetry_request_duration_seconds Request duration in seconds\n")
output.WriteString("# TYPE telemetry_request_duration_seconds histogram\n")
reqCount := atomic.LoadUint64(&reqDurationCount)
reqDurationMu.RLock()
for _, b := range histogramBuckets {
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_bucket{le=\"%s\"} %d\n", formatFloat(b), reqDurationBuckets[b]))
}
reqSum := reqDurationSum
reqDurationMu.RUnlock()
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", reqCount))
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_count %d\n", reqCount))
output.WriteString(fmt.Sprintf("telemetry_request_duration_seconds_sum %s\n", formatFloat(reqSum)))
output.WriteString("\n")
// active_connections gauge
output.WriteString("# HELP active_connections Current number of active connections\n")
output.WriteString("# TYPE active_connections gauge\n")
output.WriteString(fmt.Sprintf("active_connections %d\n", GetActiveConnections()))
output.WriteString("\n")
// db_query_duration_seconds histogram
output.WriteString("# HELP db_query_duration_seconds Database query duration in seconds\n")
output.WriteString("# TYPE db_query_duration_seconds histogram\n")
dbCount := atomic.LoadUint64(&dbDurationCount)
dbDurationMu.RLock()
for _, b := range histogramBuckets {
output.WriteString(fmt.Sprintf("db_query_duration_seconds_bucket{le=\"%s\"} %d\n", formatFloat(b), dbDurationBuckets[b]))
}
dbSum := dbDurationSum
dbDurationMu.RUnlock()
output.WriteString(fmt.Sprintf("db_query_duration_seconds_bucket{le=\"+Inf\"} %d\n", dbCount))
output.WriteString(fmt.Sprintf("db_query_duration_seconds_count %d\n", dbCount))
output.WriteString(fmt.Sprintf("db_query_duration_seconds_sum %s\n", formatFloat(dbSum)))
w.WriteHeader(200)
w.Write([]byte(output.String()))
}
// splitLast splits a string on the last occurrence of sep
func splitLast(s, sep string) []string {
idx := strings.LastIndex(s, sep)
if idx == -1 {
return []string{s}
}
return []string{s[:idx], s[idx+len(sep):]}
}
// formatFloat formats a float without scientific notation
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}

View File

@ -1,41 +1,25 @@
# Clavis Vault — CLAUDE.md
> **Quickstart (60s):** [../../QUICKSTART.md](../../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../../CLAVITOR-AGENT-HANDBOOK.md](../../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavis-vault (your domain).
> **You are:** **Sarah** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
## 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.
---
## Security Failures — NEVER HIDE THEM
**The cardinal rule:** If decryption/verification fails, expose the failure. Never fall back to plaintext. Never silently continue.
### WRONG — Silent fallback (fireable offense)
```javascript
try {
@ -44,7 +28,6 @@ try {
decrypted = plaintext; // NEVER DO THIS
}
```
### CORRECT — Visible failure
```javascript
try {
@ -53,24 +36,17 @@ try {
decrypted = '[decryption failed]'; // User sees the failure
}
```
This applies to:
- Encryption/decryption errors
- Signature verification failures
- Authentication failures
- Tampering detection
- Any security-critical operation
**Security failures must be noisy, visible, and blocking — never silent, hidden, or permissive.**
See `SECURITY.md` for full principles.
---
## Edition System (Community vs Commercial)
Clavitor Vault has two editions with build-time separation:
### Community Edition (Default)
```bash
go build -o clavitor ./cmd/clavitor/
@ -79,7 +55,6 @@ go build -o clavitor ./cmd/clavitor/
- Local logging only
- Self-hosted
- Elastic License 2.0
### Commercial Edition
```bash
go build -tags commercial -o clavitor ./cmd/clavitor/
@ -88,27 +63,18 @@ go build -tags commercial -o clavitor ./cmd/clavitor/
- Operator alerts POST to `/v1/alerts`
- Multi-POP management
- Commercial license
### Using the Edition Package
```go
import "github.com/johanj/clavitor/edition"
// Send operator alerts (works in both editions)
edition.Current.AlertOperator(ctx, "auth_error", "message", details)
// Check edition
currentEdition := edition.Current.Name() // "community" or "commercial"
```
See `edition/CLAUDE.md` for full documentation.
---
## Clavitor Vault v2 — Current State & Testing
### What we built this session
#### 1. Domain classification for import scopes
- Import page (`cmd/clavitor/web/import.html`) parses 14+ password manager formats client-side
- Unique domains are extracted (eTLD+1) and sent to `https://clavitor.ai/classify`
@ -117,7 +83,6 @@ See `edition/CLAUDE.md` for full documentation.
- Domains with no URL get scope "unclassified" (not "misc"). "misc" = LLM tried and failed
- Domains are sent in chunks of 200 to stay within token limits
- Classification is opt-in: user sees consent dialog with Yes/Skip/Cancel
#### 2. Import flow UX
- Drop file → parse → hide file step → consent dialog (Yes/Skip/Cancel)
- Cancel returns to file step
@ -127,7 +92,6 @@ See `edition/CLAUDE.md` for full documentation.
- Black entry icons (LGN/CARD/NOTE) with white text — on brand
- Global black checkboxes (`accent-color: var(--text)`)
- Unified CSS classes: `.item-row`, `.item-icon`, `.item-list` (replacing import-specific classes)
#### 3. Import filtering visibility (NEW)
- Import parsers now track what gets filtered and why
- `importers-parsers.js` has `_importReport` object that records: `rawCount`, `filtered[]`, `finalCount`
@ -136,7 +100,6 @@ See `edition/CLAUDE.md` for full documentation.
- Filter badge with tooltip showing breakdown: `"Filtered: 150 duplicate, 97 alias, 12 trashed"`
- Clicking the filtered badge shows detailed list of what was skipped
- **Why**: User saw 1073 during import but only 818 in vault. Now they can see the ~250 records were: duplicates (older versions), email aliases, and trashed items.
#### 4. Credential Alternates System (NEW)
- **Problem**: Chrome/Firefox imports don't have modification dates, so we can't tell which password is newer. Same site+user from different sources would overwrite each other.
- **Solution**: Multiple passwords per site+user are now stored as "alternates" instead of overwriting.
@ -149,7 +112,6 @@ See `edition/CLAUDE.md` for full documentation.
- `GET /ext/match?url=...` now returns alternates array sorted by verified_at (verified first)
- **CLI Protocol**: When multiple passwords for same site+user, try verified ones first. On success, call `/worked` endpoint. Backend marks winner as verified and links alternates to it.
- **Import UI**: Shows `"Done — 50 imported, 12 alternates"` so user knows some were stored as alternates.
#### 5. Security hardening (IN PROGRESS — needs testing)
- **List endpoint stripped**: GET /api/entries now always returns metadata only (title, type, scopes, entry_id). No data blobs, no ?meta=1 toggle. Full entry data only via GET /api/entries/{id} with scope enforcement.
- **Agent system type guard**: Agents cannot create/update entries with type=agent or type=scope. Enforced on CreateEntry, CreateEntryBatch, UpsertEntry, UpdateEntry.
@ -157,11 +119,8 @@ See `edition/CLAUDE.md` for full documentation.
- **Per-agent IP whitelist**: Stored in agent entry (L1-encrypted). Empty on creation → filled with IP from first contact → enforced on every subsequent request. Supports CIDRs (10.0.0.0/16), exact IPs, and FQDNs (home.smith.family), comma-separated.
- **Per-agent rate limiting**: Configurable requests/minute per agent ID (not per IP). Stored in agent entry.
- **Admin operations require PRF tap**: Agent CRUD and scope updates require a fresh WebAuthn assertion. Flow: POST /auth/admin/begin → PRF tap → POST /auth/admin/complete → one-time admin token in X-Admin-Token header → pass to admin endpoint. Token is single-use, 5-minute expiry.
### What is semi-done / needs testing
The security hardening code compiles and the vault runs, but none of it has been tested with actual agent tokens or WebAuthn assertions yet. Specifically:
1. **IP whitelist first-contact fill**: ✅ Fixed - DB errors now return 500
2. **IP whitelist enforcement**: Does CIDR matching work? FQDN resolution? Comma-separated lists? FQDN now has 5-min cache
3. **Per-agent rate limiter**: Does it correctly track per agent ID and reset per minute?
@ -169,30 +128,22 @@ The security hardening code compiles and the vault runs, but none of it has been
5. **System type guards**: ✅ Fixed - Agents blocked entirely from batch import; returns 403 on forbidden types
6. **L3 field preservation**: ✅ Fixed - Agents cannot overwrite L3 fields in batch or upsert
7. **List endpoint**: Verify no data blobs leak. Check browser console: entries[0] should have no data or fields property.
### Known Issues (Accepted)
**IP Whitelist Race Condition**: There is a theoretical race on first-contact IP recording if two parallel requests from different IPs arrive simultaneously. This was reviewed and accepted because:
- Requires a stolen agent token (already a compromise)
- Requires racing first contact from two different IPs
- The "loser" simply won't be auto-whitelisted
- Cannot be reproduced in testing; practically impossible to trigger
- Fix would require plaintext column + atomic update (not worth complexity)
See comment in `api/middleware.go` for full rationale.
**Admin Token Consumed Early**: The admin token is consumed immediately upon validation in `requireAdmin()`. If the subsequent operation fails (DB error, validation error, etc.), the token is gone but the operation didn't complete. The user must perform a fresh PRF tap to retry.
This was reviewed and accepted because:
- 5-10 minute token lifetime makes re-auth acceptable
- It's a UX inconvenience, not a security vulnerability
- Deferring consumption until operation success would require transaction-like complexity
- Rare edge case: requires admin operation to fail after token validation
### How testing works
No automated test suite for this session's work. Testing is manual via the browser:
1. Vault runs locally on forge (this machine) at port 1984, accessed via https://dev.clavitor.ai/app/
2. Caddy on 192.168.0.2 reverse-proxies dev.clavitor.ai → forge:1984
3. Import testing: Drop a Proton Pass ZIP export (or any of the 14 supported formats) on the import page. Check scope pills, counts, classifications, and **filtered count badge** — shows why records were skipped (duplicates, aliases, trashed).
@ -200,9 +151,7 @@ No automated test suite for this session's work. Testing is manual via the brows
5. Screen capture: `/capture` skill takes a live screenshot from Johan's Mac (display 3). `/screenshot` fetches the latest manual screenshot.
6. Version verification: The topbar shows the **build timestamp** (e.g., `2026-04-04-1432`) fetched from `/api/version`. If the timestamp doesn't update after `make dev`, the old binary is still running — check `make status`.
7. DB location: Vault data is in `/home/johan/dev/clavitor/clavis/clavis-vault/data/`. Delete clavitor-* files there to start fresh (will require passkey re-registration).
### Key files
| File | What |
|------|------|
| api/handlers.go | All HTTP handlers, security guards, admin auth |
@ -216,22 +165,17 @@ No automated test suite for this session's work. Testing is manual via the brows
| cmd/clavitor/web/topbar.js | Version number, nav, idle timer |
| cmd/clavitor/web/clavitor-app.css | All styles, item-row/item-icon system |
| clavitor.ai/main.go | Portal + /classify endpoint (Haiku on OpenRouter) |
### Deploy Clavitor Vault (dev)
Working directory: `/home/johan/dev/clavitor/clavis/clavis-vault`
**Prerequisites:**
```bash
# Enable user systemd services (one-time setup)
systemctl --user enable --now clavitor.service
```
**Build and deploy (one command):**
```bash
make dev # stop → build → start (graceful shutdown via SIGTERM)
```
**Individual commands:**
```bash
make stop # systemctl --user stop clavitor.service
@ -241,40 +185,25 @@ make status # systemctl --user status clavitor.service
make logs # journalctl --user -u clavitor -f
make build # go build...
```
**Service file location:** `~/.config/systemd/user/clavitor.service`
Caddy on 192.168.0.2 reverse-proxies dev.clavitor.ai → forge:1984 (self-signed, so tls_insecure_skip_verify).
**Update Caddy config:**
```bash
ssh root@192.168.0.2
# Edit /etc/caddy/Caddyfile, then:
systemctl reload caddy
```
Web files are embedded at compile time (go:embed). CSS/JS/HTML changes require rebuild.
Bump version in `cmd/clavitor/web/topbar.js` (search for v2.0.) to verify new build is live.
### Deploy clavitor.ai (prod)
Working directory: `/home/johan/dev/clavitor/clavitor.ai`
```bash
make deploy-prod
```
This cross-compiles, SCPs to Zürich, enters maintenance mode, restarts systemd, exits maintenance. One command.
SSH: root@clavitor.ai — port 22 blocked on public IP, use Tailscale. Never use johan@. Avoid rapid SSH attempts (fail2ban will lock you out — it already happened once this session).
Env vars are in `/opt/clavitor-web/.env` and `/etc/systemd/system/clavitor-web.service`. After changing .env, run `systemctl daemon-reload && systemctl restart clavitor-web` on the server.
**NEVER deploy the database. Only the binary gets uploaded. The SQLite DB on prod is the source of truth.**
Verify: `ssh root@<tailscale-ip> "systemctl status clavitor-web"`
### IMPORTANT
**NEVER deploy to prod without Johan's explicit approval. This caused a SEV-1 on 2026-03-29.**
**NEVER deploy to prod without Johan's explicit approval. This caused a SEV-1 on 2026-03-29.**

View File

@ -1,258 +1,165 @@
# Clavitor Vault Login - Simplified Flow (Single Node)
# Clavitor Vault Login — Simplified Flow
**Date:** April 5, 2026
**Status:** Simplified Architecture - WL3s Stored Locally
**Updated:** April 7, 2026
**Status:** P1 lookup over filesystem-backed WL3
---
## The Problem We Solved
## The model
**Original complex flow:**
```
User → Vault → POP (local WL3 lookup) → Admin (if not cached)
User → Vault (local WL3 files) → SQLite vault data
```
**Simplified flow:**
```
User → Vault (local SQLite with credentials table)
```
**Why:** We don't need distributed POP infrastructure until we have:
- Thousands of users
- Global latency requirements
- Multiple datacenters
**For now:** Single-node vault with local WL3 storage works fine.
Login is a P1 lookup against a small JSON file on disk. No central round-trip,
no SQLite credential index — just `WL3/<shard>/<p1>.json`.
---
## New Login Flow
## Key tiers — L vs P
### Registration (First Device)
There are two parallel families derived from the same WebAuthn PRF output:
| Tier | L (encryption key, secret) | P (lookup token, public-ish) | Bytes |
|------|----------------------------|------------------------------|-------|
| 0 | L0 — vault file routing | — | 4 |
| 1 | L1 — server encryption key | **P1 — WL3 lookup index** | 8 |
| 2 | L2 — agent decryption key | — | 16 |
| 3 | L3 — hardware-only key | — | 32 |
- **L0L3** are derived from the user's L3 master and used to encrypt vault data
at progressively higher tiers. Knowing L3 gives you all of L0/L1/L2.
- **P1** is derived from the same 32-byte PRF output via HKDF with a distinct
info string (`clavitor-p1-v1`). It is the public lookup token for the WL3 file.
Knowing P1 leaks no decryption capability — login still requires a valid
WebAuthn assertion.
P1 ⊥ L1: HKDF with different info strings produces uncorrelated outputs.
---
## Registration (first device)
```
1. User taps "Create Vault"
2. Web generates random L3 (32 bytes)
3. Web calls navigator.credentials.create() → gets PRF
4. P0 = first 4 bytes of PRF
5. wrapped_L3 = encrypt(L3, PRF)
6. POST /api/register {credential_id, p0, wrapped_l3, device_name}
7. Server stores in vault credentials table
8. Web derives L0-L3 from L3, creates vault
9. Done - user is in
2. Browser calls navigator.credentials.create() with PRF extension
3. PRF output → 32-byte master key
4. P1 = HKDF(masterKey, info="clavitor-p1-v1", 8 bytes)
5. L0 = first 4 bytes of masterKey (vault file routing)
6. L1 = first 8 bytes of masterKey (vault data encryption)
7. wrapped_L3 = encrypt(L3, PRF) (browser only)
8. POST /api/auth/register/complete {master_key, credential_id, public_key, wrapped_l3}
9. Server derives P1, creates clavitor-<L0> SQLite, writes WL3/<shard>/<p1>.json
10. Done — user is in
```
### Login (Returning User)
## Login (returning user, no localStorage)
```
1. User sees "Unlock Vault" button
2. Web calls navigator.credentials.get() → gets PRF
3. P0 = first 4 bytes of PRF
4. POST /api/unlock {p0}
5. Server: SELECT wrapped_l3 FROM credentials WHERE p0 = ?
6. Server returns wrapped_L3
7. Web: L3 = decrypt(wrapped_l3, PRF)
8. Web derives L0-L2 from L3
9. Web can now decrypt vault entries
10. Server opens vault DB with L0
1. Browser calls navigator.credentials.get() with PRF extension
2. PRF output → 32-byte master key
3. P1 = HKDF(masterKey, info="clavitor-p1-v1", 8 bytes)
4. POST /api/auth/login/begin {p1}
5. Server reads WL3/<shard>/<p1>.json → has L0, home_pop, credential_id list
6. Server opens clavitor-<L0> SQLite, returns WebAuthn challenge
7. Browser does WebAuthn assertion, posts to /api/auth/login/complete
8. Server verifies assertion, opens session
9. Browser unwraps L3 in memory using PRF, derives L1 for vault calls
```
---
## API Endpoints (Vault)
## WL3 file format
### POST /api/auth/register
One file per credential at `<wl3_dir>/<first-byte-hex>/<full-p1-hex>.json`:
```json
{
"p1": "ab2f7c8d9e1f4a3b",
"l0": "12345678",
"wrapped_l3": "base64url(...)",
"credential_id": "base64url(...)",
"public_key": "base64url(...)",
"home_pop": "uk1",
"created_at": 1712534400
}
```
- **No personal data.** No customer_id, no name, no email. The link from a
credential to a human lives only in the central admin DB. WL3 files are
GDPR-out-of-scope by design.
- **Append-only.** Files are written once at registration and never edited.
Atomic write via `.tmp` + rename.
- **Sharded** by first byte of P1 (256 directories) for filesystem sanity at
scale. ~390k files per shard at 100M users — fine for ext4/xfs.
Default location: `./WL3/` relative to the binary's working directory. Override
with the `WL3_DIR` environment variable.
---
## API endpoints
### POST /api/auth/register/complete
**Request:**
```json
{
"credential_id": "base64(...)", // WebAuthn credential ID
"p0": "a1b2c3d4", // First 4 bytes of PRF (hex)
"wrapped_l3": "base64(...)", // L3 encrypted with PRF
"device_name": "YubiKey 5 NFC",
"device_type": "cross-platform",
"prf_salt": "Clavitor" // Salt used for PRF
"challenge": "base64(...)",
"credential_id": "base64(...)",
"public_key": "base64(...)",
"master_key": "base64(32 bytes)",
"name": "Primary Passkey",
"authenticator_attachment": "platform"
}
```
**Response:**
```json
{
"status": "created",
"vault_l0": "6f696d94" // L0 for vault filename
"status": "registered",
"cred_id": "...",
"registered_types": ["platform"]
}
```
### POST /api/auth/unlock
### POST /api/auth/login/begin
**Request:**
```json
{
"p0": "a1b2c3d4" // First 4 bytes of PRF
"p1": "ab2f7c8d9e1f4a3b"
}
```
**Response:**
```json
{
"status": "unlocked",
"wrapped_l3": "base64(...)" // Encrypted L3 to unwrap
}
```
### POST /api/auth/add-device (for 2nd device)
**Request:**
```json
{
"l3": "base64(...)", // Current L3 (from already unlocked session)
"new_credential_id": "base64(...)",
"new_device_name": "iPhone Touch ID",
"new_device_type": "platform"
}
```
**Response:**
```json
{
"status": "device_added",
"credential_id": "..."
}
```
**Response:** WebAuthn assertion challenge for the credential(s) bound to that vault.
---
## Vault Database Schema (Simplified)
## Security properties
```sql
-- Credentials: P0 → wrapped_L3 mappings
CREATE TABLE credentials (
credential_id BLOB PRIMARY KEY, -- WebAuthn credential ID
p0 TEXT NOT NULL, -- First 4 bytes of PRF (lookup)
wrapped_l3 BLOB NOT NULL, -- L3 encrypted with full PRF
device_name TEXT,
device_type TEXT,
created_at INTEGER,
last_used_at INTEGER
);
CREATE INDEX idx_credentials_p0 ON credentials(p0);
| What server sees | What server can do with it |
|-----------------------------------|----------------------------|
| P1 (8 bytes) | Look up the WL3 file. Nothing else. |
| WL3 file contents | Route the login. Cannot decrypt L3. |
| WebAuthn public key | Verify assertions. Cannot mint them. |
-- Everything else unchanged (agents, entries, etc.)
```
| Attack | Outcome |
|-----------------------------------|---------|
| Server breach (WL3 corpus stolen) | All blobs encrypted with each user's PRF output. Useless without hardware keys. |
| Client XSS | Can steal in-memory L3 during an active session. Requires the user to be unlocked. |
| Network MITM | TLS protects in transit. P1 is not secret; observing P1 doesn't help. |
| Lost device | Attacker needs the physical authenticator + user verification (PIN/biometric). |
| P1 brute force | 2⁶⁴ space. Filesystem lookup is O(1) per attempt; the rate limiter and IP lockout cap attempts. Birthday-safe to ~4B credentials. |
---
## Client-Side Flow (Web)
## Community vs Commercial
### Registration
```javascript
// 1. Generate L3
const l3 = crypto.getRandomValues(new Uint8Array(32));
**This doc describes the community flow.** A community vault stores its WL3
files locally and never talks to anything else. Backup `data/` and `WL3/` and
you can restore the whole vault to a fresh box.
// 2. Create credential with PRF
const cred = await navigator.credentials.create({
publicKey: {
// ... standard WebAuthn options
extensions: {
prf: { eval: { first: encode("Clavitor") } }
}
}
});
// 3. Extract PRF
const prf = cred.getClientExtensionResults().prf.results.first;
const p0 = prf.slice(0, 4);
// 4. Wrap L3
const wrappedL3 = await encrypt(l3, prf);
// 5. Send to server
await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
credential_id: b64encode(cred.rawId),
p0: b64encode(p0),
wrapped_l3: b64encode(wrappedL3),
device_name: 'YubiKey 5'
})
});
// 6. Derive L0-L2 from L3
const l0 = l3.slice(0, 4);
const l1 = l3.slice(0, 8);
const l2 = l3.slice(0, 16);
// 7. Store L3 in sessionStorage (cleared on browser close)
sessionStorage.setItem('vault_l3', b64encode(l3));
```
### Login
```javascript
// 1. Get PRF
const assertion = await navigator.credentials.get({
publicKey: {
extensions: {
prf: { eval: { first: encode("Clavitor") } }
}
}
});
const prf = assertion.getClientExtensionResults().prf.results.first;
const p0 = prf.slice(0, 4);
// 2. Get wrapped L3 from server
const resp = await fetch('/api/auth/unlock', {
method: 'POST',
body: JSON.stringify({ p0: b64encode(p0) })
});
const { wrapped_l3 } = await resp.json();
// 3. Unwrap L3
const l3 = await decrypt(b64decode(wrapped_l3), prf);
// 4. Store in sessionStorage
sessionStorage.setItem('vault_l3', b64encode(l3));
// 5. Ready to use vault
```
---
## Security Properties
**What server sees:**
- P0 (4 bytes) - lookup key, not secret
- wrapped_L3 - encrypted blob, useless without PRF
- PRF never leaves client hardware
**What client has after login:**
- L3 in sessionStorage (32 bytes)
- Can derive L0, L1, L2 on demand
- L3 wiped when browser closes
**Attack scenarios:**
- Server breach: Only P0 + wrapped_L3, can't decrypt without hardware keys
- Client XSS: Can steal L3 from sessionStorage, but needs active session
- Network MITM: Sees P0 + wrapped_L3 transit, can't decrypt
- Lost device: Attacker needs physical hardware key + PIN
---
## What's Next
1. **Implement /api/auth/register** - Create credential, store wrapped_L3
2. **Implement /api/auth/unlock** - Lookup by P0, return wrapped_L3
3. **Update web UI** - Call new endpoints, handle PRF
4. **Test end-to-end** - Register → Logout → Login → Access entries
**After this works:**
- Add 2nd device support
- Add billing integration (admin decides if vault allowed)
- Scale to POPs if needed
---
## Files to Modify
| File | Changes |
|------|---------|
| `api/handlers.go` | New auth endpoints (register, unlock) |
| `cmd/clavitor/web/auth.js` | PRF extraction, API calls |
| `cmd/clavitor/web/index.html` | Updated login flow |
| `lib/dbcore.go` | Initialize credentials table |
**Commercial editions** layer the same WL3 file format with a sync mechanism
that mirrors the corpus to every POP via a central distribution hub. Same
on-disk schema, same lookup path — only the sync layer differs. See the
commercial edition docs for the enrollment token, central seat ledger, and
sync cadence.

View File

@ -0,0 +1,9 @@
{
"p1": "cc85d8cd49328dae",
"l0": "4573c027",
"wrapped_l3": "r_A7Mc9cVOCNyfYXlArjt9Xamh9YLCT-DC8BdrH6R3HlP6HEYI-OaeLqxmZ5v-Evl0wZsdDl8Fo7g4-u",
"credential_id": "AJJ3rlWU9vT8zNp-ELxolwktd72vcf1dvUcUMQRsTkFZl02JKGJ-O7BeWN2prB76",
"public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAJJ3rlWU9vT8zNp-EMBWpbhjlj9E-caGzhiCVnWW4FQIOt136UsBJPXRQC2QkoqbyGKppJMEmBh4aihChEsi-A",
"home_pop": "",
"created_at": 1775552762
}

View File

@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agents — Clavitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="clavitor-app.css">
<script src="/app/crypto.js"></script>
<script src="/app/webauthn.js"></script>
</head>
<body>
<div id="topbar"></div>
<script src="/app/topbar.js"></script>
<div class="app-column" style="padding:1.5rem 1rem">
<!-- Create agent -->
<div class="card mb-8">
<h2 class="mb-4">Create Agent</h2>
<form id="createForm">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem" class="mb-4">
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="agentName">Agent Name</label>
<input type="text" id="agentName" required placeholder="e.g. claude-forge" class="form-input">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="agentIPs">IP Whitelist (comma-separated)</label>
<input type="text" id="agentIPs" value="init" placeholder="1.2.3.4, 10.0.0.0/24, home.smith.family" class="form-input">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;align-items:end" class="mb-4">
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="rateMin">Max requests / minute</label>
<input type="number" id="rateMin" value="5" min="1" max="60" class="form-input">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="rateHour">Max requests / hour</label>
<input type="number" id="rateHour" value="10" min="1" max="1000" class="form-input">
</div>
<button type="submit" class="btn btn-primary">Create Agent</button>
</div>
</form>
<!-- New agent token display (shown once after creation) -->
<div id="newAgentDisplay" class="hidden mt-6">
<div style="border:1px solid rgba(148,163,184,0.15);border-radius:var(--radius);padding:1rem 1.25rem">
<div class="label mb-2">Agent token (copy now — shown only once)</div>
<div style="display:flex;align-items:center;gap:0.75rem;background:rgba(0,0,0,0.3);border-radius:6px;padding:0.75rem">
<code id="newAgentToken" class="font-mono select-all" style="flex:1;word-break:break-all;color:var(--gold);font-size:0.8rem"></code>
<button onclick="copyAgentToken()" class="btn btn-ghost" style="padding:0.375rem 0.75rem;font-size:0.75rem;flex-shrink:0">Copy</button>
</div>
<div class="label mb-2 mt-4">Usage</div>
<div style="background:#0d1117;border-radius:6px;padding:0.75rem 1rem;position:relative;font-size:0.8rem;line-height:1.7">
<button onclick="copyUsage()" class="btn btn-ghost" style="position:absolute;top:0.5rem;right:0.5rem;padding:0.2rem 0.5rem;font-size:0.65rem">Copy</button>
<code id="usageCode" style="white-space:pre;color:#c9d1d9;font-family:'JetBrains Mono',monospace"></code>
</div>
</div>
</div>
</div>
<!-- Agent list -->
<div class="card">
<h2 class="mb-4">Active Agents</h2>
<p style="color:var(--muted);font-size:0.8rem;margin-bottom:1rem">
Tokens are shown only once at creation and are never stored on the server.
If a token is lost, revoke the agent and create a new one.
</p>
<div id="agentList">
<p class="text-muted text-center" style="padding:2rem 0">Loading...</p>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<script>
// Auth: getL1Bearer() and api() are defined in topbar.js (loaded above).
var lastToken = '';
function toast(msg, type) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + (type === 'error' ? 'error' : 'success');
var dur = (type === 'error') ? 8000 : 3000;
setTimeout(function() { t.className = 'toast hidden'; }, dur);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function copyAgentToken() {
navigator.clipboard.writeText(lastToken);
toast('Token copied!');
}
function copyUsage() {
var code = document.getElementById('usageCode').textContent;
navigator.clipboard.writeText(code);
toast('Usage copied!');
}
function formatTime(ms) {
if (!ms || ms === 0) return 'Never';
return new Date(ms).toLocaleString();
}
function statusBadge(status) {
var cls = { active: 'accent', locked: 'red', revoked: 'muted' };
return '<span class="badge ' + (cls[status] || 'muted') + '">' + status + '</span>';
}
async function loadAgents() {
var agents = await api('GET', '/api/agents');
var container = document.getElementById('agentList');
if (!agents || agents.length === 0) {
container.innerHTML = '<p class="text-muted text-center" style="padding:2rem 0">No agents yet</p>';
return;
}
var html = '<div class="audit-scroll"><table class="audit-table">' +
'<thead><tr>' +
'<th>Name</th><th>Status</th><th>Last Used</th><th>Last IP</th><th>Rate</th><th>IPs</th><th></th>' +
'</tr></thead><tbody>';
agents.forEach(function(a) {
var ips = (a.ip_whitelist || []).join(', ');
if (ips.length > 30) ips = ips.substring(0, 27) + '...';
html += '<tr>' +
'<td style="color:var(--text);font-weight:500">' + escapeHtml(a.name) + '</td>' +
'<td>' + statusBadge(a.status) + '</td>' +
'<td>' + formatTime(a.last_used) + '</td>' +
'<td><span class="font-mono text-subtle" style="font-size:0.75rem">' + escapeHtml(a.last_ip) + '</span></td>' +
'<td><span class="text-subtle" style="font-size:0.75rem">' + a.rate_limit_minute + '/m ' + a.rate_limit_hour + '/h</span></td>' +
'<td><span class="text-subtle" style="font-size:0.75rem">' + escapeHtml(ips) + '</span></td>' +
'<td style="display:flex;gap:0.375rem;justify-content:flex-end">';
if (a.status === 'locked') {
html += '<button onclick="unlockAgent(\'' + a.id + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Unlock</button>';
} else if (a.status === 'active') {
html += '<button onclick="lockAgent(\'' + a.id + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Lock</button>';
}
html += '<button onclick="revokeAgent(\'' + a.id + '\')" class="btn btn-red" style="padding:0.25rem 0.625rem;font-size:0.75rem">Revoke</button>' +
'</td></tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
async function lockAgent(id) {
await api('POST', '/api/agents/' + id + '/lock');
toast('Agent locked');
loadAgents();
}
async function unlockAgent(id) {
await api('POST', '/api/agents/' + id + '/unlock');
toast('Agent unlocked');
loadAgents();
}
async function revokeAgent(id) {
if (!confirm('Permanently revoke this agent?')) return;
await api('DELETE', '/api/agents/' + id);
toast('Agent revoked');
loadAgents();
}
// Unlock vault (called from lock banner in topbar.js)
window.unlockVault = async function() {
try {
await api('POST', '/api/vault-unlock');
toast('Vault unlocked');
var banner = document.getElementById('vaultLockBanner');
if (banner) banner.remove();
loadAgents();
} catch(e) {
toast('Unlock failed: ' + e.message, 'error');
}
};
document.getElementById('createForm').addEventListener('submit', async function(e) {
e.preventDefault();
var name = document.getElementById('agentName').value.trim();
var ipsStr = document.getElementById('agentIPs').value.trim();
var ips = ipsStr ? ipsStr.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : ['init'];
var data = {
name: name,
ip_whitelist: ips,
rate_limit_minute: parseInt(document.getElementById('rateMin').value) || 5,
rate_limit_hour: parseInt(document.getElementById('rateHour').value) || 10
};
var result = await api('POST', '/api/agents', data);
if (result && result.error) {
toast(result.error, 'error');
return;
}
// L2 key from active PRF session — needed to generate the token
var l2Key = ClavitorWebAuthn.getL2Key();
if (!l2Key) {
try {
await ClavitorWebAuthn.unlock();
l2Key = ClavitorWebAuthn.getL2Key();
} catch(e) {
toast('Unlock required to generate agent token', 'error');
return;
}
}
// Build token: encrypt(host + \0 + name + \0 + l2_key)
var host = location.hostname;
var enc = new TextEncoder();
var hostBytes = enc.encode(host);
var nameBytes = enc.encode(name);
var payload = new Uint8Array(hostBytes.length + 1 + nameBytes.length + 1 + 16);
payload.set(hostBytes, 0);
payload[hostBytes.length] = 0;
payload.set(nameBytes, hostBytes.length + 1);
payload[hostBytes.length + 1 + nameBytes.length] = 0;
payload.set(l2Key, hostBytes.length + 1 + nameBytes.length + 1);
// Encrypt with key derived from 'clavitor-l2-'
var seed = enc.encode('clavitor-l2-');
var encKey = await clavitor.crypto.hkdf_sha256(seed, null, enc.encode('token'), 16);
var encrypted = await clavitor.crypto.aes_gcm_encrypt(encKey, payload);
lastToken = clavitor.crypto.uint8_to_base64(encrypted)
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
document.getElementById('newAgentToken').textContent = lastToken;
document.getElementById('usageCode').textContent =
'# 1. Install clavitor-cli (< 1MB, no dependencies)\n' +
'$ curl -fsSL https://clavitor.com/install.sh | sh\n' +
'\n' +
'# 2. Set your token\n' +
'$ export CLAVITOR_TOKEN=' + lastToken + '\n' +
'\n' +
'# 3. Usage\n' +
'$ clavitor-cli --token $CLAVITOR_TOKEN get twitter\n' +
'$ clavitor-cli --token $CLAVITOR_TOKEN totp github\n' +
'$ clavitor-cli --token $CLAVITOR_TOKEN list';
document.getElementById('newAgentDisplay').classList.remove('hidden');
document.getElementById('createForm').reset();
document.getElementById('agentIPs').value = 'init';
toast('Agent created');
loadAgents();
});
// Stateless: check sessionStorage for master key
if (!getL1Bearer()) {
window.location.href = '/app/';
} else {
loadAgents();
}
</script>
</body>
</html>

View File

@ -1,404 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clavitor Design System — v0.1</title>
<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=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============================================
CLAVITOR DESIGN SYSTEM — v0.1
One file. No dependencies. Copy what you need.
============================================ */
/* --- CSS Variables (Tokens) --- */
:root {
/* Brand */
--brand-black: #0A0A0A;
--brand-accent: #B45309;
--brand-accent-light: #D97706;
--brand-accent-dark: #92400E;
/* Core Colors — Light Mode */
--bg-primary: #FFFFFF;
--bg-secondary: #F5F5F5;
--bg-tertiary: #E5E5E5;
--bg-inverse: #0A0A0A;
--text-primary: #171717;
--text-secondary: #525252;
--text-tertiary: #737373;
--text-inverse: #FFFFFF;
--border-default: #E5E5E5;
--border-strong: #D4D4D4;
/* Semantic */
--success: #16A34A;
--warning: #CA8A04;
--error: #DC2626;
/* Typography */
--font-family: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
/* Spacing (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Motion */
--duration-fast: 100ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- Reset --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-family: var(--font-family);
font-size: 16px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
}
/* --- Layout --- */
.container { max-width: 1200px; margin: 0 auto; padding: 48px 24px; }
.section { margin-bottom: 64px; }
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
.flex-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.flex-col { display: flex; flex-direction: column; gap: 12px; }
/* --- Typography --- */
h1 { font-size: 48px; font-weight: 700; letter-spacing: -0.022em; line-height: 1; }
h2 { font-size: 36px; font-weight: 600; letter-spacing: -0.022em; line-height: 1.1; }
h3 { font-size: 24px; font-weight: 600; letter-spacing: -0.019em; }
h4 { font-size: 20px; font-weight: 500; }
.text-sm { font-size: 14px; }
.text-xs { font-size: 12px; }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
/* --- Brand Block --- */
.brand-block { display: flex; align-items: center; gap: 16px; margin: 32px 0; }
.black-square { width: 64px; height: 64px; background: var(--brand-black); }
/* --- Colors --- */
.color-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 16px; }
.color-swatch { border-radius: 8px; overflow: hidden; border: 1px solid var(--border-default); }
.color-block { height: 80px; }
.color-label { padding: 12px; font-size: 12px; background: var(--bg-primary); }
.color-label code { font-family: ui-monospace, monospace; background: var(--bg-secondary); padding: 2px 4px; border-radius: 4px; }
/* --- Buttons --- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font-family: inherit; font-size: 16px; font-weight: 500;
text-decoration: none; border: none; border-radius: 8px;
cursor: pointer; transition: all 100ms ease;
height: 40px; padding: 0 16px;
}
.btn:focus { outline: none; box-shadow: 0 0 0 2px white, 0 0 0 4px var(--brand-accent); }
.btn-primary { background: var(--brand-black); color: white; }
.btn-primary:hover { background: #262626; }
.btn-secondary { background: transparent; color: var(--text-primary); border: 1px solid var(--border-strong); }
.btn-secondary:hover { background: var(--bg-secondary); }
.btn-accent { background: var(--brand-accent); color: white; }
.btn-accent:hover { background: var(--brand-accent-light); }
.btn-ghost { background: transparent; color: var(--text-primary); }
.btn-ghost:hover { background: var(--bg-secondary); }
.btn-sm { height: 32px; padding: 0 12px; font-size: 14px; }
.btn-lg { height: 48px; padding: 0 20px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* --- Inputs --- */
.input-group { display: flex; flex-direction: column; gap: 8px; }
.input-label { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.input {
height: 40px; padding: 0 12px;
font-family: inherit; font-size: 16px;
color: var(--text-primary); background: var(--bg-primary);
border: 1px solid var(--border-strong); border-radius: 8px;
transition: all 100ms ease;
}
.input:hover { border-color: var(--text-tertiary); }
.input:focus { outline: none; border-color: var(--brand-accent); box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1); }
.input::placeholder { color: var(--text-tertiary); }
.input-error { border-color: var(--error); }
.input-hint { font-size: 12px; color: var(--text-tertiary); }
.input-error-text { font-size: 12px; color: var(--error); }
/* --- Cards --- */
.card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; }
.card-flat { background: var(--bg-secondary); border: 1px solid var(--border-default); }
.card-header { margin-bottom: 16px; }
.card-title { font-size: 18px; font-weight: 600; }
/* --- Badges --- */
.badge { display: inline-flex; align-items: center; height: 24px; padding: 0 12px; font-size: 12px; font-weight: 500; border-radius: 8px; }
.badge-default { background: var(--bg-tertiary); color: var(--text-secondary); }
.badge-primary { background: var(--brand-black); color: white; }
.badge-accent { background: var(--brand-accent); color: white; }
.badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success); }
.badge-warning { background: rgba(202, 138, 4, 0.1); color: var(--warning); }
.badge-error { background: rgba(220, 38, 38, 0.1); color: var(--error); }
/* --- Alerts --- */
.alert { display: flex; gap: 12px; padding: 16px; border-radius: 12px; border-left: 3px solid; }
.alert-accent { background: rgba(180, 83, 9, 0.05); border-left-color: var(--brand-accent); }
.alert-success { background: rgba(22, 163, 74, 0.05); border-left-color: var(--success); }
.alert-error { background: rgba(220, 38, 38, 0.05); border-left-color: var(--error); }
/* --- Tables --- */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th { text-align: left; padding: 12px 16px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-tertiary); background: var(--bg-secondary); border-bottom: 1px solid var(--border-default); }
.table td { padding: 12px 16px; border-bottom: 1px solid var(--border-default); }
.table tr:hover td { background: var(--bg-secondary); }
/* --- Code --- */
.code-block { background: var(--bg-inverse); color: var(--text-inverse); font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; padding: 16px; border-radius: 12px; overflow-x: auto; }
.code-block .prompt { color: var(--brand-accent); }
code { font-family: ui-monospace, monospace; font-size: 0.9em; background: var(--bg-secondary); padding: 2px 6px; border-radius: 4px; color: var(--brand-accent); }
/* --- Spacing --- */
.spacing-demo { display: flex; flex-direction: column; gap: 16px; }
.spacing-row { display: flex; align-items: center; gap: 16px; }
.spacing-box { background: var(--brand-accent); height: 24px; }
/* --- Section Title --- */
.section-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-tertiary); margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid var(--border-default); }
/* --- Checkbox --- */
.checkbox { display: flex; align-items: center; gap: 12px; cursor: pointer; }
.checkbox-input { width: 20px; height: 20px; border: 2px solid var(--border-strong); border-radius: 4px; appearance: none; cursor: pointer; transition: all 100ms ease; }
.checkbox-input:checked { background: var(--brand-accent); border-color: var(--brand-accent); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='white'%3E%3Cpath fill-rule='evenodd' d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
/* --- Toggle --- */
.toggle { width: 44px; height: 24px; background: var(--bg-tertiary); border-radius: 9999px; position: relative; cursor: pointer; transition: background 200ms ease; }
.toggle::after { content: ''; position: absolute; width: 20px; height: 20px; background: white; border-radius: 9999px; top: 2px; left: 2px; transition: transform 200ms ease; }
.toggle.on { background: var(--brand-accent); }
.toggle.on::after { transform: translateX(20px); }
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="section">
<div class="brand-block">
<div class="black-square"></div>
<div>
<h1>Clavitor</h1>
<p class="text-secondary">Design System v0.1</p>
</div>
</div>
<p class="text-secondary" style="max-width: 600px; margin-top: 24px; font-size: 18px;">
A black box vault for AI infrastructure. One file. No dependencies.
Copy the CSS variables and classes you need.
</p>
</div>
<!-- Brand Colors -->
<div class="section">
<div class="section-title">Brand Colors</div>
<div class="color-grid">
<div class="color-swatch"><div class="color-block" style="background: var(--brand-black);"></div><div class="color-label"><strong>Black Square</strong><br><code>--brand-black</code><br>#0A0A0A</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--brand-accent);"></div><div class="color-label"><strong>Accent</strong><br><code>--brand-accent</code><br>#B45309</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--brand-accent-light);"></div><div class="color-label"><strong>Accent Light</strong><br><code>--brand-accent-light</code><br>#D97706</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--brand-accent-dark);"></div><div class="color-label"><strong>Accent Dark</strong><br><code>--brand-accent-dark</code><br>#92400E</div></div>
</div>
</div>
<!-- Core Colors -->
<div class="section">
<div class="section-title">Core Colors</div>
<div class="color-grid">
<div class="color-swatch"><div class="color-block" style="background: var(--bg-primary); border: 1px solid var(--border-default);"></div><div class="color-label"><code>--bg-primary</code><br>#FFFFFF</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--bg-secondary);"></div><div class="color-label"><code>--bg-secondary</code><br>#F5F5F5</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--text-primary);"></div><div class="color-label"><code>--text-primary</code><br>#171717</div></div>
<div class="color-swatch"><div class="color-block" style="background: var(--text-secondary);"></div><div class="color-label"><code>--text-secondary</code><br>#525252</div></div>
</div>
</div>
<!-- Typography -->
<div class="section">
<div class="section-title">Typography — Plus Jakarta Sans</div>
<div class="flex-col">
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">48px</span><span style="font-size:48px;font-weight:700;">Heading 48</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">36px</span><span style="font-size:36px;font-weight:600;">Heading 36</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">24px</span><span style="font-size:24px;font-weight:600;">Heading 24</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">18px</span><span style="font-size:18px;">Body large 18</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">16px</span><span style="font-size:16px;">Body 16 — The quick brown fox jumps</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">14px</span><span style="font-size:14px;">Small 14 — The quick brown fox jumps</span></div>
<div><span class="text-tertiary text-xs" style="display:inline-block;width:80px;">12px</span><span style="font-size:12px;">Tiny 12 — The quick brown fox</span></div>
</div>
</div>
<!-- Spacing -->
<div class="section">
<div class="section-title">Spacing Scale (4px base)</div>
<div class="spacing-demo">
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">4px</span><div class="spacing-box" style="width:4px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">8px</span><div class="spacing-box" style="width:8px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">16px</span><div class="spacing-box" style="width:16px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">24px</span><div class="spacing-box" style="width:24px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">32px</span><div class="spacing-box" style="width:32px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">48px</span><div class="spacing-box" style="width:48px;"></div></div>
<div class="spacing-row"><span class="text-xs text-tertiary" style="width:60px;">64px</span><div class="spacing-box" style="width:64px;"></div></div>
</div>
</div>
<!-- Buttons -->
<div class="section">
<div class="section-title">Buttons</div>
<h4 style="margin-bottom:16px;">Variants</h4>
<div class="flex-row" style="margin-bottom:24px;">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-accent">Accent</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-primary" disabled>Disabled</button>
</div>
<h4 style="margin-bottom:16px;">Sizes</h4>
<div class="flex-row">
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary">Medium</button>
<button class="btn btn-primary btn-lg">Large</button>
</div>
</div>
<!-- Inputs -->
<div class="section">
<div class="section-title">Inputs</div>
<div class="grid-2">
<div class="input-group">
<label class="input-label">Default</label>
<input type="text" class="input" placeholder="Enter text...">
</div>
<div class="input-group">
<label class="input-label">With Hint</label>
<input type="text" class="input" value="user@example.com">
<span class="input-hint">We'll never share your email.</span>
</div>
<div class="input-group">
<label class="input-label">Error State</label>
<input type="text" class="input input-error" value="invalid@email">
<span class="input-error-text">Please enter a valid email address.</span>
</div>
<div class="input-group">
<label class="input-label">Disabled</label>
<input type="text" class="input" value="Cannot edit" disabled>
</div>
</div>
</div>
<!-- Cards -->
<div class="section">
<div class="section-title">Cards</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<div class="card-title">Credential Entry</div>
<p class="text-sm text-secondary">Last modified 2 hours ago</p>
</div>
<p class="text-secondary">Password for production database. Private fields encrypted with your biometric.</p>
<div class="flex-row" style="margin-top:16px;">
<span class="badge badge-accent">Private</span>
<span class="badge badge-default">Database</span>
</div>
</div>
<div class="card card-flat">
<div class="card-header">
<div class="card-title">API Key</div>
<p class="text-sm text-secondary">Never expires</p>
</div>
<p class="text-secondary">Production API key for CI/CD pipelines. Rotate every 90 days.</p>
<div class="flex-row" style="margin-top:16px;">
<span class="badge badge-primary">Active</span>
<span class="badge badge-default">API</span>
</div>
</div>
</div>
</div>
<!-- Badges -->
<div class="section">
<div class="section-title">Badges</div>
<div class="flex-row">
<span class="badge badge-default">Default</span>
<span class="badge badge-primary">Primary</span>
<span class="badge badge-accent">Accent</span>
<span class="badge badge-success">Success</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-error">Error</span>
</div>
</div>
<!-- Alerts -->
<div class="section">
<div class="section-title">Alerts</div>
<div class="flex-col">
<div class="alert alert-accent">
<strong>Info:</strong> Your vault is end-to-end encrypted. We cannot access your data.
</div>
<div class="alert alert-success">
<strong>Success:</strong> Credential rotated successfully. API key updated.
</div>
<div class="alert alert-error">
<strong>Error:</strong> Failed to connect to vault. Check your network connection.
</div>
</div>
</div>
<!-- Tables -->
<div class="section">
<div class="section-title">Tables</div>
<table class="table">
<thead>
<tr><th>Name</th><th>Type</th><th>Status</th><th>Last Used</th></tr>
</thead>
<tbody>
<tr><td>Production DB</td><td>Password</td><td><span class="badge badge-success">Active</span></td><td>2 min ago</td></tr>
<tr><td>AWS API Key</td><td>API Key</td><td><span class="badge badge-warning">Expiring</span></td><td>1 hour ago</td></tr>
<tr><td>GitHub SSH</td><td>SSH Key</td><td><span class="badge badge-success">Active</span></td><td>3 days ago</td></tr>
</tbody>
</table>
</div>
<!-- Code Block -->
<div class="section">
<div class="section-title">Code / Terminal</div>
<div class="code-block">
<span class="prompt">$</span> clavitor search github<br>
<span class="output">Found 3 credentials:</span><br>
<span class="output"> • github-personal (SSH key)</span><br>
<span class="output"> • github-work (Token)</span><br>
<span class="output"> • github-actions (API key)</span>
</div>
<p style="margin-top:16px;">Inline code looks like <code>--brand-accent</code> or <code>get_credential()</code></p>
</div>
<!-- Form Elements -->
<div class="section">
<div class="section-title">Form Elements</div>
<div class="flex-row">
<label class="checkbox"><input type="checkbox" class="checkbox-input" checked> Enable 2FA</label>
<label class="checkbox"><input type="checkbox" class="checkbox-input"> Share with team</label>
<div class="toggle on"></div>
<div class="toggle"></div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#0A1628"/>
<text y=".9em" font-size="72" x="14" fill="#22C55E" font-family="monospace" font-weight="bold">v</text>
<text y=".9em" font-size="72" x="44" fill="#22C55E" font-family="monospace" font-weight="bold" opacity="0.7">84</text>
</svg>

Before

Width:  |  Height:  |  Size: 355 B

View File

@ -1,327 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clavitor — Password manager for humans with AI assistants</title>
<meta name="description" content="Password manager built for humans with AI assistants. Two-tier encryption keeps agents useful and secrets safe.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/clavitor.css">
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<a href="/" class="nav-logo">clav<span class="n">itor</span></a>
<div class="nav-links">
<a href="https://github.com/johanjongsma/clavitor" target="_blank" rel="noopener" class="nav-link">GitHub</a>
<a href="/hosted" class="nav-link">Hosted</a>
<a href="/pricing" class="nav-link">Pricing</a>
<a href="/install" class="nav-link">Self-host</a>
<a href="/app/" class="nav-link">Open Vault</a>
</div>
</div>
</nav>
<div class="container hero-split">
<div>
<p class="label accent mb-6">Password manager for the AI era</p>
<h1 class="mb-6">Passwords for AI agents.</h1>
<p class="lead mb-6">Clavitor is a password manager built for humans who work with AI assistants. Your agent gets the credentials it needs via MCP, API, or CLI. You get everything &mdash; including the secrets agents should never see, locked behind your fingerprint. Same vault, different access.</p>
<div class="btn-row">
<a href="/install" class="btn btn-primary">Get started</a>
<a href="#how" class="btn btn-ghost">How it works &rarr;</a>
</div>
</div>
<div>
<div class="code-block mb-4">
<p class="code-label">Terminal</p>
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
<div><span class="prompt">$</span> clavitor</div>
<div class="comment"># Running on http://localhost:1984</div>
</div>
<div class="code-block">
<p class="code-label">MCP config &mdash; Claude Code / Cursor / Codex</p>
<pre>{
"mcpServers": {
"clavitor": {
"url": "http://localhost:1984/mcp",
"headers": {
"Authorization": "Bearer <span class="prompt">your_token</span>"
}
}
}
}</pre>
</div>
</div>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">This is the vault. Not a plugin.</h2>
<p class="lead mb-8">1Password and Bitwarden weren't built for AI. Bolting on a connector gives your agent access to everything &mdash; or nothing. That's not security, that's a checkbox. Clavitor was designed from day one so your agent and you share the same vault with different access.</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">Connectors are all-or-nothing</h3>
<p>A 1Password plugin gives your AI the same access you have. Your agent needs your GitHub token &mdash; 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">"AI-safe" isn't safe</h3>
<p>Other vaults decrypt everything on the server and then filter what the AI sees. If the server can read it, it's not private. We can't read your private fields. Mathematically.</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 more than read access</h3>
<p>Your AI needs to log in, pass 2FA, and rotate keys. <span class="vaultname">clav<span class="n">itor</span></span> lets it do all three &mdash; without exposing your credit card.</p>
</div>
</div>
</div>
<hr class="divider">
<div id="how" class="section container">
<p class="label mb-4">How it works</p>
<h2 class="mb-6">Your AI sees the API key.<br><span class="gradient-text">Not your credit card.</span></h2>
<p class="lead mb-8">Every field in your vault is encrypted. But some fields get a second lock &mdash; one derived from your fingerprint. That key only exists on your device. We don't have it. Your AI doesn't have it. Nobody does except you.</p>
<div class="grid-2">
<div class="card alt">
<span class="badge accent mb-4">Your agent can read these</span>
<h3 class="mb-3">Shared with AI</h3>
<p class="mb-4">Encrypted at rest. Your AI agent reads them via MCP, API, or CLI.</p>
<ul class="checklist">
<li>Passwords &amp; logins</li>
<li>API keys &amp; tokens</li>
<li>SSH keys</li>
<li>2FA codes &mdash; your AI generates them for you</li>
<li>Notes &amp; credentials</li>
</ul>
</div>
<div class="card red">
<span class="badge red mb-4">Only you can read these</span>
<h3 class="mb-3">Locked to your fingerprint</h3>
<p class="mb-4">Encrypted on your device with your biometric. The server stores ciphertext it cannot decrypt. Ever.</p>
<ul class="checklist red">
<li>Credit card numbers</li>
<li>CVV</li>
<li>Passport &amp; SSN</li>
<li>Private signing keys</li>
<li>Anything you mark as private</li>
</ul>
</div>
</div>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">What your agent sees vs. what it doesn't</h2>
<p class="lead mb-8">This is a real API response. The card number and CVV are encrypted with your fingerprint. The server doesn't have the key. Neither does your agent.</p>
<div class="code-block" style="max-width:640px">
<p class="code-label">GET /api/search?q=visa</p>
<pre>{
"title": "My Visa",
"type": "card",
"fields": [
{ "label": "Cardholder", "value": "Johan Jongsma" },
{ "label": "Number", "value": "<span style="color:var(--red)">[REDACTED — not available to agents]</span>" },
{ "label": "CVV", "value": "<span style="color:var(--red)">[REDACTED — not available to agents]</span>" },
{ "label": "Expiry", "value": "2029-02" }
]
}</pre>
</div>
</div>
<hr class="divider">
<div class="section container">
<div class="grid-2">
<div>
<h2 class="mb-4">10 agents.<br><span class="gradient-text">Each sees only what it needs.</span></h2>
<p class="lead mb-6">Give each agent its own token scoped to specific entries. Your coding agent sees GitHub. Your DevOps agent sees AWS. Neither sees your bank.</p>
<div class="code-block">
<p class="code-label">~/.claude/mcp.json</p>
<pre>{
"mcpServers": {
"vault-dev": {
"url": "http://localhost:1984/mcp",
"headers": { "Authorization": "Bearer <span class="prompt">token_dev_...</span>" }
},
"vault-devops": {
"url": "http://localhost:1984/mcp",
"headers": { "Authorization": "Bearer <span class="prompt">token_devops_...</span>" }
}
}
}</pre>
</div>
</div>
<div>
<svg viewBox="0 0 400 360" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="160" y="140" width="80" height="80" rx="12" fill="#111f38" stroke="#94a3b8" stroke-width="1.5"/>
<text x="200" y="175" font-family="JetBrains Mono, monospace" font-size="10" fill="#94a3b8" text-anchor="middle">vault</text>
<text x="200" y="195" font-family="JetBrains Mono, monospace" font-size="14" fill="white" text-anchor="middle" font-weight="600">1984</text>
<circle cx="80" cy="60" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
<text x="80" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Coding</text>
<text x="80" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">agent</text>
<line x1="108" y1="80" x2="165" y2="145" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
<circle cx="320" cy="60" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
<text x="320" y="56" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Social</text>
<text x="320" y="68" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">agent</text>
<line x1="292" y1="80" x2="235" y2="145" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
<circle cx="50" cy="220" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
<text x="50" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Finance</text>
<text x="50" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">agent</text>
<line x1="78" y1="204" x2="164" y2="190" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
<circle cx="350" cy="220" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
<text x="350" y="216" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">DevOps</text>
<text x="350" y="228" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">agent</text>
<line x1="322" y1="204" x2="236" y2="190" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
<circle cx="200" cy="330" r="32" fill="#22C55E" fill-opacity="0.08" stroke="#22C55E" stroke-width="1"/>
<text x="200" y="326" font-family="JetBrains Mono, monospace" font-size="9" fill="#22C55E" text-anchor="middle">Deploy</text>
<text x="200" y="338" font-family="JetBrains Mono, monospace" font-size="8" fill="#94a3b8" text-anchor="middle">agent</text>
<line x1="200" y1="298" x2="200" y2="220" stroke="#22C55E" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4 3"/>
<rect x="10" y="98" width="140" height="20" rx="4" fill="#0A1628"/>
<text x="80" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">github ssh gitlab</text>
<rect x="250" y="98" width="140" height="20" rx="4" fill="#0A1628"/>
<text x="320" y="112" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">twitter slack discord</text>
<rect x="0" y="256" width="100" height="20" rx="4" fill="#0A1628"/>
<text x="50" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">stripe plaid</text>
<rect x="300" y="256" width="100" height="20" rx="4" fill="#0A1628"/>
<text x="350" y="270" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">aws k8s docker</text>
<rect x="150" y="296" width="100" height="16" rx="4" fill="#0A1628"/>
<text x="200" y="308" font-family="JetBrains Mono, monospace" font-size="7.5" fill="#94a3b8" text-anchor="middle">vercel netlify</text>
</svg>
</div>
</div>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">Four ways in</h2>
<p class="lead mb-8">Same vault. Same credentials. Different interfaces for different contexts.</p>
<div class="grid-2">
<div class="card card-hover">
<p class="label accent mb-3">MCP</p>
<h3 class="mb-2">For AI agents</h3>
<p>Claude, Cursor, Codex, or any MCP-compatible agent. Search credentials, fetch keys, generate 2FA codes &mdash; scoped to what you allow.</p>
</div>
<div class="card card-hover">
<p class="label accent mb-3">Browser extension</p>
<h3 class="mb-2">For you</h3>
<p>Autofill passwords. Generate 2FA codes inline. Unlock private fields with Touch ID. No content scripts, no page slowdown.</p>
</div>
<div class="card card-hover">
<p class="label accent mb-3">CLI</p>
<h3 class="mb-2">For scripts</h3>
<p>Pipe credentials into CI pipelines and shell scripts. <code>clavitor get github.token</code> &mdash; done.</p>
</div>
<div class="card card-hover">
<p class="label accent mb-3">REST API</p>
<h3 class="mb-2">For everything else</h3>
<p>Scoped tokens over HTTPS. Give your deployment pipeline read access to staging keys. Nothing else.</p>
</div>
</div>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">What you get</h2>
<p class="lead mb-8">One binary. One file. No dependencies.</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">Per-field privacy</h3>
<p>Each field in an entry has its own visibility. 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">Fingerprint encryption</h3>
<p>Private fields are encrypted with a key derived from your Touch ID or YubiKey. The server stores ciphertext it cannot decrypt.</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 does your 2FA</h3>
<p>Store TOTP secrets in your vault. Your AI generates time-based codes on demand via MCP. No more switching to your phone.</p>
</div>
<div class="card card-hover">
<div class="feature-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg></div>
<h3 class="mb-3">Scoped tokens</h3>
<p>One token per agent. Each sees only what you allow. 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">Single binary</h3>
<p>Go + SQLite. No Docker, no Postgres, no Redis. 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">Import anything</h3>
<p>Chrome, Firefox, Bitwarden, 1Password, Proton Pass. Smart dedup. Private fields auto-detected.</p>
</div>
</div>
</div>
<hr class="divider">
<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"># Install</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="btn-row">
<a href="/install" class="btn btn-primary">Full install guide &rarr;</a>
<a href="/hosted" class="btn btn-ghost">Or let us host it &mdash; $12/yr</a>
</div>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div class="footer-links">
<a href="/" class="vaultname">clav<span class="n">itor</span></a>
<a href="https://github.com/johanjongsma/clavitor" target="_blank" rel="noopener">GitHub</a>
<a href="#">Discord</a>
<a href="#">X</a>
</div>
<div class="footer-links">
<a href="/privacy">Privacy</a>
<a href="/terms">Terms</a>
<span>MIT License</span>
</div>
</div>
<p class="footer-copy">Built for humans with AI assistants.</p>
</div>
</footer>
</body>
</html>

View File

@ -1,250 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security — Clavitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="clavitor-app.css">
<script src="/app/webauthn.js"></script>
</head>
<body>
<div id="topbar"></div>
<script src="/app/topbar.js"></script>
<div class="app-column" style="padding:1.5rem 1rem">
<!-- L2 Lock Status -->
<div class="card mb-6">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<h2>L2 Encryption</h2>
<p style="font-size:0.8125rem;margin-top:0.25rem">WebAuthn PRF-derived key for client-side encryption of sensitive fields.</p>
</div>
<span id="l2Status" class="badge accent">Locked</span>
</div>
<div class="btn-row mt-4">
<button id="unlockBtn" onclick="doUnlockL2()" class="btn btn-accent">Unlock L2</button>
<button id="lockBtn" onclick="doLockL2()" class="btn btn-ghost hidden">Lock L2</button>
</div>
</div>
<!-- Register Passkey -->
<div class="card mb-6">
<h2 class="mb-2">Register Passkey</h2>
<p style="font-size:0.8125rem;margin-bottom:1rem">Register a security key or biometric for L2 field encryption via WebAuthn PRF.</p>
<div id="prfWarning" class="alert alert-warning hidden mb-4">
Your browser may not support the PRF extension. L2 encryption requires a compatible authenticator.
</div>
<div id="regError" class="alert alert-error hidden mb-4"></div>
<div class="device-list">
<button onclick="doRegister('platform', 'Touch ID')" class="device-option">
<span class="device-option-icon">&#x1F4BB;</span>
<div><strong>Touch ID / Windows Hello</strong><span>Fingerprint or face on this computer</span></div>
</button>
<button onclick="doRegister('cross-platform', 'Security Key')" class="device-option">
<span class="device-option-icon">&#x1F511;</span>
<div><strong>Security key</strong><span>YubiKey, Titan Key, or any FIDO2 USB/NFC key</span></div>
</button>
</div>
</div>
<!-- Registered Authenticators -->
<div class="card">
<h2 class="mb-4">Registered Authenticators</h2>
<div id="credList">
<p class="text-subtle text-center" style="padding:1.5rem 0">Loading...</p>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<script>
// Auth: getL1Bearer() and api() are defined in topbar.js (loaded above).
function toast(msg, type) {
type = type || 'success';
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type;
setTimeout(function() { t.classList.add('hidden'); }, 3000);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function updateL2Status() {
var status = document.getElementById('l2Status');
var unlockBtn = document.getElementById('unlockBtn');
var lockBtn = document.getElementById('lockBtn');
if (ClavitorWebAuthn.isL2Unlocked()) {
status.className = 'badge accent';
status.textContent = 'Unlocked';
unlockBtn.classList.add('hidden');
lockBtn.classList.remove('hidden');
} else {
status.className = 'badge muted';
status.textContent = 'Locked';
unlockBtn.classList.remove('hidden');
lockBtn.classList.add('hidden');
}
}
async function doRegister(attachment, defaultName) {
var errDiv = document.getElementById('regError');
errDiv.classList.add('hidden');
try {
var bearer = getL1Bearer();
var headers = { 'Content-Type': 'application/json' };
if (bearer) headers['Authorization'] = 'Bearer ' + bearer;
var res = await fetch('/api/webauthn/register/begin', {
method: 'POST',
headers: headers,
body: '{}'
});
var options = await res.json();
var challenge = new Uint8Array(options.publicKey.challenge);
var userId = new Uint8Array(options.publicKey.user.id);
var prfSalt = new Uint8Array(32);
crypto.getRandomValues(prfSalt);
var authSelection = {
residentKey: 'preferred',
userVerification: 'required'
};
var hints;
if (attachment === 'platform') {
authSelection.authenticatorAttachment = 'platform';
hints = ['client-device'];
} else {
authSelection.authenticatorAttachment = 'cross-platform';
hints = ['security-key'];
}
var credential = await navigator.credentials.create({
publicKey: {
challenge: challenge,
rp: options.publicKey.rp,
user: { id: userId, name: options.publicKey.user.name, displayName: options.publicKey.user.displayName },
pubKeyCredParams: options.publicKey.pubKeyCredParams,
authenticatorSelection: authSelection,
attestation: 'none',
hints: hints,
extensions: { prf: {} }
}
});
var extResults = credential.getClientExtensionResults();
var prfEnabled = extResults.prf && extResults.prf.enabled;
var regRes = await fetch('/api/webauthn/register/complete', {
method: 'POST',
headers: headers,
body: JSON.stringify({
cred_id: ClavitorWebAuthn.b64urlEncode(credential.rawId),
public_key: Array.from(new Uint8Array(
credential.response.getPublicKey ? credential.response.getPublicKey() : new ArrayBuffer(0)
)),
prf_salt: Array.from(prfSalt),
name: defaultName
})
});
if (prfEnabled === false) {
toast('Registered, but PRF not supported by this authenticator', 'error');
} else {
toast('Passkey registered!');
}
loadCredentials();
} catch (e) {
errDiv.textContent = e.message;
errDiv.classList.remove('hidden');
}
}
async function doUnlockL2() {
try {
await ClavitorWebAuthn.unlockL2();
updateL2Status();
toast('L2 unlocked!');
} catch (e) {
toast('Unlock failed: ' + e.message, 'error');
}
}
function doLockL2() {
ClavitorWebAuthn.lockL2();
updateL2Status();
toast('L2 locked');
}
async function loadCredentials() {
try {
var bearer = getL1Bearer();
var headers = {};
if (bearer) headers['Authorization'] = 'Bearer ' + bearer;
var res = await fetch('/api/webauthn/credentials', { headers: headers });
var creds = await res.json();
var container = document.getElementById('credList');
if (!creds || creds.length === 0) {
container.innerHTML = '<p class="text-subtle text-center" style="padding:1.5rem 0">No authenticators registered</p>';
return;
}
var html = '<div style="display:flex;flex-direction:column;gap:0.5rem">';
creds.forEach(function(c) {
html += '<div class="field-box" style="display:flex;align-items:center;justify-content:space-between">' +
'<div>' +
'<div style="font-weight:600">' + escapeHtml(c.name) + '</div>' +
'<div class="text-subtle" style="font-size:0.75rem;margin-top:0.25rem">Registered: ' + new Date(c.created_at * 1000).toLocaleDateString() +
' &middot; Sign count: ' + c.sign_count + '</div>' +
'</div>' +
'<button onclick="deleteCred(\'' + encodeURIComponent(c.cred_id) + '\')" class="btn btn-red btn-sm">Remove</button>' +
'</div>';
});
html += '</div>';
container.innerHTML = html;
} catch (e) {
document.getElementById('credList').innerHTML = '<p class="text-red">Failed to load</p>';
}
}
async function deleteCred(credId) {
if (!confirm('Remove this authenticator?')) return;
var bearer = getL1Bearer();
var headers = {};
if (bearer) headers['Authorization'] = 'Bearer ' + bearer;
await fetch('/api/webauthn/credentials/' + credId, {
method: 'DELETE',
headers: headers
});
toast('Authenticator removed');
loadCredentials();
}
if (!ClavitorWebAuthn.isPRFSupported()) {
document.getElementById('prfWarning').classList.remove('hidden');
}
// Stateless: check sessionStorage for master key
if (!getL1Bearer()) {
window.location.href = '/app/';
} else {
updateL2Status();
loadCredentials();
}
</script>
</body>
</html>

View File

@ -1,242 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Tokens — Clavitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="clavitor-app.css">
</head>
<body>
<div id="topbar"></div>
<script src="/app/topbar.js"></script>
<div class="app-column" style="padding:1.5rem 1rem">
<!-- Create form -->
<div class="card mb-8">
<h2 class="mb-4">Create Token</h2>
<form id="createForm">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem" class="mb-4">
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="tokenLabel">Label</label>
<input type="text" id="tokenLabel" required placeholder="e.g. Claude Desktop" class="form-input">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="tokenExpires">Expires in (days, 0 = never)</label>
<input type="number" id="tokenExpires" value="0" min="0" class="form-input">
</div>
</div>
<div class="form-group mb-4" style="margin-bottom:0">
<label class="form-label" for="tokenEntryIDs">Limit to entry IDs (comma-separated, leave empty for all)</label>
<input type="text" id="tokenEntryIDs" placeholder="e.g. 3a1f..., 9c2b..." class="form-input">
</div>
<div style="display:flex;align-items:center;gap:1rem">
<label style="display:inline-flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.8125rem;color:var(--muted)">
<input type="checkbox" id="tokenReadOnly">
<span>Read-only</span>
</label>
<button type="submit" class="btn btn-primary">Create Token</button>
</div>
</form>
<!-- New token display (shown once after creation) -->
<div id="newTokenDisplay" class="hidden mt-6">
<div class="card alt" style="padding:1rem 1.25rem">
<div class="label mb-2">New token (copy now — shown only once)</div>
<div style="display:flex;align-items:center;gap:0.75rem">
<code id="newTokenValue" class="font-mono select-all" style="flex:1;word-break:break-all;color:var(--accent)"></code>
<button onclick="copyToken()" class="btn btn-ghost" style="padding:0.375rem 0.75rem;font-size:0.75rem;flex-shrink:0">Copy</button>
</div>
</div>
<!-- Config snippets -->
<div id="configSnippets" class="mt-4">
<div class="config-tabs">
<button class="config-tab active" onclick="showConfigTab('mcp')">MCP</button>
<button class="config-tab" onclick="showConfigTab('api')">API</button>
<button class="config-tab" onclick="showConfigTab('cli')">CLI</button>
</div>
<div class="config-block" id="configBlock">
<button class="config-copy" onclick="copyConfig()">Copy</button>
<code id="configCode"></code>
</div>
</div>
</div>
</div>
<!-- Token list -->
<div class="card">
<h2 class="mb-4">Active Tokens</h2>
<div id="tokenList">
<p class="text-muted text-center" style="padding:2rem 0">Loading...</p>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<script>
// Auth: getL1Bearer() and api() are defined in topbar.js (loaded above).
var vaultId = '';
var lastCreatedToken = '';
var activeConfigTab = 'mcp';
function toast(msg, type) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + (type === 'error' ? 'error' : 'success');
setTimeout(function() { t.className = 'toast hidden'; }, 3000);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function copyToken() {
var val = document.getElementById('newTokenValue').textContent;
navigator.clipboard.writeText(val);
toast('Token copied!');
}
function copyExistingToken(t) {
navigator.clipboard.writeText(t);
toast('Token copied!');
}
function copyConfig() {
var code = document.getElementById('configCode').textContent;
navigator.clipboard.writeText(code);
toast('Config copied!');
}
function buildConfigSnippet(tab) {
var origin = window.location.origin;
var t = lastCreatedToken;
if (tab === 'mcp') {
return JSON.stringify({
mcpServers: {
clavitor: {
url: origin + '/' + vaultId + '/mcp',
headers: {
Authorization: 'Bearer ' + t
}
}
}
}, null, 2);
} else if (tab === 'api') {
return 'curl ' + origin + '/' + vaultId + '/api/search?q=github \\\n -H "Authorization: Bearer ' + t + '"';
} else {
return 'clavitor get github.com \\\n --server ' + origin + ' --vault ' + vaultId + ' --token ' + t;
}
}
function showConfigTab(tab) {
activeConfigTab = tab;
var tabs = document.querySelectorAll('.config-tab');
tabs.forEach(function(el) { el.classList.remove('active'); });
var labels = { mcp: 'MCP', api: 'API', cli: 'CLI' };
tabs.forEach(function(el) {
if (el.textContent === labels[tab]) el.classList.add('active');
});
document.getElementById('configCode').textContent = buildConfigSnippet(tab);
}
async function fetchVaultId() {
try {
var info = await api('GET', '/api/vault-info');
if (info && info.vault_id) vaultId = info.vault_id;
} catch (e) {
// vault_id will remain empty
}
}
async function loadTokens() {
var tokens = await api('GET', '/api/mcp-tokens');
var container = document.getElementById('tokenList');
if (!tokens || tokens.length === 0) {
container.innerHTML = '<p class="text-muted text-center" style="padding:2rem 0">No tokens yet</p>';
return;
}
var html = '<div class="audit-scroll"><table class="audit-table">' +
'<thead><tr>' +
'<th>Label</th><th>Scopes</th><th>Read-only</th><th>Expires</th><th>Last Used</th><th></th>' +
'</tr></thead><tbody>';
tokens.forEach(function(t) {
var scopeHtml = '';
if (t.entry_ids && t.entry_ids.length) {
scopeHtml = '<span class="badge blue">' + t.entry_ids.length + ' entries</span>';
} else {
scopeHtml = '<span class="badge accent">all</span>';
}
var expires = t.expires_at > 0 ? new Date(t.expires_at * 1000).toLocaleDateString() : 'Never';
var lastUsed = t.last_used > 0 ? new Date(t.last_used * 1000).toLocaleString() : 'Never';
var isExpired = t.expires_at > 0 && t.expires_at < Date.now() / 1000;
html += '<tr>' +
'<td style="color:var(--text);font-weight:500">' + escapeHtml(t.label) + '</td>' +
'<td>' + scopeHtml + '</td>' +
'<td>' + (t.read_only ? '<span class="badge gold">yes</span>' : '<span class="text-subtle">no</span>') + '</td>' +
'<td>' + (isExpired ? '<span class="text-red">' + expires + '</span>' : expires) + '</td>' +
'<td>' + lastUsed + '</td>' +
'<td class="text-right" style="display:flex;gap:0.375rem;justify-content:flex-end">' +
'<button onclick="copyExistingToken(\'' + escapeHtml(t.token) + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Copy</button>' +
'<button onclick="revokeToken(\'' + t.id + '\')" class="btn btn-red" style="padding:0.25rem 0.625rem;font-size:0.75rem">Revoke</button>' +
'</td>' +
'</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
async function revokeToken(id) {
if (!confirm('Revoke this token?')) return;
await api('DELETE', '/api/mcp-tokens/' + id);
toast('Token revoked');
loadTokens();
}
document.getElementById('createForm').addEventListener('submit', async function(e) {
e.preventDefault();
var idsStr = document.getElementById('tokenEntryIDs').value.trim();
var entryIDs = idsStr ? idsStr.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
var data = {
label: document.getElementById('tokenLabel').value,
entry_ids: entryIDs,
read_only: document.getElementById('tokenReadOnly').checked,
expires_in_days: parseInt(document.getElementById('tokenExpires').value) || 0
};
var result = await api('POST', '/api/mcp-tokens', data);
if (result && result.token) {
lastCreatedToken = result.token;
document.getElementById('newTokenValue').textContent = result.token;
document.getElementById('newTokenDisplay').classList.remove('hidden');
showConfigTab('mcp');
document.getElementById('createForm').reset();
toast('Token created!');
loadTokens();
} else {
toast('Failed to create token', 'error');
}
});
// Stateless: check sessionStorage for master key
if (!getL1Bearer()) {
window.location.href = '/app/';
} else {
fetchVaultId();
loadTokens();
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@ -122,6 +122,37 @@ func (c *tc) reqNoAuth(method, path string, body any) *http.Response {
return resp
}
// reqAgent sends a request with CVT wire token authentication (agent).
// The wireToken is a type 0x00 CVT token containing L1 + agent_id.
func (c *tc) reqAgent(method, path string, body any, wireToken string) *http.Response {
c.t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, c.srv.URL+path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+wireToken)
resp, err := c.srv.Client().Do(req)
if err != nil {
c.t.Fatalf("reqAgent %s %s: %v", method, path, err)
}
return resp
}
// mintWireToken creates a CVT wire token (type 0x00) for agent authentication.
func (c *tc) mintWireToken(agentID []byte) string {
c.t.Helper()
token, err := lib.MintWireToken(fakeL0(), fakeL1(), agentID)
if err != nil {
c.t.Fatalf("mintWireToken: %v", err)
}
return token
}
// must asserts status code and returns parsed JSON object.
func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
c.t.Helper()
@ -323,29 +354,195 @@ func TestAuditLog(t *testing.T) {
// ---------------------------------------------------------------------------
func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) {
// AGENT CREDENTIALS NOW GENERATED CLIENT-SIDE
// Tests requiring agent tokens need client-side credential generation
t.Skip("Agent credentials now client-side - test needs rewrite")
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create entries with different scopes
c.must(c.req("POST", "/api/entries", credentialEntry("Work Entry", "user", "pass", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("Personal Entry", "user", "pass", nil)), 201)
// Create agent with specific scope (work only)
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "Work Agent",
"all_access": false,
"scope_whitelist": []string{"work"},
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent with work scope should see entries (scope filtering happens at data level)
// With empty scope_whitelist, agent sees nothing
// With ["work"], agent sees work entries
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
// Agent with work scope should see entries (default scope is "0000" which is owner-only)
// Since entries have no explicit scope, they default to "0000" (owner-only)
// So agent should see 0 entries
if len(list) != 0 {
t.Fatalf("agent with work scope should see 0 owner-scoped entries, got %d", len(list))
}
}
func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite")
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create an entry
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Test Entry", "user", "pass", nil)), 201)
entryID := entry["entry_id"].(string)
// Create agent with no scope whitelist (cannot access owner-only entries)
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "Limited Agent",
"all_access": false,
"scope_whitelist": []string{}, // Empty whitelist
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent should get 403 when trying to access owner-only entry
resp := c.reqAgent("GET", "/api/entries/"+entryID, nil, wireToken)
if resp.StatusCode != 403 {
t.Fatalf("agent without scope access should get 403, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_all_access_sees_everything(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite")
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create entries
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user", "pass", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user", "pass", nil)), 201)
// Create agent with all_access flag
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "All Access Agent",
"all_access": true,
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent with all_access should see all entries
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
if len(list) != 2 {
t.Fatalf("all_access agent should see 2 entries, got %d", len(list))
}
}
func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite")
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create agent
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "Regular Agent",
"all_access": false,
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent should get 403 when trying to create another agent
resp := c.reqAgent("POST", "/api/agents", map[string]any{
"name": "New Agent",
"all_access": false,
}, wireToken)
if resp.StatusCode != 403 {
t.Fatalf("agent should not be able to create agents, expected 403 got %d", resp.StatusCode)
}
resp.Body.Close()
// Agent should get 403 when trying to list agents
resp = c.reqAgent("GET", "/api/agents", nil, wireToken)
if resp.StatusCode != 403 {
t.Fatalf("agent should not be able to list agents, expected 403 got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite")
func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) {
// Agents cannot create entries with system types (agent, scope)
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create agent
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "Regular Agent",
"all_access": false,
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent should get 403 when trying to create entry with type=agent
resp := c.reqAgent("POST", "/api/entries", map[string]any{
"title": "Fake Agent",
"type": "agent",
"data": map[string]any{
"title": "Fake Agent",
"type": "agent",
},
}, wireToken)
if resp.StatusCode != 403 {
t.Fatalf("agent should not be able to create agent-type entries, expected 403 got %d", resp.StatusCode)
}
resp.Body.Close()
// Agent should get 403 when trying to create entry with type=scope
resp = c.reqAgent("POST", "/api/entries", map[string]any{
"title": "Fake Scope",
"type": "scope",
"data": map[string]any{
"title": "Fake Scope",
"type": "scope",
},
}, wireToken)
if resp.StatusCode != 403 {
t.Fatalf("agent should not be able to create scope-type entries, expected 403 got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_agent_entries_invisible(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite")
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create entries
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user1", "pass1", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user2", "pass2", nil)), 201)
// Create agent with limited scope
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
"name": "Limited Agent",
"all_access": false,
"scope_whitelist": []string{"work"},
}), 201)
agentIDHex := agentResp["agent_id"].(string)
agentID, _ := hex.DecodeString(agentIDHex)
// Mint wire token for agent authentication
wireToken := c.mintWireToken(agentID)
// Agent with work scope should not see owner-only entries (default scope "0000")
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
// Entries default to "0000" scope (owner-only), so agent sees nothing
if len(list) != 0 {
t.Fatalf("agent with work scope should see 0 owner-only entries, got %d", len(list))
}
}
// ---------------------------------------------------------------------------
@ -424,9 +621,32 @@ func TestKeyLeak_L3_never_appears(t *testing.T) {
func TestKeyLeak_agent_credential_is_opaque(t *testing.T) {
// CREDENTIAL GENERATION IS NOW CLIENT-SIDE
// Server returns agent_id, web UI generates credential using crypto.js
// This test is obsolete - credential opacity is ensured by client-side implementation
t.Skip("Credential generation moved to client-side - server no longer returns credential")
// Server returns agent_id only, web UI generates credential using crypto.js
// This test verifies that the server response contains no key material
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create agent - server returns only agent_id, no credential
body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{
"name": "Leak Check Agent",
}), 201)
bodyStr := string(body)
// Verify response contains agent_id but no credential token
if !strings.Contains(bodyStr, "agent_id") {
t.Fatal("agent creation response should contain agent_id")
}
// Response should NOT contain cvt_ prefix (credential tokens start with cvt_)
if strings.Contains(bodyStr, "cvt_") {
t.Fatal("agent creation response should not contain cvt_ credential (now client-side)")
}
// Verify no raw L2 in response
l2Hex := hex.EncodeToString(fakeL2())
if strings.Contains(bodyStr, l2Hex) {
t.Fatal("L2 key (hex) found in agent creation response")
}
}
// ---------------------------------------------------------------------------

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -162,28 +162,30 @@
var html = '<div class="audit-scroll"><table class="audit-table">' +
'<thead><tr>' +
'<th>Name</th><th>Status</th><th>Last Used</th><th>Last IP</th><th>Rate</th><th>IPs</th><th></th>' +
'<th>Name</th><th>Status</th><th>Last Strike</th><th>Rate</th><th>IPs</th><th></th>' +
'</tr></thead><tbody>';
agents.forEach(function(a) {
var ips = (a.ip_whitelist || []).join(', ');
if (ips.length > 30) ips = ips.substring(0, 27) + '...';
// last_strike_at is unix seconds; formatTime takes ms
var strikeMs = a.last_strike_at ? a.last_strike_at * 1000 : 0;
html += '<tr>' +
'<td style="color:var(--text);font-weight:500">' + escapeHtml(a.name) + '</td>' +
'<td>' + statusBadge(a.status) + '</td>' +
'<td>' + formatTime(a.last_used) + '</td>' +
'<td><span class="font-mono text-subtle" style="font-size:0.75rem">' + escapeHtml(a.last_ip) + '</span></td>' +
'<td><span class="text-subtle" style="font-size:0.75rem">' + (strikeMs ? formatTime(strikeMs) : '—') + '</span></td>' +
'<td><span class="text-subtle" style="font-size:0.75rem">' + a.rate_limit_minute + '/m ' + a.rate_limit_hour + '/h</span></td>' +
'<td><span class="text-subtle" style="font-size:0.75rem">' + escapeHtml(ips) + '</span></td>' +
'<td style="display:flex;gap:0.375rem;justify-content:flex-end">' +
'<button onclick="openEditModal(\'' + a.id + '\', \'' + escapeHtml(a.name) + '\', \'' + escapeHtml((a.ip_whitelist || []).join(\', \')) + '\', ' + (a.rate_limit_minute || 5) + ', ' + (a.rate_limit_hour || 10) + ')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Edit</button> ';
'<button onclick="openEditModal(\'' + a.id + '\', \'' + escapeHtml(a.name) + '\', \'' + escapeHtml((a.ip_whitelist || []).join(\', \')) + '\', ' + (a.rate_limit_minute || 3) + ', ' + (a.rate_limit_hour || 10) + ')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Edit</button> ';
if (a.status === 'locked') {
html += '<button onclick="unlockAgent(\'' + a.id + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Unlock</button>';
} else if (a.status === 'active') {
html += '<button onclick="lockAgent(\'' + a.id + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem">Lock</button>';
html += '<button onclick="unlockAgent(\'' + a.id + '\')" class="btn btn-ghost" style="padding:0.25rem 0.625rem;font-size:0.75rem;color:var(--accent)">Unlock</button>';
}
// No manual Lock button — locking happens automatically via the
// two-strike rate-limit policy. Owner can revoke instead.
html += '<button onclick="revokeAgent(\'' + a.id + '\')" class="btn btn-red" style="padding:0.25rem 0.625rem;font-size:0.75rem">Revoke</button>' +
'</td></tr>';
@ -193,12 +195,6 @@
container.innerHTML = html;
}
async function lockAgent(id) {
await api('POST', '/api/agents/' + id + '/lock');
toast('Agent locked');
loadAgents();
}
async function unlockAgent(id) {
await api('POST', '/api/agents/' + id + '/unlock');
toast('Agent unlocked');

View File

@ -0,0 +1,281 @@
// webauthn.js — WebAuthn PRF for Clavitor key derivation
//
// Derives the master secret from hardware authenticator (WebAuthn PRF).
// Truncation model:
// L1 / vault_id = bytes[0..8] (8 bytes)
// L2 = bytes[0..16] (16 bytes, AES-128-GCM)
// L3 = bytes[0..32] (32 bytes, AES-256-GCM)
//
// Field encryption/decryption is handled by crypto.js (shared with CLI).
(function(window) {
'use strict';
var SESSION_KEY = 'clavitor_master';
var HKDF_SALT = new TextEncoder().encode('clavitor-master-v2');
// Derive master key from raw PRF output and store in sessionStorage.
// Returns true if stored, false if prfOutput is null/missing.
async function storeMasterKey(prfOutput) {
if (!prfOutput || prfOutput.byteLength === 0) return false;
var raw = new Uint8Array(prfOutput);
var keyMaterial = await crypto.subtle.importKey(
'raw', raw, { name: 'HKDF' }, false, ['deriveBits']
);
var masterBits = await crypto.subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: new Uint8Array(0) },
keyMaterial, 256
);
sessionStorage.setItem(SESSION_KEY, arrayBufferToBase64(masterBits));
return true;
}
// Check browser support for WebAuthn + PRF
function isPRFSupported() {
return !!(window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function');
}
// Base64URL encode/decode helpers
function b64urlEncode(buf) {
var bytes = new Uint8Array(buf);
var str = '';
for (var i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
var binary = atob(str);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var str = '';
for (var i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str);
}
function base64ToUint8(b64) {
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// Register a passkey with PRF extension
async function registerPasskey(name) {
var headers = { 'Content-Type': 'application/json' };
var bearer = (typeof getAuthBearer === 'function') ? getAuthBearer() : null;
if (bearer) headers['Authorization'] = 'Bearer ' + bearer;
var res = await fetch('/api/webauthn/register/begin', {
method: 'POST',
headers: headers,
body: '{}'
});
var options = await res.json();
var challenge = new Uint8Array(options.publicKey.challenge);
var userId = new Uint8Array(options.publicKey.user.id);
var prfSalt = new Uint8Array(32);
crypto.getRandomValues(prfSalt);
var createOptions = {
publicKey: {
challenge: challenge,
rp: options.publicKey.rp,
user: {
id: userId,
name: options.publicKey.user.name,
displayName: options.publicKey.user.displayName
},
pubKeyCredParams: options.publicKey.pubKeyCredParams,
authenticatorSelection: options.publicKey.authenticatorSelection,
extensions: {
prf: {
eval: { first: prfSalt }
}
}
}
};
var credential = await navigator.credentials.create(createOptions);
var extResults = credential.getClientExtensionResults();
var prfEnabled = extResults.prf && extResults.prf.enabled;
// Get PRF output (32 bytes) to send as master_key (optional, for vault creation)
var prfResults = extResults.prf && extResults.prf.results;
var masterKey = prfResults && prfResults.first;
var regRes = await fetch('/api/webauthn/register/complete', {
method: 'POST',
headers: headers,
body: JSON.stringify({
cred_id: b64urlEncode(credential.rawId),
public_key: Array.from(new Uint8Array(credential.response.getPublicKey ? credential.response.getPublicKey() : new ArrayBuffer(0))),
prf_salt: Array.from(prfSalt),
name: name || 'Security Key',
master_key: masterKey ? Array.from(new Uint8Array(masterKey)) : null
})
});
var result = await regRes.json();
result.prf_supported = prfEnabled;
// Note: Master key is stored by caller (index.html) after this returns
return result;
}
// Unlock vault using WebAuthn PRF → derive 32-byte master secret
async function unlock() {
var headers = { 'Content-Type': 'application/json' };
var bearer = (typeof getAuthBearer === 'function') ? getAuthBearer() : null;
if (bearer) headers['Authorization'] = 'Bearer ' + bearer;
var res = await fetch('/api/webauthn/auth/begin', {
method: 'POST',
headers: headers,
body: '{}'
});
var options = await res.json();
if (!options.publicKey || !options.publicKey.allowCredentials || options.publicKey.allowCredentials.length === 0) {
throw new Error('No registered passkeys found');
}
var challenge = new Uint8Array(options.publicKey.challenge);
var allowCreds = options.publicKey.allowCredentials.map(function(c) {
return { type: c.type, id: b64urlDecode(c.id) };
});
var prfExt = {};
if (options.publicKey.extensions && options.publicKey.extensions.prf && options.publicKey.extensions.prf.eval) {
var saltArr = options.publicKey.extensions.prf.eval.first;
prfExt = { prf: { eval: { first: new Uint8Array(saltArr) } } };
}
var assertion = await navigator.credentials.get({
publicKey: {
challenge: challenge,
allowCredentials: allowCreds,
userVerification: 'required',
extensions: prfExt
}
});
var extResults = assertion.getClientExtensionResults();
if (!extResults.prf || !extResults.prf.results || !extResults.prf.results.first) {
throw new Error('PRF extension not supported or no result returned');
}
await storeMasterKey(extResults.prf.results.first);
// Notify server
await fetch('/api/webauthn/auth/complete', {
method: 'POST',
headers: headers,
body: JSON.stringify({
cred_id: b64urlEncode(assertion.rawId),
sign_count: 0
})
});
return true;
}
// Get L2 key (first 16 bytes of master) as Uint8Array
function getL2Key() {
var masterB64 = sessionStorage.getItem(SESSION_KEY);
if (!masterB64) return null;
var master = base64ToUint8(masterB64);
return master.slice(0, 16);
}
// Get L3 key (full 32 bytes of master) as Uint8Array
function getL3Key() {
var masterB64 = sessionStorage.getItem(SESSION_KEY);
if (!masterB64) return null;
return base64ToUint8(masterB64);
}
// Get vault ID (first 8 bytes of master, base64-encoded)
function getVaultId() {
var masterB64 = sessionStorage.getItem(SESSION_KEY);
if (!masterB64) return null;
var master = base64ToUint8(masterB64);
return arrayBufferToBase64(master.slice(0, 8).buffer);
}
// Encrypt a field value (uses shared crypto.js)
// Tier determined by key length: L2 = 16 bytes, L3 = 32 bytes
async function encryptField(fieldLabel, plaintext, tier) {
var key = (tier === 3) ? getL3Key() : getL2Key();
if (!key) throw new Error('Vault not unlocked');
return clavitor.crypto.encrypt_field(key, fieldLabel, plaintext);
}
// Decrypt a field value (uses shared crypto.js)
async function decryptField(fieldLabel, ciphertextB64, tier) {
var key = (tier === 3) ? getL3Key() : getL2Key();
if (!key) throw new Error('Vault not unlocked');
return clavitor.crypto.decrypt_field(key, fieldLabel, ciphertextB64);
}
// Backward compat
async function encryptL2Field(entryId, fieldLabel, pt) { return encryptField(fieldLabel, pt, 2); }
async function decryptL2Field(entryId, fieldLabel, ct) { return decryptField(fieldLabel, ct, 2); }
async function encryptL3Field(entryId, fieldLabel, pt) { return encryptField(fieldLabel, pt, 3); }
async function decryptL3Field(entryId, fieldLabel, ct) { return decryptField(fieldLabel, ct, 3); }
// Check if vault is unlocked (any tier)
function isUnlocked() {
return !!sessionStorage.getItem(SESSION_KEY);
}
// Lock vault
function lock() {
sessionStorage.removeItem(SESSION_KEY);
}
// Backward compatibility aliases
var unlockL2 = unlock;
var isL2Unlocked = isUnlocked;
var lockL2 = lock;
// Export public API
window.ClavitorWebAuthn = {
isPRFSupported: isPRFSupported,
storeMasterKey: storeMasterKey,
registerPasskey: registerPasskey,
unlock: unlock,
unlockL2: unlockL2,
getL2Key: getL2Key,
getL3Key: getL3Key,
getVaultId: getVaultId,
encryptField: encryptField,
decryptField: decryptField,
encryptL2Field: encryptL2Field,
decryptL2Field: decryptL2Field,
encryptL3Field: encryptL3Field,
decryptL3Field: decryptL3Field,
isUnlocked: isUnlocked,
isL2Unlocked: isL2Unlocked,
lock: lock,
lockL2: lockL2,
b64urlEncode: b64urlEncode
};
})(window);

View File

@ -1,5 +1,7 @@
# Clavitor Edition System
> **Required reading before any work**: [CLAVITOR-AGENT-HANDBOOK.md](../../../CLAVITOR-AGENT-HANDBOOK.md) — Section I (Culture), Section II (Security), Section III (Workflow), and Section V: clavis-vault (this subproject). You are **Sarah**. Commercial-only code MUST be behind build tags so community builds cannot accidentally activate it.
This directory implements build-time differentiation between **Community** (OSS) and **Commercial** (hosted) editions of Clavitor Vault.
## Architecture

View File

@ -21,7 +21,8 @@ import (
// (clavis-cli/src/cvt.c) and never decrypted by Go code — L2 is a hard veto
// for the server.
const (
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
CVTCredentialType byte = 0x01 // Client credential: L2(16) + agent_id(16) + POP(4)
)
const cvtPrefix = "cvt_"
@ -68,6 +69,36 @@ func ParseWireToken(token string) (l0, l1, agentID []byte, err error) {
return l0, payload[0:8], payload[8:24], nil
}
// MintCredential creates a type 0x01 client credential token (for testing).
// This simulates client-side credential generation that normally happens in browser/CLI.
// Payload: L2(16) + agent_id(16) + POP(4) = 36 bytes, encrypted with L0.
func MintCredential(l0, l2, agentID, pop []byte) (string, error) {
if len(l0) != 4 || len(l2) != 16 || len(agentID) != 16 || len(pop) != 4 {
return "", fmt.Errorf("bad lengths: l0=%d l2=%d agent_id=%d pop=%d", len(l0), len(l2), len(agentID), len(pop))
}
payload := make([]byte, 36)
copy(payload[0:16], l2)
copy(payload[16:32], agentID)
copy(payload[32:36], pop)
return cvtEncode(CVTCredentialType, l0, payload)
}
// ParseCredential decrypts a type 0x01 client credential token (for testing).
// Returns L0 (4 bytes), L2 (16 bytes), agent_id (16 bytes), and POP (4 bytes).
func ParseCredential(token string) (l0, l2, agentID, pop []byte, err error) {
typ, l0, payload, err := cvtDecode(token)
if err != nil {
return nil, nil, nil, nil, err
}
if typ != CVTCredentialType {
return nil, nil, nil, nil, ErrCVTBadType
}
if len(payload) != 36 {
return nil, nil, nil, nil, fmt.Errorf("credential payload: got %d bytes, want 36", len(payload))
}
return l0, payload[0:16], payload[16:32], payload[32:36], nil
}
// ---------------------------------------------------------------------------
// CVT envelope: type(1) + L0(4) + AES-GCM(derived(L0), payload)
// ---------------------------------------------------------------------------

View File

@ -59,3 +59,65 @@ func TestCVT_unique(t *testing.T) {
t.Fatal("two tokens with same input should differ (random nonce)")
}
}
// Test client credential (type 0x01) - used for agent testing
func TestMintCredential_roundtrip(t *testing.T) {
l0 := []byte{0x11, 0x22, 0x33, 0x44}
l2 := bytes.Repeat([]byte{0xAB}, 16) // 16 bytes test key
agentID := make([]byte, 16)
for i := range agentID {
agentID[i] = byte(0x40 + i)
}
pop := []byte{0x01, 0x02, 0x03, 0x04}
token, err := MintCredential(l0, l2, agentID, pop)
if err != nil {
t.Fatalf("MintCredential: %v", err)
}
gotL0, gotL2, gotAgentID, gotPOP, err := ParseCredential(token)
if err != nil {
t.Fatalf("ParseCredential: %v", err)
}
if !bytes.Equal(gotL0, l0) {
t.Fatalf("L0 mismatch: got %x, want %x", gotL0, l0)
}
if !bytes.Equal(gotL2, l2) {
t.Fatalf("L2 mismatch: got %x, want %x", gotL2, l2)
}
if !bytes.Equal(gotAgentID, agentID) {
t.Fatalf("agent_id mismatch: got %x, want %x", gotAgentID, agentID)
}
if !bytes.Equal(gotPOP, pop) {
t.Fatalf("POP mismatch: got %x, want %x", gotPOP, pop)
}
}
func TestMintCredential_unique(t *testing.T) {
l0 := []byte{0x11, 0x22, 0x33, 0x44}
l2 := bytes.Repeat([]byte{0xCD}, 16)
agentID := make([]byte, 16)
pop := []byte{0x00, 0x00, 0x00, 0x00}
t1, _ := MintCredential(l0, l2, agentID, pop)
t2, _ := MintCredential(l0, l2, agentID, pop)
if t1 == t2 {
t.Fatal("two credential tokens with same input should differ (random nonce)")
}
}
func TestMintCredential_tamper_detection(t *testing.T) {
l0 := []byte{0x11, 0x22, 0x33, 0x44}
l2 := bytes.Repeat([]byte{0xEF}, 16)
agentID := make([]byte, 16)
pop := []byte{0xFF, 0xFF, 0xFF, 0xFF}
token, _ := MintCredential(l0, l2, agentID, pop)
// Flip a character in the middle
tampered := token[:10] + "X" + token[11:]
_, _, _, _, err := ParseCredential(tampered)
if err == nil {
t.Fatal("expected error on tampered credential token")
}
}

View File

@ -0,0 +1,616 @@
package lib
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sync"
"time"
)
// ---------------------------------------------------------------------------
// Severity Levels
// ---------------------------------------------------------------------------
type Severity int
const (
SeverityDebug Severity = iota // Development/debug only
SeverityInfo // Informational, no action needed
SeverityWarning // Anomaly detected, monitoring required
SeverityError // User-impacting issue
SeverityCritical // System down / security breach
)
func (s Severity) String() string {
switch s {
case SeverityDebug:
return "debug"
case SeverityInfo:
return "info"
case SeverityWarning:
return "warning"
case SeverityError:
return "error"
case SeverityCritical:
return "critical"
default:
return "unknown"
}
}
// ---------------------------------------------------------------------------
// Error/Event Definition Registry
// ---------------------------------------------------------------------------
// EventDef defines a class of events in the registry.
// This is the single source of truth for all errors and warnings.
type EventDef struct {
Code string // Unique code: ERR-12345 or WRN-12345
Category string // auth, database, network, security, etc.
Severity Severity // How serious
Message string // User-facing message (for errors shown to users)
Description string // Internal description for operators
AutoResolve bool // True if system can auto-resolve (e.g., retry succeeded)
CreateTicket bool // True if this creates a trackable incident
}
// EventRegistry is the canonical list of all events.
// Errors = user-impacting and create tickets.
// Warnings = anomalies that may indicate future issues.
var EventRegistry = map[string]EventDef{
// WARNINGS (WRN-1xxxx) - Anomalies, not yet user-impacting
"WRN-10001": {
Code: "WRN-10001",
Category: "performance",
Severity: SeverityWarning,
Description: "Response time >500ms for credential lookup",
AutoResolve: true,
CreateTicket: false, // Log only, no ticket unless sustained
},
"WRN-10002": {
Code: "WRN-10002",
Category: "security",
Severity: SeverityWarning,
Description: "Agent rate limit at 80% of threshold",
AutoResolve: true,
CreateTicket: false,
},
"WRN-10003": {
Code: "WRN-10003",
Category: "storage",
Severity: SeverityWarning,
Description: "Disk usage >80% on vault storage",
AutoResolve: false,
CreateTicket: true, // Creates ticket for ops to expand storage
},
"WRN-10004": {
Code: "WRN-10004",
Category: "network",
Severity: SeverityWarning,
Description: "TLS handshake latency elevated",
AutoResolve: true,
CreateTicket: false,
},
// AUTH ERRORS (ERR-1xxxx)
"ERR-10001": {
Code: "ERR-10001",
Category: "auth",
Severity: SeverityError,
Message: "Authentication failed. Please check your credentials and try again.",
Description: "WebAuthn challenge verification failed",
AutoResolve: false,
CreateTicket: false, // User error, not system error
},
"ERR-10002": {
Code: "ERR-10002",
Category: "auth",
Severity: SeverityError,
Message: "Access denied. You don't have permission for this operation.",
Description: "Actor attempted operation outside their scope",
AutoResolve: false,
CreateTicket: false,
},
"ERR-10003": {
Code: "ERR-10003",
Category: "auth",
Severity: SeverityError,
Message: "Invalid agent token. The agent may need to be re-enrolled.",
Description: "CVT token validation failed",
AutoResolve: false,
CreateTicket: true,
},
"ERR-10004": {
Code: "ERR-10004",
Category: "security",
Severity: SeverityCritical,
Message: "Agent locked due to suspicious activity. Contact the vault owner.",
Description: "Agent triggered harvester defenses (two-strike lockdown)",
AutoResolve: false,
CreateTicket: true,
},
"ERR-10005": {
Code: "ERR-10005",
Category: "security",
Severity: SeverityCritical,
Message: "Request from unauthorized IP. Token may be compromised.",
Description: "Agent request from IP not in whitelist",
AutoResolve: false,
CreateTicket: true,
},
// INPUT ERRORS (ERR-2xxxx)
"ERR-20001": {
Code: "ERR-20001",
Category: "input",
Severity: SeverityError,
Message: "Invalid request. Please check your input and try again.",
Description: "JSON parse failed or required field missing",
AutoResolve: false,
CreateTicket: false,
},
"ERR-20002": {
Code: "ERR-20002",
Category: "input",
Severity: SeverityError,
Message: "Invalid ID format. The requested item may not exist.",
Description: "Entry/agent ID parsing failed",
AutoResolve: false,
CreateTicket: false,
},
// NOT FOUND ERRORS (ERR-3xxxx)
"ERR-30001": {
Code: "ERR-30001",
Category: "not_found",
Severity: SeverityError,
Message: "Vault not found. Please register or check your vault path.",
Description: "No vault exists at the requested path",
AutoResolve: false,
CreateTicket: false,
},
"ERR-30002": {
Code: "ERR-30002",
Category: "not_found",
Severity: SeverityError,
Message: "Entry not found. It may have been deleted.",
Description: "Requested entry ID does not exist",
AutoResolve: false,
CreateTicket: false,
},
// SYSTEM ERRORS (ERR-5xxxx) - These POST to central
"ERR-50001": {
Code: "ERR-50001",
Category: "database",
Severity: SeverityCritical,
Message: "Service temporarily unavailable. Our team has been alerted and is working on it. Reference: ERR-50001.",
Description: "Vault database connection failed or file inaccessible",
AutoResolve: false,
CreateTicket: true,
},
"ERR-50002": {
Code: "ERR-50002",
Category: "database",
Severity: SeverityError,
Message: "Failed to save data. Please try again in a moment.",
Description: "Database write operation failed",
AutoResolve: true,
CreateTicket: true,
},
"ERR-50003": {
Code: "ERR-50003",
Category: "storage",
Severity: SeverityCritical,
Message: "Service temporarily unavailable. Our team has been alerted. Reference: ERR-50003.",
Description: "WL3 credential storage write failed",
AutoResolve: false,
CreateTicket: true,
},
"ERR-50004": {
Code: "ERR-50004",
Category: "network",
Severity: SeverityError,
Message: "Connection issue. Please try again.",
Description: "Failed to connect to central admin for sync",
AutoResolve: true,
CreateTicket: true,
},
// RATE LIMITING (ERR-6xxxx)
"ERR-60001": {
Code: "ERR-60001",
Category: "rate_limit",
Severity: SeverityError,
Message: "Too many requests. Please slow down and try again in a moment.",
Description: "Global per-IP rate limit exceeded",
AutoResolve: true,
CreateTicket: false,
},
"ERR-60002": {
Code: "ERR-60002",
Category: "rate_limit",
Severity: SeverityWarning,
Message: "Agent rate limit warning. Reduce request frequency.",
Description: "Per-agent unique-entry quota at 90%",
AutoResolve: true,
CreateTicket: false,
},
// INTERNAL ERRORS (ERR-9xxxx) - Invariant violations
"ERR-90001": {
Code: "ERR-90001",
Category: "invariant",
Severity: SeverityCritical,
Message: "An unexpected error occurred. Please try again or contact support. Reference: ERR-90001.",
Description: "Condition assumed impossible was triggered",
AutoResolve: false,
CreateTicket: true,
},
}
// ---------------------------------------------------------------------------
// Incident Tracking - Centralized Error Management
// ---------------------------------------------------------------------------
// CentralEvent is sent to clavitor.ai for every ticket-creating event.
// No deduplication - "let it rain". Central handles aggregation if needed.
type CentralEvent struct {
EventID string `json:"event_id"` // UUID generated locally
Code string `json:"code"` // ERR-50001, etc.
Category string `json:"category"` // database, network, etc.
Severity string `json:"severity"`
Resource string `json:"resource"` // uk1, db-primary, etc.
POP string `json:"pop"` // Which POP reported this
Operation string `json:"operation"` // GetEntry, agent unlock, etc.
ErrorDetail string `json:"error_detail"` // The actual error message
Actor string `json:"actor"` // web, agent:abc123
Timestamp int64 `json:"timestamp"`
UserMessage string `json:"user_message"` // Shown to users
}
// CentralClient posts events to central (clavitor.ai).
// Only created in commercial edition; community edition logs locally only.
type CentralClient struct {
centralURL string
popID string
apiKey string
}
var (
globalCentralClient *CentralClient
onceCentral sync.Once
)
// InitCentralClient creates the global central reporter.
// Called at startup from main.go.
func InitCentralClient(centralURL, popID, apiKey string) {
onceCentral.Do(func() {
globalCentralClient = &CentralClient{
centralURL: centralURL,
popID: popID,
apiKey: apiKey,
}
})
}
// postEvent sends an event to central asynchronously.
// Every event is sent individually - no local deduplication.
func (c *CentralClient) postEvent(ev CentralEvent) {
if c == nil {
return // Community edition - no central reporting
}
payload, _ := json.Marshal(ev)
req, err := http.NewRequest("POST", c.centralURL+"/v1/events", bytes.NewReader(payload))
if err != nil {
log.Printf("[ERROR] Failed to create event request: %v", err)
return
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[ERROR] Failed to post event to central: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
log.Printf("[ERROR] Central rejected event: %d", resp.StatusCode)
}
}
// ---------------------------------------------------------------------------
// Event Logging - The main interface for handlers
// ---------------------------------------------------------------------------
// EventContext holds all the searchable fields for an event.
type EventContext struct {
Code string // ERR-50001, WRN-10001, etc.
Category string // auth, database, network, security
Resource string // Which resource: uk1, db-primary, vault-file-123
Operation string // What was being attempted: "GetEntry", "agent unlock"
Actor string // web, agent:abc123, extension
ErrorDetail string // The actual error: "connection refused", "disk full"
IPAddr string // Client IP
Severity Severity
}
// LogEvent is the main entry point for all events (errors and warnings).
// This replaces direct AuditLog calls for error cases.
func LogEvent(db *DB, ctx context.Context, ec EventContext) {
def, ok := EventRegistry[ec.Code]
if !ok {
log.Printf("[ERROR] Unknown event code: %s", ec.Code)
def = EventRegistry["ERR-90001"]
ec.Code = "ERR-90001"
ec.Severity = SeverityCritical
}
// Log to local audit (searchable fields)
// Title contains structured data: resource, error, operation
// Searchable: grep "resource=uk1" audit.log
auditTitle := fmt.Sprintf("op=%s | resource=%s | error=%s | %s",
ec.Operation, ec.Resource, ec.ErrorDetail, def.Description)
AuditLog(db, &AuditEvent{
Action: ec.Code,
Actor: ec.Actor,
Title: auditTitle,
IPAddr: ec.IPAddr,
})
// Log to operator logs (human readable)
log.Printf("[%s] %s | resource=%s | op=%s | actor=%s | error=%s | severity=%s",
ec.Code,
def.Description,
ec.Resource,
ec.Operation,
ec.Actor,
ec.ErrorDetail,
def.Severity.String(),
)
// Post to central if ticket-creating event
// "Let it rain" - every event is sent individually, no deduplication
if def.CreateTicket && globalCentralClient != nil {
go globalCentralClient.postEvent(CentralEvent{
EventID: generateEventID(),
Code: ec.Code,
Category: def.Category,
Severity: def.Severity.String(),
Resource: ec.Resource,
POP: globalCentralClient.popID,
Operation: ec.Operation,
ErrorDetail: ec.ErrorDetail,
Actor: ec.Actor,
Timestamp: time.Now().Unix(),
UserMessage: def.Message,
})
}
}
// generateEventID creates a simple UUID-like identifier.
func generateEventID() string {
return fmt.Sprintf("EVT-%d-%d", time.Now().Unix(), time.Now().UnixNano()%1000)
}
// LogWarning for non-critical anomalies.
func LogWarning(db *DB, ctx context.Context, code, resource, operation, detail, actor, ip string) {
LogEvent(db, ctx, EventContext{
Code: code,
Category: "performance", // default, override via registry
Resource: resource,
Operation: operation,
Actor: actor,
ErrorDetail: detail,
IPAddr: ip,
Severity: SeverityWarning,
})
}
// LogError for user-impacting issues.
func LogError(db *DB, ctx context.Context, code, resource, operation, detail, actor, ip string) {
LogEvent(db, ctx, EventContext{
Code: code,
Category: "system", // default, override via registry
Resource: resource,
Operation: operation,
Actor: actor,
ErrorDetail: detail,
IPAddr: ip,
Severity: SeverityError,
})
}
// ---------------------------------------------------------------------------
// HTTP Response Helpers
// ---------------------------------------------------------------------------
// ErrorResponse sends a user-facing error with code.
// Use this for API responses to clients.
func ErrorResponse(w http.ResponseWriter, status int, code string) {
def, ok := EventRegistry[code]
if !ok {
def = EventDef{
Code: code,
Message: "An error occurred. Please try again.",
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := map[string]string{
"error": code,
"message": def.Message,
}
// For critical/system errors, add incident reference if available
if def.Severity >= SeverityError && def.CreateTicket {
response["reference"] = code // User can reference this when contacting support
response["status"] = "Our team has been alerted"
}
json.NewEncoder(w).Encode(response)
}
// HandleError is the full-flow helper for handlers.
// Logs event, posts to central if needed, and returns user response.
func HandleError(
w http.ResponseWriter,
r *http.Request,
db *DB,
code string,
resource string,
operation string,
internalErr error,
httpStatus int,
) {
ctx := r.Context()
// Build actor from context (set by middleware)
actor := "unknown"
if a, ok := ctx.Value("actor").(string); ok {
actor = a
}
// Log the full event
LogEvent(db, ctx, EventContext{
Code: code,
Resource: resource,
Operation: operation,
Actor: actor,
ErrorDetail: internalErr.Error(),
IPAddr: r.RemoteAddr,
})
// Return user-facing response
ErrorResponse(w, httpStatus, code)
}
// LookupEvent returns the event definition for documentation.
func LookupEvent(code string) (EventDef, bool) {
def, ok := EventRegistry[code]
return def, ok
}
// ListEventsByCategory returns all events in a category.
func ListEventsByCategory(category string) []EventDef {
var results []EventDef
for _, def := range EventRegistry {
if def.Category == category {
results = append(results, def)
}
}
return results
}
// ---------------------------------------------------------------------------
// Status Page Integration (for central/clavitor.ai)
// ---------------------------------------------------------------------------
// StatusPageEntry is returned by central's /status endpoint.
type StatusPageEntry struct {
Component string `json:"component"` // uk1, db-primary, etc.
Status string `json:"status"` // operational, degraded, down
IncidentID string `json:"incident_id,omitempty"`
UpdatedAt int64 `json:"updated_at"`
UserMessage string `json:"user_message,omitempty"`
}
// IsResourceAffected queries central for recent events on a resource.
// This queries central rather than local cache (no local deduplication).
func IsResourceAffected(resource string) bool {
// TODO: Query central /v1/events?resource=uk1&since=5m
// For now, returns false - central is source of truth
return false
}
// GetStatusForResource returns current status by querying central.
func GetStatusForResource(resource string) (StatusPageEntry, bool) {
// TODO: Query central /v1/status?resource=uk1
// For now, assumes operational - central drives status page
return StatusPageEntry{
Component: resource,
Status: "operational",
UpdatedAt: time.Now().Unix(),
}, false
}
// ---------------------------------------------------------------------------
// Central Query Helpers (for clavitor.ai implementation)
// ---------------------------------------------------------------------------
// ActiveEventSummary is what central's dashboard shows.
// SQL equivalent:
// SELECT code, resource, pop, COUNT(*) as count, MAX(timestamp) as last_seen
// FROM events
// WHERE status != 'resolved'
// GROUP BY code, resource, pop
// ORDER BY count DESC
//
// This gives you: "uk1 has 12 ERR-50001 in the last hour"
type ActiveEventSummary struct {
Code string `json:"code"` // ERR-50001
Resource string `json:"resource"` // uk1
POP string `json:"pop"` // zrh
Count int `json:"count"` // How many events
FirstSeen int64 `json:"first_seen"` // First event timestamp
LastSeen int64 `json:"last_seen"` // Most recent event timestamp
Status string `json:"status"` // investigating, identified, monitoring, resolved
}
// CentralQuery represents the query parameters for the central endpoint.
// The central API should support:
// GET /v1/events?status=active&group_by=code,resource,pop
// GET /v1/events?code=ERR-50001&resource=uk1&since=1h
// POST /v1/events/bulk-resolve { "code": "ERR-50001", "resource": "uk1" }
type CentralQuery struct {
Status string `json:"status,omitempty"` // active, resolved, all
Code string `json:"code,omitempty"` // ERR-50001
Resource string `json:"resource,omitempty"` // uk1
POP string `json:"pop,omitempty"` // zrh
Since string `json:"since,omitempty"` // 1h, 24h, 7d
GroupBy []string `json:"group_by,omitempty"` // code, resource, pop
Severity string `json:"severity,omitempty"` // error, critical
CreateTicket bool `json:"create_ticket,omitempty"` // true = ticket-creating only
}
// BulkResolveRequest marks events as resolved in bulk.
// Use case: uk1 fixed, resolve all ERR-50001 for uk1 at once.
type BulkResolveRequest struct {
Code string `json:"code"` // Required
Resource string `json:"resource,omitempty"` // Optional: resolve for specific resource
POP string `json:"pop,omitempty"` // Optional: resolve for specific POP
Since int64 `json:"since,omitempty"` // Optional: resolve events after this time
Message string `json:"message"` // Resolution message: "Disk space freed, service restored"
ResolvedBy string `json:"resolved_by"` // Who fixed it: "ops-johan"
}
// ---------------------------------------------------------------------------
// Environment-based initialization helper
// ---------------------------------------------------------------------------
// InitErrorsFromEnv sets up the central client from environment variables.
// Call this from main():
// lib.InitErrorsFromEnv()
func InitErrorsFromEnv() {
centralURL := os.Getenv("CLAVITOR_CENTRAL_URL")
popID := os.Getenv("CLAVITOR_POP_ID")
apiKey := os.Getenv("CLAVITOR_API_KEY")
if centralURL != "" && popID != "" && apiKey != "" {
InitCentralClient(centralURL, popID, apiKey)
log.Printf("[INIT] Central event reporting enabled: POP=%s -> %s", popID, centralURL)
} else {
log.Printf("[INIT] Central event reporting disabled (community edition or missing config)")
}
}

View File

@ -0,0 +1,5 @@
2026/04/03 10:53:26 Starting Clavitor Vault v2.0.45-dev-2026-04-02-0428 - community Edition
2026/04/03 10:53:26 Community edition: single-node operation (no replication)
2026/04/03 10:53:26 Clavitor listening on https://0.0.0.0:8443 (self-signed)
2026/04/03 10:54:58 Received terminated, initiating graceful shutdown...
2026/04/03 10:54:58 Clavitor Vault stopped

View File

@ -0,0 +1 @@
1336917

Binary file not shown.

BIN
clavitor.ai/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,24 +1,19 @@
# Clavitor Website — clavitor.com
> **Quickstart (60s):** [../QUICKSTART.md](../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../CLAVITOR-AGENT-HANDBOOK.md](../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavitor.ai (your domain).
> **You are:** **Emma** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
## 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`
@ -26,40 +21,33 @@ The bar is high. The support is real. These rules apply to you now.
- 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
@ -69,27 +57,23 @@ CSS and SVG changes require rebuild (embedded at compile time). Template changes
- 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`
@ -97,10 +81,9 @@ CSS and SVG changes require rebuild (embedded at compile time). Template changes
- 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
- Rackmill: Perth

166
clavitor.ai/OAUTH_SETUP.md Normal file
View File

@ -0,0 +1,166 @@
# OAuth Setup — Google, Apple, Meta
The onboarding flow at `/signup` supports email plus three social providers.
Email always works. Social providers light up the moment you set their env vars.
All three need the same three things:
- A **Client ID** (public)
- A **Client Secret** (keep secret)
- A **Redirect URL** registered with the provider — this MUST match what you set on our side, character for character
The redirect URL is where the provider sends the user back after they consent.
For production, use the canonical clavitor.ai host:
- Google: `https://clavitor.ai/auth/google/callback`
- Apple: `https://clavitor.ai/auth/apple/callback`
- Meta: `https://clavitor.ai/auth/meta/callback`
For local dev, register a second app per provider with `http://localhost:8099/auth/<name>/callback` (Apple requires HTTPS even for dev — use a tunnel like Tailscale Funnel or `ngrok`).
After registering, set the env vars on the server (systemd unit or `.env`):
```
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=https://clavitor.ai/auth/google/callback
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_REDIRECT_URL=https://clavitor.ai/auth/apple/callback
META_CLIENT_ID=
META_CLIENT_SECRET=
META_REDIRECT_URL=https://clavitor.ai/auth/meta/callback
```
Restart the web server. Look for `OAuth: google configured` in the logs.
---
## Google
Console: https://console.cloud.google.com/
1. Create a new project (or pick an existing one).
2. Open **APIs & Services → OAuth consent screen**.
- User type: **External**.
- App name: `Clavitor`. Support email: `support@clavitor.ai`.
- Authorized domains: `clavitor.ai`, `clavitor.com`.
- Scopes: add `openid`, `email`, `profile`.
- Test users: while in "Testing" mode, only listed users can sign in. Promote to "In production" before launch.
3. Open **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
- Application type: **Web application**.
- Name: `Clavitor (production)`.
- Authorized JavaScript origins: `https://clavitor.ai`, `https://clavitor.com`.
- Authorized redirect URIs: `https://clavitor.ai/auth/google/callback`.
4. Copy the Client ID and Client Secret into the env vars above.
---
## Apple
Console: https://developer.apple.com/account/resources/
Apple's Sign in with Apple is the most fiddly. You need a paid Apple Developer account ($99/yr).
1. **Create an App ID**
- Identifiers → "+" → App IDs → App.
- Description: `Clavitor`. Bundle ID: `ai.clavitor.web` (reverse-DNS).
- Capabilities: enable **Sign in with Apple**.
2. **Create a Services ID** (this is what acts as the OAuth client_id)
- Identifiers → "+" → Services IDs.
- Description: `Clavitor Web`. Identifier: `ai.clavitor.web.signin`.
- Enable **Sign in with Apple****Configure**.
- Primary App ID: the one from step 1.
- Domains: `clavitor.ai`, `clavitor.com`.
- Return URLs: `https://clavitor.ai/auth/apple/callback`.
3. **Create a Sign in with Apple key**
- Keys → "+".
- Name: `Clavitor SIWA`. Enable **Sign in with Apple**, configure to the App ID above.
- Download the `.p8` file. **You can only download it once.**
- Note the Key ID and your Team ID (top right of the dashboard).
4. **Generate the client_secret** — Apple's secret is a JWT you sign with the .p8 key. It expires every 6 months. Use this Go snippet (one-shot tool):
```go
// tools/apple_secret.go — run once, copy output to APPLE_CLIENT_SECRET
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
teamID := "YOUR_TEAM_ID"
clientID := "ai.clavitor.web.signin"
keyID := "YOUR_KEY_ID"
p8, _ := os.ReadFile("AuthKey_YOUR_KEY_ID.p8")
block, _ := pem.Decode(p8)
key, _ := x509.ParsePKCS8PrivateKey(block.Bytes)
claims := jwt.MapClaims{
"iss": teamID,
"iat": time.Now().Unix(),
"exp": time.Now().Add(180 * 24 * time.Hour).Unix(),
"aud": "https://appleid.apple.com",
"sub": clientID,
}
tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tok.Header["kid"] = keyID
s, _ := tok.SignedString(key)
fmt.Println(s)
}
```
5. Set the env vars:
- `APPLE_CLIENT_ID` = the Services ID from step 2 (`ai.clavitor.web.signin`)
- `APPLE_CLIENT_SECRET` = the JWT from step 4
- `APPLE_REDIRECT_URL` = `https://clavitor.ai/auth/apple/callback`
Note: Apple uses `response_mode=form_post`, which means the callback comes in as a POST instead of a GET. Our handler already handles both.
---
## Meta (Facebook)
Console: https://developers.facebook.com/
1. **Create an app**
- My Apps → Create App.
- Use case: **Authenticate and request data from users with Facebook Login**.
- App name: `Clavitor`. Contact email: `support@clavitor.ai`.
2. **Add Facebook Login**
- Add Product → Facebook Login → Set Up → Web.
- Site URL: `https://clavitor.ai`.
3. **Configure**
- Facebook Login → Settings.
- Valid OAuth Redirect URIs: `https://clavitor.ai/auth/meta/callback`.
- Client OAuth Login: ON. Web OAuth Login: ON. Use Strict Mode: ON.
4. **Get credentials**
- Settings → Basic.
- Copy App ID → `META_CLIENT_ID`.
- Copy App Secret → `META_CLIENT_SECRET`.
5. **Switch to Live mode**
- Top-right toggle: Development → Live.
- You may need to submit for App Review if you request anything beyond `email` and `public_profile`. We only ask for those, so review is usually waived.
---
## Verifying
Tail the web server logs after restart:
```
OAuth: google configured
OAuth: apple configured
OAuth: meta configured
```
Each provider that's missing env vars is silently skipped — its button on `/signup` returns a 503 with a "not configured" message until you finish setup.
Click each button and walk through the consent screen. You should land on `/onboarding/profile` with your email pre-populated as a customer in `corporate.db`.

View File

@ -0,0 +1,19 @@
# clavitor.ai/admin — central admin / Paddle integration
> **Quickstart (60s):** [../QUICKSTART.md](../QUICKSTART.md) — who you are, 4 things to do, critical rules.
> **Deep reference:** [../CLAVITOR-AGENT-HANDBOOK.md](../CLAVITOR-AGENT-HANDBOOK.md) — Section V: clavitor.ai (your domain).
> **You are:** **Emma** — Run `./scripts/daily-review.sh` every morning. Fix failures first.
Central admin service. Owns the customer hierarchy (MSP → end-customer → vault slots), the seat ledger, the Paddle webhook handler, and the vault registry mapping `(customer, slot_index, l0, pop)`. This is the directory service — not a vault.
## Hard rules specific to this subproject
- **Never hold any decryption material.** Central is not a vault. The wrapped L3 stored centrally for distribution is opaque to central — no L3, no L2, no L1, no master_key, ever. If you find yourself wanting to decrypt vault content here, you are in the wrong process.
- **Never trust an inbound webhook without verifying its HMAC signature.** Paddle webhook verification is mandatory. The secret comes from `PADDLE_WEBHOOK_SECRET`. If the env var is unset, every webhook is refused. There is no debug bypass.
- **Never accept admin operations from outside Tailscale.** The `vaults/claim`, `issue-token`, `wl3/since`, `wl3/full` endpoints listen only on the tailnet interface. Public clavitor.ai serves users; the ops control plane is invisible to the internet.
- **Never expose the Paddle API key, webhook secret, or any service credentials in client-side code.** Server-side env only.
- **Never delete vault data, WL3 files, or audit logs without an explicit GDPR request.** The default lifetime is forever. Cancellation/downgrade marks slots `archived`; only an explicit one-shot deletion script touches the underlying files.
## Vault slot lifecycle (canonical)
Pre-create at subscription time:
1. Customer subscribes to a plan with N seats → `INSERT INTO vault_slots` N rows, all `status='unused'`.
2. Owner names a slot ("Anna") and clicks "invite" → status moves to `pending`, central calls the POP's `/admin/issue-token` over Tailscale, POP returns the 6-char token, central displays it to the owner.
3. Anna enrolls at the POP → status moves to `active`, `l0` populated, `enrolled_at` set.
4. Cancellation/downgrade → status moves to `archived`. Vault data and WL3 file persist.
Plan upgrade = `INSERT` more `unused` rows. Plan downgrade = refuse until the owner manually marks excess slots `archived` (no silent data loss).
See `CLAVITOR-AGENT-HANDBOOK.md` Section V → clavitor.ai/admin for the full subproject contract.

BIN
clavitor.ai/admin/admin Executable file

Binary file not shown.

BIN
clavitor.ai/admin/admin.test Executable file

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,155 @@
#!/bin/bash
# Create all Clavitor products in Paddle Sandbox
# Run: chmod +x create_in_paddle.sh && ./create_in_paddle.sh
API_KEY="pdl_sdbx_apikey_01knegw36v6cvybp2y5652xpmq_weT2XzhV6Qk0rGEYDY0V5X_Aig"
BASE_URL="https://sandbox-api.paddle.com"
echo "Creating Clavitor products in Paddle Sandbox..."
echo ""
# Create Personal product
PERSONAL=$(curl -s -X POST "$BASE_URL/products" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Clavitor Personal",
"description": "Password manager for individuals. 1 vault, 5 agents, 2 devices.",
"tax_category": "software",
"type": "standard",
"custom_data": {"tier": "personal", "max_vaults": 1, "max_agents": 5, "max_devices": 2}
}' | jq -r '.data.id')
echo "Personal Product ID: $PERSONAL"
# Create Personal prices
for currency in USD EUR GBP; do
amount="1200"
[ "$currency" = "EUR" ] && amount="1100"
[ "$currency" = "GBP" ] && amount="1000"
curl -s -X POST "$BASE_URL/prices" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"product_id\": \"$PERSONAL\",
\"description\": \"Yearly subscription - $currency\",
\"name\": \"Yearly\",
\"billing_cycle\": {\"interval\": \"year\", \"frequency\": 1},
\"unit_price\": {\"amount\": \"$amount\", \"currency_code\": \"$currency\"},
\"custom_data\": {\"billing_period\": \"yearly\", \"region\": \"${currency,,}\"}
}" | jq -r '.data.id' > /dev/null
echo " ✓ Personal $currency price created"
done
# Create Family product
FAMILY=$(curl -s -X POST "$BASE_URL/products" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Clavitor Family",
"description": "Family sharing. 1 vault, 15 agents, 6 devices.",
"tax_category": "software",
"type": "standard",
"custom_data": {"tier": "family", "max_vaults": 1, "max_agents": 15, "max_devices": 6}
}' | jq -r '.data.id')
echo "Family Product ID: $FAMILY"
for currency in USD EUR GBP; do
amount="2900"
[ "$currency" = "EUR" ] && amount="2700"
[ "$currency" = "GBP" ] && amount="2500"
curl -s -X POST "$BASE_URL/prices" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"product_id\": \"$FAMILY\",
\"description\": \"Yearly - $currency\",
\"name\": \"Yearly\",
\"billing_cycle\": {\"interval\": \"year\", \"frequency\": 1},
\"unit_price\": {\"amount\": \"$amount\", \"currency_code\": \"$currency\"}
}" | jq -r '.data.id' > /dev/null
echo " ✓ Family $currency price created"
done
# Create Pro product
PRO=$(curl -s -X POST "$BASE_URL/products" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Clavitor Pro",
"description": "Power users. 1 vault, 50 agents, unlimited devices.",
"tax_category": "software",
"type": "standard",
"custom_data": {"tier": "pro", "max_vaults": 1, "max_agents": 50}
}' | jq -r '.data.id')
echo "Pro Product ID: $PRO"
for currency in USD EUR GBP; do
amount="4900"
[ "$currency" = "EUR" ] && amount="4500"
[ "$currency" = "GBP" ] && amount="4100"
curl -s -X POST "$BASE_URL/prices" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"product_id\": \"$PRO\",
\"description\": \"Yearly - $currency\",
\"name\": \"Yearly\",
\"billing_cycle\": {\"interval\": \"year\", \"frequency\": 1},
\"unit_price\": {\"amount\": \"$amount\", \"currency_code\": \"$currency\"}
}" | jq -r '.data.id' > /dev/null
echo " ✓ Pro $currency price created"
done
# Create Team products
for seats in 10 25 100 250 500; do
amounts=(24900 49900 149900 299900 499900)
case $seats in
10) amount=${amounts[0]} ;;
25) amount=${amounts[1]} ;;
100) amount=${amounts[2]} ;;
250) amount=${amounts[3]} ;;
500) amount=${amounts[4]} ;;
esac
TEAM=$(curl -s -X POST "$BASE_URL/products" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Clavitor Team $seats\",
\"description\": \"Up to $seats employees. Company vault + personal vaults.\",
\"tax_category\": \"software\",
\"type\": \"standard\",
\"custom_data\": {\"tier\": \"team$seats\", \"max_employees\": $seats}
}" | jq -r '.data.id')
echo "Team $seats Product ID: $TEAM"
curl -s -X POST "$BASE_URL/prices" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"product_id\": \"$TEAM\",
\"description\": \"Per employee yearly - USD\",
\"name\": \"Yearly\",
\"billing_cycle\": {\"interval\": \"year\", \"frequency\": 1},
\"unit_price\": {\"amount\": \"$amount\", \"currency_code\": \"USD\"},
\"quantity\": {\"minimum\": 1, \"maximum\": 1}
}" | jq -r '.data.id' > /dev/null
echo " ✓ Team $seats USD price created"
done
echo ""
echo "========================================"
echo "All products and prices created!"
echo "View in dashboard: https://sandbox-vendors.paddle.com"
echo "========================================"

File diff suppressed because it is too large Load Diff

View File

@ -3,97 +3,120 @@
"products": [
{
"name": "Clavitor Personal",
"description": "Password manager for individuals. 1 vault, 5 agents, 2 devices.",
"description": "Password manager for individuals. 1 person, 1 vault, 5 agents. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "personal",
"max_vaults": 1,
"max_agents": 5,
"max_devices": 2
"max_people": 1,
"trial_days": 7
}
},
{
"name": "Clavitor Family",
"description": "Family sharing. 1 vault, 15 agents, 6 devices. Share with your household.",
"name": "Clavitor Family",
"description": "Family sharing. ~6 people, 1 vault, 15 agents. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "family",
"max_vaults": 1,
"max_agents": 15,
"max_devices": 6
"max_people": 6,
"trial_days": 7
}
},
{
"name": "Clavitor Pro",
"description": "Power users. 1 vault, 50 agents, unlimited devices. For professionals.",
"description": "Power users. 1 person, 1 vault, 50 agents. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "pro",
"max_vaults": 1,
"max_agents": 50,
"max_devices": -1
"max_people": 1,
"trial_days": 7
}
},
{
"name": "Clavitor Team 10",
"description": "Small teams. Up to 10 employees. Each gets their own vault.",
"description": "Small teams. 10 people, 11 vaults (10+1 company), 100 agents. Annual only. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "team10",
"max_employees": 10,
"includes_company_vault": true
"max_vaults": 11,
"max_agents": 100,
"includes_company_vault": true,
"billing": "annual_only",
"trial_days": 7
}
},
{
"name": "Clavitor Team 25",
"description": "Growing teams. Up to 25 employees. Company vault + personal vaults.",
"description": "Growing teams. 25 people, 26 vaults (25+1 company), 250 agents. Annual only. 7-day free trial. $499/year.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "team25",
"max_employees": 25,
"includes_company_vault": true
"max_vaults": 26,
"max_agents": 250,
"includes_company_vault": true,
"billing": "annual_only",
"trial_days": 7
}
},
{
"name": "Clavitor Team 100",
"description": "Mid-size teams. Up to 100 employees. Admin controls.",
"tax_category": "saas",
"description": "Mid-size teams. 100 people, 101 vaults (100+1 company), 1,000 agents. Monthly or annual. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "team100",
"max_employees": 100,
"includes_company_vault": true
"max_vaults": 101,
"max_agents": 1000,
"includes_company_vault": true,
"billing": "monthly_or_annual",
"trial_days": 7
}
},
{
"name": "Clavitor Team 250",
"description": "Large teams. Up to 250 employees. Full management plane.",
"description": "Large teams. 250 people, 251 vaults (250+1 company), 2,500 agents. Monthly or annual. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "team250",
"max_employees": 250,
"includes_company_vault": true
"max_vaults": 251,
"max_agents": 2500,
"includes_company_vault": true,
"billing": "monthly_or_annual",
"trial_days": 7
}
},
{
"name": "Clavitor Team 500",
"description": "Enterprise teams. Up to 500 employees. Priority support.",
"description": "Enterprise teams. 500 people, 501 vaults (500+1 company), 5,000 agents. Monthly or annual. 7-day free trial.",
"tax_category": "saas",
"type": "standard",
"custom_data": {
"tier": "team500",
"max_employees": 500,
"includes_company_vault": true
"max_vaults": 501,
"max_agents": 5000,
"includes_company_vault": true,
"billing": "monthly_or_annual",
"trial_days": 7
}
}
],
"prices": [
{
"product_name": "Clavitor Personal",
@ -120,11 +143,11 @@
"custom_data": {"billing_period": "yearly", "region": "uk"}
},
{
"product_name": "Clavitor Family",
"product_name": "Clavitor Family",
"description": "Yearly subscription - USD",
"name": "Yearly",
"billing_cycle": {"interval": "year", "frequency": 1},
"unit_price": {"amount": "2900", "currency_code": "USD"},
"unit_price": {"amount": "2400", "currency_code": "USD"},
"custom_data": {"billing_period": "yearly", "region": "us"}
},
{

View File

@ -442,4 +442,132 @@ CREATE INDEX idx_wl3_created ON wl3_storage(created_at);
-- 7. Audit logging of ALL POP access
-- 8. Short-lived tokens (5 min) with refresh
-- ============================================
-- CURRENCY CONVERSION & PRICING
-- ============================================
--
-- DESIGN PRINCIPLE: "Pretty Commercial Prices"
-- -------------------------------------------------
-- We NEVER show exact converted prices (e.g., $29.99 → €27.42).
--
-- Why? Because:
-- - €27.42 looks calculated, foreign, and suspicious
-- - €29 looks local, intentional, and trustworthy
-- - Psychological pricing works differently per market
--
-- CONVERSION STRATEGY:
-- 1. Convert USD amount using exchange rate
-- 2. Round UP to "pretty" numbers based on magnitude:
-- - Under €25: Round to nearest €0.99 (€23.21 → €24.00)
-- - €25-€100: Round to nearest €5 (€47.32 → €49.99)
-- - €100-€500: Round to nearest €10 (€123.45 → €129)
-- - €500+: Round to nearest €50 or €99 ending
-- 3. Never drop below margin floor (stored separately)
-- 4. Use currency-local patterns (USD loves .99, JPY loves round 100s)
--
-- This table tracks metadata needed for proper formatting.
CREATE TABLE currencies (
code TEXT PRIMARY KEY, -- ISO 4217: "USD", "EUR", "JPY"
name TEXT NOT NULL, -- "US Dollar", "Euro"
decimals INTEGER NOT NULL DEFAULT 2, -- Minor units: USD=2, JPY=0, BHD=3
exchange_rate REAL, -- vs USD (1 USD = X of this currency). NULL = not fetched yet
rate_fetched_at INTEGER, -- When rate was last updated
symbol TEXT, -- "$", "€", "¥"
symbol_position TEXT DEFAULT 'prefix', -- 'prefix' ($10) or 'suffix' (10€)
pretty_pattern TEXT DEFAULT 'x.99', -- 'x.99', 'x00', 'x99', 'x50'
is_active INTEGER DEFAULT 1, -- 1 = supported, 0 = disabled
created_at INTEGER NOT NULL,
updated_at INTEGER
);
-- Exchange rate sources (in priority order):
-- 1. Frankfurter API (free, ECB rates): https://api.frankfurter.app/latest?from=USD
-- 2. Open Exchange Rates (requires API key): https://openexchangerates.org/
-- 3. Paddle's pricing-preview (for customer-facing prices with tax)
--
-- NOTE: Exchange rates are fetched dynamically. Seed data below has NULL rates.
-- Run: go run scripts/update_rates.go to populate exchange_rate column.
-- Comprehensive ISO 4217 currency list (top 50 by trading volume)
-- Rates should be fetched from Frankfurter API (free, no API key required)
INSERT INTO currencies (code, name, decimals, exchange_rate, symbol, symbol_position, pretty_pattern, is_active, created_at) VALUES
-- Major reserve currencies (always active)
('USD', 'US Dollar', 2, NULL, '$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('EUR', 'Euro', 2, NULL, '', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('GBP', 'British Pound', 2, NULL, '£', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('JPY', 'Japanese Yen', 0, NULL, '¥', 'prefix', 'x00', 1, strftime('%s', 'now')),
('CNY', 'Chinese Yuan', 2, NULL, '¥', 'prefix', 'x.00', 1, strftime('%s', 'now')),
-- Other major trading currencies
('AUD', 'Australian Dollar', 2, NULL, 'A$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('CAD', 'Canadian Dollar', 2, NULL, 'C$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('CHF', 'Swiss Franc', 2, NULL, 'Fr', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('SEK', 'Swedish Krona', 2, NULL, 'kr', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('NZD', 'New Zealand Dollar', 2, NULL, 'NZ$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('SGD', 'Singapore Dollar', 2, NULL, 'S$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('HKD', 'Hong Kong Dollar', 2, NULL, 'HK$', 'prefix', 'x.99', 1, strftime('%s', 'now')),
('NOK', 'Norwegian Krone', 2, NULL, 'kr', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('MXN', 'Mexican Peso', 2, NULL, '$', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('INR', 'Indian Rupee', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('BRL', 'Brazilian Real', 2, NULL, 'R$', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('ZAR', 'South African Rand', 2, NULL, 'R', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('KRW', 'South Korean Won', 0, NULL, '', 'prefix', 'x000', 1, strftime('%s', 'now')),
('TWD', 'New Taiwan Dollar', 2, NULL, 'NT$', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('DKK', 'Danish Krone', 2, NULL, 'kr', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('PLN', 'Polish Zloty', 2, NULL, '', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('THB', 'Thai Baht', 2, NULL, '฿', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('IDR', 'Indonesian Rupiah', 2, NULL, 'Rp', 'prefix', 'x000', 1, strftime('%s', 'now')),
('HUF', 'Hungarian Forint', 2, NULL, 'Ft', 'suffix', 'x00', 1, strftime('%s', 'now')),
('CZK', 'Czech Koruna', 2, NULL, '', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('ILS', 'Israeli Shekel', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('CLP', 'Chilean Peso', 0, NULL, '$', 'prefix', 'x00', 1, strftime('%s', 'now')),
('PHP', 'Philippine Peso', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('AED', 'UAE Dirham', 2, NULL, 'Dh', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('COP', 'Colombian Peso', 2, NULL, '$', 'prefix', 'x00', 1, strftime('%s', 'now')),
('SAR', 'Saudi Riyal', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('MYR', 'Malaysian Ringgit', 2, NULL, 'RM', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('RON', 'Romanian Leu', 2, NULL, 'lei', 'suffix', 'x.00', 1, strftime('%s', 'now')),
-- Middle Eastern (3-decimal currencies)
('BHD', 'Bahraini Dinar', 3, NULL, 'BD', 'prefix', 'x.000', 0, strftime('%s', 'now')),
('KWD', 'Kuwaiti Dinar', 3, NULL, 'KD', 'prefix', 'x.000', 0, strftime('%s', 'now')),
('OMR', 'Omani Rial', 3, NULL, 'OR', 'prefix', 'x.000', 0, strftime('%s', 'now')),
('JOD', 'Jordanian Dinar', 3, NULL, 'JD', 'prefix', 'x.000', 0, strftime('%s', 'now')),
-- Other European
('TRY', 'Turkish Lira', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('RUB', 'Russian Ruble', 2, NULL, '', 'suffix', 'x.00', 0, strftime('%s', 'now')), -- Sanctions
('UAH', 'Ukrainian Hryvnia', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('BGN', 'Bulgarian Lev', 2, NULL, 'лв', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('HRK', 'Croatian Kuna', 2, NULL, 'kn', 'suffix', 'x.00', 1, strftime('%s', 'now')),
('ISK', 'Icelandic Krona', 0, NULL, 'kr', 'suffix', 'x00', 1, strftime('%s', 'now')),
-- African
('EGP', 'Egyptian Pound', 2, NULL, '£', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('NGN', 'Nigerian Naira', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('KES', 'Kenyan Shilling', 2, NULL, 'KSh', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('GHS', 'Ghanaian Cedi', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('MAD', 'Moroccan Dirham', 2, NULL, 'DH', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('TND', 'Tunisian Dinar', 3, NULL, 'DT', 'prefix', 'x.000', 0, strftime('%s', 'now')),
-- Asian
('PKR', 'Pakistani Rupee', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('BDT', 'Bangladeshi Taka', 2, NULL, '', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('VND', 'Vietnamese Dong', 0, NULL, '', 'suffix', 'x000', 1, strftime('%s', 'now')),
('MMK', 'Myanmar Kyat', 2, NULL, 'K', 'suffix', 'x00', 0, strftime('%s', 'now')),
('KHR', 'Cambodian Riel', 2, NULL, '', 'prefix', 'x00', 0, strftime('%s', 'now')),
('LAK', 'Lao Kip', 2, NULL, '', 'prefix', 'x000', 0, strftime('%s', 'now')),
('MNT', 'Mongolian Tugrik', 2, NULL, '', 'suffix', 'x00', 0, strftime('%s', 'now')),
-- Latin American
('ARS', 'Argentine Peso', 2, NULL, '$', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('PEN', 'Peruvian Sol', 2, NULL, 'S/', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('UYU', 'Uruguayan Peso', 2, NULL, '$', 'prefix', 'x.00', 1, strftime('%s', 'now')),
('PYG', 'Paraguayan Guarani', 0, NULL, '', 'prefix', 'x000', 0, strftime('%s', 'now')),
('BOB', 'Bolivian Boliviano', 2, NULL, 'Bs', 'prefix', 'x.00', 0, strftime('%s', 'now')),
-- Caribbean
('XCD', 'Eastern Caribbean Dollar', 2, NULL, '$', 'prefix', 'x.99', 0, strftime('%s', 'now')); -- Eastern Caribbean

View File

@ -24,25 +24,25 @@
<div class="plan" data-price-id="pri_01knejm7ft2ska5r4qff2gm9r4" onclick="selectPlan(this)">
<h3>Personal</h3>
<div class="price">$12/year</div>
<p>1 vault, 5 agents, 2 devices</p>
<p>1 person, 1 vault, 5 agents</p>
</div>
<div class="plan" data-price-id="pri_01knejm7xs9kqt0vn61dx6q808" onclick="selectPlan(this)">
<h3>Family</h3>
<div class="price">$29/year</div>
<p>1 vault, 15 agents, 6 devices</p>
<div class="price">$24/year</div>
<p>1 vault, 15 agents, ~6 people</p>
</div>
<div class="plan" data-price-id="pri_01knejm8djq4p63rmsxze7by58" onclick="selectPlan(this)">
<h3>Pro</h3>
<div class="price">$49/year</div>
<p>1 vault, 50 agents, unlimited devices</p>
<p>1 person, 1 vault, 50 agents</p>
</div>
<div class="plan" data-price-id="pri_01knejm8twprj5ca4zem1g4g56" onclick="selectPlan(this)">
<h3>Team 10</h3>
<div class="price">$249/employee/year</div>
<p>Up to 10 employees, company vault included</p>
<div class="price">$249/year (annual only)</div>
<p>10 people, 11 vaults (10+1 company), 100 agents</p>
</div>
</div>

BIN
clavitor.ai/clavitor-web Executable file

Binary file not shown.

Binary file not shown.

View File

@ -133,6 +133,35 @@ code { font-size: 0.875em; }
.nav-dropdown-item { display: block; padding: 6px 16px; font-size: 0.825rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; }
.nav-dropdown-item:hover { color: var(--text); background: var(--surface); }
/* === SPLIT LANGUAGE/CURRENCY SELECTORS === */
.nav-dropdown--language,
.nav-dropdown--currency { display: inline-block; }
.nav-dropdown--language .nav-dropdown-trigger,
.nav-dropdown--currency .nav-dropdown-trigger { min-width: 60px; justify-content: center; }
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 8px; }
@media (max-width: 768px) {
.nav-dropdown--language,
.nav-dropdown--currency { display: block; width: 100%; }
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 0; margin-top: 0.5rem; }
.nav-dropdown--language .nav-dropdown-menu,
.nav-dropdown--currency .nav-dropdown-menu { position: static; right: auto; left: auto; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
}
/* === CURRENCY DROPDOWN SECTIONS === */
.dropdown-section {
padding: 8px 16px;
font-size: 11px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
pointer-events: none;
}
.dropdown-divider {
border-top: 1px solid var(--border);
margin: 8px 0;
}
/* === BUTTONS === */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--font-sans); font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; transition: all 100ms ease; text-align: center; text-decoration: none; }
.btn-primary { background: var(--brand-black); color: #ffffff; border-color: var(--brand-black); }

BIN
clavitor.ai/clavitor.db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
clavitor.ai/corporate.db Normal file
View File

Binary file not shown.

Binary file not shown.

View File

@ -104,23 +104,24 @@
</div>
</div>
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
<div class="nav-dropdown nav-dropdown--locale">
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
<div class="nav-dropdown nav-dropdown--language">
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right">
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
<a href="/fr" class="nav-dropdown-item" data-lang="fr">🇫🇷 Français</a>
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
<a href="#" class="nav-dropdown-item" data-currency="CHF">CHF</a>
<a href="#" class="nav-dropdown-item" data-currency="GBP">GBP £</a>
</div>
</div>
<div class="nav-dropdown nav-dropdown--currency">
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
<!-- Currency options loaded dynamically from /api/currencies -->
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
</div>
</div>
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; <s>$20</s> $12/yr</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; $12/yr</a>
</div>
</div>
</nav>
@ -173,61 +174,140 @@
<script>
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
// Locale selector
// Language selector state management
(function() {
const localeTrigger = document.getElementById('localeTrigger');
if (!localeTrigger) return;
const dropdown = localeTrigger.parentElement;
const langTrigger = document.getElementById('languageTrigger');
if (!langTrigger) return;
const dropdown = langTrigger.closest('.nav-dropdown--language');
const langItems = dropdown.querySelectorAll('[data-lang]');
const currencyItems = dropdown.querySelectorAll('[data-currency]');
// Load saved preferences
const saved = JSON.parse(localStorage.getItem('clavitor-locale') || '{}');
const currentLang = saved.lang || 'en';
const currentCurrency = saved.currency || 'USD';
function updateDisplay() {
const lang = dropdown.querySelector('[data-lang].active')?.dataset.lang || currentLang;
const currency = dropdown.querySelector('[data-currency].active')?.dataset.currency || currentCurrency;
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
localeTrigger.textContent = `${langFlags[lang] || '🌐'} ${lang.toUpperCase()} / ${currency}`;
}
// Set initial active states
// Load saved preference
const savedLang = localStorage.getItem('preferredLanguage') || 'en';
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
// Set initial active state
langItems.forEach(el => {
if (el.dataset.lang === currentLang) el.classList.add('active');
else el.classList.remove('active');
if (el.dataset.lang === savedLang) {
el.classList.add('active');
langTrigger.textContent = langFlags[savedLang] + ' ' + savedLang.toUpperCase();
} else {
el.classList.remove('active');
}
});
currencyItems.forEach(el => {
if (el.dataset.currency === currentCurrency) el.classList.add('active');
else el.classList.remove('active');
});
updateDisplay();
// Handle language selection
langItems.forEach(el => el.addEventListener('click', (e) => {
e.preventDefault();
const lang = el.dataset.lang;
const flag = el.textContent.trim().split(' ')[0];
langTrigger.textContent = flag + ' ' + lang.toUpperCase();
langItems.forEach(i => i.classList.remove('active'));
el.classList.add('active');
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: el.dataset.lang, currency: currentCurrency }));
updateDisplay();
localStorage.setItem('preferredLanguage', lang);
// Navigate to language path
if (el.dataset.lang === 'en') window.location.href = '/';
else window.location.href = '/' + el.dataset.lang;
}));
// Handle currency selection
currencyItems.forEach(el => el.addEventListener('click', (e) => {
e.preventDefault();
currencyItems.forEach(i => i.classList.remove('active'));
el.classList.add('active');
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: currentLang, currency: el.dataset.currency }));
updateDisplay();
// Refresh page to apply currency (or fetch rates via JS)
window.location.reload();
if (lang === 'en') window.location.href = '/';
else window.location.href = '/' + lang;
}));
})();
// Currency selector - fetch from API and render with sections
(function() {
const currencyTrigger = document.getElementById('currencyTrigger');
if (!currencyTrigger) return;
async function loadCurrencies() {
const menu = document.getElementById('currencyMenu');
if (!menu) return;
try {
const response = await fetch('/api/currencies');
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
const data = await response.json();
// Clear existing content
menu.innerHTML = '';
// Render "Popular" section
if (data.top && data.top.length > 0) {
const popularHeader = document.createElement('div');
popularHeader.className = 'dropdown-section';
popularHeader.textContent = 'Popular';
menu.appendChild(popularHeader);
data.top.forEach(currency => {
const item = createCurrencyItem(currency, currencyTrigger);
menu.appendChild(item);
});
}
// Divider between sections
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
const divider = document.createElement('div');
divider.className = 'dropdown-divider';
menu.appendChild(divider);
}
// Render "All Currencies" section
if (data.all && data.all.length > 0) {
const allHeader = document.createElement('div');
allHeader.className = 'dropdown-section';
allHeader.textContent = 'All Currencies';
menu.appendChild(allHeader);
data.all.forEach(currency => {
const item = createCurrencyItem(currency, currencyTrigger);
menu.appendChild(item);
});
}
} catch (err) {
// ERR-CURRENCY-002: API unavailable - keep default fallback options
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
}
}
function createCurrencyItem(currency, trigger) {
const item = document.createElement('a');
item.href = '#';
item.className = 'nav-dropdown-item';
item.setAttribute('data-currency', currency.code);
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
// Set active state based on current selection or saved preference
const savedCurrency = localStorage.getItem('preferredCurrency') || 'USD';
const currentText = trigger.textContent.trim();
if (currentText.includes(currency.code) || savedCurrency === currency.code) {
item.classList.add('active');
if (savedCurrency === currency.code) {
trigger.textContent = (currency.symbol || '$') + ' ' + currency.code;
}
}
item.addEventListener('click', (e) => {
e.preventDefault();
const code = item.getAttribute('data-currency');
const symbol = currency.symbol || '$';
trigger.textContent = symbol + ' ' + code;
// Update active state
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
// Store preference
localStorage.setItem('preferredCurrency', code);
// Refresh page to apply currency (or fetch rates via JS)
window.location.reload();
});
return item;
}
// Load currencies on page load
loadCurrencies();
})();
</script>
</body>
</html>

View File

@ -3,7 +3,7 @@
<div class="hero container">
<p class="label accent mb-4"><span class="vaultname">clavitor</span> hosted</p>
<h1>Zero cache. Every request hits the vault.</h1>
<p class="lead">Clavitor never caches credentials — not in memory, not on disk, not anywhere. Every request is a fresh decrypt from the vault. That's the security model. To make it fast, we run {{len .Pops}} regions across every continent. Your data lives where you choose. <s>$20</s> $12/yr.</p>
<p class="lead">Clavitor never caches credentials — not in memory, not on disk, not anywhere. Every request is a fresh decrypt from the vault. That's the security model. To make it fast, we run {{len .Pops}} regions across every continent. Your data lives where you choose. $12/yr.</p>
</div>
<!-- Map -->
@ -159,7 +159,7 @@
<!-- CTA -->
<div class="section container">
<h2 class="mb-4">Ready?</h2>
<p class="lead mb-6"><s>$20</s> $12/yr. 7-day money-back. Every feature included. <strong>Price for life</strong> — your rate never increases.</p>
<p class="lead mb-6">$12/yr. 7-day money-back. Every feature included. <strong>Price for life</strong> — your rate never increases.</p>
<div class="btn-row">
<a href="/signup" class="btn btn-primary">Get started</a>
<a href="/pricing" class="btn btn-ghost">Compare plans &rarr;</a>

View File

@ -6,7 +6,7 @@
<h1 class="mb-6">"If you want to keep a secret, you must also hide it from yourself."</h1>
<p class="lead mb-6">We did. Your Identity Encryption key is derived in your browser from your WebAuthn authenticator — fingerprint, face, or hardware key. Our servers have never seen it. They could not decrypt your private fields even if they wanted to. Or anybody else.</p>
<div class="btn-row">
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; <s>$20</s> $12/yr</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; $12/yr</a>
<a href="/install" class="btn btn-ghost">Self-host free &rarr;</a>
</div>
</div>
@ -439,7 +439,7 @@ ghp_a3f8...</pre>
<h2 class="mb-4">Your vault needs to be everywhere you are.</h2>
<p class="lead mb-3">A password manager that only works on your home network isn't a password manager. Your laptop moves. Your phone moves. Your browser extension needs your vault at the coffee shop, on the plane, at the client's office.</p>
<p class="mb-3">Self-hosting that means a server with a public IP, DNS, TLS certificates, uptime monitoring, and backups. That's not a weekend project — that's infrastructure.</p>
<p class="mb-8">We run <span class="vaultname">clavitor</span> across {{len .Pops}} regions on every continent. <s>$20</s> $12/yr. Your Identity Encryption keys never leave your browser — we mathematically cannot read your private fields.</p>
<p class="mb-8">We run <span class="vaultname">clavitor</span> across {{len .Pops}} regions on every continent. $12/yr. Your Identity Encryption keys never leave your browser — we mathematically cannot read your private fields.</p>
<div class="btn-row">
<a href="/hosted" class="btn btn-primary">Get hosted &rarr;</a>
<a href="/install" class="btn btn-ghost">Self-host anyway</a>

View File

@ -121,7 +121,7 @@ WantedBy=multi-user.target</pre></div>
<hr class="divider mb-8 mt-4">
<h2 class="mb-4">Rather not manage it yourself?</h2>
<p class="lead mb-6">Same vault, same features. We handle updates, backups, and TLS. <s>$20</s> $12/yr.</p>
<p class="lead mb-6">Same vault, same features. We handle updates, backups, and TLS. $12/yr.</p>
<a href="/hosted" class="btn btn-primary">See hosted option &rarr;</a>
</div>

View File

@ -79,7 +79,7 @@
<div class="card mb-8" style="text-align:center">
<p class="mb-4">Three-tier encryption. Scoped access. Your AI gets what it needs — nothing more.</p>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; <s>$20</s> $12/yr</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; $12/yr</a>
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free &rarr;</a>
</div>
</div>
@ -152,7 +152,7 @@
<div class="card mb-8" style="text-align:center">
<p class="mb-4">Scoped access for every agent. Your secrets stay yours.</p>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; <s>$20</s> $12/yr</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; $12/yr</a>
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free &rarr;</a>
</div>
</div>
@ -200,7 +200,7 @@
<div class="card mb-8" style="text-align:center">
<p class="mb-4">Multi-agent. Scoped. Encrypted. Built for autonomous workflows.</p>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; <s>$20</s> $12/yr</a>
<a href="/hosted" class="btn btn-primary">Get hosted &mdash; $12/yr</a>
<a href="/install" class="btn btn-ghost" style="margin-left:8px">Self-host free &rarr;</a>
</div>
</div>
@ -253,7 +253,7 @@
<div class="card mb-8" style="text-align:center">
<p class="mb-4">多智能体。范围限定。加密。为自主工作流构建。</p>
<a href="/hosted" class="btn btn-primary">托管服务 &mdash; <s>$20</s> $12/年</a>
<a href="/hosted" class="btn btn-primary">托管服务 &mdash; $12/年</a>
<a href="/install" class="btn btn-ghost" style="margin-left:8px">免费自托管 &rarr;</a>
</div>
</div>

View File

@ -137,7 +137,7 @@
<div class="section container">
<h2 class="mb-4">Ready to upgrade?</h2>
<p class="lead mb-6">Self-host for free, or let us run it for <s>$20</s> $12/yr.</p>
<p class="lead mb-6">Self-host for free, or let us run it for $12/yr.</p>
<div class="btn-row">
<a href="/install" class="btn btn-ghost">Self-host &rarr;</a>
<a href="/hosted" class="btn btn-primary">Get hosted &rarr;</a>

View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mid-Market — Test</title>
<link rel="stylesheet" href="/clavitor.css">
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('https://fonts.gstatic.com/s/ibmplexsans/v19/zYXzKVElJtQ_m--A1PHz_MlYGHbM.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flE3B8wJ47L-j6pEsKTZbBr8M.woff2') format('woff2');
}
</style>
</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></span></a>
<div class="nav-links">
<a href="/for/mme" class="nav-link active">Mid-Market</a>
<a href="/pricing" class="nav-link">Pricing</a>
<a href="/hosted" class="btn btn-primary">Get hosted</a>
</div>
</div>
</nav>
<div class="hero container">
<p class="label mb-3">For mid-market enterprises</p>
<h1 class="mb-4">10250 humans.<br>1000+ credentials.</h1>
<p class="lead mb-6">Per-human pricing. Agents unlimited.</p>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">Mid-Market pricing</h2>
<div class="price-card featured max-w-sm mx-auto">
<span class="badge recommended price-badge">Most popular</span>
<p class="label mb-4">Per seat</p>
<div class="price-amount mb-2">$3<span class="price-period">/user/month</span></div>
<p class="mb-6">Annual billing. Minimum 10 users.</p>
<a href="#" class="btn btn-primary btn-block mb-8">Contact sales</a>
<ul class="checklist">
<li>Everything in Business, plus</li>
<li>SAML SSO</li>
<li>Audit logs (90 days)</li>
<li>Priority support</li>
<li>Shared team vaults</li>
</ul>
</div>
<p class="text-sm text-tertiary mt-4 text-center"><strong>Price for life:</strong> Your per-user rate is locked as long as you stay subscribed.</p>
</div>
<hr class="divider">
<div class="section container">
<h2 class="mb-4">Ready to talk?</h2>
<p class="lead mb-6">We'll get you set up with a pilot program.</p>
<a href="#" class="btn btn-primary">Contact sales</a>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div>© 2025 clavitor</div>
</div>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hosted — Test</title>
<link rel="stylesheet" href="/clavitor.css">
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('https://fonts.gstatic.com/s/ibmplexsans/v19/zYXzKVElJtQ_m--A1PHz_MlYGHbM.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flE3B8wJ47L-j6pEsKTZbBr8M.woff2') format('woff2');
}
</style>
</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></span></a>
<div class="nav-links">
<a href="/hosted" class="nav-link active">Hosted</a>
<a href="/pricing" class="nav-link">Pricing</a>
<div class="nav-dropdown nav-dropdown--locale">
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right">
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
</div>
</div>
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
</div>
</div>
</nav>
<div class="hero container">
<p class="label accent mb-4">clavitor hosted</p>
<h1>Zero cache. Every request hits the vault.</h1>
<p class="lead">Clavitor never caches credentials. To make it fast, we run 4 regions across every continent. $12/yr.</p>
</div>
<div class="container">
<div class="map-wrap" style="background:#f5f5f5;border-radius:12px;padding:20px;text-align:center;height:300px;display:flex;align-items:center;justify-content:center">
<svg id="worldmap" viewBox="0 0 1000 460" width="100%" height="260" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="460" fill="#f5f5f5"/>
<text x="500" y="440" font-family="IBM Plex Sans,sans-serif" font-size="18" font-weight="700" fill="#0A0A0A" text-anchor="middle" opacity="0.35" letter-spacing="0.3em">CLAVITOR GLOBAL PRESENCE</text>
<!-- Sample dots for regions -->
<circle cx="150" cy="200" r="8" fill="#DC2626"/>
<text x="150" y="220" font-family="IBM Plex Sans,sans-serif" font-size="12" fill="#0A0A0A" text-anchor="middle">US-East</text>
<circle cx="500" cy="180" r="8" fill="#DC2626"/>
<text x="500" y="200" font-family="IBM Plex Sans,sans-serif" font-size="12" fill="#0A0A0A" text-anchor="middle">EU-West</text>
<circle cx="800" cy="220" r="8" fill="#DC2626"/>
<text x="800" y="240" font-family="IBM Plex Sans,sans-serif" font-size="12" fill="#0A0A0A" text-anchor="middle">Asia-East</text>
</svg>
</div>
</div>
<div class="section container">
<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.</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.</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.</p>
</div>
</div>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div>© 2025 clavitor</div>
</div>
</div>
</footer>
<script>
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
</script>
</body>
</html>

234
clavitor.ai/test-index.html Normal file
View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clavitor — Test Page</title>
<link rel="stylesheet" href="/clavitor.css">
<style>
/* Inline the fonts for testing */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('https://fonts.gstatic.com/s/ibmplexsans/v19/zYXzKVElJtQ_m--A1PHz_MlYGHbM.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flE3B8wJ47L-j6pEsKTZbBr8M.woff2') format('woff2');
}
</style>
</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">
<a href="/hosted" class="nav-link">Hosted</a>
<div class="nav-dropdown">
<span class="nav-link nav-dropdown-trigger">Product</span>
<div class="nav-dropdown-menu">
<a href="/upgrade" class="nav-dropdown-item">Upgrade</a>
<a href="/developers" class="nav-dropdown-item">Developers</a>
<a href="/install" class="nav-dropdown-item">Self-host</a>
</div>
</div>
<div class="nav-dropdown">
<span class="nav-link nav-dropdown-trigger">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 active">Pricing</a>
<div class="nav-dropdown nav-dropdown--language">
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right">
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
</div>
</div>
<div class="nav-dropdown nav-dropdown--currency">
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
<!-- Currency options loaded dynamically from /api/currencies -->
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
</div>
</div>
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
<a href="/hosted" class="btn btn-primary">Get hosted — $12/yr</a>
</div>
</div>
</nav>
<div class="hero container">
<p class="label accent 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</li><li>WebAuthn PRF</li><li>CLI for AI agents</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">$12<span class="price-period">/year</span></div>
<p class="mb-6">7-day free trial. Cancel anytime.</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>4 regions across every continent</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.</p>
<h3>Will my price go up?</h3>
<p><strong>Never.</strong> Your price is locked for life. Pay $12/yr today, pay $12/yr in 2035.</p>
<h3>Does self-hosted get every feature?</h3>
<p>Yes. Every feature ships in both versions.</p>
</div>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div>© 2025 clavitor</div>
<div class="footer-links">
<a href="/privacy">Privacy</a>
<a href="/terms">Terms</a>
</div>
</div>
</div>
</footer>
<script>
// Toggle dropdown menus
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
// Language selector state management
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const lang = item.getAttribute('data-lang');
const flag = item.textContent.trim().split(' ')[0];
document.getElementById('languageTrigger').textContent = flag + ' ' + lang.toUpperCase();
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
});
});
// Currency selector - fetch from API and render with sections
async function loadCurrencies() {
const menu = document.getElementById('currencyMenu');
const trigger = document.getElementById('currencyTrigger');
try {
const response = await fetch('/api/currencies');
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
const data = await response.json();
// Clear existing content
menu.innerHTML = '';
// Render "Popular" section
if (data.top && data.top.length > 0) {
const popularHeader = document.createElement('div');
popularHeader.className = 'dropdown-section';
popularHeader.textContent = 'Popular';
menu.appendChild(popularHeader);
data.top.forEach(currency => {
const item = createCurrencyItem(currency, trigger);
menu.appendChild(item);
});
}
// Divider between sections
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
const divider = document.createElement('div');
divider.className = 'dropdown-divider';
menu.appendChild(divider);
}
// Render "All Currencies" section
if (data.all && data.all.length > 0) {
const allHeader = document.createElement('div');
allHeader.className = 'dropdown-section';
allHeader.textContent = 'All Currencies';
menu.appendChild(allHeader);
data.all.forEach(currency => {
const item = createCurrencyItem(currency, trigger);
menu.appendChild(item);
});
}
} catch (err) {
// ERR-CURRENCY-002: API unavailable - keep default fallback options
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
}
}
function createCurrencyItem(currency, trigger) {
const item = document.createElement('a');
item.href = '#';
item.className = 'nav-dropdown-item';
item.setAttribute('data-currency', currency.code);
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
// Set active state based on current selection
const currentText = trigger.textContent.trim();
if (currentText.includes(currency.code)) {
item.classList.add('active');
}
item.addEventListener('click', (e) => {
e.preventDefault();
const code = item.getAttribute('data-currency');
const symbol = currency.symbol || '$';
trigger.textContent = symbol + ' ' + code;
// Update active state
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
// Store preference
localStorage.setItem('preferredCurrency', code);
});
return item;
}
// Load currencies on page load
loadCurrencies();
</script>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mobile Test — 375px</title>
<link rel="stylesheet" href="/clavitor.css">
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('https://fonts.gstatic.com/s/ibmplexsans/v19/zYXzKVElJtQ_m--A1PHz_MlYGHbM.woff2') format('woff2');
}
body { max-width: 375px; margin: 0 auto; border: 1px solid #ccc; }
</style>
</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></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">
<a href="/hosted" class="nav-link">Hosted</a>
<a href="/pricing" class="nav-link active">Pricing</a>
<div class="nav-dropdown nav-dropdown--locale">
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
<div class="nav-dropdown-menu nav-dropdown-menu--right">
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
</div>
</div>
<a href="/hosted" class="btn btn-primary">Get hosted</a>
</div>
</div>
</nav>
<div class="hero container">
<p class="label accent mb-3">Simple pricing</p>
<h1 class="mb-4">No tiers.</h1>
<p class="lead">Two options.</p>
</div>
<div class="section container">
<div class="grid-2">
<div class="price-card">
<p class="label mb-2">Self-hosted</p>
<div class="price-amount mb-2">Free</div>
</div>
<div class="price-card featured">
<span class="badge recommended price-badge">Rec</span>
<p class="label accent mb-2">Hosted</p>
<div class="price-amount mb-2">$12<span class="price-period">/yr</span></div>
</div>
</div>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div>© 2025</div>
</div>
</div>
</footer>
<script>
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
</script>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign up — Test</title>
<link rel="stylesheet" href="/clavitor.css">
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('https://fonts.gstatic.com/s/ibmplexsans/v19/zYXzKVElJtQ_m--A1PHz_MlYGHbM.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flE3B8wJ47L-j6pEsKTZbBr8M.woff2') format('woff2');
}
</style>
</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></span></a>
</div>
</nav>
<div class="container-sm" style="padding:4rem 1rem 2rem">
<p class="label accent mb-3 text-center">Create your vault</p>
<h1 class="mb-4 text-center">Get started</h1>
<p class="lead text-center mb-6">Pick a method to create your account.</p>
<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:32px">
<a href="#" class="btn btn-ghost" style="justify-content:center;gap:12px;height:44px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Continue with Google
</a>
<a href="#" class="btn btn-ghost" style="justify-content:center;gap:12px;height:44px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.74.82 0 2.1-.93 3.72-.8 1.25.1 2.16.55 2.85 1.37-2.57 1.54-2.14 4.5.44 5.38-.49 1.37-1.03 2.74-1.09 2.28zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
Continue with Apple
</a>
<a href="#" class="btn btn-ghost" style="justify-content:center;gap:12px;height:44px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm3.5 15.5h-2.1v-3.3h-2.8v3.3H8.5v-7h2.1v3.1h2.8v-3.1h2.1v7z"/></svg>
Continue with Meta
</a>
<a href="#" class="btn btn-ghost" style="justify-content:center;gap:12px;height:44px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.37.6.1.8-.26.8-.58v-2.03c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.33-1.76-1.33-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.8 1.3 3.49 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.17 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.65.24 2.87.12 3.17.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.8.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
Continue with GitHub
</a>
</div>
<div style="text-align:center;color:var(--text-tertiary);font-size:13px;margin-bottom:24px">or</div>
<form style="display:flex;flex-direction:column;gap:12px">
<input type="email" placeholder="you@example.com" class="btn btn-ghost" style="text-align:left;font-weight:400;height:44px">
<button type="submit" class="btn btn-primary" style="height:44px">Continue with email</button>
</form>
<p class="text-sm text-tertiary text-center mt-6" style="max-width:320px;margin-left:auto;margin-right:auto">
By signing up, you agree to the <a href="/terms" class="text-accent">Terms of Service</a> and <a href="/privacy" class="text-accent">Privacy Policy</a>.
</p>
</div>
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div>© 2025 clavitor</div>
</div>
</div>
</footer>
</body>
</html>

125
consolidate-clavitor-db-auto.sh Executable file
View File

@ -0,0 +1,125 @@
#!/bin/bash
# consolidate-clavitor-db-auto.sh
# NON-INTERACTIVE VERSION for SSH execution
set -e
DB="${1:-/opt/clavitor-web/clavitor.db}"
BACKUP="${DB}.backup.$(date +%Y%m%d_%H%M%S)"
# AUTO-CONFIRM MODE
FORCE="${FORCE:-0}"
echo "=== Clavitor DB Production Consolidation ==="
echo "Target: $DB"
echo ""
# Show current state
echo "=== Current State ==="
sqlite3 "$DB" <<'EOF'
SELECT "=== Row Counts ===";
SELECT "accounts: " || COUNT(*) FROM accounts;
SELECT "domain_scopes: " || COUNT(*) || " (KEEP)" FROM domain_scopes;
SELECT "incidents: " || COUNT(*) FROM incidents;
SELECT "incident_updates: " || COUNT(*) FROM incident_updates;
SELECT "login_codes: " || COUNT(*) || " (DROP)" FROM login_codes;
SELECT "maintenance: " || COUNT(*) || " (CHECK)" FROM maintenance;
SELECT "outages: " || COUNT(*) || " (CHECK)" FROM outages;
SELECT "pops: " || COUNT(*) || " (KEEP)" FROM pops;
SELECT "sessions: " || COUNT(*) || " (DROP)" FROM sessions;
SELECT "telemetry: " || COUNT(*) || " (KEEP)" FROM telemetry;
SELECT "uptime: " || COUNT(*) || " (KEEP)" FROM uptime;
SELECT "uptime_daily: " || COUNT(*) || " (KEEP)" FROM uptime_daily;
SELECT "uptime_spans: " || COUNT(*) || " (KEEP)" FROM uptime_spans;
SELECT "vaults: " || COUNT(*) || " (DROP)" FROM vaults;
SELECT "=== POP Status ===";
SELECT status || ": " || COUNT(*) FROM pops GROUP BY status;
EOF
# Create backup
echo ""
echo "=== Creating Backup ==="
cp "$DB" "$BACKUP"
echo "✓ Backup: $BACKUP"
echo ""
echo "=== Phase 1: Drop Unused Tables ==="
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS login_codes;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS vaults;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Dropped: login_codes, sessions, vaults"
echo ""
echo "=== Phase 2: Check Status Table Merge ==="
MAINT_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM maintenance")
OUTAGE_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM outages")
INCIDENT_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM incidents")
echo "Maintenance: $MAINT_COUNT, Outages: $OUTAGE_COUNT, Incidents: $INCIDENT_COUNT"
if [[ $INCIDENT_COUNT -eq 0 && ($MAINT_COUNT -gt 0 || $OUTAGE_COUNT -gt 0) ]]; then
echo "Merging maintenance and outages into incidents..."
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
-- Migrate maintenance to incidents
INSERT INTO incidents (title, status, date, node_ids, created_at)
SELECT
'Maintenance: ' || COALESCE(reason, 'Scheduled'),
CASE WHEN end_at IS NULL THEN 'active' ELSE 'resolved' END,
datetime(start_at, 'unixepoch'),
'',
COALESCE(start_at, strftime('%s', 'now'))
FROM maintenance;
-- Migrate outages to incidents
INSERT INTO incidents (title, status, date, node_ids, created_at)
SELECT
COALESCE(description, 'Service Outage'),
CASE
WHEN status = 'resolved' THEN 'resolved'
WHEN end_at = '' THEN 'investigating'
ELSE 'monitoring'
END,
start_at,
node_id,
COALESCE(strftime('%s', start_at), strftime('%s', 'now'))
FROM outages;
DROP TABLE IF EXISTS maintenance;
DROP TABLE IF EXISTS outages;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Merged maintenance ($MAINT_COUNT) and outages ($OUTAGE_COUNT) into incidents"
else
echo "Skipped merge (incidents has data or no source data)"
fi
echo ""
echo "=== Phase 3: Vacuum and Optimize ==="
sqlite3 "$DB" <<'EOF'
VACUUM;
ANALYZE;
EOF
echo "✓ Database optimized"
echo ""
echo "=== Final State ==="
sqlite3 "$DB" ".tables"
echo ""
echo "=== Done ==="
echo "Backup: $BACKUP"
echo "To restore: cp $BACKUP $DB"

View File

@ -0,0 +1,168 @@
#!/bin/bash
# consolidate-clavitor-db-production.sh
# PRODUCTION VERSION - More conservative than local dev
# Only removes truly unused tables, keeps operational data
set -e
DB="${1:-/opt/clavitor-web/clavitor.db}"
BACKUP="${DB}.backup.$(date +%Y%m%d_%H%M%S)"
echo "=== Clavitor DB Production Consolidation ==="
echo "Target: $DB"
echo ""
# Pre-flight check - show current state
echo "=== Current State ==="
sqlite3 "$DB" <<'EOF'
SELECT "=== Row Counts ===";
SELECT "accounts: " || COUNT(*) FROM accounts;
SELECT "domain_scopes: " || COUNT(*) || " (KEEP - used)" FROM domain_scopes;
SELECT "incidents: " || COUNT(*) FROM incidents;
SELECT "incident_updates: " || COUNT(*) FROM incident_updates;
SELECT "login_codes: " || COUNT(*) || " (DROP - ephemeral)" FROM login_codes;
SELECT "maintenance: " || COUNT(*) || " (KEEP - 149 records)" FROM maintenance;
SELECT "outages: " || COUNT(*) || " (KEEP - 1 record)" FROM outages;
SELECT "pops: " || COUNT(*) || " (KEEP - source of truth)" FROM pops;
SELECT "sessions: " || COUNT(*) || " (DROP - ephemeral)" FROM sessions;
SELECT "telemetry: " || COUNT(*) || " (KEEP - 676k records)" FROM telemetry;
SELECT "uptime: " || COUNT(*) || " (KEEP - status page)" FROM uptime;
SELECT "uptime_daily: " || COUNT(*) || " (KEEP - 351 records)" FROM uptime_daily;
SELECT "uptime_spans: " || COUNT(*) || " (KEEP - 66 records)" FROM uptime_spans;
SELECT "vaults: " || COUNT(*) || " (DROP - obsolete)" FROM vaults;
EOF
echo ""
read -p "Continue with backup and consolidation? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
# Create backup
echo ""
echo "=== Creating Backup ==="
cp "$DB" "$BACKUP"
echo "✓ Backup: $BACKUP"
echo ""
echo "=== Phase 1: Drop Only Truly Unused Tables ==="
# Only drop tables that are definitely not used in production
# accounts (2 rows) - might be referenced, keep for now
# login_codes (0 rows) - safe to drop (ephemeral)
# sessions (3 rows) - safe to drop (ephemeral)
# vaults (1 row) - obsolete hosted vaults
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
-- Safe drops (no production data or ephemeral)
DROP TABLE IF EXISTS login_codes;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS vaults;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Dropped ephemeral tables (login_codes, sessions, vaults)"
echo ""
echo "=== Phase 2: Consolidate Status Tables (Optional) ==="
# Check if we should merge maintenance/outages into incidents
MAINT_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM maintenance")
OUTAGE_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM outages")
INCIDENT_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM incidents")
echo "Maintenance records: $MAINT_COUNT"
echo "Outage records: $OUTAGE_COUNT"
echo "Incident records: $INCIDENT_COUNT"
if [[ $INCIDENT_COUNT -eq 0 && ($MAINT_COUNT -gt 0 || $OUTAGE_COUNT -gt 0) ]]; then
read -p "Merge maintenance ($MAINT_COUNT) and outages ($OUTAGE_COUNT) into incidents? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
-- Migrate maintenance windows to incidents
INSERT INTO incidents (title, status, date, node_ids, created_at)
SELECT
'Maintenance: ' || COALESCE(reason, 'Scheduled'),
CASE WHEN end_at IS NULL THEN 'active' ELSE 'resolved' END,
datetime(start_at, 'unixepoch'),
'',
COALESCE(start_at, strftime('%s', 'now'))
FROM maintenance;
-- Migrate outages to incidents
INSERT INTO incidents (title, status, date, node_ids, created_at)
SELECT
COALESCE(description, 'Service Outage'),
CASE
WHEN status = 'resolved' THEN 'resolved'
WHEN end_at = '' THEN 'investigating'
ELSE 'monitoring'
END,
start_at,
node_id,
COALESCE(strftime('%s', start_at), strftime('%s', 'now'))
FROM outages;
DROP TABLE IF EXISTS maintenance;
DROP TABLE IF EXISTS outages;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Merged into incidents"
else
echo "Skipped status table merge"
fi
else
echo "Skipped (incidents table has data or no maintenance/outage data)"
fi
echo ""
echo "=== Phase 3: Vacuum and Optimize ==="
sqlite3 "$DB" <<'EOF'
VACUUM;
ANALYZE;
EOF
echo "✓ Database optimized"
echo ""
echo "=== Final State ==="
sqlite3 "$DB" ".tables"
echo ""
echo "=== Remaining Tables ==="
cat <<'TABLEDOCS'
CORE TABLES:
- pops: 21 regional POPs (source of truth)
- telemetry: 676k+ metrics records (operational data)
- uptime: Daily status per POP (status page)
- uptime_daily: Aggregated daily uptime (351 records)
- uptime_spans: Continuous uptime spans (66 records)
- domain_scopes: 634 domain mappings (feature in use)
- maintenance: Maintenance windows (149 records) OR merged
- outages: Outage records (1 record) OR merged
- incidents: Consolidated incidents
- incident_updates: Incident timeline
- accounts: 2 accounts (evaluate if needed)
DROPPED:
- login_codes - ephemeral verification codes
- sessions - ephemeral web sessions
- vaults - obsolete hosted vault feature
TABLEDOCS
echo ""
echo "=== Done ==="
echo "Backup: $BACKUP"
echo ""
echo "To restore: cp $BACKUP $DB"

Some files were not shown because too many files have changed in this diff Show More