Authentication and authorisation

Numeralens ships working authentication and server-enforced authorisation, not a placeholder. Sessions are signed cookies, every data route checks a permission before it answers, and the demo exists to let you see that happen.

It is still not auth-opinionated: there is no bundled identity provider to fight with. The session layer is one small file you can replace outright.

How it fits together

Layer File Job
Session src/lib/auth/session.ts Sign, verify and read a session cookie
Directory src/lib/auth/users.ts Verify credentials, return a SessionUser
Enforcement src/lib/auth/dal.ts requireSession / requirePermission — the security boundary
Redirect src/proxy.ts Send signed-out visitors to /login before rendering
Presentation src/providers/auth-provider.tsx can() — decides what to render

The split matters. dal.ts decides what a user may receive. The provider decides what they see. The two are independent on purpose: the client copy of the role lives in a cookie the user holds, so it is a hint, not an authority. Editing the provider changes the UI and nothing about access.

proxy.ts is not a boundary either — it runs before the route, may be served from a CDN, and only reads the cookie. It exists so a signed-out visitor does not render an app shell they cannot use.

Next.js 16 note. The middleware.ts convention is deprecated and renamed to proxy.ts, exporting proxy. A file named middleware.ts will not run.

Signing in

/login posts to a server action that verifies credentials and issues the session. Passwords are hashed with scrypt and a per-password salt, compared in constant time, and a wrong password and an unknown address return the same message — otherwise the form becomes a way to enumerate your users. Attempts are rate limited to ten per five minutes per address.

Demo accounts

Demo mode seeds one account per role, all with the password demo1234, listed on the sign-in screen. Sign in as Viewer and the ledger, transactions, budget and inventory pages disappear — then try curling /api/general-ledger with that session and you get 403, not data. That is the point of shipping the demo with real auth rather than an auto-login.

The role switcher in the top bar re-issues the session with a new role, so the server enforces the switch too. It refuses when demo mode is off; without that check it would be a privilege-escalation endpoint.

Enforcing permissions on a route

Data routes declare what they require. handle() and aiRoute() check it against the verified session before the handler runs:

// src/app/api/general-ledger/route.ts
export const GET = handle((req) => engine.getGeneralLedger(parseFilters(req)), "view:ledger");

A route that passes no permission still requires a signed-in session. Failures return 401 unauthenticated or 403 forbidden as JSON — never a redirect to an HTML page, which would break every fetch in the app.

In a Server Component or Server Action, call the DAL directly:

import { requirePermission } from "@/lib/auth/dal";

export default async function LedgerPage() {
  await requirePermission("view:ledger");
  // ...
}

Roles and permissions

Five roles ship in src/config/roles.ts:

Role Intent
admin Everything, including user management
finance_manager Everything except user management
accountant Reports, ledger, transactions, customers, vendors
analyst Analytics and dashboards; no ledger detail
viewer Read-only dashboard and reports

Permissions are strings such as view:ledger, edit:dashboard, export:data, manage:settings. rolePermissions maps one to the other; the matrix is visible in the app at Settings → Users & roles.

To add one: extend the Permission union, add it to the roles that should have it, tag the navigation entry, and pass it to the route that serves the data — the last step is the one that actually protects anything.

{ label: "Payroll", href: "/payroll", permission: "view:payroll" }
export const GET = handle((req) => getPayroll(req), "view:payroll");

Connecting your own identity provider

Replace src/lib/auth/users.ts with a query against your user table, or replace src/lib/auth/session.ts wholesale if you already issue sessions. Everything downstream depends only on getSession() returning a SessionUser, so nothing else changes — including the 26 route guards.

Map your provider's roles onto Role at that boundary. If your roles do not fit the five here, change the union in src/config/roles.ts; it is a plain type.

Before you deploy

  1. Set AUTH_SECRET — openssl rand -base64 32. With demo mode off in production the app refuses to start without it, rather than falling back to the public development key.
  2. Replace the demo directory. src/lib/auth/users.ts contains five accounts with a published password.
  3. Decide about revocation. Sessions are stateless JWTs, so signing out clears the cookie but cannot invalidate an already-issued token before it expires (8 hours). If you need immediate revocation, move to database sessions — keep getSession()'s signature and nothing else changes.
  4. Keep enforcement server-side. If you add a route, give it a permission. tests/auth.test.ts covers the session and the matrix, but it cannot know about a route you forgot to guard.