security: scraper auth + rate limiting + prompt injection + refresh blacklist#149
Open
security: scraper auth + rate limiting + prompt injection + refresh blacklist#149
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second security pass on 2026-04-15, companion to scraper PR (ppprevost/fanchat-scraper main, commit 7936d18).
Companion scraper changes (already deployed to Coolify via main push):
Test plan
🤖 Generated with Claude Code