diff --git a/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md b/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md new file mode 100644 index 0000000..4aadc7f --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md @@ -0,0 +1,1881 @@ +# Vertical Refactor — Plan 3: Auth + Media + Restore Blog Cross-Feature Relations + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Migrate the existing `auth` domain (Users collection, sign-in/up/out flows) into a new `@repo/auth` feature package, migrate the Media collection into a new `@repo/media` feature package, and restore the cross-feature relationships in `@repo/blog/integrations/cms/collections/articles.ts` (`author` → relationship to users, `featuredImage` → upload to media). Demonstrates that the per-feature DI pattern + composition exception (`core-cms` aggregating multiple feature `/cms` exports) scales beyond one feature. + +**Architecture:** Two new feature packages following the canonical pattern proven in Plan 2. Auth has multiple use-cases, two services (auth, dropping the unused telemetry), and depends on InversifyJS constructor injection (auth service takes users repo). Media is minimal — collection + barrel only (no use-cases or DI yet; spec addendum v5 "no empty folders"). + +**Tech Stack:** Same as Plan 2. + +**Plan position:** Plan 3 of 6. +- Plan 1 ✅ Foundation +- Plan 2 ✅ Blog feature +- **Plan 3 (this doc):** Auth + Media + restore blog relations +- Plan 4: Marketing-pages + Navigation features +- Plan 5: App + UI integration (core-trpc client/providers, route handlers, example pages) +- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite + +**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` + +**Lessons from Plan 2 (apply throughout):** +1. **Vitest config**: each new feature's `vitest.config.ts` MUST include `resolve.alias: { "@": path.resolve(__dirname, "./src") }` — vitest doesn't read tsconfig paths. +2. **No `rootDir` in tsconfig**: removed in blog because `tests/**/*` is outside `src/`. Same for new features. +3. **Source files use relative imports** (NOT `@/`). `@/` reserved for test files within the feature's own context. Reason: when a downstream package typechecks and follows imports into the feature, it can't resolve the feature's `@/` alias. +4. **Payload-backed repos take `SanitizedConfig` via constructor**, NOT `import config from '@repo/core-cms'`. Avoids workspace cycle. Feature `package.json` does NOT depend on `@repo/core-cms`. App boot (Plan 5) supplies the config when binding the prod repo. +5. **`z.unknown()` in input schemas should be `.optional()`** — Zod treats `unknown` as accepting `undefined` anyway; explicit is more honest. + +--- + +## Decisions taken in this plan + +- **Drop `ITelemetryService`** — currently unused in any auth use-case. Spec addendum v5 says don't carry dead code. If telemetry is needed later, add it back via `core-shared/src/telemetry/` (cross-cutting concern, not auth-specific). +- **Auth domain entity stays `User { id, username, passwordHash }`** — the existing model. The Payload `Users` collection (with `email`, `displayName`, `role`) is a SEPARATE, parallel concept used for Payload admin login. The two are deliberately not wired in this plan; reconciling them is future work. The `author` relationship in blog points at the Payload Users collection (i.e., admin-side users), which is consistent with the existing template. +- **Media is a "skeleton-only" feature** — collection + barrel only. No entities/use-cases/repos yet. Add when something needs them. +- **Blog's `author` field becomes `relationship → users`** with `relationTo: "users"` (the Payload collection slug). Blog package doesn't import auth — relationship is by slug, satisfied at Payload runtime when both collections are registered in `core-cms`. + +--- + +## File Structure + +**Create — new `@repo/auth` package:** +- `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` +- `packages/auth/src/index.ts` +- `packages/auth/src/config.ts` (SESSION_COOKIE constant) +- `packages/auth/src/entities/user.ts` + `user.test.ts` +- `packages/auth/src/entities/cookie.ts` +- `packages/auth/src/entities/session.ts` + `session.test.ts` +- `packages/auth/src/entities/errors.ts` +- `packages/auth/src/application/repositories/users-repository.interface.ts` +- `packages/auth/src/application/services/authentication-service.interface.ts` +- `packages/auth/src/application/use-cases/sign-in.use-case.ts` + `.test.ts` +- `packages/auth/src/application/use-cases/sign-up.use-case.ts` + `.test.ts` +- `packages/auth/src/application/use-cases/sign-out.use-case.ts` + `.test.ts` +- `packages/auth/src/infrastructure/repositories/mock-users.repository.ts` +- `packages/auth/src/infrastructure/services/mock-authentication.service.ts` +- `packages/auth/src/di/symbols.ts` +- `packages/auth/src/di/module.ts` +- `packages/auth/src/di/container.ts` + `container.test.ts` +- `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` + `.test.ts` +- `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts` + `.test.ts` +- `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts` + `.test.ts` +- `packages/auth/src/integrations/cms/collections/users.ts` +- `packages/auth/src/integrations/cms/index.ts` +- `packages/auth/src/integrations/api/router.ts` + `router.test.ts` +- `packages/auth/src/ui/query.ts` +- `packages/auth/tests/sign-in-flow.feature.test.ts` + +**Create — new `@repo/media` package:** +- `packages/media/{package.json,tsconfig.json,turbo.json}` +- `packages/media/src/index.ts` +- `packages/media/src/integrations/cms/collections/media.ts` +- `packages/media/src/integrations/cms/index.ts` + +**Modify:** +- `packages/blog/src/integrations/cms/collections/articles.ts` — restore `author: relationship → users`, add `featuredImage: upload → media` +- `packages/core-cms/src/payload.config.ts` — register `users`, `media` collections; depend on `@repo/auth` and `@repo/media` +- `packages/core-cms/package.json` — add `@repo/auth`, `@repo/media` +- `packages/core-api/src/root.ts` — add `auth: authRouter` to appRouter +- `packages/core-api/package.json` — add `@repo/auth` +- `tsconfig.base.json` — add `@repo/auth*`, `@repo/media*` aliases +- `apps/cms/package.json` — add `@repo/auth`, `@repo/media` (for schema visibility) + +--- + +## Phase A: Auth feature + +### Task 3.1: Scaffold @repo/auth package + +**Files:** `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases. + +- [ ] **Step 1: Create `packages/auth/package.json`** + +```json +{ + "name": "@repo/auth", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./cms": "./src/integrations/cms/index.ts", + "./api": "./src/integrations/api/router.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/core-shared": "workspace:*", + "@trpc/server": "^11.0.0", + "inversify": "^6.2.0", + "payload": "^3.14.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.0.0", + "vitest": "^3.1.0" + } +} +``` + +> Note: NO `@repo/core-cms` dep (cycle avoidance per Plan 2 lesson #4). + +- [ ] **Step 2: Create `packages/auth/tsconfig.json`** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM"], + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +> Note: NO `rootDir` (Plan 2 lesson #2). Path alias `@/` declared but only used in test files (Plan 2 lesson #3). + +- [ ] **Step 3: Create `packages/auth/turbo.json`** + +```json +{ + "extends": ["//"], + "tags": ["feature"] +} +``` + +- [ ] **Step 4: Create `packages/auth/vitest.config.ts`** + +```typescript +import path from "node:path"; +import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; + +export default { + ...baseVitestConfig, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}; +``` + +- [ ] **Step 5: Create `packages/auth/src/index.ts`** + +```typescript +export {}; +``` + +- [ ] **Step 6: Add path aliases to `tsconfig.base.json`** + +Add three lines inside `compilerOptions.paths`: + +```json +"@repo/auth": ["packages/auth/src/index.ts"], +"@repo/auth/cms": ["packages/auth/src/integrations/cms/index.ts"], +"@repo/auth/api": ["packages/auth/src/integrations/api/router.ts"] +``` + +- [ ] **Step 7: Install + verify** + +Run: `pnpm install` +Verify: `pnpm list --recursive --depth=-1 | grep "@repo/auth"` + +- [ ] **Step 8: Commit** + +```bash +git add packages/auth tsconfig.base.json pnpm-lock.yaml +git commit -m "feat(auth): scaffold empty package with feature tag + path aliases" +``` + +--- + +### Task 3.2: Auth entities (User, Cookie, Session, errors) + tests + +**Files:** `packages/auth/src/entities/{user.ts,cookie.ts,session.ts,errors.ts}` + tests, `packages/auth/src/config.ts`. + +- [ ] **Step 1: Write `packages/auth/src/entities/user.test.ts`** + +```typescript +import { describe, expect, it } from "vitest"; +import { userSchema } from "./user"; + +describe("userSchema", () => { + it("accepts a valid user", () => { + const result = userSchema.parse({ + id: "1", + username: "alice", + passwordHash: "hashed_password_1", + }); + expect(result.username).toBe("alice"); + }); + + it("rejects username shorter than 3 chars", () => { + expect(() => + userSchema.parse({ + id: "1", + username: "ab", + passwordHash: "hashed_password_1", + }), + ).toThrow(); + }); + + it("rejects passwordHash shorter than 6 chars", () => { + expect(() => + userSchema.parse({ + id: "1", + username: "alice", + passwordHash: "abc", + }), + ).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/auth && pnpm vitest run src/entities/user.test.ts` +Expected: FAIL — Cannot find module './user'. + +- [ ] **Step 3: Implement `packages/auth/src/entities/user.ts`** + +```typescript +import { z } from "zod"; + +export const userSchema = z.object({ + id: z.string(), + username: z.string().min(3).max(31), + passwordHash: z.string().min(6).max(255), +}); + +export type User = z.infer; +``` + +- [ ] **Step 4: Implement `packages/auth/src/entities/cookie.ts`** + +```typescript +type CookieAttributes = { + secure?: boolean; + path?: string; + domain?: string; + sameSite?: "lax" | "strict" | "none"; + httpOnly?: boolean; + maxAge?: number; + expires?: Date; +}; + +export type Cookie = { + name: string; + value: string; + attributes: CookieAttributes; +}; +``` + +- [ ] **Step 5: Write `packages/auth/src/entities/session.test.ts`** + +```typescript +import { describe, expect, it } from "vitest"; +import { sessionSchema } from "./session"; + +describe("sessionSchema", () => { + it("accepts a valid session", () => { + const result = sessionSchema.parse({ + id: "session_1", + userId: "1", + expiresAt: new Date(), + }); + expect(result.userId).toBe("1"); + }); + + it("rejects non-Date expiresAt", () => { + expect(() => + sessionSchema.parse({ + id: "session_1", + userId: "1", + expiresAt: "2026-05-04", + }), + ).toThrow(); + }); +}); +``` + +- [ ] **Step 6: Implement `packages/auth/src/entities/session.ts`** + +```typescript +import { z } from "zod"; + +export const sessionSchema = z.object({ + id: z.string(), + userId: z.string(), + expiresAt: z.date(), +}); + +export type Session = z.infer; +``` + +- [ ] **Step 7: Implement `packages/auth/src/entities/errors.ts`** + +```typescript +export class AuthenticationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class UnauthenticatedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class UnauthorizedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} +``` + +- [ ] **Step 8: Implement `packages/auth/src/config.ts`** + +```typescript +export const SESSION_COOKIE = "session"; +``` + +- [ ] **Step 9: Run all entity tests — expect 5 PASS** + +Run: `cd packages/auth && pnpm vitest run src/entities` +Expected: 5 tests pass (user 3 + session 2). + +- [ ] **Step 10: Commit** + +```bash +git add packages/auth/src/entities packages/auth/src/config.ts +git commit -m "feat(auth): add User, Cookie, Session entities + errors + config" +``` + +--- + +### Task 3.3: Auth application interfaces (repository + service) + +- [ ] **Step 1: Create `packages/auth/src/application/repositories/users-repository.interface.ts`** + +```typescript +import type { User } from "../../entities/user"; + +export interface IUsersRepository { + getUser(id: string): Promise; + getUserByUsername(username: string): Promise; + createUser(input: User): Promise; +} +``` + +- [ ] **Step 2: Create `packages/auth/src/application/services/authentication-service.interface.ts`** + +```typescript +import type { Cookie } from "../../entities/cookie"; +import type { Session } from "../../entities/session"; +import type { User } from "../../entities/user"; + +export interface IAuthenticationService { + generateUserId(): string; + hashPassword(password: string): Promise; + verifyPassword(hash: string, password: string): Promise; + validateSession( + sessionId: string, + ): Promise<{ user: User; session: Session }>; + createSession(user: User): Promise<{ session: Session; cookie: Cookie }>; + invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>; +} +``` + +- [ ] **Step 3: Verify compiles** + +Run: `cd packages/auth && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/auth/src/application +git commit -m "feat(auth): add IUsersRepository + IAuthenticationService interfaces" +``` + +--- + +### Task 3.4: Auth use-cases (sign-in, sign-up, sign-out) + tests + +**Files:** 3 use-case files + 3 test files. Tests will be RED until DI container lands in Task 3.7. + +- [ ] **Step 1: Create test `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { AuthenticationError } from "@/entities/errors"; +import { signInUseCase } from "./sign-in.use-case"; + +describe("signInUseCase", () => { + let usersRepo: MockUsersRepository; + let authService: MockAuthenticationService; + + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + usersRepo = new MockUsersRepository(); + authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("returns a session + cookie on valid credentials", async () => { + const result = await signInUseCase({ + username: "alice", + password: "password_alice", + }); + expect(result.session.userId).toBe("1"); + expect(result.cookie.name).toBe("session"); + }); + + it("throws AuthenticationError when user does not exist", async () => { + await expect( + signInUseCase({ username: "ghost", password: "anything" }), + ).rejects.toBeInstanceOf(AuthenticationError); + }); + + it("throws AuthenticationError on wrong password", async () => { + await expect( + signInUseCase({ username: "alice", password: "wrong" }), + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); +``` + +- [ ] **Step 2: Implement `packages/auth/src/application/use-cases/sign-in.use-case.ts`** + +```typescript +import { AuthenticationError } from "../../entities/errors"; +import type { Cookie } from "../../entities/cookie"; +import type { Session } from "../../entities/session"; +import { authContainer } from "../../di/container"; +import { AUTH_SYMBOLS } from "../../di/symbols"; +import type { IUsersRepository } from "../repositories/users-repository.interface"; +import type { IAuthenticationService } from "../services/authentication-service.interface"; + +export async function signInUseCase(input: { + username: string; + password: string; +}): Promise<{ session: Session; cookie: Cookie }> { + const usersRepository = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const authService = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + + const existingUser = await usersRepository.getUserByUsername(input.username); + if (!existingUser) { + throw new AuthenticationError("User does not exist"); + } + + const validPassword = await authService.verifyPassword( + existingUser.passwordHash, + input.password, + ); + if (!validPassword) { + throw new AuthenticationError("Incorrect username or password"); + } + + return await authService.createSession(existingUser); +} +``` + +- [ ] **Step 3: Commit (test RED until 3.7)** + +```bash +git add packages/auth/src/application/use-cases/sign-in.use-case.ts packages/auth/src/application/use-cases/sign-in.use-case.test.ts +git commit -m "feat(auth): add signInUseCase (test red until DI lands)" +``` + +- [ ] **Step 4: Create test `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { AuthenticationError } from "@/entities/errors"; +import { signUpUseCase } from "./sign-up.use-case"; + +describe("signUpUseCase", () => { + let usersRepo: MockUsersRepository; + let authService: MockAuthenticationService; + + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + usersRepo = new MockUsersRepository(); + authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("creates a new user and returns session + cookie + user", async () => { + const result = await signUpUseCase({ + username: "carol", + password: "secret_password", + }); + expect(result.user.username).toBe("carol"); + expect(result.session.userId).toBe(result.user.id); + expect(result.cookie.name).toBe("session"); + }); + + it("throws AuthenticationError when username taken", async () => { + await expect( + signUpUseCase({ username: "alice", password: "secret_password" }), + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); +``` + +- [ ] **Step 5: Implement `packages/auth/src/application/use-cases/sign-up.use-case.ts`** + +```typescript +import { AuthenticationError } from "../../entities/errors"; +import type { Cookie } from "../../entities/cookie"; +import type { Session } from "../../entities/session"; +import type { User } from "../../entities/user"; +import { authContainer } from "../../di/container"; +import { AUTH_SYMBOLS } from "../../di/symbols"; +import type { IUsersRepository } from "../repositories/users-repository.interface"; +import type { IAuthenticationService } from "../services/authentication-service.interface"; + +export async function signUpUseCase(input: { + username: string; + password: string; +}): Promise<{ + session: Session; + cookie: Cookie; + user: Pick; +}> { + const usersRepository = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const authService = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + + const existingUser = await usersRepository.getUserByUsername(input.username); + if (existingUser) { + throw new AuthenticationError("Username taken"); + } + + const passwordHash = await authService.hashPassword(input.password); + const userId = authService.generateUserId(); + + const newUser = await usersRepository.createUser({ + id: userId, + username: input.username, + passwordHash, + }); + + const { cookie, session } = await authService.createSession(newUser); + + return { + cookie, + session, + user: { id: newUser.id, username: newUser.username }, + }; +} +``` + +- [ ] **Step 6: Commit (test RED)** + +```bash +git add packages/auth/src/application/use-cases/sign-up.use-case.ts packages/auth/src/application/use-cases/sign-up.use-case.test.ts +git commit -m "feat(auth): add signUpUseCase (test red until DI lands)" +``` + +- [ ] **Step 7: Create test `packages/auth/src/application/use-cases/sign-out.use-case.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { signOutUseCase } from "./sign-out.use-case"; + +describe("signOutUseCase", () => { + let usersRepo: MockUsersRepository; + let authService: MockAuthenticationService; + + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + usersRepo = new MockUsersRepository(); + authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("returns a blank cookie", async () => { + const result = await signOutUseCase("session_1"); + expect(result.blankCookie.name).toBe("session"); + expect(result.blankCookie.value).toBe(""); + }); +}); +``` + +- [ ] **Step 8: Implement `packages/auth/src/application/use-cases/sign-out.use-case.ts`** + +```typescript +import type { Cookie } from "../../entities/cookie"; +import { authContainer } from "../../di/container"; +import { AUTH_SYMBOLS } from "../../di/symbols"; +import type { IAuthenticationService } from "../services/authentication-service.interface"; + +export async function signOutUseCase( + sessionId: string, +): Promise<{ blankCookie: Cookie }> { + const authService = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + return await authService.invalidateSession(sessionId); +} +``` + +- [ ] **Step 9: Commit** + +```bash +git add packages/auth/src/application/use-cases/sign-out.use-case.ts packages/auth/src/application/use-cases/sign-out.use-case.test.ts +git commit -m "feat(auth): add signOutUseCase (test red until DI lands)" +``` + +--- + +### Task 3.5: Mock users repository + +- [ ] **Step 1: Create `packages/auth/src/infrastructure/repositories/mock-users.repository.ts`** + +```typescript +import "reflect-metadata"; +import { injectable } from "inversify"; + +import type { IUsersRepository } from "../../application/repositories/users-repository.interface"; +import type { User } from "../../entities/user"; + +@injectable() +export class MockUsersRepository implements IUsersRepository { + private _users: User[] = [ + { id: "1", username: "alice", passwordHash: "hashed_password_alice" }, + { id: "2", username: "bob", passwordHash: "hashed_password_bob" }, + ]; + + async getUser(id: string): Promise { + return this._users.find((u) => u.id === id); + } + + async getUserByUsername(username: string): Promise { + return this._users.find((u) => u.username === username); + } + + async createUser(input: User): Promise { + this._users.push(input); + return input; + } +} +``` + +- [ ] **Step 2: Verify compiles** + +Run: `cd packages/auth && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth/src/infrastructure/repositories +git commit -m "feat(auth): add MockUsersRepository with seed users" +``` + +--- + +### Task 3.6: Mock authentication service + +- [ ] **Step 1: Create `packages/auth/src/infrastructure/services/mock-authentication.service.ts`** + +```typescript +import "reflect-metadata"; +import { inject, injectable } from "inversify"; + +import type { IAuthenticationService } from "../../application/services/authentication-service.interface"; +import type { IUsersRepository } from "../../application/repositories/users-repository.interface"; +import { UnauthenticatedError } from "../../entities/errors"; +import { sessionSchema, type Session } from "../../entities/session"; +import type { Cookie } from "../../entities/cookie"; +import type { User } from "../../entities/user"; +import { AUTH_SYMBOLS } from "../../di/symbols"; +import { SESSION_COOKIE } from "../../config"; + +@injectable() +export class MockAuthenticationService implements IAuthenticationService { + private _sessions: Record = {}; + + constructor( + @inject(AUTH_SYMBOLS.IUsersRepository) + private _usersRepository: IUsersRepository, + ) {} + + generateUserId(): string { + return (Math.random() + 1).toString(36).substring(7); + } + + async hashPassword(password: string): Promise { + return `hashed_${password}`; + } + + async verifyPassword(hash: string, password: string): Promise { + return hash === `hashed_${password}`; + } + + async validateSession( + sessionId: string, + ): Promise<{ user: User; session: Session }> { + const result = this._sessions[sessionId]; + if (!result) { + throw new UnauthenticatedError("Unauthenticated"); + } + const user = await this._usersRepository.getUser(result.user.id); + if (!user) { + throw new UnauthenticatedError("Unauthenticated"); + } + return { user, session: result.session }; + } + + async createSession( + user: User, + ): Promise<{ session: Session; cookie: Cookie }> { + const session = sessionSchema.parse({ + id: "session_" + user.id, + userId: user.id, + expiresAt: new Date(Date.now() + 86400000 * 7), + }); + const cookie: Cookie = { + name: SESSION_COOKIE, + value: session.id, + attributes: {}, + }; + this._sessions[session.id] = { session, user }; + return { session, cookie }; + } + + async invalidateSession( + sessionId: string, + ): Promise<{ blankCookie: Cookie }> { + delete this._sessions[sessionId]; + return { + blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} }, + }; + } +} +``` + +- [ ] **Step 2: Verify compiles** + +Run: `cd packages/auth && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth/src/infrastructure/services +git commit -m "feat(auth): add MockAuthenticationService with constructor-injected users repo" +``` + +--- + +### Task 3.7: Per-feature DI container (symbols, module, container) + test + +- [ ] **Step 1: Write `packages/auth/src/di/container.test.ts`** + +```typescript +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "./container"; +import { AUTH_SYMBOLS } from "./symbols"; +import { AuthModule } from "./module"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; + +describe("authContainer", () => { + beforeEach(() => { + authContainer.unbindAll(); + authContainer.load(AuthModule); + }); + + afterEach(() => { + authContainer.unbindAll(); + }); + + it("resolves IUsersRepository to MockUsersRepository by default", () => { + const repo = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + expect(repo).toBeInstanceOf(MockUsersRepository); + }); + + it("resolves IAuthenticationService to MockAuthenticationService by default", () => { + const service = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + expect(service).toBeInstanceOf(MockAuthenticationService); + }); + + it("authentication service receives users repository via constructor injection", async () => { + const service = authContainer.get( + AUTH_SYMBOLS.IAuthenticationService, + ); + // The service should be able to validate against the seeded users + const { session, cookie } = await service.createSession({ + id: "1", + username: "alice", + passwordHash: "hashed_password_alice", + }); + expect(session.userId).toBe("1"); + expect(cookie.value).toBe(session.id); + + // After session creation, validateSession should resolve user via the repo + const validated = await service.validateSession(session.id); + expect(validated.user.username).toBe("alice"); + }); +}); +``` + +- [ ] **Step 2: Implement `packages/auth/src/di/symbols.ts`** + +```typescript +export const AUTH_SYMBOLS = { + IUsersRepository: Symbol.for("auth:IUsersRepository"), + IAuthenticationService: Symbol.for("auth:IAuthenticationService"), +} as const; +``` + +- [ ] **Step 3: Implement `packages/auth/src/di/module.ts`** + +```typescript +import { ContainerModule, type interfaces } from "inversify"; + +import type { IUsersRepository } from "../application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "../application/services/authentication-service.interface"; +import { MockUsersRepository } from "../infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "../infrastructure/services/mock-authentication.service"; +import { AUTH_SYMBOLS } from "./symbols"; + +export const AuthModule = new ContainerModule((bind: interfaces.Bind) => { + bind(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository); + bind(AUTH_SYMBOLS.IAuthenticationService).to( + MockAuthenticationService, + ); +}); +``` + +- [ ] **Step 4: Implement `packages/auth/src/di/container.ts`** + +```typescript +import "reflect-metadata"; +import { Container } from "inversify"; +import { AuthModule } from "./module"; + +export const authContainer = new Container({ defaultScope: "Singleton" }); +authContainer.load(AuthModule); +``` + +- [ ] **Step 5: Run container test — expect 3 PASS** + +Run: `cd packages/auth && pnpm vitest run src/di/container.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 6: Run previously-RED use-case tests — expect 6 PASS** + +Run: `cd packages/auth && pnpm vitest run src/application/use-cases` +Expected: PASS — 6 tests (sign-in 3 + sign-up 2 + sign-out 1). + +- [ ] **Step 7: Commit** + +```bash +git add packages/auth/src/di +git commit -m "feat(auth): add per-feature InversifyJS container with constructor-injected service" +``` + +--- + +### Task 3.8: Auth controllers + tests + +- [ ] **Step 1: Write test `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { InputParseError } from "@/entities/errors"; +import { signInController } from "./sign-in.controller"; + +describe("signInController", () => { + let usersRepo: MockUsersRepository; + let authService: MockAuthenticationService; + + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + usersRepo = new MockUsersRepository(); + authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("returns a cookie on valid credentials", async () => { + const cookie = await signInController({ + username: "alice", + password: "password_alice", + }); + expect(cookie.name).toBe("session"); + }); + + it("throws InputParseError on missing username", async () => { + await expect( + signInController({ password: "anything" }), + ).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError on too-short password", async () => { + await expect( + signInController({ username: "alice", password: "abc" }), + ).rejects.toBeInstanceOf(InputParseError); + }); +}); +``` + +- [ ] **Step 2: Implement `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`** + +```typescript +import { z } from "zod"; + +import { InputParseError } from "../../entities/errors"; +import type { Cookie } from "../../entities/cookie"; +import { signInUseCase } from "../../application/use-cases/sign-in.use-case"; + +const inputSchema = z.object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), +}); + +export async function signInController( + input: Partial>, +): Promise { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); + } + const { cookie } = await signInUseCase(parsed.data); + return cookie; +} +``` + +- [ ] **Step 3: Run — expect 3 PASS** + +Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-in.controller.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 4: Write test `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { InputParseError } from "@/entities/errors"; +import { signUpController } from "./sign-up.controller"; + +describe("signUpController", () => { + let usersRepo: MockUsersRepository; + let authService: MockAuthenticationService; + + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + usersRepo = new MockUsersRepository(); + authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("creates a new user when passwords match", async () => { + const result = await signUpController({ + username: "carol", + password: "secret_password", + confirmPassword: "secret_password", + }); + expect(result.user.username).toBe("carol"); + }); + + it("throws InputParseError when passwords do not match", async () => { + await expect( + signUpController({ + username: "dave", + password: "secret_password", + confirmPassword: "different_password", + }), + ).rejects.toBeInstanceOf(InputParseError); + }); +}); +``` + +- [ ] **Step 5: Implement `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts`** + +```typescript +import { z } from "zod"; + +import { InputParseError } from "../../entities/errors"; +import { signUpUseCase } from "../../application/use-cases/sign-up.use-case"; + +const inputSchema = z + .object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + confirmPassword: z.string().min(6).max(255), + }) + .superRefine(({ password, confirmPassword }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["password"], + }); + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["confirmPassword"], + }); + } + }); + +export async function signUpController( + input: Partial>, +): Promise> { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid sign-up input", { cause: parsed.error }); + } + return await signUpUseCase(parsed.data); +} +``` + +- [ ] **Step 6: Run — expect 2 PASS** + +Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-up.controller.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 7: Write test `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { InputParseError } from "@/entities/errors"; +import { signOutController } from "./sign-out.controller"; + +describe("signOutController", () => { + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + const usersRepo = new MockUsersRepository(); + const authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("returns a blank cookie", async () => { + const cookie = await signOutController("session_anything"); + expect(cookie.name).toBe("session"); + expect(cookie.value).toBe(""); + }); + + it("throws InputParseError when sessionId is missing", async () => { + await expect(signOutController(undefined)).rejects.toBeInstanceOf( + InputParseError, + ); + }); +}); +``` + +- [ ] **Step 8: Implement `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts`** + +```typescript +import { InputParseError } from "../../entities/errors"; +import type { Cookie } from "../../entities/cookie"; +import { signOutUseCase } from "../../application/use-cases/sign-out.use-case"; + +export async function signOutController( + sessionId: string | undefined, +): Promise { + if (!sessionId) { + throw new InputParseError("Must provide a session ID"); + } + const { blankCookie } = await signOutUseCase(sessionId); + return blankCookie; +} +``` + +- [ ] **Step 9: Run — expect 2 PASS** + +Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-out.controller.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 10: Single commit for all 3 controllers** + +```bash +git add packages/auth/src/interface-adapters +git commit -m "feat(auth): add sign-in/sign-up/sign-out controllers with Zod validation" +``` + +--- + +### Task 3.9: Auth CMS integration (Users collection) + +- [ ] **Step 1: Create `packages/auth/src/integrations/cms/collections/users.ts`** + +```typescript +import type { CollectionConfig } from "payload"; + +export const users: CollectionConfig = { + slug: "users", + auth: true, + admin: { + useAsTitle: "email", + }, + fields: [ + { + name: "displayName", + type: "text", + }, + { + name: "role", + type: "select", + options: [ + { label: "Admin", value: "admin" }, + { label: "Editor", value: "editor" }, + { label: "Author", value: "author" }, + ], + defaultValue: "author", + required: true, + }, + ], +}; +``` + +> Note: This is the Payload-managed admin auth collection. It is intentionally separate from the domain `User` entity (id/username/passwordHash) used by the auth feature's use-cases. Reconciling the two is future work; for now they coexist. + +- [ ] **Step 2: Create `packages/auth/src/integrations/cms/index.ts`** + +```typescript +export { users } from "./collections/users"; +``` + +- [ ] **Step 3: Verify compiles** + +Run: `cd packages/auth && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/auth/src/integrations/cms +git commit -m "feat(auth): add users collection with role + displayName fields" +``` + +--- + +### Task 3.10: Auth API integration (tRPC router) + tests + +- [ ] **Step 1: Write test `packages/auth/src/integrations/api/router.test.ts`** + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "@/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication-service.interface"; +import { authRouter } from "./router"; + +describe("authRouter", () => { + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + const usersRepo = new MockUsersRepository(); + const authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("exposes signIn, signUp, signOut procedures", () => { + const names = Object.keys(authRouter._def.procedures); + expect(names).toContain("signIn"); + expect(names).toContain("signUp"); + expect(names).toContain("signOut"); + }); + + it("signIn returns a cookie", async () => { + const caller = authRouter.createCaller({}); + const result = await caller.signIn({ + username: "alice", + password: "password_alice", + }); + expect(result.name).toBe("session"); + }); +}); +``` + +- [ ] **Step 2: Implement `packages/auth/src/integrations/api/router.ts`** + +```typescript +import { z } from "zod"; +import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { signInController } from "../../interface-adapters/controllers/sign-in.controller"; +import { signUpController } from "../../interface-adapters/controllers/sign-up.controller"; +import { signOutController } from "../../interface-adapters/controllers/sign-out.controller"; + +export const authRouter = router({ + signIn: publicProcedure + .input( + z.object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + }), + ) + .mutation(({ input }) => signInController(input)), + + signUp: publicProcedure + .input( + z.object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + confirmPassword: z.string().min(6).max(255), + }), + ) + .mutation(({ input }) => signUpController(input)), + + signOut: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(({ input }) => signOutController(input.sessionId)), +}); + +export type AuthRouter = typeof authRouter; +``` + +- [ ] **Step 3: Run — expect 2 PASS** + +Run: `cd packages/auth && pnpm vitest run src/integrations/api/router.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 4: Commit** + +```bash +git add packages/auth/src/integrations/api +git commit -m "feat(auth): add tRPC router with signIn + signUp + signOut" +``` + +--- + +### Task 3.11: ui/query.ts (placeholder for Plan 5 consumers) + barrel + +- [ ] **Step 1: Create `packages/auth/src/ui/query.ts`** + +```typescript +// React Query option builders for auth feature procedures. +// Sign-in/up/out are mutations — no query options needed. +// This file is intentionally minimal; expand if read procedures get added. + +export {}; +``` + +- [ ] **Step 2: Replace `packages/auth/src/index.ts` with public surface** + +```typescript +export type { User } from "./entities/user"; +export type { Session } from "./entities/session"; +export type { Cookie } from "./entities/cookie"; +export { + AuthenticationError, + UnauthenticatedError, + UnauthorizedError, + InputParseError, +} from "./entities/errors"; +export { SESSION_COOKIE } from "./config"; +``` + +- [ ] **Step 3: Verify compiles** + +Run: `cd packages/auth && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/auth/src/ui packages/auth/src/index.ts +git commit -m "feat(auth): wire root barrel + ui/query stub" +``` + +--- + +### Task 3.12: Auth feature-level test + +- [ ] **Step 1: Create `packages/auth/tests/sign-in-flow.feature.test.ts`** + +```typescript +// Feature-level test: sign-up, then sign-in with the new credentials, then sign-out. + +import { beforeEach, describe, expect, it } from "vitest"; +import { authContainer } from "../src/di/container"; +import { AUTH_SYMBOLS } from "../src/di/symbols"; +import { MockUsersRepository } from "../src/infrastructure/repositories/mock-users.repository"; +import { MockAuthenticationService } from "../src/infrastructure/services/mock-authentication.service"; +import type { IUsersRepository } from "../src/application/repositories/users-repository.interface"; +import type { IAuthenticationService } from "../src/application/services/authentication-service.interface"; +import { authRouter } from "../src/integrations/api/router"; + +describe("auth feature: sign-up → sign-in → sign-out", () => { + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + const usersRepo = new MockUsersRepository(); + const authService = new MockAuthenticationService(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(usersRepo); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(authService); + }); + + it("a new user can sign up, then sign in, then sign out", async () => { + const caller = authRouter.createCaller({}); + + const signUpResult = await caller.signUp({ + username: "newperson", + password: "verysecret", + confirmPassword: "verysecret", + }); + expect(signUpResult.user.username).toBe("newperson"); + const userId = signUpResult.user.id; + + const signInCookie = await caller.signIn({ + username: "newperson", + password: "verysecret", + }); + expect(signInCookie.value).toBe("session_" + userId); + + const signOutResult = await caller.signOut({ + sessionId: signInCookie.value, + }); + expect(signOutResult.value).toBe(""); + }); +}); +``` + +- [ ] **Step 2: Run** + +Run: `cd packages/auth && pnpm vitest run tests/` +Expected: PASS — 1 test. + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth/tests +git commit -m "test(auth): add feature test for sign-up → sign-in → sign-out flow" +``` + +--- + +## Phase B: Media feature (skeleton-only) + +### Task 3.13: Scaffold @repo/media package + +- [ ] **Step 1: Create `packages/media/package.json`** + +```json +{ + "name": "@repo/media", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./cms": "./src/integrations/cms/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "payload": "^3.14.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "vitest": "^3.1.0" + } +} +``` + +> Note: NO `./api` export — media has no tRPC procedures yet (no use-cases). Add when needed. + +- [ ] **Step 2: Create `packages/media/tsconfig.json`** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create `packages/media/turbo.json`** + +```json +{ + "extends": ["//"], + "tags": ["feature"] +} +``` + +- [ ] **Step 4: Create `packages/media/src/integrations/cms/collections/media.ts`** + +```typescript +import type { CollectionConfig } from "payload"; + +export const media: CollectionConfig = { + slug: "media", + upload: { + mimeTypes: ["image/*", "application/pdf"], + }, + admin: { + useAsTitle: "filename", + }, + fields: [ + { + name: "alt", + type: "text", + required: true, + }, + ], +}; +``` + +- [ ] **Step 5: Create `packages/media/src/integrations/cms/index.ts`** + +```typescript +export { media } from "./collections/media"; +``` + +- [ ] **Step 6: Create `packages/media/src/index.ts`** + +```typescript +export {}; +``` + +- [ ] **Step 7: Add path aliases to `tsconfig.base.json`** + +Add to `compilerOptions.paths`: + +```json +"@repo/media": ["packages/media/src/index.ts"], +"@repo/media/cms": ["packages/media/src/integrations/cms/index.ts"] +``` + +- [ ] **Step 8: Install + verify** + +Run: `pnpm install` then `pnpm typecheck --filter @repo/media` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add packages/media tsconfig.base.json pnpm-lock.yaml +git commit -m "feat(media): scaffold feature package with media collection only" +``` + +--- + +## Phase C: Compose into core-cms + core-api, restore blog cross-feature relations + +### Task 3.14: Wire users + media into core-cms + +- [ ] **Step 1: Add `@repo/auth` and `@repo/media` to `packages/core-cms/package.json`** + +Edit `dependencies` block — add both lines: + +```json +"@repo/auth": "workspace:*", +"@repo/media": "workspace:*", +``` + +- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`** + +```typescript +import { buildConfig } from "payload"; +import { postgresAdapter } from "@payloadcms/db-postgres"; +import { lexicalEditor } from "@payloadcms/richtext-lexical"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { users } from "@repo/auth/cms"; +import { articles } from "@repo/blog/cms"; +import { media } from "@repo/media/cms"; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default buildConfig({ + editor: lexicalEditor(), + collections: [users, articles, media], + globals: [], + secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", + db: postgresAdapter({ + pool: { + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/template", + }, + }), + typescript: { + outputFile: path.resolve(dirname, "generated-types.ts"), + }, +}); +``` + +- [ ] **Step 3: Install + typecheck** + +Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-cms pnpm-lock.yaml +git commit -m "feat(core-cms): compose users + media into payload config alongside articles" +``` + +--- + +### Task 3.15: Restore blog's author + featuredImage relations + +- [ ] **Step 1: Replace `packages/blog/src/integrations/cms/collections/articles.ts`** + +```typescript +import type { CollectionConfig } from "payload"; +import { slugifyIfMissing } from "@repo/core-shared/payload"; + +export const articles: CollectionConfig = { + slug: "articles", + admin: { + useAsTitle: "title", + defaultColumns: ["title", "status", "author", "updatedAt"], + }, + hooks: { + beforeChange: [slugifyIfMissing], + }, + versions: { + drafts: true, + }, + fields: [ + { + name: "title", + type: "text", + required: true, + maxLength: 255, + }, + { + name: "slug", + type: "text", + unique: true, + admin: { + position: "sidebar", + description: "Auto-generated from title if left empty", + }, + }, + { + name: "content", + type: "richText", + }, + { + name: "status", + type: "select", + options: [ + { label: "Draft", value: "draft" }, + { label: "Published", value: "published" }, + ], + defaultValue: "draft", + required: true, + admin: { + position: "sidebar", + }, + }, + { + name: "author", + type: "relationship", + relationTo: "users", + required: true, + admin: { + position: "sidebar", + }, + }, + { + name: "featuredImage", + type: "upload", + relationTo: "media", + }, + { + name: "publishedAt", + type: "date", + admin: { + position: "sidebar", + date: { + pickerAppearance: "dayAndTime", + }, + }, + }, + ], +}; +``` + +> Note: `relationTo: "users"` and `relationTo: "media"` are slug references — they don't require importing `@repo/auth` or `@repo/media` from blog. Payload resolves them at runtime by looking up the collection slug in the assembled config. + +- [ ] **Step 2: Verify blog still compiles + tests pass** + +Run: `cd packages/blog && pnpm typecheck && pnpm test` +Expected: PASS — 26 tests. + +- [ ] **Step 3: Commit** + +```bash +git add packages/blog/src/integrations/cms/collections/articles.ts +git commit -m "feat(blog): restore author relationship → users + featuredImage upload → media" +``` + +--- + +### Task 3.16: Wire authRouter into core-api + +- [ ] **Step 1: Add `@repo/auth` to `packages/core-api/package.json`** + +Add inside `dependencies`: + +```json +"@repo/auth": "workspace:*", +``` + +- [ ] **Step 2: Replace `packages/core-api/src/root.ts`** + +```typescript +import { router } from "@repo/core-shared/trpc/init"; +import { authRouter } from "@repo/auth/api"; +import { blogRouter } from "@repo/blog/api"; + +export const appRouter = router({ + auth: authRouter, + blog: blogRouter, +}); + +export type AppRouter = typeof appRouter; +``` + +- [ ] **Step 3: Install + typecheck** + +Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/auth` +Expected: BOTH PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-api pnpm-lock.yaml +git commit -m "feat(core-api): compose @repo/auth/api into appRouter under 'auth' namespace" +``` + +--- + +### Task 3.17: Final verification + apps/cms boot smoke + +- [ ] **Step 1: Run all tests** + +Run: `pnpm test --filter @repo/auth --filter @repo/blog --filter @repo/core-shared` +Expected: PASS. Auth: ~14 tests (entities 5 + DI container 3 + use-cases 6 + controllers 7 + router 2 + feature 1 = 24 — check actual count). Blog: 26. Core-shared: 26. + +- [ ] **Step 2: Repo-wide typecheck** + +Run: `pnpm typecheck` +Expected: PASS for blog, auth, media, core-shared, core-cms, core-api, core-trpc, core-ui, cms, cms-core. PRE-EXISTING failures remain in `@repo/api` and `@repo/ui`. + +- [ ] **Step 3: Boot apps/cms admin smoke test** + +Postgres: `docker ps | grep postgres` — should be running. + +Start cms in background: +```bash +pnpm dev --filter @repo/cms & +DEV_PID=$! +sleep 15 +``` + +Test endpoints: +```bash +curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin +curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles +curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/users +curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/media +``` +Expected: ALL return `200`. + +> Note: Payload may again offer schema push (drop article fields not in current config or add new user/media tables). Smoke test only cares about 200 responses; kill dev server after. + +Kill: `kill $DEV_PID 2>/dev/null; pkill -f "next.*3001" 2>/dev/null` + +- [ ] **Step 4: Regenerate types** + +Run: `cd apps/cms && pnpm generate:types` + +Verify both Articles + Users + Media types are present: +```bash +cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor +grep -c "Article\|User\|Media" packages/core-cms/src/generated-types.ts +``` +Expected: count >= 10 (multiple references each). + +- [ ] **Step 5: Commit regenerated types if changed** + +```bash +git add packages/core-cms/src/generated-types.ts +git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Articles, Users, Media" +``` + +--- + +## Plan 3 Done Criteria + +- [ ] All auth tests pass (~24 tests in `@repo/auth`) +- [ ] All blog tests pass (26) +- [ ] Media compiles (no tests yet — skeleton only) +- [ ] `apps/cms` admin serves all four endpoints (`/admin`, `/admin/collections/articles`, `/users`, `/media`) with 200 +- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Articles + Users + Media +- [ ] `core-cms/src/payload.config.ts` registers `users`, `articles`, `media` collections +- [ ] `core-api/src/root.ts` exposes both `auth.*` and `blog.*` namespaces +- [ ] Blog's `articles.ts` has `author: relationship → users` and `featuredImage: upload → media` +- [ ] No deletions yet (old `packages/core/src/entities/models/user.ts` etc. still exist — drained in Plan 6) + +**Next plan:** Plan 4 — Marketing-pages + Navigation features. Adds the `pages` collection (with `featuredImage` relation to media), the `header` global, and the `siteSettings` global. No new cross-feature dependencies; should be quicker than Plan 3 since the patterns are well-established.