From 16838dd1161cc3032a0a227dce2bdb7b74cfcdaf Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 22 Mar 2026 11:48:37 -0400 Subject: [PATCH 1/7] feat: first pass at admin routes --- package-lock.json | 39 ++++++ package.json | 21 ++- src/controllers/admin.ts | 64 +++++++++ src/controllers/internal.ts | 186 ++++++++++++++++++++++++++ src/controllers/internalDashboard.ts | 26 ++++ src/controllers/internalMetrics.ts | 109 +++++++++++++++ src/controllers/internalSecurity.ts | 37 +++++ src/controllers/internalTimeSeries.ts | 0 src/routes/admin.routes.ts | 50 +++++++ src/routes/admin.sessions.routes.ts | 46 +++++++ src/routes/internal.routes.ts | 142 ++++++++++++++++++++ src/schemas/admin.responses.ts | 13 ++ src/schemas/admin.sessions.ts | 5 + src/schemas/internal.metrics.query.ts | 7 + src/schemas/internal.query.ts | 13 ++ src/schemas/internal.responses.ts | 19 +++ src/schemas/user.patch.schema.ts | 12 ++ src/services/authEventService.ts | 1 + src/utils/getLocalLogs.ts | 49 +++++++ 19 files changed, 827 insertions(+), 12 deletions(-) create mode 100644 src/controllers/admin.ts create mode 100644 src/controllers/internal.ts create mode 100644 src/controllers/internalDashboard.ts create mode 100644 src/controllers/internalMetrics.ts create mode 100644 src/controllers/internalSecurity.ts create mode 100644 src/controllers/internalTimeSeries.ts create mode 100644 src/routes/admin.routes.ts create mode 100644 src/routes/admin.sessions.routes.ts create mode 100644 src/routes/internal.routes.ts create mode 100644 src/schemas/admin.responses.ts create mode 100644 src/schemas/admin.sessions.ts create mode 100644 src/schemas/internal.metrics.query.ts create mode 100644 src/schemas/internal.query.ts create mode 100644 src/schemas/internal.responses.ts create mode 100644 src/schemas/user.patch.schema.ts create mode 100644 src/utils/getLocalLogs.ts diff --git a/package-lock.json b/package-lock.json index 9925507..4b6717c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@vitest/coverage-v8": "^4.0.18", + "env-cmd": "^11.0.0", "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", @@ -3734,6 +3735,44 @@ "node": ">= 0.8" } }, + "node_modules/env-cmd": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-11.0.0.tgz", + "integrity": "sha512-gnG7H1PlwPqsGhFJNTv68lsDGyQdK+U9DwLVitcj1+wGq7LeOBgUzZd2puZ710bHcH9NfNeGWe2sbw7pdvAqDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^13.1.0", + "commander": "^13.1.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=20.10.0" + } + }, + "node_modules/env-cmd/node_modules/@commander-js/extra-typings": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", + "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~13.1.0" + } + }, + "node_modules/env-cmd/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", diff --git a/package.json b/package.json index fd9096b..0cf1873 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,13 @@ "coverage": "vitest run --coverage", "lint": "eslint . --ext .ts", "format": "prettier --write .", - "docker": "docker compose up -d", - "docker:down": "docker compose down", - "db:create": "sequelize-cli db:create", - "db:drop": "sequelize-cli db:drop", - "db:refresh": "sequelize-cli db:drop && sequelize-cli db:create", - "migrate:create": "sequelize-cli migration:generate --name", - "migrate:up": "sequelize-cli db:migrate", - "migrate:down": "sequelize-cli db:migrate:undo", - "migrate:reset": "sequelize-cli db:migrate:undo:all && sequelize-cli db:migrate", - "seed:run": "sequelize-cli db:seed:all", - "seed:undo": "sequelize-cli db:seed:undo:all" + "db:create": "env-cmd -f .env sequelize-cli db:create || sequelize-cli db:create", + "db:drop": "env-cmd -f .env sequelize-cli db:drop || sequelize-cli db:drop", + "db:refresh": "env-cmd -f .env sequelize-cli db:drop && sequelize-cli db:create", + "migrate:create": "env-cmd -f .env sequelize-cli migration:generate --name", + "migrate:up": "env-cmd -f .env sequelize-cli db:migrate || sequelize-cli db:migrate", + "migrate:down": "env-cmd -f .env sequelize-cli db:migrate:undo", + "migrate:reset": "env-cmd -f .env sequelize-cli db:migrate:undo:all && sequelize-cli db:migrate" }, "repository": { "type": "git", @@ -75,6 +71,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@vitest/coverage-v8": "^4.0.18", + "env-cmd": "^11.0.0", "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", @@ -89,4 +86,4 @@ "typescript-eslint": "^8.56.0", "vitest": "^4.0.3" } -} +} \ No newline at end of file diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts new file mode 100644 index 0000000..7e85418 --- /dev/null +++ b/src/controllers/admin.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; + +import { Session } from '../models/sessions.js'; +import { hardRevokeSession } from '../services/sessionService.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('admin-sessions'); + +/* +GET /admin/sessions/:userId +*/ +export const listUserSessions = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const sessions = await Session.findAll({ + where: { + userId, + revokedAt: null, + }, + }); + + return res.json({ + sessions: sessions.map((s) => ({ + id: s.id, + deviceName: s.deviceName, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + lastUsedAt: s.lastUsedAt, + expiresAt: s.expiresAt, + })), + }); + } catch (err) { + logger.error(`Failed to fetch sessions: ${err}`); + return res.status(500).json({ message: 'Failed to fetch sessions' }); + } +}; + +/* +DELETE /admin/sessions/:userId/revoke-all +*/ +export const revokeAllUserSessions = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const sessions = await Session.findAll({ + where: { + userId, + revokedAt: null, + }, + }); + + for (const session of sessions) { + await hardRevokeSession(session, 'admin_revoke_all'); + } + + logger.info(`All sessions revoked for user ${userId}`); + + return res.json({ message: 'Success' }); + } catch (err) { + logger.error(`Failed to revoke sessions: ${err}`); + return res.status(500).json({ message: 'Failed to revoke sessions' }); + } +}; diff --git a/src/controllers/internal.ts b/src/controllers/internal.ts new file mode 100644 index 0000000..9b71ccc --- /dev/null +++ b/src/controllers/internal.ts @@ -0,0 +1,186 @@ +import { Response } from 'express'; +import { Op } from 'sequelize'; + +import { AuthEvent } from '../models/authEvents.js'; +import { Credential } from '../models/credentials.js'; +import { User } from '../models/users.js'; +import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { UpdateUserSchema } from '../schemas/user.patch.schema.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { ServiceRequest } from '../types/types.js'; +import { getLocalLogs } from '../utils/getLocalLogs.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('internal'); + +export const getUsers = async (req: ServiceRequest, res: Response) => { + logger.info('Internal users call made.'); + try { + const users = await User.findAll({ + attributes: [ + 'id', + 'email', + 'phone', + 'revoked', + 'emailVerified', + 'phoneVerified', + 'verified', + 'lastLogin', + 'roles', + ], + }); + + return res.json({ users }); + } catch (err) { + logger.error(`Failed to fetch users: ${err}`); + res.status(500).json({ message: 'Failed to fetch users' }); + } +}; + +export const getAuthEvents = async (req: ServiceRequest, res: Response) => { + const parsed = AuthEventQuerySchema.safeParse(req.query); + + if (!parsed.success) { + return res.status(400).json({ message: 'Invalid query params' }); + } + + const { limit, offset, userId, type, from, to } = parsed.data; + + const where: any = {}; + + if (userId) where.user_id = userId; + if (type) where.type = type; + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + + try { + const events = await AuthEvent.findAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }); + + return res.json({ events }); + } catch (err) { + logger.error(`Failed to fetch auth events: ${err}`); + res.status(500).json({ message: 'Failed to fetch events' }); + } +}; +export const getCredentialsCount = async (req: ServiceRequest, res: Response) => { + logger.info('Internal credential count call made.'); + try { + const credentialCount = await Credential.count(); + + return res.json({ count: credentialCount || 0 }); + } catch (err) { + logger.error(`Failed to fetch credential count: ${err}`); + res.status(500).json({ message: 'Failed to fetch credential count' }); + } +}; + +export const getLogs = async (req: ServiceRequest, res: Response) => { + logger.info('Internal logs call made.'); + + if (process.env.NODE_ENV !== 'production') { + const logsResult = await getLocalLogs(req.query.search as string); + res.json(logsResult); + + return; + } + + return res.status(200).json({ message: 'No logs' }); +}; + +export const deleteUser = async (req: ServiceRequest, res: Response) => { + logger.info('Internal deletion call made.'); + const { userId } = req.body; + + try { + if (!userId) { + return res.status(404).json({ message: 'User not found.' }); + } + + try { + const user = await User.findOne({ + where: { + id: userId, + }, + }); + + if (user) { + user.destroy(); + logger.info(`User ${user.email} deleted from database through the seamless auth portal.`); + } else { + logger.error(`Failed to destory a seemingly valid user via the portal`); + } + + return res.status(200).json({ message: 'Success' }); + } catch (error: unknown) { + logger.error(`Failed to delete user: ${userId}. Error: ${error}`); + return res.status(500).json({ message: 'Failed' }); + } + } catch (error) { + logger.error(`Error occured deleting a user: ${error}`); + return res.status(500).json({ message: `Failed` }); + } +}; + +export const updateUser = async (req: ServiceRequest, res: Response) => { + const { userId, triggeredBy: actorId } = req.params; + + if (!actorId || !userId) { + return res.status(400).json({ message: 'Bad request' }); + } + + logger.info(`${actorId} is updating user profile for ${userId}`); + + const parsed = UpdateUserSchema.safeParse(req.body); + + if (!parsed.success || Object.keys(parsed.data).length === 0) { + logger.error(`Failed to parse update user body. ${JSON.stringify(req.body)}`); + return res.status(400).json({ + message: 'Invalid update payload', + details: parsed.error, + }); + } + + try { + const user = await User.findByPk(userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const before = user.toJSON(); + + try { + await user.update(parsed.data); + + await AuthEventService.log({ + type: 'internal_user_updated_by_owner', + userId: actorId, + req, + metadata: { + before, + after: parsed.data, + targetUser: userId, + }, + }); + } catch (error) { + logger.error(`Failed to update user ${error}`); + res.status(500).json({ message: 'Failed to update user' }); + return; + } + + res.status(200).json({ user }); + return; + } catch { + logger.error('Failed to find user'); + res.status(400).json({ message: 'Could not update users' }); + } +}; diff --git a/src/controllers/internalDashboard.ts b/src/controllers/internalDashboard.ts new file mode 100644 index 0000000..8866b67 --- /dev/null +++ b/src/controllers/internalDashboard.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; + +import { AuthEvent } from '../models/authEvents.js'; +import { Session } from '../models/sessions.js'; +import { User } from '../models/users.js'; + +export const getDashboardMetrics = async (_req: Request, res: Response) => { + try { + const [totalUsers, totalSessions, loginSuccess, loginFailed] = await Promise.all([ + User.count(), + Session.count({ where: { revokedAt: null } }), + AuthEvent.count({ where: { type: 'login_success' } }), + AuthEvent.count({ where: { type: 'login_failed' } }), + ]); + + return res.json({ + users: totalUsers, + activeSessions: totalSessions, + loginSuccess, + loginFailed, + successRate: loginSuccess + loginFailed > 0 ? loginSuccess / (loginSuccess + loginFailed) : 0, + }); + } catch { + return res.status(500).json({ message: 'Failed to fetch dashboard metrics' }); + } +}; diff --git a/src/controllers/internalMetrics.ts b/src/controllers/internalMetrics.ts new file mode 100644 index 0000000..d1b340c --- /dev/null +++ b/src/controllers/internalMetrics.ts @@ -0,0 +1,109 @@ +import { Request, Response } from 'express'; +import { col, fn, literal, Op } from 'sequelize'; + +import { AuthEvent } from '../models/authEvents.js'; +import { MetricsQuerySchema } from '../schemas/internal.metrics.query.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('internal-metrics'); + +export const getAuthEventSummary = async (req: Request, res: Response) => { + const parsed = MetricsQuerySchema.safeParse(req.query); + + if (!parsed.success) { + return res.status(400).json({ message: 'Invalid query params' }); + } + + const { from, to } = parsed.data; + + const where: any = {}; + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + + try { + const results = await AuthEvent.findAll({ + attributes: ['type', [fn('COUNT', col('type')), 'count']], + where, + group: ['type'], + }); + + return res.json({ + summary: results.map((r: any) => ({ + type: r.type, + count: Number(r.get('count')), + })), + }); + } catch (err) { + logger.error(`Failed to fetch auth summary: ${err}`); + return res.status(500).json({ message: 'Failed to fetch summary' }); + } +}; + +export const getAuthEventTimeseries = async (req: Request, res: Response) => { + const parsed = MetricsQuerySchema.safeParse(req.query); + + if (!parsed.success) { + return res.status(400).json({ message: 'Invalid query params' }); + } + + const { from, to, interval } = parsed.data; + + const where: any = {}; + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + + const bucket = + interval === 'day' + ? literal(`DATE_TRUNC('day', created_at)`) + : literal(`DATE_TRUNC('hour', created_at)`); + + try { + const results = await AuthEvent.findAll({ + attributes: [ + [bucket, 'bucket'], + [fn('COUNT', col('id')), 'count'], + ], + where, + group: ['bucket'], + order: [[literal('bucket'), 'ASC']], + }); + + return res.json({ + timeseries: results.map((r: any) => ({ + bucket: r.get('bucket'), + count: Number(r.get('count')), + })), + }); + } catch (err) { + logger.error(`Failed to fetch timeseries: ${err}`); + return res.status(500).json({ message: 'Failed to fetch timeseries' }); + } +}; + +export const getLoginStats = async (req: Request, res: Response) => { + try { + const success = await AuthEvent.count({ + where: { type: 'login_success' }, + }); + + const failed = await AuthEvent.count({ + where: { type: 'login_failed' }, + }); + + return res.json({ + success, + failed, + successRate: success + failed > 0 ? success / (success + failed) : 0, + }); + } catch (err) { + return res.status(500).json({ message: 'Failed to compute login stats' }); + } +}; diff --git a/src/controllers/internalSecurity.ts b/src/controllers/internalSecurity.ts new file mode 100644 index 0000000..2a2aa88 --- /dev/null +++ b/src/controllers/internalSecurity.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { Op } from 'sequelize'; + +import { AuthEvent } from '../models/authEvents.js'; + +export const getSecurityAnomalies = async (_req: Request, res: Response) => { + const now = new Date(); + const windowStart = new Date(now.getTime() - 60 * 60 * 1000); + + try { + const failedLogins = await AuthEvent.findAll({ + where: { + type: 'login_failed', + created_at: { [Op.gte]: windowStart }, + }, + attributes: ['ip_address'], + }); + + const ipCounts: Record = {}; + + for (const event of failedLogins) { + const ip = event.ip_address || 'unknown'; + ipCounts[ip] = (ipCounts[ip] || 0) + 1; + } + + const suspicious = Object.entries(ipCounts) + .filter(([_, count]) => count > 10) + .map(([ip, count]) => ({ ip, count })); + + return res.json({ + suspiciousIps: suspicious, + totalFailedLogins: failedLogins.length, + }); + } catch { + return res.status(500).json({ message: 'Failed to detect anomalies' }); + } +}; diff --git a/src/controllers/internalTimeSeries.ts b/src/controllers/internalTimeSeries.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts new file mode 100644 index 0000000..adc41ce --- /dev/null +++ b/src/routes/admin.routes.ts @@ -0,0 +1,50 @@ +import { deleteUser, updateUser } from '../controllers/internal.js'; +import { createRouter } from '../lib/createRouter.js'; +import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; +import { + InternalErrorSchema, + SuccessMessageSchema, + UserResponseSchema, +} from '../schemas/admin.responses.js'; +import { UpdateUserSchema } from '../schemas/user.patch.schema.js'; + +const adminRouter = createRouter('/admin'); + +adminRouter.patch( + '/users/:triggeredBy/:userId', + { + summary: 'Update user', + tags: ['Admin'], + middleware: [verifyServiceToken], + + schemas: { + body: UpdateUserSchema, + + response: { + 200: UserResponseSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, + }, + }, + }, + updateUser, +); + +adminRouter.delete( + '/users', + { + summary: 'Delete user', + tags: ['Admin'], + middleware: [verifyServiceToken], + + schemas: { + response: { + 200: SuccessMessageSchema, + 500: InternalErrorSchema, + }, + }, + }, + deleteUser, +); + +export default adminRouter.router; diff --git a/src/routes/admin.sessions.routes.ts b/src/routes/admin.sessions.routes.ts new file mode 100644 index 0000000..02942b8 --- /dev/null +++ b/src/routes/admin.sessions.routes.ts @@ -0,0 +1,46 @@ +// src/routes/admin.sessions.routes.ts +import { listUserSessions, revokeAllUserSessions } from '../controllers/admin.js'; +import { createRouter } from '../lib/createRouter.js'; +import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; +import { UserIdParamSchema } from '../schemas/admin.sessions.js'; +import { InternalErrorSchema } from '../schemas/internal.responses.js'; +import { + SessionDeleteResponseSchema, + SessionListResponseSchema, +} from '../schemas/session.responses.js'; + +const adminSessionsRouter = createRouter('/admin/sessions'); + +adminSessionsRouter.get( + '/:userId', + { + middleware: [verifyServiceToken], + tags: ['Admin'], + schemas: { + params: UserIdParamSchema, + response: { + 200: SessionListResponseSchema, + 500: InternalErrorSchema, + }, + }, + }, + listUserSessions, +); + +adminSessionsRouter.delete( + '/:userId/revoke-all', + { + middleware: [verifyServiceToken], + tags: ['Admin'], + schemas: { + params: UserIdParamSchema, + response: { + 200: SessionDeleteResponseSchema, + 500: InternalErrorSchema, + }, + }, + }, + revokeAllUserSessions, +); + +export default adminSessionsRouter.router; diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts new file mode 100644 index 0000000..2836b7f --- /dev/null +++ b/src/routes/internal.routes.ts @@ -0,0 +1,142 @@ +import { getAuthEvents, getCredentialsCount, getLogs, getUsers } from '../controllers/internal.js'; +import { getDashboardMetrics } from '../controllers/internalDashboard.js'; +import { + getAuthEventSummary, + getAuthEventTimeseries, + getLoginStats, +} from '../controllers/internalMetrics.js'; +import { getSecurityAnomalies } from '../controllers/internalSecurity.js'; +import { createRouter } from '../lib/createRouter.js'; +import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; +import { MetricsQuerySchema } from '../schemas/internal.metrics.query.js'; +import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { + AuthEventsResponseSchema, + CredentialCountSchema, + InternalErrorSchema, + LogsResponseSchema, + UsersListResponseSchema, +} from '../schemas/internal.responses.js'; + +const internalRouter = createRouter('/internal'); + +internalRouter.get( + '/users', + { + summary: 'List users (internal)', + tags: ['Internal'], + middleware: [verifyServiceToken], + + schemas: { + response: { + 200: UsersListResponseSchema, + 500: InternalErrorSchema, + }, + }, + }, + getUsers, +); + +internalRouter.get( + '/auth-events', + { + middleware: [verifyServiceToken], + tags: ['Internal'], + schemas: { + query: AuthEventQuerySchema, + response: { + 200: AuthEventsResponseSchema, + }, + }, + }, + getAuthEvents, +); + +internalRouter.get( + '/credential-count', + { + summary: 'Get credential count', + tags: ['Internal'], + middleware: [verifyServiceToken], + + schemas: { + response: { + 200: CredentialCountSchema, + 500: InternalErrorSchema, + }, + }, + }, + getCredentialsCount, +); + +internalRouter.get( + '/logs', + { + summary: 'Fetch logs (dev only)', + tags: ['Internal'], + middleware: [verifyServiceToken], + + schemas: { + response: { + 200: LogsResponseSchema, + 500: InternalErrorSchema, + }, + }, + }, + getLogs, +); + +internalRouter.get( + '/auth-events/summary', + { + middleware: [verifyServiceToken], + tags: ['Internal'], + schemas: { + query: MetricsQuerySchema, + }, + }, + getAuthEventSummary, +); + +internalRouter.get( + '/auth-events/timeseries', + { + middleware: [verifyServiceToken], + tags: ['Internal'], + schemas: { + query: MetricsQuerySchema, + }, + }, + getAuthEventTimeseries, +); + +internalRouter.get( + '/auth-events/login-stats', + { + middleware: [verifyServiceToken], + tags: ['Internal'], + }, + getLoginStats, +); + +internalRouter.get( + '/security/anomalies', + { + middleware: [verifyServiceToken], + summary: 'Detect suspicious activity', + tags: ['Internal'], + }, + getSecurityAnomalies, +); + +internalRouter.get( + '/metrics/dashboard', + { + middleware: [verifyServiceToken], + summary: 'Dashboard metrics', + tags: ['Internal'], + }, + getDashboardMetrics, +); + +export default internalRouter.router; diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts new file mode 100644 index 0000000..ab46574 --- /dev/null +++ b/src/schemas/admin.responses.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const UserResponseSchema = z.object({ + user: z.record(z.unknown()), +}); + +export const SuccessMessageSchema = z.object({ + message: z.literal('Success'), +}); + +export const InternalErrorSchema = z.object({ + message: z.string(), +}); diff --git a/src/schemas/admin.sessions.ts b/src/schemas/admin.sessions.ts new file mode 100644 index 0000000..7027db4 --- /dev/null +++ b/src/schemas/admin.sessions.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const UserIdParamSchema = z.object({ + userId: z.string(), +}); diff --git a/src/schemas/internal.metrics.query.ts b/src/schemas/internal.metrics.query.ts new file mode 100644 index 0000000..5cee614 --- /dev/null +++ b/src/schemas/internal.metrics.query.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const MetricsQuerySchema = z.object({ + from: z.string().optional(), + to: z.string().optional(), + interval: z.enum(['hour', 'day']).optional().default('hour'), +}); diff --git a/src/schemas/internal.query.ts b/src/schemas/internal.query.ts new file mode 100644 index 0000000..283c15f --- /dev/null +++ b/src/schemas/internal.query.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const PaginationQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).optional().default(50), + offset: z.coerce.number().min(0).optional().default(0), +}); + +export const AuthEventQuerySchema = PaginationQuerySchema.extend({ + userId: z.string().optional(), + type: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), +}); diff --git a/src/schemas/internal.responses.ts b/src/schemas/internal.responses.ts new file mode 100644 index 0000000..7f657fe --- /dev/null +++ b/src/schemas/internal.responses.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const UsersListResponseSchema = z.object({ + users: z.array(z.record(z.unknown())), +}); + +export const AuthEventsResponseSchema = z.object({ + events: z.array(z.record(z.unknown())), +}); + +export const CredentialCountSchema = z.object({ + count: z.number(), +}); + +export const LogsResponseSchema = z.record(z.unknown()); + +export const InternalErrorSchema = z.object({ + message: z.string(), +}); diff --git a/src/schemas/user.patch.schema.ts b/src/schemas/user.patch.schema.ts new file mode 100644 index 0000000..99a8fd0 --- /dev/null +++ b/src/schemas/user.patch.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const UpdateUserSchema = z + .object({ + userId: z.guid(), + email: z.email().optional(), + phone: z.string().min(5).optional(), + emailVerified: z.boolean().optional(), + phoneVerified: z.boolean().optional(), + roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), + }) + .strict(); diff --git a/src/services/authEventService.ts b/src/services/authEventService.ts index a919839..096468f 100644 --- a/src/services/authEventService.ts +++ b/src/services/authEventService.ts @@ -31,6 +31,7 @@ type AuthEventType = | 'otp_success' | 'otp_failed' | 'otp_suspicious' + | 'internal_user_updated_by_owner' | 'verify_otp_success' | 'verify_otp_failed' | 'verify_otp_suspicious' diff --git a/src/utils/getLocalLogs.ts b/src/utils/getLocalLogs.ts new file mode 100644 index 0000000..a910d04 --- /dev/null +++ b/src/utils/getLocalLogs.ts @@ -0,0 +1,49 @@ +import fs from 'fs'; +import path from 'path'; + +import getLogger from './logger.js'; + +const logger = getLogger('getLocalLogs'); + +export async function getLocalLogs(find?: string): Promise<{ + events: { + timestamp: number; + message: string; + ingestionTime: EpochTimeStamp; + }[]; + error?: string; +}> { + try { + const filePath = path.resolve('./logs/app.log'); + + if (!fs.existsSync(filePath)) { + return { events: [] }; + } + + const logs = fs.readFileSync(filePath, 'utf8'); + + let lines = logs.split('\n').filter(Boolean); + + // Optional search filter + if (find) { + const search = find.toLowerCase(); + lines = search ? lines.filter((line) => line.toLowerCase().includes(search)) : lines; + } + + const events = lines.slice(-500).map((line) => { + const isoMatch = line.match(/^\d{4}-\d{2}-\d{2}T[^\s]+/); + const timestamp = isoMatch ? Date.parse(isoMatch[0]) : Date.now(); + + return { + timestamp, + message: line, + ingestionTime: timestamp, + }; + }); + + return { events }; + } catch (err) { + logger.error('Error reading logs:', err); + return { events: [], error: 'Failed to read logs' }; + } +} From 9762d882d31b4651421536ecee93662af8efa8f7 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 23 Mar 2026 01:38:37 -0400 Subject: [PATCH 2/7] feat: internal and admin routes --- LICENSE | 682 +++++++++++++++++++-- src/app.ts | 14 +- src/controllers/admin.ts | 120 +++- src/controllers/internal.ts | 81 ++- src/controllers/internalDashboard.ts | 72 ++- src/controllers/internalMetrics.ts | 123 +++- src/controllers/systemConfig.ts | 34 +- src/controllers/webauthn.ts | 38 +- src/lib/defineRoute.ts | 45 +- src/middleware/authenticateServiceToken.ts | 2 +- src/models/index.ts | 2 +- src/routes/admin.routes.ts | 57 +- src/routes/internal.routes.ts | 31 +- src/routes/systemConfig.routes.ts | 30 +- src/schemas/admin.createUser.ts | 7 + src/schemas/admin.responses.ts | 4 +- src/schemas/authEvent.schema.ts | 22 + src/schemas/authEvent.types.ts | 64 ++ src/schemas/internal.metrics.query.ts | 1 + src/schemas/internal.query.ts | 12 +- src/schemas/internal.responses.ts | 8 +- src/schemas/systemConfig.params.ts | 7 - src/schemas/systemConfig.patch.schema.ts | 40 +- src/schemas/user.base.ts | 4 + src/schemas/user.patch.schema.ts | 1 - src/services/authEventService.ts | 90 +-- 26 files changed, 1343 insertions(+), 248 deletions(-) create mode 100644 src/schemas/admin.createUser.ts create mode 100644 src/schemas/authEvent.schema.ts create mode 100644 src/schemas/authEvent.types.ts delete mode 100644 src/schemas/systemConfig.params.ts diff --git a/LICENSE b/LICENSE index bf9fc5a..162676c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,79 +1,661 @@ -GNU AFFERO GENERAL PUBLIC LICENSE -Version 3, 19 November 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Copyright (C) 2007 Free Software Foundation, Inc. - +Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -This license is identical to the GNU General Public License, except that it also -ensures that software running as a network service makes its source code -available to users. + Preamble ---- +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -TERMS AND CONDITIONS +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS 0. Definitions. -“This License” refers to version 3 of the GNU Affero General Public License. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. -“Copyright” also means copyright-like laws that apply to other kinds of works, -such as semiconductor masks. +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). -The “Program” refers to any copyrightable work licensed under this License. -Each licensee is addressed as “you”. +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. -To “modify” a work means to copy from or adapt all or part of the work in a -fashion requiring copyright permission, other than the making of an exact copy. +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. -To “propagate” a work means to do anything with it that, without permission, -would make you directly or secondarily liable for infringement under applicable -copyright law, except executing it on a computer or modifying a private copy. +7. Additional Terms. -To “convey” a work means any kind of propagation that enables other parties to -make or receive copies. +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. -An interactive user interface displays “Appropriate Legal Notices” to the extent -that it includes a convenient and prominently visible feature that displays an -appropriate copyright notice, and tells the user that there is no warranty for -the work (except to the extent that warranties are provided), that licensees may -convey the work under this License, and how to view a copy of this License. +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. ---- +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. -Notwithstanding any other provision of this License, if you modify the Program, -your modified version must prominently offer all users interacting with it -remotely through a computer network an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source from a -network server at no charge, through some standard or customary means of -facilitating copying of software. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. ---- +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. -15. Disclaimer of Warranty. +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. -EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER -PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER -EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. ---- +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY -COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS -PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, -INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE -THE PROGRAM. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . ---- +Also add information on how to contact you by electronic and paper mail. -END OF TERMS AND CONDITIONS +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. -You should have received a copy of the GNU Affero General Public License along -with this program. If not, see . +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/src/app.ts b/src/app.ts index e7d744d..4203b9a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,7 +38,7 @@ const corsOptions: CorsOptions = { return callback(null, true); } - if (origin === allowedOrigin) { + if (origin === allowedOrigin || origin === 'http://localhost:5174') { return callback(null, true); } @@ -110,6 +110,18 @@ export async function createApp() { return next(); }); + app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { + if (err) { + logger.error('Unhandled error', err); + + return res.status(500).json({ + error: 'Internal server error', + }); + } + + return next(); + }); + app.use((req: Request, res: Response) => { logger.warn( `[${req.ip}] didn't make it anywhere. Path: ${req.path}. Tracking of suspicous behavior`, diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 7e85418..a022722 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -1,14 +1,49 @@ import { Request, Response } from 'express'; +import { Op } from 'sequelize'; +import { AuthEvent } from '../models/authEvents.js'; +import { Credential } from '../models/credentials.js'; +import { sequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; +import { User } from '../models/users.js'; +import { CreateUserSchema } from '../schemas/admin.createUser.js'; import { hardRevokeSession } from '../services/sessionService.js'; +import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; -const logger = getLogger('admin-sessions'); +const logger = getLogger('admin'); + +export const createUser = async (req: Request, res: Response) => { + const parsed = CreateUserSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + message: 'Invalid payload', + details: parsed.error, + }); + } + + const { email, phone, roles } = parsed.data; + + try { + const existing = await User.findOne({ where: { email } }); + + if (existing) { + return res.status(409).json({ message: 'User already exists' }); + } + + const user = await User.create({ + email, + phone: phone, + roles: roles ?? [], + }); + + return res.status(201).json({ user }); + } catch (err) { + return res.status(500).json({ message: 'Failed to create user' }); + } +}; -/* -GET /admin/sessions/:userId -*/ export const listUserSessions = async (req: Request, res: Response) => { const { userId } = req.params; @@ -36,9 +71,6 @@ export const listUserSessions = async (req: Request, res: Response) => { } }; -/* -DELETE /admin/sessions/:userId/revoke-all -*/ export const revokeAllUserSessions = async (req: Request, res: Response) => { const { userId } = req.params; @@ -62,3 +94,77 @@ export const revokeAllUserSessions = async (req: Request, res: Response) => { return res.status(500).json({ message: 'Failed to revoke sessions' }); } }; + +export const getUserDetail = async (req: ServiceRequest, res: Response) => { + const { userId } = req.params; + + const user = await User.findByPk(userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const sessions = await Session.findAll({ + where: { userId }, + }); + + const credentials = await Credential.findAll({ + where: { userId }, + }); + + const events = await AuthEvent.findAll({ + where: { user_id: userId }, + limit: 50, + order: [['created_at', 'DESC']], + }); + + return res.json({ + user, + sessions, + credentials, + events, + }); +}; + +export const getUserAnomalies = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const userEvents = await AuthEvent.findAll({ + where: { user_id: userId }, + attributes: ['ip_address', 'user_agent'], + }); + + const ips = [...new Set(userEvents.map((e) => e.ip_address).filter(Boolean))]; + const agents = [...new Set(userEvents.map((e) => e.user_agent).filter(Boolean))]; + + const suspiciousEvents = await AuthEvent.findAll({ + where: { + type: { [Op.like]: '%suspicious%' }, + [Op.or]: [ + { user_id: userId }, + { ip_address: { [Op.in]: ips ?? [] } }, + { user_agent: { [Op.in]: agents ?? [] } }, + ], + }, + order: [['created_at', 'DESC']], + limit: 50, + }); + + return res.json({ + suspiciousEvents: suspiciousEvents, + relatedIps: Array.from(ips), + relatedAgents: Array.from(agents), + }); + } catch { + return res.status(500).json({ message: 'Failed to fetch anomalies' }); + } +}; + +export const getDatabaseSize = async () => { + const [result] = await sequelize.query(` + SELECT pg_database_size(current_database()) as size + `); + + return Number((result as any)[0].size); +}; diff --git a/src/controllers/internal.ts b/src/controllers/internal.ts index 9b71ccc..9035799 100644 --- a/src/controllers/internal.ts +++ b/src/controllers/internal.ts @@ -13,10 +13,41 @@ import getLogger from '../utils/logger.js'; const logger = getLogger('internal'); +function expandType(type?: string): string[] { + if (!type) return []; + + if (type === 'login') return ['login_success', 'login_failed']; + if (type === 'otp') return ['otp_success', 'otp_failed']; + if (type === 'webauthn') return ['webauthn_login_success', 'webauthn_login_failed']; + if (type === 'magicLink') return ['magic_link_success', 'magic_link_requested']; + + if (type === 'suspicious') + return [ + 'login_suspicious', + 'otp_suspicious', + 'webauthn_login_suspicious', + 'verify_otp_suspicious', + 'service_token_suspicious', + ]; + + return [type]; +} + export const getUsers = async (req: ServiceRequest, res: Response) => { - logger.info('Internal users call made.'); - try { - const users = await User.findAll({ + const { limit = 50, offset = 0, search } = req.query; + + const where: any = {}; + + if (search) { + where[Op.or] = [ + { email: { [Op.iLike]: `%${search}%` } }, + { phone: { [Op.iLike]: `%${search}%` } }, + ]; + } + + const [users, total] = await Promise.all([ + await User.findAll({ + where, attributes: [ 'id', 'email', @@ -27,14 +58,19 @@ export const getUsers = async (req: ServiceRequest, res: Response) => { 'verified', 'lastLogin', 'roles', + 'createdAt', + 'updatedAt', ], - }); - - return res.json({ users }); - } catch (err) { - logger.error(`Failed to fetch users: ${err}`); - res.status(500).json({ message: 'Failed to fetch users' }); - } + limit: Number(limit), + offset: Number(offset), + }), + User.count({ where }), + ]); + + return res.json({ + users: users ?? [], + total, + }); }; export const getAuthEvents = async (req: ServiceRequest, res: Response) => { @@ -48,8 +84,23 @@ export const getAuthEvents = async (req: ServiceRequest, res: Response) => { const where: any = {}; + if (type) { + const raw = Array.isArray(type) ? req.query.type : [req.query.type]; + + const expanded = raw.flatMap(expandType); + + where.type = { + [Op.in]: expanded, + }; + } + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + if (userId) where.user_id = userId; - if (type) where.type = type; if (from || to) { where.created_at = {}; @@ -131,14 +182,13 @@ export const deleteUser = async (req: ServiceRequest, res: Response) => { }; export const updateUser = async (req: ServiceRequest, res: Response) => { - const { userId, triggeredBy: actorId } = req.params; + const { userId } = req.params; - if (!actorId || !userId) { + if (!userId) { + logger.error('Missing user id for updating user'); return res.status(400).json({ message: 'Bad request' }); } - logger.info(`${actorId} is updating user profile for ${userId}`); - const parsed = UpdateUserSchema.safeParse(req.body); if (!parsed.success || Object.keys(parsed.data).length === 0) { @@ -163,7 +213,6 @@ export const updateUser = async (req: ServiceRequest, res: Response) => { await AuthEventService.log({ type: 'internal_user_updated_by_owner', - userId: actorId, req, metadata: { before, diff --git a/src/controllers/internalDashboard.ts b/src/controllers/internalDashboard.ts index 8866b67..85faa16 100644 --- a/src/controllers/internalDashboard.ts +++ b/src/controllers/internalDashboard.ts @@ -1,24 +1,80 @@ import { Request, Response } from 'express'; +import { Op } from 'sequelize'; import { AuthEvent } from '../models/authEvents.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; +import { getDatabaseSize } from './admin.js'; export const getDashboardMetrics = async (_req: Request, res: Response) => { + const now = new Date(); + const last24h = new Date(now.getTime() - 1000 * 60 * 60 * 24); + try { - const [totalUsers, totalSessions, loginSuccess, loginFailed] = await Promise.all([ + const [ + totalUsers, + activeSessions, + newUsers24h, + loginSuccess24h, + loginFailed24h, + otpUsage24h, + passkeyUsage24h, + dbSize, + ] = await Promise.all([ User.count(), Session.count({ where: { revokedAt: null } }), - AuthEvent.count({ where: { type: 'login_success' } }), - AuthEvent.count({ where: { type: 'login_failed' } }), + + User.count({ + where: { + createdAt: { [Op.gt]: last24h }, + }, + }), + + AuthEvent.count({ + where: { + type: 'login_success', + created_at: { [Op.gt]: last24h }, + }, + }), + + AuthEvent.count({ + where: { + type: 'login_failed', + created_at: { [Op.gt]: last24h }, + }, + }), + + AuthEvent.count({ + where: { + type: 'otp_success', + created_at: { [Op.gt]: last24h }, + }, + }), + + AuthEvent.count({ + where: { + type: { [Op.like]: '%webauthn_login_success%' }, + created_at: { [Op.gt]: last24h }, + }, + }), + + getDatabaseSize(), ]); + const totalLogins = loginSuccess24h + loginFailed24h; + return res.json({ - users: totalUsers, - activeSessions: totalSessions, - loginSuccess, - loginFailed, - successRate: loginSuccess + loginFailed > 0 ? loginSuccess / (loginSuccess + loginFailed) : 0, + totalUsers, + activeSessions, + newUsers24h, + + loginSuccess24h, + loginFailed24h, + successRate24h: totalLogins > 0 ? loginSuccess24h / totalLogins : 0, + + otpUsage24h, + passkeyUsage24h, + databaseSize: dbSize, }); } catch { return res.status(500).json({ message: 'Failed to fetch dashboard metrics' }); diff --git a/src/controllers/internalMetrics.ts b/src/controllers/internalMetrics.ts index d1b340c..02f819d 100644 --- a/src/controllers/internalMetrics.ts +++ b/src/controllers/internalMetrics.ts @@ -50,14 +50,30 @@ export const getAuthEventTimeseries = async (req: Request, res: Response) => { return res.status(400).json({ message: 'Invalid query params' }); } - const { from, to, interval } = parsed.data; + const { from, to, interval, userId } = parsed.data; - const where: any = {}; + const where: any = { + type: { + [Op.in]: ['login_success', 'login_failed'], + }, + }; + + if (userId) { + where.user_id = req.query.userId; + } + + // Default to last 24h if not provided + const now = new Date(); + const defaultFrom = new Date(now.getTime() - 1000 * 60 * 60 * 24); if (from || to) { where.created_at = {}; if (from) where.created_at[Op.gte] = new Date(from); if (to) where.created_at[Op.lte] = new Date(to); + } else { + where.created_at = { + [Op.gte]: defaultFrom, + }; } const bucket = @@ -67,20 +83,67 @@ export const getAuthEventTimeseries = async (req: Request, res: Response) => { try { const results = await AuthEvent.findAll({ - attributes: [ - [bucket, 'bucket'], - [fn('COUNT', col('id')), 'count'], - ], + attributes: [[bucket, 'bucket'], 'type', [fn('COUNT', col('id')), 'count']], where, - group: ['bucket'], + group: ['bucket', 'type'], order: [[literal('bucket'), 'ASC']], }); + const map: Record = {}; + + for (const r of results as any[]) { + const bucket = new Date(r.get('bucket')).toISOString(); + const type = r.get('type'); + const count = Number(r.get('count')); + + if (!map[bucket]) { + map[bucket] = { + bucket, + success: 0, + failed: 0, + }; + } + + if (type === 'login_success') { + map[bucket].success = count; + } else if (type === 'login_failed') { + map[bucket].failed = count; + } + } + + const filled: any[] = []; + + if (interval === 'day') { + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(d.getUTCDate() - i); + d.setUTCHours(0, 0, 0, 0); + + const key = d.toISOString(); + + filled.push({ + bucket: key, + success: map[key]?.success ?? 0, + failed: map[key]?.failed ?? 0, + }); + } + } else { + for (let i = 23; i >= 0; i--) { + const d = new Date(now); + d.setUTCHours(d.getUTCHours() - i, 0, 0, 0); + + const key = d.toISOString(); + + filled.push({ + bucket: key, + success: map[key]?.success ?? 0, + failed: map[key]?.failed ?? 0, + }); + } + } + return res.json({ - timeseries: results.map((r: any) => ({ - bucket: r.get('bucket'), - count: Number(r.get('count')), - })), + timeseries: filled, }); } catch (err) { logger.error(`Failed to fetch timeseries: ${err}`); @@ -107,3 +170,41 @@ export const getLoginStats = async (req: Request, res: Response) => { return res.status(500).json({ message: 'Failed to compute login stats' }); } }; + +// src/controllers/internalMetrics.ts +export const getGroupedEventSummary = async (_req: Request, res: Response) => { + try { + const events = await AuthEvent.findAll(); + + const grouped = { + login: 0, + otp: 0, + webauthn: 0, + magicLink: 0, + system: 0, + suspicious: 0, + other: 0, + }; + + for (const e of events) { + const type = e.type; + + if (type.includes('login')) grouped.login++; + else if (type.includes('otp')) grouped.otp++; + else if (type.includes('webauthn')) grouped.webauthn++; + else if (type.includes('magic_link')) grouped.magicLink++; + else if (type.includes('system_config')) grouped.system++; + else if (type.includes('suspicious')) grouped.suspicious++; + else grouped.other++; + } + + return res.json({ + summary: Object.entries(grouped).map(([type, count]) => ({ + type, + count, + })), + }); + } catch { + return res.status(500).json({ message: 'Failed to group events' }); + } +}; diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 33bf675..1461669 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -4,16 +4,15 @@ */ import { Response } from 'express'; -import { invalidateSystemConfigCache } from '../config/getSystemConfig.js'; +import { getSystemConfig, invalidateSystemConfigCache } from '../config/getSystemConfig.js'; import { SystemConfig } from '../models/systemConfig.js'; import { User } from '../models/users.js'; -import { PatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; +import { createPatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; import { AuthEventService } from '../services/authEventService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; -const UpdateSystemConfigSchema = PatchSystemConfigSchema; const logger = getLogger('systemConfig'); async function getRolesInUse(): Promise> { @@ -29,15 +28,12 @@ async function getRolesInUse(): Promise> { } export async function updateSystemConfig(req: ServiceRequest, res: Response) { - const actorId = req.triggeredBy; + logger.info('Updating system config'); + const existing = await getSystemConfig(); - logger.debug(`Updating Systeml config. Updated by ${actorId}`); + const schema = createPatchSystemConfigSchema(existing); - if (!actorId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const parsed = UpdateSystemConfigSchema.safeParse(req.body); + const parsed = schema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -81,7 +77,6 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { { key, value, - updatedBy: actorId, }, { transaction: tx }, ); @@ -92,7 +87,6 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { await AuthEventService.log({ type: 'system_config_updated', - userId: actorId, req, metadata: { before: existingMap, @@ -107,12 +101,6 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { } export async function getSystemConfigHandler(req: ServiceRequest, res: Response) { - const actorId = req.triggeredBy; - - if (!actorId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - const rows = await SystemConfig.findAll(); const configObject = Object.fromEntries(rows.map((row) => [row.key, row.value])); @@ -122,7 +110,6 @@ export async function getSystemConfigHandler(req: ServiceRequest, res: Response) if (!parsed.success) { logger.error(`System config has become tainted. Critical issue.`); AuthEventService.log({ - userId: actorId, type: 'system_config_error', req, metadata: { reason: 'Failed to parse the system config schema from the database' }, @@ -134,9 +121,16 @@ export async function getSystemConfigHandler(req: ServiceRequest, res: Response) await AuthEventService.log({ type: 'system_config_read', - userId: actorId, req, }); return res.status(200).json(parsed.data); } + +export const getAvailableRoles = async (_req: Request, res: Response) => { + const config = await getSystemConfig(); + + return res.json({ + roles: config.available_roles ?? [], + }); +}; diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index b6c87f1..328b613 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -409,13 +409,13 @@ const verifyWebAuthn = async (req: Request, res: Response) => { } if (!user || !user.challenge) { - await AuthEvent.create({ - user_id: null, - type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_login_failed', + req, metadata: { reason: 'No user or user challenge' }, }); + return res.status(401).json({ message: 'Authentication failed.' }); } @@ -425,13 +425,14 @@ const verifyWebAuthn = async (req: Request, res: Response) => { if (!cred) { logger.error(`Failed to find the credental for the user ${assertionResponse.id}`); - await AuthEvent.create({ - user_id: user.id, - type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_login_failed', + req, metadata: { reason: 'No credential' }, }); + return res.status(401).json({ message: 'Authentication failed.' }); } @@ -459,13 +460,13 @@ const verifyWebAuthn = async (req: Request, res: Response) => { if (error instanceof Error) { logger.error(`Verification failed error stack: ${error.stack}`); } - await AuthEvent.create({ - user_id: user.id, - type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_login_failed', + req, metadata: { reason: 'Incorrect passkey' }, }); + return res.status(500).json({ message: 'Internal server error' }); } @@ -475,6 +476,13 @@ const verifyWebAuthn = async (req: Request, res: Response) => { counter: verification.authenticationInfo.newCounter, }); + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_login_success', + req, + metadata: { reason: 'Successful login' }, + }); + const refreshToken = generateRefreshToken(); const refreshTokenHash = await hashRefreshToken(refreshToken); const { expiresAt, idleExpiresAt } = computeSessionTimes(); diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index 5034051..f60efac 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -3,15 +3,18 @@ * Licensed under the GNU Affero General Public License v3.0 */ import { NextFunction, RequestHandler, Response, Router } from 'express'; -import { ZodTypeAny } from 'zod'; +import { ZodError, ZodTypeAny } from 'zod'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; import { registry } from '../openapi/registry.js'; import { CookieType } from '../services/sessionService.js'; +import getLogger from '../utils/logger.js'; import { expressToOpenAPI } from './convertPath.js'; import { InferRequest, RouteSchemas } from './routeTypes.js'; import { generateExample } from './zodExample.js'; +const logger = getLogger('defineRoute'); + type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; interface DefineRouteOptions { @@ -144,21 +147,37 @@ export function defineRoute( const originalJson = res.json.bind(res); if (response) { - const schema = - typeof response === 'object' && '200' in (response as object) - ? (response as Record)[200] - : response; - - if (schema) { - res.json = ((data: unknown) => { - const parsed = (schema as ZodTypeAny).parse(data); - return originalJson(parsed); - }) as typeof res.json; - } + res.json = ((data: unknown) => { + try { + const status = res.statusCode || 200; + + let schema: ZodTypeAny | undefined; + + if (typeof response === 'object') { + schema = (response as Record)[status]; + } else { + schema = response; + } + + if (schema) { + const parsed = schema.parse(data); + return originalJson(parsed); + } + + return originalJson(data); + } catch (err) { + logger.error('Response schema validation failed', err); + + return originalJson({ + error: 'Response validation failed', + issues: err instanceof ZodError ? err.issues : err, + }); + } + }) as typeof res.json; } - await Promise.resolve(handler(req as InferRequest, res, next)); } catch (error: unknown) { + logger.error(`Error wrapping parsed handler. ${error}`); return next(error); } }; diff --git a/src/middleware/authenticateServiceToken.ts b/src/middleware/authenticateServiceToken.ts index dd1deef..c922090 100644 --- a/src/middleware/authenticateServiceToken.ts +++ b/src/middleware/authenticateServiceToken.ts @@ -12,7 +12,7 @@ import { getSecret } from '../utils/secretsStore.js'; const logger = getLogger('authenticateServiceToken'); export async function verifyServiceToken(req: ServiceRequest, res: Response, next: NextFunction) { - const JWT_INTERNAL = await getSecret('SEAMLESS_INTERNAL_TOKEN'); + const JWT_INTERNAL = await getSecret('API_SERVICE_TOKEN'); const authHeader = req.headers.authorization || ''; const token = authHeader.replace('Bearer ', ''); diff --git a/src/models/index.ts b/src/models/index.ts index 94a4132..a2ae7d2 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -18,7 +18,7 @@ const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD } = process.env; const DATABASE_URL = `postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}`; -const sequelize = new Sequelize(DATABASE_URL, { +export const sequelize = new Sequelize(DATABASE_URL, { logging: enableDbLogging ? (msg) => logger.debug(msg) : false, }); diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index adc41ce..1a51cd1 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -1,6 +1,8 @@ +import { createUser, getUserAnomalies, getUserDetail } from '../controllers/admin.js'; import { deleteUser, updateUser } from '../controllers/internal.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; +import { CreateUserSchema } from '../schemas/admin.createUser.js'; import { InternalErrorSchema, SuccessMessageSchema, @@ -10,12 +12,40 @@ import { UpdateUserSchema } from '../schemas/user.patch.schema.js'; const adminRouter = createRouter('/admin'); +adminRouter.post( + '/users', + { + // middleware: [verifyServiceToken], + schemas: { + body: CreateUserSchema, + }, + }, + createUser, +); + +adminRouter.delete( + '/users', + { + summary: 'Delete user', + tags: ['Admin'], + //middleware: [verifyServiceToken], + + schemas: { + response: { + 200: SuccessMessageSchema, + 500: InternalErrorSchema, + }, + }, + }, + deleteUser, +); + adminRouter.patch( - '/users/:triggeredBy/:userId', + '/users/:userId', { summary: 'Update user', tags: ['Admin'], - middleware: [verifyServiceToken], + //middleware: [verifyServiceToken], schemas: { body: UpdateUserSchema, @@ -30,21 +60,20 @@ adminRouter.patch( updateUser, ); -adminRouter.delete( - '/users', +adminRouter.get( + '/users/:userId', { - summary: 'Delete user', - tags: ['Admin'], - middleware: [verifyServiceToken], + //middleware: [verifyServiceToken], + }, + getUserDetail, +); - schemas: { - response: { - 200: SuccessMessageSchema, - 500: InternalErrorSchema, - }, - }, +adminRouter.get( + '/users/:userId/anomalies', + { + //middleware: [verifyServiceToken] }, - deleteUser, + getUserAnomalies, ); export default adminRouter.router; diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts index 2836b7f..69dab7c 100644 --- a/src/routes/internal.routes.ts +++ b/src/routes/internal.routes.ts @@ -3,6 +3,7 @@ import { getDashboardMetrics } from '../controllers/internalDashboard.js'; import { getAuthEventSummary, getAuthEventTimeseries, + getGroupedEventSummary, getLoginStats, } from '../controllers/internalMetrics.js'; import { getSecurityAnomalies } from '../controllers/internalSecurity.js'; @@ -25,7 +26,7 @@ internalRouter.get( { summary: 'List users (internal)', tags: ['Internal'], - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], schemas: { response: { @@ -40,7 +41,7 @@ internalRouter.get( internalRouter.get( '/auth-events', { - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], tags: ['Internal'], schemas: { query: AuthEventQuerySchema, @@ -57,7 +58,7 @@ internalRouter.get( { summary: 'Get credential count', tags: ['Internal'], - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], schemas: { response: { @@ -74,7 +75,7 @@ internalRouter.get( { summary: 'Fetch logs (dev only)', tags: ['Internal'], - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], schemas: { response: { @@ -89,7 +90,7 @@ internalRouter.get( internalRouter.get( '/auth-events/summary', { - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], tags: ['Internal'], schemas: { query: MetricsQuerySchema, @@ -101,7 +102,7 @@ internalRouter.get( internalRouter.get( '/auth-events/timeseries', { - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], tags: ['Internal'], schemas: { query: MetricsQuerySchema, @@ -113,7 +114,7 @@ internalRouter.get( internalRouter.get( '/auth-events/login-stats', { - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], tags: ['Internal'], }, getLoginStats, @@ -122,7 +123,7 @@ internalRouter.get( internalRouter.get( '/security/anomalies', { - middleware: [verifyServiceToken], + // middleware: [verifyServiceToken], summary: 'Detect suspicious activity', tags: ['Internal'], }, @@ -132,11 +133,23 @@ internalRouter.get( internalRouter.get( '/metrics/dashboard', { - middleware: [verifyServiceToken], + //TODO: Uncomment just for testing locally + // middleware: [verifyServiceToken], summary: 'Dashboard metrics', tags: ['Internal'], }, getDashboardMetrics, ); +internalRouter.get( + '/auth-events/grouped', + { + //TODO: Uncomment just for testing locally + // middleware: [verifyServiceToken], + summary: 'Auth Event metrics grouped', + tags: ['Internal'], + }, + getGroupedEventSummary, +); + export default internalRouter.router; diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 0bfde80..6ec2494 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -2,11 +2,14 @@ * Copyright © 2026 Fells Code, LLC * Licensed under the GNU Affero General Public License v3.0 */ -import { getSystemConfigHandler, updateSystemConfig } from '../controllers/systemConfig.js'; +import { + getAvailableRoles, + getSystemConfigHandler, + updateSystemConfig, +} from '../controllers/systemConfig.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; import { SystemConfigParamsSchema } from '../schemas/systemConfig.params.js'; -import { PatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; import { GetSystemConfigResponseSchema, InvalidPayloadSchema, @@ -14,20 +17,28 @@ import { UnauthorizedSchema, UpdateSystemConfigResponseSchema, } from '../schemas/systemConfig.responses.js'; +import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; const systemConfigRouter = createRouter('/system-config'); systemConfigRouter.get( - '/:triggeredBy', + '/roles', + { + summary: 'Get available roles', + tags: ['SystemConfig'], + }, + getAvailableRoles, +); + +systemConfigRouter.get( + '/admin', { summary: 'Retrieve system configuration', tags: ['SystemConfig'], - middleware: [verifyServiceToken], + //middleware: [verifyServiceToken], schemas: { - params: SystemConfigParamsSchema, - response: { 200: GetSystemConfigResponseSchema, 401: UnauthorizedSchema, @@ -39,17 +50,14 @@ systemConfigRouter.get( ); systemConfigRouter.patch( - '/:triggeredBy', + '/admin', { summary: 'Update system configuration', tags: ['SystemConfig'], - middleware: [verifyServiceToken], + //middleware: [verifyServiceToken], schemas: { - params: SystemConfigParamsSchema, - body: PatchSystemConfigSchema, - response: { 200: UpdateSystemConfigResponseSchema, 400: InvalidPayloadSchema, diff --git a/src/schemas/admin.createUser.ts b/src/schemas/admin.createUser.ts new file mode 100644 index 0000000..cb5e958 --- /dev/null +++ b/src/schemas/admin.createUser.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const CreateUserSchema = z.object({ + email: z.email(), + phone: z.string(), + roles: z.array(z.string()).optional(), +}); diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts index ab46574..fdfc0bf 100644 --- a/src/schemas/admin.responses.ts +++ b/src/schemas/admin.responses.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; +import { UserBaseSchema } from './user.base.js'; + export const UserResponseSchema = z.object({ - user: z.record(z.unknown()), + user: UserBaseSchema, }); export const SuccessMessageSchema = z.object({ diff --git a/src/schemas/authEvent.schema.ts b/src/schemas/authEvent.schema.ts new file mode 100644 index 0000000..fc0a431 --- /dev/null +++ b/src/schemas/authEvent.schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const IsoDate = z.coerce.date().transform((d) => d.toISOString()); + +export const AuthEventSchema = z.object({ + id: z.string(), + + user_id: z.string().nullable().optional(), + + type: z.string(), + + ip_address: z.string().nullable().optional(), + + user_agent: z.string().nullable().optional(), + + metadata: z.record(z.string(), z.unknown()).nullable(), + + created_at: IsoDate, + updated_at: IsoDate, +}); + +export type AuthEvent = z.infer; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts new file mode 100644 index 0000000..25adbc9 --- /dev/null +++ b/src/schemas/authEvent.types.ts @@ -0,0 +1,64 @@ +// src/schemas/authEvent.types.ts +import { z } from 'zod'; + +export const AuthEventTypeEnum = z.enum([ + 'auth_action_incremented', + 'bearer_token_failed', + 'bearer_token_success', + 'bearer_token_suspicious', + 'cookie_token_failed', + 'cookie_token_success', + 'cookie_token_suspicious', + 'informational', + 'internal_user_updated_by_owner', + 'jwks_failed', + 'jwks_success', + 'jwks_suspicious', + 'login_failed', + 'login_success', + 'login_suspicious', + 'logout_failed', + 'logout_success', + 'logout_suspicious', + 'magic_link_poll_completed_successfully', + 'magic_link_requested', + 'magic_link_success', + 'mfa_otp_failed', + 'mfa_otp_success', + 'mfa_otp_suspicious', + 'notication_sent', + 'otp_failed', + 'otp_success', + 'otp_suspicious', + 'recovery_otp_failed', + 'recovery_otp_success', + 'recovery_otp_suspicious', + 'refresh_token_failed', + 'refresh_token_success', + 'refresh_token_suspicious', + 'registration_failed', + 'registration_success', + 'registration_suspicious', + 'service_token_failed', + 'service_token_rotated', + 'service_token_success', + 'service_token_suspicious', + 'system_config_error', + 'system_config_read', + 'system_config_updated', + 'user_created', + 'user_data_failed', + 'user_data_success', + 'user_data_suspicious', + 'verify_otp_failed', + 'verify_otp_success', + 'verify_otp_suspicious', + 'webauthn_login_failed', + 'webauthn_login_success', + 'webauthn_login_suspicious', + 'webauthn_registration_failed', + 'webauthn_registration_success', + 'webauthn_registration_suspicious', +]); + +export type AuthEventType = z.infer; diff --git a/src/schemas/internal.metrics.query.ts b/src/schemas/internal.metrics.query.ts index 5cee614..2b44cff 100644 --- a/src/schemas/internal.metrics.query.ts +++ b/src/schemas/internal.metrics.query.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; export const MetricsQuerySchema = z.object({ + userId: z.string().optional(), from: z.string().optional(), to: z.string().optional(), interval: z.enum(['hour', 'day']).optional().default('hour'), diff --git a/src/schemas/internal.query.ts b/src/schemas/internal.query.ts index 283c15f..dbcbfee 100644 --- a/src/schemas/internal.query.ts +++ b/src/schemas/internal.query.ts @@ -1,13 +1,21 @@ import { z } from 'zod'; +import { AuthEventTypeEnum } from './authEvent.types.js'; + export const PaginationQuerySchema = z.object({ limit: z.coerce.number().min(1).max(100).optional().default(50), offset: z.coerce.number().min(0).optional().default(0), }); -export const AuthEventQuerySchema = PaginationQuerySchema.extend({ +export const AuthEventQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(50), + offset: z.coerce.number().min(0).default(0), + userId: z.string().optional(), - type: z.string().optional(), + type: z + .union([AuthEventTypeEnum, z.string(), z.array(z.union([AuthEventTypeEnum, z.string()]))]) + .optional(), + from: z.string().optional(), to: z.string().optional(), }); diff --git a/src/schemas/internal.responses.ts b/src/schemas/internal.responses.ts index 7f657fe..5094b48 100644 --- a/src/schemas/internal.responses.ts +++ b/src/schemas/internal.responses.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; +import { AuthEventSchema } from './authEvent.schema.js'; +import { UserBaseSchema } from './user.base.js'; + export const UsersListResponseSchema = z.object({ - users: z.array(z.record(z.unknown())), + users: z.array(UserBaseSchema), + total: z.number(), }); export const AuthEventsResponseSchema = z.object({ - events: z.array(z.record(z.unknown())), + events: z.array(AuthEventSchema), }); export const CredentialCountSchema = z.object({ diff --git a/src/schemas/systemConfig.params.ts b/src/schemas/systemConfig.params.ts deleted file mode 100644 index ded434e..0000000 --- a/src/schemas/systemConfig.params.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const SystemConfigParamsSchema = z.object({ - triggeredBy: z.string(), -}); - -export type SystemConfigParams = z.infer; diff --git a/src/schemas/systemConfig.patch.schema.ts b/src/schemas/systemConfig.patch.schema.ts index e2a5f2f..8b935c4 100644 --- a/src/schemas/systemConfig.patch.schema.ts +++ b/src/schemas/systemConfig.patch.schema.ts @@ -2,20 +2,34 @@ * Copyright © 2026 Fells Code, LLC * Licensed under the GNU Affero General Public License v3.0 */ +// src/schemas/systemConfig.patch.schema.ts import { z } from 'zod'; +import type { SystemConfig } from './systemConfig.schema.js'; import { SystemConfigSchema } from './systemConfig.schema.js'; -export const PatchSystemConfigSchema = SystemConfigSchema.partial().superRefine((data, ctx) => { - if ( - data.default_roles && - data.available_roles && - !data.default_roles.every((r) => data.available_roles!.includes(r)) - ) { - ctx.addIssue({ - path: ['default_roles'], - message: 'All default roles must exist in available_roles', - code: z.ZodIssueCode.custom, - }); - } -}); +export function createPatchSystemConfigSchema(existing: SystemConfig) { + return SystemConfigSchema.partial().superRefine((data, ctx) => { + const nextAvailable = data.available_roles ?? existing.available_roles; + const nextDefault = data.default_roles ?? existing.default_roles; + + if ( + data.available_roles && + existing.default_roles.some((r) => !data.available_roles!.includes(r)) + ) { + ctx.addIssue({ + path: ['available_roles'], + message: 'Cannot remove roles currently set as default', + code: z.ZodIssueCode.custom, + }); + } + + if (nextDefault && nextAvailable && !nextDefault.every((r) => nextAvailable.includes(r))) { + ctx.addIssue({ + path: ['default_roles'], + message: 'All default roles must exist in available_roles', + code: z.ZodIssueCode.custom, + }); + } + }); +} diff --git a/src/schemas/user.base.ts b/src/schemas/user.base.ts index 0807265..aaf07d5 100644 --- a/src/schemas/user.base.ts +++ b/src/schemas/user.base.ts @@ -10,6 +10,10 @@ export const UserBaseSchema = z.object({ lastLogin: IsoDate.optional(), createdAt: IsoDate, updatedAt: IsoDate.optional(), + revoked: z.boolean().optional(), + emailVerified: z.boolean().optional(), + phoneVerified: z.boolean().optional(), + verified: z.boolean().optional(), }); export type UserBase = z.infer; diff --git a/src/schemas/user.patch.schema.ts b/src/schemas/user.patch.schema.ts index 99a8fd0..4d7550d 100644 --- a/src/schemas/user.patch.schema.ts +++ b/src/schemas/user.patch.schema.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; export const UpdateUserSchema = z .object({ - userId: z.guid(), email: z.email().optional(), phone: z.string().min(5).optional(), emailVerified: z.boolean().optional(), diff --git a/src/services/authEventService.ts b/src/services/authEventService.ts index 096468f..ee7df6d 100644 --- a/src/services/authEventService.ts +++ b/src/services/authEventService.ts @@ -9,64 +9,64 @@ import getLogger from '../utils/logger.js'; const logger = getLogger('authEventService'); -type AuthEventType = - | 'login_success' +export type AuthEventType = + | 'auth_action_incremented' + | 'bearer_token_failed' + | 'bearer_token_success' + | 'bearer_token_suspicious' + | 'cookie_token_failed' + | 'cookie_token_success' + | 'cookie_token_suspicious' + | 'informational' + | 'internal_user_updated_by_owner' + | 'jwks_failed' + | 'jwks_success' + | 'jwks_suspicious' | 'login_failed' + | 'login_success' | 'login_suspicious' - | 'registration_success' - | 'registration_failed' - | 'registration_suspicious' - | 'webauthn_registration_success' - | 'webauthn_registration_failed' - | 'webauthn_registration_suspicious' - | 'webauthn_login_success' - | 'webauthn_login_failed' - | 'webauthn_login_suspicious' - | 'logout_success' | 'logout_failed' + | 'logout_success' | 'logout_suspicious' - | 'jwks_success' - | 'jwks_failed' - | 'jwks_suspicious' - | 'otp_success' - | 'otp_failed' - | 'otp_suspicious' - | 'internal_user_updated_by_owner' - | 'verify_otp_success' - | 'verify_otp_failed' - | 'verify_otp_suspicious' - | 'mfa_otp_success' + | 'magic_link_poll_completed_successfully' + | 'magic_link_requested' + | 'magic_link_success' | 'mfa_otp_failed' + | 'mfa_otp_success' | 'mfa_otp_suspicious' - | 'recovery_otp_success' + | 'notication_sent' + | 'otp_failed' + | 'otp_success' + | 'otp_suspicious' | 'recovery_otp_failed' + | 'recovery_otp_success' | 'recovery_otp_suspicious' - | 'user_created' - | 'user_data_success' - | 'user_data_failed' - | 'user_data_suspicious' - | 'service_token_success' - | 'service_token_failed' - | 'service_token_suspicious' - | 'refresh_token_success' | 'refresh_token_failed' + | 'refresh_token_success' | 'refresh_token_suspicious' + | 'registration_failed' + | 'registration_success' + | 'registration_suspicious' + | 'service_token_failed' | 'service_token_rotated' - | 'bearer_token_success' - | 'bearer_token_failed' - | 'bearer_token_suspicious' - | 'cookie_token_success' - | 'cookie_token_failed' - | 'cookie_token_suspicious' - | 'auth_action_incremented' - | 'system_config_updated' + | 'service_token_success' + | 'service_token_suspicious' | 'system_config_error' | 'system_config_read' - | 'notication_sent' - | 'magic_link_requested' - | 'magic_link_success' - | 'magic_link_poll_completed_successfully' - | 'informational'; + | 'system_config_updated' + | 'user_created' + | 'user_data_failed' + | 'user_data_success' + | 'user_data_suspicious' + | 'verify_otp_failed' + | 'verify_otp_success' + | 'verify_otp_suspicious' + | 'webauthn_login_failed' + | 'webauthn_login_success' + | 'webauthn_login_suspicious' + | 'webauthn_registration_failed' + | 'webauthn_registration_success' + | 'webauthn_registration_suspicious'; export interface AuthEventOptions { userId?: string | null; From 0f1b8c6c9e7ec9b9103ad5beabfc03aac5849b99 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 24 Mar 2026 00:02:48 -0400 Subject: [PATCH 3/7] feat: port over to seamless auth types but not actual version yet --- src/controllers/admin.ts | 17 ++++++- src/controllers/internal.ts | 38 ++++++-------- src/controllers/internalMetrics.ts | 3 +- src/routes/admin.routes.ts | 31 ++++++++---- src/routes/admin.sessions.routes.ts | 11 ++--- src/routes/auth.routes.ts | 26 +++++----- src/routes/internal.routes.ts | 25 ++-------- src/routes/magicLink.routes.ts | 23 ++++----- src/routes/otp.routes.ts | 40 +++++++-------- src/routes/registration.routes.ts | 10 ++-- src/routes/session.routes.ts | 19 +++---- src/routes/systemConfig.routes.ts | 10 ++-- src/routes/users.routes.ts | 12 ++--- src/routes/webauthn.routes.ts | 22 ++++----- src/schemas/admin.createUser.ts | 7 --- .../{admin.sessions.ts => admin.query.ts} | 0 src/schemas/admin.responses.ts | 13 +---- src/schemas/auth.responses.ts | 12 +---- src/schemas/authEvent.schema.ts | 22 --------- src/schemas/credential.base.ts | 20 -------- src/schemas/credential.request.ts | 21 -------- src/schemas/error.schema.ts | 5 -- src/schemas/generic.responses.ts | 15 ++++++ src/schemas/internal.metrics.query.ts | 8 --- src/schemas/internal.query.ts | 9 +++- src/schemas/internal.responses.ts | 13 ++--- src/schemas/magicLink.schema.ts | 2 +- src/schemas/magiclink.responses.ts | 16 ------ src/schemas/me.response.ts | 13 +++++ src/schemas/me.schema.ts | 31 ------------ src/schemas/otp.responses.ts | 18 +------ src/schemas/registration.requests.ts | 2 +- src/schemas/registration.responses.ts | 4 -- src/schemas/session.responses.ts | 20 +------- src/schemas/user.base.ts | 19 ------- src/schemas/user.patch.schema.ts | 11 ----- src/schemas/user.responses.ts | 5 -- src/schemas/webauthn.responses.ts | 10 +--- src/utils/getLocalLogs.ts | 49 ------------------- src/utils/signingKeyStore.ts | 3 +- 40 files changed, 181 insertions(+), 454 deletions(-) delete mode 100644 src/schemas/admin.createUser.ts rename src/schemas/{admin.sessions.ts => admin.query.ts} (100%) delete mode 100644 src/schemas/authEvent.schema.ts delete mode 100644 src/schemas/credential.base.ts delete mode 100644 src/schemas/credential.request.ts delete mode 100644 src/schemas/error.schema.ts create mode 100644 src/schemas/generic.responses.ts delete mode 100644 src/schemas/internal.metrics.query.ts create mode 100644 src/schemas/me.response.ts delete mode 100644 src/schemas/me.schema.ts delete mode 100644 src/schemas/user.base.ts delete mode 100644 src/schemas/user.patch.schema.ts delete mode 100644 src/schemas/user.responses.ts delete mode 100644 src/utils/getLocalLogs.ts diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index a022722..a48efd4 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -1,3 +1,4 @@ +import { CreateUserSchema } from '@seamless-auth/types'; import { Request, Response } from 'express'; import { Op } from 'sequelize'; @@ -6,7 +7,6 @@ import { Credential } from '../models/credentials.js'; import { sequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; -import { CreateUserSchema } from '../schemas/admin.createUser.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -95,6 +95,21 @@ export const revokeAllUserSessions = async (req: Request, res: Response) => { } }; +export const listAllSessions = async (req: Request, res: Response) => { + const { limit = 10, offset = 0 } = req.query; + + const [sessions, total] = await Promise.all([ + Session.findAll({ + where: { revokedAt: null }, + limit: Number(limit), + offset: Number(offset), + }), + Session.count({ where: { revokedAt: null } }), + ]); + + return res.json({ sessions, total }); +}; + export const getUserDetail = async (req: ServiceRequest, res: Response) => { const { userId } = req.params; diff --git a/src/controllers/internal.ts b/src/controllers/internal.ts index 9035799..2d41671 100644 --- a/src/controllers/internal.ts +++ b/src/controllers/internal.ts @@ -1,3 +1,4 @@ +import { UpdateUserSchema } from '@seamless-auth/types'; import { Response } from 'express'; import { Op } from 'sequelize'; @@ -5,10 +6,8 @@ import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import { AuthEventQuerySchema } from '../schemas/internal.query.js'; -import { UpdateUserSchema } from '../schemas/user.patch.schema.js'; import { AuthEventService } from '../services/authEventService.js'; import { ServiceRequest } from '../types/types.js'; -import { getLocalLogs } from '../utils/getLocalLogs.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('internal'); @@ -109,19 +108,25 @@ export const getAuthEvents = async (req: ServiceRequest, res: Response) => { } try { - const events = await AuthEvent.findAll({ - where, - order: [['created_at', 'DESC']], - limit, - offset, - }); - - return res.json({ events }); + const [events, total] = await Promise.all([ + AuthEvent.findAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }), + AuthEvent.count({ + where, + }), + ]); + + return res.json({ events, total }); } catch (err) { logger.error(`Failed to fetch auth events: ${err}`); res.status(500).json({ message: 'Failed to fetch events' }); } }; + export const getCredentialsCount = async (req: ServiceRequest, res: Response) => { logger.info('Internal credential count call made.'); try { @@ -134,19 +139,6 @@ export const getCredentialsCount = async (req: ServiceRequest, res: Response) => } }; -export const getLogs = async (req: ServiceRequest, res: Response) => { - logger.info('Internal logs call made.'); - - if (process.env.NODE_ENV !== 'production') { - const logsResult = await getLocalLogs(req.query.search as string); - res.json(logsResult); - - return; - } - - return res.status(200).json({ message: 'No logs' }); -}; - export const deleteUser = async (req: ServiceRequest, res: Response) => { logger.info('Internal deletion call made.'); const { userId } = req.body; diff --git a/src/controllers/internalMetrics.ts b/src/controllers/internalMetrics.ts index 02f819d..15a42d9 100644 --- a/src/controllers/internalMetrics.ts +++ b/src/controllers/internalMetrics.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { col, fn, literal, Op } from 'sequelize'; import { AuthEvent } from '../models/authEvents.js'; -import { MetricsQuerySchema } from '../schemas/internal.metrics.query.js'; +import { MetricsQuerySchema } from '../schemas/internal.query.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('internal-metrics'); @@ -171,7 +171,6 @@ export const getLoginStats = async (req: Request, res: Response) => { } }; -// src/controllers/internalMetrics.ts export const getGroupedEventSummary = async (_req: Request, res: Response) => { try { const events = await AuthEvent.findAll(); diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 1a51cd1..943d402 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -1,14 +1,17 @@ -import { createUser, getUserAnomalies, getUserDetail } from '../controllers/admin.js'; +import { CreateUserSchema, UpdateUserSchema } from '@seamless-auth/types'; + +import { + createUser, + getUserAnomalies, + getUserDetail, + listAllSessions, +} from '../controllers/admin.js'; import { deleteUser, updateUser } from '../controllers/internal.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; -import { CreateUserSchema } from '../schemas/admin.createUser.js'; -import { - InternalErrorSchema, - SuccessMessageSchema, - UserResponseSchema, -} from '../schemas/admin.responses.js'; -import { UpdateUserSchema } from '../schemas/user.patch.schema.js'; +import { UserResponseSchema } from '../schemas/admin.responses.js'; +import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { PaginationQuerySchema } from '../schemas/internal.query.js'; const adminRouter = createRouter('/admin'); @@ -32,7 +35,7 @@ adminRouter.delete( schemas: { response: { - 200: SuccessMessageSchema, + 200: MessageSchema, 500: InternalErrorSchema, }, }, @@ -76,4 +79,14 @@ adminRouter.get( getUserAnomalies, ); +adminRouter.get( + '/sessions', + { + schema: { + query: PaginationQuerySchema, + }, + }, + listAllSessions, +); + export default adminRouter.router; diff --git a/src/routes/admin.sessions.routes.ts b/src/routes/admin.sessions.routes.ts index 02942b8..a22776b 100644 --- a/src/routes/admin.sessions.routes.ts +++ b/src/routes/admin.sessions.routes.ts @@ -2,12 +2,9 @@ import { listUserSessions, revokeAllUserSessions } from '../controllers/admin.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; -import { UserIdParamSchema } from '../schemas/admin.sessions.js'; -import { InternalErrorSchema } from '../schemas/internal.responses.js'; -import { - SessionDeleteResponseSchema, - SessionListResponseSchema, -} from '../schemas/session.responses.js'; +import { UserIdParamSchema } from '../schemas/admin.query.js'; +import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { SessionListResponseSchema } from '../schemas/session.responses.js'; const adminSessionsRouter = createRouter('/admin/sessions'); @@ -35,7 +32,7 @@ adminSessionsRouter.delete( schemas: { params: UserIdParamSchema, response: { - 200: SessionDeleteResponseSchema, + 200: MessageSchema, 500: InternalErrorSchema, }, }, diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 36f9269..8be9ed1 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -6,12 +6,8 @@ import { login, logout, refreshSession } from '../controllers/authentication.js' import { createRouter } from '../lib/createRouter.js'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; import { LoginRequestSchema } from '../schemas/auth.requests.js'; -import { - AuthErrorSchema, - LoginSuccessSchema, - LogoutSuccessSchema, - RefreshSuccessSchema, -} from '../schemas/auth.responses.js'; +import { LoginSuccessResponseSchema } from '../schemas/auth.responses.js'; +import { ErrorSchema, InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; const authRouter = createRouter(''); @@ -25,11 +21,11 @@ authRouter.post( body: LoginRequestSchema, response: { - 200: LoginSuccessSchema, - 400: AuthErrorSchema, - 401: AuthErrorSchema, - 403: AuthErrorSchema, - 500: AuthErrorSchema, + 200: LoginSuccessResponseSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -45,7 +41,7 @@ authRouter.get( schemas: { response: { - 200: LogoutSuccessSchema, + 200: MessageSchema, }, }, }, @@ -61,9 +57,9 @@ authRouter.post( schemas: { response: { - 200: RefreshSuccessSchema, - 401: AuthErrorSchema, - 500: AuthErrorSchema, + 200: MessageSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, }, }, }, diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts index 69dab7c..6429001 100644 --- a/src/routes/internal.routes.ts +++ b/src/routes/internal.routes.ts @@ -1,4 +1,4 @@ -import { getAuthEvents, getCredentialsCount, getLogs, getUsers } from '../controllers/internal.js'; +import { getAuthEvents, getCredentialsCount, getUsers } from '../controllers/internal.js'; import { getDashboardMetrics } from '../controllers/internalDashboard.js'; import { getAuthEventSummary, @@ -9,13 +9,11 @@ import { import { getSecurityAnomalies } from '../controllers/internalSecurity.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; -import { MetricsQuerySchema } from '../schemas/internal.metrics.query.js'; -import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { InternalErrorSchema } from '../schemas/generic.responses.js'; +import { AuthEventQuerySchema, MetricsQuerySchema } from '../schemas/internal.query.js'; import { AuthEventsResponseSchema, CredentialCountSchema, - InternalErrorSchema, - LogsResponseSchema, UsersListResponseSchema, } from '../schemas/internal.responses.js'; @@ -70,23 +68,6 @@ internalRouter.get( getCredentialsCount, ); -internalRouter.get( - '/logs', - { - summary: 'Fetch logs (dev only)', - tags: ['Internal'], - // middleware: [verifyServiceToken], - - schemas: { - response: { - 200: LogsResponseSchema, - 500: InternalErrorSchema, - }, - }, - }, - getLogs, -); - internalRouter.get( '/auth-events/summary', { diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index d6d54da..4ae5dec 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -10,14 +10,9 @@ import { import { createRouter } from '../lib/createRouter.js'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; import { magicLinkEmailLimiter, magicLinkIpLimiter } from '../middleware/rateLimit.js'; +import { ErrorSchema, InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { MagicLinkVerifyParamsSchema } from '../schemas/magiclink.requests.js'; -import { - MagicLinkErrorSchema, - MagicLinkPollPendingSchema, - MagicLinkPollSuccessSchema, - MagicLinkRequestResponseSchema, - MagicLinkVerifyResponseSchema, -} from '../schemas/magiclink.responses.js'; +import { MagicLinkPollSuccessSchema } from '../schemas/magiclink.responses.js'; const magicLinkRouter = createRouter('/magic-link'); @@ -30,7 +25,7 @@ magicLinkRouter.get( schemas: { response: { - 200: MagicLinkRequestResponseSchema, + 200: MessageSchema, }, }, }, @@ -47,9 +42,9 @@ magicLinkRouter.get( schemas: { response: { 200: MagicLinkPollSuccessSchema, - 204: MagicLinkPollPendingSchema, - 404: MagicLinkPollPendingSchema, - 500: MagicLinkErrorSchema, + 204: MessageSchema, + 404: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -66,9 +61,9 @@ magicLinkRouter.get( params: MagicLinkVerifyParamsSchema, response: { - 200: MagicLinkVerifyResponseSchema, - 400: MagicLinkErrorSchema, - 500: MagicLinkErrorSchema, + 200: MessageSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, }, }, }, diff --git a/src/routes/otp.routes.ts b/src/routes/otp.routes.ts index 9d56672..4c75876 100644 --- a/src/routes/otp.routes.ts +++ b/src/routes/otp.routes.ts @@ -12,13 +12,9 @@ import { } from '../controllers/otp.js'; import { createRouter } from '../lib/createRouter.js'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; +import { ErrorSchema, InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { VerifyOTPRequestSchema } from '../schemas/otp.requests.js'; -import { - OTPInvalidSchema, - OTPServerErrorSchema, - OTPSuccessSchema, - OTPVerifyTokenSuccessSchema, -} from '../schemas/otp.responses.js'; +import { OTPVerifyTokenSuccessSchema } from '../schemas/otp.responses.js'; const otpRouter = createRouter('/otp'); @@ -31,9 +27,9 @@ otpRouter.get( schemas: { response: { - 200: OTPSuccessSchema, - 400: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 200: MessageSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -49,9 +45,9 @@ otpRouter.get( schemas: { response: { - 200: OTPSuccessSchema, - 400: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 200: MessageSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -67,7 +63,7 @@ otpRouter.get( schemas: { response: { - 200: OTPSuccessSchema, + 200: MessageSchema, }, }, }, @@ -83,7 +79,7 @@ otpRouter.get( schemas: { response: { - 200: OTPSuccessSchema, + 200: MessageSchema, }, }, }, @@ -102,8 +98,8 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, - 401: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, }, }, }, @@ -122,8 +118,8 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, - 401: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, }, }, }, @@ -142,8 +138,8 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, - 401: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, }, }, }, @@ -162,8 +158,8 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, - 401: OTPInvalidSchema, - 500: OTPServerErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, }, }, }, diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index d70eb40..3bdad85 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -4,11 +4,9 @@ */ import { register } from '../controllers/registration.js'; import { createRouter } from '../lib/createRouter.js'; +import { ErrorSchema } from '../schemas/generic.responses.js'; import { RegistrationRequestSchema } from '../schemas/registration.requests.js'; -import { - RegistrationErrorSchema, - RegistrationSuccessSchema, -} from '../schemas/registration.responses.js'; +import { RegistrationSuccessSchema } from '../schemas/registration.responses.js'; const registrationRouter = createRouter('/registration'); @@ -23,8 +21,8 @@ registrationRouter.post( response: { 200: RegistrationSuccessSchema, - 400: RegistrationErrorSchema, - 500: RegistrationErrorSchema, + 400: ErrorSchema, + 500: ErrorSchema, }, }, }, diff --git a/src/routes/session.routes.ts b/src/routes/session.routes.ts index cdac0ef..b6dc681 100644 --- a/src/routes/session.routes.ts +++ b/src/routes/session.routes.ts @@ -5,12 +5,9 @@ import { listSessions, revokeAllSessions, revokeSession } from '../controllers/sessions.js'; import { createRouter } from '../lib/createRouter.js'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; +import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { SessionIdParamsSchema } from '../schemas/session.params.js'; -import { - SessionDeleteResponseSchema, - SessionErrorSchema, - SessionListResponseSchema, -} from '../schemas/session.responses.js'; +import { SessionListResponseSchema } from '../schemas/session.responses.js'; const sessionsRouter = createRouter('/sessions'); @@ -24,7 +21,7 @@ sessionsRouter.get( schemas: { response: { 200: SessionListResponseSchema, - 401: SessionErrorSchema, + 401: ErrorSchema, }, }, }, @@ -42,9 +39,9 @@ sessionsRouter.delete( params: SessionIdParamsSchema, response: { - 200: SessionDeleteResponseSchema, - 401: SessionErrorSchema, - 404: SessionErrorSchema, + 200: MessageSchema, + 401: ErrorSchema, + 404: ErrorSchema, }, }, }, @@ -60,8 +57,8 @@ sessionsRouter.delete( schemas: { response: { - 200: SessionDeleteResponseSchema, - 401: SessionErrorSchema, + 200: MessageSchema, + 401: ErrorSchema, }, }, }, diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 6ec2494..8423486 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -9,15 +9,13 @@ import { } from '../controllers/systemConfig.js'; import { createRouter } from '../lib/createRouter.js'; import { verifyServiceToken } from '../middleware/authenticateServiceToken.js'; -import { SystemConfigParamsSchema } from '../schemas/systemConfig.params.js'; +import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { GetSystemConfigResponseSchema, InvalidPayloadSchema, - SystemConfigErrorSchema, UnauthorizedSchema, UpdateSystemConfigResponseSchema, } from '../schemas/systemConfig.responses.js'; -import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; const systemConfigRouter = createRouter('/system-config'); @@ -41,8 +39,8 @@ systemConfigRouter.get( schemas: { response: { 200: GetSystemConfigResponseSchema, - 401: UnauthorizedSchema, - 500: SystemConfigErrorSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -61,7 +59,7 @@ systemConfigRouter.patch( response: { 200: UpdateSystemConfigResponseSchema, 400: InvalidPayloadSchema, - 401: UnauthorizedSchema, + 401: ErrorSchema, }, }, }, diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index b6687c4..a5e0f27 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -2,14 +2,12 @@ * Copyright © 2026 Fells Code, LLC * Licensed under the GNU Affero General Public License v3.0 */ +import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@seamless-auth/types'; + import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; -import { - DeleteCredentialRequestSchema, - UpdateCredentialRequestSchema, -} from '../schemas/credential.request.js'; -import { MeResponseSchema } from '../schemas/me.schema.js'; -import { DeleteUserResponseSchema } from '../schemas/user.responses.js'; +import { MessageSchema } from '../schemas/generic.responses.js'; +import { MeResponseSchema } from '../schemas/me.response.js'; const usersRouter = createRouter('/users'); @@ -48,7 +46,7 @@ usersRouter.delete( summary: 'Delete authenticated user', schemas: { - response: DeleteUserResponseSchema, + response: MessageSchema, }, }, deleteUser, diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index 5c7214f..91c7cba 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -10,13 +10,13 @@ import { } from '../controllers/webauthn.js'; import { createRouter } from '../lib/createRouter.js'; import { attachAuthMiddleware } from '../middleware/attachAuthMiddleware.js'; +import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { WebAuthnLoginFinishSchema, WebAuthnRegisterFinishSchema, } from '../schemas/webauthn.requests.js'; import { WebAuthnChallengeSchema, - WebAuthnErrorSchema, WebAuthnTokenSuccessSchema, } from '../schemas/webauthn.responses.js'; @@ -32,8 +32,8 @@ webauthnRouter.get( schemas: { response: { 200: WebAuthnChallengeSchema, - 403: WebAuthnErrorSchema, - 500: WebAuthnErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, }, }, }, @@ -52,8 +52,8 @@ webauthnRouter.post( response: { 200: WebAuthnTokenSuccessSchema, - 403: WebAuthnErrorSchema, - 500: WebAuthnErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, }, }, }, @@ -70,9 +70,9 @@ webauthnRouter.post( schemas: { response: { 200: WebAuthnChallengeSchema, - 401: WebAuthnErrorSchema, - 403: WebAuthnErrorSchema, - 500: WebAuthnErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: InternalErrorSchema, }, }, }, @@ -91,9 +91,9 @@ webauthnRouter.post( response: { 200: WebAuthnTokenSuccessSchema, - 401: WebAuthnErrorSchema, - 403: WebAuthnErrorSchema, - 500: WebAuthnErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: InternalErrorSchema, }, }, }, diff --git a/src/schemas/admin.createUser.ts b/src/schemas/admin.createUser.ts deleted file mode 100644 index cb5e958..0000000 --- a/src/schemas/admin.createUser.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const CreateUserSchema = z.object({ - email: z.email(), - phone: z.string(), - roles: z.array(z.string()).optional(), -}); diff --git a/src/schemas/admin.sessions.ts b/src/schemas/admin.query.ts similarity index 100% rename from src/schemas/admin.sessions.ts rename to src/schemas/admin.query.ts diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts index fdfc0bf..b9eb4d9 100644 --- a/src/schemas/admin.responses.ts +++ b/src/schemas/admin.responses.ts @@ -1,15 +1,6 @@ +import { UserSchema } from '@seamless-auth/types'; import { z } from 'zod'; -import { UserBaseSchema } from './user.base.js'; - export const UserResponseSchema = z.object({ - user: UserBaseSchema, -}); - -export const SuccessMessageSchema = z.object({ - message: z.literal('Success'), -}); - -export const InternalErrorSchema = z.object({ - message: z.string(), + user: UserSchema, }); diff --git a/src/schemas/auth.responses.ts b/src/schemas/auth.responses.ts index 33795da..f9c3732 100644 --- a/src/schemas/auth.responses.ts +++ b/src/schemas/auth.responses.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -export const LoginSuccessSchema = z.object({ +export const LoginSuccessResponseSchema = z.object({ message: z.string(), token: z.string().optional(), sub: z.string().optional(), @@ -8,11 +8,7 @@ export const LoginSuccessSchema = z.object({ ttl: z.number().optional(), }); -export const LogoutSuccessSchema = z.object({ - message: z.string(), -}); - -export const RefreshSuccessSchema = z.object({ +export const RefreshSuccessResponseSchema = z.object({ message: z.string(), token: z.string().optional(), refreshToken: z.string().optional(), @@ -20,7 +16,3 @@ export const RefreshSuccessSchema = z.object({ ttl: z.number().optional(), refreshTtl: z.number().optional(), }); - -export const AuthErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/schemas/authEvent.schema.ts b/src/schemas/authEvent.schema.ts deleted file mode 100644 index fc0a431..0000000 --- a/src/schemas/authEvent.schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; - -const IsoDate = z.coerce.date().transform((d) => d.toISOString()); - -export const AuthEventSchema = z.object({ - id: z.string(), - - user_id: z.string().nullable().optional(), - - type: z.string(), - - ip_address: z.string().nullable().optional(), - - user_agent: z.string().nullable().optional(), - - metadata: z.record(z.string(), z.unknown()).nullable(), - - created_at: IsoDate, - updated_at: IsoDate, -}); - -export type AuthEvent = z.infer; diff --git a/src/schemas/credential.base.ts b/src/schemas/credential.base.ts deleted file mode 100644 index eedad52..0000000 --- a/src/schemas/credential.base.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod'; - -import { IsoDate } from './user.base.js'; - -export const CredentialBaseSchema = z.object({ - id: z.string(), - transports: z.array(z.string()).nullable().optional(), - deviceType: z.string().nullable().optional(), - backedup: z.boolean().nullable().optional(), - counter: z.number(), - friendlyName: z.string().nullable().optional(), - lastUsedAt: IsoDate.nullable().optional(), - platform: z.string().nullable().optional(), - browser: z.string().nullable().optional(), - deviceInfo: z.string().nullable().optional(), - createdAt: IsoDate, - updatedAt: IsoDate.optional(), -}); - -export type CredentialBase = z.infer; diff --git a/src/schemas/credential.request.ts b/src/schemas/credential.request.ts deleted file mode 100644 index faeb1b6..0000000 --- a/src/schemas/credential.request.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - */ -import { z } from 'zod'; - -export const UpdateCredentialRequestSchema = z.object({ - id: z.string(), - - friendlyName: z.string().min(1).max(128).optional(), - - deviceInfo: z.string().max(256).optional(), -}); - -export type UpdateCredentialRequest = z.infer; - -export const DeleteCredentialRequestSchema = z.object({ - id: z.string(), -}); - -export type DeleteCredentialRequest = z.infer; diff --git a/src/schemas/error.schema.ts b/src/schemas/error.schema.ts deleted file mode 100644 index f504877..0000000 --- a/src/schemas/error.schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from 'zod'; - -export const ErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/schemas/generic.responses.ts b/src/schemas/generic.responses.ts new file mode 100644 index 0000000..007dbab --- /dev/null +++ b/src/schemas/generic.responses.ts @@ -0,0 +1,15 @@ +import z from 'zod'; + +export const MessageSchema = z.object({ + message: z.string(), +}); + +export const ErrorSchema = z.object({ + message: z.string().optional(), + error: z.string(), +}); + +export const InternalErrorSchema = z.object({ + message: z.string().optional(), + error: z.string(), +}); diff --git a/src/schemas/internal.metrics.query.ts b/src/schemas/internal.metrics.query.ts deleted file mode 100644 index 2b44cff..0000000 --- a/src/schemas/internal.metrics.query.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -export const MetricsQuerySchema = z.object({ - userId: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - interval: z.enum(['hour', 'day']).optional().default('hour'), -}); diff --git a/src/schemas/internal.query.ts b/src/schemas/internal.query.ts index dbcbfee..fc8268e 100644 --- a/src/schemas/internal.query.ts +++ b/src/schemas/internal.query.ts @@ -8,7 +8,7 @@ export const PaginationQuerySchema = z.object({ }); export const AuthEventQuerySchema = z.object({ - limit: z.coerce.number().min(1).max(100).default(50), + limit: z.coerce.number().min(1).max(100).default(10), offset: z.coerce.number().min(0).default(0), userId: z.string().optional(), @@ -19,3 +19,10 @@ export const AuthEventQuerySchema = z.object({ from: z.string().optional(), to: z.string().optional(), }); + +export const MetricsQuerySchema = z.object({ + userId: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + interval: z.enum(['hour', 'day']).optional().default('hour'), +}); diff --git a/src/schemas/internal.responses.ts b/src/schemas/internal.responses.ts index 5094b48..249923c 100644 --- a/src/schemas/internal.responses.ts +++ b/src/schemas/internal.responses.ts @@ -1,23 +1,16 @@ +import { AuthEventSchema, UserSchema } from '@seamless-auth/types'; import { z } from 'zod'; -import { AuthEventSchema } from './authEvent.schema.js'; -import { UserBaseSchema } from './user.base.js'; - export const UsersListResponseSchema = z.object({ - users: z.array(UserBaseSchema), + users: z.array(UserSchema), total: z.number(), }); export const AuthEventsResponseSchema = z.object({ events: z.array(AuthEventSchema), + total: z.number(), }); export const CredentialCountSchema = z.object({ count: z.number(), }); - -export const LogsResponseSchema = z.record(z.unknown()); - -export const InternalErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/schemas/magicLink.schema.ts b/src/schemas/magicLink.schema.ts index efb7308..c71e902 100644 --- a/src/schemas/magicLink.schema.ts +++ b/src/schemas/magicLink.schema.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; export const MagicLinkRequestSchema = z.object({ - email: z.string().email(), + email: z.email(), redirect_url: z.string().optional(), }); diff --git a/src/schemas/magiclink.responses.ts b/src/schemas/magiclink.responses.ts index c670471..5028323 100644 --- a/src/schemas/magiclink.responses.ts +++ b/src/schemas/magiclink.responses.ts @@ -1,13 +1,5 @@ import { z } from 'zod'; -export const MagicLinkRequestResponseSchema = z.object({ - message: z.string(), -}); - -export const MagicLinkVerifyResponseSchema = z.object({ - message: z.string(), -}); - export const MagicLinkPollSuccessSchema = z.object({ message: z.string(), token: z.string().optional(), @@ -19,11 +11,3 @@ export const MagicLinkPollSuccessSchema = z.object({ ttl: z.number().optional(), refreshTtl: z.number().optional(), }); - -export const MagicLinkPollPendingSchema = z.object({ - error: z.string(), -}); - -export const MagicLinkErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts new file mode 100644 index 0000000..a7182f5 --- /dev/null +++ b/src/schemas/me.response.ts @@ -0,0 +1,13 @@ +import { CredentialApiSchema, UserSchema } from '@seamless-auth/types'; +import { z } from 'zod'; + +export const MeResponseSchema = z.object({ + user: UserSchema.pick({ + id: true, + email: true, + phone: true, + roles: true, + lastLogin: true, + }), + credentials: z.array(CredentialApiSchema), +}); diff --git a/src/schemas/me.schema.ts b/src/schemas/me.schema.ts deleted file mode 100644 index 143f274..0000000 --- a/src/schemas/me.schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; - -import { CredentialBaseSchema } from './credential.base.js'; -import { UserBaseSchema } from './user.base.js'; - -export const MeUserSchema = UserBaseSchema.pick({ - id: true, - email: true, - phone: true, - roles: true, - lastLogin: true, -}); - -export const CredentialSchema = CredentialBaseSchema.pick({ - id: true, - transports: true, - deviceType: true, - backedup: true, - counter: true, - friendlyName: true, - lastUsedAt: true, - platform: true, - browser: true, - deviceInfo: true, - createdAt: true, -}); - -export const MeResponseSchema = z.object({ - user: MeUserSchema, - credentials: z.array(CredentialSchema), -}); diff --git a/src/schemas/otp.responses.ts b/src/schemas/otp.responses.ts index 84694ad..55641fc 100644 --- a/src/schemas/otp.responses.ts +++ b/src/schemas/otp.responses.ts @@ -1,23 +1,7 @@ import { z } from 'zod'; -export const OTPSuccessSchema = z.object({ - message: z.literal('success'), -}); - -export const OTPVerifySuccessSchema = z.object({ - message: z.literal('Success'), -}); - export const OTPVerifyTokenSuccessSchema = z.object({ - message: z.literal('Success'), + message: z.string(), token: z.string().optional(), refreshTokenHash: z.string().optional(), }); - -export const OTPInvalidSchema = z.object({ - message: z.string(), -}); - -export const OTPServerErrorSchema = z.object({ - message: z.literal('Internal server error'), -}); diff --git a/src/schemas/registration.requests.ts b/src/schemas/registration.requests.ts index be95413..1ea8ceb 100644 --- a/src/schemas/registration.requests.ts +++ b/src/schemas/registration.requests.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; export const RegistrationRequestSchema = z.object({ - email: z.string().email(), + email: z.email(), phone: z.string(), }); diff --git a/src/schemas/registration.responses.ts b/src/schemas/registration.responses.ts index e804e64..e1f7b55 100644 --- a/src/schemas/registration.responses.ts +++ b/src/schemas/registration.responses.ts @@ -6,7 +6,3 @@ export const RegistrationSuccessSchema = z.object({ token: z.string().optional(), ttl: z.string().optional(), }); - -export const RegistrationErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/schemas/session.responses.ts b/src/schemas/session.responses.ts index 0a94cf8..3d782ce 100644 --- a/src/schemas/session.responses.ts +++ b/src/schemas/session.responses.ts @@ -1,23 +1,7 @@ +import { SessionSchema } from '@seamless-auth/types'; import { z } from 'zod'; -export const SessionSchema = z.object({ - id: z.string(), - deviceName: z.string().nullable().optional(), - ipAddress: z.string().nullable().optional(), - userAgent: z.string().nullable().optional(), - lastUsedAt: z.string(), - expiresAt: z.string(), - current: z.boolean(), -}); - export const SessionListResponseSchema = z.object({ sessions: z.array(SessionSchema), -}); - -export const SessionDeleteResponseSchema = z.object({ - message: z.literal('Success'), -}); - -export const SessionErrorSchema = z.object({ - error: z.string(), + total: z.number(), }); diff --git a/src/schemas/user.base.ts b/src/schemas/user.base.ts deleted file mode 100644 index aaf07d5..0000000 --- a/src/schemas/user.base.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const IsoDate = z.coerce.date().transform((d) => d.toISOString()); - -export const UserBaseSchema = z.object({ - id: z.string(), - email: z.email(), - phone: z.string().nullable().optional(), - roles: z.array(z.string()), - lastLogin: IsoDate.optional(), - createdAt: IsoDate, - updatedAt: IsoDate.optional(), - revoked: z.boolean().optional(), - emailVerified: z.boolean().optional(), - phoneVerified: z.boolean().optional(), - verified: z.boolean().optional(), -}); - -export type UserBase = z.infer; diff --git a/src/schemas/user.patch.schema.ts b/src/schemas/user.patch.schema.ts deleted file mode 100644 index 4d7550d..0000000 --- a/src/schemas/user.patch.schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const UpdateUserSchema = z - .object({ - email: z.email().optional(), - phone: z.string().min(5).optional(), - emailVerified: z.boolean().optional(), - phoneVerified: z.boolean().optional(), - roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), - }) - .strict(); diff --git a/src/schemas/user.responses.ts b/src/schemas/user.responses.ts deleted file mode 100644 index d8bd77f..0000000 --- a/src/schemas/user.responses.ts +++ /dev/null @@ -1,5 +0,0 @@ -import z from 'zod'; - -export const DeleteUserResponseSchema = z.object({ - success: z.boolean(), -}); diff --git a/src/schemas/webauthn.responses.ts b/src/schemas/webauthn.responses.ts index 2f44a05..ac4aafb 100644 --- a/src/schemas/webauthn.responses.ts +++ b/src/schemas/webauthn.responses.ts @@ -2,12 +2,8 @@ import { z } from 'zod'; export const WebAuthnChallengeSchema = z.record(z.string(), z.unknown()); -export const WebAuthnSimpleSuccessSchema = z.object({ - message: z.literal('Success'), -}); - export const WebAuthnTokenSuccessSchema = z.object({ - message: z.literal('Success'), + message: z.string(), token: z.string().optional(), refreshToken: z.string().optional(), refreshTokenHash: z.string().optional(), @@ -20,7 +16,3 @@ export const WebAuthnTokenSuccessSchema = z.object({ ttl: z.number().optional(), refreshTtl: z.number().optional(), }); - -export const WebAuthnErrorSchema = z.object({ - message: z.string(), -}); diff --git a/src/utils/getLocalLogs.ts b/src/utils/getLocalLogs.ts deleted file mode 100644 index a910d04..0000000 --- a/src/utils/getLocalLogs.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import getLogger from './logger.js'; - -const logger = getLogger('getLocalLogs'); - -export async function getLocalLogs(find?: string): Promise<{ - events: { - timestamp: number; - message: string; - ingestionTime: EpochTimeStamp; - }[]; - error?: string; -}> { - try { - const filePath = path.resolve('./logs/app.log'); - - if (!fs.existsSync(filePath)) { - return { events: [] }; - } - - const logs = fs.readFileSync(filePath, 'utf8'); - - let lines = logs.split('\n').filter(Boolean); - - // Optional search filter - if (find) { - const search = find.toLowerCase(); - lines = search ? lines.filter((line) => line.toLowerCase().includes(search)) : lines; - } - - const events = lines.slice(-500).map((line) => { - const isoMatch = line.match(/^\d{4}-\d{2}-\d{2}T[^\s]+/); - const timestamp = isoMatch ? Date.parse(isoMatch[0]) : Date.now(); - - return { - timestamp, - message: line, - ingestionTime: timestamp, - }; - }); - - return { events }; - } catch (err) { - logger.error('Error reading logs:', err); - return { events: [], error: 'Failed to read logs' }; - } -} diff --git a/src/utils/signingKeyStore.ts b/src/utils/signingKeyStore.ts index 329f17c..e704239 100644 --- a/src/utils/signingKeyStore.ts +++ b/src/utils/signingKeyStore.ts @@ -61,7 +61,7 @@ function ensureDevKeys() { async function loadProdSigningKey(): Promise { const now = Date.now(); - logger.info('Refreshing signing key from Secrets Manager'); + logger.info('Refreshing signing key from env'); const activeKid = await getSecret(`${jwksPrefix}_ACTIVE_KID`); const privateKeySecretName = `${jwksPrefix}_KEY_${activeKid}_PRIVATE`; @@ -124,7 +124,6 @@ export async function getPublicKeyByKid(kid: string): Promise { return cached.pem; } - // TTL expired or not in cache → reload entire secret await loadAllPublicKeys(); return publicKeyCache[kid]?.pem ?? null; From acb0d8ecbf115349e6b6a82b4a32db57fdac931b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 25 Mar 2026 09:56:21 -0400 Subject: [PATCH 4/7] feat: require admin and session updates + seamless auth types --- .github/FUNDING.yml | 2 +- package-lock.json | 654 +++++++++++---------- package.json | 3 +- src/controllers/admin.ts | 320 ++++++++-- src/controllers/internal.ts | 227 ------- src/controllers/internalTimeSeries.ts | 0 src/middleware/authenticateServiceToken.ts | 19 +- src/middleware/requireAdmin.ts | 36 ++ src/middleware/verifyBearerAuth.ts | 4 +- src/middleware/verifyCookieAuth.ts | 41 +- src/routes/admin.routes.ts | 79 ++- src/routes/admin.sessions.routes.ts | 7 +- src/routes/internal.routes.ts | 74 +-- src/routes/systemConfig.routes.ts | 9 +- src/routes/users.routes.ts | 7 + src/services/sessionService.ts | 177 +++--- src/types/types.ts | 1 + 17 files changed, 859 insertions(+), 801 deletions(-) delete mode 100644 src/controllers/internal.ts delete mode 100644 src/controllers/internalTimeSeries.ts create mode 100644 src/middleware/requireAdmin.ts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0234f80..94cb2d8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: "@fells-code" \ No newline at end of file +github: '@fells-code' diff --git a/package-lock.json b/package-lock.json index 4b6717c..3b853b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", + "@seamless-auth/types": "^0.1.2", "@sequelize/postgres": "^7.0.0-alpha.46", "@simplewebauthn/server": "^13.1.1", "@types/bcrypt": "^6.0.0", @@ -64,9 +65,9 @@ } }, "node_modules/@asteasolutions/zod-to-openapi": { - "version": "8.4.3", - "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.4.3.tgz", - "integrity": "sha512-lwfMTN7kDbFDwMniYZUebiGGHxVGBw9ZSI4IBYjm6Ey22Kd5z/fsQb2k+Okr8WMbCCC553vi/ZM9utl5/XcvuQ==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", "license": "MIT", "dependencies": { "openapi3-ts": "^4.1.2" @@ -118,9 +119,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -166,18 +167,28 @@ "node": ">=0.1.90" } }, + "node_modules/@commander-js/extra-typings": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", + "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~13.1.0" + } + }, "node_modules/@commitlint/cli": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.4.tgz", - "integrity": "sha512-GLMNQHYGcn0ohL2HMlAnXcD1PS2vqBBGbYKlhrRPOYsWiRoLWtrewsR3uKRb9v/IdS+qOS0vqJQ64n1g8VPKFw==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", + "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.4.4", - "@commitlint/lint": "^20.4.4", - "@commitlint/load": "^20.4.4", - "@commitlint/read": "^20.4.4", - "@commitlint/types": "^20.4.4", + "@commitlint/format": "^20.5.0", + "@commitlint/lint": "^20.5.0", + "@commitlint/load": "^20.5.0", + "@commitlint/read": "^20.5.0", + "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, @@ -189,13 +200,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.4.tgz", - "integrity": "sha512-Usg+XXbPNG2GtFWTgRURNWCge1iH1y6jQIvvklOdAbyn2t8ajfVwZCnf5t5X4gUsy17BOiY+myszGsSMIvhOVA==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", + "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -203,13 +214,13 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.4.tgz", - "integrity": "sha512-K8hMS9PTLl7EYe5vWtSFQ/sgsV2PHUOtEnosg8k3ZQxCyfKD34I4C7FxWEfRTR54rFKeUYmM3pmRQqBNQeLdlw==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", + "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "ajv": "^8.11.0" }, "engines": { @@ -217,13 +228,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.4.tgz", - "integrity": "sha512-QivV0M1MGL867XCaF+jJkbVXEPKBALhUUXdjae66hes95aY1p3vBJdrcl3x8jDv2pdKWvIYIz+7DFRV/v0dRkA==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", + "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", @@ -245,13 +256,13 @@ } }, "node_modules/@commitlint/format": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.4.tgz", - "integrity": "sha512-jLi/JBA4GEQxc5135VYCnkShcm1/rarbXMn2Tlt3Si7DHiiNKHm4TaiJCLnGbZ1r8UfwDRk+qrzZ80kwh08Aow==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", + "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "picocolors": "^1.1.1" }, "engines": { @@ -259,13 +270,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.4.tgz", - "integrity": "sha512-y76rT8yq02x+pMDBI2vY4y/ByAwmJTkta/pASbgo8tldBiKLduX8/2NCRTSCjb3SumE5FBeopERKx3oMIm8RTQ==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", + "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "semver": "^7.6.0" }, "engines": { @@ -273,32 +284,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.4.tgz", - "integrity": "sha512-svOEW+RptcNpXKE7UllcAsV0HDIdOck9reC2TP1QA6K5Fo0xxQV+QPjV8Zqx9g6X/hQBkF2S9ZQZ78Xrv1Eiog==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", + "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.4.4", - "@commitlint/parse": "^20.4.4", - "@commitlint/rules": "^20.4.4", - "@commitlint/types": "^20.4.4" + "@commitlint/is-ignored": "^20.5.0", + "@commitlint/parse": "^20.5.0", + "@commitlint/rules": "^20.5.0", + "@commitlint/types": "^20.5.0" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/load": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.4.tgz", - "integrity": "sha512-kvFrzvoIACa/fMjXEP0LNEJB1joaH3q3oeMJsLajXE5IXjYrNGVcW1ZFojXUruVJ7odTZbC3LdE/6+ONW4f2Dg==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", + "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.4", + "@commitlint/config-validator": "^20.5.0", "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.4.4", - "@commitlint/types": "^20.4.4", + "@commitlint/resolve-extends": "^20.5.0", + "@commitlint/types": "^20.5.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "is-plain-obj": "^4.1.0", @@ -320,13 +331,13 @@ } }, "node_modules/@commitlint/parse": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.4.tgz", - "integrity": "sha512-AjfgOgrjEozeQNzhFu1KL5N0nDx4JZmswVJKNfOTLTUGp6xODhZHCHqb//QUHKOzx36If5DQ7tci2o7szYxu1A==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", + "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, @@ -335,14 +346,14 @@ } }, "node_modules/@commitlint/read": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.4.tgz", - "integrity": "sha512-jvgdAQDdEY6L8kCxOo21IWoiAyNFzvrZb121wU2eBxI1DzWAUZgAq+a8LlJRbT0Qsj9INhIPVWgdaBbEzlF0dQ==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", + "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.4.4", + "@commitlint/types": "^20.5.0", "git-raw-commits": "^5.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" @@ -352,14 +363,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.4.tgz", - "integrity": "sha512-pyOf+yX3c3m/IWAn2Jop+7s0YGKPQ8YvQaxt9IQxnLIM3yZAlBdkKiQCT14TnrmZTkVGTXiLtckcnFTXYwlY0A==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", + "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.4", - "@commitlint/types": "^20.4.4", + "@commitlint/config-validator": "^20.5.0", + "@commitlint/types": "^20.5.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", @@ -370,16 +381,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.4.tgz", - "integrity": "sha512-PmUp8QPLICn9w05dAx5r1rdOYoTk7SkfusJJh5tP3TqHwo2mlQ9jsOm8F0HSXU9kuLfgTEGNrunAx/dlK/RyPQ==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", + "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.4.4", + "@commitlint/ensure": "^20.5.0", "@commitlint/message": "^20.4.3", "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.4.4" + "@commitlint/types": "^20.5.0" }, "engines": { "node": ">=v18" @@ -409,9 +420,9 @@ } }, "node_modules/@commitlint/types": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.4.tgz", - "integrity": "sha512-dwTGzyAblFXHJNBOgrTuO5Ee48ioXpS5XPRLLatxhQu149DFAHUcB3f0Q5eea3RM4USSsP1+WVT2dBtLVod4fg==", + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", + "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", "dev": true, "license": "MIT", "dependencies": { @@ -1287,20 +1298,10 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "license": "MIT" }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "license": "MIT", "funding": { @@ -1500,9 +1501,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -1517,9 +1518,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -1534,9 +1535,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -1551,9 +1552,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -1568,9 +1569,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -1585,9 +1586,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -1605,9 +1606,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -1625,9 +1626,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -1645,9 +1646,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -1665,9 +1666,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -1685,9 +1686,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -1705,9 +1706,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -1722,9 +1723,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -1739,9 +1740,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -1756,9 +1757,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -1773,9 +1774,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true, "license": "MIT" }, @@ -1786,6 +1787,18 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@seamless-auth/types": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.1.2.tgz", + "integrity": "sha512-e+cglrZORKIwIwnaIuKy6vBEt7oS+NAeZIrOY/HGI2wipfFp7G8+9RhOQV+Wt2fMGJwAnnEyHEXNsEQXF+acJw==", + "license": "AGPL-3.0", + "dependencies": { + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@sequelize/core": { "version": "7.0.0-alpha.48", "resolved": "https://registry.npmjs.org/@sequelize/core/-/core-7.0.0-alpha.48.tgz", @@ -2025,9 +2038,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { "@types/ms": "*" @@ -2141,9 +2154,9 @@ } }, "node_modules/@types/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -2301,17 +2314,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -2324,22 +2337,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "engines": { @@ -2355,14 +2368,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "engines": { @@ -2377,14 +2390,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2395,9 +2408,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "license": "MIT", "engines": { @@ -2412,15 +2425,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -2437,9 +2450,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -2451,16 +2464,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2479,16 +2492,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2503,13 +2516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2534,14 +2547,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", + "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2555,8 +2568,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.1", + "vitest": "4.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2565,16 +2578,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, @@ -2583,13 +2596,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2598,7 +2611,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2610,9 +2623,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2623,13 +2636,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" }, "funding": { @@ -2637,14 +2650,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2653,9 +2666,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "dev": true, "license": "MIT", "funding": { @@ -2663,13 +2676,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -2973,9 +2986,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3278,13 +3291,13 @@ } }, "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/compare-func": { @@ -3753,26 +3766,6 @@ "node": ">=20.10.0" } }, - "node_modules/env-cmd/node_modules/@commander-js/extra-typings": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", - "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "commander": "~13.1.0" - } - }, - "node_modules/env-cmd/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3930,16 +3923,16 @@ } }, "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", + "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", @@ -3952,7 +3945,7 @@ "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4708,9 +4701,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5215,9 +5208,9 @@ } }, "node_modules/jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5696,6 +5689,16 @@ "url": "https://opencollective.com/lint-staged" } }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -5997,9 +6000,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -6585,9 +6588,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6936,14 +6939,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -6952,21 +6955,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/run-parallel": { @@ -7734,9 +7737,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.32.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", - "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "version": "5.32.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz", + "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -7860,9 +7863,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -8013,16 +8016,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8142,17 +8145,16 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "bin": { @@ -8169,7 +8171,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", + "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8221,19 +8223,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8245,7 +8247,7 @@ "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8261,13 +8263,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -8553,9 +8555,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 0cf1873..787c17f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "homepage": "https://github.com/fells-code/seamless-auth-api#readme", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", + "@seamless-auth/types": "^0.1.2", "@sequelize/postgres": "^7.0.0-alpha.46", "@simplewebauthn/server": "^13.1.1", "@types/bcrypt": "^6.0.0", @@ -86,4 +87,4 @@ "typescript-eslint": "^8.56.0", "vitest": "^4.0.3" } -} \ No newline at end of file +} diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index a48efd4..93a15e0 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -1,4 +1,4 @@ -import { CreateUserSchema } from '@seamless-auth/types'; +import { CreateUserSchema, UpdateUserSchema } from '@seamless-auth/types'; import { Request, Response } from 'express'; import { Op } from 'sequelize'; @@ -7,12 +7,54 @@ import { Credential } from '../models/credentials.js'; import { sequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; +import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { AuthEventService } from '../services/authEventService.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('admin'); +export const getUsers = async (req: ServiceRequest, res: Response) => { + const { limit = 50, offset = 0, search } = req.query; + + const where: any = {}; + + if (search) { + where[Op.or] = [ + { email: { [Op.iLike]: `%${search}%` } }, + { phone: { [Op.iLike]: `%${search}%` } }, + ]; + } + + const [users, total] = await Promise.all([ + await User.findAll({ + where, + attributes: [ + 'id', + 'email', + 'phone', + 'revoked', + 'emailVerified', + 'phoneVerified', + 'verified', + 'lastLogin', + 'roles', + 'createdAt', + 'updatedAt', + ], + limit: Number(limit), + offset: Number(offset), + }), + User.count({ where }), + ]); + + return res.json({ + users: users ?? [], + total, + }); +}; + export const createUser = async (req: Request, res: Response) => { const parsed = CreateUserSchema.safeParse(req.body); @@ -44,70 +86,91 @@ export const createUser = async (req: Request, res: Response) => { } }; -export const listUserSessions = async (req: Request, res: Response) => { - const { userId } = req.params; +export const deleteUser = async (req: ServiceRequest, res: Response) => { + logger.info('Internal deletion call made.'); + const { userId } = req.body; try { - const sessions = await Session.findAll({ - where: { - userId, - revokedAt: null, - }, - }); + if (!userId) { + return res.status(404).json({ message: 'User not found.' }); + } - return res.json({ - sessions: sessions.map((s) => ({ - id: s.id, - deviceName: s.deviceName, - ipAddress: s.ipAddress, - userAgent: s.userAgent, - lastUsedAt: s.lastUsedAt, - expiresAt: s.expiresAt, - })), - }); - } catch (err) { - logger.error(`Failed to fetch sessions: ${err}`); - return res.status(500).json({ message: 'Failed to fetch sessions' }); + try { + const user = await User.findOne({ + where: { + id: userId, + }, + }); + + if (user) { + user.destroy(); + logger.info(`User ${user.email} deleted from database through the seamless auth portal.`); + } else { + logger.error(`Failed to destory a seemingly valid user via the portal`); + } + + return res.status(200).json({ message: 'Success' }); + } catch (error: unknown) { + logger.error(`Failed to delete user: ${userId}. Error: ${error}`); + return res.status(500).json({ message: 'Failed' }); + } + } catch (error) { + logger.error(`Error occured deleting a user: ${error}`); + return res.status(500).json({ message: `Failed` }); } }; -export const revokeAllUserSessions = async (req: Request, res: Response) => { +export const updateUser = async (req: ServiceRequest, res: Response) => { const { userId } = req.params; - try { - const sessions = await Session.findAll({ - where: { - userId, - revokedAt: null, - }, - }); - - for (const session of sessions) { - await hardRevokeSession(session, 'admin_revoke_all'); - } + if (!userId) { + logger.error('Missing user id for updating user'); + return res.status(400).json({ message: 'Bad request' }); + } - logger.info(`All sessions revoked for user ${userId}`); + const parsed = UpdateUserSchema.safeParse(req.body); - return res.json({ message: 'Success' }); - } catch (err) { - logger.error(`Failed to revoke sessions: ${err}`); - return res.status(500).json({ message: 'Failed to revoke sessions' }); + if (!parsed.success || Object.keys(parsed.data).length === 0) { + logger.error(`Failed to parse update user body. ${JSON.stringify(req.body)}`); + return res.status(400).json({ + message: 'Invalid update payload', + details: parsed.error, + }); } -}; -export const listAllSessions = async (req: Request, res: Response) => { - const { limit = 10, offset = 0 } = req.query; + try { + const user = await User.findByPk(userId); - const [sessions, total] = await Promise.all([ - Session.findAll({ - where: { revokedAt: null }, - limit: Number(limit), - offset: Number(offset), - }), - Session.count({ where: { revokedAt: null } }), - ]); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } - return res.json({ sessions, total }); + const before = user.toJSON(); + + try { + await user.update(parsed.data); + + await AuthEventService.log({ + type: 'internal_user_updated_by_owner', + req, + metadata: { + before, + after: parsed.data, + targetUser: userId, + }, + }); + } catch (error) { + logger.error(`Failed to update user ${error}`); + res.status(500).json({ message: 'Failed to update user' }); + return; + } + + res.status(200).json({ user }); + return; + } catch { + logger.error('Failed to find user'); + res.status(400).json({ message: 'Could not update users' }); + } }; export const getUserDetail = async (req: ServiceRequest, res: Response) => { @@ -176,6 +239,74 @@ export const getUserAnomalies = async (req: Request, res: Response) => { } }; +// TODO: Need a public session return type for sessions +export const listUserSessions = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const sessions = await Session.findAll({ + where: { + userId, + revokedAt: null, + }, + }); + + return res.json({ + sessions: sessions.map((s) => ({ + id: s.id, + deviceName: s.deviceName, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + lastUsedAt: s.lastUsedAt, + expiresAt: s.expiresAt, + })), + }); + } catch (err) { + logger.error(`Failed to fetch sessions: ${err}`); + return res.status(500).json({ message: 'Failed to fetch sessions' }); + } +}; + +export const revokeAllUserSessions = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const sessions = await Session.findAll({ + where: { + userId, + revokedAt: null, + }, + }); + + for (const session of sessions) { + await hardRevokeSession(session, 'admin_revoke_all'); + } + + logger.info(`All sessions revoked for user ${userId}`); + + return res.json({ message: 'Success' }); + } catch (err) { + logger.error(`Failed to revoke sessions: ${err}`); + return res.status(500).json({ message: 'Failed to revoke sessions' }); + } +}; + +// TODO: Need a public session return type for sessions +export const listAllSessions = async (req: Request, res: Response) => { + const { limit = 10, offset = 0 } = req.query; + + const [sessions, total] = await Promise.all([ + Session.findAll({ + where: { revokedAt: null }, + limit: Number(limit), + offset: Number(offset), + }), + Session.count({ where: { revokedAt: null } }), + ]); + + return res.json({ sessions, total }); +}; + export const getDatabaseSize = async () => { const [result] = await sequelize.query(` SELECT pg_database_size(current_database()) as size @@ -183,3 +314,90 @@ export const getDatabaseSize = async () => { return Number((result as any)[0].size); }; + +function expandType(type?: string): string[] { + if (!type) return []; + + if (type === 'login') return ['login_success', 'login_failed']; + if (type === 'otp') return ['otp_success', 'otp_failed']; + if (type === 'webauthn') return ['webauthn_login_success', 'webauthn_login_failed']; + if (type === 'magicLink') return ['magic_link_success', 'magic_link_requested']; + + if (type === 'suspicious') + return [ + 'login_suspicious', + 'otp_suspicious', + 'webauthn_login_suspicious', + 'verify_otp_suspicious', + 'service_token_suspicious', + ]; + + return [type]; +} + +export const getAuthEvents = async (req: ServiceRequest, res: Response) => { + const parsed = AuthEventQuerySchema.safeParse(req.query); + + if (!parsed.success) { + return res.status(400).json({ message: 'Invalid query params' }); + } + + const { limit, offset, userId, type, from, to } = parsed.data; + + const where: any = {}; + + if (type) { + const raw = Array.isArray(type) ? req.query.type : [req.query.type]; + + const expanded = raw.flatMap(expandType); + + where.type = { + [Op.in]: expanded, + }; + } + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + + if (userId) where.user_id = userId; + + if (from || to) { + where.created_at = {}; + if (from) where.created_at[Op.gte] = new Date(from); + if (to) where.created_at[Op.lte] = new Date(to); + } + + try { + const [events, total] = await Promise.all([ + AuthEvent.findAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }), + AuthEvent.count({ + where, + }), + ]); + + return res.json({ events, total }); + } catch (err) { + logger.error(`Failed to fetch auth events: ${err}`); + res.status(500).json({ message: 'Failed to fetch events' }); + } +}; + +export const getCredentialsCount = async (req: ServiceRequest, res: Response) => { + logger.info('Internal credential count call made.'); + try { + const credentialCount = await Credential.count(); + + return res.json({ count: credentialCount || 0 }); + } catch (err) { + logger.error(`Failed to fetch credential count: ${err}`); + res.status(500).json({ message: 'Failed to fetch credential count' }); + } +}; diff --git a/src/controllers/internal.ts b/src/controllers/internal.ts deleted file mode 100644 index 2d41671..0000000 --- a/src/controllers/internal.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { UpdateUserSchema } from '@seamless-auth/types'; -import { Response } from 'express'; -import { Op } from 'sequelize'; - -import { AuthEvent } from '../models/authEvents.js'; -import { Credential } from '../models/credentials.js'; -import { User } from '../models/users.js'; -import { AuthEventQuerySchema } from '../schemas/internal.query.js'; -import { AuthEventService } from '../services/authEventService.js'; -import { ServiceRequest } from '../types/types.js'; -import getLogger from '../utils/logger.js'; - -const logger = getLogger('internal'); - -function expandType(type?: string): string[] { - if (!type) return []; - - if (type === 'login') return ['login_success', 'login_failed']; - if (type === 'otp') return ['otp_success', 'otp_failed']; - if (type === 'webauthn') return ['webauthn_login_success', 'webauthn_login_failed']; - if (type === 'magicLink') return ['magic_link_success', 'magic_link_requested']; - - if (type === 'suspicious') - return [ - 'login_suspicious', - 'otp_suspicious', - 'webauthn_login_suspicious', - 'verify_otp_suspicious', - 'service_token_suspicious', - ]; - - return [type]; -} - -export const getUsers = async (req: ServiceRequest, res: Response) => { - const { limit = 50, offset = 0, search } = req.query; - - const where: any = {}; - - if (search) { - where[Op.or] = [ - { email: { [Op.iLike]: `%${search}%` } }, - { phone: { [Op.iLike]: `%${search}%` } }, - ]; - } - - const [users, total] = await Promise.all([ - await User.findAll({ - where, - attributes: [ - 'id', - 'email', - 'phone', - 'revoked', - 'emailVerified', - 'phoneVerified', - 'verified', - 'lastLogin', - 'roles', - 'createdAt', - 'updatedAt', - ], - limit: Number(limit), - offset: Number(offset), - }), - User.count({ where }), - ]); - - return res.json({ - users: users ?? [], - total, - }); -}; - -export const getAuthEvents = async (req: ServiceRequest, res: Response) => { - const parsed = AuthEventQuerySchema.safeParse(req.query); - - if (!parsed.success) { - return res.status(400).json({ message: 'Invalid query params' }); - } - - const { limit, offset, userId, type, from, to } = parsed.data; - - const where: any = {}; - - if (type) { - const raw = Array.isArray(type) ? req.query.type : [req.query.type]; - - const expanded = raw.flatMap(expandType); - - where.type = { - [Op.in]: expanded, - }; - } - - if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); - } - - if (userId) where.user_id = userId; - - if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); - } - - try { - const [events, total] = await Promise.all([ - AuthEvent.findAll({ - where, - order: [['created_at', 'DESC']], - limit, - offset, - }), - AuthEvent.count({ - where, - }), - ]); - - return res.json({ events, total }); - } catch (err) { - logger.error(`Failed to fetch auth events: ${err}`); - res.status(500).json({ message: 'Failed to fetch events' }); - } -}; - -export const getCredentialsCount = async (req: ServiceRequest, res: Response) => { - logger.info('Internal credential count call made.'); - try { - const credentialCount = await Credential.count(); - - return res.json({ count: credentialCount || 0 }); - } catch (err) { - logger.error(`Failed to fetch credential count: ${err}`); - res.status(500).json({ message: 'Failed to fetch credential count' }); - } -}; - -export const deleteUser = async (req: ServiceRequest, res: Response) => { - logger.info('Internal deletion call made.'); - const { userId } = req.body; - - try { - if (!userId) { - return res.status(404).json({ message: 'User not found.' }); - } - - try { - const user = await User.findOne({ - where: { - id: userId, - }, - }); - - if (user) { - user.destroy(); - logger.info(`User ${user.email} deleted from database through the seamless auth portal.`); - } else { - logger.error(`Failed to destory a seemingly valid user via the portal`); - } - - return res.status(200).json({ message: 'Success' }); - } catch (error: unknown) { - logger.error(`Failed to delete user: ${userId}. Error: ${error}`); - return res.status(500).json({ message: 'Failed' }); - } - } catch (error) { - logger.error(`Error occured deleting a user: ${error}`); - return res.status(500).json({ message: `Failed` }); - } -}; - -export const updateUser = async (req: ServiceRequest, res: Response) => { - const { userId } = req.params; - - if (!userId) { - logger.error('Missing user id for updating user'); - return res.status(400).json({ message: 'Bad request' }); - } - - const parsed = UpdateUserSchema.safeParse(req.body); - - if (!parsed.success || Object.keys(parsed.data).length === 0) { - logger.error(`Failed to parse update user body. ${JSON.stringify(req.body)}`); - return res.status(400).json({ - message: 'Invalid update payload', - details: parsed.error, - }); - } - - try { - const user = await User.findByPk(userId); - - if (!user) { - return res.status(404).json({ message: 'User not found' }); - } - - const before = user.toJSON(); - - try { - await user.update(parsed.data); - - await AuthEventService.log({ - type: 'internal_user_updated_by_owner', - req, - metadata: { - before, - after: parsed.data, - targetUser: userId, - }, - }); - } catch (error) { - logger.error(`Failed to update user ${error}`); - res.status(500).json({ message: 'Failed to update user' }); - return; - } - - res.status(200).json({ user }); - return; - } catch { - logger.error('Failed to find user'); - res.status(400).json({ message: 'Could not update users' }); - } -}; diff --git a/src/controllers/internalTimeSeries.ts b/src/controllers/internalTimeSeries.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/middleware/authenticateServiceToken.ts b/src/middleware/authenticateServiceToken.ts index c922090..f4fe160 100644 --- a/src/middleware/authenticateServiceToken.ts +++ b/src/middleware/authenticateServiceToken.ts @@ -11,9 +11,22 @@ import { getSecret } from '../utils/secretsStore.js'; const logger = getLogger('authenticateServiceToken'); +let cachedSecret: string | null = null; + +async function getInternalSecret() { + if (cachedSecret) return cachedSecret; + cachedSecret = await getSecret('API_SERVICE_TOKEN'); + return cachedSecret; +} + export async function verifyServiceToken(req: ServiceRequest, res: Response, next: NextFunction) { - const JWT_INTERNAL = await getSecret('API_SERVICE_TOKEN'); + const JWT_INTERNAL = await getInternalSecret(); const authHeader = req.headers.authorization || ''; + + if (!authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Malformed authorization header' }); + } + const token = authHeader.replace('Bearer ', ''); if (!token) { @@ -34,6 +47,10 @@ export async function verifyServiceToken(req: ServiceRequest, res: Response, nex return res.status(403).json({ error: 'Invalid token issuer' }); } + if (decoded.aud !== 'seamless-auth') { + return res.status(403).json({ error: 'Invalid audience' }); + } + req.clientId = decoded.sub; req.triggeredBy = req.params.triggeredBy; next(); diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts new file mode 100644 index 0000000..216d07f --- /dev/null +++ b/src/middleware/requireAdmin.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + */ +import { NextFunction, Response } from 'express'; + +import { AuthenticatedRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('requireAdmin'); + +export function requireAdmin() { + return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + if (!req.clientId) { + logger.error('Admin route hit without service identity'); + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (!req.user) { + logger.error('Admin route hit without authenticated user'); + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (!req.user.roles?.includes('admin')) { + logger.warn(`User ${req.user.id} attempted admin access without admin role`); + return res.status(403).json({ error: 'Forbidden' }); + } + + next(); + } catch (err) { + logger.error(`requireAdmin failure: ${err}`); + return res.status(500).json({ error: 'Internal server error' }); + } + }; +} diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index 116d219..5357cf9 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -4,7 +4,7 @@ */ import { NextFunction, Request, Response } from 'express'; -import { validateSession } from '../services/sessionService.js'; +import { validateBearerToken } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -19,7 +19,7 @@ export async function verifyBearerAuth(req: Request, res: Response, next: NextFu const token = auth.slice(7); try { - const user = await validateSession({ type: 'bearer', value: token }); + const user = await validateBearerToken(token); if (!user) { logger.error('No user found for service bearer token'); return res.status(401).json({ error: 'unauthorized' }); diff --git a/src/middleware/verifyCookieAuth.ts b/src/middleware/verifyCookieAuth.ts index 261f9ff..3f7d99a 100644 --- a/src/middleware/verifyCookieAuth.ts +++ b/src/middleware/verifyCookieAuth.ts @@ -13,9 +13,12 @@ import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; import { CookieType, + getUserFromSession, hardRevokeSession, revokeSessionChain, - validateSession, + validateAccessToken, + validateSessionRecord, + verifyJwtWithKid, } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -35,10 +38,12 @@ export function verifyCookieAuth(cookieType: CookieType = 'access') { clearAuthCookies(res); return res.status(401).json({ error: 'unauthorized' }); } - const user = await validateSession({ - type: 'cookie', - value: ephemeralCookie, - cookieType: 'ephemeral', + + const payload = await verifyJwtWithKid(ephemeralCookie, cookieType); + if (!payload) return null; + + const user = await User.findOne({ + where: { id: payload.sub, revoked: false }, }); if (!user) { @@ -55,15 +60,23 @@ export function verifyCookieAuth(cookieType: CookieType = 'access') { // Try validating existing access token first if (accessCookie) { logger.debug(`Validating access cookie`); - const user = await validateSession({ - type: 'cookie', - value: accessCookie, - cookieType: 'access', - }); + const accessCookie = cookies['seamless_access']; + + if (accessCookie) { + const tokenData = await validateAccessToken(accessCookie); + + if (tokenData) { + const session = await validateSessionRecord(tokenData.sessionId as string); + + if (session) { + const user = await getUserFromSession(session); - if (user) { - (req as AuthenticatedRequest).user = user; - return next(); + if (user) { + (req as AuthenticatedRequest).user = user; + return next(); + } + } + } } } @@ -98,13 +111,13 @@ async function performSilentRefresh(req: Request, res: Response): Promise { - try { - let payload: JwtPayload | string | null = null; - - if (type === 'cookie') { - payload = await verifyJwtWithKid(value, cookieType); - if (!payload) return null; - - if (cookieType === 'ephemeral') { - const user = await User.findOne({ - where: { id: payload.sub, revoked: false }, - }); - return user ?? null; - } - - if (cookieType === 'access') { - const { sub: userId, sid: sessionId, typ } = payload; - - if (!userId || !sessionId || typ !== 'access') { - logger.warn('Access token missing required claims'); - return null; - } - - const session = await Session.findByPk(sessionId); - if (!session) { - logger.warn(`No session found for sid=${sessionId}`); - return null; - } - - const now = new Date(); - - if (session.revokedAt) { - logger.warn(`Session ${sessionId} revoked`); - return null; - } - - if (session.replacedBySessionId) { - logger.warn(`Session ${sessionId} rotated → reuse detected`); - await revokeSessionChain(session); - return null; - } - - if (session.expiresAt < now) { - logger.warn(`Session ${sessionId} expired`); - return null; - } - - if (session.idleExpiresAt < now) { - logger.warn(`Session ${sessionId} idle timeout`); - return null; - } - - const user = await User.findOne({ - where: { id: userId, revoked: false }, - }); - return user ?? null; - } - } - - if (type === 'bearer') { - const serviceSecret = await getSecret('API_SERVICE_TOKEN'); - - try { - payload = jwt.verify(value, serviceSecret, { - issuer: process.env.APP_ORIGIN, - audience: process.env.ISSUER, - }); - } catch (err: Error | unknown) { - if (err instanceof Error && err.name === 'TokenExpiredError') { - logger.info(`Expired bearer token`); - } else { - logger.error(`Bearer token verification error: ${err}`); - } - return null; - } - - const user = await User.findOne({ - where: { id: payload.sub as string, revoked: false }, - }); - - return user ?? null; - } - - return null; - } catch (err) { - console.error('[validateSession] failed:', err); - return null; - } -} - export async function revokeSessionChain(session: Session, reason = 'refresh_token_reuse') { const now = new Date(); const seen = new Set(); @@ -191,3 +101,70 @@ export async function hardRevokeSession(session: Session, reason = 'manual_revok session.revokedReason = reason; await session.save(); } + +export async function validateAccessToken(token: string) { + const payload = await verifyJwtWithKid(token, 'access'); + if (!payload) return null; + + const { sub: userId, sid: sessionId } = payload; + + if (!userId || !sessionId) return null; + + return { + userId, + sessionId, + roles: payload.roles || [], + }; +} + +export async function validateSessionRecord(sessionId: string) { + const session = await Session.findByPk(sessionId); + if (!session) return null; + + const now = new Date(); + + if (session.revokedAt) return null; + + if (session.replacedBySessionId) { + await revokeSessionChain(session); + return null; + } + + if (session.expiresAt < now) return null; + if (session.idleExpiresAt < now) return null; + + return session; +} + +export async function getUserFromSession(session: Session) { + const user = await User.findOne({ + where: { id: session.userId, revoked: false }, + }); + + return user ?? null; +} + +export async function validateBearerToken(token: string) { + const serviceSecret = await getInternalSecret(); + let payload; + + try { + payload = jwt.verify(token, serviceSecret, { + issuer: process.env.APP_ORIGIN, + audience: process.env.ISSUER, + }); + } catch (err: Error | unknown) { + if (err instanceof Error && err.name === 'TokenExpiredError') { + logger.info(`Expired bearer token`); + } else { + logger.error(`Bearer token verification error: ${err}`); + } + return null; + } + + const user = await User.findOne({ + where: { id: payload.sub as string, revoked: false }, + }); + + return user ?? null; +} diff --git a/src/types/types.ts b/src/types/types.ts index 0871003..2514d1c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -10,6 +10,7 @@ import { User } from '../models/users.js'; export interface AuthenticatedRequest extends Request { user: User; sessionId: Session['id']; + clientId?: string; } export interface ServiceRequest extends Request { clientId?: string | (() => string); From 43a2ffd3d098e06f0daf56f463c5d9c284f04f7f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 25 Mar 2026 12:20:10 -0400 Subject: [PATCH 5/7] chore: linting fixes --- src/controllers/admin.ts | 53 +++++++++-------- src/controllers/internalMetrics.ts | 96 ++++++++++++++++++++---------- 2 files changed, 92 insertions(+), 57 deletions(-) diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 93a15e0..3c16f60 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -1,8 +1,8 @@ import { CreateUserSchema, UpdateUserSchema } from '@seamless-auth/types'; import { Request, Response } from 'express'; -import { Op } from 'sequelize'; +import { Op, WhereOptions } from 'sequelize'; -import { AuthEvent } from '../models/authEvents.js'; +import { AuthEvent, AuthEventAttributes } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { sequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; @@ -18,14 +18,14 @@ const logger = getLogger('admin'); export const getUsers = async (req: ServiceRequest, res: Response) => { const { limit = 50, offset = 0, search } = req.query; - const where: any = {}; - - if (search) { - where[Op.or] = [ - { email: { [Op.iLike]: `%${search}%` } }, - { phone: { [Op.iLike]: `%${search}%` } }, - ]; - } + const where: WhereOptions = search + ? { + [Op.or]: [ + { email: { [Op.iLike]: `%${search}%` } }, + { phone: { [Op.iLike]: `%${search}%` } }, + ], + } + : {}; const [users, total] = await Promise.all([ await User.findAll({ @@ -82,6 +82,7 @@ export const createUser = async (req: Request, res: Response) => { return res.status(201).json({ user }); } catch (err) { + logger.error(`Failed to create user. Reason: ${err}`); return res.status(500).json({ message: 'Failed to create user' }); } }; @@ -213,8 +214,11 @@ export const getUserAnomalies = async (req: Request, res: Response) => { attributes: ['ip_address', 'user_agent'], }); - const ips = [...new Set(userEvents.map((e) => e.ip_address).filter(Boolean))]; - const agents = [...new Set(userEvents.map((e) => e.user_agent).filter(Boolean))]; + const ips = [...new Set(userEvents.map((e) => e.ip_address).filter((v): v is string => !!v))]; + + const agents = [ + ...new Set(userEvents.map((e) => e.user_agent).filter((v): v is string => !!v)), + ]; const suspiciousEvents = await AuthEvent.findAll({ where: { @@ -312,7 +316,8 @@ export const getDatabaseSize = async () => { SELECT pg_database_size(current_database()) as size `); - return Number((result as any)[0].size); + // TODO: Properly type this one day + return Number((result as { size: string }[])[0].size); }; function expandType(type?: string): string[] { @@ -344,10 +349,15 @@ export const getAuthEvents = async (req: ServiceRequest, res: Response) => { const { limit, offset, userId, type, from, to } = parsed.data; - const where: any = {}; + const where: WhereOptions = {}; if (type) { - const raw = Array.isArray(type) ? req.query.type : [req.query.type]; + const rawType = req.query.type; + const raw: string[] = Array.isArray(rawType) + ? rawType.filter((v): v is string => typeof v === 'string') + : typeof rawType === 'string' + ? [rawType] + : []; const expanded = raw.flatMap(expandType); @@ -357,19 +367,14 @@ export const getAuthEvents = async (req: ServiceRequest, res: Response) => { } if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); + where.created_at = { + ...(from ? { [Op.gte]: new Date(from) } : {}), + ...(to ? { [Op.lte]: new Date(to) } : {}), + }; } if (userId) where.user_id = userId; - if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); - } - try { const [events, total] = await Promise.all([ AuthEvent.findAll({ diff --git a/src/controllers/internalMetrics.ts b/src/controllers/internalMetrics.ts index 15a42d9..26c0e01 100644 --- a/src/controllers/internalMetrics.ts +++ b/src/controllers/internalMetrics.ts @@ -1,12 +1,39 @@ import { Request, Response } from 'express'; -import { col, fn, literal, Op } from 'sequelize'; +import { col, fn, literal, Op, WhereOptions } from 'sequelize'; -import { AuthEvent } from '../models/authEvents.js'; +import { AuthEvent, AuthEventAttributes } from '../models/authEvents.js'; import { MetricsQuerySchema } from '../schemas/internal.query.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('internal-metrics'); +type TimeseriesRow = { + bucket: Date | string; + type: string; + count: string | number; +}; + +type ResultInstance = { + get(key: K): TimeseriesRow[K]; +}; + +type BucketStats = { + bucket: string; + success: number; + failed: number; +}; + +type SummaryRow = { + type: string; + count: string | number; +}; + +type SummaryResultInstance = { + get(key: K): SummaryRow[K]; +} & { + type: string; +}; + export const getAuthEventSummary = async (req: Request, res: Response) => { const parsed = MetricsQuerySchema.safeParse(req.query); @@ -16,23 +43,25 @@ export const getAuthEventSummary = async (req: Request, res: Response) => { const { from, to } = parsed.data; - const where: any = {}; - - if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); - } + const where: WhereOptions = + from || to + ? { + created_at: { + ...(from ? { [Op.gte]: new Date(from) } : {}), + ...(to ? { [Op.lte]: new Date(to) } : {}), + }, + } + : {}; try { - const results = await AuthEvent.findAll({ + const results = (await AuthEvent.findAll({ attributes: ['type', [fn('COUNT', col('type')), 'count']], where, group: ['type'], - }); + })) as SummaryResultInstance[]; return res.json({ - summary: results.map((r: any) => ({ + summary: results.map((r: SummaryResultInstance) => ({ type: r.type, count: Number(r.get('count')), })), @@ -52,29 +81,29 @@ export const getAuthEventTimeseries = async (req: Request, res: Response) => { const { from, to, interval, userId } = parsed.data; - const where: any = { + // Default to last 24h if not provided + const now = new Date(); + const defaultFrom = new Date(now.getTime() - 1000 * 60 * 60 * 24); + + const createdAtFilter = + from || to + ? { + ...(from ? { [Op.gte]: new Date(from) } : {}), + ...(to ? { [Op.lte]: new Date(to) } : {}), + } + : { + [Op.gte]: defaultFrom, + }; + + const where: WhereOptions = { type: { [Op.in]: ['login_success', 'login_failed'], }, - }; - - if (userId) { - where.user_id = req.query.userId; - } - // Default to last 24h if not provided - const now = new Date(); - const defaultFrom = new Date(now.getTime() - 1000 * 60 * 60 * 24); + ...(userId ? { user_id: userId } : {}), - if (from || to) { - where.created_at = {}; - if (from) where.created_at[Op.gte] = new Date(from); - if (to) where.created_at[Op.lte] = new Date(to); - } else { - where.created_at = { - [Op.gte]: defaultFrom, - }; - } + created_at: createdAtFilter, + }; const bucket = interval === 'day' @@ -89,9 +118,9 @@ export const getAuthEventTimeseries = async (req: Request, res: Response) => { order: [[literal('bucket'), 'ASC']], }); - const map: Record = {}; + const map: Record = {}; - for (const r of results as any[]) { + for (const r of results as ResultInstance[]) { const bucket = new Date(r.get('bucket')).toISOString(); const type = r.get('type'); const count = Number(r.get('count')); @@ -111,7 +140,7 @@ export const getAuthEventTimeseries = async (req: Request, res: Response) => { } } - const filled: any[] = []; + const filled: BucketStats[] = []; if (interval === 'day') { for (let i = 29; i >= 0; i--) { @@ -167,6 +196,7 @@ export const getLoginStats = async (req: Request, res: Response) => { successRate: success + failed > 0 ? success / (success + failed) : 0, }); } catch (err) { + logger.error(`Failed to get Auth Events timeseries data. Reason: ${err}`); return res.status(500).json({ message: 'Failed to compute login stats' }); } }; From af7b12e5b4451029474d8c789c255aae3abd55af Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 25 Mar 2026 12:27:12 -0400 Subject: [PATCH 6/7] chore: upgrade seamless auth types --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b853b0..480dffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", - "@seamless-auth/types": "^0.1.2", + "@seamless-auth/types": "^0.1.3", "@sequelize/postgres": "^7.0.0-alpha.46", "@simplewebauthn/server": "^13.1.1", "@types/bcrypt": "^6.0.0", @@ -1788,9 +1788,9 @@ "license": "Apache-2.0" }, "node_modules/@seamless-auth/types": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.1.2.tgz", - "integrity": "sha512-e+cglrZORKIwIwnaIuKy6vBEt7oS+NAeZIrOY/HGI2wipfFp7G8+9RhOQV+Wt2fMGJwAnnEyHEXNsEQXF+acJw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.1.3.tgz", + "integrity": "sha512-9Y2dq3clnXcSfoJ+pypt6FbMhXN75XLRu4+KuIFITl4OCSjuE8qudrF2nw21TG2meeadN0GHhAAYYzrng5tRoQ==", "license": "AGPL-3.0", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 787c17f..df9890b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "homepage": "https://github.com/fells-code/seamless-auth-api#readme", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", - "@seamless-auth/types": "^0.1.2", + "@seamless-auth/types": "^0.1.3", "@sequelize/postgres": "^7.0.0-alpha.46", "@simplewebauthn/server": "^13.1.1", "@types/bcrypt": "^6.0.0", From a2ba85a414771d2bebf8e3e5cd792753959915cd Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 25 Mar 2026 13:21:49 -0400 Subject: [PATCH 7/7] fix: limit sessions to active sessions --- src/controllers/admin.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 3c16f60..1d63cc8 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -183,8 +183,16 @@ export const getUserDetail = async (req: ServiceRequest, res: Response) => { return res.status(404).json({ message: 'User not found' }); } + const now = new Date(); + const sessions = await Session.findAll({ - where: { userId }, + where: { + userId, + revokedAt: null, + expiresAt: { + [Op.gt]: now, + }, + }, }); const credentials = await Credential.findAll({ @@ -247,11 +255,16 @@ export const getUserAnomalies = async (req: Request, res: Response) => { export const listUserSessions = async (req: Request, res: Response) => { const { userId } = req.params; + const now = new Date(); + try { const sessions = await Session.findAll({ where: { userId, revokedAt: null, + expiresAt: { + [Op.gt]: now, + }, }, }); @@ -299,13 +312,22 @@ export const revokeAllUserSessions = async (req: Request, res: Response) => { export const listAllSessions = async (req: Request, res: Response) => { const { limit = 10, offset = 0 } = req.query; + const now = new Date(); + + const where = { + revokedAt: null, + expiresAt: { + [Op.gt]: now, + }, + }; + const [sessions, total] = await Promise.all([ Session.findAll({ - where: { revokedAt: null }, + where: where, limit: Number(limit), offset: Number(offset), }), - Session.count({ where: { revokedAt: null } }), + Session.count({ where }), ]); return res.json({ sessions, total });