From 041227dfd715b454af1d491373ed984da57b22be Mon Sep 17 00:00:00 2001 From: James Date: Fri, 20 Mar 2026 06:02:37 -0400 Subject: [PATCH] chore: auto-commit uncommitted changes --- .gitignore | 1 + commercial/account/.gitkeep | 0 commercial/account/account.css | 802 ++++++++++++++++++++ commercial/account/account.db | Bin 0 -> 4096 bytes commercial/account/account.db-shm | Bin 0 -> 32768 bytes commercial/account/account.db-wal | Bin 0 -> 131872 bytes commercial/account/api.go | 306 ++++++++ commercial/account/db.go | 192 +++++ commercial/account/favicon.svg | 5 + commercial/account/go.mod | 5 + commercial/account/go.sum | 2 + commercial/account/main.go | 202 +++++ commercial/account/templates/base.tmpl | 23 + commercial/account/templates/checkout.tmpl | 107 +++ commercial/account/templates/dashboard.tmpl | 165 ++++ commercial/account/templates/login.tmpl | 99 +++ commercial/account/templates/regions.tmpl | 263 +++++++ commercial/account/templates/settings.tmpl | 179 +++++ commercial/account/templates/verify.tmpl | 73 ++ oss/app/api/handlers.go | 14 +- 20 files changed, 2435 insertions(+), 3 deletions(-) delete mode 100644 commercial/account/.gitkeep create mode 100644 commercial/account/account.css create mode 100644 commercial/account/account.db create mode 100644 commercial/account/account.db-shm create mode 100644 commercial/account/account.db-wal create mode 100644 commercial/account/api.go create mode 100644 commercial/account/db.go create mode 100644 commercial/account/favicon.svg create mode 100644 commercial/account/go.mod create mode 100644 commercial/account/go.sum create mode 100644 commercial/account/main.go create mode 100644 commercial/account/templates/base.tmpl create mode 100644 commercial/account/templates/checkout.tmpl create mode 100644 commercial/account/templates/dashboard.tmpl create mode 100644 commercial/account/templates/login.tmpl create mode 100644 commercial/account/templates/regions.tmpl create mode 100644 commercial/account/templates/settings.tmpl create mode 100644 commercial/account/templates/verify.tmpl 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 0000000000000000000000000000000000000000..4d0f4d75c0c482c3485d3529b474a8f56497a2ca GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV%?r{lK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3ArvhW9) literal 0 HcmV?d00001 diff --git a/commercial/account/account.db-shm b/commercial/account/account.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..a309f0b8aa06d10271843599cd903cf177dc7b6d GIT binary patch literal 32768 zcmeI)J5EC}5CzalLP)|ld=vhWs3=$f8=ha-q<>c;uG#&r)e7Wj# zOv?BD$N1Wf7|#|ZJKAM-%52t6Ki%1SzSQ1q-7mH2ub!>{>VBWv73-9 zl0d#dH%9qQ=zu_q!2I{Sb1Csc2_y>iV>PzoD6s?41hNDMF^uh)WW~!RkSMSiYq1e~ zagf-9XaYF`OR*lCu^)#y@lpv8AV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N M0t5&UAn=C*9}^@h-v9sr literal 0 HcmV?d00001 diff --git a/commercial/account/account.db-wal b/commercial/account/account.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..d700392fceec8e64222b41998cf89f1ed8a435ff GIT binary patch literal 131872 zcmeI*e{2)y9mnyroj5TN4ph`MsHmO-N?dT_A4yE8x;7*xO@M^vhex7mp0O{9Nn$s# z^F!@67TCl_*Hu&hLW3cyY2DUMYoUUTf2L_w*KN`SsN2L4o1ok1x=}H)R-L+{-E)^S zzBK#@5h6D4Qyibq=kq8lD&QEi^B(7|8Qkh=aXCFX~o%>9376NoqqMzR;%>5)2Z%jRc|)&n(0NnzQA~( z-fPFpdOX{zRjPXKqZ0W;$ttgq$>bNQp$F$+1K_W!zgEJAw^eL1$OP#w|govDI0*$l-7(!;$!qQ(wA*+q<0G zI$E|ibo4kk2Yc3u9a8Dh_)|(a9#i+xc4!K2YUtk5<*caCo{x?yk+c#Es~^Vo=iA!5 zoNe7(wm6L)D`V;o>G-fxS&>LiRJdH~VO`Z$duih;ixf}9l*!b{kouWE92rX|wZ~!O z9K&8?O?<8|p0*XA^E3Sg%OCYUQhxu_>r2E&y(>Yiu^@l|0tg_000IagfB*srAb>!A z3Wz=en~@_}WPkaWpA8I!bVj-a6AtX4-*a(3y(tYfl@00IagfB*srAbDgDA5SKxbN1$(y!N~@`BZ#VNogti89Dk-r0i!rf8(6}+5&s& zV`bON+3NsduYO;6kJS-qpXrUfK&iZRdv*1nI;bO<|1Tg%KmY**5I_I{1Q0*~0R#|0 zAO{4*|NaY&et|bue)@lho`0~II)WUya-1Rp2q1s}0tg_000IagfB*vX7ckWky!pm~ zy)P8)`rb|I2#T`aR*X+@+jRu?C9h}d2$sBF^24m-vWfr#2q1s}0tg_000IcyvcOcd zP#ZsfPni~&PbsOCnpRI4_jXfg-q@+tbekX0|NO8c z0tg_000IagfB*srAbW2do!| zn;vuCl#`!1V#M^G&O%ourr#^1f&^PBGb*HK4s2mbhSNCXf-009ILKmY**5I_I{ z1Qwov_}{<7=oheGIpVObe$h%D!NR*xoDl*DAbBzIMCF3s~eclKRDh00IagfB*srAb@sLe9XQ@Gc2{DDW#x9`#O0{bO-e_k}4Mj(Iy0tg_0 z00IagfB*srAdp)Eils2>s`U?qw`~r$2O{;}t*yO5-{g)b+FSj;P{T-IvMJHGskvu+ zS4&!Hod|@w%^&zP*#K{skqzk0Y%}Er#5{$Wyuhdav*uvKH@>t>&kLNANz^xPXWd)QI5)dez36<|Fug;^@Q+ zQ%jFAzCey$c1{-o1Q0*~0R#|0009ILKmdW83d}mbfGIC92&H>#o-_v%o*o;GM+ekcLcs0uyL}!p`G8-W zd>|85@HmZa>fsC3o5ScADCv7=^~5`==k@srK9J-OWQ}0+1KmY**5I_I{1Q0*~ z0R#|O*aH2w!l=8}-`?YoB*Nq4_3L9DEko}3U_+=TJQ(Wh9H`saHCQ{LBqF0jp9$8D zCQ>sa^_lW}uPDD4gZkI>X0~~p#x})T7+c_k>}+cs>`%oS6Mcb=J6iNN`oyFF>f20D3SfGh>1~Qddz*pqw$0)8K&0NgwY4|s zo80k4d#m3UY8VMjHYNHtHTP`qYDp`t6M<0oZM}_pQH#`DW_*G0L-psZCteGQynsy} zk>nBiuzYG^UwY0C0R#|0009ILKmY**5I_KdoDrzDMJ;pays(M==g= 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. +
+ +
+ +
    +
  • + + Zero-knowledge encryption — we cannot read your vault +
  • +
  • + + Field-level AI visibility — agents see what you allow +
  • +
  • + + 23 global regions — pick where your data lives +
  • +
  • + + WebAuthn PRF — hardware key is the only key +
  • +
  • + + FIPS 140-3 certified cryptography +
  • +
  • + + MCP, CLI, browser extension, REST API +
  • +
+ +
+ +
+
+ + +
+ +
+ + + +
+ + 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 {