Minimal authentication system
This prompt adds user authentication to the LFTI spine. Read the existing codebase fully before starting — particularly server.js, db/database.js, and api/nodes.js.
This prompt adds user authentication to the LFTI spine. Read the existing codebase fully before starting — particularly server.js, db/database.js, and api/nodes.js. Understand what exists before adding to it.
The goal is the smallest correct auth system that supports role-based access and institution scoping, is easy to extend later, and doesn’t break anything currently working.
Design principles (do not violate)
- Simple over complete. No OAuth, no email verification, no password reset flow yet. Those come later. Build what’s needed now.
- Additive only. Nothing currently working should break. The API endpoints continue to work. The frontend continues to load. Auth wraps the existing system, it doesn’t replace it.
- Users are also nodes. Every user has a corresponding node in the graph with
type: "person". The user table is the auth layer only — all relationships live in the graph. - Escape hatch preserved. The
meta{}field on the user table follows the same convention as nodes. - Local dev friendly. Must be testable with multiple users from one machine using different browsers or incognito windows.
Database changes
Add to db/database.js:
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'student',
institution_id TEXT,
node_id TEXT,
created_at TEXT NOT NULL,
meta TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
Roles (stored as strings, checked in middleware):
superadmin— sees everything, can create institutions and usersinstitution_admin— manages their institution’s projects and usersfacilitator— runs sessions within projects they’re assigned tostudent— contributes to sessions they’re part ofobserver— read-only access to approved artefacts
Password hashing: Use bcrypt with 10 salt rounds. Add bcrypt to package.json dependencies.
Session tokens: Generate with crypto.randomBytes(32).toString('hex'). Sessions expire after 7 days. Clean up expired sessions on server start.
Seed users for development
On first run (if users table is empty), seed these development users automatically so the system is immediately testable:
const devUsers = [
{
id: 'user_superadmin',
name: 'Super Admin',
email: 'admin@lfti.dev',
password: 'lfti-admin',
role: 'superadmin',
institution_id: null,
node_id: 'person_admin'
},
{
id: 'user_uk_admin',
name: 'UK School Admin',
email: 'ukadmin@lfti.dev',
password: 'lfti-uk',
role: 'institution_admin',
institution_id: 'uk', // matches existing institution node
node_id: 'person_uk_admin'
},
{
id: 'user_facilitator',
name: 'James (Facilitator)',
email: 'facilitator@lfti.dev',
password: 'lfti-facilitator',
role: 'facilitator',
institution_id: 'uk',
node_id: 'person_facilitator'
},
{
id: 'user_student_1',
name: 'Student One (UK)',
email: 'student1@lfti.dev',
password: 'lfti-student',
role: 'student',
institution_id: 'uk',
node_id: 'person_student_1'
},
{
id: 'user_zm_admin',
name: 'Kamoto Admin',
email: 'zmadmin@lfti.dev',
password: 'lfti-zm',
role: 'institution_admin',
institution_id: 'zm', // matches existing institution node
node_id: 'person_zm_admin'
},
{
id: 'user_observer',
name: 'Observer (Funder)',
email: 'observer@lfti.dev',
password: 'lfti-observer',
role: 'observer',
institution_id: null,
node_id: 'person_observer'
}
];
Print all dev credentials to the console on first seed so they’re easy to find:
╔══════════════════════════════════════════╗
║ LFTI Dev Users — First Run Seed ║
╠══════════════════════════════════════════╣
║ superadmin admin@lfti.dev ║
║ institution_admin ukadmin@lfti.dev ║
║ facilitator facilitator@lfti.dev ║
║ student student1@lfti.dev ║
║ zm admin zmadmin@lfti.dev ║
║ observer observer@lfti.dev ║
║ (all passwords in db/database.js seed) ║
╚══════════════════════════════════════════╝
Also create corresponding person nodes in the graph for each dev user, with involves relations to their institution nodes where applicable.
New files
api/auth.js
Handles all auth routes:
POST /api/auth/login
body: { email, password }
response: { user: { id, name, role, institution_id, node_id }, token }
sets httpOnly cookie: lfti_session=<token>
on failure: 401 { error: "Invalid credentials" }
POST /api/auth/logout
clears cookie, deletes session token from db
response: { ok: true }
GET /api/auth/me
reads cookie, returns current user or 401
response: { user: { id, name, role, institution_id, node_id } }
this is what the frontend calls on boot to check if logged in
Cookie settings:
res.cookie('lfti_session', token, {
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
// secure: true // uncomment when running over HTTPS
});
middleware/auth.js
Two middleware functions used by API routes:
// requireAuth — rejects unauthenticated requests
function requireAuth(req, res, next) { ... }
// requireRole(...roles) — rejects users without matching role
function requireRole(...roles) {
return (req, res, next) => { ... }
}
Both attach req.user (the full user row) when successful.
Usage in routes:
const { requireAuth, requireRole } = require('../middleware/auth');
router.get('/api/nodes', requireAuth, (req, res) => { ... });
router.delete('/api/nodes/:id', requireAuth, requireRole('facilitator', 'institution_admin', 'superadmin'), (req, res) => { ... });
Institution scoping
When a non-superadmin user requests nodes, filter by institution visibility:
A node is visible to a user if ANY of the following:
- The node’s
meta._visibilityis"public"or"portal" - The node’s
meta._visibilityis"partners"and the user’s institution has aninvolvesorresponds_torelation to the node or its project ancestor - The node has no
_visibilityset (defaults to private) AND the node belongs to the user’s institution (determined by traversingcontainsrelations up to an institution node) - The user is
superadmin - The node’s
meta._levelis"reference"(reference nodes are always visible to authenticated users)
Implementation: Add a filterNodesByVisibility(nodes, user) function in db/database.js. Call it in GET /api/nodes after fetching all nodes.
For this first pass, implement rules 1, 4, 5, and a simplified version of 3 (nodes whose institution_id meta field matches the user’s institution_id). Rules 2 and the full graph traversal version of 3 can come later — add a comment marking where that logic goes.
Frontend changes
Login screen
When the app loads and GET /api/auth/me returns 401, show a login screen instead of the main UI. The login screen replaces the full app — not a modal, not an overlay, a full page replacement.
Login screen elements:
- LFTI logo / wordmark (same styling as the header)
- Email input
- Password input
- Login button
- Error message area (shows “Invalid credentials” on failure)
- No registration link — accounts are created by admins
On successful login: hide login screen, show main app, initialise normally (call loadDB() etc.)
Style to match existing design language exactly — var(--bg), var(--surface), var(--accent), var(--mono) font. The login screen should feel like part of the same system, not a different product.
User context in the app
After login, the frontend holds the current user in state:
state.currentUser = {
id, name, role, institution_id, node_id
};
Use this to:
Show/hide controls based on role:
- Delete button in footer: hide for
studentandobserver + capturebutton: hide forobserver- Type pills: hide for
observer + map imagebutton: hide forobserver
Show user identity in the header: Add a small user indicator to the right of the header (before the search input):
[role badge] [name] [logout]
Role badge styling:
superadmin→ accent colourinstitution_admin→ muted bluefacilitator→ muted orangestudent→ muted greenobserver→ text-dim
Logout: clicking logout calls POST /api/auth/logout, clears state.currentUser, shows login screen.
API calls with auth
All existing fetch calls to /api/ already work with cookies because cookies are sent automatically with same-origin requests. No changes needed to the fetch calls themselves — the httpOnly cookie is sent by the browser on every request.
However: wrap the boot sequence so it waits for GET /api/auth/me before calling loadDB(). If auth fails, show login screen. If auth succeeds, store user in state and proceed normally.
async function boot() {
try {
const res = await fetch('/api/auth/me');
if (!res.ok) { showLoginScreen(); return; }
const { user } = await res.json();
state.currentUser = user;
seedLFTI();
seedReferenceInstance();
loadDB();
saveDB();
renderTree();
updateStatus();
renderTemporalNav();
renderUserBadge();
} catch(e) {
showLoginScreen();
}
}
boot();
Route protection in server.js
Apply requireAuth to all /api/nodes routes.
Apply requireAuth to all /api/images routes.
Leave /api/auth/login and /api/auth/logout and /api/auth/me unprotected (they are the auth routes themselves).
Leave the static file serving (the HTML file) unprotected — the frontend handles the auth check on load.
package.json additions
Add one dependency:
"bcrypt": "^5.1.1"
No other new packages. crypto is built into Node.js.
File structure after this prompt
/
├── api/
│ ├── nodes.js (modified — add requireAuth)
│ ├── images.js (modified — add requireAuth)
│ └── auth.js (new)
├── middleware/
│ └── auth.js (new)
├── db/
│ └── database.js (modified — users table, sessions table, dev seed)
├── codeReference/
│ └── ... (unchanged)
├── server.js (modified — mount auth routes, apply middleware)
└── lfti-spine.html (modified — login screen, boot sequence, user badge)
Testing checklist — do all six before finishing
-
Open Chrome. Go to
http://localhost:3000. See login screen (not the app). -
Login as
admin@lfti.dev/lfti-admin. See the app withsuperadminbadge in header. All controls visible. All nodes visible including reference instance. -
Open Firefox (or incognito). Login as
student1@lfti.dev/lfti-student. See app withstudentbadge. Delete button hidden. Capture button visible. Only UK institution nodes visible (not Zambia private nodes). -
Open Edge (or second incognito). Login as
observer@lfti.dev/lfti-observer. See app withobserverbadge. Delete hidden, capture hidden, type pills hidden. Only public/portal visibility nodes visible. -
Click logout in any browser. Login screen appears. App state is cleared.
-
Close browser entirely (not just the tab). Reopen
http://localhost:3000. Session cookie persists — app loads directly without login screen (7-day cookie). Verify correct user is still recognised viaGET /api/auth/me.