diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 74f6f5d14..d9a7455d9 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -7,14 +7,18 @@ import { defineConfig } from 'drizzle-kit'; * schema's generated SQL lives under `drizzle/{schema}/`. Expand the * `schema` array and `schemaFilter` when a new module joins. * - * Currently managed: `research`, `website`. + * Currently managed: `research`, `website`, `unlisted`. */ export default defineConfig({ - schema: ['./src/modules/research/schema.ts', './src/modules/website/schema.ts'], + schema: [ + './src/modules/research/schema.ts', + './src/modules/website/schema.ts', + './src/modules/unlisted/schema.ts', + ], out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', }, - schemaFilter: ['research', 'website'], + schemaFilter: ['research', 'website', 'unlisted'], }); diff --git a/apps/api/drizzle/unlisted/0000_init.sql b/apps/api/drizzle/unlisted/0000_init.sql new file mode 100644 index 000000000..3fd709cae --- /dev/null +++ b/apps/api/drizzle/unlisted/0000_init.sql @@ -0,0 +1,33 @@ +-- Unlisted Snapshots — schema bootstrap. +-- See docs/plans/unlisted-sharing.md §1. +-- +-- Apply with: +-- docker exec -i mana-postgres psql -U mana -d mana_platform \ +-- < apps/api/drizzle/unlisted/0000_init.sql + +CREATE SCHEMA IF NOT EXISTS "unlisted"; + +CREATE TABLE IF NOT EXISTS "unlisted"."snapshots" ( + "token" text PRIMARY KEY, + "user_id" text NOT NULL, + "space_id" text NOT NULL, + "collection" text NOT NULL, + "record_id" uuid NOT NULL, + "blob" jsonb NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "expires_at" timestamptz, + "revoked_at" timestamptz +); + +-- One active token per record (user + collection + recordId). +-- revoked rows are allowed to coexist because re-publish after revoke +-- should mint a fresh token row instead of updating the old one. +CREATE UNIQUE INDEX IF NOT EXISTS "snapshots_record_active_idx" + ON "unlisted"."snapshots" ("user_id", "collection", "record_id") + WHERE "revoked_at" IS NULL; + +-- Expiry-cleanup path: cron scans for snapshots past expires_at. +CREATE INDEX IF NOT EXISTS "snapshots_expiry_idx" + ON "unlisted"."snapshots" ("expires_at") + WHERE "expires_at" IS NOT NULL AND "revoked_at" IS NULL; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 325621509..b5ecdcc7c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,6 +49,8 @@ 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); @@ -73,9 +75,11 @@ app.get('/metrics', async (c) => { app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); // Public routes — no auth required (weather data is public, published -// websites are by definition public). +// 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()); @@ -133,6 +137,7 @@ 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); diff --git a/apps/api/src/modules/unlisted/public-routes.ts b/apps/api/src/modules/unlisted/public-routes.ts new file mode 100644 index 000000000..926ad7aaa --- /dev/null +++ b/apps/api/src/modules/unlisted/public-routes.ts @@ -0,0 +1,105 @@ +/** + * Unlisted Snapshots — public read endpoint. + * + * GET /:token — resolve a share-token to its snapshot blob. Returns + * { collection, blob, createdAt, expiresAt? }. Revoked + * or expired tokens → 410 Gone. Unknown tokens → 404. + * + * Rate-limited aggressively since this is the untrusted entry point: + * - 20 requests per minute per token (normal reload behaviour) + * - 60 requests per minute per client IP (enumeration-safety) + * + * Mounted pre-auth in index.ts so anonymous visitors can resolve + * their share links without a Mana account. + * + * See docs/plans/unlisted-sharing.md §2. + */ + +import { Hono } from 'hono'; +import { rateLimitMiddleware } from '@mana/shared-hono'; +import { eq } from 'drizzle-orm'; +import { errorResponse } from '../../lib/responses'; +import { db, snapshots } from './schema'; + +const routes = new Hono(); + +// Token-scoped rate limit. 20 requests/min per token covers a reasonable +// browser reload pattern; more than that on a single link is enumeration +// or a hammering client. +routes.use( + '/:token', + rateLimitMiddleware({ + max: 20, + windowMs: 60_000, + keyFn: (c) => `unlisted:token:${c.req.param('token')}`, + }) +); + +// IP-scoped rate limit. Stacks on the token limit — 60 req/min/IP caps +// how fast a scanner can enumerate the token space from one host. +routes.use( + '/:token', + rateLimitMiddleware({ + max: 60, + windowMs: 60_000, + keyFn: (c) => { + const ip = + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || + c.req.header('x-real-ip') || + 'unknown'; + return `unlisted:ip:${ip}`; + }, + }) +); + +const TOKEN_REGEX = /^[A-Za-z0-9_-]{32}$/; + +routes.get('/:token', async (c) => { + const token = c.req.param('token'); + if (!TOKEN_REGEX.test(token)) { + return errorResponse(c, 'Invalid token format', 400, { code: 'INVALID_TOKEN' }); + } + + const rows = await db + .select({ + token: snapshots.token, + collection: snapshots.collection, + blob: snapshots.blob, + createdAt: snapshots.createdAt, + updatedAt: snapshots.updatedAt, + expiresAt: snapshots.expiresAt, + revokedAt: snapshots.revokedAt, + }) + .from(snapshots) + .where(eq(snapshots.token, token)) + .limit(1); + + const row = rows[0]; + if (!row) { + return errorResponse(c, 'Link nicht gefunden', 404, { code: 'NOT_FOUND' }); + } + if (row.revokedAt) { + return errorResponse(c, 'Link wurde widerrufen', 410, { code: 'REVOKED' }); + } + if (row.expiresAt && row.expiresAt.getTime() < Date.now()) { + return errorResponse(c, 'Link ist abgelaufen', 410, { code: 'EXPIRED' }); + } + + // Short private cache — revocation is still near-immediate (60s + // max) but repeated hits from the same client don't hammer the db. + c.header('Cache-Control', 'private, max-age=60'); + // Belt-and-suspenders for search bots that don't read the HTML + // meta tag. + c.header('X-Robots-Tag', 'noindex, nofollow'); + + return c.json({ + token: row.token, + collection: row.collection, + blob: row.blob, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null, + }); +}); + +export const unlistedPublicRoutes = routes; diff --git a/apps/api/src/modules/unlisted/routes.ts b/apps/api/src/modules/unlisted/routes.ts new file mode 100644 index 000000000..36544430b --- /dev/null +++ b/apps/api/src/modules/unlisted/routes.ts @@ -0,0 +1,160 @@ +/** + * Unlisted Snapshots — authenticated create/update/revoke endpoints. + * + * POST /:collection/:recordId — publish or re-publish a snapshot. + * Body: { blob, expiresAt? }. Returns + * { token, url }. Re-publish keeps + * the existing active token unless + * it's been revoked (then a fresh + * row + fresh token). + * DELETE /:collection/:recordId — soft-revoke (revoked_at = now()). + * Idempotent; already-revoked is OK. + * + * Public GET lives in public-routes.ts — mounted pre-auth so anonymous + * visitors can resolve tokens. + * + * See docs/plans/unlisted-sharing.md §2. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { AuthVariables } from '@mana/shared-hono'; +import { errorResponse, validationError } from '../../lib/responses'; +import { db, snapshots } from './schema'; + +const routes = new Hono<{ Variables: AuthVariables }>(); + +/** + * Modules that are allowed to publish unlisted snapshots. v1 covers + * the three pilot modules from plan §Scope — Calendar, Library, + * Places. Expand as new modules adopt the system; keeps the server + * honest about what it accepts (a confused client trying to publish + * an arbitrary collection gets 400). + */ +const ALLOWED_COLLECTIONS = new Set(['events', 'libraryEntries', 'places']); + +const PublishBodySchema = z.object({ + spaceId: z.string().min(1).max(64), + blob: z.record(z.string(), z.unknown()), + expiresAt: z.string().datetime().optional(), +}); + +const TOKEN_BYTES = 24; // 24 random bytes → 32 base64url chars (~192 bits) + +function generateToken(): string { + const bytes = new Uint8Array(TOKEN_BYTES); + crypto.getRandomValues(bytes); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function buildShareUrl( + token: string, + c: { req: { header: (k: string) => string | undefined } } +): string { + // The share URL points at the SvelteKit web app, not the api host. + // In dev the web is on http://localhost:5173; in prod it's on + // https://mana.how. The api doesn't know its sibling's public host + // directly — use Origin/Referer, fall back to env, fall back to the + // prod host. + const envHost = process.env.MANA_WEB_ORIGIN; + const origin = c.req.header('origin') ?? c.req.header('referer') ?? envHost ?? 'https://mana.how'; + const cleaned = origin.replace(/\/$/, ''); + return `${cleaned}/share/${token}`; +} + +// ─── POST /:collection/:recordId ──────────────────────── + +routes.post('/:collection/:recordId', async (c) => { + const userId = c.get('userId'); + const collection = c.req.param('collection'); + const recordId = c.req.param('recordId'); + + if (!ALLOWED_COLLECTIONS.has(collection)) { + return errorResponse(c, `Collection "${collection}" not allowed for unlisted sharing`, 400, { + code: 'COLLECTION_NOT_ALLOWED', + }); + } + // Record ids in Mana are UUIDs client-side. Reject obviously malformed + // values to keep the unique index healthy. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(recordId)) { + return errorResponse(c, 'recordId must be a UUID', 400, { code: 'INVALID_RECORD_ID' }); + } + + const parsed = PublishBodySchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return validationError(c, parsed.error.issues); + const { spaceId, blob, expiresAt } = parsed.data; + + // Is there already an active snapshot for this record? Re-publish + // should reuse the existing token so link-shares don't break on edit. + const existing = await db + .select({ token: snapshots.token }) + .from(snapshots) + .where( + and( + eq(snapshots.userId, userId), + eq(snapshots.collection, collection), + eq(snapshots.recordId, recordId), + isNull(snapshots.revokedAt) + ) + ) + .limit(1); + + const now = new Date(); + + if (existing[0]) { + await db + .update(snapshots) + .set({ + blob, + expiresAt: expiresAt ? new Date(expiresAt) : null, + updatedAt: now, + }) + .where(eq(snapshots.token, existing[0].token)); + return c.json({ token: existing[0].token, url: buildShareUrl(existing[0].token, c) }, 200); + } + + const token = generateToken(); + await db.insert(snapshots).values({ + token, + userId, + spaceId, + collection, + recordId, + blob, + expiresAt: expiresAt ? new Date(expiresAt) : null, + createdAt: now, + updatedAt: now, + }); + return c.json({ token, url: buildShareUrl(token, c) }, 201); +}); + +// ─── DELETE /:collection/:recordId ────────────────────── + +routes.delete('/:collection/:recordId', async (c) => { + const userId = c.get('userId'); + const collection = c.req.param('collection'); + const recordId = c.req.param('recordId'); + + // Idempotent — revoke any active row for (user + collection + record). + // If there's none, 204 anyway. Keeps client-side logic simple: the + // store can unconditionally call DELETE on setVisibility-away. + const updated = await db + .update(snapshots) + .set({ revokedAt: new Date() }) + .where( + and( + eq(snapshots.userId, userId), + eq(snapshots.collection, collection), + eq(snapshots.recordId, recordId), + isNull(snapshots.revokedAt) + ) + ) + .returning({ token: snapshots.token }); + + return c.json({ revoked: updated.length }, 200); +}); + +export const unlistedRoutes = routes; diff --git a/apps/api/src/modules/unlisted/schema.ts b/apps/api/src/modules/unlisted/schema.ts new file mode 100644 index 000000000..3d5417ba0 --- /dev/null +++ b/apps/api/src/modules/unlisted/schema.ts @@ -0,0 +1,58 @@ +/** + * Unlisted module — DB schema (Drizzle / pgSchema 'unlisted') + * + * Server-side store for unlisted-share snapshots. One row per + * (user + collection + record) at any given time — a re-publish after + * revoke creates a fresh row with a new token. + * + * See docs/plans/unlisted-sharing.md §1. + * + * All id fields are `text` because both Mana user ids (Better-Auth + * nanoid) and space ids (Better-Auth organization nanoid) are + * strings, not UUIDs. See feedback/api-hand-authored-migrations for + * the regression where this rule was learned. + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import { pgSchema, uuid, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { getConnection } from '../../lib/db'; + +export const unlistedSchema = pgSchema('unlisted'); + +export const snapshots = unlistedSchema.table( + 'snapshots', + { + /** 32-char base64url token — also the URL path segment. */ + token: text('token').primaryKey(), + /** Owner. Better-Auth nanoid, not UUID. */ + userId: text('user_id').notNull(), + /** Where the original record lives. Better-Auth organization nanoid. */ + spaceId: text('space_id').notNull(), + /** Dexie collection name: 'events' | 'libraryEntries' | 'places' | … */ + collection: text('collection').notNull(), + /** Original record id from Dexie. Untethered UUID (no FK across stores). */ + recordId: uuid('record_id').notNull(), + /** Whitelist-filtered plaintext blob built by the client resolver. */ + blob: jsonb('blob').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + /** Optional expiry. `null` = never expires. */ + expiresAt: timestamp('expires_at', { withTimezone: true }), + /** Soft-delete timestamp. Revoked rows stay for audit + 410-Gone responses. */ + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (table) => [ + index('snapshots_record_idx').on(table.userId, table.collection, table.recordId), + index('snapshots_expiry_idx').on(table.expiresAt), + ] + // The partial unique index on (user_id, collection, record_id) + // WHERE revoked_at IS NULL lives in the SQL migration — drizzle-orm + // doesn't express partial-index predicates cleanly. See 0000_init.sql. +); + +export const db = drizzle(getConnection(), { + schema: { snapshots }, +}); + +export type SnapshotRow = typeof snapshots.$inferSelect; +export type NewSnapshot = typeof snapshots.$inferInsert; diff --git a/docs/plans/unlisted-sharing.md b/docs/plans/unlisted-sharing.md new file mode 100644 index 000000000..bb2774c26 --- /dev/null +++ b/docs/plans/unlisted-sharing.md @@ -0,0 +1,504 @@ +# Unlisted Sharing — Plan (M8 des Visibility-Systems) + +## Status (2026-04-24) + +**PLANUNG** — noch kein Code. Dieses Dokument schreibt die Design-Entscheidungen fest, bevor Code entsteht. Ergänzung zum `docs/plans/visibility-system.md`-Plan (M8 dort war nur Stub). Der Ausgangspunkt: das Visibility-System (M1–M5.c shipped) hat die Stufe `'unlisted'` als vierten Level definiert, aber die Share-Route + der Snapshot-Mechanismus fehlen — aktuell wird beim Flip auf `'unlisted'` nur ein `unlistedToken` lokal auf den Record gesetzt, der Token führt ins Leere. + +## Ziel + +Wenn ein User einen Record auf `visibility = 'unlisted'` setzt, soll: + +1. Ein Server-seitiger Snapshot erzeugt werden (whitelist-gefiltert, field-redacted), +2. Eine öffentliche URL `https://mana.how/share/` den Snapshot serverseitig (SSR) rendern — zugänglich für jeden Besucher ohne Mana-Account, +3. Der User den Link aus der App kopieren, als QR-Code zeigen, ablaufen lassen oder widerrufen können. + +Konkrete Use-Cases die das löst: +- Event mit Nicht-Member-Gästen teilen (Geburtstag, Konzert, Rehearsal) +- Buch/Film-Empfehlung an Freunde schicken +- "Lieblings-Café"-Tipp weitergeben + +## Abgrenzung + +- **Nicht Public-Website.** Websites (`visibility='public'`) laufen weiter über den bestehenden Website-Builder + `published_snapshots`-Mechanismus. Unlisted ist der "ein einzelner Record, geteilt via Link" Fall. +- **Kein Editing durch Link-Empfänger.** Der Share-Link ist read-only. Rückmeldungen (RSVP, Kommentare) sind nicht Phase 1. +- **Keine Personalisierung der Empfänger-Seite.** Keine "Hallo {Name}"-Anzeige — der Link-Empfänger ist anonym. +- **Keine Multi-Tokens pro Record.** Ein Record hat zu einem Zeitpunkt genau einen aktiven Token. "Unterschiedliche Links für verschiedene Leute" ist v2. +- **Keine Passwort-Protection auf Token-Ebene** in v1. Der Token selbst (32 char base64url, ~192 bit entropy) ist der Schutz. +- **Keine Zero-Knowledge-Komplikationen gelöst** in v1: der Publish-Snapshot wird **clientseitig entschlüsselt** und als plaintext-Blob gepusht. Das funktioniert in Standard- UND ZK-Mode (Client hat den MK, baut den Snapshot, Server kriegt nur das Whitelist-Ergebnis). + +## Warum Architektur A (Publish-Snapshot) + +Vier Alternativen wurden diskutiert, A gewann klar. Kurzfassung: + +- **A: Publish-Snapshot** (gewählt). Bei Flip-auf-unlisted baut Client einen plaintext-Blob mit Whitelist-Feldern und pusht an mana-api in eine `unlisted.snapshots`-Tabelle. Share-Route liest daraus. +- **B: Plaintext-Row in mana-sync.** Record wird in der Sync-DB plaintext gespeichert. Bricht Encryption-Invariante, zerstört ZK, kein Field-Whitelist. Abgelehnt. +- **C: Server-Side On-Demand-Decrypt.** Share-Route fragt mana-api, das entschlüsselt den encrypted Record server-seitig und serviert. Funktioniert nicht in ZK-Mode, größere Security-Surface, schlechter Audit. Abgelehnt. +- **D: Signed-URL mit Blob in URL.** Kein Revoke, riesige URLs. Abgelehnt. + +Warum A gewinnt: +- **ZK-kompatibel.** Client macht Decrypt. +- **Fail-safe Redaction.** Whitelist-Filter passiert einmal beim Publish, nicht bei jedem Request. +- **Konsistent mit Mana-Patterns.** Website-Builder macht fast identisches Muster mit `published_snapshots`. +- **Revoke ist O(1).** Delete-Row. +- **Expiry natürlich.** Cron-Cleanup. +- **Audit-freundlich.** Snapshot-Tabelle ist der explizite "geteilter Stand zum Zeitpunkt X". + +Tradeoff: Snapshot veraltet wenn User den Original-Record editiert. **Mitigation: Re-Publish-on-Edit** — Store-Hooks bei `updateX` rebuilden + pushen, wenn `visibility === 'unlisted'` auf dem Record ist. + +## Entscheidung: Module-Scope (erste Runde) + +**In v1:** +- `calendar.events` — primärer Use-Case (Event mit externen Gästen) +- `library.entries` — einfach, klar, hoher Demo-Wert +- `places.places` — "Lieblings-Ort"-Share + +**Nicht in v1, folgen später mit gleichem Pattern:** +- `recipes.recipes` (v1.1) +- `wardrobe.outfits` (v1.1, leichte Media-Komplexität) +- `todo.tasks`, `goals.goals` (v2 wenn Nachfrage) +- `picture.board` (v2, Media-Handling nicht trivial) + +## Core-Architektur + +### 1. Datenbank — pgSchema `unlisted` in `mana_platform` + +```sql +create schema unlisted; + +create table unlisted.snapshots ( + token text primary key, -- 32-char base64url + user_id text not null, -- Better-Auth nanoid + space_id text not null, -- wo der Original-Record lebt + collection text not null, -- 'events' | 'libraryEntries' | 'places' | … + record_id uuid not null, -- Original-Record-ID in Dexie + blob jsonb not null, -- whitelist-gefilterter Content + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + expires_at timestamptz, -- nullable = never expires + revoked_at timestamptz -- soft-delete für Audit +); + +-- ein Record hat maximal einen aktiven Token: +create unique index snapshots_record_active_idx + on unlisted.snapshots (user_id, collection, record_id) + where revoked_at is null; + +-- Expiry-Cleanup-Query: +create index snapshots_expiry_idx + on unlisted.snapshots (expires_at) + where expires_at is not null and revoked_at is null; +``` + +**Token-Format:** 32-char base64url. Generiert via `generateUnlistedToken()` aus `@mana/shared-privacy` — existiert bereits, ~192 bits Entropie. + +**Token-vs-Primärschlüssel:** Token ist Primärschlüssel → GET-by-Token ist O(1) index-Lookup. Collision-Chance auf 10^40 Samples praktisch null. + +**space_id** wird mitgespeichert für spätere Space-Admin-Features ("widerrufe alle Unlisted-Links in diesem Space") — nicht in v1 genutzt aber billig mitzuschleppen. + +**Keine FK** auf andere Tabellen (Records leben in mana-sync, nicht hier). Orphaning beim Record-Delete wird explizit im Store gehandelt (siehe unten). + +### 2. mana-api — neues Modul `apps/api/src/modules/unlisted/` + +Drei Endpoints: + +| Methode | Pfad | Auth | Zweck | +|---------|------|------|-------| +| `POST` | `/api/v1/unlisted/:collection/:recordId` | JWT | Create-or-Update Snapshot. Body: `{ blob, expiresAt? }`. Returns `{ token, url }`. | +| `DELETE` | `/api/v1/unlisted/:collection/:recordId` | JWT | Revoke. Markiert `revoked_at=now()`. | +| `GET` | `/api/v1/unlisted/:token` | Public, rate-limited | Serve Snapshot für Render. Returns `{ collection, blob, expiresAt }`. 404 wenn nicht-existent/revoked/expired. | + +**Collection-Allowlist im Server:** Der POST-Handler validiert `collection` gegen eine explizite Liste (`['events', 'libraryEntries', 'places']` in v1). Beliebige Strings landen in 400. Schutz gegen verwirrte Clients. + +**Auth-Prüfung beim POST:** JWT `sub` muss mit `user_id` aus dem Request matchen. Record-Ownership wird vom Client angenommen — der Client hat ja den entschlüsselten Record gebaut. Server-side doppelt zu prüfen wäre überflüssig (wir haben den Record gar nicht auf dem Server strukturiert zum Abgleichen). + +**Revoke-Semantik:** `DELETE` ist hart — flipt `revoked_at`. Ein erneutes POST mit gleichem `(user_id, collection, recordId)` erzeugt einen **neuen** Row mit neuem Token (der Unique-Index lässt revoked Rows nebeneinander stehen). + +**Rate-Limit auf GET:** +- 20 req/min pro Token (normales Reload-Verhalten) +- 60 req/min pro IP (Schutz gegen Token-Enumeration) + +Beide via `rateLimitMiddleware` aus `@mana/shared-hono`, mit custom `keyFn`. + +**404 vs 410:** Revokierte/abgelaufene Tokens → `410 Gone`. Nicht-existente → `404 Not Found`. UX-Hilfe für SSR-Page: klare Unterscheidung "Link wurde widerrufen" vs. "Link war nie gültig". + +**Hand-authored SQL-Migration** unter `apps/api/drizzle/unlisted/0000_init.sql`. Drizzle-Schema unter `apps/api/src/modules/unlisted/schema.ts`. Wird in `drizzle.config.ts` als managed schema registriert. + +### 3. Client-Helper + +**Neuer Helper in `@mana/shared-privacy/src/unlisted-client.ts`:** + +```ts +export interface PublishUnlistedOptions { + collection: string; + recordId: string; + blob: Record; + expiresAt?: Date | null; + apiUrl: string; + jwt: string; +} + +export async function publishUnlistedSnapshot( + opts: PublishUnlistedOptions +): Promise<{ token: string; url: string }>; + +export async function revokeUnlistedSnapshot(opts: { + collection: string; + recordId: string; + apiUrl: string; + jwt: string; +}): Promise; +``` + +Das ist thin wrapper über `fetch` — handled Auth-Header, serialisiert JSON, wirft bei !ok. + +**Module-Resolver in `apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts`** (analog zu `website/embeds.ts`, aber per-Record statt per-Collection): + +```ts +export async function buildUnlistedBlob( + collection: string, + recordId: string +): Promise>; +``` + +Dispatcher-Struktur intern: +- `events` → `buildEventBlob` (join mit timeBlock für startTime/endTime/allDay, decryptRecord, whitelist: title, startTime, endTime, isAllDay, location, calendarId) +- `libraryEntries` → `buildLibraryBlob` (decryptRecord, whitelist: title, kind, creators, year, coverUrl, coverMediaId, rating) +- `places` → `buildPlaceBlob` (decryptRecord, whitelist: name, address, category — **lat/lng NICHT inlined**) + +Der Whitelist-Kontrakt ist hart: was nicht explizit gelistet ist, wird nicht serialisiert. Das ist identisch zum website/embeds-Pattern. + +### 4. Store-Integration + +Jeder affektierte Store (`eventsStore`, `libraryEntriesStore`, `placesStore`) bekommt drei Hooks: + +**Hook 1: `setVisibility` flippt auf `'unlisted'` — Snapshot erstellen + Token speichern** + +Existierend: `setVisibility` mintet schon `unlistedToken` auf dem Dexie-Record. **Erweiterung:** bei Flip auf `'unlisted'` auch `buildUnlistedBlob` + `publishUnlistedSnapshot` aufrufen, Response-Token ersetzt lokalen Token (Server ist Authority fürs Mapping). + +**Hook 2: `setVisibility` flippt weg von `'unlisted'` — Snapshot revoken** + +Existierend: `setVisibility` wischt `unlistedToken` weg. **Erweiterung:** `revokeUnlistedSnapshot` aufrufen vorher. + +**Hook 3: `updateX` berührt Whitelist-Feld — Re-Publish** + +Nach jedem erfolgreichen `updateEvent`/`updateEntry`/`updatePlace`: wenn Record `visibility === 'unlisted'` hat, `buildUnlistedBlob` + `publishUnlistedSnapshot` mit gleicher `recordId` (Server updated den bestehenden Row). Kein Token-Change. + +Pro Store: ~15 Zeilen neue Logik. Macht jeden Edit-Pfad automatisch Re-Publishing — der Share-Link zeigt immer den aktuellen Stand. + +### 5. Share-Routes (SvelteKit SSR) + +**`/share/[token]/+page.server.ts`** — fetcht Snapshot von mana-api: + +```ts +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + const res = await fetch(`${MANA_API}/api/v1/unlisted/${params.token}`); + if (res.status === 404) error(404, 'Link nicht gefunden'); + if (res.status === 410) error(410, 'Link wurde widerrufen oder ist abgelaufen'); + if (!res.ok) error(res.status, 'Fehler beim Laden'); + + const data = await res.json(); + + setHeaders({ + 'cache-control': 'private, max-age=60', + 'x-robots-tag': 'noindex, nofollow', + }); + + return data; +}; +``` + +Caching: `private, max-age=60`. CDN-Cache ausgeschlossen (sonst könnte ein Token-Wechsel zwischen User-Session und CDN-Cache leaken). 60s ist akzeptabel für "ich hab gerade geändert und es zeigt noch den alten Stand" — User reload + erlebt frischen Zustand. + +**`/share/[token]/+page.svelte`** — Dispatcher: + +```svelte + + +{#if data.collection === 'events'} + +{:else if data.collection === 'libraryEntries'} + +{:else if data.collection === 'places'} + +{:else} +

Unbekannter Link-Typ.

+{/if} +``` + +**`/share/[token]/+layout.svelte`** — minimal Chrome: +- Kein App-Header, keine PillNavigation, keine Auth-Checks +- Schlichter Footer: "Geteilt via Mana · mana.how · [kostenlos registrieren]" +- Theme: Light-Mode default (für Link-Embed-Preview-Kompatibilität) + +**`/share/[token]/ical/+server.ts`** — nur für Events: + +```ts +export const GET: RequestHandler = async ({ params, fetch }) => { + const res = await fetch(`${MANA_API}/api/v1/unlisted/${params.token}`); + if (!res.ok) return new Response('Not found', { status: 404 }); + const { collection, blob } = await res.json(); + if (collection !== 'events') return new Response('Wrong type', { status: 400 }); + + const ics = buildIcsFromBlob(blob); + return new Response(ics, { + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="event.ics"`, + 'cache-control': 'private, max-age=60', + }, + }); +}; +``` + +`buildIcsFromBlob` ist ~30 Zeilen RFC 5545, keine npm-Library nötig. + +### 6. Per-Modul Share-Views + +Drei neue Svelte-Components — clean, standalone, keine App-Dependencies: + +**`apps/mana/apps/web/src/lib/modules/calendar/SharedEventView.svelte`:** +- Groß: Datum (formatiert nach Locale) +- Titel als h1 +- Ort mit Map-Pin-Icon +- Button "📅 Zum Kalender hinzufügen" → `/share/[token]/ical` +- CTA "Mit Mana eigene Events erstellen →" am Ende + +**`apps/mana/apps/web/src/lib/modules/library/SharedLibraryEntryView.svelte`:** +- Cover-Bild groß (aus `coverUrl` oder `coverMediaId` via `mediaFileUrl`) +- Titel + Kind-Badge +- Creators + Jahr +- Rating-Stars +- CTA "Mit Mana eigene Library führen →" + +**`apps/mana/apps/web/src/lib/modules/places/SharedPlaceView.svelte`:** +- Map-Embed (OpenStreetMap iframe — lat/lng ist nicht im Blob, aber aus Adresse geocodable oder — Entscheidung später —: doch im Blob für Map-Rendering, aber nur für Places, nicht für Events?) +- **Offen:** siehe Design-Frage 7 +- Name, Adresse +- Kategorie-Badge +- CTA "Mit Mana deine Orte sammeln →" + +### 7. UI-Integration — SharedLinkControls + +Neue Komponente in `@mana/shared-privacy/src/SharedLinkControls.svelte`. Wird unter dem `VisibilityPicker` eingeblendet wenn `level === 'unlisted'` und ein Token vorhanden ist. + +**Props:** +```ts +{ + token: string; + url: string; // vollständige Share-URL + onRegenerate: () => Promise; + onRevoke: () => Promise; + expiresAt?: string | null; + onExpiryChange?: (expiresAt: Date | null) => Promise; +} +``` + +**UI-Elemente:** +- URL als monospace, truncated +- Icon-Buttons: 📋 Kopieren · 🔗 QR · 🔄 Neu erzeugen · 🗑 Widerrufen +- Datepicker "Läuft ab: [Datum]" — optional +- Kopieren-Toast: "Link kopiert" +- QR-Dropdown: inline SVG-QR-Code (Library: `qrcode` npm, ~20kB gzipped, einfache API) +- Regenerate-Confirm-Dialog: "Alter Link wird sofort ungültig. Weiter?" + +Einbindung in die drei DetailViews: +- Calendar: unter dem `VisibilityPicker` in der `prop-row--labeled`-Struktur +- Library: unter dem Picker im `
`-Block +- Places: unter dem Picker in der `
`-Struktur + +### 8. OG-Meta-Tags + SEO + +Jede Share-View setzt per `` OG- und Twitter-Card-Tags für Preview-Embedding in Chats (WhatsApp, Slack, Discord, Email): + +**Calendar:** +```html + + {blob.title} · Geteilt via Mana + + + + + + +``` + +**Library:** +```html + + {blob.title} · Geteilt via Mana + + + + {#if blob.coverUrl}{/if} + + +``` + +**Places:** +```html + + {blob.name} · Geteilt via Mana + + + + +``` + +`noindex, nofollow` sowohl HTTP-Header (aus load()) als auch Meta — Google + Bing respektieren beides, Bots die nur Meta lesen auch. + +### 9. Expiry-Cleanup + +**Cron in mana-api** — alle 60 Minuten: + +```sql +update unlisted.snapshots +set revoked_at = now() +where revoked_at is null + and expires_at is not null + and expires_at < now(); +``` + +Soft-delete via `revoked_at=now()` statt hart `DELETE` — damit `GET /unlisted/:token` weiterhin `410 Gone` liefern kann (anstatt `404` zurückzugeben als wäre der Link nie existiert). + +**Hard-Delete-Cron** alle 24h: löscht Rows mit `revoked_at < now() - interval '30 days'` — Tabelle klein halten. + +Setup via simplem `setInterval` in mana-api-index oder via bun-Cron-Package. Minimaler Impact. + +### 10. Security-Checkliste + +- [ ] Token-Format: strict regex-validiert (32 char base64url, `^[A-Za-z0-9_-]{32}$`) +- [ ] Rate-Limit 20/min/Token + 60/min/IP auf GET +- [ ] `unlistedToken` wird **nur client-seitig** auf den Dexie-Record geschrieben (via `setVisibility`); Server kennt das Lokal-Token-Mapping nicht +- [ ] Field-Whitelist hart im Resolver — keine "spread-all"-Versuchung +- [ ] `noindex, nofollow` sowohl HTTP-Header als auch Meta +- [ ] SSR-Cache `private, max-age=60` (keine CDN-Cross-Leak) +- [ ] Revoke ist **sofort wirksam** beim nächsten GET-Request (60s-SSR-Cache akzeptabel) +- [ ] Audit via Domain-Events (siehe unten) +- [ ] Keine PII in Server-Logs: Token in Logs **redacted** zu ersten 6 Chars + "...", nur `user_id + collection + status` in Metrics +- [ ] Collection-Allowlist beim POST — unbekannte Collection-Namen → 400 + +### 11. Domain-Events + +Neue cross-modul-Events im Catalog (`apps/mana/apps/web/src/lib/data/events/catalog.ts`): + +```ts +export interface UnlistedSnapshotCreatedPayload { + recordId: string; + collection: string; + /** Nur die ersten 6 Chars für Audit — kompletter Token nicht im Log. */ + tokenPrefix: string; + expiresAt?: string; +} + +export interface UnlistedSnapshotRevokedPayload { + recordId: string; + collection: string; + tokenPrefix: string; + reason: 'user-revoke' | 'visibility-flip' | 'expired' | 'record-deleted'; +} +``` + +Emitted aus dem jeweiligen Store beim Publish/Revoke. Landet im `_events`-Log → sichtbar in der Workbench-Timeline → auditable. + +## Rollout — Milestones + +### M8.1 — Backend Foundation +- `unlisted` pgSchema + `0000_init.sql` Migration +- Drizzle-Schema (`schema.ts`) +- Drizzle-config um `unlisted` erweitern +- mana-api-Modul `modules/unlisted/` mit 3 Endpoints + Rate-Limit +- Routes in `apps/api/src/index.ts` registrieren +- Migration applyieren lokal +- Smoke-Test via curl/httpie: POST → GET → DELETE Zyklus + +**Aufwand:** ~4h + +### M8.2 — Shared Client-Helper + SharedLinkControls +- `@mana/shared-privacy/src/unlisted-client.ts` (`publishUnlistedSnapshot`, `revokeUnlistedSnapshot`) +- `@mana/shared-privacy/src/SharedLinkControls.svelte` +- Tests: HTTP mocked +- Export aus `index.ts` + +**Aufwand:** ~2h + +### M8.3 — Calendar als Pilot-Modul +- `lib/data/unlisted/resolvers.ts` mit `buildEventBlob` + Dispatcher-Stub +- `eventsStore.setVisibility` + `updateEvent` Store-Hooks +- `/share/[token]/+page.server.ts` + Dispatcher + Layout +- `SharedEventView.svelte` mit OG-Tags +- `/share/[token]/ical/+server.ts` (iCal-Export) +- `buildIcsFromBlob` Helper +- `` in Calendar-DetailView + EventDetailModal einbinden +- Domain-Events registrieren + emitten + +**Aufwand:** ~4h + +### M8.4 — Library + Places +- `buildLibraryBlob` + `buildPlaceBlob` Resolver +- `SharedLibraryEntryView.svelte` + `SharedPlaceView.svelte` mit OG-Tags +- Store-Hooks in beide Module +- `` in beide DetailViews einbinden + +**Aufwand:** ~3h + +### M8.5 — Polish +- QR-Code (qrcode npm Package integrieren) +- Expiry-Datepicker +- Regenerate-Flow mit Confirm-Dialog +- End-to-End-Test: Incognito-Tab öffnet den Link, sieht den Snapshot +- Link-Preview-Test: URL in WhatsApp/Slack pasten, OG-Preview erscheint + +**Aufwand:** ~2h + +### M8.6 — Expiry-Cleanup + Observability +- Cron in mana-api für expired/old-revoked snapshots +- Prometheus-Metrics: `mana_unlisted_snapshots_active_total`, `mana_unlisted_fetch_total{collection,status}` +- Record-Delete-Hook: `deleteEvent`/`deleteEntry`/`deletePlace` revoken automatisch den Snapshot + +**Aufwand:** ~1h + +### Gesamtschätzung +~15h über 4–5 Tage, passt für iterative Reviews zwischen Milestones. + +## Offene Design-Entscheidungen + +Die folgenden Entscheidungen wurden mit Empfehlung mitgeplant und sind vor M8.1-Start zu bestätigen: + +1. **Share-Domain.** `mana.how/share/:token` (vs. kürzere Domain wie `m.how`). **Empfehlung:** `mana.how/share/:token` — konsistent mit der Marke, noindex macht SEO-Pfad irrelevant. + +2. **Token-Länge.** 32 char (aktuell, ~192 bits) vs. 20 char (~120 bits). **Empfehlung:** bleiben bei 32 — User kopiert eh, die 12 Extra-Zeichen kosten nichts, Overkill-Entropie ist billiger Versicherung. + +3. **Space-Siloierung.** Sehen andere Space-Member den Token? **Empfehlung:** nein, nur Author sieht den Link im UI. Token selbst ist publik-lesbar wenn bekannt (by design). + +4. **Cross-Device.** `unlistedToken` auf dem Dexie-Record wird via mana-sync zu anderen Devices propagiert → Desktop hat den Link auch. ✓ **Funktioniert automatisch**, keine Extra-Arbeit. + +5. **Record-Delete-Verhalten.** Wird der Snapshot gelöscht wenn der Original-Record gelöscht wird? **Empfehlung:** Ja — Store-`deleteX`-Methoden revoken den Snapshot explizit (siehe M8.6). Alternativ: Server-Cron prüft Record-Existenz → komplex + unnötig. + +6. **Was wird geteilt? — Preview vor Flip.** Soll User vor Opt-In sehen was genau serialisiert wird? **Empfehlung:** ja, aber als kleiner Text unter dem Picker ("Geteilt werden: Titel, Datum, Ort") — kein separater Dialog, kein Hover-Tooltip. Default-Platz: unter `SharedLinkControls`. + +7. **Lat/Lng bei Places.** Whitelist sagt: NICHT inlined (Home-Leak-Risiko). Aber dann keine Map auf der Share-Page. **Empfehlung:** für v1 **nur Adresse + Category, keine Karte**. v1.1 kann mit Opt-In-Toggle "Karte im Link zeigen" nachgerüstet werden. + +## Anti-Patterns + +Bewusst nicht gebaut: + +1. **Kein Server-Side-Decrypt.** Der Server hat nie den MK für Unlisted-Zwecke. Alle Decryption passiert im Client. +2. **Kein Record-to-Token-Mapping ohne Whitelist.** Was nicht im Resolver steht, gelangt nicht ins Blob. "Spread the record"-Shortcut ist verboten. +3. **Kein Editieren durch Empfänger.** Share-Page ist read-only, keine Kommentar- oder RSVP-Form. +4. **Keine Auto-Token-Regeneration.** Tokens ändern sich nur bei explizitem User-"Neu erzeugen"-Klick. Keine Zeit-basierte Rotation (würde Links brechen). +5. **Keine Public-Indexing der Share-URLs.** `noindex` streng durchgesetzt. Google soll Share-Links nicht surfacen. +6. **Kein CDN-Cache.** `private, max-age=60` only — kein CloudFlare-Edge-Cache der cross-Token leaken könnte. +7. **Kein Password-Protected-Token-Flow.** Token selbst ist der Schutz. Passwort-Extra wäre v2 für "zusätzliche Barriere" — nicht v1. +8. **Keine Rendering-Telemetrie cross-User.** Wir sehen NICHT wer welchen Share-Link öffnet (nur Metrics aggregate). Klar kommuniziert an den Owner: "wir zeigen dir keinen Viewer-Log". + +## Referenz + +- Visibility-System-Plan: [`docs/plans/visibility-system.md`](visibility-system.md) (M8-Stub wird durch dieses Doc ersetzt) +- Website-Builder: [`docs/plans/website-builder.md`](website-builder.md) (Snapshot-Pattern-Präzedenz) +- `@mana/shared-privacy`-Package: `packages/shared-privacy/` +- Token-Generator: `packages/shared-privacy/src/tokens.ts`