feat(api): create unified API server with first 3 modules

New consolidated Hono/Bun API server at apps/api/ that replaces individual
app servers. One process, one port, one auth middleware, one container.

Modules ported:
- calendar: RRULE expansion, ICS import, Google Calendar (stub)
- contacts: avatar upload (S3), vCard import/parsing
- mukke: audio upload/download presigned URLs, batch cover art

Architecture: each module registers routes under /api/v1/{module}/*
using the shared-hono middleware stack (auth, rate limit, error handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 21:12:15 +02:00
parent 079015ade7
commit aa93c54391
6 changed files with 368 additions and 0 deletions

22
apps/api/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@manacore/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun run dist/index.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.0"
}
}

44
apps/api/src/index.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* ManaCore Unified API Server
*
* Consolidates all app compute servers into one Hono/Bun process.
* Each module registers its routes under /api/v1/{module}/*.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
// Module routes
import { calendarRoutes } from './modules/calendar/routes';
import { contactsRoutes } from './modules/contacts/routes';
import { mukkeRoutes } from './modules/mukke/routes';
const PORT = parseInt(process.env.PORT || '3050', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
// ─── Global Middleware ──────────────────────────────────────
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('mana-api'));
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// ─── Module Routes ──────────────────────────────────────────
app.route('/api/v1/calendar', calendarRoutes);
app.route('/api/v1/contacts', contactsRoutes);
app.route('/api/v1/mukke', mukkeRoutes);
// ─── Server Info ────────────────────────────────────────────
console.log(`mana-api starting on port ${PORT}...`);
export default { port: PORT, fetch: app.fetch };

View file

@ -0,0 +1,110 @@
/**
* Calendar module RRULE expansion + ICS import
* Ported from apps/calendar/apps/server
*/
import { Hono } from 'hono';
import { z } from 'zod';
const routes = new Hono();
// ─── RRULE Expansion (server-only: DoS protection) ──────────
const ExpandSchema = z.object({
rrule: z.string().min(1).max(500),
dtstart: z.string().min(1),
until: z.string().optional(),
maxOccurrences: z.number().int().min(1).max(5000).optional(),
});
routes.post('/events/expand', async (c) => {
const parsed = ExpandSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
}
const { rrule, dtstart, until, maxOccurrences } = parsed.data;
const max = Math.min(maxOccurrences || 365, 5000);
try {
const start = new Date(dtstart);
const end = until ? new Date(until) : new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000);
const occurrences: string[] = [];
const parts = rrule.replace('RRULE:', '').split(';');
const freq = parts.find((p: string) => p.startsWith('FREQ='))?.split('=')[1];
const interval = parseInt(
parts.find((p: string) => p.startsWith('INTERVAL='))?.split('=')[1] || '1',
10
);
let current = new Date(start);
while (current <= end && occurrences.length < max) {
occurrences.push(current.toISOString());
switch (freq) {
case 'DAILY':
current = new Date(current.getTime() + interval * 24 * 60 * 60 * 1000);
break;
case 'WEEKLY':
current = new Date(current.getTime() + interval * 7 * 24 * 60 * 60 * 1000);
break;
case 'MONTHLY':
current = new Date(current.setMonth(current.getMonth() + interval));
break;
case 'YEARLY':
current = new Date(current.setFullYear(current.getFullYear() + interval));
break;
default:
current = end;
}
}
return c.json({ occurrences, count: occurrences.length });
} catch {
return c.json({ error: 'RRULE expansion failed' }, 500);
}
});
// ─── Google Calendar Import ─────────────────────────────────
routes.post('/sync/google', async (c) => {
return c.json({ error: 'Google Calendar sync not yet implemented' }, 501);
});
// ─── ICS Import ─────────────────────────────────────────────
routes.post('/import/ics', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
const text = await file.text();
const events = parseICS(text);
return c.json({ events, count: events.length });
});
function parseICS(text: string): Array<Record<string, string>> {
const events: Array<Record<string, string>> = [];
const blocks = text.split('BEGIN:VEVENT').filter((b) => b.includes('END:VEVENT'));
for (const block of blocks) {
const event: Record<string, string> = {};
const lines = block.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('SUMMARY:')) event.title = line.slice(8);
if (line.startsWith('DTSTART')) event.start = line.split(':').pop() || '';
if (line.startsWith('DTEND')) event.end = line.split(':').pop() || '';
if (line.startsWith('DESCRIPTION:')) event.description = line.slice(12);
if (line.startsWith('LOCATION:')) event.location = line.slice(9);
if (line.startsWith('RRULE:')) event.rrule = line.slice(6);
}
if (event.title || event.start) events.push(event);
}
return events;
}
export { routes as calendarRoutes };

View file

@ -0,0 +1,87 @@
/**
* Contacts module Avatar upload + vCard import
* Ported from apps/contacts/apps/server
*/
import { Hono } from 'hono';
const ALLOWED_AVATAR_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
const routes = new Hono();
// ─── Avatar Upload (S3) ─────────────────────────────────────
routes.post('/:id/avatar', async (c) => {
const userId = c.get('userId');
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, SVG' }, 400);
}
try {
const { createContactsStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
);
const storage = createContactsStorage();
const key = generateUserFileKey(
userId,
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`
);
const buffer = Buffer.from(await file.arrayBuffer());
const result = await storage.upload(key, buffer, {
contentType: getContentType(file.name),
public: true,
});
return c.json({ avatarUrl: result.url }, 201);
} catch {
return c.json({ error: 'Upload failed' }, 500);
}
});
// ─── vCard Import ───────────────────────────────────────────
routes.post('/import/vcard', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
const text = await file.text();
const contacts = parseVCard(text);
return c.json({ contacts, count: contacts.length });
});
function parseVCard(text: string): Array<Record<string, string>> {
const contacts: Array<Record<string, string>> = [];
const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD'));
for (const card of cards) {
const contact: Record<string, string> = {};
const lines = card.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('FN:')) contact.name = line.slice(3);
if (line.startsWith('EMAIL')) contact.email = line.split(':').pop() || '';
if (line.startsWith('TEL')) contact.phone = line.split(':').pop() || '';
if (line.startsWith('ORG:')) contact.company = line.slice(4);
if (line.startsWith('TITLE:')) contact.title = line.slice(6);
}
if (contact.name || contact.email) contacts.push(contact);
}
return contacts;
}
export { routes as contactsRoutes };

View file

@ -0,0 +1,91 @@
/**
* Mukke module Audio upload, presigned URLs, cover art
* Ported from apps/mukke/apps/server
*/
import { Hono } from 'hono';
const routes = new Hono();
// ─── Song Upload (presigned URL) ────────────────────────────
routes.post('/songs/upload', async (c) => {
const userId = c.get('userId');
const { filename } = await c.req.json();
if (!filename) return c.json({ error: 'filename required' }, 400);
const songId = crypto.randomUUID();
const key = `users/${userId}/songs/${songId}/${filename}`;
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 3600 });
return c.json({
song: { id: songId, title: filename.replace(/\.[^/.]+$/, ''), storagePath: key },
uploadUrl,
});
} catch {
return c.json({ error: 'Failed to generate upload URL' }, 500);
}
});
// ─── Download URL ───────────────────────────────────────────
routes.get('/songs/:id/download-url', async (c) => {
const { storagePath } = c.req.query();
if (!storagePath) return c.json({ error: 'storagePath required' }, 400);
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const url = await storage.getDownloadUrl(storagePath, { expiresIn: 3600 });
return c.json({ url });
} catch {
return c.json({ error: 'Failed to generate download URL' }, 500);
}
});
// ─── Cover Art URL ──────────────────────────────────────────
routes.get('/songs/:id/cover-url', async (c) => {
const { coverArtPath } = c.req.query();
if (!coverArtPath) return c.json({ url: null });
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const url = await storage.getDownloadUrl(coverArtPath, { expiresIn: 3600 });
return c.json({ url });
} catch {
return c.json({ url: null });
}
});
// ─── Batch Cover URLs ───────────────────────────────────────
routes.post('/library/cover-urls', async (c) => {
const { paths } = await c.req.json();
if (!paths?.length) return c.json({ urls: {} });
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const urls: Record<string, string> = {};
for (const path of paths.slice(0, 50)) {
try {
urls[path] = await storage.getDownloadUrl(path, { expiresIn: 3600 });
} catch {
// Skip failed URLs
}
}
return c.json({ urls });
} catch {
return c.json({ urls: {} });
}
});
export { routes as mukkeRoutes };

14
apps/api/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}