mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +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.
132 lines
5.5 KiB
TypeScript
132 lines
5.5 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
|
|
// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB
|
|
// check (expires_at) is always the tighter bound.
|
|
const API_TOKEN_JWT_CEILING = '400d';
|
|
const MAX_ACTIVE_TOKENS_PER_USER = 25;
|
|
|
|
const API_TOKEN_SCOPE_MESSAGE = 'API tokens cannot manage other API tokens.';
|
|
|
|
export const apiTokensRouter = Router();
|
|
|
|
apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const { name, scope, expires_in } = req.body;
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
res.status(400).json({ error: 'Token name is required.' });
|
|
return;
|
|
}
|
|
if (name.trim().length > 100) {
|
|
res.status(400).json({ error: 'Token name must be 100 characters or fewer.' });
|
|
return;
|
|
}
|
|
const validScopes = ['read-only', 'deploy-only', 'full-admin'];
|
|
if (!scope || !validScopes.includes(scope)) {
|
|
res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` });
|
|
return;
|
|
}
|
|
const validExpiry = [30, 60, 90, 365];
|
|
if (expires_in !== undefined && expires_in !== null && !validExpiry.includes(expires_in)) {
|
|
res.status(400).json({ error: `expires_in must be one of: ${validExpiry.join(', ')} (days), or null for no expiry.` });
|
|
return;
|
|
}
|
|
const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null;
|
|
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
const jwtSecret = settings.auth_jwt_secret;
|
|
if (!jwtSecret) {
|
|
res.status(500).json({ error: 'No JWT secret configured.' });
|
|
return;
|
|
}
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername(req.user!.username);
|
|
if (!user) {
|
|
res.status(500).json({ error: 'User not found.' });
|
|
return;
|
|
}
|
|
|
|
const activeCount = db.getActiveApiTokenCountByUser(user.id);
|
|
if (activeCount >= MAX_ACTIVE_TOKENS_PER_USER) {
|
|
res.status(400).json({ error: `Maximum of ${MAX_ACTIVE_TOKENS_PER_USER} active API tokens per user.` });
|
|
return;
|
|
}
|
|
|
|
if (db.getActiveApiTokenByNameAndUser(name.trim(), user.id)) {
|
|
res.status(409).json({ error: 'An active token with this name already exists.' });
|
|
return;
|
|
}
|
|
|
|
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING });
|
|
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
|
|
const id = db.addApiToken({
|
|
token_hash: tokenHash,
|
|
name: name.trim(),
|
|
scope: scope as ApiTokenScope,
|
|
user_id: user.id,
|
|
created_at: Date.now(),
|
|
expires_at: expiresAt,
|
|
});
|
|
|
|
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token created:', { name: name.trim(), scope, expires_in, user: req.user!.username });
|
|
res.status(201).json({ id, token: rawToken });
|
|
} catch (error) {
|
|
console.error('[ApiTokens] Create error:', error);
|
|
res.status(500).json({ error: 'Failed to create API token' });
|
|
}
|
|
});
|
|
|
|
apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
|
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
|
|
const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id);
|
|
// Never expose token hashes to the client.
|
|
const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest);
|
|
res.json(sanitized);
|
|
} catch (error) {
|
|
console.error('[ApiTokens] List error:', error);
|
|
res.status(500).json({ error: 'Failed to list API tokens' });
|
|
}
|
|
});
|
|
|
|
apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const id = parseInt(req.params.id as string, 10);
|
|
if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; }
|
|
|
|
const apiToken = DatabaseService.getInstance().getApiTokenById(id);
|
|
if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; }
|
|
|
|
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
|
if (!user || apiToken.user_id !== user.id) {
|
|
res.status(403).json({ error: 'You can only revoke your own tokens.' });
|
|
return;
|
|
}
|
|
|
|
DatabaseService.getInstance().revokeApiToken(id);
|
|
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token revoked:', { id, name: apiToken.name, user: req.user!.username });
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[ApiTokens] Revoke error:', error);
|
|
res.status(500).json({ error: 'Failed to revoke API token' });
|
|
}
|
|
});
|