Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-3-auth-media.md
Danijel Martinek 0972645ebb refactor: rename eslint-config + typescript-config to core-eslint + core-typescript
Aligns tooling packages with the core-* naming convention used by all
other foundation packages (core-shared, core-cms, core-api, core-trpc,
core-ui). Updates ~50 files: package.json names, devDependencies,
tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md
references, and the boundaries plugin patterns to match the new paths.

The tooling-specific patterns in boundaries/elements are now ordered BEFORE
the broader core-* pattern to ensure correct first-match-wins behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:37:07 +02:00

59 KiB

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
{
  "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
{
  "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
{
  "extends": ["//"],
  "tags": ["feature"]
}
  • Step 4: Create packages/auth/vitest.config.ts
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
export {};
  • Step 6: Add path aliases to tsconfig.base.json

Add three lines inside compilerOptions.paths:

"@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
git add packages/auth tsconfig.base.json pnpm-lock.yaml
git commit -m "feat(auth): scaffold empty package with feature tag + path aliases"

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
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
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
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
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
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
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
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
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
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
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
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
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
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)
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
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
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)
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
export { users } from "./collections/users";
  • Step 3: Verify compiles

Run: cd packages/auth && pnpm typecheck Expected: PASS.

  • Step 4: Commit
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
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
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
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
// 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
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
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
// 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
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
{
  "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
{
  "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
{
  "extends": ["//"],
  "tags": ["feature"]
}
  • Step 4: Create packages/media/src/integrations/cms/collections/media.ts
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
export { media } from "./collections/media";
  • Step 6: Create packages/media/src/index.ts
export {};
  • Step 7: Add path aliases to tsconfig.base.json

Add to compilerOptions.paths:

"@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
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:

"@repo/auth": "workspace:*",
"@repo/media": "workspace:*",
  • Step 2: Replace packages/core-cms/src/payload.config.ts
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
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
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
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:

"@repo/auth": "workspace:*",
  • Step 2: Replace packages/core-api/src/root.ts
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
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:

pnpm dev --filter @repo/cms &
DEV_PID=$!
sleep 15

Test endpoints:

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:

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