mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 06:35:29 +00:00
50e64b058b
First slice of Phase 4 (route extraction). Pulls 8 well-tested, mostly
independent route groups out of index.ts into focused Router files. No
behavior change; every handler body moves verbatim.
New route files under backend/src/routes/:
- meta.ts /api/health, /api/meta (mounted before authGate)
- license.ts /api/license/* + /api/system/update,
exports scheduleLocalUpdate for the fleet route
- permissions.ts /api/permissions/me
- convert.ts POST /api/convert
- alerts.ts /api/alerts/*
- labels.ts /api/labels/* + PUT /api/stacks/:name/labels
(exported as stackLabelsRouter)
- apiTokens.ts /api/api-tokens/*
- auditLog.ts /api/audit-log/*
Shared helper lifts:
- helpers/cacheInvalidation.ts: invalidateNodeCaches()
- middleware/tierGates.ts: requireBody (was inline in index.ts)
- utils/errors.ts: isSqliteUniqueViolation (was inline in index.ts)
- middleware/apiTokenScope.ts: rejectApiTokenScope() helper (new)
- utils/csv.ts: escapeCsvField() (new)
index.ts drops from ~7520 to ~6775 lines and now mounts the routers right
after enforceApiTokenScope. The remote proxy and fleet/auth/webhooks/users
routes remain inline in index.ts pending later Phase 4 slices.
Code review fixes: rejectApiTokenScope helper replaces duplicated
`if (req.apiTokenScope) 403 SCOPE_DENIED` blocks in apiTokens.ts and
license.ts; escapeCsvField replaces the inline CSV escape in auditLog.ts.
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { LicenseService } from '../services/LicenseService';
|
|
import SelfUpdateService from '../services/SelfUpdateService';
|
|
import { requireAdmin } from '../middleware/tierGates';
|
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
|
|
|
const LICENSE_SCOPE_MESSAGE = 'API tokens cannot manage licenses.';
|
|
|
|
export const licenseRouter = Router();
|
|
|
|
licenseRouter.get('/', (_req: Request, res: Response): void => {
|
|
try {
|
|
const info = LicenseService.getInstance().getLicenseInfo();
|
|
res.json(info);
|
|
} catch (error) {
|
|
console.error('[License] Error getting license info:', error);
|
|
res.status(500).json({ error: 'Failed to retrieve license information' });
|
|
}
|
|
});
|
|
|
|
licenseRouter.post('/activate', async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const { license_key } = req.body;
|
|
if (!license_key || typeof license_key !== 'string') {
|
|
res.status(400).json({ error: 'A valid license key is required' });
|
|
return;
|
|
}
|
|
const result = await LicenseService.getInstance().activate(license_key.trim());
|
|
if (result.success) {
|
|
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
|
} else {
|
|
res.status(400).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('[License] Activation error:', error);
|
|
res.status(500).json({ error: 'License activation failed' });
|
|
}
|
|
});
|
|
|
|
licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const result = await LicenseService.getInstance().deactivate();
|
|
if (result.success) {
|
|
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
|
} else {
|
|
res.status(500).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('[License] Deactivation error:', error);
|
|
res.status(500).json({ error: 'License deactivation failed' });
|
|
}
|
|
});
|
|
|
|
licenseRouter.post('/validate', async (_req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const result = await LicenseService.getInstance().validate();
|
|
res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() });
|
|
} catch (error) {
|
|
console.error('[License] Validation error:', error);
|
|
res.status(500).json({ error: 'License validation failed' });
|
|
}
|
|
});
|
|
|
|
licenseRouter.get('/billing-portal', async (_req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const result = await LicenseService.getInstance().getBillingPortalUrl();
|
|
if ('error' in result) {
|
|
res.status(404).json({ error: result.error });
|
|
return;
|
|
}
|
|
res.json({ url: result.url });
|
|
} catch (error) {
|
|
console.error('[License] Billing portal error:', error);
|
|
res.status(500).json({ error: 'Failed to retrieve billing portal URL' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Respond 202, then trigger the "last breath" self-update after flush.
|
|
* Exported because the fleet "update this node" route reuses the same
|
|
* response shape + post-flush trigger for local-node self-updates.
|
|
*/
|
|
export function scheduleLocalUpdate(res: Response, message: string): void {
|
|
res.status(202).json({ message });
|
|
res.on('finish', () => {
|
|
setTimeout(() => {
|
|
// Defense in depth: triggerUpdate records its own errors into
|
|
// lastUpdateError; guard against an unexpected throw becoming an
|
|
// unhandled rejection.
|
|
SelfUpdateService.getInstance().triggerUpdate().catch((err) => {
|
|
console.error('[SelfUpdate] Unexpected error during triggerUpdate:', err);
|
|
});
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
export const systemUpdateRouter = Router();
|
|
|
|
systemUpdateRouter.post('/update', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!SelfUpdateService.getInstance().isAvailable()) {
|
|
res.status(503).json({ error: 'Self-update unavailable. Sencho must be deployed via Docker Compose.' });
|
|
return;
|
|
}
|
|
scheduleLocalUpdate(res, 'Update initiated. The server will restart shortly.');
|
|
});
|