Imported from ksm007/kmarathe-493C3875-5748-4C7F-9803-3D2EFE845779 (
AGENTS.md). Install upstream withnpx skills add ksm007/kmarathe-493C3875-5748-4C7F-9803-3D2EFE845779. Copyright stays with the author.
Project agent memory
This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code.
- Add durable project-specific notes here as they are discovered through real work.
Auth rate limiting & brute-force protection (apps/api)
@nestjs/throttleris wired inapps/api/src/app/app.module.tsviaThrottlerModule.forRootAsync(config-driven, per-IP). It defines TWO named throttlers:'auth'(tight,AUTH_RATE_LIMIT_*) for the public abuse-prone routes, and'invite'(higher,INVITE_RATE_LIMIT_*) for the authenticated bulk-invite create route.ThrottlerModuleis@Global, but theThrottlerGuardis intentionally NOT registered as anAPP_GUARD- it is opt-in per route via@UseGuards(ThrottlerGuard)so normal authenticated app traffic is never throttled. The guard enforces ALL named throttlers, so each route scopes itself to exactly one with@SkipThrottle:POST /auth/login,/auth/register,/auth/forgot-password,/auth/reset-password, andPOST /invitations/acceptuse@SkipThrottle({ invite: true })(so only'auth'applies);POST /invitations(create) uses@SkipThrottle({ auth: true })(so only the higher'invite'limit applies).- Per-account login brute-force protection lives in
apps/api/src/app/auth/login-attempt.service.ts(in-memory, mirrorschat/chat-rate-limiter.service.ts).AuthService.logincallsassertNotLocked→recordFailure/reset; lockout returns HTTP 429. The attempts map is keyed by attacker-controlled email, sorecordFailureopportunistically sweeps stale/expired entries and enforces a hardMAX_TRACKED_ACCOUNTScap (evicting oldest non-locked first) to bound memory under credential-stuffing. - The AI chat limiter (
chat/chat-rate-limiter.service.ts) is a separate, independent mechanism - do not fold it into the throttler. - Thresholds/TTLs are env vars validated in
apps/api/src/app/config/env.validation.ts:AUTH_RATE_LIMIT_TTL_SECONDS,AUTH_RATE_LIMIT_MAX,INVITE_RATE_LIMIT_TTL_SECONDS,INVITE_RATE_LIMIT_MAX,LOGIN_MAX_FAILED_ATTEMPTS,LOGIN_LOCKOUT_SECONDS(all have defaults; also in.env.example). - Tests:
auth/auth-throttler.spec.ts(HTTP 429 via supertest),auth/login-attempt.service.spec.ts(lockout + recovery with fake timers), and lockout cases inapi.integration.spec.ts. Note:api.integration.spec.tsconstructs services by hand - adding anAuthServiceconstructor param means updating that wiring.
Task attachment storage
AttachmentStorageService(apps/api/src/app/tasks/attachment-storage.service.ts) is the single storage seam used byTasksServicefor image attachments. It is a thin facade that delegatessave/createReadStream/openReadStream/removeto an adapter selected at construction from env config.- Adapters live in
apps/api/src/app/tasks/storage/:local-disk-...(dev/test default, disk viafs, the seam's placeholder) andcloudinary-...(production, officialcloudinarySDK). Selection is inattachment-storage.factory.tskeyed onATTACHMENT_STORAGE_PROVIDER(localdefault,cloudinary). See ADR 0024. (S3 is not implemented; it could be added later behind the same seam.) - The seam exposes a synchronous
createReadStream(): Readable(so the controller can wrap it inStreamableFile). The Cloudinary adapter stores assets asauthenticated(private at the CDN), uses the opaquestorageKeyminus its file extension as the public id, and on read signs a delivery URL and proxies the bytes back through the existing authenticated serving endpoint - keeping private attachments private without changing the controller contract. It bridges the async fetch through aPassThroughand surfaces failures as streamerrorevents. - Cloudinary env vars (
CLOUDINARY_URL, orCLOUDINARY_CLOUD_NAME+CLOUDINARY_API_KEY+CLOUDINARY_API_SECRET) are validated inapps/api/src/app/config/env.validation.tsonly when the provider iscloudinary, and documented in.env.example. - Tests:
tasks/storage/attachment-storage.spec.tscovers adapter selection (factory), Cloudinary adapter upload/read/delete/stream behavior via injected mock, andfetchWithRedirectsedge cases (off-domain rejection, hop cap, NaN/missing Content-Length).
Task activity emission (apps/api)
- Activity types are defined in
libs/data/src/lib/models/task.ts(TaskActivityType). EveryupdateTaskalways emitstask_updated. Six field changes emit an additional discrete activity alongside it:status_changed,epic_changed,sprint_changed,acceptance_criteria_changed,story_point_changed, andassignee_changed. story_point_changedis emitted beforeloadTaskWithRelations(it only needs the scalar before/after values).assignee_changedis emitted afterloadTaskWithRelationsbecause it readshydratedTask.assignee.fullNameto populatetoNamein the metadata. The ordering is intentional; do not moveassignee_changedabove the hydration call.- Both discrete activities are suppressed when the value did not actually change (guard:
previousX !== task.X). They are also suppressed when the field is absent from the payload (payload.x !== undefinedguard). - Metadata shape:
assignee_changedcarries{ from, fromName, to, toName }(ids + display names, either may benull);story_point_changedcarries{ from, to }(numbers ornull). - Tests:
apps/api/src/app/tasks/tasks-activity.spec.ts(unit, no DB - mocks repositories by hand).
apps/web routing (TanStack Start)
File-based routes live in apps/web/src/routes; routeTree.gen.ts is auto-generated by the tanstackStart Vite plugin.
It regenerates on vite dev and at the start of vite build, NOT during tsc.
After adding/removing/renaming a route file, run npx nx build web (or the dev server) to refresh routeTree.gen.ts before npx nx typecheck web, otherwise typecheck sees the stale tree.
Auth lives in localStorage (bearer token via ~/lib/auth-storage), which is invisible during SSR.
Any route whose beforeLoad/loader/component depends on the session must set ssr: false so those hooks run on the client.
The authenticated area is a pathless _authed layout (ssr: false) whose beforeLoad redirects to /login when there is no session and exposes the user via route context; nested _authed/_admin is a second pathless layout that redirects non-admins (owner/admin only) to /tasks and wraps sprints + audit-log.
/login and /signup also set ssr: false so their "already signed in -> /tasks" guard can read localStorage.
Route loaders use queryClient.prefetchQuery (the shared singleton from ~/lib/query-client), not ensureQueryData: prefetch warms the cache without throwing on fetch failure, so components keep their own useQuery loading/error UI instead of crashing the route into the error boundary.
Live session/user reads go through useCurrentUser() (the ['me'] query), so the org switcher stays reactive.
The _authed/tasks/$id route (apps/web/src/routes/_authed.tasks.$id.tsx) is a URL-driven deep link for task detail - the URL is bookmarkable and shareable.
Its close handler calls router.history.back() when window.history.length > 1 so navigating from the AI-chat source badge or a direct link returns the user to the previous page; it falls back to navigate({ to: '/tasks' }) for cold loads with no prior history.
Do not replace this with a plain navigate({ to: '/tasks' }) - that would break back-navigation from the AI-chat page and other entry points.
Google sign-in (apps/api + apps/web)
- Backend:
POST /auth/googleaccepts{ idToken: string }.AuthService.googleSignIndelegates token verification toGoogleVerifierService(auth/google-verifier.service.ts), which wrapsgoogle-auth-library'sOAuth2Client.verifyIdToken. Never trust a client-supplied email/profile - only values extracted from the verified token are used. - Response is discriminated by
kind:{ kind: 'session', accessToken, user }for users with an org membership, or{ kind: 'needs-org', email, fullName, hasPendingInvitations }for users with no membership (ADR 0005). No org or user record is silently created for brand-new identities. - Account linking (ADR 0011): if a verified Google email matches an existing password-only account, the
googleIdis persisted on first Google sign-in. Password sign-in continues to work alongside Google sign-in. User is found bygoogleIdfirst, then by email. GOOGLE_CLIENT_IDenv var is required for the backend (config default'', documented in.env.example).VITE_GOOGLE_CLIENT_IDis the matching Vite-exposed frontend env var; when omitted the Google button is hidden.- Frontend:
AuthLanding(features/auth/auth-landing.tsx) loads the Google Identity Services (GIS) script viauseEffect, callsgoogle.accounts.id.initialize+renderButton, and on credential response callsapiClient.googleSignIn. Theneeds-orgresponse shows a contextual alert without navigating away. - Rate limiting:
POST /auth/googleuses the sameauththrottler as login/register (@SkipThrottle({ invite: true })). - Tests:
auth/google-auth.spec.tscovers token verification delegation, account linking, password sign-in coexistence, no-silent-org (ADR 0005), pending invitation detection;auth/auth-throttler.spec.tscovers 429 on the Google route.api.integration.spec.tswires ajest.fn()stub asGoogleVerifierService- adding constructor params toAuthServicerequires updating both specs. google-auth-libraryis in rootpackage.jsondependencies.
Invitation audit logging (apps/api)
- Three invitation lifecycle events are recorded via
AuditService:invitations.create(actor = inviting user,allowed=true, noresourceId, metadata includesroleandtargetEmail);invitations.accepton success (actor constructed from the newly-created/existing user + membership + invitation.organization,allowed=true,resourceId=invitation.id);invitations.accepton failure - invalid, expired, or already-used token - (actor=null,allowed=false,reason='Invite link is invalid or has expired'). createaudit is logged inInvitationsService.create()after the invitation email is sent.acceptaudits are logged inAuthService.acceptInvitation()before throwing on failure, and after markingacceptedAton success.AuthModuleandInvitationsModuleboth importAuditModuledirectly soAuditServiceis injectable.AuditServiceis now a constructor param of bothAuthServiceandInvitationsService. When constructing either by hand in tests, pass a realAuditService(for integration tests that verify audit entries) or{ log: jest.fn() } as unknown as AuditService(for tests that do not). Existing specs updated:api.integration.spec.ts(realauditService),auth/google-auth.spec.ts(stub).- Tests:
invitations/invitations-audit.spec.ts(pg-mem, covers create/accept-success/accept-expired/accept-invalid/accept-replay).
apps/web routing - public landing page
- The public marketing landing page lives at
apps/web/src/routes/index.tsx(the/route). It redirects signed-in users to/tasks. The old_authed.index.tsx(which previously provided a/redirect to/tasksinside the authed layout) was removed because TanStack Router treats both files as the same/path and raises a conflicting-paths error at build time. When adding any route that competes with/, check for this conflict first.
apps/web task analytics view
- The task board has three view modes:
board,list,analytics. Theanalyticsmode rendersTaskAnalyticsView(defined inline in_authed.tasks.tsx) - stat cards, a completionRingProgress, and CSS bar charts usingPriorityBar- no external chart library. The sprint filter axis uses asprintsQueryloaded alongsidetasksQueryvia a separate['sprints-all']cache key.
apps/web acceptance criteria interactive toggle
TaskDetailModalpersists AC toggle/add changes viaupdateTask(full criteria array replacement viaAcceptanceCriteriaInput[]). Each toggle sends the whole current criteria list with the toggled item'scompletedflipped. New items useid: ''(the backend assigns an id on creation). TheTaskDetailSummarysubcomponent owns the AC UI and receivesonToggleCriterion,onAddCriterion, andcriteriaUpdatingprops from the parent modal.
apps/web RBAC - Admin cannot remove Owner
- In
_authed.team.tsxthe Remove button is also disabled whencurrentUser.role === Role.Admin && user.role === Role.Owner. Only Owners can remove other Owners.
apps/web TanStack Form + Table (ADR 0027)
@tanstack/react-form(v1.x) and@tanstack/react-table(v8.x) are both in use and both installed in the rootpackage.json.- Forms:
useFormfrom@tanstack/react-formis used for all forms: login/signup (features/auth/auth-landing.tsx), task create/edit (features/tasks/task-form-modal.tsx), and invite (routes/_authed.team.tsx). Field-level validators useonSubmit/onChangewithvalidatorsoption; cross-field validation (password match) usesonChangeListenToon the dependent field. - To read reactive form state outside a
<form.Field>render prop, useuseStore(re-exported from@tanstack/react-form):useStore(form.store, s => s.values.fieldName).form.useStore(...)does not exist in this version. TaskFormModal(features/tasks/task-form-modal.tsx) owns its own TanStack Form instance. It receivesdefaultValues: TaskFormStateandeditingTask: Task | nullprops; the parent mounts it with akey={editingTask?.id ?? 'new'}so the form resets when the editing task changes. TheonSaveprop receives the fully-builtCreateTaskRequestpayload rather than raw form state.- Tables:
useReactTablefrom@tanstack/react-tablewithgetCoreRowModel+getSortedRowModelis used for: task list (features/tasks/board.tsxTaskList), team members + invitations (routes/_authed.team.tsx), and audit log (routes/_authed._admin.audit-log.tsx). Column defs usecreateColumnHelper. MantineTablemarkup is the presentation layer viaflexRender. - Columns that should not be sortable (e.g. the metadata column in audit log) set
enableSorting: falseon the column def.
apps/web Jest test infrastructure
- Test target is
@nx/jest:jestwithpassWithNoTests: truein the executor options (apps/web/project.json), pointing atapps/web/jest.config.ts. apps/webuses Vite which relies onimport.meta.env.*- this syntax is illegal in Jest/CommonJS. The custom transformer atapps/web/jest.transformer.jspreprocessesimport.meta.env.Xtoundefinedandimport.meta.envto({})before handing the source to@swc/corefor TypeScript/JSX compilation. All test files are compiled through this transformer; the preset's default transformer is completely overridden.- SWC (unlike Babel) does NOT hoist
jest.mock()calls before static imports. Modules compiled to CJS have their static imports as top-levelrequire()calls that run before the module body. To mock a module that is transitively imported by the SUT at load time, userequire()insidebeforeAll()rather than a static import for the SUT - thejest.mock()calls in the module body are already registered by then. Seeauth-landing.spec.tsxandauth-guard.spec.tsfor examples. - jsdom polyfills needed (all in
apps/web/jest.setup.ts):TextEncoder/TextDecoder(from Nodeutil),URLSearchParams.sizegetter (jsdom uses older WHATWG spec),ResizeObserverstub (Mantine'sSegmentedControluses it viaFloatingIndicator). - The
typechecktarget runs two tsc passes: the maintsconfig.json(excludes*.spec.*files) andapps/web/tsconfig.spec.json(includes only spec files, usestypes: ["jest", "node"]). This keeps Jest globals out of the production-code type layer. apps/web/vite.config.mtssetsrouter: { routeFileIgnorePattern: '__tests__' }insidetanstackStart(...)to suppress TanStack Router's warning about spec files in theroutes/__tests__/directory.- Auth header tests in
api-client.spec.tsuselocalStoragedirectly rather than mocking~/lib/auth-storage, becausegetStoredSession()reads fromwindow.localStoragewhich jsdom provides. The session key is'turbo-vets.web.session'. - For Mantine required fields, the label
textContentincludes*from thearia-hiddenasterisk span. Use a starts-with regex like/^password/iinstead of/^password$/iwhen selecting a required Mantine input by label text. Bypass jsdom's native HTML5 required-field validation (which prevents thesubmitevent from firing for empty required fields) by dispatchingfireEvent.submit(form)on the<form>element directly instead of clicking the submit button. webis now included in the rootnpm testscript (npx nx run-many -t test --projects=api,dashboard,auth,data,web).