mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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>
This commit is contained in:
parent
6f37e00bf4
commit
92bee0d71a
7 changed files with 873 additions and 4 deletions
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
33
apps/api/drizzle/unlisted/0000_init.sql
Normal file
33
apps/api/drizzle/unlisted/0000_init.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
105
apps/api/src/modules/unlisted/public-routes.ts
Normal file
105
apps/api/src/modules/unlisted/public-routes.ts
Normal file
|
|
@ -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;
|
||||
160
apps/api/src/modules/unlisted/routes.ts
Normal file
160
apps/api/src/modules/unlisted/routes.ts
Normal file
|
|
@ -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<string>(['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;
|
||||
58
apps/api/src/modules/unlisted/schema.ts
Normal file
58
apps/api/src/modules/unlisted/schema.ts
Normal file
|
|
@ -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;
|
||||
504
docs/plans/unlisted-sharing.md
Normal file
504
docs/plans/unlisted-sharing.md
Normal file
|
|
@ -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/<token>` 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<string, unknown>;
|
||||
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<void>;
|
||||
```
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
```
|
||||
|
||||
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
|
||||
<script>
|
||||
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
|
||||
import SharedLibraryEntryView from '$lib/modules/library/SharedLibraryEntryView.svelte';
|
||||
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.collection === 'events'}
|
||||
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
||||
{:else if data.collection === 'libraryEntries'}
|
||||
<SharedLibraryEntryView blob={data.blob} />
|
||||
{:else if data.collection === 'places'}
|
||||
<SharedPlaceView blob={data.blob} />
|
||||
{:else}
|
||||
<p>Unbekannter Link-Typ.</p>
|
||||
{/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<void>;
|
||||
onRevoke: () => Promise<void>;
|
||||
expiresAt?: string | null;
|
||||
onExpiryChange?: (expiresAt: Date | null) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**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 `<dl class="details">`-Block
|
||||
- Places: unter dem Picker in der `<div class="fields">`-Struktur
|
||||
|
||||
### 8. OG-Meta-Tags + SEO
|
||||
|
||||
Jede Share-View setzt per `<svelte:head>` OG- und Twitter-Card-Tags für Preview-Embedding in Chats (WhatsApp, Slack, Discord, Email):
|
||||
|
||||
**Calendar:**
|
||||
```html
|
||||
<svelte:head>
|
||||
<title>{blob.title} · Geteilt via Mana</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta property="og:title" content={blob.title} />
|
||||
<meta property="og:description" content="{formatDate(blob.startTime)}{#if blob.location}· {blob.location}{/if}" />
|
||||
<meta property="og:type" content="event" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</svelte:head>
|
||||
```
|
||||
|
||||
**Library:**
|
||||
```html
|
||||
<svelte:head>
|
||||
<title>{blob.title} · Geteilt via Mana</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta property="og:title" content={blob.title} />
|
||||
<meta property="og:description" content={`${blob.creators?.[0] ?? ''} · ${blob.year ?? ''}`} />
|
||||
{#if blob.coverUrl}<meta property="og:image" content={blob.coverUrl} />{/if}
|
||||
<meta property="og:type" content="book" />
|
||||
</svelte:head>
|
||||
```
|
||||
|
||||
**Places:**
|
||||
```html
|
||||
<svelte:head>
|
||||
<title>{blob.name} · Geteilt via Mana</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta property="og:title" content={blob.name} />
|
||||
<meta property="og:description" content={blob.address ?? ''} />
|
||||
</svelte:head>
|
||||
```
|
||||
|
||||
`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
|
||||
- `<SharedLinkControls>` 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
|
||||
- `<SharedLinkControls>` 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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue