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.tsconvention is deprecated and renamed toproxy.ts, exportingproxy. A file namedmiddleware.tswill 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
- 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. - Replace the demo directory.
src/lib/auth/users.tscontains five accounts with a published password. - 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. - Keep enforcement server-side. If you add a route, give it a permission.
tests/auth.test.tscovers the session and the matrix, but it cannot know about a route you forgot to guard.