From 1e4c92c2df43cb97fb6c2c8188a286081ebf72cb Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 12:50:58 -0400 Subject: [PATCH] feat(mam-api): add personal API token routes --- services/mam-api/src/routes/tokens.js | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 services/mam-api/src/routes/tokens.js diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js new file mode 100644 index 0000000..ba1bb35 --- /dev/null +++ b/services/mam-api/src/routes/tokens.js @@ -0,0 +1,70 @@ +/** + * Personal API token routes (requires authentication) + * + * GET /api/v1/tokens — list current user's tokens (no raw values) + * POST /api/v1/tokens — create token, returns raw value ONCE + * DELETE /api/v1/tokens/:id — revoke token + */ +import express from 'express'; +import crypto from 'crypto'; +import pool from '../db/pool.js'; + +const router = express.Router(); + +// Helper: get current user ID from session or req.user +const userId = req => req.user?.id || req.session?.userId; + +// ── List ────────────────────────────────────────────────────── +router.get('/', async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT id, name, token_prefix, last_used_at, expires_at, created_at + FROM api_tokens + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId(req)] + ); + res.json(rows); + } catch (err) { next(err); } +}); + +// ── Create ──────────────────────────────────────────────────── +router.post('/', async (req, res, next) => { + try { + const { name, expires_in_days } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + + // Generate: wd_ + 40 random hex chars = 43 chars total + const raw = 'wd_' + crypto.randomBytes(20).toString('hex'); + const hash = crypto.createHash('sha256').update(raw).digest('hex'); + const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars + + const expiresAt = expires_in_days + ? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000) + : null; + + const { rows } = await pool.query( + `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`, + [userId(req), name.trim(), hash, prefix, expiresAt] + ); + + // Return raw token ONCE — it is never stored in plaintext + res.status(201).json({ ...rows[0], token: raw }); + } catch (err) { next(err); } +}); + +// ── 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, userId(req)] + ); + if (!rowCount) return res.status(404).json({ error: 'Token not found' }); + res.json({ message: 'Token revoked' }); + } catch (err) { next(err); } +}); + +export default router;