The Meta Level

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 users
  • institution_admin — manages their institution’s projects and users
  • facilitator — runs sessions within projects they’re assigned to
  • student — contributes to sessions they’re part of
  • observer — 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:

  1. The node’s meta._visibility is "public" or "portal"
  2. The node’s meta._visibility is "partners" and the user’s institution has an involves or responds_to relation to the node or its project ancestor
  3. The node has no _visibility set (defaults to private) AND the node belongs to the user’s institution (determined by traversing contains relations up to an institution node)
  4. The user is superadmin
  5. The node’s meta._level is "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 student and observer
  • + capture button: hide for observer
  • Type pills: hide for observer
  • + map image button: hide for observer

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 colour
  • institution_admin → muted blue
  • facilitator → muted orange
  • student → muted green
  • observer → 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

  1. Open Chrome. Go to http://localhost:3000. See login screen (not the app).

  2. Login as admin@lfti.dev / lfti-admin. See the app with superadmin badge in header. All controls visible. All nodes visible including reference instance.

  3. Open Firefox (or incognito). Login as student1@lfti.dev / lfti-student. See app with student badge. Delete button hidden. Capture button visible. Only UK institution nodes visible (not Zambia private nodes).

  4. Open Edge (or second incognito). Login as observer@lfti.dev / lfti-observer. See app with observer badge. Delete hidden, capture hidden, type pills hidden. Only public/portal visibility nodes visible.

  5. Click logout in any browser. Login screen appears. App state is cleared.

  6. 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 via GET /api/auth/me.


← All notes · More from LFTI