mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
Server-side Public-Submit für unlisted-shared Forms (Plan
docs/plans/forms-module.md M3.b):
- POST /api/v1/forms/public/:token/submit (apps/api):
- Token-resolve via unlistedSnapshots-Tabelle (eq, limit 1).
- Hard-blocks: 404 unbekannt, 410 revoked/expired, 400 wrong
collection, 400 invalid JSON.
- Schema-validiert serverseitig: filtert eingehende answers auf
field-IDs aus dem Snapshot (anti-injection), prüft required
Antwort-Felder + required consent-Felder.
- Hashed IP (SHA-256, hex) als Anti-Spam-Fingerprint, plus
User-Agent + Referer truncated, in submitterMeta.
- Schreibt sync_changes(table='formResponses', op='insert', data,
field_meta, actor='system:forms-public-submit', origin='system')
in einer Transaktion mit set_config('app.current_user_id') für
RLS — mirror vom articles import-extractor.
- Token-scoped rate-limit (10/min) + IP-scoped (30/min), gleiche
Architektur wie unlisted/public-routes.
- Returns { ok: true, responseId, submittedAt }.
- SharedFormView (apps/mana/apps/web): handleSubmit POSTet jetzt an
${PUBLIC_MANA_API_URL || origin:3060}/api/v1/forms/public/:token/submit.
Submitting-State (Disabled-Button + "Sende ..."), Error-Block bei
Server-Fehlern, Submitter-Block (Name + Email, beide optional). Der
DEV-Hinweis ist weg.
Encryption: server speichert plaintext im sync_changes-Blob. Der
Client-side Decrypt-Path ist no-op für non-encrypted shapes
(record-helpers.ts:241), also kein Crash beim Pull. Encrypted-at-rest
für public submissions ist M6 ZK-Mode (eigener per-Form-Key der
Form-Owner client-seitig hält).
Mounted pre-auth in apps/api/src/index.ts neben unlisted/public.
apps/api buildet (1769 modules, no TS errors). svelte-check:
0 errors in forms/. Forms-Modul ist End-to-End nutzbar — User legt
Form an, publisht, setzt visibility=unlisted, kopiert Share-Link,
externe Person füllt aus + sendet, Antwort landet im
ResponsesView des Owners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
6.7 KiB
TypeScript
158 lines
6.7 KiB
TypeScript
/**
|
|
* Mana 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,
|
|
requireTier,
|
|
type AuthVariables,
|
|
} from '@mana/shared-hono';
|
|
|
|
// MCP server
|
|
import { handleMcpRequest } from './mcp/server';
|
|
|
|
// Prometheus metrics
|
|
import { register as metricsRegister } from './lib/metrics';
|
|
|
|
// Module routes
|
|
import { calendarRoutes } from './modules/calendar/routes';
|
|
import { contactsRoutes } from './modules/contacts/routes';
|
|
import { musicRoutes } from './modules/music/routes';
|
|
import { chatRoutes } from './modules/chat/routes';
|
|
import { notesRoutes } from './modules/notes/routes';
|
|
import { pictureRoutes } from './modules/picture/routes';
|
|
import { profileRoutes } from './modules/profile/routes';
|
|
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
|
import { storageRoutes } from './modules/storage/routes';
|
|
import { todoRoutes } from './modules/todo/routes';
|
|
import { plantsRoutes } from './modules/plants/routes';
|
|
import { foodRoutes } from './modules/food/routes';
|
|
import { guidesRoutes } from './modules/guides/routes';
|
|
import { moodlitRoutes } from './modules/moodlit/routes';
|
|
import { newsRoutes } from './modules/news/routes';
|
|
import { newsResearchRoutes } from './modules/news-research/routes';
|
|
import { articlesRoutes } from './modules/articles/routes';
|
|
import { startArticleImportWorker } from './modules/articles/import-worker';
|
|
import { tracesRoutes } from './modules/traces/routes';
|
|
import { writingRoutes } from './modules/writing/routes';
|
|
import { comicRoutes } from './modules/comic/routes';
|
|
import { presiRoutes } from './modules/presi/routes';
|
|
import { researchRoutes } from './modules/research/routes';
|
|
import { whoRoutes } from './modules/who/routes';
|
|
import { websiteRoutes } from './modules/website/routes';
|
|
import { websitePublicRoutes } from './modules/website/public-routes';
|
|
import { unlistedRoutes } from './modules/unlisted/routes';
|
|
import { unlistedPublicRoutes } from './modules/unlisted/public-routes';
|
|
import { formsPublicRoutes } from './modules/forms/public-routes';
|
|
import { wetterRoutes } from './modules/wetter/routes';
|
|
|
|
const PORT = parseInt(process.env.PORT || '3060', 10);
|
|
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
|
|
|
|
const app = new Hono<{ Variables: AuthVariables }>();
|
|
|
|
// ─── Global Middleware ──────────────────────────────────────
|
|
app.onError(errorHandler);
|
|
app.notFound(notFoundHandler);
|
|
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
|
|
app.route('/health', healthRoute('mana-api'));
|
|
|
|
// Prometheus scrape endpoint. Unauthenticated on purpose — the Grafana
|
|
// / Prometheus stack runs on the internal network; we rely on the
|
|
// reverse-proxy layer to block external access to /metrics.
|
|
app.get('/metrics', async (c) => {
|
|
c.header('Content-Type', metricsRegister.contentType);
|
|
return c.text(await metricsRegister.metrics());
|
|
});
|
|
|
|
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
|
|
|
|
// Public routes — no auth required (weather data is public, published
|
|
// websites are by definition public, unlisted-share tokens are public
|
|
// by design).
|
|
app.route('/api/v1/wetter', wetterRoutes);
|
|
app.route('/api/v1/website/public', websitePublicRoutes);
|
|
app.route('/api/v1/unlisted/public', unlistedPublicRoutes);
|
|
app.route('/api/v1/forms/public', formsPublicRoutes);
|
|
|
|
app.use('/api/*', authMiddleware());
|
|
|
|
// ─── Tier Gating ────────────────────────────────────────────
|
|
// Defense-in-depth on top of per-route credits validation.
|
|
// Routes that call LLMs, image-gen, or external search APIs are gated
|
|
// to `beta`+ so that unauthenticated guest fallbacks (tier='public'
|
|
// from a missing claim) can't hit paid infrastructure.
|
|
// Pure CRUD modules (calendar, contacts, music, storage, todo, news,
|
|
// presi, moodlit) rely on authMiddleware alone — users access only
|
|
// their own records.
|
|
const RESOURCE_MODULES = [
|
|
'chat',
|
|
'food',
|
|
'guides',
|
|
'kontext',
|
|
'news-research',
|
|
'notes',
|
|
'picture',
|
|
'plants',
|
|
'research',
|
|
'traces',
|
|
'who',
|
|
'writing',
|
|
] as const;
|
|
for (const mod of RESOURCE_MODULES) {
|
|
app.use(`/api/v1/${mod}/*`, requireTier('beta'));
|
|
}
|
|
|
|
// ─── MCP Endpoint ──────────────────────────────────────────
|
|
// Streamable HTTP transport: POST (messages), GET (SSE stream), DELETE (close)
|
|
// MCP exposes the full tool catalog including LLM/research tools, so it
|
|
// gets the same minimum tier.
|
|
app.use('/api/v1/mcp', requireTier('beta'));
|
|
app.all('/api/v1/mcp', (c) => handleMcpRequest(c.req.raw, c.get('userId')));
|
|
|
|
// ─── Module Routes ──────────────────────────────────────────
|
|
app.route('/api/v1/calendar', calendarRoutes);
|
|
app.route('/api/v1/contacts', contactsRoutes);
|
|
app.route('/api/v1/music', musicRoutes);
|
|
app.route('/api/v1/chat', chatRoutes);
|
|
app.route('/api/v1/notes', notesRoutes);
|
|
app.route('/api/v1/picture', pictureRoutes);
|
|
app.route('/api/v1/profile', profileRoutes);
|
|
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
|
app.route('/api/v1/storage', storageRoutes);
|
|
app.route('/api/v1/todo', todoRoutes);
|
|
app.route('/api/v1/plants', plantsRoutes);
|
|
app.route('/api/v1/food', foodRoutes);
|
|
app.route('/api/v1/guides', guidesRoutes);
|
|
app.route('/api/v1/moodlit', moodlitRoutes);
|
|
app.route('/api/v1/news', newsRoutes);
|
|
app.route('/api/v1/news-research', newsResearchRoutes);
|
|
app.route('/api/v1/articles', articlesRoutes);
|
|
app.route('/api/v1/traces', tracesRoutes);
|
|
app.route('/api/v1/presi', presiRoutes);
|
|
app.route('/api/v1/research', researchRoutes);
|
|
app.route('/api/v1/website', websiteRoutes);
|
|
app.route('/api/v1/unlisted', unlistedRoutes);
|
|
app.route('/api/v1/who', whoRoutes);
|
|
app.route('/api/v1/writing', writingRoutes);
|
|
app.route('/api/v1/comic', comicRoutes);
|
|
|
|
// ─── Background Workers ─────────────────────────────────────
|
|
// Articles bulk-import: ticks every 2s, advisory-lock-gated so multiple
|
|
// apps/api replicas never double-process. See
|
|
// docs/plans/articles-bulk-import.md.
|
|
startArticleImportWorker();
|
|
|
|
// ─── Server Info ────────────────────────────────────────────
|
|
console.log(`mana-api starting on port ${PORT}...`);
|
|
|
|
export default { port: PORT, fetch: app.fetch };
|