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