simplify login and update features
This commit is contained in:
parent
f2f0f8a43e
commit
19a88f50b3
12 changed files with 318 additions and 412 deletions
14
.env.example
14
.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 <noreply@project-bifrost.fenja.ai>"
|
||||
|
||||
# Public origin of the site — used only in log output
|
||||
PUBLIC_ORIGIN=https://project-bifrost.fenja.ai
|
||||
|
||||
|
|
|
|||
132
AUTH_SIMPLIFICATION_NOTES.md
Normal file
132
AUTH_SIMPLIFICATION_NOTES.md
Normal 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.
|
||||
|
|
@ -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');
|
||||
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
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">→</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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
20
server.js
20
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}`);
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── Graceful shutdown ───────────────────────────────────────
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) {
|
||||
|
|
|
|||
118
src/auth.js
118
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/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;
|
||||
|
|
|
|||
53
src/db.js
53
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();
|
||||
|
|
|
|||
53
src/mail.js
53
src/mail.js
|
|
@ -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.
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue