simplify login and update features

This commit is contained in:
Arlind Ukshini 2026-04-23 10:38:37 +02:00
parent f2f0f8a43e
commit 19a88f50b3
12 changed files with 318 additions and 412 deletions

View file

@ -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 <noreply@project-bifrost.fenja.ai>"
# Public origin of the site — used only in log output
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai

View file

@ -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.

View file

@ -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 <email>');
console.log(' invite add <email> [FirstName]');
console.log(' invite remove <email>');
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.`);
}

16
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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 @@
</form>
</section>
<!-- STEP 2 — CODE -->
<section class="step" id="step-code">
<p class="tagline">
A six-digit code is on its way. Enter it below.
</p>
<form class="field" id="code-form" novalidate>
<div class="code-row" id="code-row">
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 2" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 3" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 4" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 5" />
<input class="code-cell" type="text" inputmode="numeric" maxlength="1" aria-label="Digit 6" />
</div>
<div class="ack" id="code-ack" aria-live="polite"></div>
<button type="button" class="quiet" id="use-different">
<span>Use a different email</span>
<span class="q-arrow" aria-hidden="true">&rarr;</span>
</button>
</form>
</section>
<!-- STEP 3 — WELCOME -->
<!-- STEP 2 — WELCOME -->
<!-- The title is set dynamically by entrance.js:
with first name: "Thanks for your interest, <em>Erik.</em>"
without first name: "Thank you for your <em>interest.</em>"
Static fallback text (for no-JS) renders the anonymous variant. -->
<section class="step" id="step-welcome">
<h1 class="welcome-title">Welcome.</h1>
<h1 class="welcome-title" id="welcome-title">
Thank you for your <em>interest.</em>
</h1>
<p class="welcome-body">
Thank you for joining and for your interest in enabling sovereign AI
in Denmark and Europe. Project Bifrost is a deliberate effort to

View file

@ -2,6 +2,15 @@
// public/entrance.js — client-side behaviour for the entrance page.
// Loaded via <script src="/entrance.js" defer></script> 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 <em> — 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');

View file

@ -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']) {

View file

@ -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;

View file

@ -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();

View file

@ -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 <noreply@project-bifrost.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.
});
}

View file

@ -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();