Files
sencho/backend/src/routes/license.ts
T
Anso 50e64b058b refactor(backend): extract 8 low-blast-radius route groups into routers (phase 4a-1) (#734)
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.
2026-04-23 20:24:37 -04:00

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.');
});