feat(api): add unified v2 gateway and mobile read slice

This commit is contained in:
zouantchaw
2026-03-13 15:17:00 +01:00
parent 13bcfc9d40
commit 817a39e305
29 changed files with 6788 additions and 87 deletions

View 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;
}

View 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,
});
}
});

View 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;
}