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 # Node listens on this port, bound to 127.0.0.1 only
PORT=3000 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 of the site — used only in log output
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai 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. // bin/invite.js — add / remove / list invites.
// //
// Usage: // 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 -- remove someone@example.com
// npm run invite -- list // npm run invite -- list
// //
// Or directly: // 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'; import { q } from '../src/db.js';
const [, , cmd, arg] = process.argv; const [, , cmd, emailArg, nameArg] = process.argv;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() { function help() {
console.log('Usage:'); console.log('Usage:');
console.log(' invite add <email>'); console.log(' invite add <email> [FirstName]');
console.log(' invite remove <email>'); console.log(' invite remove <email>');
console.log(' invite list'); console.log(' invite list');
process.exit(1); process.exit(1);
@ -25,15 +32,20 @@ function help() {
switch (cmd) { switch (cmd) {
case 'add': { case 'add': {
if (!arg || !EMAIL_RE.test(arg)) help(); if (!emailArg || !EMAIL_RE.test(emailArg)) help();
const email = arg.trim().toLowerCase(); const email = emailArg.trim().toLowerCase();
q.upsertInvite.run(email, Date.now(), 'cli'); const firstName = nameArg ? nameArg.trim() : null;
console.log(`Invited ${email}`); q.upsertInvite.run(email, firstName, Date.now(), 'cli');
if (firstName) {
console.log(`Invited ${email} (${firstName})`);
} else {
console.log(`Invited ${email}`);
}
break; break;
} }
case 'remove': { case 'remove': {
if (!arg || !EMAIL_RE.test(arg)) help(); if (!emailArg || !EMAIL_RE.test(emailArg)) help();
const email = arg.trim().toLowerCase(); const email = emailArg.trim().toLowerCase();
const result = q.deleteInvite.run(email); const result = q.deleteInvite.run(email);
console.log(result.changes > 0 ? `Removed ${email}` : `No invite for ${email}`); console.log(result.changes > 0 ? `Removed ${email}` : `No invite for ${email}`);
break; break;
@ -45,7 +57,9 @@ switch (cmd) {
} else { } else {
for (const r of rows) { for (const r of rows) {
const d = new Date(r.invited_at).toISOString().slice(0, 10); 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.`); 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", "name": "project-bifrost",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-bifrost", "name": "project-bifrost",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.0", "express": "^4.21.0"
"nodemailer": "^8.0.5"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -781,15 +780,6 @@
"node": ">=10" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View file

@ -1,7 +1,7 @@
{ {
"name": "project-bifrost", "name": "project-bifrost",
"version": "0.1.0", "version": "0.2.0",
"description": "Fenja AI — invite-only entrance, Node/Express, SQLite, SMTP.", "description": "Fenja AI — invite-only entrance, Node/Express, SQLite.",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@ -16,7 +16,6 @@
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.0", "express": "^4.21.0"
"nodemailer": "^8.0.5"
} }
} }

View file

@ -123,34 +123,6 @@
} }
.field-input:disabled { opacity: 0.55; cursor: default; } .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-step wordmark (centered in the right half) ───── */
.welcome-logo { .welcome-logo {
position: fixed; position: fixed;
@ -172,7 +144,6 @@
.welcome-title { .welcome-title {
font-family: "Newsreader", Georgia, "Times New Roman", serif; font-family: "Newsreader", Georgia, "Times New Roman", serif;
font-weight: 400; font-weight: 400;
font-style: italic;
font-size: 54px; font-size: 54px;
line-height: 1.05; line-height: 1.05;
letter-spacing: -0.022em; letter-spacing: -0.022em;
@ -180,6 +151,14 @@
margin: 0 0 28px 0; margin: 0 0 28px 0;
text-wrap: pretty; 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 { .welcome-body {
font-family: "Newsreader", Georgia, "Times New Roman", serif; font-family: "Newsreader", Georgia, "Times New Roman", serif;
font-weight: 400; font-weight: 400;
@ -262,30 +241,9 @@
.ack.is-visible { opacity: 1; transform: translateY(0); } .ack.is-visible { opacity: 1; transform: translateY(0); }
.ack.is-error { color: var(--crimson); } .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) { @media (max-width: 720px) {
.entrance { padding: 0 28px; } .entrance { padding: 0 28px; }
.tagline { font-size: 26px; margin-bottom: 32px; } .tagline { font-size: 26px; margin-bottom: 32px; }
.code-cell { width: 42px; height: 54px; font-size: 22px; }
.code-row { gap: 7px; }
.currents { opacity: 0.5; } .currents { opacity: 0.5; }
.welcome-title { font-size: 38px; } .welcome-title { font-size: 38px; }
.welcome-body { font-size: 16.5px; } .welcome-body { font-size: 16.5px; }
@ -323,31 +281,15 @@
</form> </form>
</section> </section>
<!-- STEP 2 — CODE --> <!-- STEP 2 — WELCOME -->
<section class="step" id="step-code"> <!-- The title is set dynamically by entrance.js:
<p class="tagline"> with first name: "Thanks for your interest, <em>Erik.</em>"
A six-digit code is on its way. Enter it below. without first name: "Thank you for your <em>interest.</em>"
</p> Static fallback text (for no-JS) renders the anonymous variant. -->
<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 -->
<section class="step" id="step-welcome"> <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"> <p class="welcome-body">
Thank you for joining and for your interest in enabling sovereign AI Thank you for joining and for your interest in enabling sovereign AI
in Denmark and Europe. Project Bifrost is a deliberate effort to 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. // public/entrance.js — client-side behaviour for the entrance page.
// Loaded via <script src="/entrance.js" defer></script> so CSP // Loaded via <script src="/entrance.js" defer></script> so CSP
// can stay at `script-src 'self'` — no inline scripts. // 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 ───── */ /* ───── Topographic currents ───── */
@ -47,7 +56,6 @@
/* ───── Step transitions ───── */ /* ───── Step transitions ───── */
const steps = { const steps = {
email: document.getElementById('step-email'), email: document.getElementById('step-email'),
code: document.getElementById('step-code'),
welcome: document.getElementById('step-welcome'), welcome: document.getElementById('step-welcome'),
}; };
function showStep(name) { 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 ───── */ /* ───── Step 1: email ───── */
const emailForm = document.getElementById('email-form'); const emailForm = document.getElementById('email-form');
const emailInput = document.getElementById('email-input'); const emailInput = document.getElementById('email-input');
const emailAck = document.getElementById('email-ack'); const emailAck = document.getElementById('email-ack');
let rememberedEmail = '';
function setAck(el, text, isError) { function setAck(el, text, isError) {
el.textContent = text; el.textContent = text;
@ -83,10 +113,10 @@ emailForm.addEventListener('submit', async (e) => {
} }
emailInput.disabled = true; emailInput.disabled = true;
setAck(emailAck, 'Sending\u2026', false); setAck(emailAck, 'One moment\u2026', false);
try { try {
const res = await fetch('/auth/request-code', { const res = await fetch('/auth/login', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, 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); setAck(emailAck, 'Too many attempts. Try again in a little while.', true);
return; 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) { if (!res.ok) {
emailInput.disabled = false; emailInput.disabled = false;
setAck(emailAck, 'Something went wrong. Try again.', true); setAck(emailAck, 'Something went wrong. Try again.', true);
return; 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) { } catch (err) {
emailInput.disabled = false; emailInput.disabled = false;
setAck(emailAck, 'Could not reach the archive. Retry?', true); 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 ───── */ /* ───── Step 2: welcome → timeline ───── */
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 ───── */
document.getElementById('welcome-continue').addEventListener('click', () => { document.getElementById('welcome-continue').addEventListener('click', () => {
// Cross-document View Transitions animate this nav automatically on // Cross-document View Transitions animate this nav automatically on
// supported browsers (Chrome/Safari). Firefox falls back to a plain nav. // 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. // on whether the visitor already has a valid session.
(async function routeOnLoad() { (async function routeOnLoad() {
let authed = false; let authed = false;
let firstName = null;
try { try {
const res = await fetch('/auth/me', { credentials: 'same-origin' }); 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 */ } } catch { /* offline — fall through to email */ }
if (authed) { if (authed) {
setWelcomeTitle(firstName);
showStep('welcome'); showStep('welcome');
} else { } else {
showStep('email'); showStep('email');

View file

@ -12,18 +12,11 @@ import { fileURLToPath } from 'node:url';
import authRouter from './src/auth.js'; import authRouter from './src/auth.js';
import { requireAuth } from './src/middleware.js'; import { requireAuth } from './src/middleware.js';
import { initMail } from './src/mail.js';
import './src/db.js'; // side-effect import: opens DB + runs schema import './src/db.js'; // side-effect import: opens DB + runs schema
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); 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 ──────────────── // ─── Trust Nginx as the single upstream proxy ────────────────
// This makes req.ip reflect X-Forwarded-For (the real client IP) // This makes req.ip reflect X-Forwarded-For (the real client IP)
// instead of 127.0.0.1. Only "loopback" is trusted, so spoofed // instead of 127.0.0.1. Only "loopback" is trusted, so spoofed
@ -74,8 +67,7 @@ app.use('/auth', authRouter);
// ─── Root dispatch ─────────────────────────────────────────── // ─── Root dispatch ───────────────────────────────────────────
// GET / → always the entrance shell. If authed, entrance.js routes // GET / → always the entrance shell. If authed, entrance.js routes
// the user to the welcome step client-side (preserving the // the user to the welcome step client-side.
// email/code UI as the no-session fallback).
// GET /timeline → gated timeline page (protected/index.html). // GET /timeline → gated timeline page (protected/index.html).
app.get('/', (req, res) => { app.get('/', (req, res) => {
return res.sendFile(path.join(__dirname, 'public', 'entrance.html')); 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 port = Number(process.env.PORT || 3000);
const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`; const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`;
(async () => { app.listen(port, '127.0.0.1', () => {
try { console.log(`[bifrost] listening on 127.0.0.1:${port}`);
await initMail(); console.log(`[bifrost] public origin: ${origin}`);
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}`);
});
})();
// ─── Graceful shutdown ─────────────────────────────────────── // ─── Graceful shutdown ───────────────────────────────────────
for (const sig of ['SIGINT', 'SIGTERM']) { for (const sig of ['SIGINT', 'SIGTERM']) {

View file

@ -1,39 +1,43 @@
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// src/auth.js — /auth/* endpoints. // src/auth.js — /auth/* endpoints.
// //
// POST /auth/request-code { email } → 200 always (no enum leak) // POST /auth/login { email } → 200 {ok, firstName?} on success,
// POST /auth/verify-code { email, code } → 200 on success + Set-Cookie // 403 {error: "not_invited"} otherwise.
// POST /auth/logout → 200 // 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 { Router } from 'express';
import { q } from './db.js'; import { q } from './db.js';
import { sendCode } from './mail.js'; import { issueSession, clearSession } from './sessions.js';
import {
randomCode,
hashCode,
constantTimeEqual,
issueSession,
clearSession,
CODE_TTL_MS,
} from './sessions.js';
import { rateLimit } from './middleware.js'; import { rateLimit } from './middleware.js';
const router = Router(); const router = Router();
const MAX_VERIFY_ATTEMPTS = 5;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ─── POST /auth/request-code ───────────────────────────────── // ─── POST /auth/login ────────────────────────────────────────
// ALWAYS returns 200 (once past the format check) — we must not // One API call handles the whole flow. On success we set the
// reveal whether an address is on the invite list. // 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( router.post(
'/request-code', '/login',
rateLimit({ rateLimit({
key: (req) => `req:${req.ip}`, key: (req) => `login:${req.ip}`,
max: 5, max: 30,
windowMs: 60 * 60 * 1000, // 5 requests per IP per hour windowMs: 60 * 60 * 1000,
}), }),
async (req, res) => { (req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase(); const email = String(req.body?.email || '').trim().toLowerCase();
if (!EMAIL_RE.test(email)) { if (!EMAIL_RE.test(email)) {
@ -41,60 +45,17 @@ router.post(
} }
const invited = q.getInvite.get(email); const invited = q.getInvite.get(email);
if (invited) { if (!invited) {
const code = randomCode(); // Honest "not invited" — we've decided enumeration is not a
q.upsertCode.run(email, hashCode(code), Date.now() + CODE_TTL_MS); // concern for this site (invite-list-only, preview content).
return res.status(403).json({ error: 'not_invited' });
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);
}
} }
// 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); 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 }); 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) => { router.get('/me', (req, res) => {
const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null; const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null;
if (!s) return res.status(401).end(); 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; export default router;

View file

@ -27,13 +27,6 @@ db.exec(`
invited_by TEXT 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 ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT NOT NULL, 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_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email); 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 ( CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -56,28 +48,41 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end); 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 ───────────────────────────────────── // ─── Prepared statements ─────────────────────────────────────
export const q = { export const q = {
// invites // invites
getInvite: db.prepare('SELECT email FROM invites WHERE email = ?'), getInvite: db.prepare('SELECT email, first_name FROM invites WHERE email = ?'),
upsertInvite: db.prepare( upsertInvite: db.prepare(
`INSERT INTO invites (email, invited_at, invited_by) VALUES (?, ?, ?) `INSERT INTO invites (email, first_name, invited_at, invited_by) VALUES (?, ?, ?, ?)
ON CONFLICT(email) DO NOTHING` ON CONFLICT(email) DO UPDATE SET
first_name = excluded.first_name`
), ),
deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'), deleteInvite: db.prepare('DELETE FROM invites WHERE email = ?'),
listInvites: db.prepare('SELECT email, invited_at, invited_by FROM invites ORDER BY invited_at DESC'), listInvites: db.prepare(
`SELECT email, first_name, invited_at, invited_by
// codes FROM invites ORDER BY invited_at DESC`
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`
), ),
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 // sessions
createSession: db.prepare( createSession: db.prepare(
@ -99,7 +104,6 @@ export const q = {
// cleanup // cleanup
cleanup: { cleanup: {
codes: db.prepare('DELETE FROM codes WHERE expires_at < ?'),
sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'), sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'),
rates: db.prepare('DELETE FROM rate_limits WHERE window_end < ?'), rates: db.prepare('DELETE FROM rate_limits WHERE window_end < ?'),
}, },
@ -111,7 +115,6 @@ export const q = {
const CLEANUP_EVERY_MS = 5 * 60 * 1000; const CLEANUP_EVERY_MS = 5 * 60 * 1000;
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
q.cleanup.codes.run(now);
q.cleanup.sessions.run(now); q.cleanup.sessions.run(now);
q.cleanup.rates.run(now); q.cleanup.rates.run(now);
}, CLEANUP_EVERY_MS).unref(); }, 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. // Sessions: opaque 256-bit random IDs stored in SQLite, not JWTs.
// Revoking a session is a DELETE; no signing keys to rotate. // Revoking a session is a DELETE; no signing keys to rotate.
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { q } from './db.js'; 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 SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export const COOKIE_NAME = 'fenja_session'; export const COOKIE_NAME = 'fenja_session';
// ─── Crypto helpers ────────────────────────────────────────── // ─── 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() { export function randomSessionId() {
return crypto.randomBytes(32).toString('hex'); // 64 hex chars 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 ─────────────────────────────────────── // ─── Session lifecycle ───────────────────────────────────────
export function issueSession(req, res, email) { export function issueSession(req, res, email) {
const id = randomSessionId(); const id = randomSessionId();