- requireAuth bearer path now selects api_tokens.bound_hostname and users.role, populates req.tokenBoundHostname and req.user.role. /cluster/heartbeat can now authenticate via a bound api_token (issued via POST /auth/tokens with bound_hostname). - routes/tokens.js POST accepts bound_hostname; GET returns it so users can see which tokens are bound. - Remove /cluster/heartbeat from SERVICE_PATHS so requireAuth runs on it (the bearer auth handles the gate; the heartbeat handler still enforces the body.hostname === bound match). - /auth/me now returns role (final-review I2). Closes the gap where every signed-in user appeared as 'viewer' in the UI regardless of actual role. - loadUser SELECTs role for session auth. - Backend tests still 37/15/0/22 — no test changes needed; existing token CRUD tests stay passing since bound_hostname is optional.
52 lines
2.3 KiB
JavaScript
52 lines
2.3 KiB
JavaScript
// Current-user API token CRUD. The raw token is returned exactly once at
|
|
// creation time; only the SHA-256 hash and an 8-char display prefix are stored.
|
|
import express from 'express';
|
|
import pool from '../db/pool.js';
|
|
import { generateToken, hashToken, tokenDisplayPrefix } from '../auth/tokens.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// GET / — list current user's tokens (prefix only, never the raw token)
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT id, name, token_prefix AS prefix, bound_hostname, last_used_at, expires_at, created_at
|
|
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`,
|
|
[req.user.id]);
|
|
res.json(rows);
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// POST / — create a new token. Optional bound_hostname binds the token to a
|
|
// specific node-agent hostname (migration 019) — the /cluster/heartbeat handler
|
|
// rejects heartbeats whose body.hostname doesn't match a bound token.
|
|
router.post('/', async (req, res, next) => {
|
|
try {
|
|
const { name, expires_at, bound_hostname } = req.body || {};
|
|
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
|
|
if (bound_hostname !== undefined && bound_hostname !== null && typeof bound_hostname !== 'string') {
|
|
return res.status(400).json({ error: 'bound_hostname must be a string' });
|
|
}
|
|
const raw = generateToken();
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, name, token_prefix AS prefix, bound_hostname, expires_at, created_at`,
|
|
[req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw),
|
|
expires_at || null, bound_hostname?.trim() || null]);
|
|
res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// DELETE /:id — revoke; only the owner can revoke
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const { rowCount } = await pool.query(
|
|
`DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
|
|
[req.params.id, req.user.id]);
|
|
if (rowCount === 0) return res.status(404).json({ error: 'token not found' });
|
|
res.status(204).end();
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
export default router;
|