mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
refactor(backend): extract types, constants, and guards from index.ts (phase 0) (#730)
* refactor(backend): extract types, constants, and guards from index.ts (phase 0) Additive, behavior-preserving first step of the modular backend refactor. Moves purely static artifacts out of backend/src/index.ts so later phases can extract routes and middleware without touching shared symbols. New modules: - types/express.ts: Express Request augmentation - helpers/constants.ts: PORT, password policy, label colors, cookie names, MFA TTLs, hot-path cache TTLs - helpers/proxyExemptPaths.ts: PROXY_EXEMPT_PREFIXES + isProxyExemptPath - helpers/cookies.ts: isSecureRequest, getCookieOptions - helpers/policyGate.ts: buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan - middleware/permissions.ts: ROLE_PERMISSIONS, checkPermission, requirePermission - middleware/tierGates.ts: requirePaid, requireAdmiral, requireAdmin, requireNodeProxy, requireScheduledTaskTier + effectiveTier/Variant index.ts shrinks by ~260 lines; no runtime behavior changes. All 64 vitest files and 1,278 tests pass. * refactor(backend): drop unused imports left after phase 0 extraction LicenseTier, LicenseVariant, DIGEST_CACHE_TTL_MS, and isProxyExemptPath were imported into index.ts but no longer referenced there after the phase 0 move; CI lint flagged them as errors. isProxyExemptPath will be re-imported in phase 1 when the JSON parser bypass and nodeContext middleware get extracted. Silence the no-namespace warning on the Express augmentation since the namespace syntax is required for TypeScript module augmentation.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Shared constants used across the backend. Extracted from index.ts to keep
|
||||
// the entry point lean and to make values discoverable without scanning the
|
||||
// monolith.
|
||||
|
||||
// Server
|
||||
export const PORT = 3000;
|
||||
|
||||
// Password policy
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// Labels
|
||||
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
export type LabelColor = typeof VALID_LABEL_COLORS[number];
|
||||
export const MAX_LABELS_PER_NODE = 50;
|
||||
|
||||
// Session cookies
|
||||
export const COOKIE_NAME = 'sencho_token';
|
||||
export const SESSION_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
export const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
// Hot-path cache TTLs.
|
||||
// Short TTLs collapse concurrent polling pressure across browser tabs and
|
||||
// overlapping service samplers without introducing noticeable UI staleness.
|
||||
// Keys are per-node: "stats:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
|
||||
export const STATS_CACHE_TTL_MS = 2_000;
|
||||
export const SYSTEM_STATS_CACHE_TTL_MS = 3_000;
|
||||
export const STACK_STATUSES_CACHE_TTL_MS = 3_000;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Request } from 'express';
|
||||
import { SESSION_COOKIE_MAX_AGE_MS } from './constants';
|
||||
|
||||
/** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */
|
||||
export const isSecureRequest = (req: Request): boolean => {
|
||||
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||
};
|
||||
|
||||
/** Cookie options derived from the current request (secure flag follows the connection). */
|
||||
export const getCookieOptions = (req: Request) => ({
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict' as const,
|
||||
maxAge: SESSION_COOKIE_MAX_AGE_MS,
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { enforcePolicyPreDeploy, type PolicyEnforcementOptions } from '../services/PolicyEnforcement';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
|
||||
// `stack:deploy` permission alone is not sufficient because the `deployer`
|
||||
// role has that permission for day-to-day deploys.
|
||||
export function buildPolicyGateOptions(
|
||||
req: Request,
|
||||
overrides: { bypass?: boolean; actor?: string } = {},
|
||||
): PolicyEnforcementOptions {
|
||||
const defaultBypass = req.query.ignorePolicy === 'true' && req.user?.role === 'admin';
|
||||
return {
|
||||
bypass: overrides.bypass ?? defaultBypass,
|
||||
actor: overrides.actor ?? req.user?.username ?? 'unknown',
|
||||
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
|
||||
auditMethod: req.method,
|
||||
auditPath: req.originalUrl || req.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the deploy may proceed. Returns false after sending a 409,
|
||||
* in which case the caller must return immediately.
|
||||
*/
|
||||
export async function runPolicyGate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<boolean> {
|
||||
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
|
||||
if (!gate.ok) {
|
||||
res.status(409).json({
|
||||
error: `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`,
|
||||
policy: gate.policy && {
|
||||
id: gate.policy.id,
|
||||
name: gate.policy.name,
|
||||
maxSeverity: gate.policy.max_severity,
|
||||
},
|
||||
violations: gate.violations,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<void> {
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) return;
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const containers = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] },
|
||||
});
|
||||
const imageRefs = new Set<string>();
|
||||
for (const c of containers as Array<{ Image?: string }>) {
|
||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||
}
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scan.critical_count > 0 ? 'error' : 'warning',
|
||||
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'unknown error');
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, getErrorMessage(err, 'unknown error'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Path prefixes whose /api/* requests are handled locally even when an
|
||||
// x-node-id header targets a remote node. These endpoints are gateway-level
|
||||
// concerns (auth, node registry, licensing, fleet aggregation, webhooks,
|
||||
// meta) that must never be proxied to a remote Sencho instance.
|
||||
//
|
||||
// Consumed by:
|
||||
// - middleware/jsonParser.ts → skip JSON parsing only for non-exempt remote proxy requests
|
||||
// - middleware/nodeContext.ts → skip node resolution for exempt paths
|
||||
export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
|
||||
'/api/auth/',
|
||||
'/api/nodes',
|
||||
'/api/license',
|
||||
'/api/fleet',
|
||||
'/api/webhooks',
|
||||
'/api/meta',
|
||||
];
|
||||
|
||||
/** Returns true when the path should bypass the remote proxy (handled locally). */
|
||||
export function isProxyExemptPath(path: string): boolean {
|
||||
for (const prefix of PROXY_EXEMPT_PREFIXES) {
|
||||
if (path.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user