chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-03-20 06:02:37 -04:00
parent 9694a30f6c
commit 041227dfd7
20 changed files with 2435 additions and 3 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ vault1984
*.pem
oss/app/backups/
commercial/website/vault1984-web
commercial/account/vault1984-account

View File

@ -0,0 +1,802 @@
/* vault1984 account — 2027 */
:root {
--bg: #060a10;
--bg-subtle: #0a1018;
--surface: rgba(255,255,255,0.03);
--surface-hover: rgba(255,255,255,0.06);
--surface-active: rgba(255,255,255,0.08);
--glass: rgba(12,18,30,0.7);
--glass-border: rgba(255,255,255,0.06);
--glass-border-hover: rgba(255,255,255,0.12);
--text: #f1f5f9;
--text-secondary: #b0bec5;
--text-tertiary: #8a9bb0;
--accent: #4ade80;
--accent-dim: rgba(74,222,128,0.12);
--accent-glow: rgba(74,222,128,0.25);
--gold: #D4AF37;
--gold-dim: rgba(212,175,55,0.12);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.12);
--radius: 1rem;
--radius-sm: 0.625rem;
--radius-xs: 0.375rem;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* ── Ambient background ── */
.ambient {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.ambient::before {
content: '';
position: absolute;
width: 800px;
height: 800px;
top: -200px;
right: -200px;
background: radial-gradient(circle, rgba(74,222,128,0.04) 0%, transparent 70%);
animation: drift 25s ease-in-out infinite alternate;
}
.ambient::after {
content: '';
position: absolute;
width: 600px;
height: 600px;
bottom: -100px;
left: -150px;
background: radial-gradient(circle, rgba(212,175,55,0.03) 0%, transparent 70%);
animation: drift 30s ease-in-out infinite alternate-reverse;
}
@keyframes drift {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(60px, 40px) scale(1.1); }
}
/* ── Grid lines (subtle background texture) ── */
.gridlines {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 80px 80px;
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%);
}
/* ── Layout ── */
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 1.5rem;
}
.container-narrow {
max-width: 480px;
}
/* ── Topbar ── */
.topbar {
position: sticky;
top: 0;
z-index: 100;
padding: 0.875rem 0;
background: rgba(6,10,16,0.8);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border-bottom: 1px solid var(--glass-border);
}
.topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar-brand {
font-family: var(--mono);
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
text-decoration: none;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.topbar-brand .port {
color: var(--text-tertiary);
font-weight: 400;
}
.topbar-nav {
display: flex;
align-items: center;
gap: 0.25rem;
}
.topbar-nav a {
font-size: 0.8125rem;
color: var(--text-secondary);
text-decoration: none;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-xs);
transition: all 0.2s var(--ease);
}
.topbar-nav a:hover { color: var(--text); background: var(--surface-hover); }
.topbar-nav a.active { color: var(--text); background: var(--surface-active); }
.topbar-nav .separator {
width: 1px;
height: 1rem;
background: var(--glass-border);
margin: 0 0.375rem;
}
/* ── Typography ── */
h1 {
font-size: clamp(1.75rem, 3.5vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.15;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
}
h3 {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.label {
font-family: var(--mono);
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.label.accent { color: var(--accent); }
.label.gold { color: var(--gold); }
.label.red { color: var(--red); }
.mono { font-family: var(--mono); }
.text-secondary { color: var(--text-secondary); }
.text-accent { color: var(--accent); }
.text-gold { color: var(--gold); }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 550;
padding: 0.6875rem 1.375rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
text-decoration: none;
transition: all 0.25s var(--ease);
outline: none;
position: relative;
overflow: hidden;
}
.btn:focus-visible {
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
}
.btn-primary {
background: var(--accent);
color: #0a0f14;
}
.btn-primary:hover {
background: #6ee7a0;
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: var(--surface);
color: var(--text-secondary);
border: 1px solid var(--glass-border);
}
.btn-ghost:hover {
color: var(--text);
background: var(--surface-hover);
border-color: var(--glass-border-hover);
}
.btn-danger {
background: var(--red-dim);
color: var(--red);
border: 1px solid rgba(239,68,68,0.15);
}
.btn-danger:hover {
background: rgba(239,68,68,0.18);
border-color: rgba(239,68,68,0.3);
}
.btn-block { width: 100%; }
.btn-lg {
padding: 0.875rem 1.75rem;
font-size: 0.9375rem;
}
.btn .icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
/* ── Cards ── */
.card {
background: var(--surface);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
padding: 1.5rem;
transition: all 0.3s var(--ease);
}
.card-interactive {
cursor: pointer;
}
.card-interactive:hover {
background: var(--surface-hover);
border-color: var(--glass-border-hover);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.card-interactive:active { transform: translateY(0); }
.card-glow:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px var(--accent-dim);
}
.card-gold {
border-color: rgba(212,175,55,0.15);
}
.card-gold:hover {
border-color: rgba(212,175,55,0.3);
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px var(--gold-dim);
}
/* ── Glass panels ── */
.glass {
background: var(--glass);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
/* ── Forms ── */
.field { margin-bottom: 1.25rem; }
.field label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.input {
width: 100%;
font-family: var(--font);
font-size: 0.9375rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--text);
outline: none;
transition: all 0.2s var(--ease);
}
.input::placeholder { color: var(--text-secondary); }
.input:hover { border-color: var(--glass-border-hover); }
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.input-mono {
font-family: var(--mono);
font-size: 0.875rem;
letter-spacing: 0.02em;
}
/* ── Status indicators ── */
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.live {
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
animation: pulse-live 2s ease-in-out infinite;
}
.status-dot.expired {
background: var(--red);
box-shadow: 0 0 8px rgba(239,68,68,0.3);
}
.status-dot.pending {
background: var(--gold);
box-shadow: 0 0 8px rgba(212,175,55,0.3);
animation: pulse-live 2s ease-in-out infinite;
}
@keyframes pulse-live {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Region cards ── */
.region-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.region-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.125rem;
}
.region-card .region-name {
font-weight: 600;
font-size: 0.9375rem;
}
.region-card .region-location {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.region-card .region-flag {
font-size: 1.25rem;
line-height: 1;
}
.region-card.selected {
border-color: var(--accent);
background: var(--accent-dim);
box-shadow: 0 0 0 1px var(--accent-dim);
}
.region-card.hq {
border-color: rgba(212,175,55,0.2);
}
.region-card.hq .label { color: var(--gold); }
/* ── Vault list ── */
.vault-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--glass-border);
transition: background 0.15s var(--ease);
}
.vault-row:last-child { border-bottom: none; }
.vault-row:hover { background: var(--surface-hover); }
.vault-id {
font-family: var(--mono);
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.vault-region {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.vault-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
/* ── Dividers ── */
.divider {
border: none;
height: 1px;
background: var(--glass-border);
margin: 2rem 0;
}
/* ── Section spacing ── */
.section {
padding: 3rem 0;
}
.section-header {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
/* ── Login page ── */
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header .brand {
font-family: var(--mono);
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.login-header p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.login-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: var(--text-secondary);
font-size: 0.75rem;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--glass-border);
}
.login-footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.login-footer a {
color: var(--accent);
text-decoration: none;
}
.login-footer a:hover { text-decoration: underline; }
/* ── Checkout ── */
.checkout-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.checkout-card {
width: 100%;
max-width: 480px;
padding: 2.5rem;
}
.price-display {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin: 1rem 0;
}
.price-amount {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text);
}
.price-period {
font-size: 1rem;
color: var(--text-secondary);
}
.price-note {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.feature-list {
list-style: none;
margin: 1.5rem 0;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.375rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.feature-list .check {
color: var(--accent);
font-size: 0.75rem;
flex-shrink: 0;
}
/* ── Settings ── */
.settings-section {
padding: 1.5rem 0;
}
.settings-section + .settings-section {
border-top: 1px solid var(--glass-border);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.settings-label {
font-size: 0.9375rem;
font-weight: 500;
}
.settings-desc {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* ── Danger zone ── */
.danger-zone {
border: 1px solid rgba(239,68,68,0.15);
border-radius: var(--radius);
padding: 1.5rem;
margin-top: 1rem;
}
.danger-zone h3 {
color: var(--red);
margin-bottom: 0.5rem;
}
.danger-zone p {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
/* ── Passkey button ── */
.btn-passkey {
background: linear-gradient(135deg, rgba(74,222,128,0.1) 0%, rgba(74,222,128,0.05) 100%);
color: var(--accent);
border: 1px solid rgba(74,222,128,0.2);
font-weight: 600;
}
.btn-passkey:hover {
background: linear-gradient(135deg, rgba(74,222,128,0.15) 0%, rgba(74,222,128,0.08) 100%);
border-color: rgba(74,222,128,0.35);
box-shadow: 0 4px 20px rgba(74,222,128,0.1);
transform: translateY(-1px);
}
.btn-passkey:active { transform: translateY(0); }
.passkey-icon {
width: 1.25rem;
height: 1.25rem;
}
/* ── Toast / notification ── */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.875rem 1.25rem;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 500;
transform: translateY(120%);
opacity: 0;
transition: all 0.35s var(--ease);
z-index: 200;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
background: var(--accent-dim);
color: var(--accent);
border: 1px solid rgba(74,222,128,0.2);
}
.toast.error {
background: var(--red-dim);
color: var(--red);
border: 1px solid rgba(239,68,68,0.2);
}
/* ── Skeleton loading ── */
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-xs);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Responsive ── */
@media (max-width: 640px) {
.container { padding: 0 1rem; }
.login-card, .checkout-card { padding: 1.75rem; }
.region-grid { grid-template-columns: 1fr; }
.vault-row { flex-wrap: wrap; }
.vault-meta { margin-left: 0; width: 100%; justify-content: flex-start; }
.settings-row { flex-direction: column; align-items: flex-start; gap: 0.75rem; }
.topbar-nav { gap: 0; }
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.12); }
/* ── Selection ── */
::selection { background: var(--accent-dim); color: var(--text); }
/* ── Focus management ── */
:focus:not(:focus-visible) { outline: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ── Transitions for page content ── */
.fade-in {
animation: fadeIn 0.4s var(--ease-out) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-delay-1 { animation-delay: 0.05s; }
.fade-in-delay-2 { animation-delay: 0.1s; }
.fade-in-delay-3 { animation-delay: 0.15s; }

Binary file not shown.

Binary file not shown.

Binary file not shown.

306
commercial/account/api.go Normal file
View File

@ -0,0 +1,306 @@
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"strings"
"time"
)
func randomToken(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
}
func jsonResp(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
jsonResp(w, status, map[string]string{"error": msg})
}
// Get email from session cookie
func authEmail(r *http.Request) string {
c, err := r.Cookie("v1984_session")
if err != nil {
return ""
}
email, err := sessionGet(c.Value)
if err != nil {
return ""
}
return email
}
func setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "v1984_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "v1984_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// POST /api/auth/email — send login code (stubbed: always 123456)
func apiAuthEmail(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
jsonErr(w, 400, "email required")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
// Auto-create account if it doesn't exist
exists, _, _ := accountGet(email)
if !exists {
if err := accountCreate(email); err != nil {
log.Printf("account auto-create error: %v", err)
jsonErr(w, 500, "internal error")
return
}
log.Printf("account auto-created: %s", email)
}
// Store code (stub: always 123456)
code := "123456"
if err := loginCodeSet(email, code); err != nil {
log.Printf("login code error: %v", err)
jsonErr(w, 500, "internal error")
return
}
log.Printf("login code for %s: %s (stub — not emailed)", email, code)
jsonResp(w, 200, map[string]string{"status": "sent"})
}
// POST /api/auth/verify — verify login code
func apiAuthVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
var req struct {
Email string `json:"email"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" || req.Code == "" {
jsonErr(w, 400, "email and code required")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
ok, err := loginCodeVerify(email, req.Code)
if err != nil || !ok {
jsonErr(w, 401, "invalid or expired code")
return
}
token, err := sessionCreate(email)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
setSessionCookie(w, token)
jsonResp(w, 200, map[string]string{"status": "ok"})
}
// POST /api/auth/logout
func apiAuthLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("v1984_session"); err == nil {
sessionDelete(c.Value)
}
clearSessionCookie(w)
jsonResp(w, 200, map[string]string{"status": "ok"})
}
// POST /api/checkout — create account (stub: no Stripe yet)
func apiCheckout(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
jsonErr(w, 400, "email required")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
// Create account (idempotent)
if err := accountCreate(email); err != nil {
log.Printf("account create error: %v", err)
jsonErr(w, 500, "internal error")
return
}
// Auto-login after checkout (stub — real flow goes through Stripe first)
token, err := sessionCreate(email)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
setSessionCookie(w, token)
log.Printf("account created (stub): %s", email)
// In production: return Stripe checkout URL
// For now: redirect to regions page
jsonResp(w, 200, map[string]string{"url": basePath + "/regions"})
}
// GET /api/vaults — list vaults for authenticated user
func apiVaults(w http.ResponseWriter, r *http.Request) {
email := authEmail(r)
if email == "" {
jsonErr(w, 401, "not authenticated")
return
}
vaults, err := vaultList(email)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
if vaults == nil {
vaults = []Vault{}
}
count, _ := vaultCount(email)
jsonResp(w, 200, map[string]any{
"email": email,
"vaults": vaults,
"count": count,
"max": 1, // consumer: 1 vault
})
}
// POST /api/vault/create
func apiVaultCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
email := authEmail(r)
if email == "" {
jsonErr(w, 401, "not authenticated")
return
}
var req struct {
VaultID string `json:"vault_id"`
Region string `json:"region"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Region == "" {
jsonErr(w, 400, "region required")
return
}
// Check capacity
count, _ := vaultCount(email)
if count >= 1 {
jsonErr(w, 409, "vault limit reached")
return
}
// Generate vault ID if not provided (normally comes from PRF on the POP)
vaultID := req.VaultID
if vaultID == "" {
vaultID = randomToken(4)[:6] // 6 chars, stub
}
if err := vaultCreate(vaultID, email, req.Region); err != nil {
log.Printf("vault create error: %v", err)
jsonErr(w, 500, "internal error")
return
}
log.Printf("vault created: %s for %s in %s", vaultID, email, req.Region)
jsonResp(w, 201, map[string]string{
"vault_id": vaultID,
"region": req.Region,
"expires_at": time.Now().AddDate(1, 0, 0).UTC().Format(time.RFC3339),
})
}
// POST /api/vault/{id}/delete
func apiVaultDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
email := authEmail(r)
if email == "" {
jsonErr(w, 401, "not authenticated")
return
}
// Extract vault ID from path: /api/vault/{id}/delete
parts := strings.Split(r.URL.Path, "/")
var vaultID string
for i, p := range parts {
if p == "vault" && i+1 < len(parts) {
vaultID = parts[i+1]
break
}
}
if vaultID == "" {
jsonErr(w, 400, "vault_id required")
return
}
if err := vaultDelete(vaultID, email); err != nil {
jsonErr(w, 404, "vault not found")
return
}
log.Printf("vault deleted: %s for %s", vaultID, email)
jsonResp(w, 200, map[string]string{"status": "deleted"})
}
// GET /api/account — account info
func apiAccount(w http.ResponseWriter, r *http.Request) {
email := authEmail(r)
if email == "" {
jsonErr(w, 401, "not authenticated")
return
}
exists, stripeID, _ := accountGet(email)
if !exists {
jsonErr(w, 404, "account not found")
return
}
count, _ := vaultCount(email)
jsonResp(w, 200, map[string]any{
"email": email,
"stripe_id": stripeID,
"vaults": count,
"max": 1,
})
}

192
commercial/account/db.go Normal file
View File

@ -0,0 +1,192 @@
package main
import (
"database/sql"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
var db *sql.DB
func initDB(path string) {
var err error
db, err = sql.Open("sqlite3", path+"?_journal=WAL&_busy_timeout=5000")
if err != nil {
log.Fatalf("db open: %v", err)
}
migrations := []string{
`CREATE TABLE IF NOT EXISTS accounts (
email TEXT PRIMARY KEY,
stripe_id TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS vaults (
vault_id TEXT PRIMARY KEY,
account_email TEXT NOT NULL REFERENCES accounts(email),
region TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
email TEXT NOT NULL REFERENCES accounts(email),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS login_codes (
email TEXT PRIMARY KEY,
code TEXT NOT NULL,
expires_at TEXT NOT NULL
)`,
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil {
log.Fatalf("migration: %v", err)
}
}
log.Println(" database ready")
}
// Accounts
func accountGet(email string) (bool, string, error) {
var stripeID string
err := db.QueryRow("SELECT stripe_id FROM accounts WHERE email = ?", email).Scan(&stripeID)
if err == sql.ErrNoRows {
return false, "", nil
}
if err != nil {
return false, "", err
}
return true, stripeID, nil
}
func accountCreate(email string) error {
_, err := db.Exec("INSERT OR IGNORE INTO accounts (email) VALUES (?)", email)
return err
}
// Vaults
func vaultList(email string) ([]Vault, error) {
rows, err := db.Query(
"SELECT vault_id, region, expires_at, created_at FROM vaults WHERE account_email = ? ORDER BY created_at",
email,
)
if err != nil {
return nil, err
}
defer rows.Close()
var vaults []Vault
for rows.Next() {
var v Vault
if err := rows.Scan(&v.ID, &v.Region, &v.ExpiresAt, &v.CreatedAt); err != nil {
return nil, err
}
vaults = append(vaults, v)
}
return vaults, nil
}
func vaultCount(email string) (int, error) {
var n int
err := db.QueryRow("SELECT COUNT(*) FROM vaults WHERE account_email = ?", email).Scan(&n)
return n, err
}
func vaultCreate(id, email, region string) error {
expires := time.Now().AddDate(1, 0, 0).UTC().Format(time.RFC3339)
_, err := db.Exec(
"INSERT INTO vaults (vault_id, account_email, region, expires_at) VALUES (?, ?, ?, ?)",
id, email, region, expires,
)
return err
}
func vaultDelete(id, email string) error {
res, err := db.Exec("DELETE FROM vaults WHERE vault_id = ? AND account_email = ?", id, email)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// Sessions
func sessionCreate(email string) (string, error) {
token := randomToken(32)
expires := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
_, err := db.Exec(
"INSERT INTO sessions (token, email, expires_at) VALUES (?, ?, ?)",
token, email, expires,
)
return token, err
}
func sessionGet(token string) (string, error) {
var email string
var expires string
err := db.QueryRow("SELECT email, expires_at FROM sessions WHERE token = ?", token).Scan(&email, &expires)
if err != nil {
return "", err
}
t, _ := time.Parse(time.RFC3339, expires)
if time.Now().After(t) {
db.Exec("DELETE FROM sessions WHERE token = ?", token)
return "", sql.ErrNoRows
}
return email, nil
}
func sessionDelete(token string) {
db.Exec("DELETE FROM sessions WHERE token = ?", token)
}
// Login codes
func loginCodeSet(email, code string) error {
expires := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339)
_, err := db.Exec(
"INSERT OR REPLACE INTO login_codes (email, code, expires_at) VALUES (?, ?, ?)",
email, code, expires,
)
return err
}
func loginCodeVerify(email, code string) (bool, error) {
var stored string
var expires string
err := db.QueryRow("SELECT code, expires_at FROM login_codes WHERE email = ?", email).Scan(&stored, &expires)
if err != nil {
return false, nil
}
t, _ := time.Parse(time.RFC3339, expires)
if time.Now().After(t) {
db.Exec("DELETE FROM login_codes WHERE email = ?", email)
return false, nil
}
if stored != code {
return false, nil
}
db.Exec("DELETE FROM login_codes WHERE email = ?", email)
return true, nil
}
// Types
type Vault struct {
ID string `json:"vault_id"`
Region string `json:"region"`
ExpiresAt string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}

View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,5 @@
module vault1984.com/account
go 1.23.6
require github.com/mattn/go-sqlite3 v1.14.37 // indirect

View File

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

202
commercial/account/main.go Normal file
View File

@ -0,0 +1,202 @@
package main
import (
"embed"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
//go:embed templates/*.tmpl account.css favicon.svg
var embedded embed.FS
var devMode bool
var basePath string
type PageData struct {
Page string
Title string
Desc string
ActiveNav string
Base string
Data any
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8098"
}
devMode = os.Getenv("DEV") == "1"
basePath = strings.TrimRight(os.Getenv("BASE_PATH"), "/")
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "account.db"
}
initDB(dbPath)
mux := http.NewServeMux()
// Pages
mux.HandleFunc("/", handleIndex)
mux.HandleFunc("/login", handleLogin)
mux.HandleFunc("/verify", handleVerify)
mux.HandleFunc("/dashboard", handleDashboard)
mux.HandleFunc("/checkout", handleCheckout)
mux.HandleFunc("/regions", handleRegions)
mux.HandleFunc("/settings", handleSettings)
// API
mux.HandleFunc("/api/auth/email", apiAuthEmail)
mux.HandleFunc("/api/auth/verify", apiAuthVerify)
mux.HandleFunc("/api/auth/logout", apiAuthLogout)
mux.HandleFunc("/api/checkout", apiCheckout)
mux.HandleFunc("/api/vaults", apiVaults)
mux.HandleFunc("/api/vault/create", apiVaultCreate)
mux.HandleFunc("/api/vault/", apiVaultDelete) // /api/vault/{id}/delete
mux.HandleFunc("/api/account", apiAccount)
// Static assets
mux.HandleFunc("/account.css", serveStatic("account.css", "text/css"))
mux.HandleFunc("/favicon.svg", serveStatic("favicon.svg", "image/svg+xml"))
// Strip .html extensions
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".html") {
http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, ".html"), http.StatusMovedPermanently)
return
}
mux.ServeHTTP(w, r)
})
log.Printf("vault1984 account · :%s", port)
if devMode {
log.Println(" dev mode: templates reload from disk")
}
log.Fatal(http.ListenAndServe(":"+port, handler))
}
func loadTemplates(page string) (*template.Template, error) {
if devMode {
base := filepath.Join("templates", "base.tmpl")
pg := filepath.Join("templates", page+".tmpl")
return template.ParseFiles(base, pg)
}
return template.ParseFS(embedded, "templates/base.tmpl", "templates/"+page+".tmpl")
}
func render(w http.ResponseWriter, page string, data PageData) {
data.Base = basePath
tmpl, err := loadTemplates(page)
if err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("render error: %v", err)
}
}
func serveStatic(name, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var data []byte
var err error
if devMode {
data, err = os.ReadFile(name)
} else {
data, err = fs.ReadFile(embedded, name)
}
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
// Already logged in? Go to dashboard
if authEmail(r) != "" {
http.Redirect(w, r, basePath+"/dashboard", http.StatusTemporaryRedirect)
return
}
render(w, "login", PageData{
Page: "login",
Title: "Sign in — vault1984",
Desc: "Sign in to your vault1984 account",
})
}
func handleVerify(w http.ResponseWriter, r *http.Request) {
render(w, "verify", PageData{
Page: "verify",
Title: "Enter code — vault1984",
Desc: "Verify your login code",
Data: r.URL.Query().Get("email"),
})
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
if authEmail(r) == "" {
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
return
}
render(w, "dashboard", PageData{
Page: "dashboard",
Title: "Dashboard — vault1984",
Desc: "Manage your vaults",
ActiveNav: "dashboard",
})
}
func handleCheckout(w http.ResponseWriter, r *http.Request) {
render(w, "checkout", PageData{
Page: "checkout",
Title: "Get started — vault1984",
Desc: "Create your vault1984 account",
})
}
func handleRegions(w http.ResponseWriter, r *http.Request) {
if authEmail(r) == "" {
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
return
}
render(w, "regions", PageData{
Page: "regions",
Title: "Choose your region — vault1984",
Desc: "Pick where your vault lives",
ActiveNav: "dashboard",
})
}
func handleSettings(w http.ResponseWriter, r *http.Request) {
if authEmail(r) == "" {
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
return
}
render(w, "settings", PageData{
Page: "settings",
Title: "Settings — vault1984",
Desc: "Account settings",
ActiveNav: "settings",
})
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<meta name="description" content="{{.Desc}}">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="{{.Base}}/favicon.svg" type="image/svg+xml">
<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;550;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{.Base}}/account.css">
</head>
<body>
<div class="ambient"></div>
<div class="gridlines"></div>
<div class="page">
{{template "page" .}}
</div>
{{template "scripts" .}}
</body>
</html>

View File

@ -0,0 +1,107 @@
{{define "page"}}
<div class="checkout-wrapper">
<div class="checkout-card glass fade-in">
<span class="label accent">Get started</span>
<h1 style="margin-top:0.5rem">Your vault awaits</h1>
<div class="price-display">
<span class="price-amount">$12</span>
<span class="price-period">/year</span>
</div>
<div class="price-note">
30-day money-back guarantee. No questions asked.
</div>
<hr class="divider">
<ul class="feature-list">
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
Zero-knowledge encryption — we cannot read your vault
</li>
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
Field-level AI visibility — agents see what you allow
</li>
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
23 global regions — pick where your data lives
</li>
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
WebAuthn PRF — hardware key is the only key
</li>
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
FIPS 140-3 certified cryptography
</li>
<li>
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
MCP, CLI, browser extension, REST API
</li>
</ul>
<hr class="divider">
<form id="checkout-form" onsubmit="return startCheckout(event)">
<div class="field">
<label for="checkout-email">Email address</label>
<input type="email" id="checkout-email" class="input" placeholder="you@example.com" required autocomplete="email" autofocus>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" id="checkout-btn">
Continue to payment
</button>
</form>
<div class="login-footer">
Already have an account? <a href="{{.Base}}/login">Sign in</a>
</div>
<div style="text-align:center;margin-top:1rem">
<span style="font-size:0.75rem;color:var(--text-secondary)">
Payments processed by Stripe. Cancel anytime.
</span>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
async function startCheckout(e) {
e.preventDefault();
const email = document.getElementById('checkout-email').value;
const btn = document.getElementById('checkout-btn');
btn.disabled = true;
btn.textContent = 'Redirecting to Stripe…';
try {
const resp = await fetch('{{.Base}}/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (!resp.ok) throw new Error('Could not start checkout');
const { url } = await resp.json();
window.location.href = url;
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Continue to payment';
}
}
function showToast(msg, type) {
let t = document.querySelector('.toast');
if (!t) {
t = document.createElement('div');
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast ' + type;
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
}
</script>
{{end}}

View File

@ -0,0 +1,165 @@
{{define "page"}}
<header class="topbar">
<div class="container">
<div class="topbar-inner">
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
<nav class="topbar-nav">
<a href="{{.Base}}/dashboard" class="{{if eq .ActiveNav "dashboard"}}active{{end}}">Vaults</a>
<a href="{{.Base}}/settings" class="{{if eq .ActiveNav "settings"}}active{{end}}">Settings</a>
<div class="separator"></div>
<a href="#" id="logout-link">Sign out</a>
</nav>
</div>
</div>
</header>
<main class="container" style="flex:1">
<section class="section fade-in">
<div class="section-header">
<div>
<span class="label accent">Account</span>
<h1 style="margin-top:0.5rem">Your vaults</h1>
</div>
<a href="{{.Base}}/regions" class="btn btn-primary" id="new-vault-btn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New vault
</a>
</div>
</section>
<!-- Vault list (loaded dynamically) -->
<section class="fade-in fade-in-delay-1">
<div class="card" id="vault-list" style="padding:0; overflow:hidden">
<div class="vault-row" style="justify-content:center;color:var(--text-tertiary);padding:2rem">
Loading…
</div>
</div>
</section>
<!-- Plan info -->
<section class="section fade-in fade-in-delay-2">
<div class="card" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div>
<span class="label" style="margin-bottom:0.25rem;display:block">Plan</span>
<div style="font-weight:600;font-size:1.0625rem">Consumer</div>
<div class="text-secondary" style="font-size:0.8125rem;margin-top:0.125rem" id="plan-info">$12/year</div>
</div>
<a href="{{.Base}}/settings" class="btn btn-ghost">Manage billing</a>
</div>
</section>
<!-- Quick stats -->
<section class="fade-in fade-in-delay-3" style="padding-bottom:3rem">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem">
<div class="card" style="text-align:center;padding:1.25rem">
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em" id="stat-vaults">-</div>
<div class="label" style="margin-top:0.375rem">Vaults</div>
</div>
<div class="card" style="text-align:center;padding:1.25rem">
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em;color:var(--accent)" id="stat-days">-</div>
<div class="label" style="margin-top:0.375rem">Days left</div>
</div>
<div class="card" style="text-align:center;padding:1.25rem">
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em" id="stat-regions">-</div>
<div class="label" style="margin-top:0.375rem">Regions</div>
</div>
</div>
</section>
</main>
{{end}}
{{define "scripts"}}
<script>
const BASE = '{{.Base}}';
const REGIONS = {
zurich:'Zürich, Switzerland', virginia:'Virginia, US', sanfrancisco:'San Francisco, US',
montreal:'Montréal, Canada', mexico:'Mexico City, Mexico', bogota:'Bogotá, Colombia',
saopaulo:'São Paulo, Brazil', santiago:'Santiago, Chile', buenosaires:'Buenos Aires, Argentina',
london:'London, UK', madrid:'Madrid, Spain',
istanbul:'Istanbul, Turkey', dubai:'Dubai, UAE', capetown:'Cape Town, South Africa',
mumbai:'Mumbai, India', singapore:'Singapore', hongkong:'Hong Kong',
seoul:'Seoul, South Korea', tokyo:'Tokyo, Japan', sydney:'Sydney, Australia',
almaty:'Almaty, Kazakhstan'
};
async function loadDashboard() {
try {
const resp = await fetch(BASE + '/api/vaults');
if (resp.status === 401) { window.location.href = BASE + '/login'; return; }
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
const list = document.getElementById('vault-list');
const vaults = data.vaults || [];
if (vaults.length === 0) {
list.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;padding:3rem 2rem;gap:1rem;text-align:center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<h2 style="color:var(--text)">You have no vaults yet</h2>
<p style="color:var(--text-secondary);max-width:320px;font-size:0.9375rem;line-height:1.6">
Pick a region and create your first vault. Your data stays in that region — encrypted, zero-knowledge, yours.
</p>
<a href="${BASE}/regions" class="btn btn-primary btn-lg" style="margin-top:0.5rem">
Choose a region and create your vault
</a>
</div>`;
} else {
list.innerHTML = vaults.map(v => {
const expires = new Date(v.expires_at);
const days = Math.ceil((expires - new Date()) / 86400000);
const isHQ = v.region === 'zurich';
const status = days > 0 ? 'live' : 'expired';
const regionName = REGIONS[v.region] || v.region;
return `
<div class="vault-row">
<div style="display:flex;align-items:center;gap:0.75rem">
<span class="status-dot ${status}"></span>
<div>
<div class="vault-id">vault1984-${v.vault_id}</div>
<div class="vault-region">${regionName}</div>
</div>
</div>
<div class="vault-meta">
${isHQ ? '<span class="label gold">HQ</span>' : ''}
<span>${days > 0 ? 'Expires' : 'Expired'} ${expires.toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}</span>
</div>
</div>`;
}).join('');
}
// Stats
document.getElementById('stat-vaults').textContent = vaults.length;
const regions = new Set(vaults.map(v => v.region));
document.getElementById('stat-regions').textContent = regions.size;
if (vaults.length > 0) {
const earliest = vaults.map(v => new Date(v.expires_at)).sort((a,b) => a-b)[0];
const days = Math.max(0, Math.ceil((earliest - new Date()) / 86400000));
document.getElementById('stat-days').textContent = days;
} else {
document.getElementById('stat-days').textContent = '-';
}
// Hide "New vault" if at max
if (data.count >= data.max) {
document.getElementById('new-vault-btn').style.display = 'none';
}
} catch (err) {
document.getElementById('vault-list').innerHTML =
'<div class="vault-row" style="justify-content:center;color:var(--red)">Failed to load vaults</div>';
}
}
document.getElementById('logout-link')?.addEventListener('click', async (e) => {
e.preventDefault();
await fetch(BASE + '/api/auth/logout', { method: 'POST' });
window.location.href = BASE + '/login';
});
loadDashboard();
</script>
{{end}}

View File

@ -0,0 +1,99 @@
{{define "page"}}
<div class="login-wrapper">
<div class="login-card glass fade-in">
<div class="login-header">
<div class="brand">vault1984</div>
<p>Sign in to manage your vaults</p>
</div>
<button class="btn btn-passkey btn-block btn-lg" onclick="passkeyLogin()">
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/>
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>
</svg>
Sign in with passkey
</button>
<div class="login-divider">or</div>
<form id="email-form" onsubmit="return emailLogin(event)">
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" class="input" placeholder="you@example.com" required autocomplete="email" autofocus>
</div>
<button type="submit" class="btn btn-ghost btn-block">
Continue with email
</button>
</form>
<div class="login-footer">
No account? <a href="{{.Base}}/checkout">Get started</a>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
async function passkeyLogin() {
const btn = document.querySelector('.btn-passkey');
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span style="opacity:0.6">Waiting for authenticator…</span>';
try {
// WebAuthn authentication
const resp = await fetch('{{.Base}}/api/auth/passkey/options', { method: 'POST' });
if (!resp.ok) throw new Error('Server error');
const options = await resp.json();
// TODO: navigator.credentials.get() with options
// For now, simulate
await new Promise(r => setTimeout(r, 800));
// On success, redirect to dashboard
window.location.href = '{{.Base}}/dashboard';
} catch (err) {
showToast(err.message || 'Authentication failed', 'error');
btn.disabled = false;
btn.innerHTML = orig;
}
}
async function emailLogin(e) {
e.preventDefault();
const email = document.getElementById('email').value;
const btn = e.target.querySelector('button');
btn.disabled = true;
btn.textContent = 'Sending link…';
try {
const resp = await fetch('{{.Base}}/api/auth/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (!resp.ok) throw new Error('Could not send login code');
// Redirect to code entry page
window.location.href = '{{.Base}}/verify?email=' + encodeURIComponent(email);
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Continue with email';
}
}
function showToast(msg, type) {
let t = document.querySelector('.toast');
if (!t) {
t = document.createElement('div');
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast ' + type;
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
}
</script>
{{end}}

View File

@ -0,0 +1,263 @@
{{define "page"}}
<header class="topbar">
<div class="container">
<div class="topbar-inner">
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
<nav class="topbar-nav">
<a href="{{.Base}}/dashboard">Vaults</a>
<a href="{{.Base}}/settings">Settings</a>
</nav>
</div>
</div>
</header>
<main class="container" style="flex:1">
<section class="section fade-in">
<span class="label accent">New vault</span>
<h1 style="margin-top:0.5rem">Choose your region</h1>
<p class="text-secondary" style="margin-top:0.5rem;max-width:480px">
Your vault lives in one region. All data stays there. Pick the closest location for the best performance.
</p>
</section>
<section class="fade-in fade-in-delay-1" style="padding-bottom:1.5rem">
<div class="label gold" style="margin-bottom:0.75rem">Headquarters</div>
<div class="region-grid">
<div class="card card-interactive card-gold region-card" data-region="zurich" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E8;&#x1F1ED;</span>
<span class="region-name">Zürich</span>
<span class="region-location">Switzerland</span>
<span class="label gold">HQ &middot; Swiss jurisdiction</span>
</div>
</div>
</section>
<section class="fade-in fade-in-delay-2" style="padding-bottom:1.5rem">
<div class="label" style="margin-bottom:0.75rem">Americas</div>
<div class="region-grid">
<div class="card card-interactive region-card" data-region="virginia" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1FA;&#x1F1F8;</span>
<span class="region-name">Virginia</span>
<span class="region-location">US East</span>
</div>
<div class="card card-interactive region-card" data-region="sanfrancisco" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1FA;&#x1F1F8;</span>
<span class="region-name">San Francisco</span>
<span class="region-location">US West</span>
</div>
<div class="card card-interactive region-card" data-region="montreal" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E8;&#x1F1E6;</span>
<span class="region-name">Montréal</span>
<span class="region-location">Canada</span>
</div>
<div class="card card-interactive region-card" data-region="mexico" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1F2;&#x1F1FD;</span>
<span class="region-name">Mexico City</span>
<span class="region-location">Mexico</span>
</div>
<div class="card card-interactive region-card" data-region="bogota" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E8;&#x1F1F4;</span>
<span class="region-name">Bogotá</span>
<span class="region-location">Colombia</span>
</div>
<div class="card card-interactive region-card" data-region="saopaulo" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E7;&#x1F1F7;</span>
<span class="region-name">São Paulo</span>
<span class="region-location">Brazil</span>
</div>
<div class="card card-interactive region-card" data-region="santiago" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E8;&#x1F1F1;</span>
<span class="region-name">Santiago</span>
<span class="region-location">Chile</span>
</div>
<div class="card card-interactive region-card" data-region="buenosaires" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E6;&#x1F1F7;</span>
<span class="region-name">Buenos Aires</span>
<span class="region-location">Argentina</span>
</div>
</div>
</section>
<section class="fade-in fade-in-delay-3" style="padding-bottom:1.5rem">
<div class="label" style="margin-bottom:0.75rem">Europe</div>
<div class="region-grid">
<div class="card card-interactive region-card" data-region="london" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1EC;&#x1F1E7;</span>
<span class="region-name">London</span>
<span class="region-location">United Kingdom</span>
</div>
<div class="card card-interactive region-card" data-region="madrid" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1EA;&#x1F1F8;</span>
<span class="region-name">Madrid</span>
<span class="region-location">Spain</span>
</div>
</div>
</section>
<section class="fade-in fade-in-delay-3" style="padding-bottom:1.5rem">
<div class="label" style="margin-bottom:0.75rem">Middle East &amp; Africa</div>
<div class="region-grid">
<div class="card card-interactive region-card" data-region="istanbul" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1F9;&#x1F1F7;</span>
<span class="region-name">Istanbul</span>
<span class="region-location">Turkey</span>
</div>
<div class="card card-interactive region-card" data-region="dubai" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E6;&#x1F1EA;</span>
<span class="region-name">Dubai</span>
<span class="region-location">UAE</span>
</div>
<div class="card card-interactive region-card" data-region="capetown" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1FF;&#x1F1E6;</span>
<span class="region-name">Cape Town</span>
<span class="region-location">South Africa</span>
</div>
</div>
</section>
<section class="fade-in fade-in-delay-3" style="padding-bottom:3rem">
<div class="label" style="margin-bottom:0.75rem">Asia Pacific</div>
<div class="region-grid">
<div class="card card-interactive region-card" data-region="mumbai" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1EE;&#x1F1F3;</span>
<span class="region-name">Mumbai</span>
<span class="region-location">India</span>
</div>
<div class="card card-interactive region-card" data-region="singapore" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1F8;&#x1F1EC;</span>
<span class="region-name">Singapore</span>
<span class="region-location">Singapore</span>
</div>
<div class="card card-interactive region-card" data-region="hongkong" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1ED;&#x1F1F0;</span>
<span class="region-name">Hong Kong</span>
<span class="region-location">China</span>
</div>
<div class="card card-interactive region-card" data-region="seoul" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1F0;&#x1F1F7;</span>
<span class="region-name">Seoul</span>
<span class="region-location">South Korea</span>
</div>
<div class="card card-interactive region-card" data-region="tokyo" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1EF;&#x1F1F5;</span>
<span class="region-name">Tokyo</span>
<span class="region-location">Japan</span>
</div>
<div class="card card-interactive region-card" data-region="sydney" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1E6;&#x1F1FA;</span>
<span class="region-name">Sydney</span>
<span class="region-location">Australia</span>
</div>
<div class="card card-interactive region-card" data-region="almaty" onclick="selectRegion(this)">
<span class="region-flag">&#x1F1F0;&#x1F1FF;</span>
<span class="region-name">Almaty</span>
<span class="region-location">Kazakhstan</span>
</div>
</div>
</section>
<!-- Sticky bottom bar -->
<div id="region-confirm" style="position:fixed;bottom:0;left:0;right:0;transform:translateY(100%);transition:transform 0.35s cubic-bezier(0.16,1,0.3,1);z-index:50">
<div style="background:rgba(6,10,16,0.9);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-top:1px solid var(--glass-border);padding:1rem 0">
<div class="container" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;max-width:960px">
<div>
<span id="selected-name" style="font-weight:600"></span>
<span id="selected-location" class="text-secondary" style="font-size:0.875rem;margin-left:0.5rem"></span>
</div>
<button class="btn btn-primary" onclick="confirmRegion()">
Create vault here
</button>
</div>
</div>
</div>
</main>
{{end}}
{{define "scripts"}}
<script>
let selectedRegion = null;
function selectRegion(el) {
document.querySelectorAll('.region-card.selected').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
selectedRegion = el.dataset.region;
const name = el.querySelector('.region-name').textContent;
const loc = el.querySelector('.region-location').textContent;
document.getElementById('selected-name').textContent = name;
document.getElementById('selected-location').textContent = loc;
document.getElementById('region-confirm').style.transform = 'translateY(0)';
}
async function confirmRegion() {
if (!selectedRegion) return;
const btn = document.querySelector('#region-confirm .btn');
btn.disabled = true;
btn.textContent = 'Creating vault…';
try {
const resp = await fetch('{{.Base}}/api/vault/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: selectedRegion })
});
if (resp.status === 401) { window.location.href = '{{.Base}}/login'; return; }
if (resp.status === 409) throw new Error('Vault limit reached');
if (!resp.ok) throw new Error('Could not create vault');
window.location.href = '{{.Base}}/dashboard';
} catch (err) {
showToast(err.message || 'Could not create vault', 'error');
btn.disabled = false;
btn.textContent = 'Create vault here';
}
}
function showToast(msg, type) {
let t = document.querySelector('.toast');
if (!t) {
t = document.createElement('div');
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast ' + type;
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
}
// Auto-detect closest region
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
const regions = {
zurich: [47.37, 8.54], virginia: [38.95, -77.45], sanfrancisco: [37.77, -122.42],
montreal: [45.50, -73.57], mexico: [19.43, -99.13], bogota: [4.71, -74.07],
saopaulo: [-23.55, -46.63], santiago: [-33.45, -70.65], buenosaires: [-34.60, -58.38],
london: [51.51, -0.13], madrid: [40.42, -3.70],
istanbul: [41.01, 28.98], dubai: [25.20, 55.27], capetown: [-33.92, 18.42],
mumbai: [19.08, 72.88], singapore: [1.35, 103.82], hongkong: [22.32, 114.17],
seoul: [37.57, 126.98], tokyo: [35.68, 139.69], sydney: [-33.87, 151.21],
almaty: [43.24, 76.95]
};
let closest = null, minDist = Infinity;
for (const [id, [rlat, rlon]] of Object.entries(regions)) {
const d = Math.hypot(lat - rlat, lon - rlon);
if (d < minDist) { minDist = d; closest = id; }
}
if (closest) {
const el = document.querySelector(`[data-region="${closest}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.boxShadow = '0 0 0 2px var(--accent-dim)';
const hint = document.createElement('span');
hint.className = 'label accent';
hint.textContent = 'Nearest to you';
hint.style.marginTop = '0.25rem';
el.appendChild(hint);
}
}
}, () => {}, { timeout: 5000 });
}
</script>
{{end}}

View File

@ -0,0 +1,179 @@
{{define "page"}}
<header class="topbar">
<div class="container">
<div class="topbar-inner">
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
<nav class="topbar-nav">
<a href="{{.Base}}/dashboard" class="{{if eq .ActiveNav "dashboard"}}active{{end}}">Vaults</a>
<a href="{{.Base}}/settings" class="{{if eq .ActiveNav "settings"}}active{{end}}">Settings</a>
<div class="separator"></div>
<a href="{{.Base}}/login" id="logout-link">Sign out</a>
</nav>
</div>
</div>
</header>
<main class="container" style="flex:1">
<section class="section fade-in">
<span class="label">Account</span>
<h1 style="margin-top:0.5rem">Settings</h1>
</section>
<section class="card fade-in fade-in-delay-1">
<!-- Email -->
<div class="settings-section">
<div class="settings-row">
<div>
<div class="settings-label">Email</div>
<div class="settings-desc">johan@example.com</div>
</div>
</div>
</div>
<!-- Authentication -->
<div class="settings-section">
<div class="settings-row">
<div>
<div class="settings-label">Authentication</div>
<div class="settings-desc">
Passkey registered &middot;
<span class="text-accent" style="font-size:0.8125rem">Active</span>
</div>
</div>
<button class="btn btn-ghost" onclick="registerPasskey()">Add passkey</button>
</div>
</div>
<!-- Billing -->
<div class="settings-section">
<div class="settings-row">
<div>
<div class="settings-label">Billing</div>
<div class="settings-desc">
$12/year &middot; Next charge Mar 20, 2027
</div>
</div>
<button class="btn btn-ghost" onclick="manageBilling()">Manage in Stripe</button>
</div>
</div>
<!-- Invoices -->
<div class="settings-section">
<div class="settings-row">
<div>
<div class="settings-label">Invoices</div>
<div class="settings-desc">View and download past invoices</div>
</div>
<button class="btn btn-ghost" onclick="viewInvoices()">View invoices</button>
</div>
</div>
</section>
<!-- Danger zone -->
<section class="section fade-in fade-in-delay-2" style="padding-bottom:3rem">
<div class="danger-zone">
<h3>Danger zone</h3>
<p>
Deleting your account cancels your subscription, deletes all vaults,
and removes all data. This cannot be undone.
</p>
<button class="btn btn-danger" onclick="deleteAccount()">
Delete account
</button>
</div>
</section>
</main>
{{end}}
{{define "scripts"}}
<script>
function registerPasskey() {
// TODO: WebAuthn registration flow for account site
showToast('Passkey registration not yet implemented', 'error');
}
async function manageBilling() {
try {
const resp = await fetch('{{.Base}}/api/billing/portal', { method: 'POST' });
if (!resp.ok) throw new Error('Could not open billing portal');
const { url } = await resp.json();
window.location.href = url;
} catch (err) {
showToast(err.message, 'error');
}
}
function viewInvoices() {
manageBilling(); // Stripe portal handles invoices too
}
function deleteAccount() {
// Two-step confirmation
const zone = document.querySelector('.danger-zone');
if (zone.dataset.confirming) {
performDelete();
return;
}
zone.dataset.confirming = 'true';
const btn = zone.querySelector('.btn-danger');
btn.textContent = 'Confirm — delete everything';
btn.style.background = 'rgba(239,68,68,0.25)';
// Add email confirmation input
const field = document.createElement('div');
field.className = 'field';
field.style.marginBottom = '1rem';
field.innerHTML = `
<label style="color:var(--red)">Type your email to confirm</label>
<input type="email" id="delete-confirm-email" class="input" placeholder="you@example.com" style="border-color:rgba(239,68,68,0.3)">
`;
zone.insertBefore(field, btn);
document.getElementById('delete-confirm-email').focus();
}
async function performDelete() {
const email = document.getElementById('delete-confirm-email')?.value;
if (!email) {
showToast('Please enter your email to confirm', 'error');
return;
}
const btn = document.querySelector('.danger-zone .btn-danger');
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const resp = await fetch('{{.Base}}/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (!resp.ok) throw new Error('Could not delete account');
window.location.href = '/login?deleted=1';
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Confirm — delete everything';
}
}
document.getElementById('logout-link')?.addEventListener('click', (e) => {
e.preventDefault();
window.location.href = '{{.Base}}/login';
});
function showToast(msg, type) {
let t = document.querySelector('.toast');
if (!t) {
t = document.createElement('div');
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast ' + type;
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
}
</script>
{{end}}

View File

@ -0,0 +1,73 @@
{{define "page"}}
<div class="login-wrapper">
<div class="login-card glass fade-in">
<div class="login-header">
<div class="brand">vault1984</div>
<p>Enter the code sent to your email</p>
</div>
<form id="verify-form" onsubmit="return verifyCode(event)">
<input type="hidden" id="verify-email" value="{{.Data}}">
<div class="field">
<label for="code">Verification code</label>
<input type="text" id="code" class="input input-mono" placeholder="123456"
required autocomplete="one-time-code" autofocus
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
style="text-align:center;font-size:1.5rem;letter-spacing:0.3em">
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" id="verify-btn">
Verify
</button>
</form>
<div class="login-footer">
<a href="{{.Base}}/login">Back to sign in</a>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
async function verifyCode(e) {
e.preventDefault();
const email = document.getElementById('verify-email').value;
const code = document.getElementById('code').value;
const btn = document.getElementById('verify-btn');
btn.disabled = true;
btn.textContent = 'Verifying…';
try {
const resp = await fetch('{{.Base}}/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Invalid code');
}
window.location.href = '{{.Base}}/dashboard';
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Verify';
document.getElementById('code').value = '';
document.getElementById('code').focus();
}
}
function showToast(msg, type) {
let t = document.querySelector('.toast');
if (!t) {
t = document.createElement('div');
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast ' + type;
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
}
</script>
{{end}}

View File

@ -258,6 +258,7 @@ func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request)
PublicKey []byte `json:"public_key"`
PRFSalt []byte `json:"prf_salt"`
Name string `json:"name"`
L1Key []byte `json:"l1_key"` // first 8 bytes of master, for vault DB naming
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
@ -269,10 +270,17 @@ func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request)
return
}
// First passkey → create DB, derive vault key
// First passkey → create DB
if db == nil && len(req.PublicKey) > 0 {
hash := sha256.Sum256(req.PublicKey)
dbName := hex.EncodeToString(hash[:4]) + ".db" // first 8 hex chars
// DB named from L1 key: vault1984-XXXXXX (base64url of first 4 bytes, no extension)
var dbName string
if len(req.L1Key) >= 4 {
dbName = "vault1984-" + base64UrlEncode(req.L1Key[:4])
} else {
// Fallback: derive from public key hash (legacy compat)
hash := sha256.Sum256(req.PublicKey)
dbName = "vault1984-" + base64UrlEncode(hash[:4])
}
dbPath := filepath.Join(h.Cfg.DataDir, dbName)
newDB, err := lib.OpenDB(dbPath)
if err != nil {