179 lines
5.9 KiB
Markdown
179 lines
5.9 KiB
Markdown
# project-bifrost
|
|
|
|
Invite-only Fenja AI entrance and archive. Node/Express + SQLite + SMTP,
|
|
behind Nginx on a VPS.
|
|
|
|
## Local development (Windows / VS Code)
|
|
|
|
```powershell
|
|
# 1. Install deps
|
|
npm install
|
|
|
|
# 2. Configure
|
|
copy .env.example .env
|
|
# Edit .env:
|
|
# CODE_PEPPER — generate with: openssl rand -hex 32
|
|
# (or any 64-char hex string; use the same one in prod)
|
|
# SMTP_* — your relay credentials
|
|
# MAIL_FROM — e.g. "Fenja AI <noreply@project-bifrost.fenja.ai>"
|
|
|
|
# 3. Invite yourself
|
|
node bin/invite.js add your@email.com
|
|
|
|
# 4. Run
|
|
npm run dev
|
|
# → http://127.0.0.1:3000
|
|
|
|
# 5. Walk the flow:
|
|
# a) Open http://127.0.0.1:3000
|
|
# b) Enter your invited email → receive code by mail
|
|
# c) Type code → lands on /archive
|
|
# d) Hit http://127.0.0.1:3000/archive directly in a private window:
|
|
# should redirect to / (proves gating works)
|
|
```
|
|
|
|
## Deploying to the VPS
|
|
|
|
This assumes:
|
|
- VPS with Nginx already running and TLS for `project-bifrost.fenja.ai`
|
|
- Node 20+ installed
|
|
- A `fenja` system user owns `/opt/fenja`
|
|
|
|
### First-time setup
|
|
|
|
```bash
|
|
# On the VPS
|
|
sudo useradd -r -s /usr/sbin/nologin fenja
|
|
sudo mkdir -p /opt/fenja
|
|
sudo chown -R fenja:fenja /opt/fenja
|
|
```
|
|
|
|
### Pushing code from Windows
|
|
|
|
From the project root, sync with rsync (via WSL) or scp/WinSCP:
|
|
|
|
```powershell
|
|
# Example rsync (requires WSL or rsync on Windows)
|
|
rsync -avz --delete `
|
|
--exclude node_modules --exclude data --exclude .env --exclude .git `
|
|
./ user@vps:/opt/fenja/
|
|
```
|
|
|
|
Then on the VPS:
|
|
|
|
```bash
|
|
cd /opt/fenja
|
|
sudo -u fenja npm ci --omit=dev
|
|
sudo chmod 750 /opt/fenja/protected
|
|
sudo chmod 750 /opt/fenja/data
|
|
```
|
|
|
|
Create `/opt/fenja/.env` in place (do NOT rsync it — keep secrets off your laptop):
|
|
|
|
```bash
|
|
sudo -u fenja nano /opt/fenja/.env
|
|
sudo chmod 600 /opt/fenja/.env
|
|
```
|
|
|
|
Install the systemd unit and Nginx config:
|
|
|
|
```bash
|
|
sudo cp deploy/fenja.service /etc/systemd/system/fenja.service
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now fenja
|
|
sudo systemctl status fenja # should be "active (running)"
|
|
|
|
sudo cp deploy/nginx.conf /etc/nginx/sites-available/project-bifrost
|
|
sudo ln -sf /etc/nginx/sites-available/project-bifrost \
|
|
/etc/nginx/sites-enabled/project-bifrost
|
|
# Also ensure the rate-limit zone is declared — see deploy/nginx.conf for the one-liner
|
|
sudo nginx -t
|
|
sudo systemctl reload nginx
|
|
```
|
|
|
|
### Verifying from outside
|
|
|
|
```bash
|
|
curl -I https://project-bifrost.fenja.ai/
|
|
# 200 + HTML (the entrance)
|
|
|
|
curl -I https://project-bifrost.fenja.ai/archive
|
|
# 302 → /
|
|
|
|
curl -I https://project-bifrost.fenja.ai/protected/archive.html
|
|
# 404 (this URL literally does not exist)
|
|
|
|
curl -i -X POST https://project-bifrost.fenja.ai/auth/request-code \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"email":"nobody@example.com"}'
|
|
# 200 + {"ok":true} (no mail is actually sent; endpoint doesn't leak)
|
|
```
|
|
|
|
### Managing invites on the server
|
|
|
|
```bash
|
|
sudo -u fenja node /opt/fenja/bin/invite.js add someone@example.com
|
|
sudo -u fenja node /opt/fenja/bin/invite.js list
|
|
sudo -u fenja node /opt/fenja/bin/invite.js remove someone@example.com
|
|
```
|
|
|
|
### Reading Join-CTA clicks
|
|
|
|
Every press of the final "Join Project Bifrost" CTA is logged to the `bifrost_joins` table. Read it with:
|
|
|
|
```bash
|
|
sudo -u fenja node /opt/fenja/bin/joins.js list # every click, newest first
|
|
sudo -u fenja node /opt/fenja/bin/joins.js summary # one row per user, with counts
|
|
sudo -u fenja node /opt/fenja/bin/joins.js for someone@example.com
|
|
sudo -u fenja node /opt/fenja/bin/joins.js stats # totals + unique users
|
|
```
|
|
|
|
### Backups
|
|
|
|
Add a cron job:
|
|
|
|
```
|
|
# /etc/cron.d/fenja-backup
|
|
0 3 * * * fenja sqlite3 /opt/fenja/data/fenja.sqlite ".backup /opt/fenja/data/backup-$(date +\%F).sqlite"
|
|
0 4 * * * fenja find /opt/fenja/data -name 'backup-*.sqlite' -mtime +14 -delete
|
|
```
|
|
|
|
`.backup` is safe on a live SQLite DB. `cp` is not.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
project-bifrost/
|
|
├── package.json
|
|
├── server.js # entry point — binds 127.0.0.1:3000
|
|
├── .env.example # copy to .env
|
|
├── .gitignore
|
|
├── bin/
|
|
│ ├── invite.js # CLI: add / remove / list invites
|
|
│ └── joins.js # CLI: read the Join-CTA click log
|
|
├── src/
|
|
│ ├── db.js # SQLite schema + prepared statements
|
|
│ ├── sessions.js # codes, cookies, HMAC
|
|
│ ├── mail.js # Nodemailer SMTP transport
|
|
│ ├── middleware.js # rateLimit, requireAuth
|
|
│ └── auth.js # /auth/* router
|
|
├── public/ # served to anyone
|
|
│ └── index.html # the Entrance page
|
|
├── protected/ # served only with a valid session cookie
|
|
│ └── archive.html # placeholder for now; drop the real page here
|
|
├── data/ # created on first run — SQLite lives here
|
|
└── deploy/
|
|
├── nginx.conf
|
|
└── fenja.service
|
|
```
|
|
|
|
## Security model at a glance
|
|
|
|
- **Static HTML is never served from a public directory for `/archive`.** Node checks the session cookie *before* reading the file off disk. No cookie → 302 → / (the file bytes never leave the server).
|
|
- **The session cookie is `HttpOnly`, `Secure`, `SameSite=Lax`.** JavaScript on the page cannot read or steal it.
|
|
- **Sessions are opaque 256-bit random IDs** stored in SQLite. Revoking is a DELETE.
|
|
- **One-time codes are HMAC'd with a server-side pepper** (`CODE_PEPPER`) before storage. A DB leak alone cannot reveal codes.
|
|
- **Rate limits on everything.** 5 code requests per IP per hour, 20 verify attempts per IP per hour, 5 wrong guesses per code before it's nuked.
|
|
- **`/auth/request-code` always returns 200** — attackers cannot probe the invite list by email-enumeration.
|
|
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress; there is no public Node port.
|
|
- **Strict CSP** blocks inline scripts and foreign origins from the gated pages.
|