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:
Till JS 2026-04-24 17:12:13 +02:00
parent 6f37e00bf4
commit 92bee0d71a
7 changed files with 873 additions and 4 deletions

View file

@ -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'],
});

View 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;

View file

@ -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);

View 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;

View 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;

View 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;