managarten/apps/api/src/index.ts
Till JS 92bee0d71a feat(unlisted): M8.1 — backend foundation for shareable-link snapshots
First milestone of the unlisted-share rollout plan (docs/plans/
unlisted-sharing.md). Adds the server-side infrastructure that backs
`visibility='unlisted'` — previously the flag was stamped locally but
led nowhere. After this commit, a token points at an actual snapshot
the SSR share-page will render (M8.3+).

Scope: backend only. No client-side publish/revoke calls yet, no
share-route, no UI. That lands in M8.2/M8.3. Anyone hitting the
endpoints manually with curl can exercise the full publish-fetch-
revoke cycle.

Changes:
- New pgSchema `unlisted` with table `snapshots`:
    token (pk, 32-char base64url)
    user_id, space_id, collection, record_id, blob (jsonb)
    created_at, updated_at, expires_at (nullable), revoked_at
  Partial unique index on (user_id, collection, record_id) WHERE
  revoked_at IS NULL so one record has at most one active token.
  Partial btree on expires_at for the cron-cleanup path.
- Hand-authored SQL migration `apps/api/drizzle/unlisted/0000_init.sql`
  (manual-apply per the repo's feedback_api_hand_authored_migrations
  memory). Already applied to the local mana_platform.
- Drizzle schema `apps/api/src/modules/unlisted/schema.ts`. All id
  fields are `text` not uuid — Better-Auth nanoids aren't UUIDs, same
  trap we hit with the website module's publish bug.
- mana-api module `apps/api/src/modules/unlisted/`:
    POST   /api/v1/unlisted/:collection/:recordId (auth)
      Body: { spaceId, blob, expiresAt? }. Re-publish reuses the
      existing active token (by (user,collection,record) lookup); a
      revoke-then-republish mints a fresh token row. Response includes
      a fully-qualified share URL built from Origin/Referer/env.
    DELETE /api/v1/unlisted/:collection/:recordId (auth)
      Soft-revoke. Idempotent — already-revoked returns
      { revoked: 0 } cleanly so client stores can call it
      unconditionally on setVisibility-away.
    GET    /api/v1/unlisted/public/:token (public)
      Rate-limited 20/min/token + 60/min/ip so token enumeration is
      impractical. 404 for unknown, 410 Gone for revoked or expired.
      Cache-Control: private, max-age=60 + X-Robots-Tag: noindex for
      SEO isolation. Returns { token, collection, blob, createdAt,
      updatedAt, expiresAt }.
- ALLOWED_COLLECTIONS hardcoded allowlist in POST handler
  (events, libraryEntries, places — the M8.3+M8.4 scope). Unknown
  collection -> 400 COLLECTION_NOT_ALLOWED. Keeps the schema honest
  about what the server accepts.
- drizzle.config extended to include the new schema in managed
  migrations.
- index.ts wires unlistedPublicRoutes pre-auth (before
  authMiddleware) and unlistedRoutes post-auth.

Verified:
- Migration applied to mana_platform — `unlisted.snapshots` exists
  with both partial indexes.
- pnpm run type-check (api): clean
- pnpm run validate:all: theme-tokens, theme-parity, crypto-registry,
  encrypted-tools all green
- URL build uses Origin/Referer before the env fallback so dev
  (http://localhost:5173) and prod (https://mana.how) both work
  without env churn.

Next: M8.2 — shared-privacy client helper + SharedLinkControls
component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:12:13 +02:00

148 lines
6.1 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 { contextRoutes } from './modules/context/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 { 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 { 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.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',
'context',
'food',
'guides',
'news-research',
'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/context', contextRoutes);
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);
// ─── Server Info ────────────────────────────────────────────
console.log(`mana-api starting on port ${PORT}...`);
export default { port: PORT, fetch: app.fetch };