Update all CLAUDE.md files to reference CLAVITOR-AGENT-HANDBOOK.md

Updated 13 CLAUDE.md files across all subprojects:
- Root CLAUDE.md → Section I (Culture)
- clavis-vault/CLAUDE.md → Section V: clavis-vault (Sarah)
- clavis-cli/CLAUDE.md → Section V: clavis-cli (Charles)
- clavis-chrome/firefox/safari/CLAUDE.md → Section V: Browser extensions (James)
- clavis-crypto/CLAUDE.md → Section V: clavis-crypto (Maria)
- clavis-ios/android/CLAUDE.md → Section V: Mobile (James)
- clavis-telemetry/CLAUDE.md → Section V: clavis-telemetry (Hans)
- clavitor.ai/CLAUDE.md → Section V: clavitor.ai/admin (Emma)
- clavitor.ai/admin/CLAUDE.md → Section V: clavitor.ai/admin (Emma)
- clavis-vault/edition/CLAUDE.md → Section V: clavis-vault (Sarah)

All references now point to the 5-section handbook structure.
This commit is contained in:
James 2026-04-08 15:24:51 -04:00
parent 44aa3df859
commit 9860a679d4
94 changed files with 7197 additions and 2578 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
._CLAVITOR-PRINCIPLES.md Normal file

Binary file not shown.

View File

@ -1,5 +1,7 @@
# Clavitor — Agent Instructions
> **Required reading before any work**: [CLAVITOR-AGENT-HANDBOOK.md](CLAVITOR-AGENT-HANDBOOK.md) — Section I (Culture), Section II (Security), and Section III (Workflow). Read end-to-end on first session. Run the Section III → Daily review checklist each morning. Drift gets fixed before any new feature work.
## Foundation First — No Mediocrity. Ever.
The rule is simple: do it right, or say something.

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*

BIN
clavis/.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,13 @@
# clavis-android
> **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: Mobile (this subproject). You are **James**.
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,14 @@
# clavis-chrome
> **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: Browser extensions (this subproject). You are **James**.
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,5 +1,7 @@
# clavis-cli
> **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-cli (this subproject). You are **Charles**. The CLI is its own trust anchor: never bridge to Go for crypto, never expose L2 outside the C/JS boundary.
Pure C CLI for credential access by AI agents. Talks to a Clavitor vault over HTTPS, decrypts L2 fields locally.
## Build

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,18 @@
# clavis-crypto
> **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-crypto (this subproject). You are **Maria**.
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,14 @@
# clavis-firefox
> **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: Browser extensions (this subproject). You are **James**.
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,13 @@
# clavis-ios
> **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: Mobile (this subproject). You are **James**.
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,15 @@
# clavis-safari
> **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: Browser extensions (this subproject). You are **James**.
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,35 @@
# clavis-telemetry
> **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-telemetry (this subproject). You are **Hans**.
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.

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,61 @@
//go:build commercial
package main
import (
"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
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
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 {
// Silent fail - Kuma will detect silence as down
return
}
resp.Body.Close()
}

View File

@ -0,0 +1,400 @@
//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("Failed to open operations.db: %v", err)
}
defer db.Close()
// Load CA chain for mTLS - mandatory, no fallback
if err := loadCA(caChainPath); err != nil {
log.Fatalf("Failed to load CA chain for mTLS: %v", 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)
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)
if flusher, ok := w.(http.Flusher); ok {
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
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
time.Sleep(time.Second)
}
}
func ensureTables() {
// Telemetry table
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 ''
)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_node_latest ON telemetry(node_id, id DESC)`)
// Uptime spans table
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
)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_spans_node_end ON uptime_spans(node_id, end_at DESC)`)
// Maintenance table
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 ''
)`)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
// Check DB writable
var one int
err := db.QueryRow("SELECT 1").Scan(&one)
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) {
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("Invalid 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
db.Exec(`INSERT INTO telemetry (node_id, version, hostname, uptime_seconds, cpu_percent, memory_total_mb, memory_used_mb, disk_total_mb, disk_used_mb, load_1m, vault_count, vault_size_mb, vault_entries, mode) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
// Uptime span tracking
updateSpan(t.NodeID, t.Hostname, t.Version, t.CPUPercent, t.MemUsedMB, t.MemTotalMB, t.DiskUsedMB, t.DiskTotalMB, t.Load1m, t.UptimeSeconds)
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
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
var spanID int64
var spanEnd int64
err := db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, nodeID).Scan(&spanID, &spanEnd)
if err == nil && (inMaint || (now-spanEnd) <= 60) {
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else if err == nil && serverAge < 60 {
log.Printf("SPAN EXTEND node=%s gap=%ds (server up %ds, too early to judge)", nodeID, now-spanEnd, serverAge)
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} 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)
}
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, nodeID, now, now)
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("OUTAGE SPAN ntfy error creating request: %v", 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("OUTAGE SPAN ntfy error sending alert: %v", err)
return
}
resp.Body.Close()
log.Printf("OUTAGE SPAN ntfy alert sent for node=%s", nodeID)
}

View File

@ -0,0 +1,377 @@
//go:build commercial
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"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)
}
}
}
// 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

@ -1,5 +1,7 @@
# Clavis Vault — CLAUDE.md
> **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**. Run the Section III → Daily review checklist every morning. Drift gets fixed before any new feature work.
## Foundation First — No Mediocrity. Ever.
The rule is simple: do it right, or say something.

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

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

@ -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,5 +1,7 @@
# Clavitor Website — clavitor.com
> **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: clavitor.ai/admin (this subproject). You are **Emma**.
## Foundation First — No Mediocrity. Ever.
The rule is simple: do it right, or say something.

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,25 @@
# clavitor.ai/admin — central admin / Paddle integration
> **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: clavitor.ai/admin (this subproject). You are **Emma**.
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.

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

@ -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 — <s>$20</s> $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. <s>$20</s> $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>

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

@ -0,0 +1,134 @@
<!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--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="#" class="nav-link btn btn-ghost">Sign in</a>
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
</div>
</div>
</nav>
<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>
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>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"

167
consolidate-clavitor-db.sh Executable file
View File

@ -0,0 +1,167 @@
#!/bin/bash
# consolidate-clavitor-db.sh
# Consolidates clavitor.db by removing unused tables and migrating data
# Run locally first, then on Zurich server
set -e
DB="${1:-clavitor.db}"
BACKUP="${DB}.backup.$(date +%Y%m%d_%H%M%S)"
echo "=== Clavitor DB Consolidation ==="
echo "Target: $DB"
echo "Backup: $BACKUP"
# Backup first
cp "$DB" "$BACKUP"
echo "✓ Backup created"
# Check current state
echo ""
echo "=== Current Table Stats ==="
sqlite3 "$DB" <<'EOF'
SELECT '--- Row Counts ---';
SELECT 'accounts: ' || COUNT(*) FROM accounts;
SELECT 'domain_scopes: ' || COUNT(*) FROM domain_scopes;
SELECT 'incidents: ' || COUNT(*) FROM incidents;
SELECT 'incident_updates: ' || COUNT(*) FROM incident_updates;
SELECT 'login_codes: ' || COUNT(*) FROM login_codes;
SELECT 'maintenance: ' || COUNT(*) FROM maintenance;
SELECT 'outages: ' || COUNT(*) FROM outages;
SELECT 'pops: ' || COUNT(*) FROM pops;
SELECT 'sessions: ' || COUNT(*) FROM sessions;
SELECT 'telemetry: ' || COUNT(*) FROM telemetry;
SELECT 'uptime: ' || COUNT(*) FROM uptime;
SELECT 'uptime_daily: ' || COUNT(*) FROM uptime_daily;
SELECT 'uptime_spans: ' || COUNT(*) FROM uptime_spans;
SELECT 'vaults: ' || COUNT(*) FROM vaults;
EOF
echo ""
echo "=== Phase 1: Drop Unused Tables ==="
# Tables to drop and why:
# - accounts: moved to corporate.db (2 rows)
# - login_codes: ephemeral, not needed (0 rows)
# - sessions: ephemeral, not needed (3 rows)
# - vaults: hosted vaults obsolete (1 row)
# - domain_scopes: unused feature (0 rows)
# - uptime_daily: unused aggregation (0 rows)
# - uptime_spans: unused aggregation (0 rows)
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS login_codes;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS vaults;
DROP TABLE IF EXISTS domain_scopes;
DROP TABLE IF EXISTS uptime_daily;
DROP TABLE IF EXISTS uptime_spans;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Dropped unused tables"
echo ""
echo "=== Phase 2: Consolidate Status Tables ==="
# Merge maintenance + outages into incidents
# incidents becomes the single status table
sqlite3 "$DB" <<'EOF'
PRAGMA foreign_keys = OFF;
-- Migrate maintenance windows to incidents (if any)
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'),
'', -- no specific nodes
COALESCE(start_at, strftime('%s', 'now'))
FROM maintenance
WHERE EXISTS (SELECT 1 FROM maintenance LIMIT 1);
-- 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
WHERE EXISTS (SELECT 1 FROM outages LIMIT 1);
DROP TABLE IF EXISTS maintenance;
DROP TABLE IF EXISTS outages;
PRAGMA foreign_keys = ON;
EOF
echo "✓ Merged maintenance/outages into incidents"
echo ""
echo "=== Phase 3: Optimize telemetry ==="
# Keep only last 7 days of telemetry, create view for aggregated data
sqlite3 "$DB" <<'EOF'
-- Delete old telemetry (keep 7 days)
DELETE FROM telemetry
WHERE received_at < strftime('%s', 'now', '-7 days');
-- Check if telemetry is actually being used
SELECT CASE
WHEN COUNT(*) = 0 THEN 'WARNING: telemetry table is empty - POPs not reporting?'
ELSE 'OK: ' || COUNT(*) || ' telemetry records (last 7 days)'
END
FROM telemetry;
EOF
echo "✓ Cleaned telemetry"
echo ""
echo "=== Phase 4: Vacuum and Analyze ==="
sqlite3 "$DB" <<'EOF'
VACUUM;
ANALYZE;
EOF
echo "✓ Database optimized"
echo ""
echo "=== Final State ==="
sqlite3 "$DB" ".tables"
echo ""
echo "=== Remaining Tables ==="
cat <<'TABLEDOCS'
CORE TABLES (kept):
- pops: 28 regional POPs (source of truth)
- telemetry: POP metrics (last 7 days)
- uptime: Daily status per POP (for status page)
- incidents: Consolidated incidents + maintenance + outages
- incident_updates: Timeline for incidents
DROPPED:
- accounts, sessions, login_codes, vaults → moved to corporate.db
- domain_scopes → unused feature
- uptime_daily, uptime_spans → unused aggregations
- maintenance, outages → merged into incidents
TABLEDOCS
echo ""
echo "=== Done ==="
echo "Backup: $BACKUP"
echo ""
echo "To verify: sqlite3 $DB '.tables'"

BIN
marketing/.DS_Store vendored

Binary file not shown.

View File

@ -336,6 +336,72 @@ Five nines is achievable because:
- Self-hosted is the free tier. No free hosted.
- 30 days behind on payment → delete. No credit management. No collections.
### Price for Life Guarantee
**Core commitment:** The price a customer pays at signup is their price forever, as long as the subscription remains active. We have never raised prices on existing customers. We never will. We may raise prices for new customers — but never for existing, active subscribers.
**Mechanics:**
- Locked by tier + currency at moment of first successful charge
- Annual renewals charge the same amount, same currency, forever
- Currency exchange fluctuations absorbed by us (not passed to customer) — both directions
- We bear forex risk: if EUR weakens, we lose margin; if EUR strengthens, you don't pay more
- Applies to all supported currencies: USD, EUR, GBP, CHF, and future additions
**Upgrade path:**
- Upgrade to higher tier → pay then-current rate for new tier → <em>that</em> price locks for life
- Downgrade → pay then-current rate for lower tier → lock applies to new rate
- Side-grade (e.g., Team 10 → Team 25) → new tier's current rate locks
**The catch (intentional):**
- Cancel → forfeit lock forever
- Re-subscribe later → pay then-current published rate
- This creates mutual incentive: we keep you happy, you keep subscribing
**Competitive positioning (for marketing only — not legal docs):**
- 1Password raised prices ~25% on existing customers (2023-2024)
- Bitwarden raised prices ~20% on existing customers (2024)
- LastPass... well, LastPass had other issues
- We raise prices only for new customers — existing subscribers are protected forever
- Note: Competitor references belong in marketing materials only, never in Terms or legal documents
**Economics:**
We architected costs to decline over time (infrastructure efficiency, scale). Customer lifetime value rises as cost-to-serve falls. Price increases are a choice, not a necessity. We choose never.
### Cancellation, Auto-Renewal & Payment Failure Policy
**Auto-renewal is default:**
- Protects against accidental vault deletion from expired cards
- Annual plans: 14-day "regret period" after auto-renewal (full refund, no questions)
- Monthly plans: cancel before next charge
- Can be disabled anytime; vault stays active until period ends
**User-initiated cancellation:**
- Takes effect at the *start of next billing period* (not immediate)
- When effective: vaults deleted *instantly and permanently* from active systems
- 7-day warning email before deletion — final chance to export
- No grace period, no trash bin, no reversal
- 14-day regret period (Section 6) does NOT apply to cancellations — only auto-renewals
**Failed payment & 14-day retry window:**
- Day 0: Payment fails, email sent, vault active
- Days 1-14: Automatic retries by Paddle; update payment method anytime
- Day 15: Enter cancellation status; 7-day warning sent
- Day 22: Vaults deleted per Section 10
**Compliance backups:**
- Retained for maximum 30 days after deletion
- Exist solely for legal/regulatory compliance
- **NOT available for restoration** — this is a legal firewall
- After 30 days: permanently destroyed
**Reactivation is new:**
- Deleted vaults cannot be restored, even within 30-day backup window
- Start fresh with empty vault
- 14-day regret period does not apply to post-deletion reactivation
**Self-hosted exception:**
Community Edition users manage their own data. This policy applies only to hosted service.
### Three axes defending tier boundaries
- **Tokens** — agents + humans per vault
@ -348,11 +414,11 @@ Self-hosted: **Free forever.** Full product. Unlimited everything. Elastic Licen
#### Individual tiers (annual only, Paddle)
| Plan | Vaults | Tokens/vault | Devices | Annual |
|------|--------|-------------|---------|--------|
| Personal | 1 | 5 | 2 | $12/yr |
| Family | 1 | 15 | 6 | $29/yr |
| Pro | 1 | 50 | unlimited | $49/yr |
| Plan | People | Vaults | Agents | Annual | Trial |
|------|--------|--------|--------|--------|-------|
| Personal | 1 | 1 | 5 | $12/yr | 7 days |
| Family | ~6 | 1 | 15 | $24/yr | 7 days |
| Pro | 1 | 1 | 50 | $49/yr | 7 days |
#### Team tiers (annual or monthly, Paddle or invoice)
@ -360,13 +426,13 @@ Monthly = annual / 10 (12 months for the price of 10).
| Plan | Vaults | Tokens/vault | Devices | Annual | Monthly | Eff. $/user/yr |
|------|--------|-------------|---------|--------|---------|---------------|
| Team 10 | 11 | unlimited | unlimited | $249/yr | $24.90/mo | $22.64 |
| Team 25 | 26 | unlimited | unlimited | $499/yr | $49.90/mo | $19.19 |
| Team 100 | 101 | unlimited | unlimited | $1,499/yr | $149.90/mo | $14.84 |
| Team 250 | 251 | unlimited | unlimited | $2,999/yr | $299.90/mo | $11.95 |
| Team 500 | 501 | unlimited | unlimited | $4,999/yr | $499.90/mo | $9.98 |
| Team 10 | 10 | 11 (10+1) | 100 | $249/yr | &mdash; | $24.90 |
| Team 25 | 25 | 26 (25+1) | 250 | $499/yr | &mdash; | $19.96 |
| Team 100 | 100 | 101 (100+1) | 1,000 | $1,499/yr | $149/mo | $14.99 |
| Team 250 | 250 | 251 (250+1) | 2,500 | $2,999/yr | $299/mo | $11.99 |
| Team 500 | 500 | 501 (500+1) | 5,000 | $4,999/yr | $499/mo | $9.99 |
Vault count = employees + 1 (company vault).
Vault count = employees + 1 (company vault). Agents scale with seats — 10 per person on Team plans.
#### Enterprise tiers (annual, direct invoice)

Binary file not shown.

View File

@ -0,0 +1,5 @@
module clavitor.ai/operations/currency-sync
go 1.26.1
require github.com/mattn/go-sqlite3 v1.14.41

View File

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA=
github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=

View File

@ -0,0 +1,272 @@
// currency-sync - Fetches currency data from authoritative sources
// and updates the currencies table in corporate DB.
//
// Authoritative sources:
// - exchangerate-api.com: currency codes and exchange rates
// - Unicode CLDR: currency names (English, tracks ISO 4217), decimal places, symbols
//
// Usage: currency-sync -db /path/to/corporate.db
// Run on demand only - a few times per year.
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
const (
// exchangerate-api.com - provides rates and currency codes
apiKey = "62eb374484119e26faabcfda"
apiBaseURL = "https://v6.exchangerate-api.com/v6/%s/latest/USD"
// Unicode CLDR - maintained by Unicode Consortium (standards body)
// Tracks ISO 4217 currency codes, provides localized names and decimals
cldrNamesURL = "https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-numbers-full/main/en/currencies.json"
cldrFractionsURL = "https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-core/supplemental/currencyData.json"
)
// APIResponse from exchangerate-api.com
type APIResponse struct {
Result string `json:"result"`
TimeLastUpdateUnix int64 `json:"time_last_update_unix"`
TimeLastUpdateUTC string `json:"time_last_update_utc"`
BaseCode string `json:"base_code"`
ConversionRates map[string]float64 `json:"conversion_rates"`
}
// CLDRNamesData from Unicode CLDR (currency names and symbols)
type CLDRNamesData struct {
Main struct {
En struct {
Numbers struct {
Currencies map[string]struct {
DisplayName string `json:"displayName"`
Symbol string `json:"symbol"`
} `json:"currencies"`
} `json:"numbers"`
} `json:"en"`
} `json:"main"`
}
// CLDRFractionsData from Unicode CLDR (decimal places)
type CLDRFractionsData struct {
Supplemental struct {
CurrencyData struct {
Fractions map[string]struct {
Digits string `json:"_digits"`
} `json:"fractions"`
} `json:"currencyData"`
} `json:"supplemental"`
}
func main() {
var (
dbPath = flag.String("db", "", "Path to corporate.db (required)")
dryRun = flag.Bool("dry-run", false, "Show what would be updated without making changes")
)
flag.Parse()
if *dbPath == "" {
fmt.Fprintln(os.Stderr, "Error: -db flag is required")
fmt.Fprintln(os.Stderr, "Usage: currency-sync -db /path/to/corporate.db")
os.Exit(1)
}
// Step 1: Fetch exchange rates
log.Println("Fetching exchange rates from exchangerate-api.com...")
apiURL := fmt.Sprintf(apiBaseURL, apiKey)
resp, err := http.Get(apiURL)
if err != nil {
log.Fatalf("Failed to fetch rates: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
log.Fatalf("API error: HTTP %d - %s", resp.StatusCode, string(body))
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
log.Fatalf("Failed to decode API response: %v", err)
}
if apiResp.Result != "success" {
log.Fatalf("API returned error result")
}
log.Printf("Rates fetched: base=%s, last_update=%s, currencies=%d",
apiResp.BaseCode, apiResp.TimeLastUpdateUTC, len(apiResp.ConversionRates))
// Step 2: Fetch CLDR names and symbols
log.Println("Fetching currency names from Unicode CLDR (English locale)...")
cldrNamesResp, err := http.Get(cldrNamesURL)
if err != nil {
log.Fatalf("Failed to fetch CLDR names: %v", err)
}
defer cldrNamesResp.Body.Close()
if cldrNamesResp.StatusCode != 200 {
log.Fatalf("CLDR names fetch failed: HTTP %d", cldrNamesResp.StatusCode)
}
var cldrNamesData CLDRNamesData
if err := json.NewDecoder(cldrNamesResp.Body).Decode(&cldrNamesData); err != nil {
log.Fatalf("Failed to decode CLDR names: %v", err)
}
names := cldrNamesData.Main.En.Numbers.Currencies
log.Printf("CLDR names loaded: %d currencies", len(names))
// Step 3: Fetch CLDR decimal data
log.Println("Fetching currency decimals from Unicode CLDR...")
cldrFracResp, err := http.Get(cldrFractionsURL)
if err != nil {
log.Fatalf("Failed to fetch CLDR fractions: %v", err)
}
defer cldrFracResp.Body.Close()
if cldrFracResp.StatusCode != 200 {
log.Fatalf("CLDR fractions fetch failed: HTTP %d", cldrFracResp.StatusCode)
}
var cldrFracData CLDRFractionsData
if err := json.NewDecoder(cldrFracResp.Body).Decode(&cldrFracData); err != nil {
log.Fatalf("Failed to decode CLDR fractions: %v", err)
}
fractions := cldrFracData.Supplemental.CurrencyData.Fractions
log.Printf("CLDR fractions loaded: %d entries", len(fractions))
// Step 4: Connect to database
db, err := sql.Open("sqlite3", *dbPath)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Ensure table exists
if err := ensureTable(db); err != nil {
log.Fatalf("Failed to ensure table: %v", err)
}
// Step 5: Process currencies
now := time.Now().Unix()
updated := 0
inserted := 0
defaultDecimals := 2
for code, rate := range apiResp.ConversionRates {
// Skip invalid rates
if rate <= 0 {
continue
}
// Get name and symbol from CLDR
name := ""
symbol := ""
if n, ok := names[code]; ok {
name = n.DisplayName
symbol = n.Symbol
}
// Get decimals from CLDR fractions
decimals := defaultDecimals
if frac, ok := fractions[code]; ok {
if d, err := strconv.Atoi(frac.Digits); err == nil {
decimals = d
}
} else if frac, ok := fractions["DEFAULT"]; ok {
if d, err := strconv.Atoi(frac.Digits); err == nil {
decimals = d
}
}
if *dryRun {
log.Printf("[DRY-RUN] %s: name=%q, symbol=%q, rate=%.6f, decimals=%d",
code, name, symbol, rate, decimals)
continue
}
// Check if currency exists
var exists int
err := db.QueryRow("SELECT COUNT(*) FROM currencies WHERE code = ?", code).Scan(&exists)
if err != nil {
log.Printf("Warning: failed to check %s: %v", code, err)
continue
}
if exists > 0 {
// Update existing
_, err = db.Exec(`
UPDATE currencies SET
name = ?,
symbol = ?,
exchange_rate = ?,
rate_fetched_at = ?,
decimals = ?,
updated_at = ?
WHERE code = ?
`, name, symbol, rate, now, decimals, now, code)
if err != nil {
log.Printf("Warning: failed to update %s: %v", code, err)
continue
}
updated++
} else {
// Insert new - pretty_pattern is NULL (set by application)
_, err = db.Exec(`
INSERT INTO currencies (
code, name, decimals, exchange_rate, rate_fetched_at,
symbol, pretty_pattern, is_active,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, code, name, decimals, rate, now,
symbol, nil, 1,
now, now)
if err != nil {
log.Printf("Warning: failed to insert %s: %v", code, err)
continue
}
inserted++
}
}
if *dryRun {
log.Printf("[DRY-RUN] Would process %d currencies", len(apiResp.ConversionRates))
} else {
log.Printf("Complete: %d updated, %d inserted, %d total",
updated, inserted, len(apiResp.ConversionRates))
log.Printf("Base: USD=1.000000, last_update=%s", apiResp.TimeLastUpdateUTC)
log.Printf("Sources: exchangerate-api.com (rates), Unicode CLDR (names/decimals/symbols)")
}
}
func ensureTable(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS currencies (
code TEXT PRIMARY KEY,
name TEXT, -- From Unicode CLDR (English, tracks ISO 4217)
decimals INTEGER NOT NULL DEFAULT 2, -- From Unicode CLDR
exchange_rate REAL, -- From exchangerate-api.com
rate_fetched_at INTEGER, -- From exchangerate-api.com
symbol TEXT, -- From Unicode CLDR
pretty_pattern TEXT, -- Set by application (e.g., "x9", "x0", "x000")
is_active INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER
)
`)
return err
}

BIN
operations/pop-sync/pop-example Executable file

Binary file not shown.

Binary file not shown.

50
verify-db-consolidation.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/bash
# verify-db-consolidation.sh
# Quick check of consolidated database
DB="${1:-clavitor.ai/clavitor.db}"
echo "=== Clavitor DB Verification ==="
echo "Database: $DB"
echo ""
# Check tables exist
echo "Tables present:"
sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
echo ""
echo "=== Core Data ==="
# POPs
echo ""
echo "POPs (source of truth):"
sqlite3 "$DB" "SELECT status, COUNT(*) FROM pops GROUP BY status;"
# Recent telemetry
echo ""
echo "Telemetry (last 24h):"
sqlite3 "$DB" "SELECT node_id, COUNT(*), MAX(received_at)
FROM telemetry
WHERE received_at > strftime('%s', 'now', '-1 day')
GROUP BY node_id;"
# Uptime summary
echo ""
echo "Uptime records (last 7 days):"
sqlite3 "$DB" "SELECT node_id, COUNT(*), MIN(date), MAX(date)
FROM uptime
WHERE date > date('now', '-7 days')
GROUP BY node_id
ORDER BY node_id;"
# Incidents
echo ""
echo "Incidents:"
sqlite3 "$DB" "SELECT status, COUNT(*) FROM incidents GROUP BY status;"
echo ""
echo "=== Size ==="
ls -lh "$DB"
echo ""
echo "=== OK ==="