From 19a88f50b37bfbc86056e9e3339fedd4f7f06640 Mon Sep 17 00:00:00 2001 From: Arlind Ukshini Date: Thu, 23 Apr 2026 10:38:37 +0200 Subject: [PATCH] simplify login and update features --- .env.example | 14 ---- AUTH_SIMPLIFICATION_NOTES.md | 132 +++++++++++++++++++++++++++++ bin/invite.js | 36 +++++--- package-lock.json | 16 +--- package.json | 7 +- public/entrance.html | 90 ++++---------------- public/entrance.js | 157 +++++++++++++---------------------- server.js | 28 ++----- src/auth.js | 120 ++++++++++---------------- src/db.js | 53 ++++++------ src/mail.js | 53 ------------ src/sessions.js | 24 +----- 12 files changed, 318 insertions(+), 412 deletions(-) create mode 100644 AUTH_SIMPLIFICATION_NOTES.md delete mode 100644 src/mail.js diff --git a/.env.example b/.env.example index 405d79c..7138e53 100644 --- a/.env.example +++ b/.env.example @@ -7,20 +7,6 @@ # Node listens on this port, bound to 127.0.0.1 only PORT=3000 -# Secret used to HMAC one-time codes before they're stored. -# Generate with: openssl rand -hex 32 -# DO NOT change this after go-live — all pending codes will be invalidated. -CODE_PEPPER=replace_me_with_64_random_hex_characters - -# SMTP relay settings (your own relay, STARTTLS on 587) -SMTP_HOST=smtp.yourrelay.tld -SMTP_PORT=587 -SMTP_USER=fenja@yourdomain.tld -SMTP_PASS=replace_me - -# "From" address on outbound mail -MAIL_FROM="Fenja AI " - # Public origin of the site — used only in log output PUBLIC_ORIGIN=https://project-bifrost.fenja.ai diff --git a/AUTH_SIMPLIFICATION_NOTES.md b/AUTH_SIMPLIFICATION_NOTES.md new file mode 100644 index 0000000..cfa30ef --- /dev/null +++ b/AUTH_SIMPLIFICATION_NOTES.md @@ -0,0 +1,132 @@ +# Auth simplification — deployment notes + +The code-based login flow (email → 6-digit code → session) has been +replaced by a pure email-based lookup against the invite list. This is a +one-way change — after deploy, the server no longer reads `CODE_PEPPER`, +no longer talks to SMTP, and the `codes` table is dropped on first boot. + +Invites now carry an optional first name, used on the welcome screen: +*"Thanks for your interest, Erik."* (or *"Thank you for your interest."* +when no name is on file). + +## Files changed + +### Replace (drop in over the existing ones) + +| File | What changed | +|---|---| +| `server.js` | Drops `initMail()` call, drops the `CODE_PEPPER` fatal check at boot. | +| `src/db.js` | Adds `first_name` column to `invites` (idempotent migration). Drops the `codes` table + its index on startup (idempotent). Updates prepared statements — `getInvite` and `listInvites` return `first_name`; `upsertInvite` takes it as an argument; `deleteInvite` is unchanged. Removes all `codes`-related statements. Cleanup sweep no longer touches `codes`. | +| `src/auth.js` | `POST /auth/request-code` and `POST /auth/verify-code` removed. Single `POST /auth/login` endpoint — returns `200 {ok, firstName}` on success, `403 {error: "not_invited"}` otherwise. Rate limit: 30 per IP per hour. `GET /auth/me` now returns `{email, firstName}`. | +| `src/sessions.js` | Removes `CODE_TTL_MS`, `randomCode`, `hashCode`, `constantTimeEqual`. Keeps `SESSION_TTL_MS`, `COOKIE_NAME`, `randomSessionId`, `issueSession`, `clearSession`, `currentSession`. | +| `bin/invite.js` | `add` accepts an optional first name as a third argument: `node bin/invite.js add erik@example.com Erik`. `list` shows the name in square brackets next to each entry. `remove` unchanged. Re-running `add` on an existing email updates the first name only — `invited_at` and `invited_by` are preserved. | +| `public/entrance.html` | Removes the six code-digit input block and all associated styles (`.code-row`, `.code-cell`, `.quiet`, "use a different email"). Removes step 3's duplicate welcome title markup — the welcome title is now set by JS on step-enter. | +| `public/entrance.js` | Two-step state machine (email → welcome) instead of three. On submit: `POST /auth/login`. Success → set welcome title, advance. `403` → inline "not invited" message in-place. Removes `submitCode()`, code-cell handling, paste/arrow-key logic, the "use different email" button, and the `rememberedEmail` / `submitting` state. | +| `package.json` | Removes `nodemailer` dependency. Bumps version to `0.2.0`. | +| `.env.example` | Removes `CODE_PEPPER`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `MAIL_FROM`. Keeps `PORT`, `PUBLIC_ORIGIN`, `NODE_ENV`. | + +### Delete + +| File | Why | +|---|---| +| `src/mail.js` | SMTP transport and `sendCode()` — no longer used. | + +### Update (minor — you do this yourself) + +| File | Action | +|---|---| +| `.env` (on the VPS, at `/etc/fenja/env`) | Remove `CODE_PEPPER=…`, `SMTP_HOST=…`, `SMTP_PORT=…`, `SMTP_USER=…`, `SMTP_PASS=…`, `MAIL_FROM=…`. Keep the rest. | +| `.env` (on your laptop) | Same. | + +## Doc updates + +I've not touched these — update them when you next edit them: +- `PROJECT.md` — "How auth works" section, "Non-negotiable properties" (drop CODE_PEPPER and SMTP) +- `INSTALL.md` — step 5 (env file), step 0 prerequisites (SMTP relay no longer needed) +- `OPERATIONS.md` — "Editing secrets" section +- `CHECKLIST.md` — section C (email + code form) +- `CLAUDE.md` — references to `CODE_PEPPER`, SMTP, `codes` table, `/auth/request-code`, `/auth/verify-code` + +## Deploy sequence + +Local first: + +```powershell +# 1. Replace the files (see the list above). Delete src/mail.js. + +# 2. Update your local .env — remove CODE_PEPPER, SMTP_*, MAIL_FROM lines. + +# 3. Clean up node_modules to drop nodemailer: +npm install + +# 4. Add first names to existing invites, or invite fresh with names: +node bin/invite.js add you@yourdomain.com Erik + +# 5. Walk the flow: +npm run dev +# → http://127.0.0.1:3000 +# → type your email → should land on welcome with personalised title +# → click "Learn more" → timeline +# → open a private window, type an unknown email → "not on invite list" +``` + +Then the VPS (once you're happy): + +```bash +# On your laptop — rsync with the usual excludes: +rsync -avz --delete \ + --exclude node_modules --exclude data --exclude .env --exclude .git \ + ./ user@project-bifrost.fenja.ai:/tmp/fenja-upload/ + +# On the VPS: +sudo rsync -a --delete /tmp/fenja-upload/ /opt/fenja/ +sudo rm -f /opt/fenja/src/mail.js # make sure it's gone after rsync +sudo chown -R fenja:fenja /opt/fenja +rm -rf /tmp/fenja-upload + +cd /opt/fenja +sudo -u fenja npm ci --omit=dev # drops nodemailer from node_modules + +# Edit the VPS .env to remove CODE_PEPPER and SMTP_*: +sudo nano /etc/fenja/env + +sudo systemctl restart fenja +sudo journalctl -u fenja -n 20 +# Expect: just "[bifrost] listening on 127.0.0.1:3000" +# No more "[mail] SMTP relay reachable" line — the whole SMTP stack is gone. +``` + +First time the server boots, the migration runs automatically: +- adds `first_name` column to `invites` +- drops the `codes` table + +Both are idempotent — if you roll back and deploy again it's a no-op. + +## Spot-check after deploy + +- [ ] `curl -I https://project-bifrost.fenja.ai/` still returns 200 with all security headers. +- [ ] A non-existent session cookie → entrance page with the email field. +- [ ] Type an invited email → advances to welcome with *"Thanks for your interest, [Name]."* if the invite has a name, or *"Thank you for your interest."* if not. +- [ ] Type an uninvited email → inline red "This email is not on the invite list." on the email input. +- [ ] Click "Learn more about Project Bifrost" → `/timeline`. +- [ ] Refresh `/` while logged in → land directly on the welcome step with the personalised title (served from `/auth/me`). +- [ ] `node bin/invite.js list` on the VPS shows the existing `quka93@gmail.com` with no name in square brackets (NULL is fine — legacy row). +- [ ] `node bin/invite.js add someone@example.com Alice` shows `Invited someone@example.com (Alice)`. +- [ ] DevTools network tab: no 404s, no CSP violations. + +## Rollback, if needed + +The migration is not trivially reversible — `codes` was dropped. If you +need to roll back, restore from the nightly SQLite backup at +`/opt/fenja/data/backup-YYYY-MM-DD.sqlite`, and redeploy the previous +code. Invites with `first_name` values stay in the backup but will be +ignored by the old `src/db.js` that doesn't select that column — no harm +done. + +## What I deliberately did NOT do + +- Didn't remove the `rate_limits` table — still used by the login endpoint. +- Didn't change `src/middleware.js` — `rateLimit()` and `requireAuth()` behaviour is unchanged. +- Didn't change anything inside `protected/` — the timeline, archive, and Project Bifrost scenes are all untouched. +- Didn't change Nginx config or the systemd service unit — no operational changes. +- Didn't add an "admin web UI" or `/auth/invite` HTTP endpoint — invites still happen only via the CLI, as before. diff --git a/bin/invite.js b/bin/invite.js index 24b309c..e9e72d5 100644 --- a/bin/invite.js +++ b/bin/invite.js @@ -3,21 +3,28 @@ // bin/invite.js — add / remove / list invites. // // Usage: -// npm run invite -- add someone@example.com +// npm run invite -- add someone@example.com [FirstName] // npm run invite -- remove someone@example.com // npm run invite -- list // // Or directly: -// node bin/invite.js add someone@example.com +// node bin/invite.js add someone@example.com Erik +// node bin/invite.js add someone@example.com (no name — stored as NULL) +// +// The first name is optional. When present it's used on the welcome +// screen ("Thanks for your interest, Erik."). When absent the welcome +// screen falls back to anonymous copy ("Thank you for your interest."). +// Re-running `add` on an existing email updates the first name only; +// invited_at and invited_by are preserved. // ───────────────────────────────────────────────────────────── import { q } from '../src/db.js'; -const [, , cmd, arg] = process.argv; +const [, , cmd, emailArg, nameArg] = process.argv; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; function help() { console.log('Usage:'); - console.log(' invite add '); + console.log(' invite add [FirstName]'); console.log(' invite remove '); console.log(' invite list'); process.exit(1); @@ -25,15 +32,20 @@ function help() { switch (cmd) { case 'add': { - if (!arg || !EMAIL_RE.test(arg)) help(); - const email = arg.trim().toLowerCase(); - q.upsertInvite.run(email, Date.now(), 'cli'); - console.log(`Invited ${email}`); + if (!emailArg || !EMAIL_RE.test(emailArg)) help(); + const email = emailArg.trim().toLowerCase(); + const firstName = nameArg ? nameArg.trim() : null; + q.upsertInvite.run(email, firstName, Date.now(), 'cli'); + if (firstName) { + console.log(`Invited ${email} (${firstName})`); + } else { + console.log(`Invited ${email}`); + } break; } case 'remove': { - if (!arg || !EMAIL_RE.test(arg)) help(); - const email = arg.trim().toLowerCase(); + if (!emailArg || !EMAIL_RE.test(emailArg)) help(); + const email = emailArg.trim().toLowerCase(); const result = q.deleteInvite.run(email); console.log(result.changes > 0 ? `Removed ${email}` : `No invite for ${email}`); break; @@ -45,7 +57,9 @@ switch (cmd) { } else { for (const r of rows) { const d = new Date(r.invited_at).toISOString().slice(0, 10); - console.log(` ${d} ${r.email}${r.invited_by ? ` (by ${r.invited_by})` : ''}`); + const name = r.first_name ? ` [${r.first_name}]` : ''; + const by = r.invited_by ? ` (by ${r.invited_by})` : ''; + console.log(` ${d} ${r.email}${name}${by}`); } console.log(`\n${rows.length} invite${rows.length === 1 ? '' : 's'} total.`); } diff --git a/package-lock.json b/package-lock.json index 3cc2313..2e1e7fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "project-bifrost", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-bifrost", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "better-sqlite3": "^12.2.0", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", - "express": "^4.21.0", - "nodemailer": "^8.0.5" + "express": "^4.21.0" }, "engines": { "node": ">=20.0.0" @@ -781,15 +780,6 @@ "node": ">=10" } }, - "node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 6d9409d..5547109 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "project-bifrost", - "version": "0.1.0", - "description": "Fenja AI — invite-only entrance, Node/Express, SQLite, SMTP.", + "version": "0.2.0", + "description": "Fenja AI — invite-only entrance, Node/Express, SQLite.", "private": true, "type": "module", "engines": { @@ -16,7 +16,6 @@ "better-sqlite3": "^12.2.0", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", - "express": "^4.21.0", - "nodemailer": "^8.0.5" + "express": "^4.21.0" } } diff --git a/public/entrance.html b/public/entrance.html index 42311c7..4e40eb9 100644 --- a/public/entrance.html +++ b/public/entrance.html @@ -123,34 +123,6 @@ } .field-input:disabled { opacity: 0.55; cursor: default; } - /* ───── Six-digit code cells ───── */ - .code-row { - display: flex; gap: 10px; - margin-bottom: 8px; - } - .code-cell { - width: 54px; height: 64px; - padding: 0; text-align: center; - background: var(--paper-sink); - color: var(--ink); - font-family: "Newsreader", Georgia, serif; - font-weight: 400; font-size: 28px; - border: 0; outline: 0; - border-radius: 10px 10px 0 0; - box-shadow: inset 0 -1px 0 0 rgba(56, 56, 49, 0.25); - transition: box-shadow var(--dur) var(--ease), background var(--dur) var(--ease); - } - .code-cell:focus { - background: #eae3d0; - box-shadow: inset 0 -2px 0 0 var(--walnut); - } - .code-cell.is-filled { - box-shadow: inset 0 -1px 0 0 rgba(56, 56, 49, 0.4); - } - .code-cell.is-error { - box-shadow: inset 0 -2px 0 0 var(--crimson); - } - /* ───── Welcome-step wordmark (centered in the right half) ───── */ .welcome-logo { position: fixed; @@ -172,7 +144,6 @@ .welcome-title { font-family: "Newsreader", Georgia, "Times New Roman", serif; font-weight: 400; - font-style: italic; font-size: 54px; line-height: 1.05; letter-spacing: -0.022em; @@ -180,6 +151,14 @@ margin: 0 0 28px 0; text-wrap: pretty; } + /* The terminal keyword — per the Definitive Emphasis rule: + Newsreader Bold Italic on the last word, followed by the + absolute period. Applies to both "Thanks for your interest, + Erik." (name) and "Thank you for your interest." (no name). */ + .welcome-title em { + font-style: italic; + font-weight: 700; + } .welcome-body { font-family: "Newsreader", Georgia, "Times New Roman", serif; font-weight: 400; @@ -262,30 +241,9 @@ .ack.is-visible { opacity: 1; transform: translateY(0); } .ack.is-error { color: var(--crimson); } - /* ───── Quiet secondary action ───── */ - .quiet { - margin-top: 28px; - background: transparent; border: 0; padding: 0; - cursor: pointer; - color: var(--ink-soft); - font-family: "Manrope", system-ui, sans-serif; - font-size: 14px; - letter-spacing: 0; - display: inline-flex; align-items: baseline; gap: 8px; - transition: color var(--dur) var(--ease); - } - .quiet:hover { color: var(--ink); } - .quiet .q-arrow { - font-family: "Newsreader", Georgia, serif; - font-style: italic; - color: var(--walnut); - } - @media (max-width: 720px) { .entrance { padding: 0 28px; } .tagline { font-size: 26px; margin-bottom: 32px; } - .code-cell { width: 42px; height: 54px; font-size: 22px; } - .code-row { gap: 7px; } .currents { opacity: 0.5; } .welcome-title { font-size: 38px; } .welcome-body { font-size: 16.5px; } @@ -323,31 +281,15 @@ - -
-

- A six-digit code is on its way. Enter it below. -

-
-
- - - - - - -
-
- -
-
- - + +
-

Welcome.

+

+ Thank you for your interest. +

Thank you for joining and for your interest in enabling sovereign AI in Denmark and Europe. Project Bifrost is a deliberate effort to diff --git a/public/entrance.js b/public/entrance.js index b6076da..a2aba98 100644 --- a/public/entrance.js +++ b/public/entrance.js @@ -2,6 +2,15 @@ // public/entrance.js — client-side behaviour for the entrance page. // Loaded via so CSP // can stay at `script-src 'self'` — no inline scripts. +// +// Flow: +// 1. On load, GET /auth/me — if session valid, go straight to the +// welcome step (personalised with firstName if we have one). +// 2. Otherwise show the email step. On submit POST /auth/login — +// a) 200: session cookie set by the server, advance to welcome. +// b) 403 not_invited: inline "not invited" message, stay put. +// c) 429: rate limited, ask to wait. +// d) network/other: generic retry message. // ───────────────────────────────────────────────────────────── /* ───── Topographic currents ───── */ @@ -47,7 +56,6 @@ /* ───── Step transitions ───── */ const steps = { email: document.getElementById('step-email'), - code: document.getElementById('step-code'), welcome: document.getElementById('step-welcome'), }; function showStep(name) { @@ -56,11 +64,33 @@ function showStep(name) { }); } +/* ───── Welcome headline — personalized when a first name is present. + Uses the Fenja "Definitive Emphasis" rule: Newsreader Bold Italic + on the terminal keyword + absolute period. Falls back to the + anonymous variant when firstName is null. ───── */ +function setWelcomeTitle(firstName) { + const el = document.getElementById('welcome-title'); + if (!el) return; + if (firstName) { + // Keep DOM construction to textContent + appended — no innerHTML + // of unsanitised input. firstName came from the server but we still + // construct the node tree explicitly for clarity. + el.textContent = 'Thanks for your interest, '; + const em = document.createElement('em'); + em.textContent = firstName + '.'; + el.appendChild(em); + } else { + el.textContent = 'Thank you for your '; + const em = document.createElement('em'); + em.textContent = 'interest.'; + el.appendChild(em); + } +} + /* ───── Step 1: email ───── */ const emailForm = document.getElementById('email-form'); const emailInput = document.getElementById('email-input'); const emailAck = document.getElementById('email-ack'); -let rememberedEmail = ''; function setAck(el, text, isError) { el.textContent = text; @@ -83,10 +113,10 @@ emailForm.addEventListener('submit', async (e) => { } emailInput.disabled = true; - setAck(emailAck, 'Sending\u2026', false); + setAck(emailAck, 'One moment\u2026', false); try { - const res = await fetch('/auth/request-code', { + const res = await fetch('/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -98,110 +128,35 @@ emailForm.addEventListener('submit', async (e) => { setAck(emailAck, 'Too many attempts. Try again in a little while.', true); return; } + + if (res.status === 403) { + // Not on the invite list. Honest message — we've decided + // enumeration is not a concern for this site. + emailInput.disabled = false; + emailInput.classList.add('is-error'); + setAck(emailAck, 'This email is not on the invite list.', true); + return; + } + if (!res.ok) { emailInput.disabled = false; setAck(emailAck, 'Something went wrong. Try again.', true); return; } + + // 200 OK — session cookie set by the server. Personalise the + // welcome step and advance. + const data = await res.json().catch(() => ({})); + setWelcomeTitle(data.firstName || null); + setAck(emailAck, '', false); + showStep('welcome'); } catch (err) { emailInput.disabled = false; setAck(emailAck, 'Could not reach the archive. Retry?', true); - return; } - - rememberedEmail = email; - setAck(emailAck, '', false); - showStep('code'); - setTimeout(() => codeCells[0].focus(), 300); }); -/* ───── Step 2: code ───── */ -const codeCells = Array.from(document.querySelectorAll('.code-cell')); -const codeForm = document.getElementById('code-form'); -const codeAck = document.getElementById('code-ack'); - -function codeValue() { return codeCells.map(c => c.value).join(''); } - -codeCells.forEach((cell, i) => { - cell.addEventListener('input', () => { - cell.value = cell.value.replace(/\D/g, '').slice(-1); - cell.classList.toggle('is-filled', !!cell.value); - cell.classList.remove('is-error'); - setAck(codeAck, '', false); - if (cell.value && i < codeCells.length - 1) codeCells[i + 1].focus(); - if (codeValue().length === 6) submitCode(); - }); - cell.addEventListener('keydown', (e) => { - if (e.key === 'Backspace' && !cell.value && i > 0) { - codeCells[i - 1].focus(); - codeCells[i - 1].value = ''; - codeCells[i - 1].classList.remove('is-filled'); - e.preventDefault(); - } else if (e.key === 'ArrowLeft' && i > 0) { codeCells[i - 1].focus(); e.preventDefault(); } - else if (e.key === 'ArrowRight' && i < codeCells.length - 1) { codeCells[i + 1].focus(); e.preventDefault(); } - }); - cell.addEventListener('paste', (e) => { - const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, ''); - if (!text) return; - e.preventDefault(); - for (let j = 0; j < codeCells.length; j++) { - codeCells[j].value = text[j] || ''; - codeCells[j].classList.toggle('is-filled', !!codeCells[j].value); - } - (codeCells[Math.min(text.length, codeCells.length - 1)] || codeCells[0]).focus(); - if (codeValue().length === 6) submitCode(); - }); -}); - -codeForm.addEventListener('submit', (e) => { e.preventDefault(); submitCode(); }); - -let submitting = false; -async function submitCode() { - if (submitting) return; - if (codeValue().length !== 6) return; - submitting = true; - codeCells.forEach(c => c.disabled = true); - setAck(codeAck, 'Reading\u2026', false); - - try { - const res = await fetch('/auth/verify-code', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: rememberedEmail, code: codeValue() }), - }); - - if (res.ok) { - setAck(codeAck, '', false); - showStep('welcome'); - return; - } - - codeCells.forEach(c => { c.classList.add('is-error'); c.disabled = false; }); - if (res.status === 429) { - setAck(codeAck, 'Too many attempts. Request a new code.', true); - } else { - setAck(codeAck, 'That code doesn\u2019t match. Try again.', true); - } - submitting = false; - } catch (err) { - codeCells.forEach(c => c.disabled = false); - setAck(codeAck, 'Could not reach the archive. Retry?', true); - submitting = false; - } -} - -document.getElementById('use-different').addEventListener('click', () => { - codeCells.forEach(c => { c.value = ''; c.classList.remove('is-filled', 'is-error'); c.disabled = false; }); - setAck(codeAck, '', false); - submitting = false; - emailInput.disabled = false; - emailInput.value = rememberedEmail; - showStep('email'); - setTimeout(() => emailInput.focus(), 300); -}); - -/* ───── Step 3: welcome → timeline ───── */ +/* ───── Step 2: welcome → timeline ───── */ document.getElementById('welcome-continue').addEventListener('click', () => { // Cross-document View Transitions animate this nav automatically on // supported browsers (Chrome/Safari). Firefox falls back to a plain nav. @@ -213,12 +168,18 @@ document.getElementById('welcome-continue').addEventListener('click', () => { // on whether the visitor already has a valid session. (async function routeOnLoad() { let authed = false; + let firstName = null; try { const res = await fetch('/auth/me', { credentials: 'same-origin' }); - authed = res.ok; + if (res.ok) { + authed = true; + const data = await res.json().catch(() => ({})); + firstName = data.firstName || null; + } } catch { /* offline — fall through to email */ } if (authed) { + setWelcomeTitle(firstName); showStep('welcome'); } else { showStep('email'); diff --git a/server.js b/server.js index e9e0511..e0653e9 100644 --- a/server.js +++ b/server.js @@ -12,18 +12,11 @@ import { fileURLToPath } from 'node:url'; import authRouter from './src/auth.js'; import { requireAuth } from './src/middleware.js'; -import { initMail } from './src/mail.js'; import './src/db.js'; // side-effect import: opens DB + runs schema const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); -// ─── Sanity checks at boot ─────────────────────────────────── -if (!process.env.CODE_PEPPER || process.env.CODE_PEPPER.length < 32) { - console.error('FATAL: CODE_PEPPER is missing or too short. Generate with:\n openssl rand -hex 32'); - process.exit(1); -} - // ─── Trust Nginx as the single upstream proxy ──────────────── // This makes req.ip reflect X-Forwarded-For (the real client IP) // instead of 127.0.0.1. Only "loopback" is trusted, so spoofed @@ -74,8 +67,7 @@ app.use('/auth', authRouter); // ─── Root dispatch ─────────────────────────────────────────── // GET / → always the entrance shell. If authed, entrance.js routes -// the user to the welcome step client-side (preserving the -// email/code UI as the no-session fallback). +// the user to the welcome step client-side. // GET /timeline → gated timeline page (protected/index.html). app.get('/', (req, res) => { return res.sendFile(path.join(__dirname, 'public', 'entrance.html')); @@ -126,20 +118,10 @@ app.use((err, req, res, _next) => { const port = Number(process.env.PORT || 3000); const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`; -(async () => { - try { - await initMail(); - console.log('[mail] SMTP relay reachable'); - } catch (err) { - console.error('[mail] SMTP verify failed:', err?.message || err); - console.error(' Fix .env and restart. The app will still boot so you can debug, but /auth/request-code will silently drop mail.'); - } - - app.listen(port, '127.0.0.1', () => { - console.log(`[bifrost] listening on 127.0.0.1:${port}`); - console.log(`[bifrost] public origin: ${origin}`); - }); -})(); +app.listen(port, '127.0.0.1', () => { + console.log(`[bifrost] listening on 127.0.0.1:${port}`); + console.log(`[bifrost] public origin: ${origin}`); +}); // ─── Graceful shutdown ─────────────────────────────────────── for (const sig of ['SIGINT', 'SIGTERM']) { diff --git a/src/auth.js b/src/auth.js index 61dad96..3a5f60b 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,39 +1,43 @@ // ───────────────────────────────────────────────────────────── // src/auth.js — /auth/* endpoints. // -// POST /auth/request-code { email } → 200 always (no enum leak) -// POST /auth/verify-code { email, code } → 200 on success + Set-Cookie -// POST /auth/logout → 200 +// POST /auth/login { email } → 200 {ok, firstName?} on success, +// 403 {error: "not_invited"} otherwise. +// POST /auth/logout → 200 +// GET /auth/me → 200 {email, firstName} | 401 +// +// There are no one-time codes anymore — the site is invite-list-only +// and the invite list IS the authentication factor. A person who knows +// an invited email can log in as them. The site exposes only +// marketing/preview content, so this is acceptable by design. If a user +// needs to be kicked out, remove their invite AND delete their session +// rows (see OPERATIONS.md). // ───────────────────────────────────────────────────────────── import { Router } from 'express'; import { q } from './db.js'; -import { sendCode } from './mail.js'; -import { - randomCode, - hashCode, - constantTimeEqual, - issueSession, - clearSession, - CODE_TTL_MS, -} from './sessions.js'; +import { issueSession, clearSession } from './sessions.js'; import { rateLimit } from './middleware.js'; const router = Router(); -const MAX_VERIFY_ATTEMPTS = 5; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; -// ─── POST /auth/request-code ───────────────────────────────── -// ALWAYS returns 200 (once past the format check) — we must not -// reveal whether an address is on the invite list. +// ─── POST /auth/login ──────────────────────────────────────── +// One API call handles the whole flow. On success we set the +// session cookie AND return the user's first name so the frontend +// can greet them without a second round-trip to /auth/me. +// +// Rate limit: 30 login attempts per IP per hour. Enough to cover +// legitimate re-logins after cookie loss, low enough to deter +// scripted enumeration of the invite list. router.post( - '/request-code', + '/login', rateLimit({ - key: (req) => `req:${req.ip}`, - max: 5, - windowMs: 60 * 60 * 1000, // 5 requests per IP per hour + key: (req) => `login:${req.ip}`, + max: 30, + windowMs: 60 * 60 * 1000, }), - async (req, res) => { + (req, res) => { const email = String(req.body?.email || '').trim().toLowerCase(); if (!EMAIL_RE.test(email)) { @@ -41,60 +45,17 @@ router.post( } const invited = q.getInvite.get(email); - if (invited) { - const code = randomCode(); - q.upsertCode.run(email, hashCode(code), Date.now() + CODE_TTL_MS); - - try { - await sendCode(email, code); - } catch (err) { - // Log, but still return 200 to the client. If SMTP is misconfigured - // we want to know *immediately* in the logs, not via users. - console.error('[auth] SMTP send failed for', email, err?.message || err); - } + if (!invited) { + // Honest "not invited" — we've decided enumeration is not a + // concern for this site (invite-list-only, preview content). + return res.status(403).json({ error: 'not_invited' }); } - // Uniform response regardless of invite status - return res.status(200).json({ ok: true }); - } -); - -// ─── POST /auth/verify-code ────────────────────────────────── -router.post( - '/verify-code', - rateLimit({ - key: (req) => `verify:${req.ip}`, - max: 20, - windowMs: 60 * 60 * 1000, // 20 verify attempts per IP per hour - }), - (req, res) => { - const email = String(req.body?.email || '').trim().toLowerCase(); - const code = String(req.body?.code || '').trim(); - - if (!EMAIL_RE.test(email) || !/^\d{6}$/.test(code)) { - return res.status(401).json({ error: 'invalid' }); - } - - const row = q.getCode.get(email, Date.now()); - if (!row) { - return res.status(401).json({ error: 'invalid_or_expired' }); - } - - if (row.attempts >= MAX_VERIFY_ATTEMPTS) { - q.deleteCode.run(email); // force user to request a new code - return res.status(429).json({ error: 'too_many_attempts' }); - } - - const submitted = hashCode(code); - if (!constantTimeEqual(submitted, row.code_hash)) { - q.incAttempts.run(email); - return res.status(401).json({ error: 'wrong_code' }); - } - - // Success: single-use — delete the code, issue a session - q.deleteCode.run(email); issueSession(req, res, email); - return res.status(200).json({ ok: true }); + return res.status(200).json({ + ok: true, + firstName: invited.first_name || null, + }); } ); @@ -104,11 +65,22 @@ router.post('/logout', (req, res) => { return res.status(200).json({ ok: true }); }); -// ─── GET /auth/me ─ convenience for debugging, returns current email or 401 +// ─── GET /auth/me ──────────────────────────────────────────── +// Returns the current user's email + first name, or 401 if no +// valid session. The frontend calls this on page load to decide +// which step of the entrance shell to show. router.get('/me', (req, res) => { const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null; if (!s) return res.status(401).end(); - return res.json({ email: s.email }); + + // Look up the invite row to fetch first_name. If the invite was + // deleted after the session was issued, first_name will be null + // and the frontend falls back to anonymous copy. + const invited = q.getInvite.get(s.email); + return res.json({ + email: s.email, + firstName: invited?.first_name || null, + }); }); export default router; diff --git a/src/db.js b/src/db.js index f5193ab..68af730 100644 --- a/src/db.js +++ b/src/db.js @@ -27,13 +27,6 @@ db.exec(` invited_by TEXT ); - CREATE TABLE IF NOT EXISTS codes ( - email TEXT PRIMARY KEY, - code_hash TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - expires_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, email TEXT NOT NULL, @@ -45,7 +38,6 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email); - CREATE INDEX IF NOT EXISTS idx_codes_expires ON codes(expires_at); CREATE TABLE IF NOT EXISTS rate_limits ( key TEXT PRIMARY KEY, @@ -56,28 +48,41 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end); `); +// ─── Migrations ────────────────────────────────────────────── +// In-place column additions for databases created by earlier versions. +// Each block is idempotent — checks for the column before altering. +// +// Migration 1: add `first_name` to invites. Introduced when the login +// flow switched from code-based verification to email-only lookup; the +// first name lives on the invite so the welcome screen can greet the +// user by name. NULL for legacy rows is fine — the frontend falls back +// to an anonymous copy when first_name is null. +const inviteCols = db.prepare(`PRAGMA table_info(invites)`).all(); +if (!inviteCols.some((c) => c.name === 'first_name')) { + db.exec(`ALTER TABLE invites ADD COLUMN first_name TEXT`); +} + +// Migration 2: drop the old `codes` table entirely. The code-based +// verification flow was removed when auth simplified to email-only +// login against the invite list. Safe to drop unconditionally — +// worst case this is a no-op on a fresh DB. +db.exec(`DROP TABLE IF EXISTS codes`); +db.exec(`DROP INDEX IF EXISTS idx_codes_expires`); + // ─── Prepared statements ───────────────────────────────────── export const q = { // invites - getInvite: db.prepare('SELECT email FROM invites WHERE email = ?'), + getInvite: db.prepare('SELECT email, first_name FROM invites WHERE email = ?'), upsertInvite: db.prepare( - `INSERT INTO invites (email, invited_at, invited_by) VALUES (?, ?, ?) - ON CONFLICT(email) DO NOTHING` + `INSERT INTO invites (email, first_name, invited_at, invited_by) VALUES (?, ?, ?, ?) + ON CONFLICT(email) DO UPDATE SET + first_name = excluded.first_name` ), deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'), - listInvites: db.prepare('SELECT email, invited_at, invited_by FROM invites ORDER BY invited_at DESC'), - - // codes - upsertCode: db.prepare( - `INSERT INTO codes (email, code_hash, attempts, expires_at) VALUES (?, ?, 0, ?) - ON CONFLICT(email) DO UPDATE SET - code_hash = excluded.code_hash, - attempts = 0, - expires_at = excluded.expires_at` + listInvites: db.prepare( + `SELECT email, first_name, invited_at, invited_by + FROM invites ORDER BY invited_at DESC` ), - getCode: db.prepare('SELECT code_hash, attempts, expires_at FROM codes WHERE email = ? AND expires_at > ?'), - incAttempts: db.prepare('UPDATE codes SET attempts = attempts + 1 WHERE email = ?'), - deleteCode: db.prepare('DELETE FROM codes WHERE email = ?'), // sessions createSession: db.prepare( @@ -99,7 +104,6 @@ export const q = { // cleanup cleanup: { - codes: db.prepare('DELETE FROM codes WHERE expires_at < ?'), sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'), rates: db.prepare('DELETE FROM rate_limits WHERE window_end < ?'), }, @@ -111,7 +115,6 @@ export const q = { const CLEANUP_EVERY_MS = 5 * 60 * 1000; setInterval(() => { const now = Date.now(); - q.cleanup.codes.run(now); q.cleanup.sessions.run(now); q.cleanup.rates.run(now); }, CLEANUP_EVERY_MS).unref(); diff --git a/src/mail.js b/src/mail.js deleted file mode 100644 index a52055d..0000000 --- a/src/mail.js +++ /dev/null @@ -1,53 +0,0 @@ -// ───────────────────────────────────────────────────────────── -// src/mail.js — SMTP transport + code email. -// -// STARTTLS on 587, per the chosen relay setup. -// transport.verify() is called once at startup from server.js so -// config errors fail loudly at boot, not on the first user's login. -// ───────────────────────────────────────────────────────────── -import nodemailer from 'nodemailer'; - -let transport; - -export function initMail() { - if (!process.env.SMTP_HOST) { - throw new Error('SMTP_HOST is not set. Check your .env file.'); - } - - transport = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 587), - secure: false, // STARTTLS on 587 - auth: - process.env.SMTP_USER && process.env.SMTP_PASS - ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } - : undefined, - // Sensible timeouts so a bad relay doesn't hang the login flow - connectionTimeout: 10_000, - greetingTimeout: 10_000, - socketTimeout: 15_000, - }); - - return transport.verify(); -} - -export async function sendCode(email, code) { - if (!transport) throw new Error('Mail transport not initialized. Call initMail() first.'); - - const from = process.env.MAIL_FROM || 'Fenja AI '; - - await transport.sendMail({ - from, - to: email, - subject: `Your Fenja code: ${code}`, - text: [ - `Your six-digit code is ${code}`, - ``, - `It is valid for ten minutes. If you did not request this, you can safely ignore this message.`, - ``, - `— Fenja`, - ].join('\n'), - // A plain-text mail has the best deliverability for transactional codes. - // Skip HTML entirely. - }); -} diff --git a/src/sessions.js b/src/sessions.js index 55f690f..9f6b38b 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -1,42 +1,20 @@ // ───────────────────────────────────────────────────────────── -// src/sessions.js — one-time codes + session cookies. +// src/sessions.js — session cookies. // -// Codes: 6 digits, HMAC-SHA256 with a server-side pepper. // Sessions: opaque 256-bit random IDs stored in SQLite, not JWTs. // Revoking a session is a DELETE; no signing keys to rotate. // ───────────────────────────────────────────────────────────── import crypto from 'node:crypto'; import { q } from './db.js'; -export const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export const COOKIE_NAME = 'fenja_session'; // ─── Crypto helpers ────────────────────────────────────────── -export function randomCode() { - // randomInt is cryptographically secure on Node 20+ - return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0'); -} - export function randomSessionId() { return crypto.randomBytes(32).toString('hex'); // 64 hex chars } -export function hashCode(code) { - const pepper = process.env.CODE_PEPPER; - if (!pepper || pepper.length < 32) { - throw new Error('CODE_PEPPER is missing or too short. Generate with: openssl rand -hex 32'); - } - return crypto.createHmac('sha256', pepper).update(code).digest('hex'); -} - -export function constantTimeEqual(a, b) { - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) return false; - return crypto.timingSafeEqual(bufA, bufB); -} - // ─── Session lifecycle ─────────────────────────────────────── export function issueSession(req, res, email) { const id = randomSessionId();