- mana-notify integration in cards-server: PR-create notifies the
deck owner, merge/reject notifies the PR author. Fire-and-forget
via lib/notify.ts so a notify-service outage never rolls back a
domain action. ExternalIDs are deterministic (cards.pr.{event}.{id})
so retries dedupe.
- <CardDiscussions> on the learn page: collapsed by default, opens
via "💬 Diskussion" alongside the "✏️ Verbessern" trigger. Resets
whenever the current card changes so the panel doesn't bleed
between flashcards.
- MARKETPLACE_PLAN.md §13a — known limitations: PR-merge is
stale-blind (no rebase yet), diff-preview flat, threading 1-level.
Server (cards-server):
- PullRequestService: create / list / get / merge / close / reject.
Merge applies the PR's {add, modify, remove} diff to the latest
version's cards in a single transaction, writes a new
deck_version + deck_cards, bumps latest_version_id, and stamps
the PR with mergedIntoVersionId.
- DiscussionService: post / listForCard / hide. Threads are keyed
by card_content_hash so they survive version bumps.
- Routes mounted under /v1: POST/GET /decks/:slug/pull-requests,
GET /pull-requests/:id, POST /pull-requests/:id/{merge,close,reject},
GET/POST /cards/:contentHash/discussions, POST /discussions/:id/hide.
Frontend (cards-web):
- cardsApi.pullRequests + cardsApi.discussions client surface.
- <PullRequestsSection> on /d/:slug — lists PRs with diff preview;
owner sees Merge/Reject/Close buttons.
- <SuggestEditModal> + "✏️ Verbessern" button on /learn/:deckId for
cards from a subscribed deck — submits a one-card modify (or
remove) PR using the card's serverContentHash as the previous
hash.
- Deck/Card DTOs gain subscribedFromSlug + serverContentHash so the
learn page can decide whether to show the suggest-edit affordance.
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>
Marketplace discovery surface lights up. Anonymous browsers can
explore + search; signed-in users get the same surface plus star/
follow mutations.
- middleware/optional-auth.ts: opportunistic JWT — sets c.get('user')
if a token validates, otherwise leaves it undefined. Read paths
use this; mutating routes call requireUser() inline.
- services/explore.ts: browse() with q (ilike on title/description),
tag, language, author-slug, sort (recent/popular/trending), pagination.
explore() composes featured + trending for the landing.
tagTree()/curatedTagsOnly() round it out. Subqueries for star/
subscriber counts avoid N+1.
- services/engagement.ts: star/unstar deck, follow/unfollow author.
Idempotent via ON CONFLICT DO NOTHING. Self-follow rejected.
- routes/explore.ts mounts /v1/explore, /v1/decks (browse list),
/v1/tags. routes/engagement.ts mounts /v1/decks/:slug/star
(POST/DELETE) + /v1/authors/:slug/follow (POST/DELETE).
- index.ts replaces the previous strict-jwt-on-everything middleware
with optionalAuth on all of /v1, then individual routers gate
their write paths via local requireUser(). Hono context type
relaxes from `user: AuthUser` to `user?: AuthUser` accordingly.
Validated: tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shared-hono's serviceErrorHandler only translates HTTPException
instances; anything else degrades to 500. Our custom Error subclasses
were silently bypassing the translation layer, so a missing JWT came
back as `500 Internal server error` instead of the expected `401
Unauthorized`. Confirmed in prod logs after the Phase-β deploy.
Switching the error hierarchy to extend HTTPException directly. The
JSON body now carries the right status code + the existing `cause`
object surfaces our `code` discriminator + zod-style `details` for
BadRequest. No call-site changes needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First user-facing surface on cards-server. Three endpoint groups:
Authors (/v1/authors):
- POST /me — upsert author profile (slug, displayName, bio,
avatarUrl, pseudonym). Slug validated for length, charset, and
against a small reserved-words list (admin, api, me, ...).
- GET /me — read own profile (returns null if not yet an author).
- GET /:slug — public profile (omits banned-reason, etc.)
Decks (/v1/decks):
- POST / — claim a slug + create the metadata-only deck row.
License defaults to Cards-Personal-Use-1.0; paid decks
(priceCredits > 0) must use Cards-Pro-Only-1.0 (CHECK constraint
+ service-side guard).
- GET /:slug — deck + latestVersion.
- POST /:slug/publish — version semver enforced strictly increasing,
AI-mod first-pass via mana-llm (block → 403; flag → publish + log
for human review; pass → publish silently). Per-card and per-
version SHA-256 content hashes computed; cards persisted; deck's
latest_version_id flipped atomically in a single transaction.
Helpers:
- lib/slug.ts — slugify (best-effort) + validateSlug (strict).
- lib/hash.ts — canonical SHA-256 over (type, fields) for cards
and (sorted, ord-stable) for versions.
- lib/ai-moderation.ts — mana-llm /v1/chat/completions wrapper
with system prompt that forces JSON output. Fail-open: if
mana-llm is down or returns malformed JSON, the verdict is
'flag' so a human reviewer catches it. Better slow than silent.
Index-mounting of /v1/authors and /v1/decks is gated behind jwtAuth.
Anonymous public reads (Phase γ optionalAuth middleware) come later.
Validated: tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires cards-server into the Mac-mini stack so we can deploy alongside
the rest of the Mana services.
- Dockerfile mirrors the mana-credits 2-stage pattern (node+pnpm
installer → bun runtime), exposes :3072, includes a /health
healthcheck.
- docker-compose.macmini.yml: new cards-server block right after
mana-credits — depends on postgres + mana-auth, 128m mem, all the
env knobs from the Phase-α config (author payout BPS, community-
verified thresholds, sibling-service URLs).
- cloudflared-config.yml: cards-api.mana.how → :3072. Distinct from
cards.mana.how (the user-facing PWA) so the API surface is clearly
separated.
- sso-origins.ts: cards-api.mana.how added to PRODUCTION_TRUSTED_ORIGINS.
- mana-auth CORS_ORIGINS in compose: cards-api.mana.how added.
Restored whopxl.mana.how that had drifted out — sso-config.spec.ts
had been flagging it but the missing entry surfaced when I added
cards-api. spec is back to 8/8 green.
Deploy plan (next steps, not in this commit):
1. ./scripts/mac-mini/build-app.sh cards-server
2. docker exec mana-app-cards-server bun run db:push (creates the
`cards` schema + 16 tables in mana_platform)
3. ./scripts/mac-mini/sync-tunnel-config.sh
4. Smoke: curl https://cards-api.mana.how/health → 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>