# 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.