mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue