fix(auth): final-review integration fixes — Users page alias + PATCH, CSRF on uploads + heartbeat, drop .bak

Final-review findings:
- Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the
  existing SPA Users page works; add PATCH /:id for inline edits (display_name,
  role, password).
- Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass
  apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader
  403s before reaching the route.
- Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent
  heartbeats keep working when NODE_TOKEN is unset.
- Remove stale auth.js.bak.
This commit is contained in:
Zac Gaetano 2026-05-27 15:42:42 -04:00
parent 8ede44ae87
commit 03d0d098f5
6 changed files with 35 additions and 0 deletions

View file

@ -111,6 +111,7 @@ app.use('/api/v1', (req, res, next) => {
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', usersRouter);
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter);

View file

@ -69,11 +69,18 @@ export async function requireAuth(req, res, next) {
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';
// Paths exempt from the CSRF header check. Must match the SERVICE_PATHS set
// in index.js — these are non-browser service-to-service calls (node-agent
// heartbeat) where the CSRF protection doesn't apply.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
export function requireUiHeader(req, res, next) {
if (!MUTATING.has(req.method)) return next();
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
// browsers and can't be drive-by'd from another origin.
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
return res.status(403).json({ error: 'missing X-Requested-With header' });
}

View file

@ -69,4 +69,28 @@ router.delete('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// PATCH /:id { display_name?, role?, password? } — generic update.
// password update goes through hashPassword; other fields are passed through.
router.patch('/:id', async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const sets = []; const vals = [];
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
if (typeof req.body?.password === 'string') {
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
vals.push(await hashPassword(req.body.password));
}
if (sets.length === 0) return bad(res, 'nothing to update');
vals.push(req.params.id);
const { rows } = await pool.query(
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`,
vals
);
if (rows.length === 0) return res.status(404).json({ error: 'user not found' });
res.json(rows[0]);
} catch (err) { next(err); }
});
export default router;

View file

@ -308,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) {
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
method: 'POST',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
});
if (!res.ok) throw new Error('EDL export failed: ' + res.status);
const blob = await res.blob();

View file

@ -2063,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));

View file

@ -20,6 +20,7 @@ function _xhrPost(url, formData, onProgress) {
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', url);
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
xhr.send(formData);
});
}