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:
Anso
2026-04-23 17:58:04 -04:00
committed by GitHub
parent 1ef96582e1
commit 929e2fa6b1
8 changed files with 370 additions and 294 deletions
+29
View File
@@ -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;
+15
View File
@@ -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,
});
+101
View File
@@ -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'));
}
}
+24
View File
@@ -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;
}