refactor: strip Phase/Plan/R-number references from source comments

This commit is contained in:
2026-05-13 09:51:45 +02:00
parent 075b729266
commit 17ae157365
66 changed files with 980 additions and 647 deletions

View File

@@ -83,7 +83,7 @@ function resolveJobsDevSeed(): { queue: IJobQueue } {
/**
* Production path: swap each feature's mock repository binding for the real
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
* feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 1922).
* feature via `bindProductionX` exports.
*/
export async function bindAllProduction(): Promise<void> {
if (bound) return;
@@ -99,11 +99,11 @@ export async function bindAllProduction(): Promise<void> {
queue,
};
bindProductionAuth(ctx); // Phase E task 19
bindProductionBlog(ctx); // Phase E task 18
bindProductionMarketingPages(ctx); // Phase E task 20
bindProductionNavigation(ctx); // Phase E task 21
bindProductionMedia(ctx); // Phase E task 22
bindProductionAuth(ctx);
bindProductionBlog(ctx);
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
}
/**
@@ -123,11 +123,11 @@ export async function bindAllDevSeed(): Promise<void> {
queue,
};
await bindDevSeedAuth(ctx); // Phase E task 19
await bindDevSeedBlog(ctx); // Phase E task 18
await bindDevSeedMarketingPages(ctx); // Phase E task 20
await bindDevSeedNavigation(ctx); // Phase E task 21
await bindDevSeedMedia(ctx); // Phase E task 22
await bindDevSeedAuth(ctx);
await bindDevSeedBlog(ctx);
await bindDevSeedMarketingPages(ctx);
await bindDevSeedNavigation(ctx);
await bindDevSeedMedia(ctx);
}
/**

View File

@@ -3,89 +3,86 @@ import { defineContractSuite } from "@repo/core-testing/contract";
import type { IUsersRepository } from "../application/repositories/users.repository.interface.js";
import { userFactory } from "../__factories__/user.factory.js";
export const usersRepositoryContract =
defineContractSuite<IUsersRepository>(
"IUsersRepository",
({ buildSubject, getTracer }) => {
let repo: IUsersRepository;
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
"IUsersRepository",
({ buildSubject, getTracer }) => {
let repo: IUsersRepository;
beforeEach(async () => {
userFactory.reset();
repo = await buildSubject();
beforeEach(async () => {
userFactory.reset();
repo = await buildSubject();
});
// --- getUser ---
it("createUser then getUser returns it by id", async () => {
const seed = userFactory.build();
await repo.createUser(seed);
const result = await repo.getUser(seed.id);
expect(result?.id).toBe(seed.id);
expect(result?.username).toBe(seed.username);
});
it("getUser returns undefined for missing id", async () => {
expect(await repo.getUser("does-not-exist")).toBeUndefined();
});
// --- getUserByUsername ---
it("createUser then getUserByUsername returns it by username", async () => {
const seed = userFactory.build({ username: "alice" });
await repo.createUser(seed);
const result = await repo.getUserByUsername("alice");
expect(result?.id).toBe(seed.id);
expect(result?.username).toBe("alice");
});
it("getUserByUsername returns undefined for missing username", async () => {
expect(await repo.getUserByUsername("no-such-user")).toBeUndefined();
});
// --- createUser ---
it("createUser returns the created user", async () => {
const seed = userFactory.build({ username: "carol" });
const created = await repo.createUser(seed);
expect(created.id).toBe(seed.id);
expect(created.username).toBe("carol");
});
describe("span emission", () => {
it("getUser emits users.getUser span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getUser("nonexistent");
const span = tracer.findSpan("users.getUser");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
// --- getUser ---
it("getUserByUsername emits users.getUserByUsername span", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getUserByUsername("alice");
const span = tracer.findSpan("users.getUserByUsername");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
it("createUser then getUser returns it by id", async () => {
const seed = userFactory.build();
it("createUser emits users.createUser span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
const seed = userFactory.build({ username: "span-test-user" });
await repo.createUser(seed);
const result = await repo.getUser(seed.id);
expect(result?.id).toBe(seed.id);
expect(result?.username).toBe(seed.username);
const span = tracer.findSpan("users.createUser");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe(seed.id);
});
it("getUser returns undefined for missing id", async () => {
expect(await repo.getUser("does-not-exist")).toBeUndefined();
});
// --- getUserByUsername ---
it("createUser then getUserByUsername returns it by username", async () => {
const seed = userFactory.build({ username: "alice" });
await repo.createUser(seed);
const result = await repo.getUserByUsername("alice");
expect(result?.id).toBe(seed.id);
expect(result?.username).toBe("alice");
});
it("getUserByUsername returns undefined for missing username", async () => {
expect(
await repo.getUserByUsername("no-such-user"),
).toBeUndefined();
});
// --- createUser ---
it("createUser returns the created user", async () => {
const seed = userFactory.build({ username: "carol" });
const created = await repo.createUser(seed);
expect(created.id).toBe(seed.id);
expect(created.username).toBe("carol");
});
describe("span emission (R50)", () => {
it("getUser emits users.getUser span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getUser("nonexistent");
const span = tracer.findSpan("users.getUser");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
it("getUserByUsername emits users.getUserByUsername span", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getUserByUsername("alice");
const span = tracer.findSpan("users.getUserByUsername");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
it("createUser emits users.createUser span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
const seed = userFactory.build({ username: "span-test-user" });
await repo.createUser(seed);
const span = tracer.findSpan("users.createUser");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe(seed.id);
});
});
},
);
});
},
);

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { signInUseCase, signInOutputSchema } from "@/application/use-cases/sign-in.use-case";
import {
signInUseCase,
signInOutputSchema,
} from "@/application/use-cases/sign-in.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { AuthenticationError } from "@/entities/errors/auth";
@@ -18,7 +21,10 @@ describe("signInUseCase", () => {
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth);
const result = await useCase({ username: "alice", password: "testpassword" });
const result = await useCase({
username: "alice",
password: "testpassword",
});
expect(result.session.userId).toBe(seedUser.id);
expect(result.cookie.name).toBe("session");
@@ -38,7 +44,10 @@ describe("signInUseCase", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(
userFactory.build({ username: "alice", passwordHash: "hashed_correctpassword" }),
userFactory.build({
username: "alice",
passwordHash: "hashed_correctpassword",
}),
);
const useCase = signInUseCase(users, auth);
@@ -48,7 +57,7 @@ describe("signInUseCase", () => {
});
});
describe("signInUseCase output validation (R25)", () => {
describe("signInUseCase output validation", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
const seed = userFactory.build({ username: "alice" });
@@ -61,7 +70,9 @@ describe("signInUseCase output validation (R25)", () => {
} as unknown as IAuthenticationService;
const useCase = signInUseCase(users, auth);
await expect(useCase({ username: "alice", password: "x" })).rejects.toBeInstanceOf(ZodError);
await expect(
useCase({ username: "alice", password: "x" }),
).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that mirrors the success shape", () => {

View File

@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import { signUpUseCase, signUpOutputSchema } from "@/application/use-cases/sign-up.use-case";
import {
signUpUseCase,
signUpOutputSchema,
} from "@/application/use-cases/sign-up.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { AuthenticationError } from "@/entities/errors/auth";
@@ -33,7 +36,11 @@ describe("signUpUseCase", () => {
const useCase = signUpUseCase(users, auth, bus);
await expect(
useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
useCase({
username: "alice",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(AuthenticationError);
});
@@ -83,13 +90,17 @@ describe("signUpUseCase", () => {
const useCase = signUpUseCase(users, auth, bus);
await expect(
useCase({ username: "eve", password: "secret_password", confirmPassword: "secret_password" }),
useCase({
username: "eve",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(AuthenticationError);
expect(bus.published).toHaveLength(0);
});
});
describe("signUpUseCase output validation (R25)", () => {
describe("signUpUseCase output validation", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
const auth = {
@@ -103,7 +114,11 @@ describe("signUpUseCase output validation (R25)", () => {
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
await expect(
useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }),
useCase({
username: "carol",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(ZodError);
});

View File

@@ -39,12 +39,16 @@ export const signUpUseCase =
bus: EventBusProtocol | undefined,
) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
const existingUser = await usersRepository.getUserByUsername(input.username);
const existingUser = await usersRepository.getUserByUsername(
input.username,
);
if (existingUser) {
throw new AuthenticationError("Username taken");
}
const passwordHash = await authenticationService.hashPassword(input.password);
const passwordHash = await authenticationService.hashPassword(
input.password,
);
const userId = authenticationService.generateUserId();
const newUser = await usersRepository.createUser({
@@ -53,11 +57,12 @@ export const signUpUseCase =
passwordHash,
});
const { cookie, session } = await authenticationService.createSession(newUser);
const { cookie, session } =
await authenticationService.createSession(newUser);
// Auth is username-based — synthesize a deterministic email so the event
// payload validates against userSignedUpEventSchema.email().
// bus is optional: absent when core-events is not wired (Phase 3+).
// bus is optional: absent when core-events is not wired.
if (bus) {
await bus.publish(userSignedUpEvent, {
userId: newUser.id,

View File

@@ -44,8 +44,12 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
authContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
authContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
@@ -61,7 +65,9 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
.toConstantValue(repo);
// Need auth service from container for use cases
const authService = authContainer.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService);
const authService = authContainer.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
);
// Wrap use cases + controllers identically to bind-production
const wrappedSignIn = withSpan(
@@ -102,9 +108,15 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
]) {
if (authContainer.isBound(sym)) authContainer.unbind(sym);
}
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
authContainer
.bind(AUTH_SYMBOLS.ISignInUseCase)
.toConstantValue(wrappedSignIn);
authContainer
.bind(AUTH_SYMBOLS.ISignUpUseCase)
.toConstantValue(wrappedSignUp);
authContainer
.bind(AUTH_SYMBOLS.ISignOutUseCase)
.toConstantValue(wrappedSignOut);
authContainer
.bind(AUTH_SYMBOLS.ISignInController)
@@ -145,8 +157,7 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -33,7 +33,8 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
if (bound) return;
bound = true;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -42,15 +43,21 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
authContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
authContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repositories
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
const repo = new UsersRepository(config, tracer, logger);
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo);
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.toConstantValue(repo);
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
@@ -100,9 +107,15 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
]) {
if (authContainer.isBound(sym)) authContainer.unbind(sym);
}
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
authContainer
.bind(AUTH_SYMBOLS.ISignInUseCase)
.toConstantValue(wrappedSignIn);
authContainer
.bind(AUTH_SYMBOLS.ISignUpUseCase)
.toConstantValue(wrappedSignUp);
authContainer
.bind(AUTH_SYMBOLS.ISignOutUseCase)
.toConstantValue(wrappedSignOut);
// Controllers — wrapped with span at bind time
for (const sym of [
@@ -151,8 +164,7 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -10,7 +10,7 @@ export {
export { InputParseError } from "./entities/errors/common";
export { SESSION_COOKIE } from "./config";
// Use case schemas + types (Plan 9 R18)
// Use case schemas + types
export {
signInInputSchema,
signInOutputSchema,
@@ -44,7 +44,6 @@ export {
} from "./events/user-signed-up.event";
// <gen:realtime-channels>
// Feature conformance manifest (added in conformance milestone i, exposed
// here in milestone ii so the boot-time assertion and future tooling can
// read the contract from the package boundary).
// Feature conformance manifest — declares this feature's use cases, audits,
// publishes, and consumes. Read by the boot-time assertion + ESLint rules.
export { authManifest, type AuthManifest } from "./feature.manifest";

View File

@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
describe("MockUsersRepository emits spans (R42)", () => {
// Mock repo also wraps in spans; easier to assert without booting Payload.
describe("MockUsersRepository emits spans", () => {
it("getUser emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
@@ -26,13 +29,19 @@ describe("MockUsersRepository emits spans (R42)", () => {
);
await repo.getUserByUsername("alice");
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(true);
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(
true,
);
});
it("createUser records created=true", async () => {
const tracer = new RecordingTracer();
const repo = new MockUsersRepository([], tracer);
await repo.createUser({ id: "u1", username: "charlie", passwordHash: "hash" });
await repo.createUser({
id: "u1",
username: "charlie",
passwordHash: "hash",
});
expect(tracer.findSpan("users.createUser")).toBeDefined();
expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true);
});

View File

@@ -13,7 +13,7 @@ import type { User } from "../../entities/models/user";
// generic session interface without deep integration with Payload's REST/local
// API and cookie infrastructure.
//
// TODO(lazar-conformance §7): Implement these three methods once the session
// TODO: Implement these three methods once the session
// cookie strategy is settled. Until then they throw NotImplementedError to
// keep the production-shaped file in place without silently no-oping.
//
@@ -21,7 +21,7 @@ import type { User } from "../../entities/models/user";
class NotImplementedError extends Error {
constructor(method: string) {
super(`NotImplemented: AuthenticationService.${method} — see refactor log §7`);
super(`NotImplemented: AuthenticationService.${method}`);
this.name = "NotImplementedError";
}
}
@@ -42,10 +42,17 @@ export class AuthenticationService implements IAuthenticationService {
async hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
const hash = await new Promise<string>((resolve, reject) => {
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
});
crypto.pbkdf2(
password,
salt,
ITERATIONS,
KEY_LENGTH,
DIGEST,
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
},
);
});
return `${salt}${SEPARATOR}${hash}`;
}
@@ -56,10 +63,17 @@ export class AuthenticationService implements IAuthenticationService {
const salt = parts[0]!;
const expectedHash = parts[1]!;
const actualHash = await new Promise<string>((resolve, reject) => {
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
});
crypto.pbkdf2(
password,
salt,
ITERATIONS,
KEY_LENGTH,
DIGEST,
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
},
);
});
return crypto.timingSafeEqual(
Buffer.from(expectedHash, "hex"),
@@ -67,23 +81,27 @@ export class AuthenticationService implements IAuthenticationService {
);
}
// TODO(lazar-conformance §7): Implement using Payload's local.login / JWT session issuance.
// TODO:Implement using Payload's local.login / JWT session issuance.
// Payload creates sessions via its REST auth endpoint; mapping that to a
// generic { session: Session; cookie: Cookie } shape requires understanding
// the JWT payload structure and the cookie name/attributes Payload uses.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async createSession(user: User): Promise<{ session: Session; cookie: Cookie }> {
async createSession(
user: User,
): Promise<{ session: Session; cookie: Cookie }> {
throw new NotImplementedError("createSession");
}
// TODO(lazar-conformance §7): Implement using Payload's JWT verify mechanism.
// TODO:Implement using Payload's JWT verify mechanism.
// Need to call Payload's local API to verify the token and retrieve the user.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async validateSession(sessionId: string): Promise<{ user: User; session: Session }> {
async validateSession(
sessionId: string,
): Promise<{ user: User; session: Session }> {
throw new NotImplementedError("validateSession");
}
// TODO(lazar-conformance §7): Implement by clearing the session token.
// TODO:Implement by clearing the session token.
// Payload does not have a server-side session store by default; invalidation
// is typically done client-side by clearing the cookie.
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -29,7 +29,7 @@ describe("authRouter", () => {
});
});
describe("authRouter (R26 error mapping)", () => {
describe("authRouter error mapping", () => {
beforeEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
@@ -39,7 +39,9 @@ describe("authRouter (R26 error mapping)", () => {
}
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.toConstantValue(users);
authContainer
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
.toConstantValue(auth);

View File

@@ -76,9 +76,7 @@ export const articlesRepositoryContract =
it("getArticles filters by status", async () => {
await repo.createArticle(articleFactory.build({ status: "draft" }));
await repo.createArticle(
articleFactory.build({ status: "published" }),
);
await repo.createArticle(articleFactory.build({ status: "published" }));
const drafts = await repo.getArticles({ status: "draft" });
expect(drafts).toHaveLength(1);
expect(drafts[0]?.status).toBe("draft");
@@ -117,7 +115,7 @@ export const articlesRepositoryContract =
expect(result).toBeUndefined();
});
describe("span emission (R50)", () => {
describe("span emission", () => {
it("getArticles emits articles.getArticles span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import { createArticleUseCase, createArticleOutputSchema } from "@/application/use-cases/create-article.use-case";
import {
createArticleUseCase,
createArticleOutputSchema,
} from "@/application/use-cases/create-article.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
@@ -37,7 +40,7 @@ describe("createArticleUseCase", () => {
});
});
describe("createArticleUseCase output validation (R25)", () => {
describe("createArticleUseCase output validation", () => {
it("throws when repository returns a malformed article", async () => {
const repo = {
createArticle: async () => ({ id: 1 }) as unknown as never,

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { getArticleBySlugUseCase, getArticleBySlugOutputSchema } from "@/application/use-cases/get-article-by-slug.use-case";
import {
getArticleBySlugUseCase,
getArticleBySlugOutputSchema,
} from "@/application/use-cases/get-article-by-slug.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { ArticleNotFoundError } from "@/entities/errors/article";
import { articleFactory } from "@/__factories__/article.factory";
@@ -21,11 +24,13 @@ describe("getArticleBySlugUseCase", () => {
it("throws ArticleNotFoundError when slug is missing", async () => {
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError);
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(
ArticleNotFoundError,
);
});
});
describe("getArticleBySlugUseCase output validation (R25)", () => {
describe("getArticleBySlugUseCase output validation", () => {
it("throws when repository returns a malformed article", async () => {
const repo = {
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
import {
getArticlesUseCase,
getArticlesOutputSchema,
} from "@/application/use-cases/get-articles.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { articleFactory } from "@/__factories__/article.factory";
@@ -8,7 +11,9 @@ describe("getArticlesUseCase", () => {
it("returns all articles with no filters", async () => {
const repo = new MockArticlesRepository();
articleFactory.reset();
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
await repo.createArticle(
articleFactory.build({ id: "1", title: "A", slug: "a" }),
);
const useCase = getArticlesUseCase(repo);
const result = await useCase({});
@@ -19,8 +24,17 @@ describe("getArticlesUseCase", () => {
it("filters by status", async () => {
const repo = new MockArticlesRepository();
articleFactory.reset();
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }));
await repo.createArticle(articleFactory.build({ id: "2", title: "B", slug: "b", status: "published" }));
await repo.createArticle(
articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }),
);
await repo.createArticle(
articleFactory.build({
id: "2",
title: "B",
slug: "b",
status: "published",
}),
);
const useCase = getArticlesUseCase(repo);
const result = await useCase({ status: "published" });
@@ -29,7 +43,7 @@ describe("getArticlesUseCase", () => {
});
});
describe("getArticlesUseCase output validation (R25)", () => {
describe("getArticlesUseCase output validation", () => {
it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository();
// bypass the mock's createArticle (which is typed) by reaching into _articles directly

View File

@@ -40,8 +40,12 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
blogContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
blogContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
@@ -93,11 +97,15 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
]) {
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
}
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
blogContainer
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
.toConstantValue(wrappedGetArticles);
blogContainer
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
.toConstantValue(wrappedGetArticleBySlug);
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
blogContainer
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
.toConstantValue(wrappedCreateArticle);
blogContainer
.bind(BLOG_SYMBOLS.IGetArticlesController)
@@ -120,7 +128,11 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
{ name: "blog.getArticleBySlug", op: "controller" },
withCapture(
logger,
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
{
feature: "blog",
layer: "controller",
name: "blog.getArticleBySlug",
},
getArticleBySlugController(wrappedGetArticleBySlug),
),
),
@@ -138,8 +150,7 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -19,7 +19,8 @@ import { getArticleBySlugController } from "../interface-adapters/controllers/ge
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
export function bindProductionBlog(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -28,8 +29,12 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
blogContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
blogContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repository
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
@@ -38,7 +43,7 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
const repo = new ArticlesRepository(config, tracer, logger);
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
// Use cases — wrapped with span + capture at bind time (R41, R44)
// Use cases — wrapped with span + capture at bind time
const wrappedGetArticles = withSpan(
tracer,
{ name: "blog.getArticles", op: "use-case" },
@@ -76,11 +81,15 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleUseCase)) {
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleUseCase);
}
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
blogContainer
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
.toConstantValue(wrappedGetArticles);
blogContainer
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
.toConstantValue(wrappedGetArticleBySlug);
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
blogContainer
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
.toConstantValue(wrappedCreateArticle);
// Controllers — wrapped with span at bind time
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
@@ -113,7 +122,11 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
{ name: "blog.getArticleBySlug", op: "controller" },
withCapture(
logger,
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
{
feature: "blog",
layer: "controller",
name: "blog.getArticleBySlug",
},
getArticleBySlugController(wrappedGetArticleBySlug),
),
),
@@ -131,8 +144,7 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -3,7 +3,7 @@ export type { BlogRouter } from "./integrations/api/router";
export { ArticleNotFoundError } from "./entities/errors/article";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types (Plan 9 R18)
// Use case schemas + types
export {
getArticlesInputSchema,
getArticlesOutputSchema,

View File

@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
describe("MockArticlesRepository emits spans (R42)", () => {
// Mock repo also wraps in spans; easier to assert without booting Payload.
describe("MockArticlesRepository emits spans", () => {
it("getArticles emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
@@ -31,7 +34,9 @@ describe("MockArticlesRepository emits spans (R42)", () => {
updatedAt: new Date(),
});
expect(tracer.findSpan("articles.createArticle")).toBeDefined();
expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t");
expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe(
"t",
);
});
it("getArticle records found=false for missing id", async () => {

View File

@@ -45,7 +45,7 @@ describe("blogRouter", () => {
});
});
describe("blogRouter (R26 error mapping)", () => {
describe("blogRouter error mapping", () => {
beforeEach(() => {
blogContainer.unbindAll();
blogContainer.load(BlogModule);

View File

@@ -1,5 +1,5 @@
// React Query option builders for blog feature procedures.
// Consumed by apps via the @repo/core-trpc client (wired in Plan 5).
// Consumed by apps via the @repo/core-trpc client.
type TrpcClient = {
blog: {
@@ -23,7 +23,12 @@ export function articleBySlugQuery(client: TrpcClient, slug: string) {
export function listArticlesQuery(
client: TrpcClient,
options?: { status?: string; authorId?: string; limit?: number; offset?: number },
options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
},
) {
return client.blog.listArticles.queryOptions(options);
}

View File

@@ -1,4 +1,4 @@
// R44 — verify the full chain (controller → use case → repo) wraps with
// Verify the full chain (controller → use case → repo) wraps with
// withSpan + withCapture and never double-captures the same error.
//
// Each layer's withCapture catch checks the __sentryReported flag (set by
@@ -16,7 +16,7 @@ import { MockArticlesRepository } from "../src/infrastructure/repositories/artic
import { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
describe("R44 — no double-capture across span/capture-wrapped layers", () => {
describe("no double-capture across span/capture-wrapped layers", () => {
it("an error originated in the repo is captured exactly once with repo tags", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
@@ -30,7 +30,11 @@ describe("R44 — no double-capture across span/capture-wrapped layers", () => {
// Mirror what the real repo does: capture with repo tags, mark the
// flag (RecordingLogger.captureException does this for us now).
logger.captureException(err, {
tags: { feature: "blog", repo: "articles", method: "getArticleBySlug" },
tags: {
feature: "blog",
repo: "articles",
method: "getArticleBySlug",
},
});
throw err;
};
@@ -58,9 +62,11 @@ describe("R44 — no double-capture across span/capture-wrapped layers", () => {
),
);
await expect(wrappedCtrl({ slug: "anything" })).rejects.toThrow("boom from repo");
await expect(wrappedCtrl({ slug: "anything" })).rejects.toThrow(
"boom from repo",
);
// R44: exactly one capture. Outer wrappers saw the flag and skipped.
// Exactly one capture. Outer wrappers saw the flag and skipped.
expect(logger.captures).toHaveLength(1);
const only = logger.captures[0];
expect(only?.kind).toBe("exception");

View File

@@ -26,7 +26,7 @@ export type BindAuditOpts = {
* if not — better to refuse to start than to ship audit data with a dev-fallback
* salt that an attacker could reverse.
*
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog (Phase 4)
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
* so all sinks receive AuditEntry.correlationId auto-populated from the
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
*/
@@ -34,7 +34,10 @@ export function bindAudit(
container: Container,
opts: BindAuditOpts = {},
): { auditLog: IAuditLog } {
if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) {
if (
process.env.NODE_ENV === "production" &&
!process.env.AUDIT_PSEUDONYM_SALT
) {
throw new Error(
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
@@ -52,9 +55,11 @@ export function bindAudit(
}
const inner: IAuditLog =
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
: sinks.length === 1 ? sinks[0]!
: new NoopAuditLog();
sinks.length > 1
? new MultiSinkAuditLog(sinks)
: sinks.length === 1
? sinks[0]!
: new NoopAuditLog();
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {

View File

@@ -1,5 +1,9 @@
export type { IAuditLog } from "./audit-log.interface";
export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit";
export type {
AuditEntry,
AuditAction,
AuditFrom,
} from "@repo/core-shared/audit";
export { NoopAuditLog } from "./noop-audit-log";
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
export { PayloadAuditLog } from "./payload-audit-log";
@@ -8,17 +12,14 @@ export { auditLogsCollection } from "./audit-logs-collection";
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
export { AUDIT_SYMBOLS } from "./di/symbols";
// Phase 3 — GDPR erasure
// GDPR erasure
export { pseudonymize } from "./pseudonymize";
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./hooks/audit-erasure-hook";
// Phase 5 — VIEW capture
export {
createAuditAfterReadHook,
type AuditAfterReadHookOpts,
} from "./hooks";
// VIEW capture
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
export {
createAuditRouter,
auditRouter,

View File

@@ -21,7 +21,7 @@ export type Audited<F> = F & { readonly __audited: true };
* tests).
*/
export function withAudit<Args extends unknown[], R>(
// TODO(conformance milestone iii+): wire automated recording from manifest
// TODO: wire automated recording from manifest declarations.
// `audits[]` declarations. For now, the wrapper exists to:
// (1) require callers to pass the auditLog at bind time (dep is available)
// (2) attach the `__audited` brand so the boot-time assertion can verify

View File

@@ -29,7 +29,7 @@ export type AuditFrom = {
*/
export type AuditEntry = {
// WHO
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name. */
actorId: string;
actorType: "user" | "system" | "service";
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */

View File

@@ -5,7 +5,10 @@ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
import { buildResource } from "./resource";
import { createSentryOtelBridge } from "./sentry-bridge";
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
import {
PiiScrubSpanProcessor,
PiiScrubLogRecordProcessor,
} from "./pii-scrub-processor";
const { BatchSpanProcessor } = tracing;
@@ -47,7 +50,7 @@ export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
// `as never` works around a TypeScript version conflict: `core-shared`'s direct
// dep on `@opentelemetry/sdk-trace-base@1.30.1` has subtly incompatible types
// vs the 1.28.0 bundled by `sdk-node@0.55.0`. The runtime objects are compatible;
// the structural mismatch is type-only. Phase 1 implementer chose this rather than
// the structural mismatch is type-only chosen rather than
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
new BatchSpanProcessor(bridge.spanProcessor as never),
]

View File

@@ -1,6 +1,6 @@
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
// R32 — substring match on event keys (case-insensitive).
// Substring match on event keys (case-insensitive).
// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+)
// are listed here so they are key-redacted in addition to the value-level regex
// scrubbing in pii-scrub-processor.ts.
@@ -28,7 +28,7 @@ export const PII_KEY_SUBSTRINGS = [
"host.ip",
] as const;
// R33 — substring match on URL query-param keys (case-insensitive)
// Substring match on URL query-param keys (case-insensitive)
export const PII_QUERY_PARAM_SUBSTRINGS = [
"token",
"email",

View File

@@ -10,11 +10,17 @@ import {
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { SeverityNumber } from "@opentelemetry/api-logs";
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
import {
PiiScrubSpanProcessor,
PiiScrubLogRecordProcessor,
} from "./pii-scrub-processor";
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)],
spanProcessors: [
new PiiScrubSpanProcessor(),
new SimpleSpanProcessor(spanExporter),
],
});
// Use addLogRecordProcessor to chain processors in the right order.
@@ -43,7 +49,7 @@ describe("PiiScrubSpanProcessor", () => {
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]");
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine per R36
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
});
@@ -119,7 +125,7 @@ describe("PiiScrubLogRecordProcessor", () => {
expect(records[0]!.body).toBe("user signed in successfully");
});
it("scrubs IPv4 in log record body (C2 / R32)", () => {
it("scrubs IPv4 in log record body", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
@@ -132,7 +138,7 @@ describe("PiiScrubLogRecordProcessor", () => {
});
});
describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
describe("PiiScrubSpanProcessor — IP address scrubbing", () => {
it("scrubs IPv4 addresses in attribute values", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
@@ -140,7 +146,9 @@ describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.note"]).toBe("request from [redacted-ip]");
expect(exported[0]!.attributes["request.note"]).toBe(
"request from [redacted-ip]",
);
});
it("scrubs IPv6 addresses in attribute values", () => {

View File

@@ -3,14 +3,23 @@
// PII scrub processors for OTel spans and log records.
// These run FIRST in their respective processor chains so downstream exporters
// (including the Sentry exporter) see scrubbed data. This replaces the old
// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now
// Sentry beforeSend / beforeSendTransaction hooks — scrubbing now
// happens at the OTel layer, vendor-agnostic.
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
import type {
ReadableSpan,
SpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import type { Span } from "@opentelemetry/api";
import type { Context } from "@opentelemetry/api";
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
import { PII_KEY_SUBSTRINGS, REDACTED_VALUE, IPV4_REGEX, IPV6_REGEX, REDACTED_IP } from "./pii-fields";
import {
PII_KEY_SUBSTRINGS,
REDACTED_VALUE,
IPV4_REGEX,
IPV6_REGEX,
REDACTED_IP,
} from "./pii-fields";
function isPiiKey(key: string): boolean {
const lower = key.toLowerCase();
@@ -27,7 +36,7 @@ function containsPiiSubstring(s: string): boolean {
* Called for attribute values whose KEYS did not match a PII substring — the
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
* replicate it here so IP addresses embedded in non-IP-keyed attributes
* (e.g. "request.note": "from 10.0.0.1") are still redacted (C2 fix / R32).
* (e.g. "request.note": "from 10.0.0.1") are still redacted.
*/
function scrubValue(value: unknown): unknown {
if (typeof value !== "string") return value;
@@ -39,7 +48,9 @@ function scrubValue(value: unknown): unknown {
return scrubbed;
}
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
function scrubAttributes(
attrs: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrs)) {
if (isPiiKey(key)) {
@@ -54,7 +65,7 @@ function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown
/**
* Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes.
* Redacts any span attribute whose key contains a PII substring (case-insensitive).
* R32 — attribute-key-based PII redaction.
* Attribute-key-based PII redaction.
*/
export class PiiScrubSpanProcessor implements SpanProcessor {
forceFlush(): Promise<void> {
@@ -70,7 +81,9 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
}
onEnd(span: ReadableSpan): void {
const scrubbed = scrubAttributes(span.attributes as Record<string, unknown>);
const scrubbed = scrubAttributes(
span.attributes as Record<string, unknown>,
);
Object.assign(span.attributes, scrubbed);
}
}
@@ -80,7 +93,7 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
* - Strips PII from attributes (key-based substring match, case-insensitive).
* - Strips PII from the log body string (substring match — if any PII substring
* appears in the body, the entire body is redacted to avoid partial leakage).
* R32 — attribute-key-based PII redaction; R33 — body-level redaction.
* Attribute-key-based PII redaction; body-level redaction.
*/
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
forceFlush(): Promise<void> {
@@ -93,7 +106,9 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
onEmit(record: LogRecord): void {
if (record.attributes) {
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
const scrubbed = scrubAttributes(
record.attributes as Record<string, unknown>,
);
Object.assign(record.attributes, scrubbed);
}
if (typeof record.body === "string") {
@@ -103,7 +118,7 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
record.body = REDACTED_VALUE;
} else {
// No PII keyword, but may still contain IP addresses embedded in text.
// Apply value-level regex scrubbing (C2 fix / R32).
// Apply value-level regex scrubbing.
record.body = scrubValue(record.body) as string;
}
}

View File

@@ -31,7 +31,7 @@ describe("createSentryOtelBridge", () => {
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
expect(bridge.spanProcessor).toBeDefined();
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
// logRecordProcessor is wired (SentryLogRecordForwarder)
expect(bridge.logRecordProcessor).toBeDefined();
expect(bridge.logRecordProcessor).not.toBeNull();
});
@@ -50,7 +50,10 @@ describe("SentryLogRecordForwarder", () => {
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber: SEVERITY_ERROR,
@@ -84,7 +87,10 @@ describe("SentryLogRecordForwarder", () => {
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber: SEVERITY_ERROR,
@@ -110,7 +116,10 @@ describe("SentryLogRecordForwarder", () => {
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const forwarder = new SentryLogRecordForwarder({
captureException,
captureMessage,
});
const record = {
severityNumber,

View File

@@ -1,4 +1,7 @@
import type { tracing as sdkTracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
import type {
tracing as sdkTracing,
logs as sdkLogs,
} from "@opentelemetry/sdk-node";
import { SeverityNumber } from "@opentelemetry/api-logs";
import type { LogRecord } from "@opentelemetry/sdk-logs";
@@ -64,7 +67,8 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
if (severityNumber >= SeverityNumber.ERROR) {
// Reconstruct the error from OTel semantic convention attributes
const message =
(attrs["exception.message"] as string | undefined) ?? String(record.body ?? "");
(attrs["exception.message"] as string | undefined) ??
String(record.body ?? "");
const err = new Error(message);
if (attrs["exception.type"]) {
err.name = attrs["exception.type"] as string;
@@ -77,7 +81,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
? (attrs["sentry.fingerprint"] as string).split("|")
: undefined;
Sentry.captureException(err, { tags, extra, ...(fingerprint ? { fingerprint } : {}) });
Sentry.captureException(err, {
tags,
extra,
...(fingerprint ? { fingerprint } : {}),
});
} else {
// Map severity to Sentry level
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
@@ -99,9 +107,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
* forward spans and log records to Sentry. This is the ONLY file in
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
* coupling is excluded by the R40/R52 ESLint allowlist.
* coupling is excluded by the ESLint allowlist.
*/
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
export function createSentryOtelBridge(
opts: SentryOtelBridgeOpts,
): SentryOtelBridge {
if (!opts.dsn) {
return { spanProcessor: null, logRecordProcessor: null };
}
@@ -125,7 +135,9 @@ function extractTags(attrs: Record<string, unknown>): Record<string, string> {
return tags;
}
function extractExtras(attrs: Record<string, unknown>): Record<string, unknown> {
function extractExtras(
attrs: Record<string, unknown>,
): Record<string, unknown> {
const extras: Record<string, unknown> = {};
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("extra.")) {

View File

@@ -2,7 +2,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { replayIntegration } = vi.hoisted(() => {
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
const replayIntegration = vi.fn((opts: unknown) => ({
name: "Replay",
_opts: opts,
}));
return { replayIntegration };
});
@@ -19,43 +22,35 @@ describe("initSentryClientReact", () => {
vi.clearAllMocks();
});
it("calls SentryReact.init with sendDefaultPii: false (R31)", () => {
it("calls SentryReact.init with sendDefaultPii: false", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("attaches replay integration with mask flags (R34, R35)", () => {
it("attaches replay integration with mask flags", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
expect(replayIntegration).toHaveBeenCalledTimes(1);
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(replayOpts["maskAllText"]).toBe(true);
expect(replayOpts["maskAllInputs"]).toBe(true);
expect(replayOpts["blockAllMedia"]).toBe(true);
});
it("defaults replay sample rates per R37", () => {
it("defaults replay sample rates", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysSessionSampleRate"]).toBe(0.0);
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
});
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});

View File

@@ -13,7 +13,7 @@ import {
IPV6_REGEX,
} from "../otel/pii-fields";
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
@@ -33,7 +33,9 @@ function redactString(s: string): string {
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
return parentKey && keyContainsPii(parentKey)
? REDACTED_VALUE
: redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
@@ -63,8 +65,8 @@ function scrubUrl(url: string): string {
/**
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
* Mirrors init-client.ts but uses @sentry/react directly. R31, R32, R33,
* R34, R35, R37 still apply.
* Mirrors init-client.ts but uses @sentry/react directly. Same PII,
* replay, and scrubbing requirements apply.
*/
export function initSentryClientReact(opts: InitClientOpts): void {
if (!opts.dsn) return;
@@ -78,31 +80,48 @@ export function initSentryClientReact(opts: InitClientOpts): void {
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
process.env["SENTRY_ENVIRONMENT"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof SentryReact.init>[0];
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
type SentryEvent = {
extra?: Record<string, unknown> | null;
contexts?: Record<string, Record<string, unknown> | undefined>;
request?: {
url?: string;
headers?: Record<string, string | undefined>;
[key: string]: unknown;
};
transaction?: string;
[key: string]: unknown;
};
SentryReact.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"], // R32
beforeSendTransaction: ((event: SentryEvent) => { // R33
sendDefaultPii: false,
beforeSend: ((event: SentryEvent) =>
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
beforeSendTransaction: ((event: SentryEvent) => {
const out = { ...event };
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
if (out.request?.url)
out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (
out.transaction &&
(out.transaction.includes("?") || out.transaction.includes("="))
) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
replaysSessionSampleRate: 0.0, // R37
replaysOnErrorSampleRate: 1.0, // R37
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
integrations: [
// R34, R35 — mandatory mask flags; allowlist starts empty
// mandatory mask flags; allowlist starts empty
SentryReact.replayIntegration({
maskAllText: true,
maskAllInputs: true,

View File

@@ -2,7 +2,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { replayIntegration } = vi.hoisted(() => {
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
const replayIntegration = vi.fn((opts: unknown) => ({
name: "Replay",
_opts: opts,
}));
return { replayIntegration };
});
@@ -19,51 +22,41 @@ describe("initSentryClient", () => {
vi.clearAllMocks();
});
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
it("calls Sentry.init with sendDefaultPii: false", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => {
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
expect(replayIntegration).toHaveBeenCalledTimes(1);
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(replayOpts["maskAllText"]).toBe(true);
expect(replayOpts["maskAllInputs"]).toBe(true);
expect(replayOpts["blockAllMedia"]).toBe(true);
});
it("defaults replaysSessionSampleRate to 0.0 (R37)", () => {
it("defaults replaysSessionSampleRate to 0.0", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysSessionSampleRate"]).toBe(0.0);
});
it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => {
it("defaults replaysOnErrorSampleRate to 1.0", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
});
it("attaches beforeSend + beforeSendTransaction", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});

View File

@@ -18,7 +18,7 @@ export type InitClientOpts = {
release?: string;
};
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
@@ -38,7 +38,9 @@ function redactString(s: string): string {
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
return parentKey && keyContainsPii(parentKey)
? REDACTED_VALUE
: redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
@@ -78,31 +80,48 @@ export function initSentryClient(opts: InitClientOpts): void {
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
process.env["SENTRY_ENVIRONMENT"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof Sentry.init>[0];
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
type SentryEvent = {
extra?: Record<string, unknown> | null;
contexts?: Record<string, Record<string, unknown> | undefined>;
request?: {
url?: string;
headers?: Record<string, string | undefined>;
[key: string]: unknown;
};
transaction?: string;
[key: string]: unknown;
};
Sentry.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: ((event: SentryEvent) => { // R33
sendDefaultPii: false,
beforeSend: ((event: SentryEvent) =>
deepScrub(event)) as unknown as InitOpts["beforeSend"],
beforeSendTransaction: ((event: SentryEvent) => {
const out = { ...event };
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
if (out.request?.url)
out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (
out.transaction &&
(out.transaction.includes("?") || out.transaction.includes("="))
) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as InitOpts["beforeSendTransaction"],
replaysSessionSampleRate: 0.0, // R37 — privacy default
replaysOnErrorSampleRate: 1.0, // R37
replaysSessionSampleRate: 0.0, // privacy default
replaysOnErrorSampleRate: 1.0,
integrations: [
// R34, R35 — mandatory mask flags; allowlist starts empty
// mandatory mask flags; allowlist starts empty
Sentry.replayIntegration({
maskAllText: true,
maskAllInputs: true,

View File

@@ -6,16 +6,19 @@ interface Adder {
add(a: number, b: number): number;
}
const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) => {
it("adds two positive numbers", async () => {
const subject = await buildSubject();
expect(subject.add(2, 3)).toBe(5);
});
it("handles zero", async () => {
const subject = await buildSubject();
expect(subject.add(0, 0)).toBe(0);
});
});
const adderContract = defineContractSuite<Adder>(
"Adder",
({ buildSubject }) => {
it("adds two positive numbers", async () => {
const subject = await buildSubject();
expect(subject.add(2, 3)).toBe(5);
});
it("handles zero", async () => {
const subject = await buildSubject();
expect(subject.add(0, 0)).toBe(0);
});
},
);
class RealAdder implements Adder {
add(a: number, b: number) {
@@ -27,17 +30,20 @@ describe("RealAdder satisfies Adder contract", () => {
adderContract.run(() => new RealAdder());
});
describe("defineContractSuite — getTracer plumbing (R50)", () => {
describe("defineContractSuite — getTracer plumbing", () => {
it("passes the tracer accessor into the suite", () => {
let receivedTracer: RecordingTracer | undefined;
const tracer = new RecordingTracer();
const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => {
it("can read tracer", async () => {
const subject = await buildSubject();
expect(subject.foo).toBe("bar");
receivedTracer = getTracer?.();
});
});
const suite = defineContractSuite<{ foo: string }>(
"Test",
({ buildSubject, getTracer }) => {
it("can read tracer", async () => {
const subject = await buildSubject();
expect(subject.foo).toBe("bar");
receivedTracer = getTracer?.();
});
},
);
suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
// Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after.
// (This is a meta-test of plumbing only — the inner it() runs as a child describe.)
@@ -47,12 +53,15 @@ describe("defineContractSuite — getTracer plumbing (R50)", () => {
it("getTracer is undefined when opts.tracer not provided (backward compat)", () => {
let receivedAccessor: unknown = undefined;
const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => {
it("accessor undefined", async () => {
await buildSubject();
receivedAccessor = getTracer;
});
});
const suite = defineContractSuite<{ x: number }>(
"Test",
({ buildSubject, getTracer }) => {
it("accessor undefined", async () => {
await buildSubject();
receivedAccessor = getTracer;
});
},
);
suite.run(() => ({ x: 1 }));
// No tracer opts → accessor is undefined inside the suite body.
// (Exact assertion happens via type, not runtime — typecheck gates this.)

View File

@@ -44,80 +44,79 @@ export const CONTRACT_PAGES_SEED: Page[] = [
* must return a repo pre-loaded with CONTRACT_PAGES_SEED (two pages:
* one published with slug "about", one draft with slug "draft-page").
*/
export const pagesRepositoryContract =
defineContractSuite<IPagesRepository>(
"IPagesRepository",
({ buildSubject, getTracer }) => {
let repo: IPagesRepository;
export const pagesRepositoryContract = defineContractSuite<IPagesRepository>(
"IPagesRepository",
({ buildSubject, getTracer }) => {
let repo: IPagesRepository;
beforeEach(async () => {
repo = await buildSubject();
beforeEach(async () => {
repo = await buildSubject();
});
// --- getPageBySlug ---
it("getPageBySlug returns the published page by slug", async () => {
const result = await repo.getPageBySlug("about");
expect(result).toBeDefined();
expect(result?.slug).toBe("about");
expect(result?.status).toBe("published");
expect(result?.id).toBeDefined();
});
it("getPageBySlug returns undefined for an unknown slug", async () => {
expect(await repo.getPageBySlug("no-such-page")).toBeUndefined();
});
// --- getPages ---
it("getPages with no filter returns all seeded pages", async () => {
const list = await repo.getPages();
expect(list).toHaveLength(2);
for (const page of list) {
expect(page.id).toBeDefined();
expect(page.slug).toBeDefined();
expect(["draft", "published"]).toContain(page.status);
}
});
it("getPages({ status: 'published' }) returns only published pages", async () => {
const published = await repo.getPages({ status: "published" });
expect(published).toHaveLength(1);
for (const page of published) {
expect(page.status).toBe("published");
}
});
it("getPages({ status: 'draft' }) returns only draft pages", async () => {
const drafts = await repo.getPages({ status: "draft" });
expect(drafts).toHaveLength(1);
for (const page of drafts) {
expect(page.status).toBe("draft");
}
});
describe("span emission", () => {
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getPageBySlug("about");
const span = tracer.findSpan("pages.getPageBySlug");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.slug).toBe("about");
});
// --- getPageBySlug ---
it("getPageBySlug returns the published page by slug", async () => {
const result = await repo.getPageBySlug("about");
expect(result).toBeDefined();
expect(result?.slug).toBe("about");
expect(result?.status).toBe("published");
expect(result?.id).toBeDefined();
it("getPages emits pages.getPages span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getPages({ status: "published" });
const span = tracer.findSpan("pages.getPages");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.status).toBe("published");
});
it("getPageBySlug returns undefined for an unknown slug", async () => {
expect(await repo.getPageBySlug("no-such-page")).toBeUndefined();
});
// --- getPages ---
it("getPages with no filter returns all seeded pages", async () => {
const list = await repo.getPages();
expect(list).toHaveLength(2);
for (const page of list) {
expect(page.id).toBeDefined();
expect(page.slug).toBeDefined();
expect(["draft", "published"]).toContain(page.status);
}
});
it("getPages({ status: 'published' }) returns only published pages", async () => {
const published = await repo.getPages({ status: "published" });
expect(published).toHaveLength(1);
for (const page of published) {
expect(page.status).toBe("published");
}
});
it("getPages({ status: 'draft' }) returns only draft pages", async () => {
const drafts = await repo.getPages({ status: "draft" });
expect(drafts).toHaveLength(1);
for (const page of drafts) {
expect(page.status).toBe("draft");
}
});
describe("span emission (R50)", () => {
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getPageBySlug("about");
const span = tracer.findSpan("pages.getPageBySlug");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.slug).toBe("about");
});
it("getPages emits pages.getPages span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getPages({ status: "published" });
const span = tracer.findSpan("pages.getPages");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.status).toBe("published");
});
});
},
);
});
},
);

View File

@@ -35,7 +35,7 @@ export const siteSettingsRepositoryContract =
).toBe(true);
});
describe("span emission (R50)", () => {
describe("span emission", () => {
it("getSiteSettings emits site-settings.getSiteSettings span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();

View File

@@ -18,7 +18,7 @@ describe("getPageBySlugUseCase", () => {
expect(result).toBeUndefined();
});
it("(R25) throws ZodError when repository returns malformed page data", async () => {
it("throws ZodError when repository returns malformed page data", async () => {
const repo = new MockPagesRepository([
{
id: "p-bad",
@@ -34,6 +34,8 @@ describe("getPageBySlugUseCase", () => {
} as never,
]);
const useCase = getPageBySlugUseCase(repo);
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(ZodError);
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(
ZodError,
);
});
});

View File

@@ -11,7 +11,7 @@ describe("getSiteSettingsUseCase", () => {
expect(result.siteName).toBe("My App");
});
it("(R25) throws ZodError when repository returns malformed site settings", async () => {
it("throws ZodError when repository returns malformed site settings", async () => {
const malformedRepo = {
getSiteSettings: async () => ({ siteName: "" }),
};

View File

@@ -4,7 +4,7 @@ export type { MarketingPagesRouter } from "./integrations/api/router";
export { PageNotFoundError } from "./entities/errors/page";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types (Plan 9 R18)
// Use case schemas + types
export {
getPageBySlugInputSchema,
getPageBySlugOutputSchema,
@@ -26,4 +26,7 @@ export type { IGetSiteSettingsController } from "./interface-adapters/controller
// <gen:events>
// <gen:realtime-channels>
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";
export {
marketingPagesManifest,
type MarketingPagesManifest,
} from "./feature.manifest";

View File

@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
// Mock repo also wraps in spans (R42).
describe("MockPagesRepository emits spans (R42)", () => {
// Mock repo also wraps in spans.
describe("MockPagesRepository emits spans", () => {
it("getPageBySlug emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
@@ -24,6 +27,8 @@ describe("MockPagesRepository emits spans (R42)", () => {
await repo.getPages({ limit: 10 });
expect(tracer.findSpan("pages.getPages")).toBeDefined();
expect(tracer.findSpan("pages.getPages")!.attributes.limit).toBe(10);
expect(tracer.findSpan("pages.getPages")!.attributes.count).toBeGreaterThanOrEqual(0);
expect(
tracer.findSpan("pages.getPages")!.attributes.count,
).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/site-settings.repository.mock";
// Mock repo also wraps in spans (R42).
describe("MockSiteSettingsRepository emits spans (R42)", () => {
// Mock repo also wraps in spans.
describe("MockSiteSettingsRepository emits spans", () => {
it("getSiteSettings emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();

View File

@@ -33,7 +33,7 @@ describe("marketingPagesRouter", () => {
});
});
describe("marketingPagesRouter (R26 error mapping)", () => {
describe("marketingPagesRouter error mapping", () => {
beforeEach(() => {
marketingPagesContainer.unbindAll();
marketingPagesContainer.load(MarketingPagesModule);

View File

@@ -3,94 +3,93 @@ import { defineContractSuite } from "@repo/core-testing/contract";
import type { IMediaRepository } from "../application/repositories/media.repository.interface.js";
import { mediaFactory } from "../__factories__/media.factory.js";
export const mediaRepositoryContract =
defineContractSuite<IMediaRepository>(
"IMediaRepository",
({ buildSubject, getTracer }) => {
let repo: IMediaRepository;
export const mediaRepositoryContract = defineContractSuite<IMediaRepository>(
"IMediaRepository",
({ buildSubject, getTracer }) => {
let repo: IMediaRepository;
beforeEach(async () => {
mediaFactory.reset();
repo = await buildSubject();
beforeEach(async () => {
mediaFactory.reset();
repo = await buildSubject();
});
// --- getMedia ---
it("getMedia returns undefined for a missing id", async () => {
const result = await repo.getMedia("does-not-exist");
expect(result).toBeUndefined();
});
// --- listMedia ---
it("listMedia returns empty array when no media exists", async () => {
const result = await repo.listMedia();
expect(result).toHaveLength(0);
});
// --- deleteMedia ---
it("deleteMedia removes the item so getMedia returns undefined", async () => {
const seed = mediaFactory.build({ id: "contract-1" });
// @ts-expect-error _store is a test helper on MockMediaRepository
if (typeof repo._store === "function") {
// @ts-expect-error _store is a test helper
await repo._store(seed);
}
await repo.deleteMedia("contract-1");
const result = await repo.getMedia("contract-1");
expect(result).toBeUndefined();
});
it("listMedia with limit returns at most limit items", async () => {
// Only verifiable on mock; for real impl this checks the interface
const result = await repo.listMedia({ limit: 0 });
expect(Array.isArray(result)).toBe(true);
});
it("listMedia with offset skips items", async () => {
const result = await repo.listMedia({ offset: 100 });
expect(Array.isArray(result)).toBe(true);
});
it("getMedia returns undefined for empty string id", async () => {
const result = await repo.getMedia("");
expect(result).toBeUndefined();
});
describe("span emission", () => {
it("getMedia emits media.getMedia span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getMedia("nonexistent");
const span = tracer.findSpan("media.getMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
// --- getMedia ---
it("getMedia returns undefined for a missing id", async () => {
const result = await repo.getMedia("does-not-exist");
expect(result).toBeUndefined();
it("listMedia emits media.listMedia span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.listMedia({ limit: 10 });
const span = tracer.findSpan("media.listMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.limit).toBe(10);
});
// --- listMedia ---
it("listMedia returns empty array when no media exists", async () => {
const result = await repo.listMedia();
expect(result).toHaveLength(0);
it("deleteMedia emits media.deleteMedia span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.deleteMedia("nonexistent");
const span = tracer.findSpan("media.deleteMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
// --- deleteMedia ---
it("deleteMedia removes the item so getMedia returns undefined", async () => {
const seed = mediaFactory.build({ id: "contract-1" });
// @ts-expect-error _store is a test helper on MockMediaRepository
if (typeof repo._store === "function") {
// @ts-expect-error _store is a test helper
await repo._store(seed);
}
await repo.deleteMedia("contract-1");
const result = await repo.getMedia("contract-1");
expect(result).toBeUndefined();
});
it("listMedia with limit returns at most limit items", async () => {
// Only verifiable on mock; for real impl this checks the interface
const result = await repo.listMedia({ limit: 0 });
expect(Array.isArray(result)).toBe(true);
});
it("listMedia with offset skips items", async () => {
const result = await repo.listMedia({ offset: 100 });
expect(Array.isArray(result)).toBe(true);
});
it("getMedia returns undefined for empty string id", async () => {
const result = await repo.getMedia("");
expect(result).toBeUndefined();
});
describe("span emission (R50)", () => {
it("getMedia emits media.getMedia span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getMedia("nonexistent");
const span = tracer.findSpan("media.getMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
it("listMedia emits media.listMedia span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.listMedia({ limit: 10 });
const span = tracer.findSpan("media.listMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.limit).toBe(10);
});
it("deleteMedia emits media.deleteMedia span with id attribute", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.deleteMedia("nonexistent");
const span = tracer.findSpan("media.deleteMedia");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
expect(span!.attributes.id).toBe("nonexistent");
});
});
},
);
});
},
);

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { getMediaUseCase, getMediaOutputSchema } from "@/application/use-cases/get-media.use-case";
import {
getMediaUseCase,
getMediaOutputSchema,
} from "@/application/use-cases/get-media.use-case";
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
import { MediaNotFoundError } from "@/entities/errors/media";
import { mediaFactory } from "@/__factories__/media.factory";
@@ -22,15 +25,17 @@ describe("getMediaUseCase", () => {
const repo = new MockMediaRepository();
const useCase = getMediaUseCase(repo);
await expect(useCase({ id: "missing" })).rejects.toThrow(MediaNotFoundError);
await expect(useCase({ id: "missing" })).rejects.toThrow(
MediaNotFoundError,
);
});
it("R25 — parses valid output without error", async () => {
it("parses valid output without error", async () => {
const validMedia = mediaFactory.build({ id: "m-r25" });
expect(() => getMediaOutputSchema.parse(validMedia)).not.toThrow();
});
it("R25 — throws ZodError when repository returns malformed media", async () => {
it("throws ZodError when repository returns malformed media", async () => {
const repo = new MockMediaRepository();
// Store a malformed object (missing required fields) via type cast
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { listMediaUseCase, listMediaOutputSchema } from "@/application/use-cases/list-media.use-case";
import {
listMediaUseCase,
listMediaOutputSchema,
} from "@/application/use-cases/list-media.use-case";
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
import { mediaFactory } from "@/__factories__/media.factory";
@@ -33,12 +36,12 @@ describe("listMediaUseCase", () => {
expect(result).toHaveLength(2);
});
it("R25 — parses valid output without error", async () => {
it("parses valid output without error", async () => {
const items = [mediaFactory.build(), mediaFactory.build()];
expect(() => listMediaOutputSchema.parse(items)).not.toThrow();
});
it("R25 — throws ZodError when repository returns malformed items", async () => {
it("throws ZodError when repository returns malformed items", async () => {
const repo = new MockMediaRepository();
// Store a malformed object (missing required fields)
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);

View File

@@ -40,8 +40,12 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
mediaContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
mediaContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
@@ -95,9 +99,15 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
]) {
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
}
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
.toConstantValue(wrappedGetMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
.toConstantValue(wrappedListMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
.toConstantValue(wrappedDeleteMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IGetMediaController)
@@ -138,8 +148,7 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -19,7 +19,8 @@ import { listMediaController } from "../interface-adapters/controllers/list-medi
import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller";
export function bindProductionMedia(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -28,17 +29,19 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
mediaContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
mediaContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repository
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
}
const repo = new MediaRepository(config, tracer, logger);
mediaContainer
.bind(MEDIA_SYMBOLS.IMediaRepository)
.toConstantValue(repo);
mediaContainer.bind(MEDIA_SYMBOLS.IMediaRepository).toConstantValue(repo);
// Use cases — wrapped with span + capture at bind time
const wrappedGetMedia = withSpan(
@@ -76,9 +79,15 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
]) {
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
}
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
.toConstantValue(wrappedGetMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
.toConstantValue(wrappedListMedia);
mediaContainer
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
.toConstantValue(wrappedDeleteMedia);
// Controllers — wrapped with span at bind time
for (const sym of [
@@ -127,8 +136,7 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -3,7 +3,7 @@ export { MediaNotFoundError } from "./entities/errors/media";
export { InputParseError } from "./entities/errors/common";
export type { MediaRouter } from "./integrations/api/router";
// Use case schemas + types (Plan 9 R18)
// Use case schemas + types
export {
getMediaInputSchema,
getMediaOutputSchema,

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
import type { Media } from "@/entities/models/media";
@@ -12,8 +15,8 @@ const SAMPLE_MEDIA: Media = {
filesize: 1024,
};
// Mock repo also wraps in spans (R42).
describe("MockMediaRepository emits spans (R42)", () => {
// Mock repo also wraps in spans.
describe("MockMediaRepository emits spans", () => {
it("getMedia emits one span with op='repository' and found attribute", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();

View File

@@ -36,7 +36,7 @@ describe("mediaRouter", () => {
});
});
describe("mediaRouter (R26 error mapping)", () => {
describe("mediaRouter error mapping", () => {
beforeEach(() => {
mediaContainer.unbindAll();
mediaContainer.load(MediaModule);
@@ -123,7 +123,9 @@ describe("mediaRouter (R26 error mapping)", () => {
mediaContainer
.bind(MEDIA_SYMBOLS.IDeleteMediaController)
.toDynamicValue((ctx) =>
deleteMediaController(ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase)),
deleteMediaController(
ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase),
),
);
const caller = mediaRouter.createCaller({});

View File

@@ -22,66 +22,65 @@ export const CONTRACT_HEADER_SEED: Header = {
* Header is a singleton (Payload Global). The interface exposes only
* getHeader(). The contract verifies the shape, count, and order of items.
*/
export const headerRepositoryContract =
defineContractSuite<IHeaderRepository>(
"IHeaderRepository",
({ buildSubject, getTracer }) => {
let repo: IHeaderRepository;
export const headerRepositoryContract = defineContractSuite<IHeaderRepository>(
"IHeaderRepository",
({ buildSubject, getTracer }) => {
let repo: IHeaderRepository;
beforeEach(async () => {
repo = await buildSubject();
beforeEach(async () => {
repo = await buildSubject();
});
// --- getHeader ---
it("getHeader returns an object with an items array of the seeded length", async () => {
const header = await repo.getHeader();
expect(header).toBeDefined();
expect(header.items).toBeInstanceOf(Array);
expect(header.items).toHaveLength(3);
});
it("getHeader items appear in the seeded order with correct shape", async () => {
const header = await repo.getHeader();
expect(header.items[0]?.label).toBe("Home");
expect(header.items[0]?.href).toBe("/");
expect(header.items[0]?.external).toBe(false);
expect(header.items[1]?.label).toBe("Blog");
expect(header.items[1]?.href).toBe("/blog");
expect(header.items[1]?.external).toBe(false);
expect(header.items[2]?.label).toBe("Docs");
expect(header.items[2]?.href).toBe("/docs");
expect(header.items[2]?.external).toBe(true);
});
it("getHeader items have label, href, and external fields", async () => {
const header = await repo.getHeader();
for (const item of header.items) {
expect(typeof item.label).toBe("string");
expect(item.label.length).toBeGreaterThan(0);
expect(typeof item.href).toBe("string");
expect(item.href.length).toBeGreaterThan(0);
expect(typeof item.external).toBe("boolean");
}
});
it("getHeader logoId is string or undefined", async () => {
const header = await repo.getHeader();
expect(
header.logoId === undefined || typeof header.logoId === "string",
).toBe(true);
});
describe("span emission", () => {
it("getHeader emits header.getHeader span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getHeader();
const span = tracer.findSpan("header.getHeader");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
// --- getHeader ---
it("getHeader returns an object with an items array of the seeded length", async () => {
const header = await repo.getHeader();
expect(header).toBeDefined();
expect(header.items).toBeInstanceOf(Array);
expect(header.items).toHaveLength(3);
});
it("getHeader items appear in the seeded order with correct shape", async () => {
const header = await repo.getHeader();
expect(header.items[0]?.label).toBe("Home");
expect(header.items[0]?.href).toBe("/");
expect(header.items[0]?.external).toBe(false);
expect(header.items[1]?.label).toBe("Blog");
expect(header.items[1]?.href).toBe("/blog");
expect(header.items[1]?.external).toBe(false);
expect(header.items[2]?.label).toBe("Docs");
expect(header.items[2]?.href).toBe("/docs");
expect(header.items[2]?.external).toBe(true);
});
it("getHeader items have label, href, and external fields", async () => {
const header = await repo.getHeader();
for (const item of header.items) {
expect(typeof item.label).toBe("string");
expect(item.label.length).toBeGreaterThan(0);
expect(typeof item.href).toBe("string");
expect(item.href.length).toBeGreaterThan(0);
expect(typeof item.external).toBe("boolean");
}
});
it("getHeader logoId is string or undefined", async () => {
const header = await repo.getHeader();
expect(
header.logoId === undefined || typeof header.logoId === "string",
).toBe(true);
});
describe("span emission (R50)", () => {
it("getHeader emits header.getHeader span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getHeader();
const span = tracer.findSpan("header.getHeader");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
});
},
);
});
},
);

View File

@@ -12,7 +12,7 @@ describe("getHeaderUseCase", () => {
expect(result.items[0]?.label).toBe("Home");
});
it("(R25) throws ZodError when repository returns malformed header", async () => {
it("throws ZodError when repository returns malformed header", async () => {
const malformedRepo = {
getHeader: async () =>
({ items: [{ label: "", href: "/", external: false }] }) as never,

View File

@@ -37,8 +37,12 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
navigationContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
navigationContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
@@ -54,7 +58,11 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
{ name: "navigation.getHeader", op: "use-case" },
withCapture(
logger,
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
{
feature: "navigation",
layer: "use-case",
name: "navigation.getHeader",
},
getHeaderUseCase(repo),
),
);
@@ -77,13 +85,16 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
{ name: "navigation.getHeader", op: "controller" },
withCapture(
logger,
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
{
feature: "navigation",
layer: "controller",
name: "navigation.getHeader",
},
getHeaderController(wrappedGetHeader),
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -15,7 +15,8 @@ import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
export function bindProductionNavigation(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -24,8 +25,12 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
navigationContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
navigationContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repository
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
@@ -42,7 +47,11 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
{ name: "navigation.getHeader", op: "use-case" },
withCapture(
logger,
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
{
feature: "navigation",
layer: "use-case",
name: "navigation.getHeader",
},
getHeaderUseCase(repo),
),
);
@@ -66,13 +75,16 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
{ name: "navigation.getHeader", op: "controller" },
withCapture(
logger,
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
{
feature: "navigation",
layer: "controller",
name: "navigation.getHeader",
},
getHeaderController(wrappedGetHeader),
),
),
);
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;

View File

@@ -3,7 +3,7 @@ export type { NavigationRouter } from "./integrations/api/router";
export { HeaderNotFoundError } from "./entities/errors/header";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types (Plan 9 R18)
// Use case schemas + types
export {
getHeaderInputSchema,
getHeaderOutputSchema,
@@ -17,4 +17,7 @@ export type { IGetHeaderController } from "./interface-adapters/controllers/get-
// <gen:events>
// <gen:realtime-channels>
export { navigationManifest, type NavigationManifest } from "./feature.manifest";
export {
navigationManifest,
type NavigationManifest,
} from "./feature.manifest";

View File

@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
// Mock repo also wraps in spans (R42).
describe("MockHeaderRepository emits spans (R42)", () => {
// Mock repo also wraps in spans.
describe("MockHeaderRepository emits spans", () => {
it("getHeader emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();

View File

@@ -30,7 +30,7 @@ describe("navigationRouter", () => {
});
});
describe("navigationRouter (R26 error mapping)", () => {
describe("navigationRouter error mapping", () => {
beforeEach(() => {
navigationContainer.unbindAll();
navigationContainer.load(NavigationModule);
@@ -43,7 +43,10 @@ describe("navigationRouter (R26 error mapping)", () => {
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
const caller = navigationRouter.createCaller({});
try {
await caller.header({ unexpected: "field" } as unknown as Record<string, never>);
await caller.header({ unexpected: "field" } as unknown as Record<
string,
never
>);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
@@ -66,12 +69,16 @@ describe("navigationRouter (R26 error mapping)", () => {
navigationContainer
.bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase)
.toDynamicValue((ctx) =>
getHeaderUseCase(ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository)),
getHeaderUseCase(
ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository),
),
);
navigationContainer
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
.toDynamicValue((ctx) =>
getHeaderController(ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase)),
getHeaderController(
ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
),
);
const caller = navigationRouter.createCaller({});

View File

@@ -14,10 +14,10 @@ import {
/**
* Turbo generator: `feature`
*
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
* Scaffolds a feature package under `packages/<name>/`
* matching the shape of the existing `navigation` reference feature.
*
* Phase 1 scope (intentionally limited):
* Scope (intentionally limited):
* - Single entity, single use case (`getX`)
* - Skips Payload CMS collection/global templates (integrations/cms/**)
* - Skips UI query helpers (ui/query.ts) — emits an empty barrel
@@ -38,8 +38,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
plop.setHelper("eq", (a: unknown, b: unknown) => a === b);
plop.setGenerator("feature", {
description:
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
description: "Scaffold a feature package (single entity / single use case)",
prompts: [
{
type: "input",
@@ -135,7 +134,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
{
type: "add",
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts",
templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs",
templateFile:
"templates/feature/src/entities/models/entity.test.ts.hbs",
},
{
type: "add",
@@ -255,7 +255,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts",
templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs",
templateFile:
"templates/feature/src/integrations/api/procedures.ts.hbs",
},
{
type: "add",
@@ -265,10 +266,11 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts",
templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs",
templateFile:
"templates/feature/src/integrations/api/router.test.ts.hbs",
},
// Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test)
// Seeds + factories + contracts (minimal stubs that still typecheck/test)
{
type: "add",
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
@@ -282,7 +284,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
{
type: "add",
path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts",
templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs",
templateFile:
"templates/feature/src/__factories__/entity.factory.ts.hbs",
},
{
type: "add",
@@ -291,7 +294,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
"templates/feature/src/__contracts__/entity-repository.contract.ts.hbs",
},
// ui — empty barrel for Phase 1 (./ui subpath kept reserved)
// ui — empty barrel (./ui subpath kept reserved)
{
type: "add",
path: "packages/{{kebabCase name}}/src/ui/index.ts",
@@ -300,7 +303,11 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
// Final manual-wiring instructions printed to the user
function printNextSteps(answers: Record<string, unknown>): string {
const a = answers as { name: string; entity: string; entityPlural: string };
const a = answers as {
name: string;
entity: string;
entityPlural: string;
};
const kebab = a.name;
const constSym = a.name.toUpperCase().replace(/-/g, "_");
const pkg = `@repo/${kebab}`;
@@ -602,11 +609,18 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
"packages/core-eslint/rules",
),
() => {
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime");
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-realtime",
);
return "Added @repo/core-realtime to transpilePackages.";
},
() => {
addBoundariesEntry("packages/core-eslint/base.js", "packages/core-realtime", { mode: "folder" });
addBoundariesEntry(
"packages/core-eslint/base.js",
"packages/core-realtime",
{ mode: "folder" },
);
return "Added core-realtime boundaries entry.";
},
() => {
@@ -634,7 +648,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
},
...emitTemplateTree("core-package/events", "packages/core-events"),
() => {
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events");
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-events",
);
return "Added @repo/core-events to transpilePackages.";
},
() => {
@@ -654,7 +671,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
},
...emitTemplateTree("core-package/trpc", "packages/core-trpc"),
() => {
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-trpc");
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-trpc",
);
return "Added @repo/core-trpc to transpilePackages.";
},
printTrpcNextSteps,
@@ -666,7 +686,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
},
...emitTemplateTree("core-package/ui", "packages/core-ui"),
() => {
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-ui");
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-ui",
);
return "Added @repo/core-ui to transpilePackages.";
},
printUiNextSteps,
@@ -678,7 +701,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
},
...emitTemplateTree("core-package/audit", "packages/core-audit"),
() => {
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit");
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-audit",
);
return "Added @repo/core-audit to transpilePackages.";
},
printAuditNextSteps,
@@ -686,7 +712,8 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
};
plop.setGenerator("core-package", {
description: "Scaffold an optional core package (realtime, events, trpc, ui, audit)",
description:
"Scaffold an optional core package (realtime, events, trpc, ui, audit)",
prompts: [
{
type: "list",
@@ -949,7 +976,9 @@ function consumeActions(a: {
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
return [
() => {
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
assertAnchors(process.cwd(), symbolFile, [
"// <gen:event-handler-symbols>",
]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
@@ -1153,7 +1182,9 @@ function realtimeHandlerActions(a: {
assertAnchors(process.cwd(), symbolFile, [
"// <gen:realtime-handler-symbols>",
]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
assertAnchors(process.cwd(), bindProdFile, [
"// <gen:realtime-handlers>",
]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
return "All required anchors present";
},
@@ -1241,7 +1272,7 @@ function printTrpcNextSteps(): string {
"",
" 2. apps/web-next/src/app/providers.tsx:",
' - import { NextTrpcProvider } from "@repo/core-trpc/next";',
" - wrap children: <NextTrpcProvider trpcUrl=\"/api/trpc\">{children}</NextTrpcProvider>",
' - wrap children: <NextTrpcProvider trpcUrl="/api/trpc">{children}</NextTrpcProvider>',
"",
" 3. apps/web-next/src/app/api/trpc/[trpc]/route.ts:",
' - import { fetchRequestHandler } from "@trpc/server/adapters/fetch";',
@@ -1250,7 +1281,7 @@ function printTrpcNextSteps(): string {
"",
" 4. apps/web-tanstack/src/routes/__root.tsx:",
' - import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack";',
" - wrap <Outlet /> with <TanstackTrpcProvider trpcUrl=\"http://localhost:3000/api/trpc\">",
' - wrap <Outlet /> with <TanstackTrpcProvider trpcUrl="http://localhost:3000/api/trpc">',
"",
" 5. Add @repo/core-trpc to apps/web-next/package.json and apps/web-tanstack/package.json dependencies",
"",
@@ -1358,7 +1389,7 @@ function printAuditNextSteps(): string {
"",
"3. Mount the admin tRPC router in packages/core-api/src/root.ts:",
' import { createAuditRouter } from "@repo/core-audit/api";',
" // const { auditLog } = bindAudit(container, { payloadConfig, sinks: [\"payload\", \"stdout\"] });",
' // const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] });',
" // routers: { ..., audit: createAuditRouter(auditLog) },",
"",
"4. Bind audit in apps/web-next/src/server/bind-production.ts:",
@@ -1371,7 +1402,7 @@ function printAuditNextSteps(): string {
"5. Install user-collection hooks (recommended for DPA compliance):",
" In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:",
" if (ctx.auditLog) {",
' const { createAuditErasureHook, createAuditAfterReadHook } =',
" const { createAuditErasureHook, createAuditAfterReadHook } =",
' await import("@repo/core-audit/hooks");',
" // wire onto users collection — see docs/guides/audit-and-compliance.md",
" }",

View File

@@ -26,7 +26,7 @@ export type BindAuditOpts = {
* if not — better to refuse to start than to ship audit data with a dev-fallback
* salt that an attacker could reverse.
*
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog (Phase 4)
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
* so all sinks receive AuditEntry.correlationId auto-populated from the
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
*/

View File

@@ -8,13 +8,13 @@ export { auditLogsCollection } from "./audit-logs-collection";
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
export { AUDIT_SYMBOLS } from "./di/symbols";
// Phase 3 — GDPR erasure
// GDPR erasure
export { pseudonymize } from "./pseudonymize";
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./hooks/audit-erasure-hook";
// Phase 5 — VIEW capture
// VIEW capture
export {
createAuditAfterReadHook,
type AuditAfterReadHookOpts,

View File

@@ -2,7 +2,7 @@
Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.
See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design. ADR-016 (`docs/decisions/adr-016-realtime-layer.md`) lands in Phase 10 — pending.
ADR-016 (`docs/decisions/adr-016-realtime-layer.md`).
## Public exports

View File

@@ -27,7 +27,7 @@ Feature package scaffolded by `turbo gen feature`. Provides domain logic, reposi
| `./api` | `{{camelCase name}}Router` (tRPC router) |
| `./di/bind-production` | `bindProduction{{pascalCase name}}(config, tracer, logger)` |
| `./di/bind-dev-seed` | `bindDevSeed{{pascalCase name}}(tracer, logger)` |
| `./ui` | (reserved — Phase 1 is empty) |
| `./ui` | (reserved — empty barrel) |
## Tests

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock";
// Mock repo also wraps in spans (R42).
// Mock repo also wraps in spans.
describe("Mock{{pascalCase entity}}Repository emits spans", () => {
it("get{{pascalCase entity}} emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();