- new is_admin column on invites (migration 4) with DEFAULT 0
- requireAdmin middleware returns 404 for non-admins so the route's
existence isn't leaked; path obscured as /fenjaops (not /admin)
- admin/ dir lives outside public/ and protected/; only reachable via
the explicit gated mount + /api/fenjaops/{invites,joins} endpoints
- bin/invite.js gains `admin add|remove|list` subcommands
- OPERATIONS.md + CLAUDE.md + PROJECT.md document the hidden URL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
7.3 KiB
JavaScript
174 lines
7.3 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
|
// server.js — entry point.
|
|
//
|
|
// Binds to 127.0.0.1 only. Nginx reverse-proxies to this port.
|
|
// Never expose Node directly to the public internet.
|
|
// ─────────────────────────────────────────────────────────────
|
|
import 'dotenv/config';
|
|
import express from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import authRouter from './src/auth.js';
|
|
import { requireAuth, requireAdmin } from './src/middleware.js';
|
|
import { q } from './src/db.js'; // also side-effect: opens DB + runs schema
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const app = express();
|
|
|
|
// ─── 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
|
|
// XFF headers from the public internet are ignored.
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
// ─── Body parsers ────────────────────────────────────────────
|
|
app.use(express.json({ limit: '4kb' }));
|
|
app.use(cookieParser());
|
|
|
|
// ─── Security headers on every response ──────────────────────
|
|
app.use((req, res, next) => {
|
|
res.set({
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Frame-Options': 'DENY',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
// Tight CSP — only our own origin, and inline <style> blocks are allowed
|
|
// because the design system ships styles inline inside the artifacts.
|
|
'Content-Security-Policy': [
|
|
"default-src 'self'",
|
|
"script-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"font-src 'self'",
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join('; '),
|
|
});
|
|
next();
|
|
});
|
|
|
|
// ─── Tiny request log ────────────────────────────────────────
|
|
app.use((req, res, next) => {
|
|
const started = Date.now();
|
|
res.on('finish', () => {
|
|
const ms = Date.now() - started;
|
|
const line = `${new Date().toISOString()} ${req.ip} ${req.method} ${req.path} → ${res.statusCode} (${ms}ms)`;
|
|
console.log(line);
|
|
});
|
|
next();
|
|
});
|
|
|
|
// ─── Auth endpoints (public) ─────────────────────────────────
|
|
app.use('/auth', authRouter);
|
|
|
|
// ─── Bifrost join tracking (gated) ───────────────────────────
|
|
// Records every click of the final CTA button as its own row so the
|
|
// full history per user is preserved (who, when, which session).
|
|
app.post('/api/bifrost-join', requireAuth, (req, res) => {
|
|
const { email, id: sessionId } = req.session;
|
|
const clickedAt = Date.now();
|
|
q.recordJoin.run(email, clickedAt, sessionId);
|
|
res.json({ clicked_at: clickedAt });
|
|
});
|
|
|
|
// ─── Admin surface (gated, unlisted) ─────────────────────────
|
|
// Accessible only to invite rows with `is_admin=1`. Non-admins —
|
|
// authed or not — get a plain 404 from requireAdmin so the route's
|
|
// existence is not leaked. Admin files live in ./admin/ (outside
|
|
// both public/ and protected/) so they can only be reached through
|
|
// these explicit routes.
|
|
//
|
|
// The public URL path is deliberately obscure — `/fenjaops` rather
|
|
// than `/admin` — so scripted probes of common admin paths miss.
|
|
// Internal names (files, middleware, CLI subcommand) stay as "admin"
|
|
// since the obscurity is a URL concern only, not an identity one.
|
|
// Grant admin via:
|
|
// node bin/invite.js admin add <email>
|
|
app.use('/fenjaops',
|
|
requireAuth,
|
|
requireAdmin,
|
|
express.static(path.join(__dirname, 'admin'), { index: 'index.html', maxAge: 0 })
|
|
);
|
|
|
|
app.get('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
|
|
res.json(q.listInvites.all());
|
|
});
|
|
|
|
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
|
|
res.json({
|
|
clicks: q.listJoins.all(),
|
|
summary: q.summariseJoins.all(),
|
|
total_clicks: q.countJoins.get().n,
|
|
unique_users: q.countUniqueJoiners.get().n,
|
|
});
|
|
});
|
|
|
|
// ─── Root dispatch ───────────────────────────────────────────
|
|
// GET / → always the entrance shell. If authed, entrance.js routes
|
|
// 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'));
|
|
});
|
|
|
|
app.get('/timeline', requireAuth, (req, res) => {
|
|
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
|
|
});
|
|
|
|
// ─── Public static assets (entrance.js, etc.) ────────────────
|
|
// Fallthrough so Express can still try the routes below if nothing matches.
|
|
app.use(
|
|
express.static(path.join(__dirname, 'public'), {
|
|
index: false, // don't auto-serve entrance.html at /
|
|
extensions: ['html'],
|
|
fallthrough: true,
|
|
maxAge: 0,
|
|
})
|
|
);
|
|
|
|
// ─── GATED — everything in protected/ needs a session cookie ─
|
|
// Handles the authed home page assets:
|
|
// /timeline.js, /vendor/*, /fenja/colors_and_type.css, /archive.html, etc.
|
|
app.use(
|
|
requireAuth,
|
|
express.static(path.join(__dirname, 'protected'), {
|
|
index: false, // we dispatch / ourselves above
|
|
extensions: ['html'],
|
|
maxAge: 0,
|
|
})
|
|
);
|
|
|
|
// ─── 404 fallback ────────────────────────────────────────────
|
|
app.use((req, res) => {
|
|
if (req.accepts('html')) {
|
|
return res.status(404).send('<h1>404 — not found</h1>');
|
|
}
|
|
res.status(404).end();
|
|
});
|
|
|
|
// ─── Error handler ───────────────────────────────────────────
|
|
app.use((err, req, res, _next) => {
|
|
console.error('[error]', err);
|
|
res.status(500).json({ error: 'internal' });
|
|
});
|
|
|
|
// ─── Start ───────────────────────────────────────────────────
|
|
const port = Number(process.env.PORT || 3000);
|
|
const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`;
|
|
|
|
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']) {
|
|
process.on(sig, () => {
|
|
console.log(`[bifrost] ${sig} received, exiting.`);
|
|
process.exit(0);
|
|
});
|
|
}
|