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>
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):
- Vitest config: each new feature's
vitest.config.tsMUST includeresolve.alias: { "@": path.resolve(__dirname, "./src") }— vitest doesn't read tsconfig paths. - No
rootDirin tsconfig: removed in blog becausetests/**/*is outsidesrc/. Same for new features. - 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. - Payload-backed repos take
SanitizedConfigvia constructor, NOTimport config from '@repo/core-cms'. Avoids workspace cycle. Featurepackage.jsondoes NOT depend on@repo/core-cms. App boot (Plan 5) supplies the config when binding the prod repo. z.unknown()in input schemas should be.optional()— Zod treatsunknownas acceptingundefinedanyway; 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 viacore-shared/src/telemetry/(cross-cutting concern, not auth-specific). - Auth domain entity stays
User { id, username, passwordHash }— the existing model. The PayloadUserscollection (withemail,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. Theauthorrelationship 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
authorfield becomesrelationship → userswithrelationTo: "users"(the Payload collection slug). Blog package doesn't import auth — relationship is by slug, satisfied at Payload runtime when both collections are registered incore-cms.
File Structure
Create — new @repo/auth package:
packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts}packages/auth/src/index.tspackages/auth/src/config.ts(SESSION_COOKIE constant)packages/auth/src/entities/user.ts+user.test.tspackages/auth/src/entities/cookie.tspackages/auth/src/entities/session.ts+session.test.tspackages/auth/src/entities/errors.tspackages/auth/src/application/repositories/users-repository.interface.tspackages/auth/src/application/services/authentication-service.interface.tspackages/auth/src/application/use-cases/sign-in.use-case.ts+.test.tspackages/auth/src/application/use-cases/sign-up.use-case.ts+.test.tspackages/auth/src/application/use-cases/sign-out.use-case.ts+.test.tspackages/auth/src/infrastructure/repositories/mock-users.repository.tspackages/auth/src/infrastructure/services/mock-authentication.service.tspackages/auth/src/di/symbols.tspackages/auth/src/di/module.tspackages/auth/src/di/container.ts+container.test.tspackages/auth/src/interface-adapters/controllers/sign-in.controller.ts+.test.tspackages/auth/src/interface-adapters/controllers/sign-up.controller.ts+.test.tspackages/auth/src/interface-adapters/controllers/sign-out.controller.ts+.test.tspackages/auth/src/integrations/cms/collections/users.tspackages/auth/src/integrations/cms/index.tspackages/auth/src/integrations/api/router.ts+router.test.tspackages/auth/src/ui/query.tspackages/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.tspackages/media/src/integrations/cms/collections/media.tspackages/media/src/integrations/cms/index.ts
Modify:
packages/blog/src/integrations/cms/collections/articles.ts— restoreauthor: relationship → users, addfeaturedImage: upload → mediapackages/core-cms/src/payload.config.ts— registerusers,mediacollections; depend on@repo/authand@repo/mediapackages/core-cms/package.json— add@repo/auth,@repo/mediapackages/core-api/src/root.ts— addauth: authRouterto appRouterpackages/core-api/package.json— add@repo/authtsconfig.base.json— add@repo/auth*,@repo/media*aliasesapps/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-cmsdep (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"
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
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
Userentity (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.tswith 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
./apiexport — 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/authand@repo/mediatopackages/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"andrelationTo: "media"are slug references — they don't require importing@repo/author@repo/mediafrom 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/authtopackages/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/cmsadmin serves all four endpoints (/admin,/admin/collections/articles,/users,/media) with 200pnpm generate:typesproduces agenerated-types.tscontaining Articles + Users + Mediacore-cms/src/payload.config.tsregistersusers,articles,mediacollectionscore-api/src/root.tsexposes bothauth.*andblog.*namespaces- Blog's
articles.tshasauthor: relationship → usersandfeaturedImage: upload → media - No deletions yet (old
packages/core/src/entities/models/user.tsetc. 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.