feat(cards-server): Phase δ.1 — subscriptions + version reads + smart-merge diff

Server-side plumbing for Phase δ. Frontend hookup follows in δ.2.

  - services/subscriptions.ts: subscribe/unsubscribe (idempotent
    upsert on (user, deck), stamps the latest_version_id at
    subscribe-time so the client knows what it pulled). listForUser
    returns each sub with `updateAvailable: currentVersion !== latest`
    so the client can render an update indicator without a second
    round-trip. Refuses paid decks with 403 — that path comes back
    in Phase ζ once the credits Marketplace lands.
  - versionWithCards: deterministic ord-ordered card payload for a
    specific version. Read-public so anonymous browsers can preview
    a deck's content.
  - diffSince: smart-merge payload between any two versions. Splits
    the latest cards into added/changed/unchanged + lists removed
    by content_hash. The 'changed' bucket is heuristic (ord-position
    pair where one was removed and one was added) — solid enough
    until Phase ε's pull-request pipeline gives us real card
    lineage.
  - routes/subscriptions.ts mounts: GET /v1/me/subscriptions,
    POST/DELETE /v1/decks/:slug/subscribe (auth required),
    GET /v1/decks/:slug/versions/:semver (public),
    GET /v1/decks/:slug/diff?from=<semver> (public).

cards-web layout fix:
  - Marketplace surface (/explore, /u/, /d/) was previously gated
    behind the AuthGate — anonymous browsers got pushed to /login
    via client-side navigate. PUBLIC_PATHS extended so those routes
    SSR + render unauthed.

Validated: tsc clean on cards-server, svelte-check 0/0 on cards-web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 19:33:58 +02:00
parent e77134bd8b
commit 86a01426e8
4 changed files with 317 additions and 1 deletions

View file

@ -14,7 +14,10 @@
// Auth/marketing pages render outside the gate so first-time visitors
// can actually reach them. Everything else is gated.
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
// Public marketplace surface — anyone can browse decks/profiles/explore
// without signing in. AuthGate kicks in once the user opens their own
// decks/learn pages.
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/explore', '/u/', '/d/'];
const isPublic = $derived(PUBLIC_PATHS.some((p) => page.url.pathname.startsWith(p)));
function handleAuthReady() {