Icons grow 72px → 148px and shift to top:-18px right:-18px so they overflow the card edges. .m-cap gets overflow:hidden so the bleed is clipped at the tile boundary — icon reads as an ornament tucked into the top-right corner rather than a floating sticker. Num, eyebrow, and title rows bump their right padding from 92 to 120 so the text still clears the icon's visible silhouette. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| admin | ||
| bin | ||
| data | ||
| deploy | ||
| protected | ||
| public | ||
| src | ||
| .env.example | ||
| .gitignore | ||
| AUTH_SIMPLIFICATION_NOTES.md | ||
| CHANGES 2.md | ||
| CHANGES 3.md | ||
| CHANGES 4.md | ||
| CHANGES.md | ||
| CHECKLIST.md | ||
| CLAUDE.md | ||
| INSTALL.md | ||
| MERGE_NOTES.md | ||
| OPERATIONS.md | ||
| package-lock.json | ||
| package.json | ||
| PROJECT.md | ||
| README.md | ||
| server.js | ||
| SITE_STRUCTURE_REFERENCE.md | ||
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)
# 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
fenjasystem user owns/opt/fenja
First-time setup
# 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:
# 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:
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):
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:
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
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
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:
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.Secureis conditional onNODE_ENV=production. - Sessions are opaque 256-bit random IDs stored in SQLite. Revoking is a DELETE.
- Auth is invite-list-only.
POST /auth/loginwith an email on the list issues a session; an email not on the list returns403 {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.1only. Nginx is the single ingress; there is no public Node port. - Strict CSP blocks inline scripts and foreign origins from the gated pages.