2026-06-04 15:16:53 -04:00
import type { AuthenticatedRequest } from "../../../types/index.js" ;
import type { RequestHandler , Router } from "express" ;
import {
authLogger ,
getGlobalLogLevel ,
setGlobalLogLevel ,
} from "../../utils/logger.js" ;
2026-06-16 15:59:53 -05:00
import { logAudit , getRequestMeta } from "../../utils/audit-logger.js" ;
2026-09-20 14:56:39 -05:00
import {
AUDIT_FORWARD_TOKEN_SETTING ,
AUDIT_FORWARD_URL_ENV ,
AUDIT_FORWARD_URL_SETTING ,
} from "../../utils/audit-forwarder.js" ;
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY ,
parseNotificationAllowlist ,
} from "../../utils/notification-egress.js" ;
2026-06-29 13:28:26 -05:00
import {
2026-07-19 12:29:52 -05:00
createCurrentSettingsRepository ,
createCurrentUserRepository ,
} from "../repositories/factory.js" ;
import type { UserRecord } from "../repositories/user-repository.js" ;
async function getAdminActor (
userId : string | undefined ,
) : Promise < UserRecord | null > {
if ( ! userId ) return null ;
const user = await createCurrentUserRepository (). findById ( userId );
return user ? . isAdmin ? user : null ;
}
2026-06-04 15:16:53 -04:00
export function registerUserSettingsRoutes (
router : Router ,
authenticateJWT : RequestHandler ,
) : void {
/**
* @openapi
* /users/log-level:
* get:
* summary: Get log level setting
* description: Returns the configured log verbosity level.
* tags:
* - Users
* responses:
* 200:
* description: Current log level.
*/
router . get ( "/log-level" , authenticateJWT , async ( _req , res ) => {
try {
2026-07-19 12:29:52 -05:00
const level = await createCurrentSettingsRepository (). get ( "log_level" );
2026-06-04 15:16:53 -04:00
res . json ({
2026-07-19 12:29:52 -05:00
level : level ?? getGlobalLogLevel (),
2026-06-04 15:16:53 -04:00
});
} catch ( err ) {
authLogger . error ( "Failed to get log level" , err );
res . status ( 500 ). json ({ error : "Failed to get log level" });
}
});
/**
* @openapi
* /users/log-level:
* patch:
* summary: Update log level setting (admin only)
* description: Sets the log verbosity level.
* tags:
* - Users
* responses:
* 200:
* description: Log level updated.
* 400:
* description: Invalid log level.
* 403:
* description: Not authorized.
*/
router . patch ( "/log-level" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
2026-07-19 12:29:52 -05:00
const actor = await getAdminActor ( userId );
if ( ! actor ) {
2026-06-04 15:16:53 -04:00
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const { level } = req . body ;
const validLevels = [ "debug" , "info" , "warn" , "error" ];
if ( typeof level !== "string" || ! validLevels . includes ( level )) {
return res
. status ( 400 )
. json ({ error : "level must be one of: debug, info, warn, error" });
}
2026-07-19 12:29:52 -05:00
await createCurrentSettingsRepository (). set ( "log_level" , level );
2026-06-04 15:16:53 -04:00
setGlobalLogLevel ( level );
2026-06-16 15:59:53 -05:00
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : actor.username ?? userId ,
2026-06-16 15:59:53 -05:00
action : "update_log_level" ,
resourceType : "setting" ,
details : JSON.stringify ({ level }),
ipAddress ,
userAgent ,
success : true ,
});
2026-06-04 15:16:53 -04:00
res . json ({ level });
} catch ( err ) {
authLogger . error ( "Failed to set log level" , err );
res . status ( 500 ). json ({ error : "Failed to set log level" });
}
});
/**
* @openapi
* /users/session-timeout:
* get:
* summary: Get session timeout setting
* description: Returns the configured session timeout in hours.
* tags:
* - Users
* responses:
* 200:
* description: Current session timeout hours.
*/
router . get ( "/session-timeout" , authenticateJWT , async ( _req , res ) => {
try {
2026-07-19 12:29:52 -05:00
const value = await createCurrentSettingsRepository (). get (
"session_timeout_hours" ,
);
2026-06-04 15:16:53 -04:00
res . json ({
2026-07-19 12:29:52 -05:00
timeoutHours : value ? parseInt ( value , 10 ) : 24 ,
2026-06-04 15:16:53 -04:00
});
} catch ( err ) {
authLogger . error ( "Failed to get session timeout" , err );
res . status ( 500 ). json ({ error : "Failed to get session timeout" });
}
});
/**
* @openapi
* /users/session-timeout:
* patch:
* summary: Update session timeout setting (admin only)
* description: Sets the session timeout in hours.
* tags:
* - Users
* responses:
* 200:
* description: Session timeout updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
*/
router . patch ( "/session-timeout" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
2026-07-19 12:29:52 -05:00
const actor = await getAdminActor ( userId );
if ( ! actor ) {
2026-06-04 15:16:53 -04:00
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const { timeoutHours } = req . body ;
if (
typeof timeoutHours !== "number" ||
timeoutHours < 1 ||
timeoutHours > 720
) {
return res
. status ( 400 )
. json ({ error : "timeoutHours must be between 1 and 720" });
}
2026-07-19 12:29:52 -05:00
await createCurrentSettingsRepository (). set (
"session_timeout_hours" ,
String ( timeoutHours ),
);
2026-06-16 15:59:53 -05:00
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : actor.username ?? userId ,
2026-06-16 15:59:53 -05:00
action : "update_session_timeout" ,
resourceType : "setting" ,
details : JSON.stringify ({ timeoutHours }),
ipAddress ,
userAgent ,
success : true ,
});
2026-06-04 15:16:53 -04:00
res . json ({ timeoutHours });
} catch ( err ) {
authLogger . error ( "Failed to set session timeout" , err );
res . status ( 500 ). json ({ error : "Failed to set session timeout" });
}
});
2026-06-16 15:59:53 -05:00
2026-09-20 14:56:39 -05:00
/**
* @openapi
* /users/audit-forwarding:
* get:
* summary: Get audit log forwarding settings (admin only)
* tags:
* - Users
* responses:
* 200:
* description: Forwarding target. The token itself is never returned.
*/
router . get ( "/audit-forwarding" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
if ( ! ( await getAdminActor ( userId ))) {
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository ();
const url =
( await settingsRepository . get ( AUDIT_FORWARD_URL_SETTING )) ?? "" ;
const hasToken = !! ( await settingsRepository . get (
AUDIT_FORWARD_TOKEN_SETTING ,
));
res . json ({
url ,
hasToken ,
envConfigured : !! process . env [ AUDIT_FORWARD_URL_ENV ] ? . trim (),
});
} catch ( err ) {
authLogger . error ( "Failed to get audit forwarding settings" , err );
res
. status ( 500 )
. json ({ error : "Failed to get audit forwarding settings" });
}
});
/**
* @openapi
* /users/audit-forwarding:
* patch:
* summary: Update audit log forwarding settings (admin only)
* description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* token:
* type: string
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
*/
router . patch ( "/audit-forwarding" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
const actor = await getAdminActor ( userId );
if ( ! actor ) {
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const { url , token } = req . body ?? {};
if (
typeof url !== "string" ||
( token !== undefined && typeof token !== "string" )
) {
return res . status ( 400 ). json ({ error : "url must be a string" });
}
const trimmedUrl = url . trim ();
if ( trimmedUrl && ! /^https?:\/\//i . test ( trimmedUrl )) {
return res . status ( 400 ). json ({ error : "url must be http(s)" });
}
const settingsRepository = createCurrentSettingsRepository ();
if ( ! trimmedUrl ) {
await settingsRepository . delete ( AUDIT_FORWARD_URL_SETTING );
await settingsRepository . delete ( AUDIT_FORWARD_TOKEN_SETTING );
} else {
await settingsRepository . set ( AUDIT_FORWARD_URL_SETTING , trimmedUrl );
if ( token !== undefined ) {
if ( token . trim ()) {
await settingsRepository . set (
AUDIT_FORWARD_TOKEN_SETTING ,
token . trim (),
);
} else {
await settingsRepository . delete ( AUDIT_FORWARD_TOKEN_SETTING );
}
}
}
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
username : actor.username ?? userId ,
action : "update_audit_forwarding" ,
resourceType : "setting" ,
details : JSON.stringify ({ enabled : !! trimmedUrl }),
ipAddress ,
userAgent ,
success : true ,
});
res . json ({ url : trimmedUrl , hasToken : !! token ? . trim () });
} catch ( err ) {
authLogger . error ( "Failed to update audit forwarding settings" , err );
res
. status ( 500 )
. json ({ error : "Failed to update audit forwarding settings" });
}
});
/**
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
2026-10-01 16:20:16 -05:00
* One route pair per private endpoint allowlist, each with its own setting key.
2026-09-20 14:56:39 -05:00
*/
const registerPrivateEndpointAllowlist = (
path : string ,
settingKey : string ,
auditAction : string ,
label : string ,
) => {
router . get ( path , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
if ( ! ( await getAdminActor ( userId ))) {
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const raw = await createCurrentSettingsRepository (). get ( settingKey );
res . json ({ hosts : parseNotificationAllowlist ( raw ) });
} catch ( err ) {
authLogger . error ( `Failed to get ${ label } allowlist` , err );
res . status ( 500 ). json ({ error : "Failed to get the allowlist" });
}
});
router . patch ( path , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
try {
const actor = await getAdminActor ( userId );
if ( ! actor ) {
return res . status ( 403 ). json ({ error : "Not authorized" });
}
const { hosts } = req . body ;
if ( ! Array . isArray ( hosts )) {
return res . status ( 400 ). json ({ error : "hosts must be an array" });
}
if ( hosts . length > 50 ) {
return res
. status ( 400 )
. json ({ error : "At most 50 hosts are allowed" });
}
const cleaned : string [] = [];
for ( const entry of hosts ) {
if ( typeof entry !== "string" ) {
return res
. status ( 400 )
. json ({ error : "Each host must be a string" });
}
const host = entry . trim (). toLowerCase ();
if ( ! host ) continue ;
if ( ! /^[a-z0-9._:-]+$/ . test ( host )) {
return res
. status ( 400 )
. json ({ error : ` ${ entry } is not a valid hostname` });
}
if ( ! cleaned . includes ( host )) cleaned . push ( host );
}
await createCurrentSettingsRepository (). set (
settingKey ,
JSON . stringify ( cleaned ),
);
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
username : actor.username ?? userId ,
action : auditAction ,
resourceType : "setting" ,
details : JSON.stringify ({ hosts : cleaned }),
ipAddress ,
userAgent ,
success : true ,
});
res . json ({ hosts : cleaned });
} catch ( err ) {
authLogger . error ( `Failed to update ${ label } allowlist` , err );
res . status ( 500 ). json ({ error : "Failed to update the allowlist" });
}
});
};
/**
* @openapi
* /users/notification-private-endpoints:
* get:
* summary: Get the private hosts notification channels may contact (admin only)
* tags:
* - Users
* patch:
* summary: Replace that allowlist (admin only)
* tags:
* - Users
*/
registerPrivateEndpointAllowlist (
"/notification-private-endpoints" ,
NOTIFICATION_PRIVATE_ALLOWLIST_KEY ,
"update_notification_private_endpoints" ,
"notification endpoint" ,
);
2026-06-04 15:16:53 -04:00
}