feat(api): add unified v2 gateway and mobile read slice
This commit is contained in:
129
backend/unified-api/src/routes/auth.js
Normal file
129
backend/unified-api/src/routes/auth.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import express from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
const defaultAuthService = {
|
||||
parseClientSignIn,
|
||||
parseClientSignUp,
|
||||
signInClient,
|
||||
signOutActor,
|
||||
signUpClient,
|
||||
getSessionForActor,
|
||||
};
|
||||
|
||||
function getBearerToken(header) {
|
||||
if (!header) return null;
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
async function requireAuth(req, _res, next) {
|
||||
try {
|
||||
const token = getBearerToken(req.get('Authorization'));
|
||||
if (!token) {
|
||||
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_BYPASS === 'true') {
|
||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = await verifyFirebaseToken(token, { checkRevoked: true });
|
||||
req.actor = {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
role: decoded.role || null,
|
||||
};
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return next(error);
|
||||
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthRouter(options = {}) {
|
||||
const router = express.Router();
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
const authService = options.authService || defaultAuthService;
|
||||
|
||||
router.use(express.json({ limit: '1mb' }));
|
||||
|
||||
router.post('/client/sign-in', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseClientSignIn(req.body);
|
||||
const session = await authService.signInClient(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/client/sign-up', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseClientSignUp(req.body);
|
||||
const session = await authService.signUpClient(payload, { fetchImpl });
|
||||
return res.status(201).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/session', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const session = await authService.getSessionForActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/client/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
45
backend/unified-api/src/routes/health.js
Normal file
45
backend/unified-api/src/routes/health.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express';
|
||||
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
function healthHandler(req, res) {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-api-v2',
|
||||
version: process.env.SERVICE_VERSION || 'dev',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
healthRouter.get('/health', healthHandler);
|
||||
healthRouter.get('/healthz', healthHandler);
|
||||
|
||||
healthRouter.get('/readyz', async (req, res) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-api-v2',
|
||||
status: 'DATABASE_NOT_CONFIGURED',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await checkDatabaseHealth();
|
||||
return res.status(ok ? 200 : 503).json({
|
||||
ok,
|
||||
service: 'krow-api-v2',
|
||||
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-api-v2',
|
||||
status: 'DATABASE_UNAVAILABLE',
|
||||
details: { message: error.message },
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
75
backend/unified-api/src/routes/proxy.js
Normal file
75
backend/unified-api/src/routes/proxy.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
'connection',
|
||||
'content-length',
|
||||
'host',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
function resolveTargetBase(pathname) {
|
||||
if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL;
|
||||
if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL;
|
||||
if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL;
|
||||
return null;
|
||||
}
|
||||
|
||||
function copyHeaders(source, target) {
|
||||
for (const [key, value] of source.entries()) {
|
||||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
target.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardRequest(req, res, next, fetchImpl) {
|
||||
try {
|
||||
const requestPath = new URL(req.originalUrl, 'http://localhost').pathname;
|
||||
const baseUrl = resolveTargetBase(requestPath);
|
||||
if (!baseUrl) {
|
||||
throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404);
|
||||
}
|
||||
|
||||
const url = new URL(req.originalUrl, baseUrl);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) headers.append(key, item);
|
||||
} else {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
headers.set('x-request-id', req.requestId);
|
||||
|
||||
const upstream = await fetchImpl(url, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
|
||||
duplex: req.method === 'GET' || req.method === 'HEAD' ? undefined : 'half',
|
||||
});
|
||||
|
||||
copyHeaders(upstream.headers, res);
|
||||
res.status(upstream.status);
|
||||
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
return res.send(buffer);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createProxyRouter(options = {}) {
|
||||
const router = Router();
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user