# 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/core-eslint": "workspace:*",
    "@repo/core-typescript": "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/core-typescript/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/core-typescript/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<typeof userSchema>;
```

- [ ] **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<typeof sessionSchema>;
```

- [ ] **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<User | undefined>;
  getUserByUsername(username: string): Promise<User | undefined>;
  createUser(input: User): Promise<User>;
}
```

- [ ] **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<string>;
  verifyPassword(hash: string, password: string): Promise<boolean>;
  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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<IUsersRepository>(
    AUTH_SYMBOLS.IUsersRepository,
  );
  const authService = authContainer.get<IAuthenticationService>(
    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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<User, "id" | "username">;
}> {
  const usersRepository = authContainer.get<IUsersRepository>(
    AUTH_SYMBOLS.IUsersRepository,
  );
  const authService = authContainer.get<IAuthenticationService>(
    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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<IAuthenticationService>(
    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<User | undefined> {
    return this._users.find((u) => u.id === id);
  }

  async getUserByUsername(username: string): Promise<User | undefined> {
    return this._users.find((u) => u.username === username);
  }

  async createUser(input: User): Promise<User> {
    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<string, { session: Session; user: User }> = {};

  constructor(
    @inject(AUTH_SYMBOLS.IUsersRepository)
    private _usersRepository: IUsersRepository,
  ) {}

  generateUserId(): string {
    return (Math.random() + 1).toString(36).substring(7);
  }

  async hashPassword(password: string): Promise<string> {
    return `hashed_${password}`;
  }

  async verifyPassword(hash: string, password: string): Promise<boolean> {
    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<IUsersRepository>(
      AUTH_SYMBOLS.IUsersRepository,
    );
    expect(repo).toBeInstanceOf(MockUsersRepository);
  });

  it("resolves IAuthenticationService to MockAuthenticationService by default", () => {
    const service = authContainer.get<IAuthenticationService>(
      AUTH_SYMBOLS.IAuthenticationService,
    );
    expect(service).toBeInstanceOf(MockAuthenticationService);
  });

  it("authentication service receives users repository via constructor injection", async () => {
    const service = authContainer.get<IAuthenticationService>(
      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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository);
  bind<IAuthenticationService>(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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<z.infer<typeof inputSchema>>,
): Promise<Cookie> {
  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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<z.infer<typeof inputSchema>>,
): Promise<ReturnType<typeof signUpUseCase>> {
  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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<Cookie> {
  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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
      .toConstantValue(usersRepo);
    authContainer
      .bind<IAuthenticationService>(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/core-eslint": "workspace:*",
    "@repo/core-typescript": "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/core-typescript/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.
