diff --git a/.gitignore b/.gitignore index adc4e2c..9495fba 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ vault1984 *.pem oss/app/backups/ commercial/website/vault1984-web +commercial/account/vault1984-account diff --git a/commercial/account/.gitkeep b/commercial/account/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/commercial/account/account.css b/commercial/account/account.css new file mode 100644 index 0000000..ad09699 --- /dev/null +++ b/commercial/account/account.css @@ -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; } diff --git a/commercial/account/account.db b/commercial/account/account.db new file mode 100644 index 0000000..4d0f4d7 Binary files /dev/null and b/commercial/account/account.db differ diff --git a/commercial/account/account.db-shm b/commercial/account/account.db-shm new file mode 100644 index 0000000..a309f0b Binary files /dev/null and b/commercial/account/account.db-shm differ diff --git a/commercial/account/account.db-wal b/commercial/account/account.db-wal new file mode 100644 index 0000000..d700392 Binary files /dev/null and b/commercial/account/account.db-wal differ diff --git a/commercial/account/api.go b/commercial/account/api.go new file mode 100644 index 0000000..7360009 --- /dev/null +++ b/commercial/account/api.go @@ -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, + }) +} diff --git a/commercial/account/db.go b/commercial/account/db.go new file mode 100644 index 0000000..21adc18 --- /dev/null +++ b/commercial/account/db.go @@ -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"` +} diff --git a/commercial/account/favicon.svg b/commercial/account/favicon.svg new file mode 100644 index 0000000..92d2303 --- /dev/null +++ b/commercial/account/favicon.svg @@ -0,0 +1,5 @@ + + + v + 84 + diff --git a/commercial/account/go.mod b/commercial/account/go.mod new file mode 100644 index 0000000..6867530 --- /dev/null +++ b/commercial/account/go.mod @@ -0,0 +1,5 @@ +module vault1984.com/account + +go 1.23.6 + +require github.com/mattn/go-sqlite3 v1.14.37 // indirect diff --git a/commercial/account/go.sum b/commercial/account/go.sum new file mode 100644 index 0000000..9c79a75 --- /dev/null +++ b/commercial/account/go.sum @@ -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= diff --git a/commercial/account/main.go b/commercial/account/main.go new file mode 100644 index 0000000..f2e7378 --- /dev/null +++ b/commercial/account/main.go @@ -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", + }) +} diff --git a/commercial/account/templates/base.tmpl b/commercial/account/templates/base.tmpl new file mode 100644 index 0000000..96f0e0e --- /dev/null +++ b/commercial/account/templates/base.tmpl @@ -0,0 +1,23 @@ + + + + + + {{.Title}} + + + + + + + + + +
+
+
+ {{template "page" .}} +
+ {{template "scripts" .}} + + diff --git a/commercial/account/templates/checkout.tmpl b/commercial/account/templates/checkout.tmpl new file mode 100644 index 0000000..546ff9e --- /dev/null +++ b/commercial/account/templates/checkout.tmpl @@ -0,0 +1,107 @@ +{{define "page"}} +
+
+ Get started +

Your vault awaits

+ +
+ $12 + /year +
+
+ 30-day money-back guarantee. No questions asked. +
+ +
+ + + +
+ +
+
+ + +
+ +
+ + + +
+ + Payments processed by Stripe. Cancel anytime. + +
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/commercial/account/templates/dashboard.tmpl b/commercial/account/templates/dashboard.tmpl new file mode 100644 index 0000000..c923f8b --- /dev/null +++ b/commercial/account/templates/dashboard.tmpl @@ -0,0 +1,165 @@ +{{define "page"}} +
+
+ +
+
+ +
+
+
+
+ Account +

Your vaults

+
+ + + New vault + +
+
+ + +
+
+
+ Loading… +
+
+
+ + +
+
+
+ Plan +
Consumer
+
$12/year
+
+ Manage billing +
+
+ + +
+
+
+
-
+
Vaults
+
+
+
-
+
Days left
+
+
+
-
+
Regions
+
+
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/commercial/account/templates/login.tmpl b/commercial/account/templates/login.tmpl new file mode 100644 index 0000000..a8f5790 --- /dev/null +++ b/commercial/account/templates/login.tmpl @@ -0,0 +1,99 @@ +{{define "page"}} +
+
+ + + + + + +
+
+ + +
+ +
+ + +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/commercial/account/templates/regions.tmpl b/commercial/account/templates/regions.tmpl new file mode 100644 index 0000000..f161a4f --- /dev/null +++ b/commercial/account/templates/regions.tmpl @@ -0,0 +1,263 @@ +{{define "page"}} +
+
+ +
+
+ +
+
+ New vault +

Choose your region

+

+ Your vault lives in one region. All data stays there. Pick the closest location for the best performance. +

+
+ +
+
Headquarters
+
+
+ 🇨🇭 + Zürich + Switzerland + HQ · Swiss jurisdiction +
+
+
+ +
+
Americas
+
+
+ 🇺🇸 + Virginia + US East +
+
+ 🇺🇸 + San Francisco + US West +
+
+ 🇨🇦 + Montréal + Canada +
+
+ 🇲🇽 + Mexico City + Mexico +
+
+ 🇨🇴 + Bogotá + Colombia +
+
+ 🇧🇷 + São Paulo + Brazil +
+
+ 🇨🇱 + Santiago + Chile +
+
+ 🇦🇷 + Buenos Aires + Argentina +
+
+
+ +
+
Europe
+
+
+ 🇬🇧 + London + United Kingdom +
+
+ 🇪🇸 + Madrid + Spain +
+
+
+ +
+
Middle East & Africa
+
+
+ 🇹🇷 + Istanbul + Turkey +
+
+ 🇦🇪 + Dubai + UAE +
+
+ 🇿🇦 + Cape Town + South Africa +
+
+
+ +
+
Asia Pacific
+
+
+ 🇮🇳 + Mumbai + India +
+
+ 🇸🇬 + Singapore + Singapore +
+
+ 🇭🇰 + Hong Kong + China +
+
+ 🇰🇷 + Seoul + South Korea +
+
+ 🇯🇵 + Tokyo + Japan +
+
+ 🇦🇺 + Sydney + Australia +
+
+ 🇰🇿 + Almaty + Kazakhstan +
+
+
+ + +
+
+
+
+ + +
+ +
+
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/commercial/account/templates/settings.tmpl b/commercial/account/templates/settings.tmpl new file mode 100644 index 0000000..b1c22ac --- /dev/null +++ b/commercial/account/templates/settings.tmpl @@ -0,0 +1,179 @@ +{{define "page"}} +
+
+ +
+
+ +
+
+ Account +

Settings

+
+ +
+ +
+
+
+
Email
+
johan@example.com
+
+
+
+ + +
+
+
+
Authentication
+
+ Passkey registered · + Active +
+
+ +
+
+ + +
+
+
+
Billing
+
+ $12/year · Next charge Mar 20, 2027 +
+
+ +
+
+ + +
+
+
+
Invoices
+
View and download past invoices
+
+ +
+
+
+ + +
+
+

Danger zone

+

+ Deleting your account cancels your subscription, deletes all vaults, + and removes all data. This cannot be undone. +

+ +
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/commercial/account/templates/verify.tmpl b/commercial/account/templates/verify.tmpl new file mode 100644 index 0000000..1c97c77 --- /dev/null +++ b/commercial/account/templates/verify.tmpl @@ -0,0 +1,73 @@ +{{define "page"}} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/oss/app/api/handlers.go b/oss/app/api/handlers.go index 3a30c66..edf14fe 100644 --- a/oss/app/api/handlers.go +++ b/oss/app/api/handlers.go @@ -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 {