diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5c0bbd1..100397a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,3 +27,5 @@ jobs:
- name: Tests (data integrity, lookups, ranges, search quality and speed)
run: npm test
- run: npm run build
+ - name: llms-full.txt is up to date (run `npm run llms` and commit if this fails)
+ run: npm run llms && git diff --exit-code -- llms-full.txt
diff --git a/llms-full.txt b/llms-full.txt
new file mode 100644
index 0000000..9ee402d
--- /dev/null
+++ b/llms-full.txt
@@ -0,0 +1,1319 @@
+# @mgpl-diagnostics/auth-kit — full documentation and source
+
+> Generated by scripts/llms-full.mjs. Do not edit by hand. 14 files.
+
+## Contents
+
+- README.md
+- AGENTS.md
+- llms.txt
+- package.json
+- scripts/llms-full.mjs
+- scripts/test.ts
+- src/auth.ts
+- src/errors.ts
+- src/index.ts
+- src/memory.ts
+- src/password.ts
+- src/rbac.ts
+- src/redis.ts
+- src/stores.ts
+
+
+---
+
+## File: README.md
+
+# auth-kit
+
+**Authentication and permissions for the MGPL Diagnostics backend, as an internal API.**
+There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jobs) calls these functions.
+
+ 
+
+## What it does
+
+| | |
+| --- | --- |
+| **Passwords** | scrypt (N=2¹⁵), salted, self-describing hashes; weaker hashes upgraded on the next login; NIST-style policy (length, common passwords, repeated characters, no email) |
+| **Login** | rate-limited per IP and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED`; audited |
+| **Sessions** | opaque tokens, stored only as SHA-256; idle timeout that slides with use plus an absolute maximum age; list devices, revoke one, log out everywhere |
+| **Accounts** | disabling a user or changing roles applies on the very next request |
+| **Passwords, again** | change (keeps this device, ends the others); reset (single-use, expiring token; ends every session); admin set |
+| **Permissions** | `resource:action` with wildcards; roles per branch or organisation-wide; built-in lab roles plus each organisation's own |
+| **Stores** | memory (tests, one server) or Redis (`/redis`, shared by all servers) |
+
+## Use
+
+```ts
+import { createAuth, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit";
+import { redisKeyValueStore, redisRateLimiter, redisSessionStore } from "@mgpl-diagnostics/auth-kit/redis";
+
+// db-kit's repositories satisfy the user / org / role / audit stores as they are
+const auth = createAuth({
+ users: db.repos.users,
+ orgs: db.repos.orgs,
+ roles: db.repos.roles,
+ audit: db.repos.audit,
+ sessions: redisSessionStore(redis),
+ kv: redisKeyValueStore(redis),
+ limiter: redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }),
+});
+
+const { token, user } = await auth.login({ email, password, ip, userAgent });
+
+// per request, e.g. in the GraphQL context
+const { viewer, can, branchesFor } = await auth.context(bearerToken);
+can("invoice:create", branchId); // true / false
+branchesFor("booking:read"); // "all" or ["branch-id", …]
+```
+
+When you create an organisation, seed the roles: `seedOrganization(repos, { …, roles: SYSTEM_ROLES })`
+(db-kit). To create a user, call `auth.hashNewPassword(password, { email })`, which checks the policy
+and then hashes.
+
+## API
+
+| Call | Returns / throws |
+| --- | --- |
+| `login({ email, password, orgCode?, ip?, userAgent? })` | `{ token, expiresAt, user }` · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` |
+| `authenticate(token)` | `Viewer \| null` (slides the idle window) |
+| `context(token)` | `{ viewer, can, branchesFor, permissions }` (anonymous when the token is missing or invalid) |
+| `require(token, permission?, branchId?)` | `Viewer` · `UNAUTHENTICATED` `FORBIDDEN` |
+| `logout(token)` · `logoutAll(userId, keepToken?)` | |
+| `sessionsOf(userId)` · `revokeSession(userId, sessionId)` | devices; revoke one |
+| `changePassword({ userId, currentPassword, newPassword, currentToken? })` | `INVALID_CREDENTIALS` `WEAK_PASSWORD` |
+| `requestPasswordReset({ email, orgCode?, ip? })` | `{ token, expiresAt, user } \| null`. Deliver the token yourself; reply identically when it is null |
+| `resetPassword({ token, newPassword })` | `INVALID_TOKEN` `WEAK_PASSWORD` |
+| `setPassword(userId, newPassword, actorId?)` · `hashNewPassword(pw, user)` | admin tools |
+| `invalidateRoles(orgId?)` | call after editing roles |
+
+Also exported:
+- `hashPassword`, `verifyPassword`, `needsRehash`, `assertPasswordPolicy`;
+- `randomToken`, `hashToken`;
+- `PERMISSIONS`, `SYSTEM_ROLES`;
+- `can`, `branchesFor`, `permissionsOf`, `roleMap`.
+
+Every failure is an `AuthError` with a stable `code`, an HTTP-style `statusCode`, and `details.retryAfterMs`
+where it applies.
+
+## Built-in roles
+
+`owner` (everything) · `admin` · `branch_manager` · `front_desk` · `phlebotomist` · `technician` ·
+`pathologist` · `accountant` (including the books: `accounts:*`) · `doctor` (referring; read only).
+
+## Not here (by design)
+
+- **HTTP / GraphQL endpoints.** The backend exposes `login`, `logout`, `me` and the rest as GraphQL
+ mutations and queries, and calls this API.
+- **Browser code.** A client package comes later.
+
+## Licence
+
+All rights reserved. See [LICENSE](LICENSE).
+
+
+---
+
+## File: AGENTS.md
+
+# AGENTS.md — @mgpl-diagnostics/auth-kit
+
+The backend's internal authentication API: passwords, sessions, login, reset, roles, permissions.
+**No HTTP server, no GraphQL, no browser code.** The backend repo exposes these calls through its API.
+
+## Map
+
+```
+src/password.ts scrypt hash/verify/needsRehash, policy, randomToken, hashToken
+src/rbac.ts PERMISSIONS, SYSTEM_ROLES, can, branchesFor, permissionsOf, roleMap
+src/stores.ts storage interfaces (UserStore, OrgStore, RoleStore, AuditSink, SessionStore, KeyValueStore, RateLimiter)
+src/memory.ts in-process stores (tests, single server)
+src/redis.ts Redis stores ("/redis" entry): sessions, single-use values, sliding-window limiter
+src/auth.ts createAuth(): login, authenticate, context, require, logout, sessions, password change/reset
+scripts/test.ts 65 checks; session flows run on both memory and Redis (ioredis-mock) stores
+```
+
+## Rules
+
+1. **Never store or log raw tokens.** Stores receive `hashToken(token)` only. A test asserts Redis holds no raw token.
+2. **Login stays timing-safe.** An unknown email must still run one scrypt verification.
+3. **Every request re-reads the user** (`authenticate`), so disabling and role changes apply at once. Do not cache users across requests.
+4. Interfaces stay **structural and small**. db-kit's repositories must keep satisfying them (users, orgs, roles, audit).
+5. Permissions the backend checks must exist in `PERMISSIONS` and be granted by the right `SYSTEM_ROLES`
+ (db-kit's resolvers need `accounts:read|post|manage`, among others).
+6. Error codes are a public contract (`AuthError.code`). Add new ones; never rename existing ones.
+
+## Commands
+
+```bash
+npm run typecheck && npm test && npm run build
+```
+
+## Licence
+
+All rights reserved (see LICENSE).
+
+
+---
+
+## File: llms.txt
+
+# @mgpl-diagnostics/auth-kit
+
+> Internal authentication API for the MGPL Diagnostics backend (Node.js ≥ 20). Covers scrypt password hashing with rehash-on-login and a password policy; opaque session tokens stored as SHA-256, with a sliding idle timeout and an absolute max age (memory or Redis stores); timing-safe, rate-limited login with multi-organisation emails; password change and single-use reset tokens; role-based, branch-aware permissions. No HTTP server.
+
+Key facts for agents:
+- `const auth = createAuth({ users, orgs, roles, audit, sessions, kv, limiter })`. db-kit repositories satisfy users/orgs/roles/audit.
+- `auth.login({ email, password, orgCode?, ip })` → `{ token, expiresAt, user }`; `auth.context(token)` → `{ viewer, can, branchesFor, permissions }`.
+- Errors: `AuthError` with `code` (INVALID_CREDENTIALS, ACCOUNT_DISABLED, INVITE_PENDING, ORG_REQUIRED, RATE_LIMITED, WEAK_PASSWORD, INVALID_TOKEN, UNAUTHENTICATED, FORBIDDEN) and `statusCode`.
+- Redis stores: `import { redisSessionStore, redisKeyValueStore, redisRateLimiter } from "@mgpl-diagnostics/auth-kit/redis"`.
+- Seed roles with `SYSTEM_ROLES`; permissions are "resource:action" with "*" wildcards.
+
+## Docs
+
+- [README](README.md): features, usage, API, roles
+- [AGENTS.md](AGENTS.md): map and rules
+
+## Source
+
+- [src/auth.ts](src/auth.ts): createAuth
+- [src/rbac.ts](src/rbac.ts): permissions and roles
+- [src/password.ts](src/password.ts): hashing and policy
+- [src/redis.ts](src/redis.ts): Redis stores
+
+
+---
+
+## File: package.json
+
+```json
+{
+ "name": "@mgpl-diagnostics/auth-kit",
+ "version": "1.0.0",
+ "description": "MGPL Diagnostics authentication for the backend: scrypt passwords, revocable sessions (Redis or memory), rate-limited timing-safe login, password reset, roles and branch-aware permissions. An internal API, no HTTP server.",
+ "type": "module",
+ "license": "SEE LICENSE IN LICENSE",
+ "repository": "github:mgpl-diagnostics/auth-kit",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./redis": {
+ "types": "./dist/redis.d.ts",
+ "import": "./dist/redis.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "AGENTS.md",
+ "llms.txt",
+ "LICENSE",
+ "llms-full.txt"
+ ],
+ "publishConfig": {
+ "registry": "https://npm.pkg.github.com"
+ },
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit",
+ "test": "tsx scripts/test.ts",
+ "prepare": "tsup",
+ "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/auth-kit README.md AGENTS.md llms.txt package.json src scripts"
+ },
+ "peerDependencies": {
+ "ioredis": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "ioredis": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.5",
+ "ioredis": "^5.11.1",
+ "ioredis-mock": "^8.13.1",
+ "tsup": "^8.5.0",
+ "tsx": "^4.23.15",
+ "typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
+```
+
+
+---
+
+## File: scripts/llms-full.mjs
+
+````js
+// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools.
+// node scripts/llms-full.mjs "
" [file-or-folder …]
+// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically.
+// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are
+// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff).
+import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { extname, join, relative } from "node:path";
+
+const [title, ...inputs] = process.argv.slice(2);
+if (!title || !inputs.length) {
+ console.error('usage: node scripts/llms-full.mjs "" …');
+ process.exit(1);
+}
+
+const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]);
+const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]);
+const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/;
+const MAX = 150 * 1024;
+const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" };
+
+const posix = (p) => p.split("\\").join("/");
+const files = new Set();
+function walk(p) {
+ const st = statSync(p);
+ if (st.isDirectory()) {
+ for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name));
+ } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) {
+ files.add(posix(relative(".", p)));
+ }
+}
+for (const input of inputs) walk(input);
+
+const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3);
+const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
+
+const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`];
+const omitted = [];
+for (const f of ordered) {
+ // LF-normalised text, so Windows and Linux checkouts produce byte-identical output
+ const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim();
+ const size = Buffer.byteLength(body);
+ if (size > MAX) {
+ omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`);
+ parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`);
+ continue;
+ }
+ const lang = LANG[extname(f).toLowerCase()];
+ const fence = body.includes("```") ? "````" : "```";
+ parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`);
+}
+const out = parts.join("");
+writeFileSync("llms-full.txt", out);
+console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`);
+````
+
+
+---
+
+## File: scripts/test.ts
+
+```ts
+// npm test — passwords, login, sessions (memory + Redis stores), permissions, reset flow.
+import RedisMock from "ioredis-mock";
+import {
+ AuthError, createAuth, hashPassword, hashToken, memoryKeyValueStore, memoryRateLimiter, memorySessionStore, needsRehash,
+ permissionsOf, roleMap, verifyPassword, assertPasswordPolicy, can, branchesFor, SYSTEM_ROLES,
+ type AuditSink, type AuthUser, type KeyValueStore, type RateLimiter, type RoleStore, type SessionStore, type UserStore,
+} from "../src/index";
+import { redisKeyValueStore, redisRateLimiter, redisSessionStore } from "../src/redis";
+
+let failed = 0, passed = 0;
+const ok = (cond: unknown, msg: string) => {
+ if (cond) { passed++; console.log(` ok ${msg}`); }
+ else { failed++; console.log(` FAIL ${msg}`); }
+};
+const code = async (p: Promise) => { try { await p; return "none"; } catch (e) { return e instanceof AuthError ? e.code : `other:${(e as Error).message}`; } };
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+// ------------------------------------------------------------------ in-memory stand-ins for db-kit repositories
+
+function fakeDb() {
+ const users = new Map();
+ const orgs = [{ id: "org-a", code: "MGPL" }, { id: "org-b", code: "OTHER" }];
+ const orgRoles: Record = { "org-a": [{ key: "lab_auditor", permissions: ["audit:read", "accounts:read"] }] };
+ const auditLog: { action: string; userId?: string | null }[] = [];
+ const userStore: UserStore = {
+ get: async (id) => (users.has(id) ? { ...users.get(id)! } : null),
+ findByEmail: async (orgId, email) => [...users.values()].find((u) => u.orgId === orgId && u.email === email) ?? null,
+ findAllByEmail: async (email) => [...users.values()].filter((u) => u.email === email),
+ update: async (id, patch) => { users.set(id, { ...users.get(id)!, ...patch }); },
+ };
+ const roleStore: RoleStore = { find: async ({ orgId }) => orgRoles[orgId] ?? [] };
+ const audit: AuditSink = { log: async (e) => { auditLog.push(e); } };
+ const add = async (u: Omit & { password: string; hashParams?: { N: number; r: number; p: number } }) => {
+ users.set(u.id, { ...u, passwordHash: await hashPassword(u.password, u.hashParams) });
+ };
+ return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null }, roleStore, audit, auditLog, orgRoles, add };
+}
+
+// ------------------------------------------------------------------ unit: passwords & RBAC
+
+async function units() {
+ console.log("\n[passwords]");
+ const h = await hashPassword("correct horse battery");
+ ok(h.startsWith("scrypt$32768$8$1$") && (await verifyPassword("correct horse battery", h)) && !(await verifyPassword("wrong horse", h)), "scrypt hash verifies right, rejects wrong");
+ ok((await hashPassword("same")) !== (await hashPassword("same")), "salted: same password → different hashes");
+ ok(needsRehash(await hashPassword("x".repeat(9), { N: 2 ** 14, r: 8, p: 1 })) && !needsRehash(h), "needsRehash spots weaker parameters");
+ ok(!(await verifyPassword("a", "scrypt$99999999999$8$1$aa$bb")) && !(await verifyPassword("a", "md5$abc")), "absurd or foreign hashes are refused, not executed");
+ const weak = (p: string, email?: string) => { try { assertPasswordPolicy(p, { email }); return null; } catch (e) { return (e as AuthError).code; } };
+ ok(weak("short") === "WEAK_PASSWORD" && weak("password123") === "WEAK_PASSWORD" && weak("aaaaaaaaaa") === "WEAK_PASSWORD" && weak("ravi.kumar-2026", "ravi.kumar@x.in") === "WEAK_PASSWORD" && weak("tulsi-leaf-raipur") === null, "password policy: length, common, repeated, contains email");
+
+ console.log("\n[rbac]");
+ const rm = roleMap([{ key: "lab_auditor", permissions: ["audit:read"] }]);
+ const desk = [{ role: "front_desk", branchId: "b1" }];
+ ok(can(desk, "invoice:create", rm, "b1") && !can(desk, "invoice:create", rm, "b2") && !can(desk, "accounts:read", rm), "branch-scoped role: allowed in its branch only");
+ ok(can([{ role: "owner", branchId: null }], "anything:goes", rm) && can([{ role: "admin", branchId: null }], "accounts:post", rm, "b9"), "owner *:*, admin accounts:* everywhere");
+ ok(branchesFor(desk, "booking:read", rm).toString() === "b1" && branchesFor([{ role: "admin", branchId: null }], "booking:read", rm) === "all", "branchesFor: list vs all");
+ ok(permissionsOf([{ role: "lab_auditor", branchId: null }], rm).join() === "audit:read", "custom organisation role");
+ ok(SYSTEM_ROLES.some((r) => r.key === "accountant" && r.permissions.includes("accounts:*")), "accountant role covers the books");
+}
+
+// ------------------------------------------------------------------ flows, per store
+
+async function flows(label: string, stores: { sessions: SessionStore; kv: KeyValueStore; limiter: RateLimiter }) {
+ console.log(`\n[auth / ${label}]`);
+ const db = fakeDb();
+ await db.add({ id: "u1", orgId: "org-a", name: "Asha", email: "asha@mgpl.in", password: "tulsi-leaf-raipur", status: "active", roles: [{ role: "front_desk", branchId: "b1" }] });
+ await db.add({ id: "u2", orgId: "org-a", name: "Old", email: "old@mgpl.in", password: "neem-tree-arang", status: "active", roles: [{ role: "owner", branchId: null }], hashParams: { N: 2 ** 14, r: 8, p: 1 } });
+ await db.add({ id: "u3", orgId: "org-a", name: "Gone", email: "gone@mgpl.in", password: "mango-season-27", status: "disabled", roles: [] });
+ await db.add({ id: "u4", orgId: "org-a", name: "Dual A", email: "dual@x.in", password: "shared-pass-99", status: "active", roles: [{ role: "owner", branchId: null }] });
+ await db.add({ id: "u5", orgId: "org-b", name: "Dual B", email: "dual@x.in", password: "shared-pass-99", status: "active", roles: [{ role: "owner", branchId: null }] });
+ const auth = createAuth({
+ users: db.userStore, orgs: db.orgStore, roles: db.roleStore, audit: db.audit, ...stores,
+ session: { idleMs: 60_000, maxAgeMs: 120_000, touchEveryMs: 0 },
+ });
+
+ const login = await auth.login({ email: " ASHA@mgpl.in ", password: "tulsi-leaf-raipur", ip: "1.1.1.1", userAgent: "test" });
+ ok(login.token.length === 43 && login.user.email === "asha@mgpl.in" && !("passwordHash" in login.user), "login: token issued, email normalised, no hash returned");
+ const v = await auth.authenticate(login.token);
+ ok(v?.userId === "u1" && v.orgId === "org-a" && v.sessionId === hashToken(login.token), "authenticate: token → viewer");
+ ok((await code(auth.login({ email: "asha@mgpl.in", password: "wrong-password" }))) === "INVALID_CREDENTIALS", "wrong password → INVALID_CREDENTIALS");
+ ok((await code(auth.login({ email: "nobody@mgpl.in", password: "whatever-pass" }))) === "INVALID_CREDENTIALS", "unknown email → same INVALID_CREDENTIALS");
+
+ let t = Date.now(); await code(auth.login({ email: "nobody2@mgpl.in", password: "whatever-pass" })); const unknownMs = Date.now() - t;
+ t = Date.now(); await code(auth.login({ email: "asha@mgpl.in", password: "whatever-pass" })); const wrongMs = Date.now() - t;
+ ok(unknownMs > wrongMs * 0.4, `timing-safe: unknown email ${unknownMs} ms vs wrong password ${wrongMs} ms`);
+
+ ok((await code(auth.login({ email: "gone@mgpl.in", password: "mango-season-27" }))) === "ACCOUNT_DISABLED", "disabled account → ACCOUNT_DISABLED");
+ ok((await code(auth.login({ email: "dual@x.in", password: "shared-pass-99" }))) === "ORG_REQUIRED", "same email in two organisations → ORG_REQUIRED");
+ ok((await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "other" })).user.id === "u5", "…with orgCode → the right one");
+
+ const before = db.users.get("u2")!.passwordHash;
+ await auth.login({ email: "old@mgpl.in", password: "neem-tree-arang" });
+ ok(before.startsWith("scrypt$16384$") && db.users.get("u2")!.passwordHash.startsWith("scrypt$32768$"), "weak hash upgraded on login");
+
+ // context & permissions
+ const ctx = await auth.context(login.token);
+ ok(ctx.can("invoice:create", "b1") && !ctx.can("invoice:create", "b2") && !ctx.can("accounts:read") && ctx.branchesFor("booking:read").toString() === "b1", "context: branch-aware can / branchesFor");
+ db.users.set("u1", { ...db.users.get("u1")!, roles: [{ role: "lab_auditor", branchId: null }] });
+ ok((await auth.context(login.token)).can("accounts:read"), "role change applies on the next request (custom org role)");
+ db.orgRoles["org-a"] = [{ key: "lab_auditor", permissions: ["audit:read"] }];
+ auth.invalidateRoles("org-a");
+ ok(!(await auth.context(login.token)).can("accounts:read"), "invalidateRoles: edited role applies at once");
+ ok((await code(auth.require(login.token, "settings:manage"))) === "FORBIDDEN" && (await code(auth.require("bad-token-xxxxxxxxxxxxxxxx"))) === "UNAUTHENTICATED", "require: FORBIDDEN / UNAUTHENTICATED");
+ const anon = await auth.context(null);
+ ok(anon.viewer === null && !anon.can("patient:read"), "anonymous context can nothing");
+
+ // sessions
+ const second = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur", userAgent: "phone" });
+ ok((await auth.sessionsOf("u1")).length === 2 && (await auth.revokeSession("u1", hashToken(second.token))) && (await auth.authenticate(second.token)) === null, "two devices listed; one revoked by id");
+ // idle timeout, with its own short window (logins themselves take ~100 ms of scrypt)
+ const short = createAuth({ users: db.userStore, ...stores, session: { idleMs: 500, maxAgeMs: 60_000, touchEveryMs: 0 } });
+ const busy = await short.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ const idle = await short.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ await sleep(300);
+ await short.authenticate(busy.token); // used: slides its window
+ await sleep(300);
+ ok((await short.authenticate(busy.token)) !== null && (await short.authenticate(idle.token)) === null, "idle timeout: used session slides, unused one expires");
+ const capped = createAuth({ users: db.userStore, ...stores, session: { idleMs: 60_000, maxAgeMs: 300, touchEveryMs: 0 } });
+ const c = await capped.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ await sleep(350);
+ ok((await capped.authenticate(c.token)) === null, "absolute max age ends even an active session");
+ db.users.set("u1", { ...db.users.get("u1")!, status: "disabled" });
+ ok((await auth.authenticate(login.token)) === null && (await auth.sessionsOf("u1")).length === 0, "disabling a user ends their sessions at once");
+ db.users.set("u1", { ...db.users.get("u1")!, status: "active" });
+
+ const s1 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ const s2 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ await auth.logout(s1.token);
+ ok((await auth.authenticate(s1.token)) === null && (await auth.authenticate(s2.token)) !== null, "logout ends only that session");
+ ok((await code(auth.changePassword({ userId: "u1", currentPassword: "nope-nope-nope", newPassword: "banyan-roots-7" }))) === "INVALID_CREDENTIALS", "change password needs the current one");
+ const s3 = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" });
+ await auth.changePassword({ userId: "u1", currentPassword: "tulsi-leaf-raipur", newPassword: "banyan-roots-7", currentToken: s3.token });
+ ok((await auth.authenticate(s3.token)) !== null && (await auth.authenticate(s2.token)) === null, "change password keeps this session, ends the others");
+
+ // reset
+ ok((await auth.requestPasswordReset({ email: "ghost@mgpl.in" })) === null, "reset for unknown email → null (respond the same either way)");
+ const r = (await auth.requestPasswordReset({ email: "asha@mgpl.in" }))!;
+ await auth.resetPassword({ token: r.token, newPassword: "peepal-shade-12" });
+ ok((await auth.login({ email: "asha@mgpl.in", password: "peepal-shade-12" })).user.id === "u1" && (await auth.authenticate(s3.token)) === null, "reset sets the password and ends every session");
+ ok((await code(auth.resetPassword({ token: r.token, newPassword: "another-one-55" }))) === "INVALID_TOKEN", "reset token is single-use");
+ ok((await code(auth.resetPassword({ token: (await auth.requestPasswordReset({ email: "asha@mgpl.in" }))!.token, newPassword: "short" }))) === "WEAK_PASSWORD", "reset enforces the password policy");
+
+ // rate limit (fresh limiter: 3 attempts / window)
+ const strict = createAuth({ users: db.userStore, sessions: stores.sessions, limiter: label === "redis" ? redisRateLimiter(new RedisMock() as any, { limit: 3, windowMs: 60_000, prefix: `rl${Date.now()}:` }) : memoryRateLimiter({ limit: 3, windowMs: 60_000 }) });
+ const codes: string[] = [];
+ for (let i = 0; i < 4; i++) codes.push(await code(strict.login({ email: "old@mgpl.in", password: "bad-guess-000", ip: "9.9.9.9" })));
+ ok(codes.slice(0, 3).every((c) => c === "INVALID_CREDENTIALS") && codes[3] === "RATE_LIMITED", "4th attempt from the same IP/email → RATE_LIMITED");
+
+ ok(db.auditLog.some((e) => e.action === "auth.login") && db.auditLog.some((e) => e.action === "auth.login_failed") && db.auditLog.some((e) => e.action === "auth.password_reset"), "audit trail written");
+}
+
+async function main() {
+ const t0 = Date.now();
+ await units();
+ await flows("memory", { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 100, windowMs: 60_000 }) });
+ const redis = new RedisMock() as any;
+ await flows("redis", { sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis), limiter: redisRateLimiter(redis, { limit: 100, windowMs: 60_000 }) });
+ const keys: string[] = await redis.keys("*");
+ ok(keys.length > 0 && !keys.some((k) => /[A-Za-z0-9_-]{43}$/.test(k) && !k.includes("sess:") && !k.includes("kv:")), "redis holds token hashes only");
+ redis.disconnect();
+ console.log(`\n${passed} passed, ${failed} failed in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+ process.exit(failed ? 1 : 0);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
+```
+
+
+---
+
+## File: src/auth.ts
+
+```ts
+import { AuthError } from "./errors";
+import { memoryRateLimiter } from "./memory";
+import { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, verifyPassword, type PasswordPolicy } from "./password";
+import { branchesFor, can, permissionsOf, roleMap, type Permission, type RoleAssignment, type RoleMap } from "./rbac";
+import type { AuditSink, AuthUser, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores";
+
+export interface AuthOptions {
+ users: UserStore;
+ sessions: SessionStore;
+ /** Needed for login with an organisation code. */
+ orgs?: OrgStore;
+ /** The organisation's own roles; without it only SYSTEM_ROLES apply. */
+ roles?: RoleStore;
+ audit?: AuditSink;
+ /** Needed for password reset. */
+ kv?: KeyValueStore;
+ /** Login / reset attempts per IP and per email. Default: 10 per 15 minutes per key, in memory. */
+ limiter?: RateLimiter;
+ session?: {
+ /** Logged out after this long without use. Default 12 h. */
+ idleMs?: number;
+ /** Logged out after this long regardless. Default 30 days. */
+ maxAgeMs?: number;
+ /** Extend the idle window at most this often (saves writes). Default 60 s. */
+ touchEveryMs?: number;
+ };
+ /** Password-reset link lifetime. Default 30 minutes. */
+ resetTtlMs?: number;
+ /** How long an organisation's role list is cached. Default 30 s. */
+ roleCacheMs?: number;
+ passwordPolicy?: PasswordPolicy;
+}
+
+/** The signed-in user for one request. */
+export interface Viewer {
+ userId: string;
+ orgId: string;
+ name: string;
+ email: string;
+ roles: RoleAssignment[];
+ sessionId: string;
+}
+
+/** What a request handler needs: who is calling and what they may do (fits db-kit's GraphQL context). */
+export interface AuthContext {
+ viewer: Viewer | null;
+ can(permission: string, branchId?: string | null): boolean;
+ branchesFor(permission: string): "all" | string[];
+ permissions(branchId?: string | null): Permission[];
+}
+
+export type PublicUser = Omit;
+
+export interface LoginInput {
+ email: string;
+ password: string;
+ /** Required only when the same email/password exists in several organisations. */
+ orgCode?: string;
+ ip?: string;
+ userAgent?: string;
+}
+
+export interface LoginResult {
+ /** Give this to the client (Authorization: Bearer … or an httpOnly cookie). It is never stored. */
+ token: string;
+ expiresAt: Date;
+ user: PublicUser;
+}
+
+const publicUser = ({ passwordHash: _p, ...u }: AuthUser): PublicUser => u;
+const normalizeEmail = (e: string) => e.trim().toLowerCase();
+
+export type Auth = ReturnType;
+
+/**
+ * The backend's internal auth API. No HTTP: call it from GraphQL resolvers, REST handlers, jobs.
+ *
+ * const auth = createAuth({ users: db.repos.users, orgs: db.repos.orgs, roles: db.repos.roles,
+ * audit: db.repos.audit, sessions: redisSessionStore(redis), kv: redisKeyValueStore(redis),
+ * limiter: redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }) });
+ * const { token } = await auth.login({ email, password, ip });
+ * const ctx = await auth.context(token); // { viewer, can, branchesFor, permissions }
+ */
+export function createAuth(o: AuthOptions) {
+ const idleMs = o.session?.idleMs ?? 12 * 3600_000;
+ const maxAgeMs = o.session?.maxAgeMs ?? 30 * 86400_000;
+ const touchEveryMs = o.session?.touchEveryMs ?? 60_000;
+ const resetTtlMs = o.resetTtlMs ?? 30 * 60_000;
+ const roleCacheMs = o.roleCacheMs ?? 30_000;
+ const limiter = o.limiter ?? memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 });
+ const roleCache = new Map();
+ let dummy: Promise | undefined;
+
+ const audit = async (entry: Parameters[0]) => {
+ try {
+ await o.audit?.log(entry);
+ } catch {
+ /* auditing must never break a login */
+ }
+ };
+
+ async function limit(keys: (string | undefined)[]) {
+ for (const k of keys) {
+ if (!k) continue;
+ const r = await limiter.hit(k);
+ if (!r.allowed) throw new AuthError("RATE_LIMITED", "Too many attempts. Try again later.", { retryAfterMs: r.retryAfterMs });
+ }
+ }
+
+ async function findCandidates(email: string, orgCode?: string): Promise {
+ if (!orgCode) return o.users.findAllByEmail(email);
+ if (!o.orgs) throw new Error("createAuth: pass `orgs` to log in with an organisation code");
+ const org = await o.orgs.findOne({ code: orgCode.trim().toUpperCase() });
+ const user = org ? await o.users.findByEmail(org.id, email) : null;
+ return user ? [user] : [];
+ }
+
+ async function startSession(user: AuthUser, ip?: string, userAgent?: string): Promise<{ token: string; session: Session }> {
+ const token = randomToken();
+ const now = Date.now();
+ const session: Session = { id: hashToken(token), userId: user.id, orgId: user.orgId, createdAt: now, lastSeenAt: now, expiresAt: now + maxAgeMs, ip, userAgent };
+ await o.sessions.create(session, idleMs);
+ return { token, session };
+ }
+
+ async function rolesFor(orgId: string): Promise {
+ const hit = roleCache.get(orgId);
+ if (hit && Date.now() - hit.at < roleCacheMs) return hit.map;
+ const map = roleMap(o.roles ? await o.roles.find({ orgId }) : []);
+ roleCache.set(orgId, { map, at: Date.now() });
+ return map;
+ }
+
+ async function authenticate(token: string | null | undefined): Promise {
+ if (!token || token.length < 20 || token.length > 200) return null;
+ const s = await o.sessions.get(hashToken(token));
+ if (!s) return null;
+ // the user is read on every request, so disabling an account or changing roles applies immediately
+ const user = await o.users.get(s.userId);
+ if (!user || user.status !== "active") {
+ await o.sessions.deleteByUser(s.userId); // disabled or deleted: end every device at once
+ return null;
+ }
+ if (user.orgId !== s.orgId) {
+ await o.sessions.delete(s.id);
+ return null;
+ }
+ const now = Date.now();
+ if (now - s.lastSeenAt >= touchEveryMs) await o.sessions.touch(s.id, now, idleMs);
+ return { userId: user.id, orgId: user.orgId, name: user.name, email: user.email, roles: user.roles, sessionId: s.id };
+ }
+
+ async function setNewPassword(user: AuthUser, newPassword: string, keepSessionId?: string) {
+ assertPasswordPolicy(newPassword, { email: user.email, name: user.name }, o.passwordPolicy);
+ await o.users.update(user.id, { passwordHash: await hashPassword(newPassword) });
+ await o.sessions.deleteByUser(user.id, keepSessionId);
+ }
+
+ return {
+ /** Email + password → session token. Timing-safe, rate-limited, audited. */
+ async login(input: LoginInput): Promise {
+ const email = normalizeEmail(input.email);
+ await limit([input.ip && `login:ip:${input.ip}`, `login:email:${email}`]);
+ const candidates = await findCandidates(email, input.orgCode);
+ // unknown email still costs one scrypt verification, so its timing matches a wrong password
+ if (!candidates.length) await verifyPassword(input.password, await (dummy ??= hashPassword(randomToken())));
+ const matched: AuthUser[] = [];
+ for (const u of candidates) if (await verifyPassword(input.password, u.passwordHash)) matched.push(u);
+ if (!matched.length) {
+ const first = candidates[0];
+ if (first) await audit({ orgId: first.orgId, userId: first.id, action: "auth.login_failed", ip: input.ip });
+ throw new AuthError("INVALID_CREDENTIALS", "Email or password is incorrect");
+ }
+ if (matched.length > 1) throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one.");
+ const user = matched[0]!;
+ if (user.status === "disabled") throw new AuthError("ACCOUNT_DISABLED", "This account is disabled");
+ if (user.status === "invited") throw new AuthError("INVITE_PENDING", "Accept your invitation first");
+
+ await limiter.reset(`login:email:${email}`);
+ const patch: { lastLoginAt: Date; passwordHash?: string } = { lastLoginAt: new Date() };
+ if (needsRehash(user.passwordHash)) patch.passwordHash = await hashPassword(input.password);
+ await o.users.update(user.id, patch);
+ const { token, session } = await startSession(user, input.ip, input.userAgent);
+ await audit({ orgId: user.orgId, userId: user.id, action: "auth.login", ip: input.ip });
+ return { token, expiresAt: new Date(session.expiresAt), user: publicUser({ ...user, lastLoginAt: patch.lastLoginAt }) };
+ },
+
+ /** Token → viewer, or null (unknown, expired, logged out, disabled). Extends the idle window. */
+ authenticate,
+
+ /** Everything a request needs. Pass a null/invalid token for an anonymous context. */
+ async context(token: string | null | undefined): Promise {
+ const viewer = await authenticate(token);
+ if (!viewer) return { viewer: null, can: () => false, branchesFor: () => [], permissions: () => [] };
+ const rm = await rolesFor(viewer.orgId);
+ return {
+ viewer,
+ can: (permission, branchId) => can(viewer.roles, permission, rm, branchId),
+ branchesFor: (permission) => branchesFor(viewer.roles, permission, rm),
+ permissions: (branchId) => permissionsOf(viewer.roles, rm, branchId),
+ };
+ },
+
+ /** Throws UNAUTHENTICATED / FORBIDDEN; returns the viewer. */
+ async require(token: string | null | undefined, permission?: string, branchId?: string | null): Promise {
+ const ctx = await this.context(token);
+ if (!ctx.viewer) throw new AuthError("UNAUTHENTICATED", "Sign in required");
+ if (permission && !ctx.can(permission, branchId)) throw new AuthError("FORBIDDEN", `Missing permission ${permission}`, { permission });
+ return ctx.viewer;
+ },
+
+ async logout(token: string): Promise {
+ const s = await o.sessions.get(hashToken(token));
+ if (!s) return;
+ await o.sessions.delete(s.id);
+ await audit({ orgId: s.orgId, userId: s.userId, action: "auth.logout" });
+ },
+
+ /** Logs a user out everywhere (optionally keeping the current session). */
+ async logoutAll(userId: string, keepToken?: string): Promise {
+ return o.sessions.deleteByUser(userId, keepToken ? hashToken(keepToken) : undefined);
+ },
+
+ /** Active sessions ("devices") of a user. `id` can be passed to revokeSession. */
+ async sessionsOf(userId: string): Promise[]> {
+ return (await o.sessions.listByUser(userId)).map(({ userId: _u, orgId: _o, ...s }) => s).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
+ },
+
+ async revokeSession(userId: string, sessionId: string): Promise {
+ const s = await o.sessions.get(sessionId);
+ if (!s || s.userId !== userId) return false;
+ await o.sessions.delete(sessionId);
+ return true;
+ },
+
+ /** Needs the current password; logs out every other session. */
+ async changePassword(input: { userId: string; currentPassword: string; newPassword: string; currentToken?: string }): Promise {
+ const user = await o.users.get(input.userId);
+ if (!user || !(await verifyPassword(input.currentPassword, user.passwordHash))) throw new AuthError("INVALID_CREDENTIALS", "Current password is incorrect");
+ if (input.currentPassword === input.newPassword) throw new AuthError("WEAK_PASSWORD", "Choose a different password");
+ await setNewPassword(user, input.newPassword, input.currentToken ? hashToken(input.currentToken) : undefined);
+ await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_change" });
+ },
+
+ /**
+ * Starts a reset. Returns the token to deliver (email / SMS link), or null when there is no such
+ * active user. Respond the same way in both cases, so the endpoint does not reveal accounts.
+ */
+ async requestPasswordReset(input: { email: string; orgCode?: string; ip?: string }): Promise<{ token: string; expiresAt: Date; user: PublicUser } | null> {
+ if (!o.kv) throw new Error("createAuth: pass `kv` to enable password reset");
+ const email = normalizeEmail(input.email);
+ await limit([input.ip && `reset:ip:${input.ip}`, `reset:email:${email}`]);
+ const candidates = (await findCandidates(email, input.orgCode)).filter((u) => u.status === "active");
+ if (candidates.length !== 1) return null;
+ const user = candidates[0]!;
+ const token = randomToken();
+ await o.kv.set(`reset:${hashToken(token)}`, user.id, resetTtlMs);
+ await audit({ orgId: user.orgId, userId: user.id, action: "auth.reset_requested", ip: input.ip });
+ return { token, expiresAt: new Date(Date.now() + resetTtlMs), user: publicUser(user) };
+ },
+
+ /** Completes a reset: single-use token, new password, every session logged out. */
+ async resetPassword(input: { token: string; newPassword: string }): Promise {
+ if (!o.kv) throw new Error("createAuth: pass `kv` to enable password reset");
+ const userId = await o.kv.take(`reset:${hashToken(input.token)}`);
+ const user = userId ? await o.users.get(userId) : null;
+ if (!user || user.status !== "active") throw new AuthError("INVALID_TOKEN", "This reset link is invalid or has expired");
+ await setNewPassword(user, input.newPassword);
+ await audit({ orgId: user.orgId, userId: user.id, action: "auth.password_reset" });
+ return publicUser(user);
+ },
+
+ /** Admin sets a password (e.g. new staff). Logs the user out everywhere. */
+ async setPassword(userId: string, newPassword: string, actorId?: string): Promise {
+ const user = await o.users.get(userId);
+ if (!user) throw new AuthError("INVALID_TOKEN", "No such user");
+ await setNewPassword(user, newPassword);
+ await audit({ orgId: user.orgId, userId: actorId ?? null, action: "auth.password_set", entity: "user", entityId: userId });
+ },
+
+ /** Call after editing an organisation's roles, so permission changes apply at once. */
+ invalidateRoles(orgId?: string): void {
+ if (orgId) roleCache.delete(orgId);
+ else roleCache.clear();
+ },
+
+ /** For creating users: hash + policy check. */
+ async hashNewPassword(password: string, user: { email?: string; name?: string } = {}): Promise {
+ assertPasswordPolicy(password, user, o.passwordPolicy);
+ return hashPassword(password);
+ },
+ };
+}
+```
+
+
+---
+
+## File: src/errors.ts
+
+```ts
+export type AuthErrorCode =
+ | "INVALID_CREDENTIALS"
+ | "ACCOUNT_DISABLED"
+ | "INVITE_PENDING"
+ | "ORG_REQUIRED"
+ | "RATE_LIMITED"
+ | "WEAK_PASSWORD"
+ | "INVALID_TOKEN"
+ | "UNAUTHENTICATED"
+ | "FORBIDDEN";
+
+const STATUS: Record = {
+ INVALID_CREDENTIALS: 401,
+ ACCOUNT_DISABLED: 403,
+ INVITE_PENDING: 403,
+ ORG_REQUIRED: 400,
+ RATE_LIMITED: 429,
+ WEAK_PASSWORD: 400,
+ INVALID_TOKEN: 400,
+ UNAUTHENTICATED: 401,
+ FORBIDDEN: 403,
+};
+
+/** Every auth failure. `code` is stable for clients; `statusCode` suits HTTP and GraphQL extensions. */
+export class AuthError extends Error {
+ readonly statusCode: number;
+ constructor(
+ readonly code: AuthErrorCode,
+ message: string,
+ readonly details: { retryAfterMs?: number; permission?: string } = {},
+ ) {
+ super(message);
+ this.name = "AuthError";
+ this.statusCode = STATUS[code];
+ }
+}
+```
+
+
+---
+
+## File: src/index.ts
+
+```ts
+export { createAuth, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type Viewer } from "./auth";
+export { AuthError, type AuthErrorCode } from "./errors";
+export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password";
+export {
+ branchesFor, can, permissionMatches, permissionsOf, PERMISSIONS, roleMap, SYSTEM_ROLES,
+ type Permission, type RoleAssignment, type RoleDefinition, type RoleMap,
+} from "./rbac";
+export { memoryKeyValueStore, memoryRateLimiter, memorySessionStore } from "./memory";
+export type { AuditSink, AuthUser, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores";
+```
+
+
+---
+
+## File: src/memory.ts
+
+```ts
+import type { KeyValueStore, RateLimiter, Session, SessionStore } from "./stores";
+
+/**
+ * In-process stores: for tests, scripts and single-server development. Production with more than
+ * one server needs shared state: use the Redis stores ("@mgpl-diagnostics/auth-kit/redis").
+ */
+
+export function memorySessionStore(): SessionStore {
+ const sessions = new Map();
+ const alive = (id: string) => {
+ const e = sessions.get(id);
+ if (!e) return null;
+ if (e.idleUntil <= Date.now() || e.s.expiresAt <= Date.now()) {
+ sessions.delete(id);
+ return null;
+ }
+ return e;
+ };
+ return {
+ async create(s, idleMs) {
+ sessions.set(s.id, { s: { ...s }, idleUntil: Math.min(s.expiresAt, Date.now() + idleMs) });
+ },
+ async get(id) {
+ const e = alive(id);
+ return e ? { ...e.s } : null;
+ },
+ async touch(id, lastSeenAt, idleMs) {
+ const e = alive(id);
+ if (e) {
+ e.s.lastSeenAt = lastSeenAt;
+ e.idleUntil = Math.min(e.s.expiresAt, Date.now() + idleMs);
+ }
+ },
+ async delete(id) {
+ sessions.delete(id);
+ },
+ async listByUser(userId) {
+ return [...sessions.keys()].map(alive).filter((e): e is NonNullable => !!e && e.s.userId === userId).map((e) => ({ ...e.s }));
+ },
+ async deleteByUser(userId, exceptId) {
+ let n = 0;
+ for (const [id, e] of sessions) if (e.s.userId === userId && id !== exceptId) { sessions.delete(id); n++; }
+ return n;
+ },
+ };
+}
+
+export function memoryKeyValueStore(): KeyValueStore {
+ const values = new Map();
+ return {
+ async set(key, value, ttlMs) {
+ values.set(key, { v: value, until: Date.now() + ttlMs });
+ },
+ async take(key) {
+ const e = values.get(key);
+ values.delete(key);
+ return e && e.until > Date.now() ? e.v : null;
+ },
+ };
+}
+
+/** Sliding window: at most `limit` hits per `windowMs` per key. */
+export function memoryRateLimiter(o: { limit: number; windowMs: number }): RateLimiter {
+ const hits = new Map();
+ return {
+ async hit(key) {
+ const now = Date.now();
+ const list = (hits.get(key) ?? []).filter((t) => t > now - o.windowMs);
+ if (list.length >= o.limit) {
+ hits.set(key, list);
+ return { allowed: false, retryAfterMs: list[0]! + o.windowMs - now };
+ }
+ list.push(now);
+ hits.set(key, list);
+ return { allowed: true, retryAfterMs: 0 };
+ },
+ async reset(key) {
+ hits.delete(key);
+ },
+ };
+}
+```
+
+
+---
+
+## File: src/password.ts
+
+```ts
+import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from "node:crypto";
+import { AuthError } from "./errors";
+
+const scrypt = (password: string, salt: Buffer, keylen: number, options: ScryptOptions) =>
+ new Promise((resolve, reject) => scryptCb(password.normalize("NFKC"), salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key))));
+
+/** scrypt cost: N=2^15, r=8, p=1 ≈ 50–100 ms per hash on a server. Slow for attackers, fine for logins. */
+export const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 32 } as const;
+const maxmem = (N: number, r: number) => 128 * N * r * 2;
+
+/** "scrypt$N$r$p$salt$hash" (base64url): self-describing, so the cost can be raised later. */
+export async function hashPassword(password: string, params: { N: number; r: number; p: number } = SCRYPT): Promise {
+ const salt = randomBytes(16);
+ const key = await scrypt(password, salt, SCRYPT.keylen, { N: params.N, r: params.r, p: params.p, maxmem: maxmem(params.N, params.r) });
+ return ["scrypt", params.N, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
+}
+
+export async function verifyPassword(password: string, stored: string): Promise {
+ const [algo, n, r, p, saltB64, keyB64] = stored.split("$");
+ if (algo !== "scrypt" || !saltB64 || !keyB64) return false;
+ const N = Number(n), R = Number(r), P = Number(p);
+ if (!(N >= 2 ** 10 && N <= 2 ** 20) || !(R >= 1 && R <= 32) || !(P >= 1 && P <= 16)) return false; // refuse absurd params
+ const expected = Buffer.from(keyB64, "base64url");
+ const key = await scrypt(password, Buffer.from(saltB64, "base64url"), expected.length, { N, r: R, p: P, maxmem: maxmem(N, R) });
+ return key.length === expected.length && timingSafeEqual(key, expected);
+}
+
+/** True when the stored hash is weaker than today's parameters: re-hash after a successful login. */
+export function needsRehash(stored: string): boolean {
+ const [algo, n, r, p] = stored.split("$");
+ return algo !== "scrypt" || Number(n) < SCRYPT.N || Number(r) < SCRYPT.r || Number(p) < SCRYPT.p;
+}
+
+const COMMON = new Set([
+ "password", "password1", "password123", "12345678", "123456789", "1234567890", "qwerty123", "11111111", "00000000",
+ "iloveyou", "admin123", "welcome1", "abcd1234", "india123", "letmein1", "changeme", "passw0rd", "p@ssw0rd",
+]);
+
+export interface PasswordPolicy {
+ minLength?: number;
+ maxLength?: number;
+}
+
+/** Throws WEAK_PASSWORD with a readable reason. Length first (NIST 800-63B), no forced symbol rules. */
+export function assertPasswordPolicy(password: string, context: { email?: string; name?: string } = {}, policy: PasswordPolicy = {}): void {
+ const min = policy.minLength ?? 8;
+ const max = policy.maxLength ?? 128;
+ const reason =
+ password.length < min ? `Use at least ${min} characters`
+ : password.length > max ? `Use at most ${max} characters`
+ : /^(.)\1+$/.test(password) ? "Do not repeat one character"
+ : COMMON.has(password.toLowerCase()) ? "This password is too common"
+ : context.email && password.toLowerCase().includes(context.email.split("@")[0]!.toLowerCase()) && context.email.split("@")[0]!.length >= 4 ? "Do not use your email in the password"
+ : null;
+ if (reason) throw new AuthError("WEAK_PASSWORD", reason);
+}
+
+/** Random opaque token (sessions, reset links). 32 bytes → 43 base64url chars. */
+export function randomToken(bytes = 32): string {
+ return randomBytes(bytes).toString("base64url");
+}
+
+/** Stores keep only this: a leaked database or Redis does not reveal usable tokens. */
+export function hashToken(token: string): string {
+ return createHash("sha256").update(token).digest("base64url");
+}
+```
+
+
+---
+
+## File: src/rbac.ts
+
+```ts
+/**
+ * Role-based access control. Permissions are "resource:action"; "*" works in either part
+ * ("invoice:*", "*:read", "*:*"). A user holds roles per branch (branchId) or organisation-wide (null).
+ */
+
+export interface RoleAssignment {
+ role: string;
+ branchId: string | null;
+}
+
+export const PERMISSIONS = [
+ "org:read", "org:update",
+ "branch:read", "branch:create", "branch:update",
+ "user:read", "user:create", "user:update", "user:disable",
+ "role:read", "role:manage",
+ "patient:read", "patient:create", "patient:update", "patient:delete",
+ "doctor:read", "doctor:manage",
+ "booking:read", "booking:create", "booking:update", "booking:cancel",
+ "sample:read", "sample:collect", "sample:receive", "sample:reject",
+ "report:read", "report:enter", "report:verify", "report:release", "report:amend",
+ "invoice:read", "invoice:create", "invoice:discount", "invoice:cancel",
+ "payment:read", "payment:create", "payment:refund",
+ "accounts:read", "accounts:post", "accounts:manage",
+ "audit:read",
+ "settings:manage",
+] as const;
+
+export type Permission = (typeof PERMISSIONS)[number];
+
+export interface RoleDefinition {
+ key: string;
+ name: string;
+ permissions: string[];
+}
+
+/** Built-in roles. Seed them per organisation (db-kit: seedOrganization(repos, { …, roles: SYSTEM_ROLES })). */
+export const SYSTEM_ROLES: RoleDefinition[] = [
+ { key: "owner", name: "Owner", permissions: ["*:*"] },
+ {
+ key: "admin",
+ name: "Administrator",
+ permissions: ["org:read", "branch:*", "user:*", "role:*", "patient:*", "doctor:*", "booking:*", "sample:*", "report:*", "invoice:*", "payment:*", "accounts:*", "audit:read", "settings:manage"],
+ },
+ {
+ key: "branch_manager",
+ name: "Branch manager",
+ permissions: ["org:read", "branch:read", "user:read", "patient:*", "doctor:*", "booking:*", "sample:*", "report:read", "report:release", "invoice:*", "payment:*", "accounts:read", "audit:read"],
+ },
+ {
+ key: "front_desk",
+ name: "Front desk",
+ permissions: ["org:read", "branch:read", "patient:read", "patient:create", "patient:update", "doctor:read", "booking:read", "booking:create", "booking:update", "sample:read", "report:read", "invoice:read", "invoice:create", "payment:read", "payment:create"],
+ },
+ { key: "phlebotomist", name: "Phlebotomist", permissions: ["branch:read", "patient:read", "booking:read", "sample:read", "sample:collect"] },
+ { key: "technician", name: "Lab technician", permissions: ["branch:read", "patient:read", "booking:read", "sample:*", "report:read", "report:enter"] },
+ { key: "pathologist", name: "Pathologist", permissions: ["branch:read", "patient:read", "booking:read", "sample:read", "report:*"] },
+ { key: "accountant", name: "Accountant", permissions: ["org:read", "branch:read", "patient:read", "invoice:read", "invoice:cancel", "payment:*", "accounts:*", "audit:read"] },
+ { key: "doctor", name: "Referring doctor", permissions: ["patient:read", "report:read"] },
+];
+
+/** role key → permissions */
+export type RoleMap = Map;
+
+/** System roles, overridden / extended by the organisation's own roles. */
+export function roleMap(orgRoles: Pick[] = []): RoleMap {
+ const m: RoleMap = new Map();
+ for (const r of SYSTEM_ROLES) m.set(r.key, r.permissions);
+ for (const r of orgRoles) m.set(r.key, r.permissions);
+ return m;
+}
+
+export function permissionMatches(granted: string, wanted: string): boolean {
+ if (granted === wanted || granted === "*:*" || granted === "*") return true;
+ const [gr, ga] = granted.split(":");
+ const [wr, wa] = wanted.split(":");
+ return (gr === "*" || gr === wr) && (ga === "*" || ga === wa);
+}
+
+/**
+ * Does someone holding `assignments` have `permission`? With `branchId`, only roles held in that
+ * branch or organisation-wide count; without it, a role in any branch counts.
+ */
+export function can(assignments: readonly RoleAssignment[], permission: string, roles: RoleMap, branchId?: string | null): boolean {
+ for (const a of assignments) {
+ if (branchId && a.branchId && a.branchId !== branchId) continue;
+ if (roles.get(a.role)?.some((g) => permissionMatches(g, permission))) return true;
+ }
+ return false;
+}
+
+/** Where `permission` applies: "all" (an organisation-wide role grants it) or the list of branch ids. */
+export function branchesFor(assignments: readonly RoleAssignment[], permission: string, roles: RoleMap): "all" | string[] {
+ const out = new Set();
+ for (const a of assignments) {
+ if (!roles.get(a.role)?.some((g) => permissionMatches(g, permission))) continue;
+ if (!a.branchId) return "all";
+ out.add(a.branchId);
+ }
+ return [...out];
+}
+
+/** Concrete permissions (wildcards expanded against PERMISSIONS) — e.g. to show or hide UI. */
+export function permissionsOf(assignments: readonly RoleAssignment[], roles: RoleMap, branchId?: string | null): Permission[] {
+ return PERMISSIONS.filter((p) => can(assignments, p, roles, branchId));
+}
+```
+
+
+---
+
+## File: src/redis.ts
+
+```ts
+import { randomBytes } from "node:crypto";
+import type { Redis } from "ioredis";
+import type { KeyValueStore, RateLimiter, Session, SessionStore } from "./stores";
+
+/**
+ * Redis stores: shared by every backend server, so a logout or password change on one server
+ * applies everywhere at once.
+ * sess: session JSON, expires after the idle window (never past expiresAt)
+ * usess: set of a user's session ids (for "log out everywhere")
+ */
+
+export function redisSessionStore(redis: Redis, prefix = "mgpl:auth:"): SessionStore {
+ const key = (id: string) => `${prefix}sess:${id}`;
+ const userKey = (userId: string) => `${prefix}usess:${userId}`;
+ const ttl = (s: Session, idleMs: number) => Math.max(1, Math.min(idleMs, s.expiresAt - Date.now()));
+
+ return {
+ async create(s, idleMs) {
+ await redis
+ .multi()
+ .set(key(s.id), JSON.stringify(s), "PX", ttl(s, idleMs))
+ .sadd(userKey(s.userId), s.id)
+ .pexpireat(userKey(s.userId), s.expiresAt)
+ .exec();
+ },
+ async get(id) {
+ const raw = await redis.get(key(id));
+ if (!raw) return null;
+ const s = JSON.parse(raw) as Session;
+ return s.expiresAt > Date.now() ? s : null;
+ },
+ async touch(id, lastSeenAt, idleMs) {
+ const raw = await redis.get(key(id));
+ if (!raw) return;
+ const s = { ...(JSON.parse(raw) as Session), lastSeenAt };
+ await redis.set(key(id), JSON.stringify(s), "PX", ttl(s, idleMs), "XX");
+ },
+ async delete(id) {
+ const raw = await redis.get(key(id));
+ const multi = redis.multi().del(key(id));
+ if (raw) multi.srem(userKey((JSON.parse(raw) as Session).userId), id);
+ await multi.exec();
+ },
+ async listByUser(userId) {
+ const ids = await redis.smembers(userKey(userId));
+ if (!ids.length) return [];
+ const raws = await redis.mget(...ids.map(key));
+ const gone = ids.filter((_, i) => !raws[i]);
+ if (gone.length) await redis.srem(userKey(userId), ...gone); // tidy expired ids
+ return raws.filter((r): r is string => !!r).map((r) => JSON.parse(r) as Session).filter((s) => s.expiresAt > Date.now());
+ },
+ async deleteByUser(userId, exceptId) {
+ const ids = (await redis.smembers(userKey(userId))).filter((id) => id !== exceptId);
+ if (!ids.length) return 0;
+ const removed = await redis.del(...ids.map(key));
+ await redis.srem(userKey(userId), ...ids);
+ return removed;
+ },
+ };
+}
+
+export function redisKeyValueStore(redis: Redis, prefix = "mgpl:auth:kv:"): KeyValueStore {
+ return {
+ async set(k, value, ttlMs) {
+ await redis.set(prefix + k, value, "PX", ttlMs);
+ },
+ async take(k) {
+ // GET + DEL in one transaction: two servers cannot both take the same value
+ const res = await redis.multi().get(prefix + k).del(prefix + k).exec();
+ return (res?.[0]?.[1] as string | null) ?? null;
+ },
+ };
+}
+
+/** Sliding-window log in a sorted set: at most `limit` hits in any `windowMs`, across all servers. */
+export function redisRateLimiter(redis: Redis, o: { limit: number; windowMs: number; prefix?: string }): RateLimiter {
+ const prefix = o.prefix ?? "mgpl:auth:rl:";
+ return {
+ async hit(k) {
+ const full = prefix + k;
+ const now = Date.now();
+ const member = `${now}:${randomBytes(4).toString("hex")}`;
+ const res = await redis
+ .multi()
+ .zremrangebyscore(full, 0, now - o.windowMs)
+ .zadd(full, now, member)
+ .zcard(full)
+ .zrange(full, 0, 0, "WITHSCORES")
+ .pexpire(full, o.windowMs)
+ .exec();
+ const count = Number(res?.[2]?.[1] ?? 0);
+ if (count <= o.limit) return { allowed: true, retryAfterMs: 0 };
+ await redis.zrem(full, member); // a blocked attempt does not extend the block
+ const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now);
+ return { allowed: false, retryAfterMs: Math.max(0, oldest + o.windowMs - now) };
+ },
+ async reset(k) {
+ await redis.del(prefix + k);
+ },
+ };
+}
+```
+
+
+---
+
+## File: src/stores.ts
+
+```ts
+import type { RoleAssignment } from "./rbac";
+
+/**
+ * Storage interfaces. They are deliberately small and structural: db-kit's repositories satisfy the
+ * user / org / role / audit ones as they are (db.repos.users, db.repos.orgs, …), and any other
+ * database can implement them in a few lines.
+ */
+
+export type UserStatus = "active" | "disabled" | "invited";
+
+export interface AuthUser {
+ id: string;
+ orgId: string;
+ name: string;
+ email: string;
+ passwordHash: string;
+ status: UserStatus;
+ roles: RoleAssignment[];
+ lastLoginAt?: Date | null;
+}
+
+export interface UserStore {
+ get(id: string): Promise;
+ findByEmail(orgId: string, email: string): Promise;
+ /** Same email may exist in several organisations. */
+ findAllByEmail(email: string): Promise;
+ update(id: string, patch: { passwordHash?: string; lastLoginAt?: Date; status?: UserStatus }): Promise;
+}
+
+export interface OrgStore {
+ findOne(filter: { code: string }): Promise<{ id: string; code: string } | null>;
+}
+
+export interface RoleStore {
+ /** The organisation's own roles (custom ones and seeded system roles). */
+ find(filter: { orgId: string }): Promise<{ key: string; permissions: string[] }[]>;
+}
+
+export interface AuditSink {
+ log(entry: { orgId: string; userId?: string | null; action: string; ip?: string; entity?: string; entityId?: string | null; data?: Record }): Promise;
+}
+
+// ------------------------------------------------------------------ sessions & short-lived values
+
+export interface Session {
+ /** sha256 of the token; the token itself is never stored. */
+ id: string;
+ userId: string;
+ orgId: string;
+ createdAt: number;
+ lastSeenAt: number;
+ /** Absolute end (epoch ms): the session dies here even if it is used continuously. */
+ expiresAt: number;
+ ip?: string;
+ userAgent?: string;
+}
+
+export interface SessionStore {
+ /** Stores a session that disappears after `idleMs` without use (and never outlives `expiresAt`). */
+ create(session: Session, idleMs: number): Promise;
+ get(id: string): Promise;
+ /** Marks use; extends the idle window. */
+ touch(id: string, lastSeenAt: number, idleMs: number): Promise;
+ delete(id: string): Promise;
+ listByUser(userId: string): Promise;
+ /** Deletes all of a user's sessions except `exceptId`; returns how many. */
+ deleteByUser(userId: string, exceptId?: string): Promise;
+}
+
+/** Single-use values with expiry (password-reset tokens). */
+export interface KeyValueStore {
+ set(key: string, value: string, ttlMs: number): Promise;
+ /** Reads and deletes atomically: a value can be taken once. */
+ take(key: string): Promise;
+}
+
+export interface RateLimiter {
+ hit(key: string): Promise<{ allowed: boolean; retryAfterMs: number }>;
+ reset(key: string): Promise;
+}
+```
diff --git a/package.json b/package.json
index 33e76c5..bf7c180 100644
--- a/package.json
+++ b/package.json
@@ -9,20 +9,42 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
- "./redis": { "types": "./dist/redis.d.ts", "import": "./dist/redis.js" },
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./redis": {
+ "types": "./dist/redis.d.ts",
+ "import": "./dist/redis.js"
+ },
"./package.json": "./package.json"
},
- "files": ["dist", "README.md", "AGENTS.md", "llms.txt", "LICENSE"],
- "publishConfig": { "registry": "https://npm.pkg.github.com" },
+ "files": [
+ "dist",
+ "README.md",
+ "AGENTS.md",
+ "llms.txt",
+ "LICENSE",
+ "llms-full.txt"
+ ],
+ "publishConfig": {
+ "registry": "https://npm.pkg.github.com"
+ },
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "tsx scripts/test.ts",
- "prepare": "tsup"
+ "prepare": "tsup",
+ "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/auth-kit README.md AGENTS.md llms.txt package.json src scripts"
+ },
+ "peerDependencies": {
+ "ioredis": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "ioredis": {
+ "optional": true
+ }
},
- "peerDependencies": { "ioredis": ">=5" },
- "peerDependenciesMeta": { "ioredis": { "optional": true } },
"devDependencies": {
"@types/node": "^22.20.5",
"ioredis": "^5.11.1",
@@ -31,5 +53,7 @@
"tsx": "^4.23.15",
"typescript": "^5.9.3"
},
- "engines": { "node": ">=20" }
+ "engines": {
+ "node": ">=20"
+ }
}
diff --git a/scripts/llms-full.mjs b/scripts/llms-full.mjs
new file mode 100644
index 0000000..77ff18a
--- /dev/null
+++ b/scripts/llms-full.mjs
@@ -0,0 +1,53 @@
+// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools.
+// node scripts/llms-full.mjs "" [file-or-folder …]
+// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically.
+// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are
+// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff).
+import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { extname, join, relative } from "node:path";
+
+const [title, ...inputs] = process.argv.slice(2);
+if (!title || !inputs.length) {
+ console.error('usage: node scripts/llms-full.mjs "" …');
+ process.exit(1);
+}
+
+const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]);
+const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]);
+const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/;
+const MAX = 150 * 1024;
+const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" };
+
+const posix = (p) => p.split("\\").join("/");
+const files = new Set();
+function walk(p) {
+ const st = statSync(p);
+ if (st.isDirectory()) {
+ for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name));
+ } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) {
+ files.add(posix(relative(".", p)));
+ }
+}
+for (const input of inputs) walk(input);
+
+const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3);
+const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
+
+const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`];
+const omitted = [];
+for (const f of ordered) {
+ // LF-normalised text, so Windows and Linux checkouts produce byte-identical output
+ const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim();
+ const size = Buffer.byteLength(body);
+ if (size > MAX) {
+ omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`);
+ parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`);
+ continue;
+ }
+ const lang = LANG[extname(f).toLowerCase()];
+ const fence = body.includes("```") ? "````" : "```";
+ parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`);
+}
+const out = parts.join("");
+writeFileSync("llms-full.txt", out);
+console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`);