feat(backend): add foundation services and sql idempotency
This commit is contained in:
31
backend/core-api/src/app.js
Normal file
31
backend/core-api/src/app.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from 'express';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
customProps: (req) => ({ requestId: req.requestId }),
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use('/core', createCoreRouter());
|
||||
app.use('/', createLegacyCoreRouter());
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
6
backend/core-api/src/contracts/core/create-signed-url.js
Normal file
6
backend/core-api/src/contracts/core/create-signed-url.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createSignedUrlSchema = z.object({
|
||||
fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'),
|
||||
expiresInSeconds: z.number().int().min(60).max(3600).optional(),
|
||||
});
|
||||
7
backend/core-api/src/contracts/core/invoke-llm.js
Normal file
7
backend/core-api/src/contracts/core/invoke-llm.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const invokeLlmSchema = z.object({
|
||||
prompt: z.string().min(1).max(12000),
|
||||
responseJsonSchema: z.record(z.any()),
|
||||
fileUrls: z.array(z.string().url()).optional(),
|
||||
});
|
||||
26
backend/core-api/src/lib/errors.js
Normal file
26
backend/core-api/src/lib/errors.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export class AppError extends Error {
|
||||
constructor(code, message, status = 400, details = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function toErrorEnvelope(error, requestId) {
|
||||
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
||||
const code = error?.code || 'INTERNAL_ERROR';
|
||||
const message = error?.message || 'Unexpected error';
|
||||
const details = error?.details || {};
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
45
backend/core-api/src/middleware/auth.js
Normal file
45
backend/core-api/src/middleware/auth.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { can } from '../services/policy.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
function getBearerToken(header) {
|
||||
if (!header) return null;
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export 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);
|
||||
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 requirePolicy(action, resource) {
|
||||
return (req, _res, next) => {
|
||||
if (!can(action, resource, req.actor)) {
|
||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
25
backend/core-api/src/middleware/error-handler.js
Normal file
25
backend/core-api/src/middleware/error-handler.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { toErrorEnvelope } from '../lib/errors.js';
|
||||
|
||||
export function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route not found: ${req.method} ${req.path}`,
|
||||
details: {},
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorHandler(error, req, res, _next) {
|
||||
const envelope = toErrorEnvelope(error, req.requestId);
|
||||
if (req.log) {
|
||||
req.log.error(
|
||||
{
|
||||
errCode: envelope.body.code,
|
||||
status: envelope.status,
|
||||
details: envelope.body.details,
|
||||
},
|
||||
envelope.body.message
|
||||
);
|
||||
}
|
||||
res.status(envelope.status).json(envelope.body);
|
||||
}
|
||||
9
backend/core-api/src/middleware/request-context.js
Normal file
9
backend/core-api/src/middleware/request-context.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function requestContext(req, res, next) {
|
||||
const incoming = req.get('X-Request-Id');
|
||||
req.requestId = incoming || randomUUID();
|
||||
res.setHeader('X-Request-Id', req.requestId);
|
||||
res.locals.startedAt = Date.now();
|
||||
next();
|
||||
}
|
||||
141
backend/core-api/src/routes/core.js
Normal file
141
backend/core-api/src/routes/core.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js';
|
||||
import { invokeLlmSchema } from '../contracts/core/invoke-llm.js';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES),
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMetaSchema = z.object({
|
||||
category: z.string().max(80).optional(),
|
||||
visibility: z.enum(['public', 'private']).optional(),
|
||||
});
|
||||
|
||||
function mockSignedUrl(fileUri, expiresInSeconds) {
|
||||
const encoded = encodeURIComponent(fileUri);
|
||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
||||
return {
|
||||
signedUrl: `https://storage.googleapis.com/mock-signed-url/${encoded}?expires=${expiresInSeconds}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function handleUploadFile(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
}
|
||||
|
||||
if (!ALLOWED_FILE_TYPES.has(file.mimetype)) {
|
||||
throw new AppError('INVALID_FILE_TYPE', `Unsupported file type: ${file.mimetype}`, 400);
|
||||
}
|
||||
|
||||
const maxFileSize = Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES);
|
||||
if (file.size > maxFileSize) {
|
||||
throw new AppError('FILE_TOO_LARGE', `File exceeds ${maxFileSize} bytes`, 400);
|
||||
}
|
||||
|
||||
const meta = parseBody(uploadMetaSchema, req.body || {});
|
||||
const visibility = meta.visibility || 'private';
|
||||
const bucket = visibility === 'public'
|
||||
? process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public'
|
||||
: process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private';
|
||||
|
||||
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const objectPath = `uploads/${req.actor.uid}/${Date.now()}_${safeName}`;
|
||||
|
||||
res.status(200).json({
|
||||
fileUri: `gs://${bucket}/${objectPath}`,
|
||||
contentType: file.mimetype,
|
||||
size: file.size,
|
||||
bucket,
|
||||
path: objectPath,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === 'LIMIT_FILE_SIZE') {
|
||||
return next(new AppError('FILE_TOO_LARGE', 'File exceeds upload limit', 400));
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSignedUrl(req, res, next) {
|
||||
try {
|
||||
const payload = parseBody(createSignedUrlSchema, req.body || {});
|
||||
const expiresInSeconds = payload.expiresInSeconds || 300;
|
||||
|
||||
const signed = mockSignedUrl(payload.fileUri, expiresInSeconds);
|
||||
|
||||
res.status(200).json({
|
||||
...signed,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvokeLlm(req, res, next) {
|
||||
try {
|
||||
const payload = parseBody(invokeLlmSchema, req.body || {});
|
||||
|
||||
if (process.env.LLM_MOCK === 'false') {
|
||||
throw new AppError('MODEL_FAILED', 'Real model integration not wired yet', 501);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
res.status(200).json({
|
||||
result: {
|
||||
summary: 'Mock model response. Replace with Vertex AI integration.',
|
||||
inputPromptSize: payload.prompt.length,
|
||||
},
|
||||
model: process.env.LLM_MODEL || 'vertexai/gemini-mock',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCoreRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile);
|
||||
router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl);
|
||||
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export function createLegacyCoreRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/uploadFile', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile);
|
||||
router.post('/createSignedUrl', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl);
|
||||
router.post('/invokeLLM', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
|
||||
|
||||
return router;
|
||||
}
|
||||
12
backend/core-api/src/routes/health.js
Normal file
12
backend/core-api/src/routes/health.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get('/healthz', (req, res) => {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-core-api',
|
||||
version: process.env.SERVICE_VERSION || 'dev',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
});
|
||||
9
backend/core-api/src/server.js
Normal file
9
backend/core-api/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const app = createApp();
|
||||
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`krow-core-api listening on port ${port}`);
|
||||
});
|
||||
13
backend/core-api/src/services/firebase-auth.js
Normal file
13
backend/core-api/src/services/firebase-auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyFirebaseToken(token) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token);
|
||||
}
|
||||
5
backend/core-api/src/services/policy.js
Normal file
5
backend/core-api/src/services/policy.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function can(action, resource, actor) {
|
||||
void action;
|
||||
void resource;
|
||||
return Boolean(actor?.uid);
|
||||
}
|
||||
Reference in New Issue
Block a user