Phase 1 — Authentication: Task Specifications
Purpose: The complete auth surface — registration + email verification, login with rotating opaque refresh tokens, session management, password reset, the shared
packages/authcore, auth UI, the global API guard with rate limiting, audit events, password policy, and the E2E proof. No workspace/page logic. Tasks run in order, TASK-0013 → TASK-0022. Phase exit criteria (verbatim from ROADMAP.md): full auth flow E2E-tested; refresh rotation proven; audit events for login/logout/ reset; rate limiting on auth endpoints.
Task Table
| ID | Title | Est. |
|---|---|---|
| TASK-0013 | Registration + email verification | M |
| TASK-0014 | Login (access JWT + rotating refresh + sessions) | L |
| TASK-0015 | Logout, session list, all-sessions revoke | M |
| TASK-0016 | Password reset (request → single-use token → confirm) | M |
| TASK-0017 | packages/auth core (token service + rotation test matrix) | M |
| TASK-0018 | Auth pages UI (login/register/forgot/reset/verify) | L |
| TASK-0019 | API auth guard (global JwtAuthGuard + rate limiting) | M |
| TASK-0020 | Auth audit events | M |
| TASK-0021 | Password policy (min 10, letter+number, bcrypt cost 12) | S |
| TASK-0022 | E2E auth flows (tests/e2e/auth.spec.ts) | M |
Phase Completion Checklist
- [ ] Every task above meets its Definition of Done
- [ ] Exit: full auth flow E2E-tested (register → login → me → refresh → logout → re-login)
- [ ] Exit: refresh rotation proven (reuse detection revokes the family)
- [ ] Exit: audit events for login/logout/reset present in
audit_logs - [ ] Exit: rate limiting (10/min per IP) on auth endpoints proven in E2E
TASK-0013
Title
Registration + email verification
Objective
POST /api/v1/auth/register creates a user (hashed password, unique email) and starts 6-digit email verification (code in email; printed to the log in dev mode); POST /api/v1/auth/verify-email flips the user to verified.
Background
Phase 0 already provides the env layer (SMTP optional — unset = dev mail mode: logged, not sent), seeded demo users, and the User/EmailVerification models; register/verify are @Public until the guard lands (TASK-0019). The register service leaves a documented onUserRegistered(user) hook — the personal workspace is Phase 2 (TASK-0024).
Requirements
apps/api/src/modules/auth/{auth.module.ts, auth.controller.ts, auth.service.ts, mail.service.ts}:POST /register { email, password, name }(zod: valid lowercased email, password ≥ 10 until TASK-0021, name 1–100) →201 { user: { id, email, name, emailVerifiedAt: null } }(never the hash); duplicate →409 email_taken; password bcrypt costBCRYPT_ROUNDS(12); user created UNVERIFIED,status: ACTIVE.- Verification: 6-digit code,
EmailVerification.tokenHash = sha256(code)(code never stored), 24 h expiry, one active row per user (new request delete+insert in a transaction);MailService= nodemailer from SMTP env, dev mode =logger.infothe code withdevMode: true. POST /verify-email { email, code }→200 { verified: true }(sets VERIFIED +emailVerifiedAt); wrong/expired →400 invalid_verification_code; unknown email →404 user_not_found;POST /resend-verification { email }→202(rate-limited by TASK-0019).
Acceptance Criteria
- Valid register → 201; the
usersrow has a$2b$12$hash, UNVERIFIED. - Duplicate email → 409
email_taken; short password → 400 with field errors. - With SMTP unset the code is in a structured log line (
devMode: true) andtokenHashequals sha256 of the logged code (integration test re-derives it). - Verify with the logged code → 200 + VERIFIED +
emailVerifiedAtset; wrong code → 400. - A second request supersedes the old code (old code stops working).
Backend Changes
apps/api/src/modules/auth/*(4 files) +dto/{register.dto.ts, verify-email.dto.ts}(zod mirrored frompackages/shared/src/dto/auth.ts— created here, consumed by TASK-0018).
Frontend Changes
None (UI is TASK-0018).
Database Changes
None (uses users, email_verifications from the Phase 0 migration).
API Changes
register(201/400/409),verify-email(200/400/404),resend-verification(202/429) —docs/api/auth.md(new).
Tests
auth.service.test.ts(register happy/duplicate/validation, supersede, no-plaintext- code-in-row);tests/integration/auth-register.test.ts(register → resend → verify, DB state asserted at each step).
Security
- Plaintext code never persisted; logged only in dev mode; the mail service throws at boot if SMTP is missing in production.
Documentation
docs/api/auth.md(new): the 3 endpoints, shapes, error codes, dev-mail behavior.
Dependencies
- TASK-0005 (SMTP env), TASK-0010 (logger for mail logging).
Definition of Done
Endpoints + mail service live; unit + integration green; API docs written; dev-mode code logging verified.
TASK-0014
Title
Login (access JWT + rotating opaque refresh + sessions)
Objective
POST /api/v1/auth/login issues a 15-min HS256 access JWT + a 30-day opaque rotating refresh token (hashed at rest, one Session row per login); POST /refresh rotates with reuse detection that revokes the token family; GET /me returns the current user.
Background
Session and RefreshToken models exist; there is no familyId column — the family is the replacedBy chain (replaying a rotated token revokes everything from that point, walking the chain to the tip). Logic lives in token.service.ts (pure, repo-injected); TASK-0017 extracts it into packages/auth without duplication.
Requirements
POST /login { email, password }: unknown email or wrong password →401 { code: "invalid_credentials" }(identical body + timing — bcrypt always verifies against a dummy hash when the user is missing); success →200 { accessToken, refreshToken, user }; access = JWT HS256{ sub, email, iat, exp: now+900 }withJWT_ACCESS_SECRET; refresh =randomBytes(48).toString("hex")(96 chars, clear, returned once).- On login create
Session(tokenHash = sha256(refresh), UA, IP — first-hopX-Forwarded-Foronly) +RefreshToken(expiresAt = now + REFRESH_TTL_DAYS,replacedBy: null); setUser.lastLoginAt. POST /refresh { refreshToken }: (a) unknown hash →401 invalid_refresh_token; (b) replaying a rotated token → reuse: revoke the token + every successor via thereplacedBychain + theirSessionrows →401 refresh_reuse_detected; (c) valid → markrevokedAt, issue a newRefreshToken(oldreplacedBy= new id; the sameSessionsurvives via itstokenHashupdate), return the new pair.GET /me(Bearer) →200 { id, email, name, avatarUrl, theme, locale, timezone, emailVerifiedAt, createdAt }; expired/invalid →401 unauthorized;submust match an existing ACTIVE user.
Acceptance Criteria
- Login with seeded demo credentials → 200, decodable 15-min token + 96-char refresh;
sessions+refresh_tokensrows store sha256 only (DB scan: no plaintext). - Wrong password → 401, no session row; body identical to unknown email.
- Refresh rotates: the old token immediately 401s (
refresh_reuse_detected+ family revoked); the new token works. - login → refresh(t1→t2) → refresh(t2→t3) → replay t1 → t3 refresh 401 and all 3
Sessionrows REVOKED. - Expired access token → 401 on
/me; expired refresh → 401 (test clock or backdatedexpiresAt).
Backend Changes
apps/api/src/modules/auth/token.service.ts(issue/verify/rotate/reuse — extracted by TASK-0017);auth.service.ts(login, refresh, me);auth.controller.ts(3 routes).
Frontend Changes
None (UI is TASK-0018).
Database Changes
None (sessions + refresh_tokens suffice; family = replacedBy chain — design note in docs/security/auth-token-design.md, TASK-0019).
API Changes
login(200/401/429),refresh(200/401/429),GET /me(200/401) —docs/api/auth.md.
Tests
token.service.test.ts(issue/verify, rotation, reuse revokes the chain via a fake repo, expiry);tests/integration/auth-login.test.ts(criteria 1–5 end-to-end).
Security
- 48 random bytes, sha256-at-rest, single-use, 30-day cap; dummy-hash timing defense; reuse → family revoke (theft defense); XFF handling unit-tested.
Documentation
docs/api/auth.md: 3 routes + error codes;docs/security/auth-token-design.md(new): token anatomy, rotation flow (ASCII), reuse algorithm, why nofamilyId.
Dependencies
- TASK-0013 (users), TASK-0005 (JWT/refresh env), TASK-0010 (request context for ip/ua).
Definition of Done
Login/refresh/me with rotation + reuse detection; unit + integration prove criteria 1–5; docs written. The rotation logic is extraction-ready (pure, repo-injected).
TASK-0015
Title
Logout, session list, all-sessions revoke
Objective
Log out (revoke the current session), list active sessions, revoke any one, revoke all others — with the matching session manager in the app shell header.
Background
Session rows survive refresh rotations (only tokenHash changes), so a session is the stable "this login" identity; revoking a session must also kill its current refresh token.
Requirements
POST /logout { refreshToken }→RefreshToken.revokedAt = now,SessionREVOKED;200 { loggedOut: true }; unknown token → same 200 (idempotent, no leak).GET /sessions(Bearer) →200 { sessions: [{ id, userAgent, ipAddress, createdAt, expiresAt, isCurrent }] }— active only, newest first, max 50;isCurrentfrom the presented token's session.DELETE /sessions/:id(Bearer; caller's only → foreign id404 session_not_found);POST /sessions/revoke-all→ all but current →200 { revokedCount }.- A revoked session's access token stays valid until its 15-min expiry (documented trade-off — access TTL is the blast radius); its refresh 401s.
- UI:
apps/web/src/components/auth/session-manager.tsx— gear menu in the shell header: "Log out" (current), "Other sessions" (device string + IP + current badge) with per-row revoke + "Log out of all other sessions"; TanStack Query invalidation per action; 401 → redirect/login.
Acceptance Criteria
- Logout → 200; that token then 401s on refresh;
SessionREVOKED; repeat logout → 200. - List shows exactly the caller's active sessions with correct
isCurrent; a second user's sessions never appear (two-user integration test). DELETE /sessions/:idrevokes the target (its refresh dies) and 404s a foreign id;revoke-allrevokes all but current (count asserted).- UI: the manager renders the current session; revoking a row removes it; "all other" empties the list except current (asserted in TASK-0022).
- Post-revoke: refresh 401, but the unexpired access token still reads
/me(asserted, documented).
Backend Changes
apps/api/src/modules/auth/session.service.ts(list/revoke/revoke-all);auth.controller.ts(3 routes);auth.service.ts(logout).
Frontend Changes
session-manager.tsx;apps/web/src/lib/queries/auth.ts(useSessions,useRevokeSession,useRevokeAllSessions,useLogout); wired into the shell header (the TASK-0018 landing).
Database Changes
None (existing rows; revokedAt already settable).
API Changes
logout,GET sessions,DELETE sessions/:id,sessions/revoke-all—docs/api/auth.md.
Tests
session.service.test.ts(list scoping — no cross-user, revoke semantics, revoke-all excludes current, idempotent logout);tests/integration/auth-sessions.test.ts(two users, isolation, post-revoke 401, access-token-still-valid window).
Security
- Caller-scoped by
sub; foreign ids → 404 (not 403 — no enumeration oracle, recorded); revocation is the theft defense after reuse detection.
Documentation
docs/api/auth.md: 4 routes;docs/security/auth-token-design.md: "Session lifecycle" (why sessions survive rotations; the access-token trade-off).
Dependencies
- TASK-0014 (sessions, token service); TASK-0018 (shell to hang the UI on — the UI may land slightly after the endpoints within this task; both required for DoD).
Definition of Done
4 endpoints + session manager; unit + integration prove criteria 1–3 + 5; criterion 4 ready for TASK-0022; docs written.
TASK-0016
Title
Password reset (request → single-use token → confirm)
Objective
POST /api/v1/auth/password-reset/request emails a 1-hour single-use token (always 202, enumeration-proof); confirm sets the new password exactly once and revokes all of the user's sessions.
Background
PasswordReset (tokenHash, status PENDING/USED, expiresAt, usedAt) exists; User has no passwordChangedAt, so session revocation is the "logged out everywhere" mechanism; mail via MailService (dev mode prints the token).
Requirements
request { email }→ always202 { status: "if_account_exists_token_sent" }(same body + timing for unknown email — dummy-verify path like login); if the user exists: createPasswordReset(PENDING,tokenHash = sha256(token), token = 32 random bytes hex,expiresAt = now + 1h, supersede prior PENDING rows), email a link${FRONTEND_URL}/reset-password?token=….confirm { token, newPassword }: bysha256(token)— missing/expired →400 reset_token_invalid; USED →400 reset_token_used; else in a transaction: setUser.passwordHash(bcrypt 12, policy per TASK-0021), row → USED +usedAt, revoke all the user's sessions + refresh tokens (TASK-0015 internal), auditpassword.reset.confirmed.- Verified and unverified users both get it (reset doubles as "find my account" — documented); the link is single-use + 1-hour bounded even if clicked late.
- UI:
apps/web/src/app/(auth)/reset-password/page.tsxreads?token=, RHF + zod (password + confirm match, API policy errors), success state → "All sessions signed out — log in with your new password" + link to/login; expired/used → inline error banner (no reload). - Request is rate-limited 10/min per IP (TASK-0019) + 5/hour per lowercased email (in-memory throttle, documented).
Acceptance Criteria
- Unknown email → 202, zero rows; real email → 202 + one PENDING row + (dev) token in the log.
- Confirm → 200,
passwordHashchanges (old password fails login, new works), row USED +usedAt. - Same token again → 400
reset_token_used; backdatedexpiresAt→ 400reset_token_invalid. - After confirm, every pre-reset session/refresh token for that user is revoked (DB assertion).
- Browser flow (request → follow logged token → new password → login with it) works; the expired-token page shows the banner (asserted in TASK-0022).
Backend Changes
apps/api/src/modules/auth/password-reset.service.ts;auth.controller.ts(2 routes);auth.module.ts(wire TASK-0015 revoke-all internal).
Frontend Changes
apps/web/src/app/(auth)/reset-password/page.tsx;packages/shared/src/dto/auth.ts(passwordResetRequestSchema,passwordResetConfirmSchemaviazodResolver).
Database Changes
None (existing rows; status/usedAt modeled).
API Changes
password-reset/request(202),password-reset/confirm(200/400) —docs/api/auth.md.
Tests
password-reset.service.test.ts(enumeration-proof, single-use, expiry, session revocation on confirm);tests/integration/auth-password-reset.test.ts(criteria 1–4, incl. old-fails / new-works round trip).
Security
- Enumeration-proof; 32 random bytes, sha256-at-rest, 1 h, single-use; all sessions revoked on confirm; per-email throttle; no plaintext token in DB or non-dev logs.
Documentation
docs/api/auth.md: 2 routes + error codes;docs/security/auth-token-design.md: "Password reset" (session-revocation rationale).
Dependencies
- TASK-0013 (MailService), TASK-0015 (revoke-all), TASK-0021 (policy; until then enforce ≥ 10 + letter+number via a shared zod schema so 0021 only adds tests + doc).
Definition of Done
Both endpoints + reset page; unit + integration prove criteria 1–4; UI flow demonstrated (screenshot in PR); docs written.
TASK-0017
Title
packages/auth core (token service + rotation test matrix)
Objective
Extract the auth core into packages/auth: a framework-agnostic token service (JWT issue/verify + opaque refresh generate/hash) and the refresh-rotation state machine (rotate, expiry, reuse, family revoke) with a full unit test matrix — so apps/api calls it and Phase 7 realtime reuses it.
Background
Per AGENTS.md (reuse, no duplication, domain logic independent of DB/UI) the api service becomes a thin Prisma adapter.
Requirements
packages/auth/package.json(name@factory/auth) +src/index.tsexports:createTokenService({ signSecret, accessTtlSeconds, refreshTtlDays, now? }),hashToken(value)(sha256 hex),generateOpaqueToken(bytes = 48),verifyAccessToken(token, signSecret) → { sub, email } | null.- Rotation:
issueSession(userId, { userAgent, ipAddress });rotate(refreshToken, repo) → { ok: true, next } | { ok: false, reason: "invalid" | "expired" | "reuse", revokedTokenHashes }—repois a package-defined interface (findByHash,revoke,create,revokeChain); the chain walk lives here, not in the api. - Unit matrix (
packages/auth/src/*.test.ts, ≥ 30 cases): rotation happy path; session identity preserved; double rotation (t1→t2→t3); reuse of t1 after t3 (chain revoked, t3 rejected next); reuse of the currently-valid token; access expiry (injected clock); refresh expiry; unknown token; 5-lengthreplacedBychain revoke-from-reuse-point; JWT tamper (sig flip → null); wrongsub; clock-injection determinism (seeded RNG option for tests). apps/api/src/modules/auth/token.service.tsdeleted; api imports@factory/auth(a grep test asserts no token logic remains in the api).packages/authcoverage ≥ 80% lines/branches (TASK-0008 config already sets it — this task makes it true).
Acceptance Criteria
pnpm testruns the matrix green (≥ 30 cases;invalid|expired|reuseall covered).- For a 3-step chain, replaying step 1 revokes steps 2+3 (their hashes in
revokedTokenHashes) and step 3's next refresh is rejected (fake repo records all revokes). - The api contains no
jsonwebtoken/crypto.randomBytestoken logic (grep test); TASK-0014's integration tests pass unchanged (behavior-preserving). - Coverage report shows
packages/auth≥ 80%. verifyAccessTokenrejects bad signature, expired, malformed, missingsub— one test per reason.
Backend Changes
packages/auth/{package.json, tsconfig.json, vitest.config.ts, src/index.ts, src/token-service.ts, src/rotation.ts, src/hash.ts}; apitoken.service.ts(deleted) →token-repo.adapter.ts(PrismaRefreshRepo),auth.service.ts(import switch).
Frontend Changes
None
Database Changes
None
API Changes
None (behavior-preserving refactor).
Tests
- The requirement-3 matrix (unit, in
packages/auth);tests/integration/auth- rotation-extraction.test.ts(api rotation still matches TASK-0014 criteria 3–4).
Security
- The core never logs token values (source-scan test); sha256-at-rest invariant;
crypto.timingSafeEqualfor hash compare in the api adapter.
Documentation
packages/auth/README.md: API surface,RefreshRepocontract, rotation state machine (ASCII), Phase 7 reuse note;docs/security/auth-token-design.md: "implementation lives inpackages/auth".
Dependencies
- TASK-0014 (the logic), TASK-0008 (coverage config).
Definition of Done
Package shipped with the full matrix; api a thin adapter; behavior proven unchanged; README + design doc updated.
TASK-0018
Title
Auth pages UI (login/register/forgot/reset/verify)
Objective
The five auth pages in apps/web — /login, /register, /forgot-password, /reset-password, /verify-email — with React Hook Form + Zod from @factory/shared/dto/auth, proper error states, theme-awareness, responsive layout, plus the minimal authenticated landing they redirect into.
Background
A logged-in Phase 1 user has no workspace yet (auto-creation is Phase 2, TASK-0024), so / is a minimal landing: greeting + the session manager (TASK-0015) + "Workspaces arrive in Phase 2". The shared zod DTOs live in packages/shared/src/dto/auth.ts (TASK-0013/0016) so form and API validate with the same schemas.
Requirements
- Routes
apps/web/src/app/(auth)/{login, register,forgot-password, reset-password, verify-email}/page.tsx— server-rendered shell + client form components (apps/web/src/components/auth/*.tsx). - Forms: RHF +
zodResolver(sharedSchema); client messages + servercode→ field mapping (email_taken→ email field,invalid_credentials→ banner,invalid_verification_code→ code field,reset_token_*→ banner); 429 → "Too many attempts, try again in X min" banner. - Login: store
accessTokenin memory (ZustanduseAuthStore) +refreshTokeninlocalStoragekeyfactory.refresh(trade-off documented indocs/security/auth-token-design.md); on any API 401 → one automatic refresh then redirect/login?expired=1; success →/. - Register success → "Check your email" (dev mode: show the code inline when
NEXT_PUBLIC_DEV_MAIL_HINT=true— server flag, never in production builds) + link to/login; verify-email: email prefilled from query, 6-digit segmented code input, success → "Verified — sign in". - Theme-aware via
@factory/uitokens (light/dark/system E2E-asserted); responsive (single column < 768 px, card ≥ 768 px); keyboard-accessible (label↔input, focus- visible, error text viaaria-describedby). - Guard:
/loginredirects authenticated users to/;/requires auth (apps/web/src/middleware.tstoken-presence check only — real authz is server-side, TASK-0019). data-testidconventions for TASK-0022:login-form/-email/-password/-submit/- error,register-form/-email/-password/-name/-submit,forgot-form,verify-email-code/-submit,reset-password-form,session-menu,session-row,logout-button(full table indocs/ui/auth-pages.md).
Acceptance Criteria
- All five pages render in light and dark with no unthemed elements (E2E
prefers-color-schemeemulation in TASK-0022). - Client validation blocks empty/invalid email + short password before any network call; server
email_takenlands as a field error (not an alert). - Login with demo credentials stores tokens, redirects to
/, the session menu shows the current session; logout returns to/login. - An expired access token triggers exactly one silent refresh (network: 401 → POST /refresh → retried request 200).
- 375 px: all forms usable, no horizontal scroll, labels unclipped; Tab order logical (PR checklist).
- Unauthenticated
/→/login; authenticated/login→/.
Backend Changes
None, except: resend-verification response gains an optional devCode field in development only (documented in docs/api/auth.md; absent in production) so the register "check your email" state can show it in dev.
Frontend Changes
(auth)/*pages (4 new + reset refinement);apps/web/src/app/page.tsx(landing);apps/web/src/middleware.ts;apps/web/src/stores/auth-store.ts;apps/web/src/components/auth/*(5 forms + sharedAuthCard);apps/web/src/lib/ api.ts(fetch wrapper: bearer injection, 401→refresh→retry).
Database Changes
None
API Changes
resend-verificationresponse: optionaldevCodein development only.
Tests
apps/web/src/components/auth/login-form.test.tsx(validation, error mapping for the 5 codes, 429 banner) + a render smoke per page; browser behavior proven by TASK-0022.
Security
localStoragerefresh trade-off documented (XSS impact; cookies rejected: CSRF posture of a Bearer-only API); no token in URLs (reset token is the single exception — single-use + 1 h); no secrets in the client bundle (grep test on the build output).
Documentation
docs/ui/auth-pages.md(new): page inventory, testid conventions, error-code → UI mapping;docs/security/auth-token-design.md: "Client token storage".
Dependencies
- TASK-0013/0014/0015/0016 (all endpoints); the api guard (TASK-0019) classifies the public auth routes — the UI lands before the guard is ON, so the public list must already cover the auth routes.
Definition of Done
Five pages + landing + middleware + api wrapper; unit tests green; theme/responsive checklist passed; testid conventions documented + in use; docs written.
TASK-0019
Title
API auth guard (global JwtAuthGuard + rate limiting)
Objective
Make authentication mandatory on every api route except explicit @Public() routes; add 10/min-per-IP rate limiting on login/register/reset; document the cookie-free Bearer design.
Background
verifyAccessToken comes from packages/auth (TASK-0017); the guard is registered once via APP_GUARD in app.module.ts.
Requirements
apps/api/src/common/guards/jwt-auth.guard.ts(APP_GUARD): readsAuthorization: Bearer <token>; missing/malformed →401 { code: "unauthorized" }(TASK-0010 envelope +requestId); verifies via@factory/auth; loads the user (exists + ACTIVE), attachesreq.user = { id, email }; failures →401 unauthorized/401 account_disabled.@Public()(apps/api/src/common/decorators/public.decorator.tsviaSetMetadata) — the guard skips it. Public list (explicit, documented):/api/v1/health,/health/ready, andPOST /api/v1/auth/register,login,refresh,verify-email,resend-verification,password-reset/request,password-reset/confirm. No other public routes — a route-map test enumerates every route and asserts each is@Public()or guarded (prevents silent public endpoints).- Rate limiting (NestJS throttler or a small custom guard):
POST /login,/register,/password-reset/request→ 10 req / 60 s per IP →429 { code: "rate_limited", retryAfterSeconds }+Retry-Afterheader;resend-verification→ 5/60 s per IP and 5/hour per email; per-IP store in-memory (single instance) with a Redis-adapter interface for multi-instance (documented; Phase 15 wires it). - The guard reads no cookies and the api sets none — the cookie-free Bearer design (stateless scaling, no CSRF surface, explicit header) is written up with the client storage trade-off in one document.
- 401s do not leak which check failed beyond the two codes (bad signature vs expired → same
unauthorized).
Acceptance Criteria
- Every non-
@Public()route without a Bearer token → 401unauthorizedwith the envelope (route-map test proves it per route). - A valid token passes; an expired one → 401 (no distinction from bad signature in the body).
- 11 consecutive
POST /loginfrom one IP in 60 s → the 11th is 429 withRetry-After(the first 10 are not 429). - Two IPs are limited independently (A exhausted, B still 200).
- The route-map test fails when a new non-public route is added without a token (deliberate violation demo, throwaway branch).
- No
Set-Cookieon any endpoint (shared integration hook).
Backend Changes
apps/api/src/common/guards/{jwt-auth.guard.ts, throttler.guard.ts},common/decorators/public.decorator.ts,auth.controller.ts(@Public()on the 7 auth routes),app.module.ts(guards + throttler store).
Frontend Changes
None (the client already speaks Bearer; 429 handling is built, TASK-0018).
Database Changes
None (in-memory throttle; the Redis adapter is an interface only).
API Changes
429+Retry-Afteron throttled routes;401codesunauthorized/account_disabled—docs/api/auth.md+docs/api/errors.md.
Tests
jwt-auth.guard.test.ts(public skip, missing/malformed/expired header, active/disabled user);tests/integration/auth-guard.test.ts(route-map enumeration, 429 behavior, per-IP independence, no-Set-Cookie).
Security
- The single server-side auth gate (AGENTS.md: never trust frontend authz); the route- map test prevents silent public routes; 10/min is the documented brute-force defense; 401 uniformity prevents token analysis.
Documentation
docs/security/auth-token-design.md: "API guard" + "Rate limiting" + the cookie-free rationale (canonical);docs/api/errors.md: 401/429 contracts.
Dependencies
- TASK-0017 (verifyAccessToken), TASK-0010 (envelope), TASK-0013/0014/0016 (routes to classify).
Definition of Done
Guard + public decorator + throttler live; route-map test proves no silent public routes; 429 proven per IP; no-Set-Cookie proven; design doc canonical.
TASK-0020
Title
Auth audit events
Objective
Write audit_logs rows for user.login, user.register, user.logout, password.reset.requested, password.reset.confirmed with actor/ip/ua captured — the foundation every later phase extends.
Background
AuditLog + AuditActorType exist and the request context (TASK-0010) carries requestId; audit failure must never break the request (log + degrade, never 500 the auth response — documented resilience rule, auditable in the log).
Requirements
apps/api/src/modules/audit/{audit.module.ts, audit.service.ts}:record(event)whereAuditEvent = { action, actorType, actorId, workspaceId?, pageId?, resourceType, resourceId, metadata? }; the service fillsipAddress(first-hop XFF, TASK-0014 rule) +userAgentfrom context; fire-and-forget insert withcatch→logger.error(the request never fails because of audit).- Emission points (exactly these five in Phase 1):
user.register(on success, metadata{ emailVerified: false });user.login(metadata{ sessionId });user.logout(metadata{ sessionId, revokeAll });password.reset.requested(internal only — the API never reveals it);password.reset.confirmed(metadata{ revokedSessionCount }). - Reuse-detection revokes (TASK-0014) write a sixth event
session.revoked_reuse(actorType: SYSTEM,actorId: "system", metadata{ sessionId, replayedTokenHash }— the hash, never the token). - Event names centralized in
packages/shared/src/audit-events.ts(const map +AuditActiontype) — the single source of truth (AGENTS.md). - No PII beyond the model allows: email only in
user.registermetadata (documented exception); passwords/tokens never in metadata (source-scan test on the audit service).
Acceptance Criteria
- Each of the 6 events produces exactly one row with correct
action,actorType,actorId,ipAddress,userAgent(integration test matches ip/ua to the request it sent). user.registermetadata has the email; no row anywhere contains a password or token value (DB-widemetadata::textscan for the known strings → zero hits).- Failing the audit insert (fault injection) leaves the auth response 200 + an
errorlog line. audit-events.tsexports all six names; an emission using a string literal not in the map fails typecheck (theAuditActiontype).session.revoked_reuseappears after a proven reuse (replay the TASK-0014 chain) withactorType: SYSTEM.
Backend Changes
apps/api/src/modules/audit/*; wiring inauth.service.ts/session.service.ts/password-reset.service.ts(6 points);packages/shared/src/audit-events.ts.
Frontend Changes
None (the viewer is Phase 14; this task is write-side).
Database Changes
prisma/schema.prisma: add@@index([action, createdAt])onAuditLog→ migration<ts>_audit_index(the first Phase 1 schema change; ship in the same commit).
API Changes
None (no endpoint exposes audit in Phase 1).
Tests
audit.service.test.ts(context filling, resilience — insert failure logged not thrown, event shape);tests/integration/auth-audit.test.ts(all 6 events + no- secret scan + fault injection).
Security
- Append-only in practice (no update/delete paths — asserted in the route-map test); the no-secret scan is a standing test;
session.revoked_reuseis the incident- response hook.
Documentation
docs/security/audit-events.md(new): the 6 events, field semantics, the resilience rule, the Phase 2/3 extension protocol.
Dependencies
- TASK-0010 (context), TASK-0014 (login/logout/reuse), TASK-0013 (register), TASK-0016 (reset).
Definition of Done
Audit module + 6 emission points live; all 5 criteria proven (incl. the no-secret scan); first Phase 1 migration applied; docs written.
TASK-0021
Title
Password policy (min 10, letter+number, bcrypt cost 12)
Objective
Make the password policy one tested, enforced rule — minimum 10 chars with ≥ 1 letter and ≥ 1 number, bcrypt cost 12 — applied at registration + reset confirm, with the policy itself in packages/auth.
Background
The policy defines character rules, the env (BCRYPT_ROUNDS, TASK-0005) defines the cost; web forms and the api share one definition.
Requirements
packages/auth/src/password-policy.ts:PASSWORD_POLICY = { minLength: 10, requireLetter: true, requireNumber: true }(exported const),validatePassword(pw) → { valid, violations }("too_short" | "missing_letter" | "missing_number"), and apasswordSchema(zod.refinemapping violations to stable codes);packages/shared/src/dto/auth.tsre-exportspasswordSchema(forms validate with the same rule).hashPassword(pw, cost)inpackages/auth(bcrypt, default 12; the api passesenv.BCRYPT_ROUNDS); api boot assertion: cost outside 10–15 → exit 64.- Wire into
POST /register+POST /password-reset/confirm→ 400 withdetails: { password: ["too_short", …] }(exact violation codes, not prose); login is not policy-checked (pre-existing hashes stay usable — documented). - The seeded passwords (
DemoPassw0rd!,AdminPassw0rd!) satisfy the policy — a test proves it (guards against a future tightening that breaks the TASK-0011 seed contract). - Single-pass validation (collect all violations; no short-circuit timing that reveals which rule failed first — coarse timing bounds in a unit test).
Acceptance Criteria
validatePasswordmatrix (≥ 15 cases): 9 chars →too_short; 10 letters →missing_number; 10 numbers →missing_letter; 10 mixed → valid; 50 chars → all violations reported;"Äbcdef1234"valid (unicode letter counts); spaces-only → all violations.- Register
short1a→ 400["too_short"];abcdefghij→["missing_number"];1234567890→["missing_letter"];ValidPass1→ 201. - Reset-confirm with a violating password → 400 with codes; the old password still works (no partial state).
- Both seeded passwords pass
validatePassword(seed-contract guard). - Default cost 12: a unit-test hash yields a
$2b$12$prefix; api boot withBCRYPT_ROUNDS=8exits 64.
Backend Changes
packages/auth/src/password-policy.ts(+hash.tsif absent from TASK-0017);apps/api/src/modules/auth/{auth.service.ts, password-reset.service.ts}(wiring);packages/shared/src/dto/auth.ts(re-export); apimain.tsboot assertion.
Frontend Changes
apps/web/src/components/auth/{register-form.tsx, reset-password-form.tsx}:zodResolvernow uses the sharedpasswordSchema(live violation hints as the user types).
Database Changes
None
API Changes
- 400 on register/reset-confirm carries
details.password: string[](additive;docs/api/auth.md).
Tests
packages/auth/src/password-policy.test.ts(15+ matrix + timing + seeded-password guard);tests/integration/auth-password-policy.test.ts(criteria 2–3, 5 via the api).
Security
- Stable enumerated violation codes (no oracle beyond the documented rules); no timing oracle (single-pass, tested); cost 12 with a documented 10 lower bound + production recommendation.
Documentation
docs/security/passwords.md(new): the policy, the codes, the cost rationale (OWASP alignment), how to raise the cost (env only), the login-is-not-checked rule.
Dependencies
- TASK-0017 (
packages/auth,hashPassword), TASK-0013/0016 (enforcement points), TASK-0018 (forms use the shared schema).
Definition of Done
Policy in packages/auth with the full matrix; api + forms wired to the shared schema; cost assertion proven; seed guard green; docs written.
TASK-0022
Title
E2E auth flows (tests/e2e/auth.spec.ts)
Objective
Prove the Phase 1 exit criteria in Playwright (tests/e2e/auth.spec.ts): the full register → login → me → refresh → logout → re-login flow, the wrong-password error path, and rate limiting at the 11th attempt.
Background
The E2E api runs with SMTP unset → dev mail mode, so verification/reset codes are logged by the api container; the helper readDevCodeFromLogs() tails the saved compose log for the code line (dev-only; in CI the E2E job saves the compose logs).
Requirements
tests/e2e/auth.spec.ts,test.describe.serial, unique email per run (e2e-<timestamp>@factory.local): - Flow 1 (happy path):/register(name/email/E2EPassw0rd1) → "Check your email" → read dev code →/verify-email?email=…→ code → verified banner →/loginwith the new user's credentials → land on/(greeting shows the name) →GET /mevia helper returns the user → refresh via helper → logout viadata-testid=logout-button→ back on/login→ re-login →/again. - Flow 2 (wrong password):/loginwith the new email + wrong password →login-errorbanner "Invalid email or password", no redirect, form values preserved except password. - Flow 3 (rotation + reuse, API-level via helper): login → refresh (t1→t2) → replay t1 → 401refresh_reuse_detected→ t2 refresh 401 →GET /sessions(fresh login) shows 0 sessions. - Flow 4 (rate limit — LAST): from the same IP, send login attempts until the 11th in the 60 s window → the 11th is 429 withRetry-After; the/loginUI then shows the "Too many attempts" banner (one browser submit after the helper loop). Budget: flows 1–3 make ≤ 9 throttled calls; assert the first 429 arrives no earlier than attempt 11 and no later than attempt 16 (window-start tolerance — deterministic enough for CI).- Theme + responsive: emulate dark + light on
/login— form card background colors differ (computed style); at 375 px the login form has no horizontal scroll. - DB assertions via
helpers/db.ts: after Flow 1,audit_logshasuser.register+user.login(≥ 2) +user.logoutfor the new user; after Flow 3,session.revoked_reuseexists. - Cleanup: unique emails only (no teardown delete — the E2E DB resets per CI run via a fresh volume); the spec passes twice in a row locally (a
beforeAllwaits for the throttle window to clear if the previous run was < 60 s ago).
Acceptance Criteria
pnpm test:e2e(auth.spec.ts) green on the dev stack (compose--profile apps+ migrated + seeded DB), clean machine — run recorded in the PR.- Flow 1 completes end-to-end (verify via dev code) + re-login; the greeting shows the registered name.
- Flow 2 shows the exact banner and preserves form state.
- Flow 3 proves reuse detection through the real api (two 401s + empty session list) + the
session.revoked_reuserow. - Flow 4: first 429 within the 11–16 budget,
Retry-Afterpresent, UI banner shown; the spec passes twice consecutively. - Theme/responsive assertions pass in both schemes + at 375 px.
Backend Changes
None (a gap found is a bug in an earlier Phase 1 task — fix it in that module in the same PR, recorded).
Frontend Changes
tests/e2e/helpers/logs.ts(dev-log code reader — dev/CI-only, refuses a production-likeE2E_BASE_URLorigin);tests/e2e/auth.spec.ts.
Database Changes
None
API Changes
None
Tests
- The spec is the deliverable; a local run instruction in
docs/development/testing.md(auth E2E needs the compose stack + dev-mail log capture).
Security
- The dev-log reader is dev/CI-only (origin allowlist); no production secrets in the spec; the spec never asserts on other users' data.
Documentation
docs/development/testing.md: how to run auth E2E locally + in CI.- PR: each exit criterion → the test that proves it (Flow 1 → full flow; Flow 3 → rotation proven; audit rows → audit exit; Flow 4 → rate limiting).
Dependencies
- TASK-0013–0021 (everything), TASK-0008 (harness), TASK-0009 (health in globalSetup).
Definition of Done
tests/e2e/auth.spec.ts green twice locally + in CI; all six criteria proven; exit→test mapping recorded; docs updated. Phase 1 exit criteria fully met — Phase 2 may start.