diff --git a/Dockerfile b/Dockerfile index a5986d5..c304680 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN apk add --no-cache python3 make g++ git COPY package.json package-lock.json ./ COPY packages ./packages COPY changelog ./changelog +COPY scripts ./scripts RUN npm ci RUN npm run build RUN npm prune --omit=dev diff --git a/package.json b/package.json index f53a697..6b842dc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev:ui": "npm run dev -w @DarkAuth/user-ui", "dev:admin": "npm run dev -w @DarkAuth/admin-ui", "dev:brochureware": "npm run dev -w @DarkAuth/brochureware", - "build": "npm run build --workspaces --if-present", + "build": "node scripts/build-workspaces.mjs", "build:ui": "npm run build -w @DarkAuth/user-ui", "build:admin": "npm run build -w @DarkAuth/admin-ui", "build:api": "npm run build -w @DarkAuth/api", diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx index 9a0ae34..0ba1467 100644 --- a/packages/admin-ui/src/App.tsx +++ b/packages/admin-ui/src/App.tsx @@ -7,6 +7,7 @@ import AdminLogin from "@/components/Login"; import { Toaster } from "@/components/ui/toaster"; import adminApiService from "@/services/api"; import authService from "@/services/auth"; +import { logger } from "@/services/logger"; import AdminOtp from "./pages/AdminOtp"; import AdminUserCreate from "./pages/AdminUserCreate"; import AdminUserEdit from "./pages/AdminUserEdit"; @@ -74,7 +75,7 @@ const App = () => { try { await adminApiService.getAdminSession(); } catch (error) { - console.error("Session refresh failed:", error); + logger.error(error, "Session refresh failed"); setAdminSession(null); authService.clearSession(); } @@ -112,7 +113,7 @@ const App = () => { try { await adminApiService.getAdminSession(); } catch (error) { - console.error("Session refresh failed:", error); + logger.error(error, "Session refresh failed"); setAdminSession(null); authService.clearSession(); } @@ -126,7 +127,7 @@ const App = () => { authService.clearSession(); window.location.assign("/"); } catch (error) { - console.error("Logout failed:", error); + logger.error(error, "Logout failed"); } }; diff --git a/packages/admin-ui/src/components/Login.tsx b/packages/admin-ui/src/components/Login.tsx index d3997b6..c777c01 100644 --- a/packages/admin-ui/src/components/Login.tsx +++ b/packages/admin-ui/src/components/Login.tsx @@ -4,6 +4,7 @@ import AuthFrame from "@/components/auth/AuthFrame"; import { Button } from "@/components/ui/button"; import adminApiService from "@/services/api"; import authService from "@/services/auth"; +import { logger } from "@/services/logger"; import adminOpaqueService, { type AdminOpaqueLoginState } from "@/services/opaque"; import styles from "./Login.module.css"; @@ -92,33 +93,34 @@ export default function AdminLogin({ onLogin }: AdminLoginProps) { setErrors({}); try { - console.debug("[admin-ui] login: start", { email: formData.email }); + logger.debug({ email: formData.email }, "[admin-ui] login start"); // Start OPAQUE admin login const loginStart = await adminOpaqueService.startLogin(formData.email, formData.password); setOpaqueState(loginStart.state); - console.debug("[admin-ui] login: sending /opaque/login/start", { - reqLen: loginStart.request.length, - }); + logger.debug({ requestLength: loginStart.request.length }, "[admin-ui] login start request"); const loginStartResponse = await adminApiService.adminOpaqueLoginStart({ email: formData.email, request: loginStart.request, }); - console.debug("[admin-ui] login: start response", loginStartResponse); + logger.debug(loginStartResponse, "[admin-ui] login start response"); // Finish OPAQUE login const loginFinish = await adminOpaqueService.finishLogin( loginStartResponse.message, loginStart.state ); - console.debug("[admin-ui] login: finish ke3 len", { len: loginFinish.request.length }); + logger.debug( + { requestLength: loginFinish.request.length }, + "[admin-ui] login finish payload" + ); // Send login finish request to server const loginFinishResponse = await adminApiService.adminOpaqueLoginFinish({ finish: loginFinish.request, sessionId: loginStartResponse.sessionId, }); - console.debug("[admin-ui] login: finish response", loginFinishResponse); + logger.debug(loginFinishResponse, "[admin-ui] login finish response"); const name = loginFinishResponse.admin.name; const email = loginFinishResponse.admin.email; @@ -161,7 +163,7 @@ export default function AdminLogin({ onLogin }: AdminLoginProps) { }); } catch {} } catch (error) { - console.error("Admin login failed:", error); + logger.error(error, "Admin login failed"); // Clear sensitive data on error if (opaqueState) { diff --git a/packages/admin-ui/src/pages/AdminUsers.tsx b/packages/admin-ui/src/pages/AdminUsers.tsx index cd5796f..1ef3eac 100644 --- a/packages/admin-ui/src/pages/AdminUsers.tsx +++ b/packages/admin-ui/src/pages/AdminUsers.tsx @@ -20,6 +20,7 @@ import { import { useToast } from "@/hooks/use-toast"; import adminApiService, { type AdminUser } from "@/services/api"; import { sha256Base64Url } from "@/services/hash"; +import { logger } from "@/services/logger"; import adminOpaqueService from "@/services/opaque-cloudflare"; export default function AdminUsers() { @@ -43,7 +44,7 @@ export default function AdminUsers() { setTotalPages(response.pagination.totalPages); setTotalCount(response.pagination.total); } catch (error) { - console.error("Failed to load admin users:", error); + logger.error(error, "Failed to load admin users"); setError(error instanceof Error ? error.message : "Failed to load admin users"); toast({ title: "Error", diff --git a/packages/admin-ui/src/pages/AuditLogs.tsx b/packages/admin-ui/src/pages/AuditLogs.tsx index 7596a6f..74b3ab5 100644 --- a/packages/admin-ui/src/pages/AuditLogs.tsx +++ b/packages/admin-ui/src/pages/AuditLogs.tsx @@ -34,6 +34,7 @@ import { SelectValue, } from "@/components/ui/select"; import adminApiService, { type AuditLog, type AuditLogFilters } from "@/services/api"; +import { logger } from "@/services/logger"; const EVENT_TYPES = [ "user_login", @@ -95,7 +96,7 @@ export default function AuditLogs() { setTotalPages(response.pagination.totalPages); setTotalLogs(response.pagination.total); } catch (error) { - console.error("Failed to load audit logs:", error); + logger.error(error, "Failed to load audit logs"); setError(error instanceof Error ? error.message : "Failed to load audit logs"); } finally { setLoading(false); @@ -149,7 +150,7 @@ export default function AuditLogs() { document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (error) { - console.error("Failed to export audit logs:", error); + logger.error(error, "Failed to export audit logs"); setError(error instanceof Error ? error.message : "Failed to export audit logs"); } }; diff --git a/packages/admin-ui/src/pages/Branding.tsx b/packages/admin-ui/src/pages/Branding.tsx index 04a18b5..ba1b5d0 100644 --- a/packages/admin-ui/src/pages/Branding.tsx +++ b/packages/admin-ui/src/pages/Branding.tsx @@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import adminApiService, { type AdminSetting } from "@/services/api"; +import { logger } from "@/services/logger"; declare global { interface Window { @@ -147,9 +148,6 @@ export default function Branding() { // Only keep the primary color from the loaded data const loadedColors = (col?.value as Record) || {}; const loadedColorsDark = (colDark?.value as Record) || {}; - console.log("LOADED COLORS FROM BACKEND:", loadedColors); - console.log("LOADED COLORS DARK FROM BACKEND:", loadedColorsDark); - // Only extract the primary field const lightPrimary = typeof loadedColors === "object" && loadedColors.primary ? loadedColors.primary : "#6600cc"; @@ -158,9 +156,6 @@ export default function Branding() { ? loadedColorsDark.primary : "#aec1e0"; - console.log("SETTING LIGHT PRIMARY:", lightPrimary); - console.log("SETTING DARK PRIMARY:", darkPrimary); - setColors({ primary: lightPrimary }); setColorsDark({ primary: darkPrimary }); setCustomCss((css?.value as string) || ""); @@ -241,9 +236,6 @@ export default function Branding() { setSaving(true); const identityToSave = { title: titleInput, tagline: taglineInput }; - console.log("SAVING COLORS:", colors); - console.log("SAVING COLORS DARK:", colorsDark); - await adminApiService.updateSetting("branding.identity", identityToSave); if (colors) await adminApiService.updateSetting("branding.colors", colors); if (wording) await adminApiService.updateSetting("branding.wording", wording); @@ -254,7 +246,7 @@ export default function Branding() { if (favicon) await adminApiService.updateSetting("branding.favicon", favicon); if (faviconDark) await adminApiService.updateSetting("branding.favicon_dark", faviconDark); if (colorsDark && Object.keys(colorsDark).length > 0) { - console.log("ACTUALLY SAVING COLORS DARK:", colorsDark); + logger.info({ colorsDark }, "Saving dark theme colors"); await adminApiService.updateSetting("branding.colors_dark", colorsDark); } toast({ title: "Branding saved" }); diff --git a/packages/admin-ui/src/pages/GroupCreate.tsx b/packages/admin-ui/src/pages/GroupCreate.tsx index b9c6a27..f04a0bb 100644 --- a/packages/admin-ui/src/pages/GroupCreate.tsx +++ b/packages/admin-ui/src/pages/GroupCreate.tsx @@ -9,6 +9,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import adminApiService, { type Permission } from "@/services/api"; +import { logger } from "@/services/logger"; export default function GroupCreate() { const navigate = useNavigate(); @@ -29,7 +30,7 @@ export default function GroupCreate() { const permissionsData = await adminApiService.getPermissions(); setPermissions(permissionsData); } catch (error) { - console.error("Failed to load permissions:", error); + logger.error(error, "Failed to load permissions"); setError(error instanceof Error ? error.message : "Failed to load permissions"); } finally { setLoadingPermissions(false); diff --git a/packages/admin-ui/src/pages/Groups.tsx b/packages/admin-ui/src/pages/Groups.tsx index f9d82ba..6d4c88b 100644 --- a/packages/admin-ui/src/pages/Groups.tsx +++ b/packages/admin-ui/src/pages/Groups.tsx @@ -18,6 +18,7 @@ import { PaginationPrevious, } from "@/components/ui/pagination"; import adminApiService, { type Group } from "@/services/api"; +import { logger } from "@/services/logger"; export default function Groups() { const navigate = useNavigate(); @@ -39,7 +40,7 @@ export default function Groups() { setTotalPages(response.pagination.totalPages); setTotalCount(response.pagination.total); } catch (error) { - console.error("Failed to load groups:", error); + logger.error(error, "Failed to load groups"); setError(error instanceof Error ? error.message : "Failed to load groups"); } finally { setLoading(false); diff --git a/packages/admin-ui/src/pages/NotFound.tsx b/packages/admin-ui/src/pages/NotFound.tsx index 4b69d03..f3d3910 100644 --- a/packages/admin-ui/src/pages/NotFound.tsx +++ b/packages/admin-ui/src/pages/NotFound.tsx @@ -1,12 +1,13 @@ import { useEffect } from "react"; import { useLocation } from "react-router-dom"; import PageHeader from "@/components/layout/page-header"; +import { logger } from "@/services/logger"; const NotFound = () => { const location = useLocation(); useEffect(() => { - console.error("404 Error: User attempted to access non-existent route:", location.pathname); + logger.error({ path: location.pathname }, "404 route access"); }, [location.pathname]); return ( diff --git a/packages/admin-ui/src/pages/Users.tsx b/packages/admin-ui/src/pages/Users.tsx index 18bbbab..82ac580 100644 --- a/packages/admin-ui/src/pages/Users.tsx +++ b/packages/admin-ui/src/pages/Users.tsx @@ -29,6 +29,7 @@ import { import UserCell from "@/components/user/user-cell"; import adminApiService, { type Group, type User } from "@/services/api"; import { sha256Base64Url } from "@/services/hash"; +import { logger } from "@/services/logger"; import adminOpaqueService from "@/services/opaque-cloudflare"; interface UserWithDetails extends User { @@ -72,7 +73,7 @@ export default function Users() { setTotalPages(response.pagination.totalPages); setTotalCount(response.pagination.total); } catch (error) { - console.error("Failed to load users:", error); + logger.error(error, "Failed to load users"); setError(error instanceof Error ? error.message : "Failed to load users"); } finally { setLoading(false); @@ -84,7 +85,7 @@ export default function Users() { const [groupsData] = await Promise.all([adminApiService.getGroups()]); setGroups(groupsData); } catch (error) { - console.error("Failed to load groups:", error); + logger.error(error, "Failed to load groups"); } }, []); diff --git a/packages/admin-ui/src/services/auth.ts b/packages/admin-ui/src/services/auth.ts index 9deaa6a..563fc17 100644 --- a/packages/admin-ui/src/services/auth.ts +++ b/packages/admin-ui/src/services/auth.ts @@ -1,3 +1,5 @@ +import { logger } from "./logger"; + interface StoredSession { adminId: string; name?: string; @@ -17,7 +19,7 @@ interface StoredLoginInfo { const SESSION_STORAGE_KEY = "DarkAuth_admin_session"; const LOGIN_INFO_KEY = "DarkAuth_admin_login_info"; -const SESSION_REFRESH_INTERVAL = 10 * 60 * 1000; // Refresh every 10 minutes (session lasts 15 min) +const SESSION_REFRESH_INTERVAL = 10 * 60 * 1000; class AuthService { private refreshTimer: ReturnType | null = null; @@ -100,7 +102,7 @@ class AuthService { try { await onRefresh(); } catch (error) { - console.error("Session refresh failed:", error); + logger.error(error, "Session refresh failed"); // If refresh fails, clear the stored session this.clearSession(); } @@ -112,7 +114,7 @@ class AuthService { try { await onRefresh(); } catch (error) { - console.error("Session refresh on visibility change failed:", error); + logger.error(error, "Session refresh on visibility change failed"); } } }; diff --git a/packages/admin-ui/src/services/logger.ts b/packages/admin-ui/src/services/logger.ts new file mode 100644 index 0000000..c6242ac --- /dev/null +++ b/packages/admin-ui/src/services/logger.ts @@ -0,0 +1,53 @@ +const levelMethod: Record = { + error: "error", + warn: "warn", + info: "info", + debug: "debug", +}; + +type LogLevel = keyof typeof levelMethod; +type LogDetail = unknown; + +function serialize(detail: LogDetail) { + if (detail == null) return undefined; + if (detail instanceof Error) { + return { + error: { + name: detail.name, + message: detail.message, + stack: detail.stack, + }, + }; + } + if (typeof detail === "string") return { detail }; + if (Array.isArray(detail)) return { detail }; + if (typeof detail === "object") return detail as Record; + return { detail }; +} + +function emit(level: LogLevel, detail?: LogDetail, message?: string) { + const method = levelMethod[level] || "log"; + const payload: Record = { + level, + timestamp: new Date().toISOString(), + }; + if (message) payload.message = message; + const extra = serialize(detail); + if (extra && typeof extra === "object") Object.assign(payload, extra); + (console[method] || console.log).call(console, JSON.stringify(payload)); +} + +export const logger = { + error(detail?: LogDetail, message?: string) { + emit("error", detail, message); + }, + warn(detail?: LogDetail, message?: string) { + emit("warn", detail, message); + }, + info(detail?: LogDetail, message?: string) { + emit("info", detail, message); + }, + debug(detail?: LogDetail, message?: string) { + emit("debug", detail, message); + }, +}; diff --git a/packages/admin-ui/tsconfig.app.json b/packages/admin-ui/tsconfig.app.json index 099410b..5e2fcf9 100644 --- a/packages/admin-ui/tsconfig.app.json +++ b/packages/admin-ui/tsconfig.app.json @@ -1,30 +1,25 @@ { - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitAny": false, - "noFallthroughCasesInSwitch": false, - - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src"] + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client"] + }, + "include": ["src"] } diff --git a/packages/admin-ui/tsconfig.json b/packages/admin-ui/tsconfig.json index 5cfda06..1ffef60 100644 --- a/packages/admin-ui/tsconfig.json +++ b/packages/admin-ui/tsconfig.json @@ -1,19 +1,7 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ], - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "noImplicitAny": false, - "noUnusedParameters": false, - "skipLibCheck": true, - "allowJs": true, - "noUnusedLocals": false, - "strictNullChecks": false - } + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] } diff --git a/packages/admin-ui/tsconfig.node.json b/packages/admin-ui/tsconfig.node.json index 84cf5de..b1bf32b 100644 --- a/packages/admin-ui/tsconfig.node.json +++ b/packages/admin-ui/tsconfig.node.json @@ -1,22 +1,13 @@ { - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] } diff --git a/packages/api/src/context/createContext.ts b/packages/api/src/context/createContext.ts index fe3c5c4..2509716 100644 --- a/packages/api/src/context/createContext.ts +++ b/packages/api/src/context/createContext.ts @@ -1,20 +1,19 @@ -import { eq, lt } from "drizzle-orm"; +import { lt } from "drizzle-orm"; import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import pino from "pino"; import { createPglite } from "../db/pglite.js"; import * as schema from "../db/schema.js"; -import { opaqueLoginSessions, settings } from "../db/schema.js"; +import { opaqueLoginSessions } from "../db/schema.js"; import { ensureDefaultGroupAndSchema } from "../models/install.js"; -import { createKekService } from "../services/kek.js"; +import { ensureKekService } from "../services/kek.js"; import { createOpaqueService } from "../services/opaque.js"; import { cleanupExpiredSessions } from "../services/sessions.js"; -import type { Config, Context, Database, KdfParams } from "../types.js"; +import type { Config, Context, Database } from "../types.js"; export async function createContext(config: Config): Promise { const cleanupFunctions: Array<() => Promise | void> = []; - // Create logger with configured level const logger = pino({ level: config.logLevel || "info", transport: config.isDevelopment @@ -27,82 +26,63 @@ export async function createContext(config: Config): Promise { : undefined, }); - let db!: Database; let pool: Pool | null = null; - if (!config.inInstallMode) { - if (config.dbMode === "pglite") { - const { db: pdb, close } = await createPglite(config.pgliteDir || "data/pglite"); - db = pdb; - cleanupFunctions.push(async () => { - await close(); - }); - } else { - pool = new Pool({ - connectionString: config.postgresUri, - keepAlive: true, - }); + let database: Database; - pool.on("error", (err) => { - logger.error({ err }, "Database pool error"); - }); + if (config.dbMode === "pglite") { + const { db, close } = await createPglite(config.pgliteDir || "data/pglite"); + database = db; + cleanupFunctions.push(async () => { + await close(); + }); + } else { + pool = new Pool({ + connectionString: config.postgresUri, + keepAlive: true, + }); - db = drizzlePg(pool, { schema }); + pool.on("error", (err) => { + logger.error({ err }, "Database pool error"); + }); - cleanupFunctions.push(async () => { - await pool?.end(); - }); - } - } + database = drizzlePg(pool, { schema }); - let kekService = undefined as Context["services"]["kek"] | undefined; - if (!config.inInstallMode && config.kekPassphrase) { - try { - const kdf = await db.query.settings.findFirst({ where: eq(settings.key, "kek_kdf") }); - const params = (kdf?.value as KdfParams | undefined) || undefined; - if (params) { - kekService = await createKekService(config.kekPassphrase, params); - } - } catch (err) { - logger.warn({ err }, "KEK service unavailable (db not ready)"); - } + cleanupFunctions.push(async () => { + await pool?.end(); + }); } - const tempContext: Context = { - db, + const services: Context["services"] = {}; + + const context: Context = { + db: database, config, - services: { kek: kekService }, + services, logger, cleanupFunctions, async destroy() { - for (const cleanup of cleanupFunctions) await cleanup(); + for (const cleanup of cleanupFunctions) { + await cleanup(); + } }, }; - let opaqueService = undefined as Context["services"]["opaque"] | undefined; + if (!config.inInstallMode && config.kekPassphrase) { + try { + await ensureKekService(context); + } catch (err) { + logger.warn({ err }, "KEK service unavailable (db not ready)"); + } + } + if (!config.inInstallMode) { try { - opaqueService = await createOpaqueService(tempContext); + services.opaque = await createOpaqueService(context); } catch (err) { logger.warn({ err }, "OPAQUE service unavailable (db not ready)"); } } - const context: Context = { - db, - config, - services: { - kek: kekService, - opaque: opaqueService, - }, - logger, - cleanupFunctions, - async destroy() { - for (const cleanup of cleanupFunctions) { - await cleanup(); - } - }, - }; - if (!config.inInstallMode) { try { await ensureDefaultGroupAndSchema(context); @@ -115,7 +95,7 @@ export async function createContext(config: Config): Promise { const interval = setInterval( async () => { try { - await cleanupExpiredSessions({ ...context, services: context.services }); + await cleanupExpiredSessions(context); await context.db .delete(opaqueLoginSessions) .where(lt(opaqueLoginSessions.expiresAt, new Date())); diff --git a/packages/api/src/controllers/admin/adminUserPasswordSetFinish.ts b/packages/api/src/controllers/admin/adminUserPasswordSetFinish.ts index f2f46c3..07d33b7 100644 --- a/packages/api/src/controllers/admin/adminUserPasswordSetFinish.ts +++ b/packages/api/src/controllers/admin/adminUserPasswordSetFinish.ts @@ -4,6 +4,7 @@ import { NotFoundError, ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { adminUserPasswordSetFinish } from "../../models/adminPasswords.js"; import { getAdminById } from "../../models/adminUsers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -27,7 +28,7 @@ async function postAdminUserPasswordSetFinishHandler( ) { const Params = z.object({ adminId: z.string() }); const { adminId } = Params.parse({ adminId: _params[0] }); - if (!context.services.opaque) throw new ValidationError("OPAQUE service not available"); + await requireOpaqueService(context); const session = await requireSession(context, request, true); if (session.adminRole !== "write") throw new ValidationError("Write permission required"); diff --git a/packages/api/src/controllers/admin/adminUserPasswordSetStart.ts b/packages/api/src/controllers/admin/adminUserPasswordSetStart.ts index 0cc7e90..0493319 100644 --- a/packages/api/src/controllers/admin/adminUserPasswordSetStart.ts +++ b/packages/api/src/controllers/admin/adminUserPasswordSetStart.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { NotFoundError, ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { getAdminById } from "../../models/adminUsers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -25,7 +26,7 @@ async function postAdminUserPasswordSetStartHandler( response: ServerResponse, adminId: string ) { - if (!context.services.opaque) throw new ValidationError("OPAQUE service not available"); + const opaque = await requireOpaqueService(context); const session = await requireSession(context, request, true); if (session.adminRole !== "write") throw new ValidationError("Write permission required"); @@ -36,10 +37,7 @@ async function postAdminUserPasswordSetStartHandler( const data = parseJsonSafely(body); const parsed = PasswordSetStartRequestSchema.parse(data); const requestBuffer = fromBase64Url(parsed.request); - const registrationResponse = await context.services.opaque.startRegistration( - requestBuffer, - admin.email - ); + const registrationResponse = await opaque.startRegistration(requestBuffer, admin.email); sendJson(response, 200, { message: toBase64Url(Buffer.from(registrationResponse.message)), diff --git a/packages/api/src/controllers/admin/opaqueLoginFinish.ts b/packages/api/src/controllers/admin/opaqueLoginFinish.ts index 5186bf4..4575533 100644 --- a/packages/api/src/controllers/admin/opaqueLoginFinish.ts +++ b/packages/api/src/controllers/admin/opaqueLoginFinish.ts @@ -7,6 +7,7 @@ import { genericErrors } from "../../http/openapi-helpers.js"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.js"; import { getAdminByEmail } from "../../models/adminUsers.js"; import { getOtpStatusModel } from "../../models/otp.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { createSession } from "../../services/sessions.js"; import type { Context, ControllerSchema, OpaqueLoginResult } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -53,9 +54,7 @@ async function postAdminOpaqueLoginFinishHandler( ..._params: unknown[] ): Promise { context.logger.debug({ path: "/admin/opaque/login/finish" }, "admin opaque login finish"); - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); // Read and parse request body const body = await getCachedBody(request); @@ -114,7 +113,7 @@ async function postAdminOpaqueLoginFinishHandler( { sessionId: data.sessionId }, "[admin:login:finish] Calling OPAQUE finishLogin" ); - loginResult = await context.services.opaque.finishLogin(finishBuffer, data.sessionId); + loginResult = await opaque.finishLogin(finishBuffer, data.sessionId); context.logger.info( { sessionId: data.sessionId, diff --git a/packages/api/src/controllers/admin/opaqueLoginStart.ts b/packages/api/src/controllers/admin/opaqueLoginStart.ts index 6e50494..3801f8a 100644 --- a/packages/api/src/controllers/admin/opaqueLoginStart.ts +++ b/packages/api/src/controllers/admin/opaqueLoginStart.ts @@ -4,14 +4,21 @@ import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.js"; import { getAdminByEmail } from "../../models/adminUsers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import type { Context, ControllerSchema, OpaqueLoginResponse } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; import { parseJsonSafely, sendError, sendJson } from "../../utils/http.js"; -const OpaqueLoginStartRequestSchema = z.object({ - email: z.string().email(), - start: z.string(), -}); +const OpaqueLoginStartRequestSchema = z + .object({ + email: z.string().email(), + start: z.string().optional(), + request: z.string().optional(), + }) + .refine((data) => typeof data.start === "string" || typeof data.request === "string", { + message: "Missing request payload", + path: ["request"], + }); const OpaqueLoginStartResponseSchema = z.object({ response: z.string(), @@ -26,74 +33,48 @@ async function postAdminOpaqueLoginStartHandler( ): Promise { try { context.logger.debug({ path: "/admin/opaque/login/start" }, "admin opaque login start"); - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaqueService = await requireOpaqueService(context); const body = await getCachedBody(request); - const data = parseJsonSafely(body) as Record; + const parsed = OpaqueLoginStartRequestSchema.parse(parseJsonSafely(body)); context.logger.debug({ bodyLen: body?.length || 0 }, "parsed body"); - // Validate request format - if (!data.email || typeof data.email !== "string") { - throw new ValidationError("Missing or invalid email field"); - } - - if (!data.request || typeof data.request !== "string") { - throw new ValidationError("Missing or invalid request field"); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(data.email)) { - throw new ValidationError("Invalid email format"); - } - let requestBuffer: Uint8Array; try { - requestBuffer = fromBase64Url(data.request as string); + const payload = parsed.start ?? parsed.request; + requestBuffer = fromBase64Url(payload as string); } catch (_err) { - throw new ValidationError("Invalid base64url encoding in request"); + throw new ValidationError("Invalid base64url encoding in request payload"); } context.logger.debug({ reqLen: requestBuffer.length }, "decoded request"); // Find admin user by email (with one retry if a PG client was dropped) - const adminUser = await getAdminByEmail(context, data.email as string); - context.logger.info({ email: data.email, found: !!adminUser }, "admin user lookup"); + const adminUser = await getAdminByEmail(context, parsed.email); + context.logger.info({ email: parsed.email, found: !!adminUser }, "admin user lookup"); let loginResponse: OpaqueLoginResponse; if (!adminUser) { - loginResponse = await context.services.opaque.startLoginWithDummy( - requestBuffer, - data.email as string - ); + loginResponse = await opaqueService.startLoginWithDummy(requestBuffer, parsed.email); } else { const { getAdminOpaqueRecordByAdminId } = await import("../../models/adminPasswords.js"); - const opaque = await getAdminOpaqueRecordByAdminId(context, adminUser.id); + const opaqueRecordRow = await getAdminOpaqueRecordByAdminId(context, adminUser.id); const envelopeBuffer = - typeof opaque?.envelope === "string" - ? Buffer.from((opaque?.envelope as unknown as string).slice(2), "hex") - : (opaque?.envelope ?? Buffer.alloc(0)); + typeof opaqueRecordRow?.envelope === "string" + ? Buffer.from((opaqueRecordRow?.envelope as unknown as string).slice(2), "hex") + : (opaqueRecordRow?.envelope ?? Buffer.alloc(0)); const serverPubkeyBuffer = - typeof opaque?.serverPubkey === "string" - ? Buffer.from((opaque?.serverPubkey as unknown as string).slice(2), "hex") - : (opaque?.serverPubkey ?? Buffer.alloc(0)); + typeof opaqueRecordRow?.serverPubkey === "string" + ? Buffer.from((opaqueRecordRow?.serverPubkey as unknown as string).slice(2), "hex") + : (opaqueRecordRow?.serverPubkey ?? Buffer.alloc(0)); - if (!opaque || envelopeBuffer.length === 0 || serverPubkeyBuffer.length === 0) { - loginResponse = await context.services.opaque.startLoginWithDummy( - requestBuffer, - data.email as string - ); + if (!opaqueRecordRow || envelopeBuffer.length === 0 || serverPubkeyBuffer.length === 0) { + loginResponse = await opaqueService.startLoginWithDummy(requestBuffer, parsed.email); } else { const opaqueRecord = { envelope: new Uint8Array(envelopeBuffer), serverPublicKey: new Uint8Array(serverPubkeyBuffer), }; - loginResponse = await context.services.opaque.startLogin( - requestBuffer, - opaqueRecord, - data.email as string - ); + loginResponse = await opaqueService.startLogin(requestBuffer, opaqueRecord, parsed.email); } } context.logger.debug({ sessionId: loginResponse.sessionId }, "opaque start response"); diff --git a/packages/api/src/controllers/admin/passwordChangeFinish.ts b/packages/api/src/controllers/admin/passwordChangeFinish.ts index 257aa47..c784883 100644 --- a/packages/api/src/controllers/admin/passwordChangeFinish.ts +++ b/packages/api/src/controllers/admin/passwordChangeFinish.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { adminPasswordChangeFinish } from "../../models/adminPasswords.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -18,9 +19,7 @@ async function postAdminPasswordChangeFinishHandler( response: ServerResponse ): Promise { try { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + await requireOpaqueService(context); const session = await requireSession(context, request, true); if (!session.adminId || !session.email) { diff --git a/packages/api/src/controllers/admin/passwordChangeStart.ts b/packages/api/src/controllers/admin/passwordChangeStart.ts index 3eb1b0f..df12a25 100644 --- a/packages/api/src/controllers/admin/passwordChangeStart.ts +++ b/packages/api/src/controllers/admin/passwordChangeStart.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -22,9 +23,7 @@ async function postAdminPasswordChangeStartHandler( request: IncomingMessage, response: ServerResponse ): Promise { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); const session = await requireSession(context, request, true); const email = session.email; @@ -47,10 +46,7 @@ async function postAdminPasswordChangeStartHandler( throw new ValidationError("Invalid base64url encoding in request"); } - const registrationResponse = await context.services.opaque.startRegistration( - requestBuffer, - email - ); + const registrationResponse = await opaque.startRegistration(requestBuffer, email); sendJson(response, 200, { message: toBase64Url(Buffer.from(registrationResponse.message)), diff --git a/packages/api/src/controllers/admin/userPasswordSetFinish.ts b/packages/api/src/controllers/admin/userPasswordSetFinish.ts index 44d2912..f72b996 100644 --- a/packages/api/src/controllers/admin/userPasswordSetFinish.ts +++ b/packages/api/src/controllers/admin/userPasswordSetFinish.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { finishUserPasswordSetForAdmin } from "../../models/passwords.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema, HttpHandler } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -26,21 +27,15 @@ async function postUserPasswordSetFinishHandler( userSub: string ): Promise { try { - if (!context.services.opaque) throw new ValidationError("OPAQUE service not available"); + await requireOpaqueService(context); const session = await requireSession(context, request, true); if (!session.adminRole) throw new ValidationError("Admin privileges required"); const body = await readBody(request); - const data = parseJsonSafely(body) as Record; - if (!data.record || typeof data.record !== "string") { - throw new ValidationError("Missing or invalid record field"); - } - if (!data.export_key_hash || typeof data.export_key_hash !== "string") { - throw new ValidationError("Missing or invalid export_key_hash field"); - } + const parsed = UserPasswordSetFinishRequestSchema.parse(parseJsonSafely(body)); - const record = data.record as string; - const exportKeyHash = data.export_key_hash as string; + const record = parsed.record; + const exportKeyHash = parsed.export_key_hash; const recordBuffer = fromBase64Url(record); const result = await finishUserPasswordSetForAdmin(context, { diff --git a/packages/api/src/controllers/admin/userPasswordSetStart.ts b/packages/api/src/controllers/admin/userPasswordSetStart.ts index b07deae..14b218f 100644 --- a/packages/api/src/controllers/admin/userPasswordSetStart.ts +++ b/packages/api/src/controllers/admin/userPasswordSetStart.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { startUserPasswordSetForAdmin } from "../../models/passwords.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -28,16 +29,13 @@ async function postUserPasswordSetStartHandler( userSub: string ): Promise { try { - if (!context.services.opaque) throw new ValidationError("OPAQUE service not available"); + await requireOpaqueService(context); const session = await requireSession(context, request, true); if (!session.adminRole) throw new ValidationError("Admin privileges required"); const body = await readBody(request); - const data = parseJsonSafely(body) as Record; - if (!data.request || typeof data.request !== "string") { - throw new ValidationError("Missing or invalid request field"); - } - const requestBuffer = fromBase64Url(data.request as string); + const parsed = PasswordSetStartRequestSchema.parse(parseJsonSafely(body)); + const requestBuffer = fromBase64Url(parsed.request); const { registrationResponse, identityU } = await startUserPasswordSetForAdmin( context, userSub, diff --git a/packages/api/src/controllers/admin/userUpdate.ts b/packages/api/src/controllers/admin/userUpdate.ts index c59593e..49d3fbc 100644 --- a/packages/api/src/controllers/admin/userUpdate.ts +++ b/packages/api/src/controllers/admin/userUpdate.ts @@ -7,6 +7,21 @@ import type { Context, ControllerSchema, HttpHandler } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; import { parseJsonSafely, readBody, sendJson } from "../../utils/http.js"; +const Req = z + .object({ + email: z.string().email().nullable().optional(), + name: z.string().nullable().optional(), + }) + .partial(); + +const Resp = z + .object({ + sub: z.string(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + }) + .partial(); + async function updateUserHandler( context: Context, request: IncomingMessage, @@ -23,11 +38,21 @@ async function updateUserHandler( } const body = await readBody(request); - const data = parseJsonSafely(body) as Record; - const payload = data as Partial<{ email: string | null; name: string | null }>; + const raw = parseJsonSafely(body); + const payload = Req.parse(raw); const updated = await updateUserBasic(context, sub, { - email: payload.email ?? undefined, - name: payload.name ?? undefined, + email: + payload.email === undefined + ? undefined + : payload.email === null + ? undefined + : payload.email.trim().toLowerCase(), + name: + payload.name === undefined + ? undefined + : payload.name === null + ? undefined + : payload.name.trim(), }); sendJson(response, 200, updated); } @@ -38,22 +63,6 @@ export const updateUser = withAudit({ extractResourceId: (_body: unknown, params: string[]) => params[0], })(updateUserHandler as HttpHandler); -// OpenAPI schema definition -const Req = z - .object({ - email: z.string().email().nullable().optional(), - name: z.string().nullable().optional(), - }) - .partial(); - -const Resp = z - .object({ - sub: z.string(), - email: z.string().nullable().optional(), - name: z.string().nullable().optional(), - }) - .partial(); - export const schema = { method: "PUT", path: "/admin/users/{sub}", diff --git a/packages/api/src/controllers/install/opaqueRegisterFinish.ts b/packages/api/src/controllers/install/opaqueRegisterFinish.ts index cc0ddf7..0ec25a8 100644 --- a/packages/api/src/controllers/install/opaqueRegisterFinish.ts +++ b/packages/api/src/controllers/install/opaqueRegisterFinish.ts @@ -6,7 +6,7 @@ import { ValidationError, } from "../../errors.js"; import { storeOpaqueAdmin } from "../../models/install.js"; -import { createOpaqueService } from "../../services/opaque.js"; +import { ensureOpaqueService } from "../../services/opaque.js"; import { isSystemInitialized } from "../../services/settings.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url } from "../../utils/crypto.js"; @@ -32,16 +32,13 @@ export async function postInstallOpaqueRegisterFinish( throw new AlreadyInitializedError(); } - let svc = context.services.opaque; - if (context.services.install?.tempDb) { - const tempContext = { ...context, db: context.services.install.tempDb } as Context; - try { - svc = await createOpaqueService(tempContext); - } catch { - svc = undefined; - } + let svc: NonNullable; + try { + svc = await ensureOpaqueService(context); + } catch (err) { + context.logger.error({ err }, "[install:opaque:finish] Failed to create OPAQUE service"); + throw new ValidationError("Database not prepared"); } - if (!svc) throw new ValidationError("Database not prepared"); const body = await readBody(request); context.logger.debug({ bodyLen: body.length }, "[install:opaque:finish] Read request body"); diff --git a/packages/api/src/controllers/install/opaqueRegisterStart.ts b/packages/api/src/controllers/install/opaqueRegisterStart.ts index 3c0bb94..2f7b36f 100644 --- a/packages/api/src/controllers/install/opaqueRegisterStart.ts +++ b/packages/api/src/controllers/install/opaqueRegisterStart.ts @@ -6,7 +6,7 @@ import { ExpiredInstallTokenError, ValidationError, } from "../../errors.js"; -import { createOpaqueService } from "../../services/opaque.js"; +import { ensureOpaqueService } from "../../services/opaque.js"; import { isSystemInitialized } from "../../services/settings.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -152,24 +152,16 @@ export async function postInstallOpaqueRegisterStart( } else if (!install.adminEmail) { install.adminEmail = data.email; } - let svc = context.services.opaque; context.logger.info( - { hasTempDb: !!context.services.install?.tempDb, hasOpaque: !!svc }, + { hasTempDb: !!context.services.install?.tempDb, hasOpaque: !!context.services.opaque }, "[install:opaque:start] Checking OPAQUE service" ); - if (context.services.install?.tempDb) { - context.logger.info("[install:opaque:start] Creating OPAQUE service with temporary database"); - const tempContext = { ...context, db: context.services.install.tempDb } as Context; - try { - svc = await createOpaqueService(tempContext); - context.logger.info("[install:opaque:start] OPAQUE service created successfully"); - } catch (err) { - context.logger.error({ err }, "[install:opaque:start] Failed to create OPAQUE service"); - svc = undefined; - } - } - if (!svc) { - context.logger.error("[install:opaque:start] No OPAQUE service available"); + let svc: NonNullable; + try { + svc = await ensureOpaqueService(context); + context.logger.info("[install:opaque:start] OPAQUE service ready"); + } catch (err) { + context.logger.error({ err }, "[install:opaque:start] Failed to create OPAQUE service"); throw new ValidationError("Database not prepared"); } const reqBuf = fromBase64Url(data.request); diff --git a/packages/api/src/controllers/install/postInstallComplete.ts b/packages/api/src/controllers/install/postInstallComplete.ts index 6c3b7c4..f0ab726 100644 --- a/packages/api/src/controllers/install/postInstallComplete.ts +++ b/packages/api/src/controllers/install/postInstallComplete.ts @@ -9,7 +9,7 @@ import { } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { generateEdDSAKeyPair, storeKeyPair } from "../../services/jwks.js"; -import { createKekService, generateKdfParams } from "../../services/kek.js"; +import { ensureKekService, generateKdfParams } from "../../services/kek.js"; import { isSystemInitialized, markSystemInitialized, @@ -87,7 +87,7 @@ async function _postInstallComplete( const passphrase = context.config.kekPassphrase; const kdfParams = generateKdfParams(); - const kekService = await createKekService(passphrase, kdfParams); + const kekService = await ensureKekService(context, passphrase, kdfParams); context.logger.info( { @@ -130,7 +130,6 @@ async function _postInstallComplete( await storeKeyPair(tempContextForKeys, kid, publicJwk, privateJwk); context.logger.debug("[install:post] creating default clients"); - const _appWebClientSecret = generateRandomString(32); const supportDeskClientSecret = generateRandomString(32); const supportDeskSecretEnc = await kekService.encrypt(Buffer.from(supportDeskClientSecret)); @@ -181,8 +180,6 @@ async function _postInstallComplete( serverWillRestart: true, }); - context.services.kek = kekService; - try { const { upsertConfig } = await import("../../config/saveConfig.js"); upsertConfig( diff --git a/packages/api/src/controllers/user/opaqueLoginFinish.ts b/packages/api/src/controllers/user/opaqueLoginFinish.ts index 6e324fe..5f82f73 100644 --- a/packages/api/src/controllers/user/opaqueLoginFinish.ts +++ b/packages/api/src/controllers/user/opaqueLoginFinish.ts @@ -6,6 +6,7 @@ import { AppError, UnauthorizedError, ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.js"; import { getUserBySubOrEmail } from "../../models/users.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { createSession } from "../../services/sessions.js"; import type { Context, ControllerSchema, OpaqueLoginResult } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -32,9 +33,7 @@ export const postOpaqueLoginFinish = withRateLimit("opaque", (body) => { response: ServerResponse, ..._params: unknown[] ): Promise => { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); if (!context.db) { throw new ValidationError("Database context not available"); @@ -88,7 +87,7 @@ export const postOpaqueLoginFinish = withRateLimit("opaque", (body) => { // Call OPAQUE service to finish login first to normalize timing let loginResult: OpaqueLoginResult; try { - loginResult = await context.services.opaque.finishLogin(finishBuffer, sessionId); + loginResult = await opaque.finishLogin(finishBuffer, sessionId); } catch (error) { context.logger.error({ err: error }, "opaque login finish failed"); throw new UnauthorizedError("Authentication failed"); diff --git a/packages/api/src/controllers/user/opaqueLoginStart.ts b/packages/api/src/controllers/user/opaqueLoginStart.ts index 72d475b..0622d14 100644 --- a/packages/api/src/controllers/user/opaqueLoginStart.ts +++ b/packages/api/src/controllers/user/opaqueLoginStart.ts @@ -4,6 +4,7 @@ import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.js"; import { getUserOpaqueRecordByEmail } from "../../models/users.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import type { Context, ControllerSchema, OpaqueLoginResponse } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; import { parseJsonSafely, sendJson } from "../../utils/http.js"; @@ -28,9 +29,7 @@ export const postOpaqueLoginStart = withRateLimit("opaque", (body) => ..._params: unknown[] ): Promise => { context.logger.debug({ path: "/opaque/login/start" }, "user opaque login start"); - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); // Read and parse request body (may be cached by rate limit middleware) const body = await getCachedBody(request); @@ -54,10 +53,7 @@ export const postOpaqueLoginStart = withRateLimit("opaque", (body) => let loginResponse: OpaqueLoginResponse; if (!userLookup.user) { - loginResponse = await context.services.opaque.startLoginWithDummy( - requestBuffer, - parsed.email - ); + loginResponse = await opaque.startLoginWithDummy(requestBuffer, parsed.email); } else { const envelopeBuffer = userLookup.envelope as unknown as Buffer | string | null; const serverPubkeyBuffer = userLookup.serverPubkey as unknown as Buffer | string | null; @@ -71,20 +67,13 @@ export const postOpaqueLoginStart = withRateLimit("opaque", (body) => : (serverPubkeyBuffer as Buffer); if (!envelopeBuf || !serverPubkeyBuf || envelopeBuf.length === 0) { - loginResponse = await context.services.opaque.startLoginWithDummy( - requestBuffer, - parsed.email - ); + loginResponse = await opaque.startLoginWithDummy(requestBuffer, parsed.email); } else { const opaqueRecord = { envelope: new Uint8Array(envelopeBuf), serverPublicKey: new Uint8Array(serverPubkeyBuf), }; - loginResponse = await context.services.opaque.startLogin( - requestBuffer, - opaqueRecord, - parsed.email - ); + loginResponse = await opaque.startLogin(requestBuffer, opaqueRecord, parsed.email); } } context.logger.debug({ sessionId: loginResponse.sessionId }, "opaque start ok"); diff --git a/packages/api/src/controllers/user/opaqueRegisterFinish.ts b/packages/api/src/controllers/user/opaqueRegisterFinish.ts index 03ef979..83323f1 100644 --- a/packages/api/src/controllers/user/opaqueRegisterFinish.ts +++ b/packages/api/src/controllers/user/opaqueRegisterFinish.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ForbiddenError, ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { userOpaqueRegisterFinish } from "../../models/registration.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { getSetting } from "../../services/settings.js"; import type { Context, ControllerSchema } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -24,9 +25,7 @@ export const postOpaqueRegisterFinish = withAudit({ ..._params: unknown[] ): Promise => { try { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + await requireOpaqueService(context); const enabled = (await getSetting(context, "users.self_registration_enabled")) as | boolean diff --git a/packages/api/src/controllers/user/opaqueRegisterStart.ts b/packages/api/src/controllers/user/opaqueRegisterStart.ts index 99ce84a..7ab6a0f 100644 --- a/packages/api/src/controllers/user/opaqueRegisterStart.ts +++ b/packages/api/src/controllers/user/opaqueRegisterStart.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod/v4"; import { ForbiddenError, ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { getSetting } from "../../services/settings.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -12,9 +13,7 @@ export const postOpaqueRegisterStart = async ( request: IncomingMessage, response: ServerResponse ): Promise => { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); const enabled = (await getSetting(context, "users.self_registration_enabled")) as | boolean @@ -57,7 +56,7 @@ export const postOpaqueRegisterStart = async ( "[opaque] controller.registerStart.decoded" ); - const registrationResponse = await context.services.opaque.startRegistration( + const registrationResponse = await opaque.startRegistration( requestBuffer, typeof parsed.data.email === "string" ? parsed.data.email : "", "DarkAuth" diff --git a/packages/api/src/controllers/user/passwordChangeFinish.ts b/packages/api/src/controllers/user/passwordChangeFinish.ts index eb9912a..15fa84b 100644 --- a/packages/api/src/controllers/user/passwordChangeFinish.ts +++ b/packages/api/src/controllers/user/passwordChangeFinish.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { userPasswordChangeFinish } from "../../models/passwords.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { withAudit } from "../../utils/auditWrapper.js"; @@ -14,9 +15,7 @@ async function postUserPasswordChangeFinishHandler( request: IncomingMessage, response: ServerResponse ) { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + await requireOpaqueService(context); const session = await requireSession(context, request, false); if (!session.sub || !session.email) { diff --git a/packages/api/src/controllers/user/passwordChangeStart.ts b/packages/api/src/controllers/user/passwordChangeStart.ts index abcc707..068bee6 100644 --- a/packages/api/src/controllers/user/passwordChangeStart.ts +++ b/packages/api/src/controllers/user/passwordChangeStart.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -12,9 +13,7 @@ async function postUserPasswordChangeStartHandler( request: IncomingMessage, response: ServerResponse ) { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); const session = await requireSession(context, request, false); const email = session.email; @@ -33,10 +32,7 @@ async function postUserPasswordChangeStartHandler( throw new ValidationError("Invalid base64url encoding in request"); } - const registrationResponse = await context.services.opaque.startRegistration( - requestBuffer, - email - ); + const registrationResponse = await opaque.startRegistration(requestBuffer, email); sendJson(response, 200, { message: toBase64Url(Buffer.from(registrationResponse.message)), diff --git a/packages/api/src/controllers/user/passwordChangeVerifyFinish.ts b/packages/api/src/controllers/user/passwordChangeVerifyFinish.ts index f6f48f6..7828c06 100644 --- a/packages/api/src/controllers/user/passwordChangeVerifyFinish.ts +++ b/packages/api/src/controllers/user/passwordChangeVerifyFinish.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { signJWT } from "../../services/jwks.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema, JWTPayload } from "../../types.js"; import { fromBase64Url } from "../../utils/crypto.js"; @@ -19,9 +20,7 @@ export async function postUserPasswordVerifyFinish( request: IncomingMessage, response: ServerResponse ): Promise { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); const session = await requireSession(context, request, false); if (!session.email || !session.sub) throw new ValidationError("Invalid user session"); @@ -37,7 +36,7 @@ export async function postUserPasswordVerifyFinish( throw new ValidationError("Invalid base64url encoding in finish"); } - await context.services.opaque.finishLogin(finishBuffer, parsed.sessionId); + await opaque.finishLogin(finishBuffer, parsed.sessionId); const token = await signJWT( context, diff --git a/packages/api/src/controllers/user/passwordChangeVerifyStart.ts b/packages/api/src/controllers/user/passwordChangeVerifyStart.ts index 470fe0c..484e033 100644 --- a/packages/api/src/controllers/user/passwordChangeVerifyStart.ts +++ b/packages/api/src/controllers/user/passwordChangeVerifyStart.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ValidationError } from "../../errors.js"; import { genericErrors } from "../../http/openapi-helpers.js"; import { getUserOpaqueRecordByEmail } from "../../models/users.js"; +import { requireOpaqueService } from "../../services/opaque.js"; import { requireSession } from "../../services/sessions.js"; import type { Context, ControllerSchema, OpaqueLoginResponse } from "../../types.js"; import { fromBase64Url, toBase64Url } from "../../utils/crypto.js"; @@ -13,9 +14,7 @@ export async function postUserPasswordVerifyStart( request: IncomingMessage, response: ServerResponse ): Promise { - if (!context.services.opaque) { - throw new ValidationError("OPAQUE service not available"); - } + const opaque = await requireOpaqueService(context); const session = await requireSession(context, request, false); if (!session.email) throw new ValidationError("Email not available for session"); @@ -68,7 +67,7 @@ export async function postUserPasswordVerifyStart( let loginResponse: OpaqueLoginResponse; try { - loginResponse = await context.services.opaque.startLogin( + loginResponse = await opaque.startLogin( requestBuffer, { envelope: new Uint8Array(envelopeBuf), diff --git a/packages/api/src/services/kek.ts b/packages/api/src/services/kek.ts index 5b64427..141daaa 100644 --- a/packages/api/src/services/kek.ts +++ b/packages/api/src/services/kek.ts @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; import { hash } from "argon2"; -import type { KdfParams } from "../types.js"; +import { eq } from "drizzle-orm"; +import { settings } from "../db/schema.js"; +import type { Context, KdfParams } from "../types.js"; import { decryptAesGcm, encryptAesGcm } from "../utils/crypto.js"; export async function createKekService(passphrase: string, params: KdfParams) { @@ -55,3 +57,34 @@ export function generateKdfParams(): KdfParams { hashLength: 32, }; } + +export async function ensureKekService( + context: Context, + passphrase?: string, + params?: KdfParams +): Promise> { + if (context.services.kek?.isAvailable()) { + return context.services.kek; + } + + const effectivePassphrase = passphrase ?? context.config.kekPassphrase; + if (!effectivePassphrase) { + throw new Error("KEK passphrase is required to initialize KDF service"); + } + + let effectiveParams = params; + if (!effectiveParams) { + const stored = await context.db.query.settings.findFirst({ + where: eq(settings.key, "kek_kdf"), + }); + effectiveParams = (stored?.value as KdfParams | undefined) || undefined; + } + + if (!effectiveParams) { + throw new Error("KDF parameters not found"); + } + + const service = await createKekService(effectivePassphrase, effectiveParams); + context.services.kek = service; + return service; +} diff --git a/packages/api/src/services/opaque.ts b/packages/api/src/services/opaque.ts index 3894826..a93d831 100644 --- a/packages/api/src/services/opaque.ts +++ b/packages/api/src/services/opaque.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { eq, lt } from "drizzle-orm"; import { opaqueLoginSessions } from "../db/schema.js"; +import { ValidationError } from "../errors.js"; import { createOpaqueClientService, createOpaqueServerService, @@ -17,63 +18,44 @@ import type { } from "../types.js"; import { loadOpaqueServerState, saveOpaqueServerState } from "./opaqueState.js"; -let opaqueServerService: OpaqueServerService | null = null; - -/** - * RFC 9380 compliant OPAQUE service - * - * Provides password-authenticated key exchange where the server never learns passwords, - * export keys are deterministic per user+password, and the protocol is secure against - * offline dictionary attacks. - */ export async function createOpaqueService(context?: Context) { - if (!opaqueServerService) { - try { - context?.logger?.debug("[opaque] server.init: loading persisted state"); - } catch { - // Logger errors are ignored - } - const persistedState = context ? await loadOpaqueServerState(context) : undefined; - const serverState = persistedState - ? { - oprfSeed: persistedState.oprfSeed, - serverKeypair: persistedState.serverKeypair, - serverIdentity: persistedState.serverIdentity || "DarkAuth", - } - : undefined; + try { + context?.logger?.debug("[opaque] server.init: loading persisted state"); + } catch {} + const persistedState = context ? await loadOpaqueServerState(context) : undefined; + const serverState = persistedState + ? { + oprfSeed: persistedState.oprfSeed, + serverKeypair: persistedState.serverKeypair, + serverIdentity: persistedState.serverIdentity || "DarkAuth", + } + : undefined; - opaqueServerService = await createOpaqueServerService(serverState, context?.logger); + const server: OpaqueServerService = await createOpaqueServerService(serverState, context?.logger); - if (context) { - const state = opaqueServerService.getState(); - try { - context?.logger?.debug( - { - oprfSeedLen: state.oprfSeed?.length || 0, - pubLen: state.serverKeypair?.public_key?.length || 0, - }, - "[opaque] server.state" - ); - } catch { - // Logger errors are ignored - } - const stateToSave = { - oprfSeed: state.oprfSeed, - serverKeypair: state.serverKeypair, - serverIdentity: state.serverIdentity || "DarkAuth", - }; - await saveOpaqueServerState(context, stateToSave); - } + if (context) { + const state = server.getState(); + try { + context.logger.debug( + { + oprfSeedLen: state.oprfSeed?.length || 0, + pubLen: state.serverKeypair?.public_key?.length || 0, + }, + "[opaque] server.state" + ); + } catch {} + const stateToSave = { + oprfSeed: state.oprfSeed, + serverKeypair: state.serverKeypair, + serverIdentity: state.serverIdentity || "DarkAuth", + }; + await saveOpaqueServerState(context, stateToSave); } let dummyRecord: { envelope: Uint8Array; serverPublicKey: Uint8Array } | null = null; async function ensureDummyRecord() { if (dummyRecord) return dummyRecord; - const server = opaqueServerService; - if (!server) { - throw new Error("OPAQUE server not initialized"); - } const client = await createOpaqueClientService(); const identityU = "dummy@example.invalid"; const password = Buffer.from(randomBytes(32)).toString("base64"); @@ -91,13 +73,9 @@ export async function createOpaqueService(context?: Context) { return dummyRecord; } - return { + const service = { async serverSetup(): Promise { - if (!opaqueServerService) { - throw new Error("OPAQUE server not initialized"); - } - - const setup = opaqueServerService.getSetup(); + const setup = server.getSetup(); try { context?.logger?.debug({ event: "opaque.serverSetup" }); } catch {} @@ -111,11 +89,7 @@ export async function createOpaqueService(context?: Context) { identityU: string, identityS = "DarkAuth" ): Promise { - if (!opaqueServerService) { - throw new Error("OPAQUE server not initialized"); - } - - const result = await opaqueServerService.startRegistration(request, identityU, identityS); + const result = await server.startRegistration(request, identityU, identityS); try { context?.logger?.debug({ event: "opaque.startRegistration", @@ -126,7 +100,7 @@ export async function createOpaqueService(context?: Context) { return { message: result.response, - serverPublicKey: opaqueServerService.getSetup().serverPublicKey, + serverPublicKey: server.getSetup().serverPublicKey, }; }, @@ -135,11 +109,7 @@ export async function createOpaqueService(context?: Context) { identityU: string, identityS = "DarkAuth" ): Promise { - if (!opaqueServerService) { - throw new Error("OPAQUE server not initialized"); - } - - const result = await opaqueServerService.finishRegistration(upload, identityU, identityS); + const result = await server.finishRegistration(upload, identityU, identityS); try { context?.logger?.debug({ event: "opaque.finishRegistration", @@ -159,11 +129,7 @@ export async function createOpaqueService(context?: Context) { identityU: string, identityS = "DarkAuth" ): Promise { - if (!opaqueServerService) { - throw new Error("OPAQUE server not initialized"); - } - - const result = await opaqueServerService.startLogin( + const result = await server.startLogin( request, record.envelope, record.serverPublicKey, @@ -177,26 +143,24 @@ export async function createOpaqueService(context?: Context) { const sessionId = Buffer.from(result.state).toString(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - // Encrypt identity parameters before storing (if KEK service is available) + if (!context) { + throw new Error("Database context is required for OPAQUE login sessions"); + } + let identitySToStore: string; let identityUToStore: string; - if (context?.services?.kek) { + if (context.services?.kek) { const kekSvc = context.services.kek; const encryptedIdentityS = await kekSvc.encrypt(Buffer.from(identityS, "utf-8")); const encryptedIdentityU = await kekSvc.encrypt(Buffer.from(identityU, "utf-8")); identitySToStore = encryptedIdentityS.toString("base64"); identityUToStore = encryptedIdentityU.toString("base64"); } else { - // Fallback to base64 encoding if KEK not available (during initial setup) identitySToStore = Buffer.from(identityS, "utf-8").toString("base64"); identityUToStore = Buffer.from(identityU, "utf-8").toString("base64"); } - if (!context?.db) { - throw new Error("Database context is required for OPAQUE login sessions"); - } - await context.db .insert(opaqueLoginSessions) .values({ @@ -226,14 +190,11 @@ export async function createOpaqueService(context?: Context) { identityS = "DarkAuth" ): Promise { const rec = await ensureDummyRecord(); - return this.startLogin(request, rec, identityU, identityS); + return service.startLogin(request, rec, identityU, identityS); }, async finishLogin(finish: Uint8Array, sessionId: string): Promise { - if (!opaqueServerService) { - throw new Error("OPAQUE server not initialized"); - } - if (!context?.db) { + if (!context) { throw new Error("Database context is required for OPAQUE login sessions"); } @@ -251,11 +212,10 @@ export async function createOpaqueService(context?: Context) { throw new Error("Invalid or expired login session"); } - // Decrypt identity parameters before using (if KEK service is available) let decryptedIdentityS: string; let decryptedIdentityU: string; - if (context?.services?.kek) { + if (context.services?.kek) { try { const kekSvc = context.services.kek; const decS = await kekSvc.decrypt(Buffer.from(row.identityS, "base64")); @@ -263,12 +223,10 @@ export async function createOpaqueService(context?: Context) { decryptedIdentityS = decS.toString("utf-8"); decryptedIdentityU = decU.toString("utf-8"); } catch { - // Fallback if decryption fails (data might be base64 encoded during initial setup) decryptedIdentityS = Buffer.from(row.identityS, "base64").toString("utf-8"); decryptedIdentityU = Buffer.from(row.identityU, "base64").toString("utf-8"); } } else { - // Fallback to base64 decoding if KEK not available decryptedIdentityS = Buffer.from(row.identityS, "base64").toString("utf-8"); decryptedIdentityU = Buffer.from(row.identityU, "base64").toString("utf-8"); } @@ -286,7 +244,7 @@ export async function createOpaqueService(context?: Context) { let result: OpaqueLoginResult; try { - result = await opaqueServerService.finishLogin( + result = await server.finishLogin( finish, new Uint8Array(row.serverState ?? []), decryptedIdentityU, @@ -318,12 +276,35 @@ export async function createOpaqueService(context?: Context) { sessionKey: result.sessionKey, }; }, - }; + } satisfies Context["services"]["opaque"]; + + return service; } -/** - * Client-side OPAQUE operations for browser/UI integration - */ export async function createOpaqueClient() { return createOpaqueClientService(); } + +export async function ensureOpaqueService( + context: Context +): Promise> { + if (context.services.opaque) { + return context.services.opaque; + } + + const tempDb = context.services.install?.tempDb; + const baseContext = tempDb ? { ...context, db: tempDb } : context; + const service = await createOpaqueService(baseContext); + context.services.opaque = service; + return service; +} + +export async function requireOpaqueService( + context: Context +): Promise> { + try { + return await ensureOpaqueService(context); + } catch (error) { + throw new ValidationError("OPAQUE service not available", { cause: error }); + } +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 0f2b351..dc6a9d2 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -12,15 +12,15 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, - "noImplicitAny": false, + "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, diff --git a/packages/brochureware/public/whitepaper.generated.md b/packages/brochureware/public/whitepaper.generated.md index 9777ec6..60be96a 100644 --- a/packages/brochureware/public/whitepaper.generated.md +++ b/packages/brochureware/public/whitepaper.generated.md @@ -2,7 +2,7 @@ A technical analysis of zero‑knowledge key delivery for OIDC -_2025-09-27_ +_2025-09-28_ # DarkAuth v1 Security Whitepaper diff --git a/packages/brochureware/src/pages/NotFound.tsx b/packages/brochureware/src/pages/NotFound.tsx index 1c672d5..7bc5c55 100644 --- a/packages/brochureware/src/pages/NotFound.tsx +++ b/packages/brochureware/src/pages/NotFound.tsx @@ -1,16 +1,8 @@ import { useLocation } from "react-router-dom"; -import { useEffect } from "react"; const NotFound = () => { const location = useLocation(); - useEffect(() => { - console.error( - "404 Error: User attempted to access non-existent route:", - location.pathname - ); - }, [location.pathname]); - return (
diff --git a/packages/brochureware/tsconfig.app.json b/packages/brochureware/tsconfig.app.json index 0b0e43e..5e2fcf9 100644 --- a/packages/brochureware/tsconfig.app.json +++ b/packages/brochureware/tsconfig.app.json @@ -1,30 +1,25 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", + "moduleResolution": "Bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, - "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - - /* Linting */ - "strict": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitAny": false, - "noFallthroughCasesInSwitch": false, - + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "types": ["vite/client"] }, "include": ["src"] } diff --git a/packages/brochureware/tsconfig.json b/packages/brochureware/tsconfig.json index 129b1a3..1ffef60 100644 --- a/packages/brochureware/tsconfig.json +++ b/packages/brochureware/tsconfig.json @@ -3,17 +3,5 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } - ], - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "noImplicitAny": false, - "noUnusedParameters": false, - "skipLibCheck": true, - "allowJs": true, - "noUnusedLocals": false, - "strictNullChecks": false - } + ] } diff --git a/packages/brochureware/tsconfig.node.json b/packages/brochureware/tsconfig.node.json index 3133162..b1bf32b 100644 --- a/packages/brochureware/tsconfig.node.json +++ b/packages/brochureware/tsconfig.node.json @@ -1,22 +1,13 @@ { "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], + "composite": true, "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true + "noEmit": true }, "include": ["vite.config.ts"] } diff --git a/packages/demo-app/src/components/Dashboard/Dashboard.tsx b/packages/demo-app/src/components/Dashboard/Dashboard.tsx index 31dcc5b..ed472fa 100644 --- a/packages/demo-app/src/components/Dashboard/Dashboard.tsx +++ b/packages/demo-app/src/components/Dashboard/Dashboard.tsx @@ -2,6 +2,7 @@ import { decryptNote, decryptNoteWithDek, resolveDek } from "@DarkAuth/client"; import { Plus } from "lucide-react"; import React from "react"; import { api } from "../../services/api"; +import { logger } from "../../services/logger"; import { useAuthStore } from "../../stores/authStore"; import { useNotesStore } from "../../stores/notesStore"; import styles from "./Dashboard.module.css"; @@ -75,7 +76,7 @@ export function Dashboard() { } } } catch (error) { - console.error(`Failed to decrypt note ${note.note_id}:`, error); + logger.error({ noteId: note.note_id, error }, "Failed to decrypt note"); anyFailed = true; } } diff --git a/packages/demo-app/src/components/Sharing/ShareModal.tsx b/packages/demo-app/src/components/Sharing/ShareModal.tsx index f494472..49fe238 100644 --- a/packages/demo-app/src/components/Sharing/ShareModal.tsx +++ b/packages/demo-app/src/components/Sharing/ShareModal.tsx @@ -3,6 +3,7 @@ import { CompactEncrypt, importJWK, type JWK } from "jose"; import { Check, Loader2, Search, UserPlus, X } from "lucide-react"; import React from "react"; import { api, type UserProfile } from "../../services/api"; +import { logger } from "../../services/logger"; import { useAuthStore } from "../../stores/authStore"; interface Props { @@ -31,13 +32,13 @@ export function ShareModal({ noteId, onClose }: Props) { return; } setSearching(true); - console.debug("[ShareModal] search start", { query: query.trim() }); + logger.debug({ query: query.trim() }, "[ShareModal] search start"); api .searchUsers(query.trim()) .then((users) => { if (!active) return; setResults(users); - console.debug("[ShareModal] search results", { count: users.length, users }); + logger.debug({ count: users.length, users }, "[ShareModal] search results"); }) .catch(() => {}) .finally(() => { @@ -53,7 +54,7 @@ export function ShareModal({ noteId, onClose }: Props) { const next = { ...prev } as Record; if (next[u.sub]) delete next[u.sub]; else next[u.sub] = u; - console.debug("[ShareModal] toggle select", { sub: u.sub, selected: !!next[u.sub] }); + logger.debug({ sub: u.sub, selected: !!next[u.sub] }, "[ShareModal] toggle select"); return next; }); }; @@ -69,9 +70,12 @@ export function ShareModal({ noteId, onClose }: Props) { const withKeys = users.filter((u) => !!u.public_key_jwk); const withoutKeys = users.filter((u) => !u.public_key_jwk); if (withoutKeys.length > 0) { - console.warn("[ShareModal] some users missing public_key_jwk", { - subs: withoutKeys.map((u) => u.sub), - }); + logger.warn( + { + subs: withoutKeys.map((u) => u.sub), + }, + "[ShareModal] some users missing public_key_jwk" + ); } if (withKeys.length === 0) { setError("Selected users have not set up encryption keys yet"); @@ -79,24 +83,27 @@ export function ShareModal({ noteId, onClose }: Props) { } const results = await Promise.allSettled( withKeys.map(async (u) => { - console.debug("[ShareModal] encrypting DEK for user", { sub: u.sub }); + logger.debug({ sub: u.sub }, "[ShareModal] encrypting DEK for user"); const pub = await importJWK(u.public_key_jwk as unknown as JWK, "ECDH-ES"); const jwe = await new CompactEncrypt(dek) .setProtectedHeader({ alg: "ECDH-ES", enc: "A256GCM" }) .encrypt(pub as CryptoKey); - console.debug("[ShareModal] calling share API", { sub: u.sub }); + logger.debug({ sub: u.sub }, "[ShareModal] calling share API"); await api.shareNote(noteId, u.sub, jwe, "write"); - console.debug("[ShareModal] share API done", { sub: u.sub }); + logger.debug({ sub: u.sub }, "[ShareModal] share API done"); return u.sub; }) ); const failures = results.filter((r) => r.status === "rejected"); const successes = results.filter((r) => r.status === "fulfilled"); if (successes.length > 0) { - console.debug("[ShareModal] share completed", { - successes: successes.length, - failures: failures.length, - }); + logger.debug( + { + successes: successes.length, + failures: failures.length, + }, + "[ShareModal] share completed" + ); onClose(); } else { setError("Failed to share with selected users"); @@ -104,7 +111,7 @@ export function ShareModal({ noteId, onClose }: Props) { } catch (e: unknown) { const message = e instanceof Error ? e.message : "Failed to share"; setError(message); - console.error("[ShareModal] share error", e); + logger.error(e, "[ShareModal] share error"); } finally { setLoading(false); } diff --git a/packages/demo-app/src/services/api.ts b/packages/demo-app/src/services/api.ts index f7a777c..18fd137 100644 --- a/packages/demo-app/src/services/api.ts +++ b/packages/demo-app/src/services/api.ts @@ -1,5 +1,6 @@ import { refreshSession } from "@DarkAuth/client"; import { z } from "zod"; +import { logger } from "./logger"; type RuntimeConfig = { demoApi?: string; issuer?: string }; const runtimeConfiguration = @@ -68,20 +69,26 @@ class ApiClient { try { const parsed = errText ? JSON.parse(errText) : null; const msg = parsed?.error || `HTTP ${response.status}`; - console.error("[demo-api] request failed", { - path, - method: options.method || "GET", - status: response.status, - body: parsed || errText || null, - }); + logger.error( + { + path, + method: options.method || "GET", + status: response.status, + body: parsed || errText || null, + }, + "[demo-api] request failed" + ); throw new Error(msg); } catch { - console.error("[demo-api] request failed", { - path, - method: options.method || "GET", - status: response.status, - body: errText || null, - }); + logger.error( + { + path, + method: options.method || "GET", + status: response.status, + body: errText || null, + }, + "[demo-api] request failed" + ); throw new Error(`HTTP ${response.status}`); } } @@ -239,12 +246,15 @@ class ApiClient { }); if (!response.ok) { const txt = await response.text().catch(() => ""); - console.error("[demo-api] request failed", { - path: "/crypto/wrapped-enc-priv", - method: "GET", - status: response.status, - body: txt, - }); + logger.error( + { + path: "/crypto/wrapped-enc-priv", + method: "GET", + status: response.status, + body: txt, + }, + "[demo-api] request failed" + ); throw new Error(`HTTP ${response.status}`); } const data = await response.json(); diff --git a/packages/demo-app/src/services/logger.ts b/packages/demo-app/src/services/logger.ts new file mode 100644 index 0000000..0ba968e --- /dev/null +++ b/packages/demo-app/src/services/logger.ts @@ -0,0 +1,53 @@ +const levelMethod: Record = { + error: "error", + warn: "warn", + info: "info", + debug: "debug", +}; + +type LogLevel = keyof typeof levelMethod; +type LogDetails = unknown; + +function serialize(details: LogDetails) { + if (details == null) return undefined; + if (details instanceof Error) { + return { + error: { + name: details.name, + message: details.message, + stack: details.stack, + }, + }; + } + if (typeof details === "string") return { detail: details }; + if (Array.isArray(details)) return { detail: details }; + if (typeof details === "object") return details as Record; + return { detail: details }; +} + +function emit(level: LogLevel, details?: LogDetails, message?: string) { + const method = levelMethod[level] || "log"; + const payload: Record = { + level, + timestamp: new Date().toISOString(), + }; + if (message) payload.message = message; + const extra = serialize(details); + if (extra && typeof extra === "object") Object.assign(payload, extra); + (console[method] || console.log).call(console, JSON.stringify(payload)); +} + +export const logger = { + error(details?: LogDetails, message?: string) { + emit("error", details, message); + }, + warn(details?: LogDetails, message?: string) { + emit("warn", details, message); + }, + info(details?: LogDetails, message?: string) { + emit("info", details, message); + }, + debug(details?: LogDetails, message?: string) { + emit("debug", details, message); + }, +}; diff --git a/packages/md-to-pdf/tsconfig.json b/packages/md-to-pdf/tsconfig.json index 443f500..8fee0b2 100644 --- a/packages/md-to-pdf/tsconfig.json +++ b/packages/md-to-pdf/tsconfig.json @@ -1,16 +1,24 @@ { "compilerOptions": { "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], "resolveJsonModule": true, "outDir": "dist", "rootDir": "src", "declaration": true, "strict": true, "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true }, "include": ["src/**/*"] } diff --git a/packages/opaque-ts/tsconfig.json b/packages/opaque-ts/tsconfig.json index f5c91ae..f302bda 100644 --- a/packages/opaque-ts/tsconfig.json +++ b/packages/opaque-ts/tsconfig.json @@ -1,32 +1,32 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "lib": [ - "dom" - ], - "composite": true, - "declarationMap": true, - "sourceMap": true, - "isolatedModules": true, - "strict": true, - "noEmitOnError": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "rootDir": ".", - "outDir": "./lib", - "skipLibCheck": true - }, - "files": [], - "include": [], - "references": [ - { - "path": "./src" - } - ] + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["DOM", "ES2022"], + "composite": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "strict": true, + "noEmitOnError": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "rootDir": ".", + "outDir": "./lib", + "skipLibCheck": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./src" + } + ] } diff --git a/packages/test-suite/tsconfig.json b/packages/test-suite/tsconfig.json index b3753bc..cc9704e 100644 --- a/packages/test-suite/tsconfig.json +++ b/packages/test-suite/tsconfig.json @@ -1,10 +1,24 @@ { - "extends": "../../tsconfig.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./", - "moduleResolution": "node", "allowImportingTsExtensions": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, "noEmit": true, "types": ["node", "@playwright/test"] }, @@ -15,4 +29,4 @@ "node_modules", "dist" ] -} \ No newline at end of file +} diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx index 7e7a135..c8166cc 100644 --- a/packages/user-ui/src/App.tsx +++ b/packages/user-ui/src/App.tsx @@ -20,6 +20,7 @@ import { clearAllExportKeys } from "./services/sessionKey"; import "./App.css"; import ThemeToggle from "./components/ThemeToggle"; import { useBranding } from "./hooks/useBranding"; +import { logger } from "./services/logger"; interface SessionData { sub: string; @@ -155,7 +156,7 @@ function AppContent() { setAuthRequestSearch(null); navigate("/login"); } catch (error) { - console.error("Logout failed:", error); + logger.error(error, "Logout failed"); } }; return ( diff --git a/packages/user-ui/src/components/Authorize.tsx b/packages/user-ui/src/components/Authorize.tsx index 23f48ba..9832414 100644 --- a/packages/user-ui/src/components/Authorize.tsx +++ b/packages/user-ui/src/components/Authorize.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useState } from "react"; import { useBranding } from "../hooks/useBranding"; import apiService from "../services/api"; import cryptoService, { fromBase64Url, sha256Base64Url, toBase64Url } from "../services/crypto"; +import { logger } from "../services/logger"; import opaqueService from "../services/opaque"; import { loadExportKey } from "../services/sessionKey"; @@ -69,7 +70,7 @@ export default function Authorize({ const keyPair = await cryptoService.generateECDHKeyPair(); setZkKeyPair(keyPair); } catch (error) { - console.error("Failed to generate ZK key pair:", error); + logger.error(error, "Failed to generate ZK key pair"); setError("Failed to initialize zero-knowledge delivery"); } }, []); @@ -147,10 +148,10 @@ export default function Authorize({ requestId: authRequest.requestId, approve, }); - console.log("[Authorize] finalize ok (non-ZK), redirecting", authResponse); + logger.info({ response: authResponse }, "[Authorize] finalize without ZK"); window.location.href = authResponse.redirectUrl; } catch (error) { - console.error("Authorization failed:", error); + logger.error(error, "Authorization failed"); let errorMessage = "Authorization failed. Please try again."; if (error instanceof Error) { @@ -168,12 +169,15 @@ export default function Authorize({ setError(errorMessage); } finally { setLoading(false); - console.log("[Authorize] handleAuthorize finished"); + logger.debug( + { requestId: authRequest.requestId, approve }, + "[Authorize] handleAuthorize finished" + ); } }; const generateNewKeys = async () => { - console.log("[Authorize] generateNewKeys start"); + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys start"); if (!sessionData.email) { setError("Email is required to initialize keys"); return; @@ -181,7 +185,7 @@ export default function Authorize({ setRecoveryLoading(true); setError(null); try { - console.log("[Authorize] generateNewKeys deriving keys"); + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys deriving keys"); const exportKey = await loadExportKey(sessionData.sub); if (!exportKey) { throw new Error("Missing export key. Please sign out and sign back in to initialize keys."); @@ -190,23 +194,33 @@ export default function Authorize({ // Clear export key from memory immediately after deriving other keys cryptoService.clearSensitiveData(exportKey); - console.log("[Authorize] generateNewKeys keys derived"); + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys keys derived"); const drk = await cryptoService.generateDRK(); const wrappedDrk = await cryptoService.wrapDRK(drk, keys.wrapKey, sessionData.sub); - console.log("[Authorize] generateNewKeys put wrapped-drk"); + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys storing wrapped DRK"); await apiService.putWrappedDrk(toBase64Url(wrappedDrk)); try { const kp = await cryptoService.generateECDHKeyPair(); const pub = await cryptoService.exportPublicKeyJWK(kp.publicKey); - console.log("[Authorize] generateNewKeys publish enc pub"); + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys publish enc pub"); await apiService.putEncPublicJwk(pub); const privJwk = await crypto.subtle.exportKey("jwk", kp.privateKey); const wrappedPriv = await cryptoService.wrapEncPrivateJwkWithDrk(privJwk, drk); - console.log("[Authorize] generateNewKeys put wrapped enc priv"); + logger.debug( + { sub: sessionData.sub }, + "[Authorize] generateNewKeys store wrapped enc priv" + ); await apiService.putWrappedEncPrivateJwk(wrappedPriv); cryptoService.clearSensitiveData(drk); - } catch {} - console.log("[Authorize] generateNewKeys success. About to finalize"); + } catch (err) { + logger.warn( + err instanceof Error + ? { name: err.name, message: err.message, stack: err.stack } + : { detail: String(err) }, + "Failed to refresh encryption keys" + ); + } + logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys success"); setRecoveryVisible(false); try { const url = new URL(window.location.href); @@ -216,7 +230,10 @@ export default function Authorize({ const zkPubJwk = JSON.parse(new TextDecoder().decode(fromBase64Url(zkPubParam))); const jwe = await cryptoService.createDrkJWE(drk, zkPubJwk, sessionData.sub, clientId); const drkHash = await sha256Base64Url(jwe); - console.log("[Authorize] finalize directly after generateNewKeys"); + logger.debug( + { requestId: authRequest.requestId }, + "[Authorize] finalize immediately after generateNewKeys" + ); const authResponse = await apiService.authorize({ requestId: authRequest.requestId, approve: true, @@ -227,10 +244,18 @@ export default function Authorize({ return; } } catch (e) { - console.warn("[Authorize] direct finalize after generateNewKeys failed; falling back", e); + logger.warn( + e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { detail: String(e) }, + "[Authorize] immediate finalize after generateNewKeys failed" + ); } queueMicrotask(() => { - console.log("[Authorize] microtask finalize after generateNewKeys"); + logger.debug( + { requestId: authRequest.requestId }, + "[Authorize] microtask finalize after generateNewKeys" + ); handleAuthorize(true); }); } catch (e) { @@ -241,7 +266,7 @@ export default function Authorize({ }; const recoverWithOldPassword = async () => { - console.log("[Authorize] recoverWithOldPassword start"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recoverWithOldPassword start"); if (!sessionData.email) { setError("Email is required for recovery"); return; @@ -253,10 +278,10 @@ export default function Authorize({ setRecoveryLoading(true); setError(null); try { - console.log("[Authorize] recoverWithOldPassword OPAQUE start"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recoverWithOldPassword OPAQUE start"); const oldStart = await opaqueService.startLogin(sessionData.email, oldPassword); const oldStartResp = await apiService.passwordVerifyStart(oldStart.request); - console.log("[Authorize] recoverWithOldPassword OPAQUE finish"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recoverWithOldPassword OPAQUE finish"); const oldFinish = await opaqueService.finishLogin(oldStartResp.message, oldStart.state); opaqueService.clearState(oldStart.state); const currentExportKey = await loadExportKey(sessionData.sub); @@ -266,24 +291,30 @@ export default function Authorize({ ); } - console.log("[Authorize] recoverWithOldPassword fetch wrapped-drk"); + logger.debug( + { sub: sessionData.sub }, + "[Authorize] recoverWithOldPassword fetch wrapped DRK" + ); const wrappedDrkB64 = await apiService.getWrappedDrk(); const wrapped = fromBase64Url(wrappedDrkB64); - console.log("[Authorize] recoverWithOldPassword unwrap old"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recoverWithOldPassword unwrap old DRK"); const keysOld = await cryptoService.deriveKeysFromExportKey( oldFinish.exportKey, sessionData.sub ); const drk = await cryptoService.unwrapDRK(wrapped, keysOld.wrapKey, sessionData.sub); - console.log("[Authorize] recoverWithOldPassword rewrap new"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recoverWithOldPassword rewrap new DRK"); const keysNew = await cryptoService.deriveKeysFromExportKey( currentExportKey, sessionData.sub ); const rewrapped = await cryptoService.wrapDRK(drk, keysNew.wrapKey, sessionData.sub); - console.log("[Authorize] recoverWithOldPassword put wrapped-drk"); + logger.debug( + { sub: sessionData.sub }, + "[Authorize] recoverWithOldPassword store wrapped DRK" + ); await apiService.putWrappedDrk(toBase64Url(rewrapped)); - console.log("[Authorize] recovery success. About to finalize"); + logger.debug({ sub: sessionData.sub }, "[Authorize] recovery success"); setRecoveryVisible(false); setOldPassword(""); try { @@ -294,7 +325,10 @@ export default function Authorize({ const zkPubJwk = JSON.parse(new TextDecoder().decode(fromBase64Url(zkPubParam))); const jwe = await cryptoService.createDrkJWE(drk, zkPubJwk, sessionData.sub, clientId); const drkHash = await sha256Base64Url(jwe); - console.log("[Authorize] finalize directly after recovery"); + logger.debug( + { requestId: authRequest.requestId }, + "[Authorize] finalize immediately after recovery" + ); const authResponse = await apiService.authorize({ requestId: authRequest.requestId, approve: true, @@ -305,10 +339,18 @@ export default function Authorize({ return; } } catch (e) { - console.warn("[Authorize] direct finalize after recovery failed; falling back", e); + logger.warn( + e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { detail: String(e) }, + "[Authorize] immediate finalize after recovery failed" + ); } queueMicrotask(() => { - console.log("[Authorize] microtask finalize after recovery"); + logger.debug( + { requestId: authRequest.requestId }, + "[Authorize] microtask finalize after recovery" + ); handleAuthorize(true); }); } catch (e) { diff --git a/packages/user-ui/src/components/Dashboard.tsx b/packages/user-ui/src/components/Dashboard.tsx index e0b7920..2cae69d 100644 --- a/packages/user-ui/src/components/Dashboard.tsx +++ b/packages/user-ui/src/components/Dashboard.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useBranding } from "../hooks/useBranding"; import apiService from "../services/api"; +import { logger } from "../services/logger"; import styles from "./Dashboard.module.css"; import UserLayout from "./UserLayout"; @@ -35,7 +36,7 @@ export default function Dashboard({ sessionData, onLogout }: DashboardProps) { const response = await apiService.getUserApps(); setApps(response.apps || []); } catch (error) { - console.error("Failed to load apps:", error); + logger.error(error, "Failed to load apps"); setApps([]); } finally { setLoading(false); diff --git a/packages/user-ui/src/components/Login.tsx b/packages/user-ui/src/components/Login.tsx index 7271907..5e723e3 100644 --- a/packages/user-ui/src/components/Login.tsx +++ b/packages/user-ui/src/components/Login.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react"; import { useBranding } from "../hooks/useBranding"; import apiService from "../services/api"; import cryptoService, { toBase64Url } from "../services/crypto"; +import { logger } from "../services/logger"; import opaqueService, { type OpaqueLoginState } from "../services/opaque"; import { saveExportKey } from "../services/sessionKey"; import Button from "./Button"; @@ -80,27 +81,25 @@ export default function Login({ onLogin, onSwitchToRegister }: LoginProps) { setErrors({}); try { - console.debug("[user-ui] login: start", { email: formData.email }); + logger.debug({ email: formData.email }, "[user-ui] login start"); // Start OPAQUE login const loginStart = await opaqueService.startLogin(formData.email, formData.password); setOpaqueState(loginStart.state); - console.debug("[user-ui] login: sending /opaque/login/start", { - reqLen: loginStart.request.length, - }); + logger.debug({ requestLength: loginStart.request.length }, "[user-ui] login start request"); // Send login start request to server const loginStartResponse = await apiService.opaqueLoginStart({ email: formData.email, request: loginStart.request, }); - console.debug("[user-ui] login: start response", loginStartResponse); + logger.debug({ response: loginStartResponse }, "[user-ui] login start response"); // Finish OPAQUE login const loginFinish = await opaqueService.finishLogin( loginStartResponse.message, loginStart.state ); - console.debug("[user-ui] login: finish ke3 len", { len: loginFinish.request.length }); + logger.debug({ requestLength: loginFinish.request.length }, "[user-ui] login finish payload"); // Send login finish request to server const loginFinishResponse = await apiService.opaqueLoginFinish({ @@ -108,7 +107,7 @@ export default function Login({ onLogin, onSwitchToRegister }: LoginProps) { finish: loginFinish.request, sessionId: loginStartResponse.sessionId, }); - console.debug("[user-ui] login: finish response", loginFinishResponse); + logger.debug({ response: loginFinishResponse }, "[user-ui] login finish response"); if (loginFinishResponse.otpRequired) { opaqueService.clearState(loginStart.state); @@ -152,7 +151,12 @@ export default function Login({ onLogin, onSwitchToRegister }: LoginProps) { await apiService.putWrappedDrk(toBase64Url(wrappedDrk)); cryptoService.clearSensitiveData(loginFinish.sessionKey, drk); } catch (e) { - console.warn("Failed to initialize DRK:", e); + logger.warn( + e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { detail: String(e) }, + "Failed to initialize DRK" + ); } } @@ -162,7 +166,7 @@ export default function Login({ onLogin, onSwitchToRegister }: LoginProps) { await saveExportKey(loginFinishResponse.sub, loginFinish.exportKey); cryptoService.clearSensitiveData(loginFinish.sessionKey, loginFinish.exportKey); } catch (error) { - console.error("Login failed:", error); + logger.error(error, "Login failed"); // Clear sensitive data on error if (opaqueState) { diff --git a/packages/user-ui/src/components/Register.tsx b/packages/user-ui/src/components/Register.tsx index e89b1e2..2ea0d2c 100644 --- a/packages/user-ui/src/components/Register.tsx +++ b/packages/user-ui/src/components/Register.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react"; import { useBranding } from "../hooks/useBranding"; import apiService from "../services/api"; import cryptoService, { toBase64Url } from "../services/crypto"; +import { logger } from "../services/logger"; import opaqueService, { type OpaqueRegistrationState } from "../services/opaque"; import { saveExportKey } from "../services/sessionKey"; import styles from "./Register.module.css"; @@ -140,7 +141,7 @@ export default function Register({ onRegister, onSwitchToLogin }: RegisterProps) try { await apiService.putWrappedDrk(toBase64Url(wrappedDrk)); } catch (error) { - console.warn("Failed to store wrapped DRK:", error); + logger.warn(error, "Failed to store wrapped DRK"); // Continue with registration even if DRK storage fails } @@ -155,10 +156,20 @@ export default function Register({ onRegister, onSwitchToLogin }: RegisterProps) const wrappedPriv = await cryptoService.wrapEncPrivateJwkWithDrk(privJwk, drk); await apiService.putWrappedEncPrivateJwk(wrappedPriv); } catch (e) { - console.warn("Failed to store wrapped private key:", e); + logger.warn( + e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { detail: String(e) }, + "Failed to store wrapped private key" + ); } } catch (e) { - console.warn("Failed to set up encryption keys", e); + logger.warn( + e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { detail: String(e) }, + "Failed to set up encryption keys" + ); } // Clear sensitive data @@ -175,7 +186,7 @@ export default function Register({ onRegister, onSwitchToLogin }: RegisterProps) passwordResetRequired: !!sessionData.passwordResetRequired, }); } catch (error) { - console.error("Registration failed:", error); + logger.error(error, "Registration failed"); // Clear sensitive data on error if (opaqueState) { diff --git a/packages/user-ui/src/pages/Authorize.tsx b/packages/user-ui/src/pages/Authorize.tsx index ef60163..eb533af 100644 --- a/packages/user-ui/src/pages/Authorize.tsx +++ b/packages/user-ui/src/pages/Authorize.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { apiService } from "../services/api"; +import { logger } from "../services/logger"; // import { cryptoService } from '../services/crypto'; @@ -26,8 +27,7 @@ export default function Authorize({ authRequest }: AuthorizeProps) { let drkJwe: string | undefined; if (authRequest.hasZk) { - // Handle Zero-Knowledge delivery - console.log("ZK delivery required - handling DRK generation and JWE creation"); + logger.info({ requestId: authRequest.requestId }, "ZK delivery requested"); // This would normally: // 1. Get the wrapped DRK from server @@ -40,7 +40,7 @@ export default function Authorize({ authRequest }: AuthorizeProps) { // 2. Unwrap it using session key // 3. Create JWE for client's ephemeral key // For now, we'll just indicate ZK delivery is available - console.log("ZK delivery would be performed here"); + logger.debug({ requestId: authRequest.requestId }, "ZK delivery placeholder execution"); drkJwe = "placeholder-jwe"; } diff --git a/packages/user-ui/src/services/api.ts b/packages/user-ui/src/services/api.ts index cdb32c3..2a24c02 100644 --- a/packages/user-ui/src/services/api.ts +++ b/packages/user-ui/src/services/api.ts @@ -1,4 +1,4 @@ -// API Service Layer for User UI +import { logger } from "./logger"; export interface ApiError { error: string; @@ -456,7 +456,7 @@ class ApiService { }); } catch (error) { // Return empty array if endpoint doesn't exist yet - console.warn("Failed to fetch user apps:", error); + logger.warn(error, "Failed to fetch user apps"); return { apps: [] }; } } diff --git a/packages/user-ui/src/services/logger.ts b/packages/user-ui/src/services/logger.ts new file mode 100644 index 0000000..cb35f03 --- /dev/null +++ b/packages/user-ui/src/services/logger.ts @@ -0,0 +1,55 @@ +const methodByLevel: Record = { + error: "error", + warn: "warn", + info: "info", + debug: "debug", +}; + +type Level = keyof typeof methodByLevel; +type Detail = unknown; + +function format(detail: Detail) { + if (detail == null) return undefined; + if (detail instanceof Error) { + return { + error: { + name: detail.name, + message: detail.message, + stack: detail.stack, + }, + }; + } + if (typeof detail === "string") return { detail }; + if (Array.isArray(detail)) return { detail }; + if (typeof detail === "object") return detail as Record; + return { detail }; +} + +function emit(level: Level, detail?: Detail, message?: string) { + const method = methodByLevel[level] || "log"; + const payload: Record = { + level, + timestamp: new Date().toISOString(), + }; + if (message) payload.message = message; + const extra = format(detail); + if (extra && typeof extra === "object") Object.assign(payload, extra); + const target = + (console as unknown as Record void>)[method] || console.log; + target.call(console, JSON.stringify(payload)); +} + +export const logger = { + error(detail?: Detail, message?: string) { + emit("error", detail, message); + }, + warn(detail?: Detail, message?: string) { + emit("warn", detail, message); + }, + info(detail?: Detail, message?: string) { + emit("info", detail, message); + }, + debug(detail?: Detail, message?: string) { + emit("debug", detail, message); + }, +}; diff --git a/packages/user-ui/src/services/secureStorage.ts b/packages/user-ui/src/services/secureStorage.ts index e714d62..ecdbfd3 100644 --- a/packages/user-ui/src/services/secureStorage.ts +++ b/packages/user-ui/src/services/secureStorage.ts @@ -2,6 +2,7 @@ // Uses WebCrypto PBKDF2, integrity checks, and key rotation import { fromBase64Url, toBase64Url } from "./crypto"; +import { logger } from "./logger"; interface SecureStorageEntry { encryptedData: string; @@ -282,7 +283,7 @@ class SecureStorageService { this.updateMetadata(); return decryptedKey; } catch (error) { - console.warn("Failed to load export key:", error); + logger.warn(error, "Failed to load export key"); // Clear potentially corrupted data this.clearExportKey(sub); return null; diff --git a/packages/user-ui/src/services/sessionKey.ts b/packages/user-ui/src/services/sessionKey.ts index 5cfbfa6..02a1e36 100644 --- a/packages/user-ui/src/services/sessionKey.ts +++ b/packages/user-ui/src/services/sessionKey.ts @@ -1,4 +1,5 @@ import { fromBase64Url, toBase64Url } from "./crypto"; +import { logger } from "./logger"; import secureStorageService from "./secureStorage"; // Legacy fallback prefix for migration @@ -22,7 +23,7 @@ async function migrateToSecureStorage(sub: string): Promise { // Clear the key from memory key.fill(0); } catch (error) { - console.warn("Failed to migrate key to secure storage:", error); + logger.warn(error, "Failed to migrate key to secure storage"); } } } @@ -34,7 +35,7 @@ export async function saveExportKey(sub: string, key: Uint8Array): Promise await secureStorageService.saveExportKey(sub, key); sessionStorage.setItem(SECURE_MIGRATION_FLAG + sub, "true"); } catch (error) { - console.warn("Failed to use secure storage, falling back to basic storage:", error); + logger.warn(error, "Failed to use secure storage, falling back"); // Fallback to enhanced basic storage with integrity check const now = Date.now(); @@ -65,7 +66,7 @@ export async function loadExportKey(sub: string): Promise { return secureKey; } } catch (error) { - console.warn("Failed to load from secure storage:", error); + logger.warn(error, "Failed to load from secure storage"); } // Fallback to legacy storage with integrity check @@ -88,14 +89,14 @@ export async function loadExportKey(sub: string): Promise { ); if (toBase64Url(expectedIntegrity) !== entry.integrity) { - console.warn("Export key integrity check failed"); + logger.warn({ sub }, "Export key integrity check failed"); clearExportKey(sub); return null; } // Check age (30 minutes) if (Date.now() - entry.timestamp > 30 * 60 * 1000) { - console.warn("Export key has expired"); + logger.warn({ sub }, "Export key expired"); clearExportKey(sub); return null; } @@ -103,7 +104,7 @@ export async function loadExportKey(sub: string): Promise { return fromBase64Url(entry.key || entry); } catch (error) { - console.warn("Failed to parse export key:", error); + logger.warn(error, "Failed to parse export key"); clearExportKey(sub); return null; } diff --git a/scripts/build-workspaces.mjs b/scripts/build-workspaces.mjs new file mode 100644 index 0000000..4fe9c9d --- /dev/null +++ b/scripts/build-workspaces.mjs @@ -0,0 +1,60 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const packagesDir = join(root, 'packages'); +const entries = await readdir(packagesDir, { withFileTypes: true }); +const workspaces = []; + +for (const entry of entries) { + if (!entry.isDirectory()) continue; + const packageFile = join(packagesDir, entry.name, 'package.json'); + let packageJson; + + try { + packageJson = JSON.parse(await readFile(packageFile, 'utf8')); + } catch { + continue; + } + + if (packageJson?.scripts?.build) { + workspaces.push(packageJson.name); + } +} + +if (workspaces.length === 0) { + process.exit(0); +} + +const command = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const tasks = workspaces + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) + .map( + (workspace) => + new Promise((resolve, reject) => { + const child = spawn(command, ['run', 'build', '-w', workspace], { + stdio: 'inherit', + cwd: root, + }); + + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(workspace)); + } + }); + + child.on('error', reject); + }), + ); + +try { + await Promise.all(tasks); +} catch (error) { + console.error('Build failed for workspace', error.message ?? error); + process.exit(1); +} diff --git a/specs/REFACTOR_TO_MATCH_CODE_GUIDE.md b/specs/REFACTOR_TO_MATCH_CODE_GUIDE.md new file mode 100644 index 0000000..7ca7874 --- /dev/null +++ b/specs/REFACTOR_TO_MATCH_CODE_GUIDE.md @@ -0,0 +1,122 @@ +# Refactor Plan: Align API and Demo App With Code Guide + +This document outlines the ordered, grouped checklists to bring `packages/api` and `packages/demo-app` in line with `specs/1_CODE_GUIDE.md`. Each group is sequenced to minimize risk and reduce churn. External protocol field names (OIDC/JWT/JWK/etc.) remain unchanged on the wire; internal variables and identifiers use full names. + +## Global Conventions + +- [x] Adopt “no abbreviations” for identifiers in code (request, response, configuration). Keep standard protocol field names in JSON payloads (e.g., `sub`, `jwk`, `jwt`), but prefer full names for local variables and function names. +- [x] Ensure ESM across packages and consistent TypeScript targets/resolution per guide. +- [x] Unify project scripts: ensure each package has `tidy`, `check`, `lint`, `format`, `build` scripts that integrate with the root workspace commands. (demo-app updated) +- [x] Standardize logging on a single logger interface (pino in Node), remove `console.log` from runtime code. + +## API: Structure And Lifecycle + +1) Server lifecycle +- [ ] Confirm a single server factory that returns `start()`, `stop()`, and `restart()` and that tests and production share the same lifecycle surface. +- [x] Ensure no global state; everything flows through `createContext` and lifecycle hooks. +- [ ] Verify ports/bindings come only from `Config`, never inline literals. + +2) Context pattern +- [x] Ensure `createContext` fully initializes `db`, `services`, `logger`, and `destroy` in one place. +- [x] Guarantee all services are injected via context and not imported as singletons. +- [x] Remove ad‑hoc resource creation from controllers; delegate to models/services via context. + +3) Folder layout +- [ ] Confirm top-level files: `main.ts`, `createServer.ts`, `context/createContext.ts`, `types.ts`, `errors.ts` match the guide’s intent. +- [ ] Ensure `controllers/`, `models/`, `services/`, `utils/`, `db/` directories follow usage boundaries per guide. + +## API: HTTP Layer And Routing + +- [x] Centralize error handling in HTTP layer: map `AppError` kinds to HTTP responses; avoid try/catch in controllers except to normalize errors to `AppError`. (updated user token, password verify start/finish, opaque login finish, wrappedDrk get/put) +- [x] Replace any `req`/`res` local identifiers with `request`/`response` within handlers. +- [ ] Ensure all controllers validate input with Zod; keep schemas colocated or under a `schemas/` namespace when reused. +- [ ] Confirm routing layout and naming are consistent and predictable; avoid ad‑hoc path parsing in controllers. +- [ ] Keep response helpers in a single utility (e.g., `sendJson`, `sendError`) and use consistently. + +## API: Validation, Types, And Errors + +- [ ] Align `types.ts` with guide: `Context`, `Config`, service interfaces, and DTOs are explicit and minimal. +- [ ] Use Zod for external types and Drizzle types for database; do not duplicate type sources. +- [ ] Implement and use `AppError`, `ValidationError`, `NotFoundError`, `ConflictError` in services/controllers. +- [ ] Bubble all non‑AppError exceptions up to HTTP layer; ensure proper logging and 500 fallback. + +## API: Models, Services, And Database + +- [ ] Keep data access in `models/` using Drizzle queries; remove direct SQL from controllers. +- [ ] Ensure services are pure where possible and receive context explicitly. +- [ ] Review schema names and relations in `db/schema.ts` match the guide’s conventions. +- [ ] Ensure migrations are current; verify `drizzle.config.ts` outputs are correct. + +## API: Security, Config, And Logging + +- [ ] Enforce CSRF/CORS/rate limits where required; ensure helpers are in `utils/` or `middleware/` and added consistently. +- [ ] Move all configuration (ports, origins, flags) into `Config`; no literals in code. +- [x] Replace `console.*` with `context.logger` calls; ensure structured logging. +- [ ] Ensure `jwks`/signing key handling honors KEK availability and secure mode defaults per guide. + +## API: Naming And Cleanups + +- [x] Rename local identifiers: `req`→`request`, `res`→`response`, `cfg`→`configuration`, `resp`→`responseData`. +- [ ] Keep external protocol names (`sub`, `jwk`, etc.) in payloads but use descriptive local variable names. +- [ ] Remove unused utilities and consolidate duplicative helpers. + +## Demo App: Architecture And Styling + +1) Styling migration +- [x] Introduce CSS Modules to align with UI packages; create `*.module.css` for key components. (Header, Layout, Sidebar, Dashboard, NoteCard, EditorToolbar, RichTextEditor) +- [ ] Replace Tailwind utility classes with CSS Modules styles for core views/components. (migrated Header, Layout, Sidebar, Dashboard, NoteCard, EditorToolbar, RichTextEditor, NoteEditor) +- [ ] Remove Tailwind where feasible or retain only as a transient step; prefer a single styling approach. + +2) Folder and component structure +- [ ] Group `components/`, `stores/`, `services/`, and `types/` under `src/`. +- [ ] Add `types/` for DTOs and Zod schemas where responses are validated. +- [ ] Ensure Vite config remains simple; avoid server‑side rendering frameworks. + +3) API client and naming +- [x] Rename local identifiers to full names: `cfg`→`configuration`, `resp`→`response`, `dek`→`dataEncryptionKey`, `aad`→`additionalAuthenticatedData` (in variable names only). +- [ ] Keep wire field names unchanged: `note_id`, `recipient_sub`, `dek_jwe`, etc.; provide mapping types if internal names differ (e.g., `noteId`). +- [x] Centralize fetch logic: consistent headers, error handling, and JSON parsing with Zod validation. (Expanded Zod on profile, search, metadata/update, share/revoke, access list, collections) + +4) App configuration +- [x] Normalize runtime configuration injection via `/config.js`; rename `appCfg`→`appConfiguration` and `runtimeCfg`→`runtimeConfiguration`. +- [ ] Ensure `setConfig` types align with client library and guide. + +5) State and routing +- [ ] Align store/state naming with full words (`selectedNoteId`, `isLoading`, `setError`). +- [ ] Prefer selector helpers and typed actions in Zustand store. +- [ ] Keep React Router usage minimal and explicit; avoid abbreviations in route params and variables. + +6) Demo server alignment +- [x] Extract a small `createDemoServer` with `start()`/`stop()` and use full identifier names for request/response. +- [x] Replace `console.log` with a minimal logger; avoid ad‑hoc string parsing of URLs where `URL` suffices. +- [x] Remove inline SQL from server where possible; encapsulate database queries in a tiny `models/` folder for the demo server or keep them isolated and clearly named. + +## Tooling And Quality Gates + +- [x] Add Biome to `packages/demo-app` with `tidy`, `check`, `lint`, and `format` scripts consistent with other packages. +- [x] Ensure TypeScript strictness matches the guide (no implicit anys, strict null checks, etc.). +- [x] Run `npm run tidy` at the root and fix any issues flagged by Biome. +- [x] Run `npm run build` for all workspaces to ensure type and build correctness. + +## Execution Order Summary + +1) Global setup +- [x] Add/align `tidy`, `check`, `lint`, `format`, `build` scripts in `demo-app`. +- [x] Add Biome config to `demo-app`. + +2) API refactor +- [x] Naming fixes (`request`/`response`) and centralized error handling. +- [x] Ensure controllers validate with Zod and bubble errors. +- [ ] Confirm context/services boundaries; remove direct SQL from controllers. +- [x] Replace `console.*` with `context.logger`; config values only from `Config`. + +3) Demo app refactor +- [x] Introduce CSS Modules and migrate priority components. (Header, Layout, Sidebar) +- [x] API client: rename identifiers, centralize request flow, add Zod validation. +- [x] Normalize runtime configuration naming and consumption. +- [x] Extract `createDemoServer` and align lifecycle/naming. +- [ ] Resolve remaining Biome issues (button types, unused imports, no-any) incrementally. + +4) Finalize +- [ ] Remove unused code/dependencies. +- [ ] Run `npm run tidy` and `npm run build` and address findings.