Skip to content

security: scraper auth + rate limiting + prompt injection + refresh blacklist#149

Open
ppprevost wants to merge 32 commits intomainfrom
feat/security-scraper-ratelimit
Open

security: scraper auth + rate limiting + prompt injection + refresh blacklist#149
ppprevost wants to merge 32 commits intomainfrom
feat/security-scraper-ratelimit

Conversation

@ppprevost
Copy link
Copy Markdown
Owner

Summary

Second security pass on 2026-04-15, companion to scraper PR (ppprevost/fanchat-scraper main, commit 7936d18).

  • Upstash Redis rate limiters on auth/refresh, /tts, /v3/message, /character/chat-builder, /character/generate, /forum/generate-topic
  • Prompt injection in /forum/generate-topic: character.sentence isolated in escaped `` XML block
  • Forum hardening: Zod validation on POST /forum, ownership check on mention in POST /forum/[topicId]
  • Refresh token revocation via Redis blacklist (jti-based), logout revokes, refresh rotates and revokes old
  • Missing auth added on /api/character/chat-builder and /api/character/generate

Companion scraper changes (already deployed to Coolify via main push):

  • Auth required on /travel/* (was public)
  • authMiddleware fail-closes if SCRAPER_SECRET unset
  • /scrape/url SSRF guard (scheme, host blocklist, DNS lookup vs RFC1918)
  • In-memory IP rate limit (60/60s) on all authenticated scraper routes

Test plan

  • scraper smoke tests local: /travel/search no Bearer -> 401, wrong Bearer -> 401, /scrape/url with 169.254/localhost/file:// -> 400, 61 req -> 429
  • verify UPSTASH_REDIS_REST_URL/TOKEN or KV_REST_API_URL/TOKEN exist in Vercel prod + preview (fallback auto if KV vars present)
  • deploy, hit /api/auth/refresh 11 times from same IP -> 11th returns 429
  • create forum topic with title "" -> 400
  • mention private character you do not own -> no notification
  • logout, then reuse old refresh token -> 401
  • hit /api/v3/message 61 times in 60s -> 429

🤖 Generated with Claude Code

…h token blacklist

Rate limiting (Upstash Redis sliding window):
- /api/auth/refresh (10/10min per IP)
- /api/tts (30/min per userId)
- /api/v3/message (60/min per userId)
- /api/character/chat-builder (60/min per userId) + missing auth added
- /api/character/generate (10/h per userId for image, 60/min for LLM)
  + missing auth added
- /api/forum/generate-topic (10/h per userId)

Prompt injection:
- character.sentence and user-controlled fields isolated in <persona>
  XML block with escape, explicit instruction not to execute contents

Forum hardening:
- POST /api/forum validates with Zod (createTopicSchema), enforces
  ownership on postAsCharacterId
- POST /api/forum/[topicId] blocks mentions of non-public characters
  not owned by the user

Refresh token revocation:
- signRefreshToken embeds a unique jti
- verifyRefreshToken checks Redis auth:revoked:{jti} before accepting
- /api/auth/logout revokes jti with TTL = remaining validity
- /api/auth/refresh revokes the previous token on rotation

Infra:
- features/shared/infrastructure/redis.ts Upstash client, fails closed
  in production if credentials missing, falls back to Vercel KV env vars
- features/shared/infrastructure/rate-limit.ts exports named limiters
  and a checkRateLimit helper returning 429 with Retry-After

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fanchat Error Error Apr 17, 2026 8:50am

… slice

- jwt.ts reverts to pure token sign/verify (no Redis, no revocation
  logic)
- domain/refresh-token-store.ts defines the RefreshTokenStore port
- infrastructure/auth/refresh-token-store-kv.ts implements it on top
  of @vercel/kv, with a no-op fallback when credentials are absent
- use-cases/revoke-refresh-token.ts, is-refresh-token-revoked.ts are
  curried, infrastructure-agnostic
- use-cases/auth-blacklist.ts wires the default adapters once
- use-cases/logout.ts and refresh-session.ts encapsulate the flows,
  returning tagged results rather than throwing
- /api/auth/logout and /api/auth/refresh become thin HTTP adapters
  that wire deps once and delegate to the use-case

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- features/character/use-cases/build-character-chat.ts receives its
  model factory, image generator, and user-quota operations as deps
  and is curried so the route wires them once at module scope
- /api/character/chat-builder keeps only session + rate-limit + body
  parsing; streaming and tool orchestration live in the use-case
- canUseFreeTier invariant stays in domain, called from the use-case

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…to kill duplication

- features/shared/infrastructure/route-guards.ts adds withSession,
  withRateLimit, withIpRateLimit and publicRoute higher-order wrappers
  that compose by currying
- features/user/infrastructure/auth/cookies.ts centralises the
  access/refresh cookie options (httpOnly, sameSite, secure-in-prod)
  and exposes setAuthCookies / clearAuthCookies
- features/forum/use-cases/generate-topic.ts turns the LLM prompt
  assembly and parsing into a curried use-case returning a tagged
  result
- /api/auth/logout, /api/auth/refresh, /api/tts,
  /api/character/chat-builder, /api/character/generate,
  /api/forum/generate-topic, /api/v3/message become thin adapters
  composed with withSession / withRateLimit / publicRoute — the session
  check, rate-limit guard, cookie serialisation and error wrapping no
  longer repeat at each call site

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Prisma

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…uards

- route-guards.ts: PublicHandler/AuthedHandler now generic over the
  resolved params shape; withSession, withRateLimit, withIpRateLimit
  and publicRoute pass through Next.js route ctx (params: Promise<P>)
  and unwrap it once before calling the handler
- All routes that did `getSession() + 401 if missing` now compose
  withSession instead, picking up the centralised 401 body, 500
  error trap and params unwrap:
  favorites, character/mine, character/create, chat/[characterId],
  chat/sessions/[characterId], skills/[characterId], notifications,
  notifications/[id]/respond, providers (+ models, + test),
  group-chat (+ [groupId], + [groupId]/message), export,
  user/email-preferences, upload/process, templates POST,
  v3/travel-pipeline, v3/message/prepare, forum POST,
  forum/[topicId] POST
- Public-but-Promise-params reads (forum GET, forum/[topicId] GET)
  use publicRoute to inherit the same params unwrap + error trap
- Routes left untouched on purpose: trip/[sessionId]/* (already use
  withOwnedSession), auth/me, /api/session, notifications/count and
  templates GET (session-optional with custom body shape)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…plate

- route-guards.ts: withOwnedResource<P, R>(check) composable factory
  that runs after withSession, fetches the resource scoped to the
  current userId, and returns 404 (not 403) on missing-or-not-owned
  to avoid leaking existence (OWASP API Security #1)
- features/shared/infrastructure/ownership-guards.ts wires the
  factory once per resource type:
    withOwnedCharacter   ({ characterId }, userId)
    withOwnedGroupChat   ({ groupId },     userId)
    withPendingNotification ({ id },        userId)
- /api/skills/[characterId] (4 methods) drop the local
  verifyCharacterOwnership helper and compose withOwnedCharacter
- /api/group-chat/[groupId] (GET, DELETE) and
  /api/group-chat/[groupId]/message (POST) drop the inline
  groupChat.userId !== session.userId check
- /api/notifications/[id]/respond drops the inline
  notification.userId check; the resource is delivered to the handler
  so the status-pending check stays as business logic only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The route used to branch twice (no cookie -> 401, then runRefresh ->
401). Now refreshSession accepts an optional refresh token and adds a
'missing' tag to RefreshSessionResult, so the route reduces to a
single ok/!ok switch and the http error message reflects the actual
reason.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…OLA in use-cases

The route used to call prisma.character directly for create / find / update
/ delete and did the ownership check inline. The matching server actions
(deleteCharacterById, updateCharacterAction) skipped the ownership check
entirely — a real BOLA accessible from any logged-in user.

- features/character/use-cases/create-character.ts (new) curries the
  characterRepo + image uploader, builds a DomainCharacter from the form
  payload (with sane defaults for mood/temperature and a normalised
  customTheme), then runs an optional image-variant upload.
- features/character/use-cases/delete-character.ts and update-character.ts
  now own the ownership invariant (characterRepo.findById +
  createdBy === userId) and return a tagged result. Both are curried.
- /api/character/create POST/PUT/DELETE wire the use-cases once at
  module scope and only translate ok/!ok to HTTP. No more Prisma in the
  route file.
- app/_actions/characters/index.ts (deleteCharacterById,
  updateCharacterAction) goes through the new tagged use-cases — they
  now refuse mutations on characters the user does not own.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the load-then-check-createdBy pattern that delete-character and
update-character were duplicating into a typed higher-order function
that loads the character, verifies ownership, and hands the verified
DomainCharacter to the inner handler. Both 'not exists' and 'belongs
to someone else' collapse to { ok: false, reason: 'not_found' } for
information hiding (OWASP API #1). The inner handler signs the success
branch so composition stays type-safe with no cast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Generalise the character-only withOwnedCharacter into
features/shared/domain/with-owned-resource.ts. The generic HOF takes
a load function (driven by the input shape, so each caller picks the
id field name) and an owns predicate; the inner handler signs the
success branch of the result type, so composition stays type-safe
without any cast and the input flows through generically.

withOwnedCharacter becomes a 6-line wrapper that fixes the loader to
characterRepo.findById and the predicate to createdBy === userId.
delete-character and update-character are unchanged on the surface.
Future use-cases on group-chat, trip-plan, notifications, etc. can
now reuse withOwnedResource with their own loader + predicate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The recent use-cases (refreshSession, deleteCharacter, updateCharacter,
withOwnedResource) returned ad-hoc tagged objects ({ ok, reason: 'missing'
| 'invalid' | 'not_found' }). The codebase already had a Result<T, E>
type and a DomainError union (used by sendMessage). Migrate the new code
to the same shape so every use-case speaks one error vocabulary.

- features/shared/domain/errors.ts adds ResourceNotFoundError,
  RefreshTokenMissingError, RefreshTokenInvalidError,
  RefreshTokenRevokedError + factories
- features/shared/domain/with-owned-resource.ts now returns
  Result<T, E | ResourceNotFoundError>; takes a `resource` label and
  an `idFrom` extractor so the error carries the missing id
- features/character/use-cases/with-owned-character.ts forwards the
  inner handler's Result; the handler is generic over <T, E>
- delete-character.ts and update-character.ts return
  Result<true, never> from the success branch; the BOLA failure flows
  through ResourceNotFoundError automatically
- refreshSession returns Result<{ accessToken, refreshToken }, RefreshSessionError>
  with the four refresh-token-specific domain errors
- /api/auth/refresh, /api/character/create and the matching server
  actions switch from `result.ok` to `result.success` and surface
  `result.error.kind` in the response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- features/shared/infrastructure/http-errors.ts maps every DomainError
  kind to an HTTP status (404 / 401 / 403 / 400 / 409 / 429 / 502 / 500)
  and emits a body shaped { error: kind, ...payload } so the response
  vocabulary mirrors the domain vocabulary one-for-one
- withSession, publicRoute, withOwnedSession and the route-level
  withOwnedResource go through unauthorizedResponse() /
  internalErrorResponse() / domainErrorToResponse(resourceNotFound(...))
  instead of inlining { error: 'Unauthorized' } / 'INTERNAL_ERROR' /
  'Not found' string literals
- withOwnedResource takes (resource, idFrom, check) so the 404 body
  carries the resource label and id (info-hiding still preserved by
  collapsing not-found and not-owned to the same error)
- ownership-guards.ts updated to the new signature
- /api/v3/message drops its local errorStatusMap + errorBody and goes
  through domainErrorToResponse, deduplicating the status table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… layer

Cherry-pick from feat/account-bulk-delete (4417443) so this branch ends
up on the new convention: each vertical slice owns its server actions
under features/{context}/presentation/actions/, app/_actions/ and the
dead app/_services/ folder go away. Also drops the orphan helpers
(getCharacterByName, urlToBinaryImage, getChatHistory,
deleteChatConversation, deleteUserById, _create-character.ts wrappers).

Conflict resolution:
- features/character/use-cases/create-character.ts kept the curried
  (deps) => (input) form introduced earlier in this branch (BOLA +
  Result vocabulary), the (deps, input) form from the cherry-pick was
  reshaped accordingly
- features/character/presentation/actions/index.ts wires runCreate /
  runUpdate / runDelete once at module scope, drops the orphan
  fetch-based getCharacterByName helper, and surfaces
  result.error.kind on failure
- createCharacterAction returns the same { ok | error } shape as
  updateCharacterAction so useCharacterForm's mutation type-checks

CLAUDE.md is updated to document the new presentation/actions/
convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-cases

- forumRepository gains findTopicWithCreator + findRawPostsByTopic so
  the use-case can fetch the raw rows it needs without leaking Prisma
  upward
- userRepository.findManyPublic and characterRepository.findManyPublic
  /findOwnedById/findMentionMeta supply the cross-context lookups the
  forum needs for post enrichment, postAsCharacter ownership and
  mention metadata
- features/forum/use-cases/get-topic-detail.ts (curried) orchestrates
  the topic + posts + author/character resolution and returns a tagged
  result
- features/forum/use-cases/post-to-topic.ts (curried) owns the
  post-creation rules: ownership check on postAsCharacter, transactional
  post insert + topic counter via forumRepository.createPost, mention
  visibility check (public OR owned), notification dispatch
- /api/forum/[topicId] becomes a thin adapter, no Prisma import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
NestJS-style decorator stacking, but as a typed functional pipe so each
guard stays a plain function and the chain order is enforced by the
type system.

- features/shared/infrastructure/route-pipe.ts exposes
  route<Params>() -> Pipeline + composable Step helpers:
    session()                        -> { session: TokenPayload }
    rateLimit(limiter, keyFn)        -> {} (short-circuits on 429)
    ipRateLimit(limiter)             -> {} (no session needed)
    body(zodSchema)                  -> { body: T }
    ownedResource(label, idFrom, load) -> { resource: R }
- Each step declares only the slice of context it requires (Need) and
  the slice it adds (Extra), so the order .use(session) before
  .use(ownedResource) etc. is enforced statically without explicit
  generic parameters at the call site.
- The pipeline accumulates Ctx from left to right; .handle gets a
  fully typed { req, params, session, body, resource, ... } and the
  trailing await runs only if every step said ok.
- Failures (401 / 400 / 404 / 429) flow through the same
  domainErrorToResponse / unauthorizedResponse helpers so the wire
  vocabulary stays one-for-one with DomainError.kind.
- /api/skills/[characterId] migrated as the showcase: the four
  handlers (GET / POST / PUT / DELETE) read top-to-bottom as a list
  of guards, no nested HOFs.

The legacy withSession / withOwnedResource HOFs stay around for the
already-migrated routes; routes can opt into the new pipeline
incrementally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same API (route<Params>().use(...).use(...).handle(...)) but the
Pipeline is now a record type with two methods built by a recursive
buildPipeline factory closing over the steps array. No `new`, no
`this`, no class — just a typed record + closure, in line with the
project's FP-by-default rule. The runSteps helper extracts the loop
so handle() reads as a single try/await/return.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The two parallel APIs (withSession/withOwnedX HOFs vs route().use()
pipeline) collapse into one. Every API route is migrated, the legacy
files are deleted.

Pipeline gains:
- .useIf(predicate, step) for conditional guards (returns Partial<Extra>)
- .tap(fn) for side-effects (logging, metrics, audit)
- features/shared/infrastructure/route-pipe-steps.ts with pre-wired
  ownership steps (ownsCharacter, ownsGroupChat, ownsNotification,
  ownsChatSession) so routes don't repeat the loader.

Routes migrated (32 files):
- auth: logout, refresh
- character: create, generate, chat-builder, mine
- chat: [characterId], sessions/[characterId]
- skills: [characterId]
- forum: route, [topicId], generate-topic
- group-chat: route, [groupId], [groupId]/message
- notifications: route, [id]/respond
- providers: route, test, models
- housing: [sessionId], [sessionId]/pipeline, [sessionId]/export
- v3: message, message/prepare, travel-pipeline
- favorites, export, tts, user/email-preferences, templates,
  upload/process

Deleted:
- features/shared/infrastructure/route-guards.ts
- features/shared/infrastructure/ownership-guards.ts

Each handler now reads top-down as a list of guards. The compiler
enforces the order (e.g. ownership before body, session before
ownership). Failures route through domainErrorToResponse for a
unified response vocabulary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…chat

- characterRepository.findOwnedAndSharedSummary(userId) replaces the
  inline OR query in /api/character/mine
- characterRepository.findAccessibleByIds(ids, userId) replaces the
  inline OR query in /api/group-chat POST (public OR owned OR shared)
- groupChatRepository gains findUserPage / createFull / findByIdFull /
  findRecentMessages / findMessagesPage / touchUpdatedAt / deleteById
  / createMessageFull so the three group-chat routes (route /
  [groupId] / [groupId]/message) hold zero prisma imports
- The SSE message route still owns its orchestration loop, but every
  read/write goes through the repository

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…error in use-cases

createCharacter and updateCharacter used to swallow image-upload errors
with `console.error('Failed to upload image variants:', e)` from inside
the use-case. That mixed two layers (domain/use-case + infra logging)
and made the failure invisible to anything but stdout - the character
still returned with a Replicate/DALL-E URL that expires in ~24h.

- features/shared/domain/event-types.ts adds CharacterImageStored and
  CharacterImageStorageFailed (both carry the characterId, the source
  url, and the failure reason)
- The use-cases publish via eventBus instead of logging, with no
  console import and no leaked stdout side effect
- features/shared/infrastructure/event-handlers/character-image-handler.ts
  subscribes and emits a structured log at the infra layer where
  logging belongs - same place a future retry/Sentry/alert handler
  would live
- ensureHandlersRegistered() now also wires the image handler

Use-case stays pure (no infra import outside the event bus port);
observability is one handler away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- @sentry/nextjs installed and configured (server + edge + client)
- DSN moved to NEXT_PUBLIC_SENTRY_DSN env var (wizard hardcoded it)
- tracesSampleRate 10% in production, 100% in dev (free tier budget)
- sendDefaultPii disabled (GDPR safe)
- next.config.js wrapped with withSentryConfig (source maps, tunnel
  route /monitoring to bypass ad-blockers)
- instrumentation.ts + instrumentation-client.ts handle server/edge/
  client init with environment tag
- global-error.tsx captures unhandled React errors in the app shell
- internalErrorResponse (route pipe 500 catch-all) now calls
  Sentry.captureException with a handler tag for every uncaught route
  error
- character-image-handler.ts uses Sentry.captureMessage on
  CharacterImageStorageFailed (level: error) and Sentry.addBreadcrumb
  on CharacterImageStored (level: info)
- Wizard example pages removed (sentry-example-page,
  sentry-example-api)
- .env.sentry-build-plugin in .gitignore (contains SENTRY_AUTH_TOKEN)

To complete:
- Add NEXT_PUBLIC_SENTRY_DSN to Vercel env (prod + preview)
- Add SENTRY_AUTH_TOKEN to Vercel env (for source map upload)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three parallel agents extended 8 repositories and rewrote 19 routes
to access data exclusively through the repository layer. The only
routes still importing prisma are auth/callback, auth/dev-login,
contact, and cron/monthly-summary (internal/infra-specific, no user
data mutations).

Repositories extended:
- chatRepository: findSessionsByCharacter, createSession, deleteSession
  (with cascade), findFirstSessionByCharacterAndUser, findMessages
- characterRepository: findSnapshotById (name/description/sentence/lang)
- favoriteRepository: toggle(userId, characterId)
- forumRepository: createTopicWithCreator, countTopics,
  findTopicsWithCreatorPaginated, findTopicRaw, incrementTopicActivity
- notificationRepository: findForUser, countUnread, markAllRead,
  markManyRead, updateStatus
- providerRepository: findAllForUser, clearDefault, save,
  deleteByProvider (already existed, routes just needed the import swap)
- tripRepository: updateStatus, persistFlightProposals,
  persistHotelProposals, persistActivityProposals, validateItem
- housingRepository: updateStatus
- userRepository: findEmailPreferences

New repository:
- features/shared/infrastructure/templates/repository.ts: findSystem,
  findByCreator, create

Routes rewritten (19):
  character/[id], chat/sessions/[characterId], export, favorites,
  forum, forum/generate-topic, notifications, notifications/count,
  notifications/[id]/respond, providers, templates,
  user/email-preferences, trip/[sessionId], trip/[sessionId]/validate,
  trip/[sessionId]/export, trip/[sessionId]/export/pdf,
  v3/travel-pipeline, housing/[sessionId], housing/[sessionId]/pipeline

Test mocks updated to include new repository methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. User context: session() step sets Sentry.setUser({ id, email }) on
   every authenticated request so errors carry user identity.

2. Security breadcrumbs: auth 401, rate limit 429, and BOLA 404 are
   recorded as warning-level breadcrumbs. Not separate Sentry issues
   (too noisy), but visible in the event trail when a real error fires
   shortly after - useful for debugging attack patterns.

3. Domain error breadcrumbs: domainErrorToResponse() adds a breadcrumb
   for every DomainError kind (NoProviderError, ValidationError, etc.)
   so the error trail shows business-logic context before a crash.

4. Refresh token reuse detection: if isRefreshTokenRevoked() returns
   true, Sentry.captureMessage fires at warning level with userId and
   security:token-reuse tag. This signals potential session compromise
   - a stolen token being replayed after logout or rotation.

5. LLM provider errors: platform-llm.ts (Anthropic) and
   resolve-model.ts (BYOA providers) wrap model creation/generation in
   try/catch and Sentry.captureException with provider + model tags.
   Upstream degradation (timeout, 500, rate limit) is now visible as
   Sentry issues grouped by provider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
NestJS has @catch(HttpException) decorators; our functional pipe now
has .catch(catcher). Catchers run left-to-right on any exception
thrown from the handler. If a catcher returns a Response, short-circuit.
If none match, fall through to internalErrorResponse (500 + Sentry).

- Pipeline.catch(catcher: ErrorCatcher) appends to a catcher list
  threaded through buildPipeline alongside the steps list
- domainCatcher (http-errors.ts) is the standard filter: checks
  'kind' in err, maps via domainErrorToResponse. Functional
  equivalent of NestJS @catch(DomainError)
- result.ts gains unwrap<T, E>(result): throws error on failure,
  returns data on success. Pairs with .catch(domainCatcher) so
  handlers can write `unwrap(await useCase(...))` instead of
  `if (!result.success) return domainErrorToResponse(result.error)`
- /api/character/create migrated as showcase: PUT and DELETE drop
  the manual result check, the pipeline catches the thrown
  ResourceNotFoundError and maps it to 404

Before:
  const result = await runDelete(...)
  if (!result.success) return domainErrorToResponse(result.error)
  return NextResponse.json('ok')

After:
  unwrap(await runDelete(...))
  return NextResponse.json('ok')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…acterId]

- /api/v3/message: both sendLegacyGreeting and sendMessage results
  unwrapped directly, .catch(domainCatcher) handles NoProviderError /
  UseLocalModelError / CharacterNotFoundError as HTTP responses
- /api/chat/[characterId] GET: loadMessages unwrapped, destructured
  into { session, chat } inline

Three routes kept manual: auth/refresh (needs clearAuthCookies on
failure), housing/[sessionId] (per-handler custom 422 status), and
trip/[sessionId]/validate (non-Result return shape). These have
specific error-handling logic that the generic catcher can't express.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete the NestJS-equivalent lifecycle in the functional pipeline:

.map(fn)
  Pre-handler context transformer. Runs after steps, before handler.
  Equivalent to NestJS PipeTransform / ParseIntPipe.
  Ready-made: trimStrings() trims all string values in ctx.body.

.transform(fn)
  Post-handler Response interceptor. Runs after handler, before
  serializers. Receives (response, ctx) so it can read the authed
  session, measure latency, add headers, etc.
  Equivalent to NestJS Interceptor (tap side of the observable).
  Ready-made: withHeaders({ ... }) and withLatencyLog(tag) (adds
  X-Response-Time header + Sentry perf breadcrumb).

.serialize(schema)
  Post-handler JSON body filter. Parses the response body through a
  Zod schema (strips unknown fields), rebuilds the Response with
  only the declared fields. Non-JSON responses pass through untouched.
  Equivalent to NestJS ClassSerializerInterceptor / @exclude().
  Prevents accidental exposure of sensitive fields (apiKey, createdBy,
  internal flags).

Execution order: steps -> mappers -> handler -> transformers -> serializers
Exceptions at any point -> catchers -> fallback 500

Pipeline config refactored from positional args (steps, catchers) to a
single PipelineConfig record threaded through buildPipeline. Cleaner
as the number of concerns grows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eaks

Every route that returned domain objects to the client via `...spread`
now goes through an explicit `toPresentation` mapper that picks only
the fields the client needs. Sensitive internal fields (userId,
createdBy, runtime, metadata.characterId, authorId, timestamps)
are stripped at the mapper boundary.

Mappers created:
- features/provider/infrastructure/mapper.ts:
  toProviderPresentation (strips id, userId, runtime, timestamps)
  maskApiKey moved from route into mapper
- features/notification/infrastructure/mapper.ts:
  toNotificationPresentation (strips userId, trims metadata to
  topicTitle/topicLang/characterName only)
- features/group-chat/infrastructure/mapper.ts:
  toGroupChatPresentation (strips userId, collapses characters to
  { id, name, image }), toGroupChatMessagePresentation (strips userId)
- features/forum/infrastructure/mapper.ts (extended):
  toForumTopicPresentation (strips creatorId),
  toEnrichedPostPresentation (strips authorId)
- features/chat/infrastructure/mapper.ts (extended):
  toChatSessionPresentation (strips userId, summary, summarizedUpTo)

Routes updated:
  providers GET/POST, notifications GET, chat/sessions GET/POST,
  group-chat GET/POST, group-chat/[groupId] GET,
  forum/[topicId] GET

Frontend types narrowed to match (GroupChatCharacterSlim,
NotificationMetadata trimmed, ForumPost without authorId).
Components updated accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in use-cases

The eventBus was imported directly in 5 use-cases, coupling domain
logic to the infrastructure singleton. Now every use-case that
publishes events receives `publish: Publish` as a dep. The Publish
port type is defined in features/shared/domain/events.ts (domain
layer, zero deps).

Use-cases migrated:
- createCharacter: publish via deps.publish
- updateCharacter: publish via deps.publish
- persistMessages: deps.publish (optional, backward-compatible)
- toggleFavorite: deps.publish (optional)
- resolveModel: deps.publish (optional)

ensureHandlersRegistered() moved to instrumentation.ts (server boot)
instead of being called inside each use-case. Handlers register once
when the Node.js runtime starts, not per-request.

Composition roots (routes + server actions) wire eventBus.publish
into deps. Use-cases stay pure: no eventBus import, no infra import,
testable with `publish: vi.fn()`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…resentation

Housing thick route:
- send_email inline handler extracted to features/housing/use-cases/
  send-housing-email.ts with explicit deps (housingRepo, userRepo)
  and Result return type
- route dispatch table now one-liners per action
- dead imports removed (renderToBuffer, HousingExportPdf,
  sendHousingSearchEmail)

Travel pipeline:
- inline resolveModel (20 lines, missing local model case) replaced
  with the real features/provider/use-cases/resolve-model.ts that
  handles server + local + free-tier + fallback correctly
- buildSummaryPrompt extracted to features/travel/use-cases/
  build-summary-prompt.ts (pure function, named input type)
- dead infra imports removed (LanguageModel, getProvider, decrypt,
  getPlatformModel, canUseFreeTier)

Missing toPresentation mappers:
- features/travel/infrastructure/mapper.ts gains toTripPlanPresentation
  (strips userId from DomainTripPlan)
- /api/character/[id] uses toCharacterPresentation
- /api/trip/[sessionId] and /api/trip/[sessionId]/validate use
  toTripPlanPresentation

Remaining spread leaks flagged (non-blocking):
- chat/[characterId] GET spreads chatSession (has userId)
- housing/[sessionId] GET returns raw DomainHousingSearch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spread leaks:
- chat/[characterId] GET uses toChatSessionPresentation (strips userId,
  summarizedUpTo, updatedAt)
- housing/[sessionId] GET uses toHousingSearchPresentation (strips
  userId, createdAt, updatedAt). New mapper in housing/infrastructure/
  mapper.ts

Unit tests (5 files, 27 scenarios):
- delete-character: success, not-found, BOLA, delete called correctly
- create-character: field mapping, CharacterImageStored event,
  CharacterImageStorageFailed event, returns character on upload fail,
  skips upload for non-external URLs
- refresh-session: all 4 error branches (Missing, Invalid, Revoked,
  UserNotFound), token rotation, old token revoked
- logout: token revoked, no-op when absent
- post-to-topic: human post, character post (owned), rejected when not
  owned, mention notification for public character, mention skipped
  for private non-owned (BOLA), topic not found, empty content

All 388 tests pass. Zero spread leaks remaining in API routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant