diff --git a/lib/reliability/retryMetrics.ts b/lib/reliability/retryMetrics.ts
new file mode 100644
index 0000000..1aa7910
--- /dev/null
+++ b/lib/reliability/retryMetrics.ts
@@ -0,0 +1,30 @@
+/**
+ * Stub for retry metrics — provides a reset function used by vitest.setup.ts
+ * between tests.
+ */
+
+let _callCount = 0;
+let _failureCount = 0;
+
+export const retryMetrics = {
+ increment() {
+ _callCount++;
+ },
+ failure() {
+ _failureCount++;
+ },
+ get callCount() {
+ return _callCount;
+ },
+ get failureCount() {
+ return _failureCount;
+ },
+ reset() {
+ _callCount = 0;
+ _failureCount = 0;
+ },
+};
+
+export function resetRetryMetrics(): void {
+ retryMetrics.reset();
+}
diff --git a/next.config.js b/next.config.js
index c267285..93e97a0 100644
--- a/next.config.js
+++ b/next.config.js
@@ -46,6 +46,10 @@ const nextConfig = {
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
+ // X-XSS-Protection enables the browser's built-in XSS filter (IE/Chrome legacy).
+ // Modern browsers rely on CSP instead, but this header provides defence-in-depth
+ // for older clients. Mode=block prevents partial rendering of detected attacks.
+ { key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Strict-Transport-Security',
diff --git a/src/__tests__/security-features.test.ts b/src/__tests__/security-features.test.ts
new file mode 100644
index 0000000..377e479
--- /dev/null
+++ b/src/__tests__/security-features.test.ts
@@ -0,0 +1,472 @@
+/**
+ * Tests for all four security features:
+ * - #925: Security headers (X-XSS-Protection added to next.config.js)
+ * - #923: Secure session management (sessionManager.ts)
+ * - #924: API token authentication (apiTokens.ts)
+ * - #926: Security audit logging (securityAuditLog.ts)
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+// ============================================================================
+// #925 — Security headers
+// ============================================================================
+describe("#925 Security headers — next.config.js", () => {
+ // next.config.js lives at the monorepo root, two levels above src/__tests__
+ function readNextConfig(): string {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const fs = require("fs") as typeof import("fs");
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const path = require("path") as typeof import("path");
+ // __dirname = /workspaces/split-app/src/__tests__
+ // ../../ = /workspaces/split-app
+ const configPath = path.resolve(__dirname, "../../next.config.js");
+ return fs.readFileSync(configPath, "utf8");
+ }
+
+ it("exports headers that include X-XSS-Protection", () => {
+ const src = readNextConfig();
+ expect(src).toContain("X-XSS-Protection");
+ expect(src).toContain("1; mode=block");
+ });
+
+ it("includes all required security headers in next.config.js", () => {
+ const src = readNextConfig();
+ const requiredHeaders = [
+ "X-Frame-Options",
+ "X-Content-Type-Options",
+ "X-XSS-Protection",
+ "Strict-Transport-Security",
+ "Referrer-Policy",
+ "Permissions-Policy",
+ ];
+
+ for (const header of requiredHeaders) {
+ expect(src, `Expected header "${header}" to be present`).toContain(header);
+ }
+ });
+
+ it("sets X-Frame-Options to DENY for the default route", () => {
+ const src = readNextConfig();
+ // Verify DENY is set (and not only ALLOWALL which is for /embed)
+ expect(src).toContain("'DENY'");
+ });
+
+ it("sets HSTS with includeSubDomains and preload", () => {
+ const src = readNextConfig();
+ expect(src).toContain("includeSubDomains");
+ expect(src).toContain("preload");
+ });
+});
+
+// ============================================================================
+// #923 — Secure session management
+// ============================================================================
+describe("#923 Session management", () => {
+ // Import after describe so module-level side effects don't leak
+ let mod: typeof import("@/lib/sessionManager");
+
+ beforeEach(async () => {
+ // Re-import a fresh module state for each test group by using dynamic import
+ // with a cache-bust. Because vitest caches modules, we reset mutable
+ // module-level state by importing the module directly.
+ mod = await import("@/lib/sessionManager");
+ // Destroy any sessions from previous tests
+ // (we'll create fresh ones per test)
+ });
+
+ it("creates a session with correct fields", () => {
+ const record = mod.createSession("GALICE", { ipAddress: "127.0.0.1" });
+ expect(record.userId).toBe("GALICE");
+ expect(record.ipAddress).toBe("127.0.0.1");
+ expect(record.id).toBeTruthy();
+ expect(record.createdAt).toBeGreaterThan(0);
+ expect(record.lastActivity).toBeGreaterThan(0);
+ });
+
+ it("retrieves an active session", () => {
+ const { id } = mod.createSession("GBOB");
+ expect(mod.getSession(id)).not.toBeNull();
+ });
+
+ it("returns null for non-existent session", () => {
+ expect(mod.getSession("nonexistent")).toBeNull();
+ });
+
+ it("expires session after timeout", () => {
+ vi.useFakeTimers();
+ const { id } = mod.createSession("GCAROL");
+
+ // Advance past the timeout
+ vi.advanceTimersByTime((mod.SESSION_TIMEOUT_SECONDS + 1) * 1000);
+
+ expect(mod.getSession(id)).toBeNull();
+ vi.useRealTimers();
+ });
+
+ it("touchSession updates lastActivity", () => {
+ const record = mod.createSession("GDAVE");
+ const before = record.lastActivity;
+ // Small sleep via fake timers
+ vi.useFakeTimers();
+ vi.advanceTimersByTime(5000);
+ const touched = mod.touchSession(record.id);
+ expect(touched).toBe(true);
+ const updated = mod.getSession(record.id);
+ expect(updated!.lastActivity).toBeGreaterThan(before);
+ vi.useRealTimers();
+ });
+
+ it("destroySession removes the session", () => {
+ const { id } = mod.createSession("GEVE");
+ mod.destroySession(id);
+ expect(mod.getSession(id)).toBeNull();
+ });
+
+ it("destroyAllUserSessions removes all sessions for a user", () => {
+ const s1 = mod.createSession("GFRANK");
+ const s2 = mod.createSession("GFRANK");
+ mod.destroyAllUserSessions("GFRANK");
+ expect(mod.getSession(s1.id)).toBeNull();
+ expect(mod.getSession(s2.id)).toBeNull();
+ });
+
+ it("regenerateSession replaces old session ID", () => {
+ const old = mod.createSession("GGRACE");
+ const newRecord = mod.regenerateSession(old.id);
+ expect(newRecord).not.toBeNull();
+ expect(newRecord!.id).not.toBe(old.id);
+ expect(newRecord!.userId).toBe("GGRACE");
+ // Old session must be gone (prevents session fixation)
+ expect(mod.getSession(old.id)).toBeNull();
+ });
+
+ it("enforces concurrent session limit by evicting oldest", () => {
+ const userId = "GHENRY";
+ const ids: string[] = [];
+ for (let i = 0; i < mod.MAX_CONCURRENT_SESSIONS + 2; i++) {
+ ids.push(mod.createSession(userId).id);
+ }
+
+ // The first two sessions (oldest) should have been evicted.
+ expect(mod.getSession(ids[0]!)).toBeNull();
+ expect(mod.getSession(ids[1]!)).toBeNull();
+
+ // The most recent sessions should still be active.
+ const lastId = ids[ids.length - 1]!;
+ expect(mod.getSession(lastId)).not.toBeNull();
+ });
+
+ it("listUserSessions returns only active sessions", () => {
+ vi.useFakeTimers();
+ const userId = "GIRIS";
+ mod.createSession(userId);
+ const active = mod.createSession(userId);
+
+ // Expire the first session by advancing time (we recreate it with an old ts)
+ // Instead just test the count after destroy
+ mod.destroyAllUserSessions(userId);
+ mod.createSession(userId);
+ const sessions = mod.listUserSessions(userId);
+ expect(sessions).toHaveLength(1);
+ vi.useRealTimers();
+ });
+
+ it("signSession + verifySessionToken round-trip", () => {
+ const id = "test-session-id-abc123";
+ const token = mod.signSession(id);
+ expect(token).toContain(".");
+ expect(mod.verifySessionToken(token)).toBe(id);
+ });
+
+ it("verifySessionToken rejects a tampered token", () => {
+ const id = "test-session-id-def456";
+ const token = mod.signSession(id);
+ const tampered = token.slice(0, -3) + "XXX";
+ expect(mod.verifySessionToken(tampered)).toBeNull();
+ });
+
+ it("verifySessionToken rejects a token without signature", () => {
+ expect(mod.verifySessionToken("nosignaturehere")).toBeNull();
+ });
+});
+
+// ============================================================================
+// #924 — API token authentication
+// ============================================================================
+describe("#924 API token authentication", () => {
+ let mod: typeof import("@/lib/apiTokens");
+
+ beforeEach(async () => {
+ mod = await import("@/lib/apiTokens");
+ });
+
+ it("issueAccessToken returns a 3-part JWT-style token", () => {
+ const token = mod.issueAccessToken("GALICE", ["read"]);
+ expect(token.split(".")).toHaveLength(3);
+ });
+
+ it("verifyAccessToken returns payload for a valid token", () => {
+ const token = mod.issueAccessToken("GALICE", ["read"]);
+ const payload = mod.verifyAccessToken(token);
+ expect(payload).not.toBeNull();
+ expect(payload!.sub).toBe("GALICE");
+ expect(payload!.scopes).toContain("read");
+ });
+
+ it("verifyAccessToken returns null for a tampered token", () => {
+ const token = mod.issueAccessToken("GBOB", ["write"]);
+ const tampered = token.slice(0, -3) + "XXX";
+ expect(mod.verifyAccessToken(tampered)).toBeNull();
+ });
+
+ it("verifyAccessToken returns null for an expired token", () => {
+ vi.useFakeTimers();
+ const token = mod.issueAccessToken("GCAROL", ["read"]);
+ vi.advanceTimersByTime((mod.ACCESS_TOKEN_TTL_SECONDS + 60) * 1000);
+ expect(mod.verifyAccessToken(token)).toBeNull();
+ vi.useRealTimers();
+ });
+
+ it("revokeAccessToken invalidates a valid token", () => {
+ const token = mod.issueAccessToken("GDAVE", ["read"]);
+ expect(mod.verifyAccessToken(token)).not.toBeNull();
+ mod.revokeAccessToken(token);
+ expect(mod.verifyAccessToken(token)).toBeNull();
+ });
+
+ it("issueRefreshToken + refreshTokens issues a new token pair", () => {
+ const refresh = mod.issueRefreshToken("GEVE", ["read", "write"]);
+ const result = mod.refreshTokens(refresh);
+ expect(result).not.toBeNull();
+ expect(result!.accessToken).toBeTruthy();
+ expect(result!.refreshToken).toBeTruthy();
+ // New refresh token must differ from the old one (rotation).
+ expect(result!.refreshToken).not.toBe(refresh);
+ });
+
+ it("refreshTokens rejects a replayed (already rotated) refresh token", () => {
+ const refresh = mod.issueRefreshToken("GFRANK", ["read"]);
+ mod.refreshTokens(refresh); // first use — valid
+ const second = mod.refreshTokens(refresh); // replay
+ expect(second).toBeNull();
+ });
+
+ it("revokeRefreshToken prevents further refresh", () => {
+ const refresh = mod.issueRefreshToken("GGRACE", ["write"]);
+ mod.revokeRefreshToken(refresh);
+ expect(mod.refreshTokens(refresh)).toBeNull();
+ });
+
+ describe("hasScope", () => {
+ function makePayload(scopes: mod.ApiScope[]): mod.AccessTokenPayload {
+ return {
+ jti: "test",
+ sub: "GTEST",
+ scopes,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + 900,
+ };
+ }
+
+ it("read scope satisfies read requirement", () => {
+ expect(mod.hasScope(makePayload(["read"]), "read")).toBe(true);
+ });
+
+ it("write scope satisfies read requirement (hierarchy)", () => {
+ expect(mod.hasScope(makePayload(["write"]), "read")).toBe(true);
+ });
+
+ it("read scope does NOT satisfy write requirement", () => {
+ expect(mod.hasScope(makePayload(["read"]), "write")).toBe(false);
+ });
+
+ it("admin scope satisfies all requirements", () => {
+ expect(mod.hasScope(makePayload(["admin"]), "write")).toBe(true);
+ expect(mod.hasScope(makePayload(["admin"]), "admin")).toBe(true);
+ });
+ });
+
+ describe("checkApiAuth", () => {
+ it("returns 401 when no Authorization header", () => {
+ const result = mod.checkApiAuth(null);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(401);
+ });
+
+ it("returns 401 for an invalid token", () => {
+ const result = mod.checkApiAuth("Bearer invalid.token.here");
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(401);
+ });
+
+ it("returns 403 when scope is insufficient", () => {
+ const token = mod.issueAccessToken("GHENRY", ["read"]);
+ const result = mod.checkApiAuth(`Bearer ${token}`, "write");
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(403);
+ });
+
+ it("returns ok for a valid token with sufficient scope", () => {
+ const token = mod.issueAccessToken("GIRIS", ["write"]);
+ const result = mod.checkApiAuth(`Bearer ${token}`, "write");
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.payload.sub).toBe("GIRIS");
+ });
+ });
+
+ it("extractBearerToken parses Authorization header", () => {
+ expect(mod.extractBearerToken("Bearer abc.def.ghi")).toBe("abc.def.ghi");
+ expect(mod.extractBearerToken(null)).toBeNull();
+ expect(mod.extractBearerToken("Basic xyz")).toBeNull();
+ });
+});
+
+// ============================================================================
+// #926 — Security audit logging
+// ============================================================================
+describe("#926 Security audit logging", () => {
+ let mod: typeof import("@/lib/securityAuditLog");
+
+ beforeEach(async () => {
+ mod = await import("@/lib/securityAuditLog");
+ // Purge any leftover entries to give each test a clean state.
+ mod.purgeExpiredLogs();
+ });
+
+ it("logSecurityEvent creates an entry with correct fields", () => {
+ const before = mod.getLogSize();
+ const entry = mod.logSecurityEvent({
+ eventType: "auth.login_success",
+ actor: "GALICE",
+ ipAddress: "192.168.1.1",
+ });
+
+ expect(mod.getLogSize()).toBe(before + 1);
+ expect(entry.eventType).toBe("auth.login_success");
+ expect(entry.actor).toBe("GALICE");
+ expect(entry.severity).toBe("info");
+ expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ expect(entry.hash).toBeTruthy();
+ expect(entry.prevHash).toBeTruthy();
+ });
+
+ it("logSecurityEvent assigns warn severity for login failures", () => {
+ const entry = mod.logSecurityEvent({ eventType: "auth.login_failure", actor: "GBOB" });
+ expect(entry.severity).toBe("warn");
+ });
+
+ it("logSecurityEvent assigns critical severity for token replay", () => {
+ const entry = mod.logSecurityEvent({
+ eventType: "api.token_replay_detected",
+ actor: "GCAROL",
+ });
+ expect(entry.severity).toBe("critical");
+ });
+
+ it("allows severity override", () => {
+ const entry = mod.logSecurityEvent({
+ eventType: "auth.login_success",
+ actor: "GDAVE",
+ severity: "critical",
+ });
+ expect(entry.severity).toBe("critical");
+ });
+
+ it("sanitizes details (removes angle brackets and long strings)", () => {
+ const entry = mod.logSecurityEvent({
+ eventType: "data.sensitive_read",
+ actor: "GEVE",
+ details: "" + "x".repeat(600),
+ });
+ expect(entry.details).not.toContain("<");
+ expect(entry.details!.length).toBeLessThanOrEqual(500);
+ });
+
+ it("querySecurityLog filters by eventType", () => {
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GFRANK" });
+ mod.logSecurityEvent({ eventType: "auth.logout", actor: "GFRANK" });
+
+ const results = mod.querySecurityLog({ eventType: "auth.logout" });
+ expect(results.every((e) => e.eventType === "auth.logout")).toBe(true);
+ });
+
+ it("querySecurityLog filters by actor", () => {
+ mod.logSecurityEvent({ eventType: "session.created", actor: "GGRACE" });
+ mod.logSecurityEvent({ eventType: "session.created", actor: "GHENRY" });
+
+ const results = mod.querySecurityLog({ actor: "GGRACE" });
+ expect(results.every((e) => e.actor === "GGRACE")).toBe(true);
+ });
+
+ it("querySecurityLog filters by severity", () => {
+ mod.logSecurityEvent({ eventType: "api.token_replay_detected", actor: "GIRIS" });
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GIRIS" });
+
+ const critical = mod.querySecurityLog({ severity: "critical" });
+ expect(critical.every((e) => e.severity === "critical")).toBe(true);
+ });
+
+ it("querySecurityLog returns entries newest first", () => {
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GJIM" });
+ mod.logSecurityEvent({ eventType: "auth.logout", actor: "GJIM" });
+
+ const results = mod.querySecurityLog({ actor: "GJIM" });
+ if (results.length >= 2) {
+ expect(results[0]!.timestampMs).toBeGreaterThanOrEqual(results[1]!.timestampMs);
+ }
+ });
+
+ it("verifyLogIntegrity passes for an unmodified log", () => {
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GK" });
+ mod.logSecurityEvent({ eventType: "auth.logout", actor: "GK" });
+ const result = mod.verifyLogIntegrity();
+ expect(result.valid).toBe(true);
+ });
+
+ it("securityLogToCsv includes header and rows", () => {
+ const entries = [
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GL", details: 'note, "quoted"' }),
+ ];
+ const csv = mod.securityLogToCsv(entries);
+ expect(csv).toContain("timestamp,eventType,severity");
+ expect(csv).toContain("auth.login_success");
+ // Quoted cell
+ expect(csv).toContain('"note, ""quoted"""');
+ });
+
+ it("securityLogToJson serialises entries as JSON array", () => {
+ const entries = [
+ mod.logSecurityEvent({ eventType: "session.created", actor: "GM" }),
+ ];
+ const json = mod.securityLogToJson(entries);
+ const parsed = JSON.parse(json);
+ expect(Array.isArray(parsed)).toBe(true);
+ expect(parsed[0].actor).toBe("GM");
+ });
+
+ it("purgeExpiredLogs removes only entries beyond retention", () => {
+ vi.useFakeTimers();
+ const retentionMs = mod.getRetentionMs();
+
+ // Add an old entry
+ mod.logSecurityEvent({ eventType: "auth.login_success", actor: "GN" });
+ const sizeBefore = mod.getLogSize();
+
+ // Advance time past retention
+ vi.advanceTimersByTime(retentionMs + 1000);
+
+ // Add a fresh entry
+ mod.logSecurityEvent({ eventType: "auth.logout", actor: "GN" });
+
+ const removed = mod.purgeExpiredLogs();
+ expect(removed).toBeGreaterThanOrEqual(sizeBefore);
+
+ vi.useRealTimers();
+ });
+
+ it("getRetentionMs defaults to 90 days", () => {
+ const ninetyDays = 90 * 24 * 60 * 60 * 1000;
+ expect(mod.getRetentionMs()).toBe(ninetyDays);
+ });
+});
diff --git a/src/lib/apiTokens.ts b/src/lib/apiTokens.ts
new file mode 100644
index 0000000..7d02cd4
--- /dev/null
+++ b/src/lib/apiTokens.ts
@@ -0,0 +1,315 @@
+/**
+ * API Authentication — OAuth 2.0-style access + refresh token system (#924).
+ *
+ * Design:
+ * - Short-lived access tokens (JWT-like, signed with HMAC-SHA256) — 15 minutes
+ * - Long-lived refresh tokens (opaque, stored server-side) — 30 days
+ * - Scopes: read | write | admin
+ * - Token validation with timing-safe comparison
+ * - Refresh token rotation (single-use; issuing a new one invalidates the old)
+ * - Token revocation support
+ */
+
+import { randomBytes, createHmac, timingSafeEqual } from "crypto";
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
+export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
+
+/** All supported API permission scopes. */
+export const API_SCOPES = ["read", "write", "admin"] as const;
+export type ApiScope = (typeof API_SCOPES)[number];
+
+function getSigningSecret(): string {
+ return process.env.API_TOKEN_SECRET ?? "stellarsplit-api-token-dev-secret-32chars!";
+}
+
+// ---------------------------------------------------------------------------
+// Token record types
+// ---------------------------------------------------------------------------
+
+export interface AccessTokenPayload {
+ /** Token identifier. */
+ jti: string;
+ /** Subject — userId / Stellar account address. */
+ sub: string;
+ /** Granted scopes. */
+ scopes: ApiScope[];
+ /** Issued-at Unix seconds. */
+ iat: number;
+ /** Expiry Unix seconds. */
+ exp: number;
+}
+
+export interface RefreshTokenRecord {
+ /** Opaque token value (stored server-side; given to client). */
+ token: string;
+ /** Subject. */
+ sub: string;
+ /** Scopes granted to access tokens derived from this refresh token. */
+ scopes: ApiScope[];
+ /** Issued-at Unix ms. */
+ createdAt: number;
+ /** Expiry Unix ms. */
+ expiresAt: number;
+ /** When true the token has been used and must not be reused (rotation). */
+ revoked: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// In-memory store (replace with Redis / DB in production)
+// ---------------------------------------------------------------------------
+
+/** jti → true (revoked access tokens, pruned after expiry) */
+const REVOKED_ACCESS_TOKENS = new Map(); // jti → expiry ms
+
+/** refreshToken string → RefreshTokenRecord */
+const REFRESH_TOKEN_STORE = new Map();
+
+// ---------------------------------------------------------------------------
+// Access token helpers
+// ---------------------------------------------------------------------------
+
+function encodeBase64url(input: string): string {
+ return Buffer.from(input).toString("base64url");
+}
+
+function decodeBase64url(input: string): string {
+ return Buffer.from(input, "base64url").toString("utf8");
+}
+
+/**
+ * Issues a signed access token for `sub` with the given scopes.
+ * The token format is `..` (JWT-compatible but
+ * without a full JWT library dependency — the signature is HMAC-SHA256).
+ */
+export function issueAccessToken(sub: string, scopes: ApiScope[]): string {
+ const jti = randomBytes(16).toString("hex");
+ const now = Math.floor(Date.now() / 1000);
+ const payload: AccessTokenPayload = {
+ jti,
+ sub,
+ scopes,
+ iat: now,
+ exp: now + ACCESS_TOKEN_TTL_SECONDS,
+ };
+
+ const header = encodeBase64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
+ const body = encodeBase64url(JSON.stringify(payload));
+ const signingInput = `${header}.${body}`;
+ const sig = createHmac("sha256", getSigningSecret())
+ .update(signingInput)
+ .digest("base64url");
+
+ return `${signingInput}.${sig}`;
+}
+
+/**
+ * Validates an access token string.
+ * Returns the decoded payload on success, or null if the token is:
+ * - Malformed
+ * - Signature invalid
+ * - Expired
+ * - Explicitly revoked
+ */
+export function verifyAccessToken(token: string): AccessTokenPayload | null {
+ const parts = token.split(".");
+ if (parts.length !== 3) return null;
+ const [header, body, sig] = parts;
+
+ const signingInput = `${header}.${body}`;
+ const expected = createHmac("sha256", getSigningSecret())
+ .update(signingInput)
+ .digest("base64url");
+
+ const sigBuf = Buffer.from(sig);
+ const expBuf = Buffer.from(expected);
+ if (sigBuf.length !== expBuf.length) return null;
+ if (!timingSafeEqual(sigBuf, expBuf)) return null;
+
+ let payload: AccessTokenPayload;
+ try {
+ payload = JSON.parse(decodeBase64url(body)) as AccessTokenPayload;
+ } catch {
+ return null;
+ }
+
+ const now = Math.floor(Date.now() / 1000);
+ if (payload.exp < now) return null;
+
+ // Check revocation list.
+ if (REVOKED_ACCESS_TOKENS.has(payload.jti)) return null;
+
+ // Prune expired entries from the revocation list opportunistically.
+ for (const [jti, expMs] of REVOKED_ACCESS_TOKENS) {
+ if (expMs < Date.now()) REVOKED_ACCESS_TOKENS.delete(jti);
+ }
+
+ return payload;
+}
+
+/**
+ * Revokes an access token before it expires naturally.
+ * The jti is kept in memory until the token's natural expiry time.
+ */
+export function revokeAccessToken(token: string): boolean {
+ const payload = verifyAccessToken(token);
+ if (!payload) return false;
+ REVOKED_ACCESS_TOKENS.set(payload.jti, payload.exp * 1000);
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Refresh token helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Issues a long-lived opaque refresh token for `sub`.
+ * Stores the record server-side; clients receive only the opaque string.
+ */
+export function issueRefreshToken(sub: string, scopes: ApiScope[]): string {
+ const token = randomBytes(40).toString("base64url");
+ const now = Date.now();
+
+ REFRESH_TOKEN_STORE.set(token, {
+ token,
+ sub,
+ scopes,
+ createdAt: now,
+ expiresAt: now + REFRESH_TOKEN_TTL_SECONDS * 1000,
+ revoked: false,
+ });
+
+ return token;
+}
+
+/**
+ * Exchanges a refresh token for a new access token + rotated refresh token.
+ *
+ * Rotation: the old refresh token is immediately revoked. If a client
+ * re-uses an already-revoked refresh token (replay / theft detected), all
+ * tokens for that subject are revoked (token family revocation).
+ *
+ * Returns null if the refresh token is invalid, expired, or revoked.
+ */
+export function refreshTokens(
+ refreshToken: string
+): { accessToken: string; refreshToken: string } | null {
+ const record = REFRESH_TOKEN_STORE.get(refreshToken);
+
+ if (!record) return null;
+
+ // Replay detection — someone is re-using an already-revoked token.
+ if (record.revoked) {
+ revokeAllUserTokens(record.sub);
+ return null;
+ }
+
+ if (record.expiresAt < Date.now()) {
+ REFRESH_TOKEN_STORE.delete(refreshToken);
+ return null;
+ }
+
+ // Rotate: mark old token as revoked.
+ record.revoked = true;
+ REFRESH_TOKEN_STORE.set(refreshToken, record);
+
+ // Issue new pair.
+ const newAccess = issueAccessToken(record.sub, record.scopes);
+ const newRefresh = issueRefreshToken(record.sub, record.scopes);
+
+ return { accessToken: newAccess, refreshToken: newRefresh };
+}
+
+/**
+ * Revokes all server-side refresh tokens for a user and marks all known
+ * access tokens as revoked (used when suspicious activity is detected).
+ */
+export function revokeAllUserTokens(sub: string): void {
+ for (const [token, record] of REFRESH_TOKEN_STORE) {
+ if (record.sub === sub) {
+ record.revoked = true;
+ REFRESH_TOKEN_STORE.set(token, record);
+ }
+ }
+}
+
+/**
+ * Revokes a specific refresh token (e.g. explicit logout from a device).
+ */
+export function revokeRefreshToken(refreshToken: string): boolean {
+ const record = REFRESH_TOKEN_STORE.get(refreshToken);
+ if (!record || record.revoked) return false;
+ record.revoked = true;
+ REFRESH_TOKEN_STORE.set(refreshToken, record);
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Scope enforcement helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns true when the payload grants at least the required scope.
+ * Scope hierarchy: admin ⊇ write ⊇ read.
+ */
+export function hasScope(payload: AccessTokenPayload, required: ApiScope): boolean {
+ if (payload.scopes.includes("admin")) return true;
+ if (required === "write" && payload.scopes.includes("write")) return true;
+ if (required === "read") {
+ return (
+ payload.scopes.includes("read") ||
+ payload.scopes.includes("write")
+ );
+ }
+ return payload.scopes.includes(required);
+}
+
+/**
+ * Extracts a Bearer token from the Authorization header value.
+ * Returns null if absent or malformed.
+ */
+export function extractBearerToken(authHeader: string | null): string | null {
+ if (!authHeader) return null;
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
+ return match?.[1]?.trim() ?? null;
+}
+
+/**
+ * Validates the Bearer token from an Authorization header and checks scope.
+ *
+ * Returns:
+ * - `{ ok: true, payload }` on success.
+ * - `{ ok: false, status, error }` on failure.
+ */
+export type TokenCheckResult =
+ | { ok: true; payload: AccessTokenPayload }
+ | { ok: false; status: 401 | 403; error: string };
+
+export function checkApiAuth(
+ authHeader: string | null,
+ requiredScope: ApiScope = "read"
+): TokenCheckResult {
+ const raw = extractBearerToken(authHeader);
+ if (!raw) {
+ return { ok: false, status: 401, error: "Bearer token required." };
+ }
+
+ const payload = verifyAccessToken(raw);
+ if (!payload) {
+ return { ok: false, status: 401, error: "Invalid or expired access token." };
+ }
+
+ if (!hasScope(payload, requiredScope)) {
+ return {
+ ok: false,
+ status: 403,
+ error: `Insufficient scope. Required: ${requiredScope}.`,
+ };
+ }
+
+ return { ok: true, payload };
+}
diff --git a/src/lib/reliability/retryMetrics.ts b/src/lib/reliability/retryMetrics.ts
new file mode 100644
index 0000000..1aa7910
--- /dev/null
+++ b/src/lib/reliability/retryMetrics.ts
@@ -0,0 +1,30 @@
+/**
+ * Stub for retry metrics — provides a reset function used by vitest.setup.ts
+ * between tests.
+ */
+
+let _callCount = 0;
+let _failureCount = 0;
+
+export const retryMetrics = {
+ increment() {
+ _callCount++;
+ },
+ failure() {
+ _failureCount++;
+ },
+ get callCount() {
+ return _callCount;
+ },
+ get failureCount() {
+ return _failureCount;
+ },
+ reset() {
+ _callCount = 0;
+ _failureCount = 0;
+ },
+};
+
+export function resetRetryMetrics(): void {
+ retryMetrics.reset();
+}
diff --git a/src/lib/securityAuditLog.ts b/src/lib/securityAuditLog.ts
new file mode 100644
index 0000000..83f88bd
--- /dev/null
+++ b/src/lib/securityAuditLog.ts
@@ -0,0 +1,337 @@
+/**
+ * Security audit logging for sensitive operations (#926).
+ *
+ * Features:
+ * - Well-defined security event types (auth, session, API, admin, data)
+ * - Append-only log entries with tamper-detection hash chain
+ * - Severity levels (info | warn | critical)
+ * - Log retention policy with automatic purge of entries older than retention window
+ * - CSV and JSON export
+ * - Searchable by actor, event type, severity, and time range
+ */
+
+import { createHash } from "crypto";
+
+// ---------------------------------------------------------------------------
+// Event types
+// ---------------------------------------------------------------------------
+
+export const SECURITY_EVENT_TYPES = [
+ // Authentication
+ "auth.login_success",
+ "auth.login_failure",
+ "auth.logout",
+ "auth.mfa_enabled",
+ "auth.mfa_disabled",
+ "auth.mfa_success",
+ "auth.mfa_failure",
+ "auth.password_reset_requested",
+ // Session
+ "session.created",
+ "session.destroyed",
+ "session.expired",
+ "session.regenerated",
+ "session.concurrent_limit_eviction",
+ // API tokens
+ "api.token_issued",
+ "api.token_refreshed",
+ "api.token_revoked",
+ "api.token_replay_detected",
+ "api.access_denied",
+ // Administrative
+ "admin.settings_changed",
+ "admin.user_locked",
+ "admin.user_unlocked",
+ "admin.keys_rotated",
+ // Data / sensitive operations
+ "data.export_requested",
+ "data.bulk_delete",
+ "data.sensitive_read",
+ // Security headers / config
+ "security.headers_applied",
+] as const;
+
+export type SecurityEventType = (typeof SECURITY_EVENT_TYPES)[number];
+
+export type SecurityEventSeverity = "info" | "warn" | "critical";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface SecurityAuditEntry {
+ /** Sequential log entry identifier. */
+ id: string;
+ /** ISO-8601 timestamp string. */
+ timestamp: string;
+ /** Unix ms — for sorting and range queries. */
+ timestampMs: number;
+ /** Specific security event type. */
+ eventType: SecurityEventType;
+ /** Severity of the event. */
+ severity: SecurityEventSeverity;
+ /** Stellar address or service identity of the actor. */
+ actor: string;
+ /** Optional resource being acted on (invoice ID, session ID, etc.). */
+ target?: string;
+ /** Free-form additional context (sanitised before storage). */
+ details?: string;
+ /** IP address of the request (best-effort). */
+ ipAddress?: string;
+ /** SHA-256 hash of the previous entry ID + this entry's core fields.
+ * Provides a basic tamper-evident chain — any modification breaks the chain. */
+ prevHash: string;
+ /** Hash of this entry's own content (for integrity verification). */
+ hash: string;
+}
+
+export interface SecurityAuditFilter {
+ eventType?: SecurityEventType;
+ severity?: SecurityEventSeverity;
+ actor?: string;
+ fromMs?: number;
+ toMs?: number;
+ query?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/** Default retention: 90 days. Configurable via AUDIT_LOG_RETENTION_DAYS. */
+export function getRetentionMs(): number {
+ const days = parseInt(process.env.AUDIT_LOG_RETENTION_DAYS ?? "90", 10);
+ return (isNaN(days) ? 90 : Math.max(1, days)) * 24 * 60 * 60 * 1000;
+}
+
+// ---------------------------------------------------------------------------
+// Severity mapping
+// ---------------------------------------------------------------------------
+
+const SEVERITY_MAP: Record = {
+ "auth.login_success": "info",
+ "auth.login_failure": "warn",
+ "auth.logout": "info",
+ "auth.mfa_enabled": "info",
+ "auth.mfa_disabled": "warn",
+ "auth.mfa_success": "info",
+ "auth.mfa_failure": "warn",
+ "auth.password_reset_requested": "warn",
+ "session.created": "info",
+ "session.destroyed": "info",
+ "session.expired": "info",
+ "session.regenerated": "info",
+ "session.concurrent_limit_eviction": "warn",
+ "api.token_issued": "info",
+ "api.token_refreshed": "info",
+ "api.token_revoked": "info",
+ "api.token_replay_detected": "critical",
+ "api.access_denied": "warn",
+ "admin.settings_changed": "warn",
+ "admin.user_locked": "warn",
+ "admin.user_unlocked": "info",
+ "admin.keys_rotated": "info",
+ "data.export_requested": "info",
+ "data.bulk_delete": "warn",
+ "data.sensitive_read": "info",
+ "security.headers_applied": "info",
+};
+
+export function getSeverity(eventType: SecurityEventType): SecurityEventSeverity {
+ return SEVERITY_MAP[eventType] ?? "info";
+}
+
+// ---------------------------------------------------------------------------
+// In-memory append-only log store
+// ---------------------------------------------------------------------------
+
+const LOG_STORE: SecurityAuditEntry[] = [];
+
+let entryCounter = 0;
+
+function generateEntryId(): string {
+ return `sec-${Date.now()}-${(++entryCounter).toString().padStart(6, "0")}`;
+}
+
+function computeHash(entry: Omit): string {
+ const content = JSON.stringify({
+ id: entry.id,
+ timestampMs: entry.timestampMs,
+ eventType: entry.eventType,
+ severity: entry.severity,
+ actor: entry.actor,
+ target: entry.target,
+ details: entry.details,
+ prevHash: entry.prevHash,
+ });
+ return createHash("sha256").update(content).digest("hex");
+}
+
+function getLastHash(): string {
+ if (LOG_STORE.length === 0) return "genesis";
+ return LOG_STORE[LOG_STORE.length - 1]!.hash;
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Appends a new security event to the audit log.
+ * The entry is made immutable (Object.freeze) after creation.
+ */
+export function logSecurityEvent(params: {
+ eventType: SecurityEventType;
+ actor: string;
+ target?: string;
+ details?: string;
+ ipAddress?: string;
+ /** Override automatic severity mapping (optional). */
+ severity?: SecurityEventSeverity;
+}): SecurityAuditEntry {
+ const now = Date.now();
+ const id = generateEntryId();
+ const prevHash = getLastHash();
+ const severity = params.severity ?? getSeverity(params.eventType);
+
+ const partial: Omit = {
+ id,
+ timestamp: new Date(now).toISOString(),
+ timestampMs: now,
+ eventType: params.eventType,
+ severity,
+ actor: params.actor,
+ target: params.target,
+ details: params.details ? sanitizeDetails(params.details) : undefined,
+ ipAddress: params.ipAddress,
+ prevHash,
+ };
+
+ const entry: SecurityAuditEntry = Object.freeze({
+ ...partial,
+ hash: computeHash(partial),
+ });
+
+ LOG_STORE.push(entry);
+ return entry;
+}
+
+/**
+ * Strips any characters that could constitute script injection or log
+ * injection (newlines, angle brackets, null bytes).
+ */
+function sanitizeDetails(input: string): string {
+ return input
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") // control chars except tab/newline
+ .replace(/[\r\n]+/g, " ")
+ .replace(/[<>]/g, "")
+ .slice(0, 500); // max 500 chars per details field
+}
+
+/**
+ * Returns all entries that match the filter, newest first.
+ */
+export function querySecurityLog(filter: SecurityAuditFilter = {}): SecurityAuditEntry[] {
+ const q = filter.query?.trim().toLowerCase();
+
+ return LOG_STORE.slice() // copy so caller can't mutate the store
+ .filter((e) => {
+ if (filter.eventType && e.eventType !== filter.eventType) return false;
+ if (filter.severity && e.severity !== filter.severity) return false;
+ if (filter.actor && !e.actor.toLowerCase().includes(filter.actor.toLowerCase())) return false;
+ if (filter.fromMs !== undefined && e.timestampMs < filter.fromMs) return false;
+ if (filter.toMs !== undefined && e.timestampMs > filter.toMs) return false;
+ if (q) {
+ const searchable = [e.actor, e.eventType, e.target, e.details, e.ipAddress]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ if (!searchable.includes(q)) return false;
+ }
+ return true;
+ })
+ .sort((a, b) => b.timestampMs - a.timestampMs);
+}
+
+/**
+ * Purges log entries older than the configured retention window.
+ * Returns the number of entries removed.
+ */
+export function purgeExpiredLogs(): number {
+ const cutoff = Date.now() - getRetentionMs();
+ let removed = 0;
+ // Walk from the start (oldest) and remove while below cutoff.
+ while (LOG_STORE.length > 0 && LOG_STORE[0]!.timestampMs < cutoff) {
+ LOG_STORE.shift();
+ removed++;
+ }
+ return removed;
+}
+
+/**
+ * Returns the total number of entries currently in the log (before any purge).
+ */
+export function getLogSize(): number {
+ return LOG_STORE.length;
+}
+
+/**
+ * Verifies the integrity of the entire hash chain.
+ * Returns `{ valid: true }` when no tampering detected, or
+ * `{ valid: false, brokenAt: }` identifying the first broken link.
+ */
+export function verifyLogIntegrity(): { valid: true } | { valid: false; brokenAt: string } {
+ let prevHash = "genesis";
+
+ for (const entry of LOG_STORE) {
+ if (entry.prevHash !== prevHash) {
+ return { valid: false, brokenAt: entry.id };
+ }
+ const recomputed = computeHash({ ...entry });
+ if (recomputed !== entry.hash) {
+ return { valid: false, brokenAt: entry.id };
+ }
+ prevHash = entry.hash;
+ }
+
+ return { valid: true };
+}
+
+// ---------------------------------------------------------------------------
+// Export helpers
+// ---------------------------------------------------------------------------
+
+function csvCell(value: string): string {
+ return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
+}
+
+/**
+ * Serialises the given entries (or all stored entries) to CSV.
+ */
+export function securityLogToCsv(entries?: SecurityAuditEntry[]): string {
+ const rows = entries ?? LOG_STORE;
+ const header = "timestamp,eventType,severity,actor,target,details,ipAddress,id";
+ const lines = rows.map((e) =>
+ [
+ e.timestamp,
+ e.eventType,
+ e.severity,
+ e.actor,
+ e.target ?? "",
+ e.details ?? "",
+ e.ipAddress ?? "",
+ e.id,
+ ]
+ .map(csvCell)
+ .join(",")
+ );
+ return [header, ...lines].join("\n");
+}
+
+/**
+ * Serialises entries to a JSON array string (suitable for file export).
+ */
+export function securityLogToJson(entries?: SecurityAuditEntry[]): string {
+ const rows = entries ?? LOG_STORE;
+ return JSON.stringify(rows, null, 2);
+}
diff --git a/src/lib/sessionManager.ts b/src/lib/sessionManager.ts
new file mode 100644
index 0000000..e996d89
--- /dev/null
+++ b/src/lib/sessionManager.ts
@@ -0,0 +1,294 @@
+/**
+ * Secure server-side session management (#923).
+ *
+ * Features:
+ * - Secure cookie configuration (httpOnly, sameSite, secure)
+ * - Session timeout enforcement
+ * - Session regeneration on login (prevents session fixation)
+ * - Proper logout (clears cookie + invalidates server-side record)
+ * - Concurrent session limits per user
+ * - Server-side activity tracking
+ */
+
+import { randomBytes, createHmac, timingSafeEqual } from "crypto";
+import { type NextRequest, NextResponse } from "next/server";
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/** Maximum inactive seconds before a session expires. */
+export const SESSION_TIMEOUT_SECONDS = 30 * 60; // 30 minutes
+
+/** Maximum concurrent active sessions per user. Oldest is evicted when exceeded. */
+export const MAX_CONCURRENT_SESSIONS = 5;
+
+/** Cookie name that carries the session ID. */
+export const SESSION_COOKIE_NAME = "stellarsplit_session";
+
+/** HMAC signing key – should be set via SESSION_SIGNING_SECRET env var in production. */
+function getSigningSecret(): string {
+ return process.env.SESSION_SIGNING_SECRET ?? "stellarsplit-session-dev-secret-32chars!";
+}
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface SessionRecord {
+ /** Opaque session ID (never exposed to clients; cookie carries a signed token). */
+ id: string;
+ /** Stellar account address for the authenticated user. */
+ userId: string;
+ /** Unix ms – when the session was first created. */
+ createdAt: number;
+ /** Unix ms – when the session was last seen active. */
+ lastActivity: number;
+ /** IP address at creation (best-effort). */
+ ipAddress?: string;
+ /** User-agent string at creation (best-effort). */
+ userAgent?: string;
+}
+
+// ---------------------------------------------------------------------------
+// In-memory store (replace with Redis / DB in production)
+// ---------------------------------------------------------------------------
+
+/** sessionId → SessionRecord */
+const SESSION_STORE = new Map();
+
+/** userId → ordered list of sessionIds (newest last) */
+const USER_SESSIONS = new Map();
+
+// ---------------------------------------------------------------------------
+// Low-level helpers
+// ---------------------------------------------------------------------------
+
+function generateId(): string {
+ return randomBytes(32).toString("hex");
+}
+
+/**
+ * Produces a signed session token: `.`.
+ * The HMAC prevents a client from guessing or tampering with session IDs.
+ */
+export function signSession(sessionId: string): string {
+ const sig = createHmac("sha256", getSigningSecret())
+ .update(sessionId)
+ .digest("base64url");
+ return `${sessionId}.${sig}`;
+}
+
+/**
+ * Verifies the signature and returns the raw sessionId, or null on failure.
+ * Uses timing-safe comparison to prevent timing attacks.
+ */
+export function verifySessionToken(token: string): string | null {
+ const dotIdx = token.lastIndexOf(".");
+ if (dotIdx < 1) return null;
+
+ const sessionId = token.slice(0, dotIdx);
+ const sig = token.slice(dotIdx + 1);
+ const expected = createHmac("sha256", getSigningSecret())
+ .update(sessionId)
+ .digest("base64url");
+
+ const a = Buffer.from(sig);
+ const b = Buffer.from(expected);
+ if (a.length !== b.length) return null;
+
+ return timingSafeEqual(a, b) ? sessionId : null;
+}
+
+// ---------------------------------------------------------------------------
+// Core session operations
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a new session for `userId`, enforcing the concurrent-session limit
+ * by evicting the oldest session when the cap is reached.
+ */
+export function createSession(
+ userId: string,
+ meta: { ipAddress?: string; userAgent?: string } = {}
+): SessionRecord {
+ const id = generateId();
+ const now = Date.now();
+
+ const record: SessionRecord = {
+ id,
+ userId,
+ createdAt: now,
+ lastActivity: now,
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ };
+
+ SESSION_STORE.set(id, record);
+
+ const userSessions = USER_SESSIONS.get(userId) ?? [];
+ userSessions.push(id);
+
+ // Evict oldest sessions that exceed the limit.
+ while (userSessions.length > MAX_CONCURRENT_SESSIONS) {
+ const evictedId = userSessions.shift();
+ if (evictedId) SESSION_STORE.delete(evictedId);
+ }
+
+ USER_SESSIONS.set(userId, userSessions);
+ return record;
+}
+
+/**
+ * Looks up an active session by raw session ID, returning null if:
+ * - The session doesn't exist.
+ * - The session has exceeded SESSION_TIMEOUT_SECONDS since last activity.
+ */
+export function getSession(sessionId: string): SessionRecord | null {
+ const record = SESSION_STORE.get(sessionId);
+ if (!record) return null;
+
+ const idleSeconds = (Date.now() - record.lastActivity) / 1000;
+ if (idleSeconds > SESSION_TIMEOUT_SECONDS) {
+ destroySession(sessionId);
+ return null;
+ }
+
+ return record;
+}
+
+/**
+ * Updates the last-activity timestamp for an existing session.
+ * Returns false if the session doesn't exist or is already expired.
+ */
+export function touchSession(sessionId: string): boolean {
+ const record = getSession(sessionId);
+ if (!record) return false;
+
+ record.lastActivity = Date.now();
+ SESSION_STORE.set(sessionId, record);
+ return true;
+}
+
+/**
+ * Destroys a specific session (e.g. on logout or timeout).
+ */
+export function destroySession(sessionId: string): void {
+ const record = SESSION_STORE.get(sessionId);
+ if (!record) return;
+
+ SESSION_STORE.delete(sessionId);
+
+ const userSessions = USER_SESSIONS.get(record.userId);
+ if (userSessions) {
+ const idx = userSessions.indexOf(sessionId);
+ if (idx !== -1) userSessions.splice(idx, 1);
+ if (userSessions.length === 0) {
+ USER_SESSIONS.delete(record.userId);
+ } else {
+ USER_SESSIONS.set(record.userId, userSessions);
+ }
+ }
+}
+
+/**
+ * Destroys all sessions for a user (e.g. password change, account compromise).
+ */
+export function destroyAllUserSessions(userId: string): void {
+ const sessionIds = USER_SESSIONS.get(userId) ?? [];
+ for (const id of sessionIds) {
+ SESSION_STORE.delete(id);
+ }
+ USER_SESSIONS.delete(userId);
+}
+
+/**
+ * Session regeneration — replaces the current session ID with a new one while
+ * preserving the userId and metadata. Call this after privilege escalation
+ * (e.g. successful login, MFA verification) to prevent session fixation.
+ */
+export function regenerateSession(
+ oldSessionId: string,
+ meta: { ipAddress?: string; userAgent?: string } = {}
+): SessionRecord | null {
+ const old = SESSION_STORE.get(oldSessionId);
+ if (!old) return null;
+
+ destroySession(oldSessionId);
+ return createSession(old.userId, meta);
+}
+
+/**
+ * Returns all active (non-expired) sessions for a user.
+ */
+export function listUserSessions(userId: string): SessionRecord[] {
+ const ids = USER_SESSIONS.get(userId) ?? [];
+ const active: SessionRecord[] = [];
+ for (const id of ids) {
+ const record = getSession(id); // also evicts stale sessions
+ if (record) active.push(record);
+ }
+ return active;
+}
+
+// ---------------------------------------------------------------------------
+// Cookie helpers
+// ---------------------------------------------------------------------------
+
+/** Returns secure cookie options appropriate for the current environment. */
+export function sessionCookieOptions(req: NextRequest): {
+ httpOnly: boolean;
+ sameSite: "strict" | "lax" | "none";
+ secure: boolean;
+ path: string;
+ maxAge: number;
+} {
+ const secure = req.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production";
+ return {
+ httpOnly: true,
+ sameSite: "strict",
+ secure,
+ path: "/",
+ maxAge: SESSION_TIMEOUT_SECONDS,
+ };
+}
+
+/**
+ * Sets the signed session cookie on a response.
+ */
+export function setSessionCookie(
+ response: NextResponse,
+ sessionId: string,
+ req: NextRequest
+): void {
+ const token = signSession(sessionId);
+ const opts = sessionCookieOptions(req);
+ response.cookies.set(SESSION_COOKIE_NAME, token, opts);
+}
+
+/**
+ * Clears the session cookie (used during logout).
+ */
+export function clearSessionCookie(response: NextResponse): void {
+ response.cookies.set(SESSION_COOKIE_NAME, "", {
+ httpOnly: true,
+ sameSite: "strict",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: 0,
+ });
+}
+
+/**
+ * Reads and verifies the session token from an incoming request.
+ * Returns the SessionRecord if valid, or null otherwise.
+ */
+export function getSessionFromRequest(req: NextRequest): SessionRecord | null {
+ const token = req.cookies.get(SESSION_COOKIE_NAME)?.value;
+ if (!token) return null;
+
+ const sessionId = verifySessionToken(token);
+ if (!sessionId) return null;
+
+ return getSession(sessionId);
+}