# project-bifrost Invite-only Fenja AI entrance and archive. Node/Express + SQLite, behind Nginx on a VPS. Auth is email-only against an invite list (no SMTP, no one-time codes). ## Local development (Windows / VS Code) ```powershell # 1. Install deps npm install # 2. Configure copy .env.example .env # .env is minimal — PORT, PUBLIC_ORIGIN, NODE_ENV only. Leave # NODE_ENV=development locally so cookies work over HTTP. # 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 → session cookie issued, welcome step renders # c) Click "Learn more" → lands on /timeline (the authed home page) # d) Hit http://127.0.0.1:3000/timeline 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 `/etc/fenja/env` (kept out of `/opt/fenja/` so it can't be rsynced over). The file is minimal — see `.env.example` for the three values (`PORT`, `PUBLIC_ORIGIN`, `NODE_ENV=production`): ```bash sudo nano /etc/fenja/env sudo chown root:fenja /etc/fenja/env sudo chmod 640 /etc/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/timeline # 302 → / (gate holds without a session cookie) curl -I https://project-bifrost.fenja.ai/protected/index.html # 404 (this URL literally does not exist) curl -i -X POST https://project-bifrost.fenja.ai/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"nobody@example.com"}' # 403 + {"error":"not_invited"} (invite-list-only; enumeration is acceptable by design) ``` ### 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 # Admin flag (gates the hidden /fenjaops page). Grant/revoke is CLI-only. sudo -u fenja node /opt/fenja/bin/invite.js admin add someone@example.com sudo -u fenja node /opt/fenja/bin/invite.js admin remove someone@example.com sudo -u fenja node /opt/fenja/bin/invite.js admin list ``` Admins can also add *non-admin* invites from the hidden `/fenjaops` page (see OPERATIONS.md). Admin promotion and invite removal stay on the CLI by design — see PROJECT.md §Non-negotiable properties. ### 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 # opaque 256-bit session cookies │ ├── middleware.js # rateLimit, requireAuth │ └── auth.js # /auth/login, /auth/logout, /auth/me ├── public/ # served to anyone │ ├── entrance.html # the Entrance page (email form → welcome) │ └── entrance.js # entrance form behaviour ├── protected/ # served only with a valid session cookie │ ├── index.html # the timeline (authed home page) │ ├── timeline.js # horizontal timeline + dot-nav + globe │ └── bifrost.js # Overview page scroll scenes ├── admin/ # served at /fenjaops, gated by requireAuth+requireAdmin │ ├── index.html # stats + invite list + join log + "Invite a user" form │ ├── admin.css │ └── admin.js ├── 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 gated pages.** 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. `Secure` is conditional on `NODE_ENV=production`. - **Sessions are opaque 256-bit random IDs** stored in SQLite. Revoking is a DELETE. - **Auth is invite-list-only.** `POST /auth/login` with an email on the list issues a session; an email not on the list returns `403 {error:"not_invited"}`. There are no one-time codes, no pepper, no SMTP — the invite list *is* the auth factor. Enumeration is acceptable by design (preview content). - **Rate limit**: 30 login attempts per IP per hour. - **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.