refactor: strip Phase/Plan/R-number references from source comments
This commit is contained in:
@@ -83,7 +83,7 @@ function resolveJobsDevSeed(): { queue: IJobQueue } {
|
|||||||
/**
|
/**
|
||||||
* Production path: swap each feature's mock repository binding for the real
|
* Production path: swap each feature's mock repository binding for the real
|
||||||
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
||||||
* feature as Phase E feature wiring lands (blog: task 18; remaining: tasks 19–22).
|
* feature via `bindProductionX` exports.
|
||||||
*/
|
*/
|
||||||
export async function bindAllProduction(): Promise<void> {
|
export async function bindAllProduction(): Promise<void> {
|
||||||
if (bound) return;
|
if (bound) return;
|
||||||
@@ -99,11 +99,11 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
queue,
|
queue,
|
||||||
};
|
};
|
||||||
|
|
||||||
bindProductionAuth(ctx); // Phase E task 19
|
bindProductionAuth(ctx);
|
||||||
bindProductionBlog(ctx); // Phase E task 18
|
bindProductionBlog(ctx);
|
||||||
bindProductionMarketingPages(ctx); // Phase E task 20
|
bindProductionMarketingPages(ctx);
|
||||||
bindProductionNavigation(ctx); // Phase E task 21
|
bindProductionNavigation(ctx);
|
||||||
bindProductionMedia(ctx); // Phase E task 22
|
bindProductionMedia(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,11 +123,11 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
queue,
|
queue,
|
||||||
};
|
};
|
||||||
|
|
||||||
await bindDevSeedAuth(ctx); // Phase E task 19
|
await bindDevSeedAuth(ctx);
|
||||||
await bindDevSeedBlog(ctx); // Phase E task 18
|
await bindDevSeedBlog(ctx);
|
||||||
await bindDevSeedMarketingPages(ctx); // Phase E task 20
|
await bindDevSeedMarketingPages(ctx);
|
||||||
await bindDevSeedNavigation(ctx); // Phase E task 21
|
await bindDevSeedNavigation(ctx);
|
||||||
await bindDevSeedMedia(ctx); // Phase E task 22
|
await bindDevSeedMedia(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { defineContractSuite } from "@repo/core-testing/contract";
|
|||||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface.js";
|
import type { IUsersRepository } from "../application/repositories/users.repository.interface.js";
|
||||||
import { userFactory } from "../__factories__/user.factory.js";
|
import { userFactory } from "../__factories__/user.factory.js";
|
||||||
|
|
||||||
export const usersRepositoryContract =
|
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
|
||||||
defineContractSuite<IUsersRepository>(
|
|
||||||
"IUsersRepository",
|
"IUsersRepository",
|
||||||
({ buildSubject, getTracer }) => {
|
({ buildSubject, getTracer }) => {
|
||||||
let repo: IUsersRepository;
|
let repo: IUsersRepository;
|
||||||
@@ -39,9 +38,7 @@ export const usersRepositoryContract =
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("getUserByUsername returns undefined for missing username", async () => {
|
it("getUserByUsername returns undefined for missing username", async () => {
|
||||||
expect(
|
expect(await repo.getUserByUsername("no-such-user")).toBeUndefined();
|
||||||
await repo.getUserByUsername("no-such-user"),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- createUser ---
|
// --- createUser ---
|
||||||
@@ -53,7 +50,7 @@ export const usersRepositoryContract =
|
|||||||
expect(created.username).toBe("carol");
|
expect(created.username).toBe("carol");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getUser emits users.getUser span with id attribute", async () => {
|
it("getUser emits users.getUser span with id attribute", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
@@ -88,4 +85,4 @@ export const usersRepositoryContract =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||||
import { AuthenticationError } from "@/entities/errors/auth";
|
import { AuthenticationError } from "@/entities/errors/auth";
|
||||||
@@ -18,7 +21,10 @@ describe("signInUseCase", () => {
|
|||||||
await users.createUser(seedUser);
|
await users.createUser(seedUser);
|
||||||
|
|
||||||
const useCase = signInUseCase(users, auth);
|
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.session.userId).toBe(seedUser.id);
|
||||||
expect(result.cookie.name).toBe("session");
|
expect(result.cookie.name).toBe("session");
|
||||||
@@ -38,7 +44,10 @@ describe("signInUseCase", () => {
|
|||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
await users.createUser(
|
await users.createUser(
|
||||||
userFactory.build({ username: "alice", passwordHash: "hashed_correctpassword" }),
|
userFactory.build({
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "hashed_correctpassword",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const useCase = signInUseCase(users, auth);
|
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 () => {
|
it("throws when authenticationService returns a malformed session", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const seed = userFactory.build({ username: "alice" });
|
const seed = userFactory.build({ username: "alice" });
|
||||||
@@ -61,7 +70,9 @@ describe("signInUseCase output validation (R25)", () => {
|
|||||||
} as unknown as IAuthenticationService;
|
} as unknown as IAuthenticationService;
|
||||||
|
|
||||||
const useCase = signInUseCase(users, auth);
|
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", () => {
|
it("exports an output schema that mirrors the success shape", () => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
|
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 { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||||
import { AuthenticationError } from "@/entities/errors/auth";
|
import { AuthenticationError } from "@/entities/errors/auth";
|
||||||
@@ -33,7 +36,11 @@ describe("signUpUseCase", () => {
|
|||||||
|
|
||||||
const useCase = signUpUseCase(users, auth, bus);
|
const useCase = signUpUseCase(users, auth, bus);
|
||||||
await expect(
|
await expect(
|
||||||
useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
|
useCase({
|
||||||
|
username: "alice",
|
||||||
|
password: "secret_password",
|
||||||
|
confirmPassword: "secret_password",
|
||||||
|
}),
|
||||||
).rejects.toBeInstanceOf(AuthenticationError);
|
).rejects.toBeInstanceOf(AuthenticationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,13 +90,17 @@ describe("signUpUseCase", () => {
|
|||||||
|
|
||||||
const useCase = signUpUseCase(users, auth, bus);
|
const useCase = signUpUseCase(users, auth, bus);
|
||||||
await expect(
|
await expect(
|
||||||
useCase({ username: "eve", password: "secret_password", confirmPassword: "secret_password" }),
|
useCase({
|
||||||
|
username: "eve",
|
||||||
|
password: "secret_password",
|
||||||
|
confirmPassword: "secret_password",
|
||||||
|
}),
|
||||||
).rejects.toBeInstanceOf(AuthenticationError);
|
).rejects.toBeInstanceOf(AuthenticationError);
|
||||||
expect(bus.published).toHaveLength(0);
|
expect(bus.published).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("signUpUseCase output validation (R25)", () => {
|
describe("signUpUseCase output validation", () => {
|
||||||
it("throws when authenticationService returns a malformed session", async () => {
|
it("throws when authenticationService returns a malformed session", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = {
|
const auth = {
|
||||||
@@ -103,7 +114,11 @@ describe("signUpUseCase output validation (R25)", () => {
|
|||||||
const bus = new RecordingEventBus();
|
const bus = new RecordingEventBus();
|
||||||
const useCase = signUpUseCase(users, auth, bus);
|
const useCase = signUpUseCase(users, auth, bus);
|
||||||
await expect(
|
await expect(
|
||||||
useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }),
|
useCase({
|
||||||
|
username: "carol",
|
||||||
|
password: "secret_password",
|
||||||
|
confirmPassword: "secret_password",
|
||||||
|
}),
|
||||||
).rejects.toBeInstanceOf(ZodError);
|
).rejects.toBeInstanceOf(ZodError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ export const signUpUseCase =
|
|||||||
bus: EventBusProtocol | undefined,
|
bus: EventBusProtocol | undefined,
|
||||||
) =>
|
) =>
|
||||||
async (input: SignUpInput): Promise<SignUpOutput> => {
|
async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
const existingUser = await usersRepository.getUserByUsername(
|
||||||
|
input.username,
|
||||||
|
);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new AuthenticationError("Username taken");
|
throw new AuthenticationError("Username taken");
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await authenticationService.hashPassword(input.password);
|
const passwordHash = await authenticationService.hashPassword(
|
||||||
|
input.password,
|
||||||
|
);
|
||||||
const userId = authenticationService.generateUserId();
|
const userId = authenticationService.generateUserId();
|
||||||
|
|
||||||
const newUser = await usersRepository.createUser({
|
const newUser = await usersRepository.createUser({
|
||||||
@@ -53,11 +57,12 @@ export const signUpUseCase =
|
|||||||
passwordHash,
|
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
|
// Auth is username-based — synthesize a deterministic email so the event
|
||||||
// payload validates against userSignedUpEventSchema.email().
|
// 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) {
|
if (bus) {
|
||||||
await bus.publish(userSignedUpEvent, {
|
await bus.publish(userSignedUpEvent, {
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
|||||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
authContainer
|
||||||
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
authContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||||
@@ -61,7 +65,9 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
|||||||
.toConstantValue(repo);
|
.toConstantValue(repo);
|
||||||
|
|
||||||
// Need auth service from container for use cases
|
// 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
|
// Wrap use cases + controllers identically to bind-production
|
||||||
const wrappedSignIn = withSpan(
|
const wrappedSignIn = withSpan(
|
||||||
@@ -102,9 +108,15 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
|||||||
]) {
|
]) {
|
||||||
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||||
}
|
}
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
|
authContainer
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
|
.bind(AUTH_SYMBOLS.ISignInUseCase)
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
|
.toConstantValue(wrappedSignIn);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignUpUseCase)
|
||||||
|
.toConstantValue(wrappedSignUp);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignOutUseCase)
|
||||||
|
.toConstantValue(wrappedSignOut);
|
||||||
|
|
||||||
authContainer
|
authContainer
|
||||||
.bind(AUTH_SYMBOLS.ISignInController)
|
.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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
|||||||
if (bound) return;
|
if (bound) return;
|
||||||
bound = true;
|
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
|
// Bind shared instrumentation into feature container
|
||||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -42,15 +43,21 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
|||||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
authContainer
|
||||||
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
authContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
// Real repositories
|
// Real repositories
|
||||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||||
}
|
}
|
||||||
const repo = new UsersRepository(config, tracer, logger);
|
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)) {
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||||||
authContainer.unbind(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);
|
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||||
}
|
}
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
|
authContainer
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
|
.bind(AUTH_SYMBOLS.ISignInUseCase)
|
||||||
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
|
.toConstantValue(wrappedSignIn);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignUpUseCase)
|
||||||
|
.toConstantValue(wrappedSignUp);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignOutUseCase)
|
||||||
|
.toConstantValue(wrappedSignOut);
|
||||||
|
|
||||||
// Controllers — wrapped with span at bind time
|
// Controllers — wrapped with span at bind time
|
||||||
for (const sym of [
|
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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export {
|
|||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
export { SESSION_COOKIE } from "./config";
|
export { SESSION_COOKIE } from "./config";
|
||||||
|
|
||||||
// Use case schemas + types (Plan 9 R18)
|
// Use case schemas + types
|
||||||
export {
|
export {
|
||||||
signInInputSchema,
|
signInInputSchema,
|
||||||
signInOutputSchema,
|
signInOutputSchema,
|
||||||
@@ -44,7 +44,6 @@ export {
|
|||||||
} from "./events/user-signed-up.event";
|
} from "./events/user-signed-up.event";
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
|
|
||||||
// Feature conformance manifest (added in conformance milestone i, exposed
|
// Feature conformance manifest — declares this feature's use cases, audits,
|
||||||
// here in milestone ii so the boot-time assertion and future tooling can
|
// publishes, and consumes. Read by the boot-time assertion + ESLint rules.
|
||||||
// read the contract from the package boundary).
|
|
||||||
export { authManifest, type AuthManifest } from "./feature.manifest";
|
export { authManifest, type AuthManifest } from "./feature.manifest";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
|
// Mock repo also wraps in spans; easier to assert without booting Payload.
|
||||||
describe("MockUsersRepository emits spans (R42)", () => {
|
describe("MockUsersRepository emits spans", () => {
|
||||||
it("getUser emits one span with op='repository'", async () => {
|
it("getUser emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
@@ -26,13 +29,19 @@ describe("MockUsersRepository emits spans (R42)", () => {
|
|||||||
);
|
);
|
||||||
await repo.getUserByUsername("alice");
|
await repo.getUserByUsername("alice");
|
||||||
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
|
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 () => {
|
it("createUser records created=true", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const repo = new MockUsersRepository([], tracer);
|
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")).toBeDefined();
|
||||||
expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true);
|
expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { User } from "../../entities/models/user";
|
|||||||
// generic session interface without deep integration with Payload's REST/local
|
// generic session interface without deep integration with Payload's REST/local
|
||||||
// API and cookie infrastructure.
|
// 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
|
// cookie strategy is settled. Until then they throw NotImplementedError to
|
||||||
// keep the production-shaped file in place without silently no-oping.
|
// 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 {
|
class NotImplementedError extends Error {
|
||||||
constructor(method: string) {
|
constructor(method: string) {
|
||||||
super(`NotImplemented: AuthenticationService.${method} — see refactor log §7`);
|
super(`NotImplemented: AuthenticationService.${method}`);
|
||||||
this.name = "NotImplementedError";
|
this.name = "NotImplementedError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,10 +42,17 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
async hashPassword(password: string): Promise<string> {
|
async hashPassword(password: string): Promise<string> {
|
||||||
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
|
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
|
||||||
const hash = await new Promise<string>((resolve, reject) => {
|
const hash = await new Promise<string>((resolve, reject) => {
|
||||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
crypto.pbkdf2(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
ITERATIONS,
|
||||||
|
KEY_LENGTH,
|
||||||
|
DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(derivedKey.toString("hex"));
|
else resolve(derivedKey.toString("hex"));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return `${salt}${SEPARATOR}${hash}`;
|
return `${salt}${SEPARATOR}${hash}`;
|
||||||
}
|
}
|
||||||
@@ -56,10 +63,17 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
const salt = parts[0]!;
|
const salt = parts[0]!;
|
||||||
const expectedHash = parts[1]!;
|
const expectedHash = parts[1]!;
|
||||||
const actualHash = await new Promise<string>((resolve, reject) => {
|
const actualHash = await new Promise<string>((resolve, reject) => {
|
||||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
crypto.pbkdf2(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
ITERATIONS,
|
||||||
|
KEY_LENGTH,
|
||||||
|
DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(derivedKey.toString("hex"));
|
else resolve(derivedKey.toString("hex"));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
Buffer.from(expectedHash, "hex"),
|
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
|
// Payload creates sessions via its REST auth endpoint; mapping that to a
|
||||||
// generic { session: Session; cookie: Cookie } shape requires understanding
|
// generic { session: Session; cookie: Cookie } shape requires understanding
|
||||||
// the JWT payload structure and the cookie name/attributes Payload uses.
|
// the JWT payload structure and the cookie name/attributes Payload uses.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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");
|
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.
|
// Need to call Payload's local API to verify the token and retrieve the user.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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");
|
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
|
// Payload does not have a server-side session store by default; invalidation
|
||||||
// is typically done client-side by clearing the cookie.
|
// is typically done client-side by clearing the cookie.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("authRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authRouter (R26 error mapping)", () => {
|
describe("authRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||||
@@ -39,7 +39,9 @@ describe("authRouter (R26 error mapping)", () => {
|
|||||||
}
|
}
|
||||||
const users = new MockUsersRepository();
|
const users = new MockUsersRepository();
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
|
authContainer
|
||||||
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||||
|
.toConstantValue(users);
|
||||||
authContainer
|
authContainer
|
||||||
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
||||||
.toConstantValue(auth);
|
.toConstantValue(auth);
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ export const articlesRepositoryContract =
|
|||||||
|
|
||||||
it("getArticles filters by status", async () => {
|
it("getArticles filters by status", async () => {
|
||||||
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
||||||
await repo.createArticle(
|
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||||
articleFactory.build({ status: "published" }),
|
|
||||||
);
|
|
||||||
const drafts = await repo.getArticles({ status: "draft" });
|
const drafts = await repo.getArticles({ status: "draft" });
|
||||||
expect(drafts).toHaveLength(1);
|
expect(drafts).toHaveLength(1);
|
||||||
expect(drafts[0]?.status).toBe("draft");
|
expect(drafts[0]?.status).toBe("draft");
|
||||||
@@ -117,7 +115,7 @@ export const articlesRepositoryContract =
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getArticles emits articles.getArticles span with op=repository", async () => {
|
it("getArticles emits articles.getArticles span with op=repository", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
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 () => {
|
it("throws when repository returns a malformed article", async () => {
|
||||||
const repo = {
|
const repo = {
|
||||||
createArticle: async () => ({ id: 1 }) as unknown as never,
|
createArticle: async () => ({ id: 1 }) as unknown as never,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import { ArticleNotFoundError } from "@/entities/errors/article";
|
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||||
import { articleFactory } from "@/__factories__/article.factory";
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
@@ -21,11 +24,13 @@ describe("getArticleBySlugUseCase", () => {
|
|||||||
it("throws ArticleNotFoundError when slug is missing", async () => {
|
it("throws ArticleNotFoundError when slug is missing", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
const useCase = getArticleBySlugUseCase(repo);
|
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 () => {
|
it("throws when repository returns a malformed article", async () => {
|
||||||
const repo = {
|
const repo = {
|
||||||
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
|
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import { articleFactory } from "@/__factories__/article.factory";
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
|
||||||
@@ -8,7 +11,9 @@ describe("getArticlesUseCase", () => {
|
|||||||
it("returns all articles with no filters", async () => {
|
it("returns all articles with no filters", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
articleFactory.reset();
|
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 useCase = getArticlesUseCase(repo);
|
||||||
const result = await useCase({});
|
const result = await useCase({});
|
||||||
@@ -19,8 +24,17 @@ describe("getArticlesUseCase", () => {
|
|||||||
it("filters by status", async () => {
|
it("filters by status", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
articleFactory.reset();
|
articleFactory.reset();
|
||||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }));
|
await repo.createArticle(
|
||||||
await repo.createArticle(articleFactory.build({ id: "2", title: "B", slug: "b", status: "published" }));
|
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 useCase = getArticlesUseCase(repo);
|
||||||
const result = await useCase({ status: "published" });
|
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 () => {
|
it("throws when the repository returns a malformed article", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
|
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
|||||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
blogContainer
|
||||||
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
blogContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||||
blogContainer.unbind(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);
|
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||||
}
|
}
|
||||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||||||
|
.toConstantValue(wrappedGetArticles);
|
||||||
blogContainer
|
blogContainer
|
||||||
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
||||||
.toConstantValue(wrappedGetArticleBySlug);
|
.toConstantValue(wrappedGetArticleBySlug);
|
||||||
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
|
||||||
|
.toConstantValue(wrappedCreateArticle);
|
||||||
|
|
||||||
blogContainer
|
blogContainer
|
||||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||||
@@ -120,7 +128,11 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
|||||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
{
|
||||||
|
feature: "blog",
|
||||||
|
layer: "controller",
|
||||||
|
name: "blog.getArticleBySlug",
|
||||||
|
},
|
||||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { getArticleBySlugController } from "../interface-adapters/controllers/ge
|
|||||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||||
|
|
||||||
export function bindProductionBlog(ctx: BindProductionContext): void {
|
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
|
// Bind shared instrumentation into feature container
|
||||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -28,8 +29,12 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
|||||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
blogContainer
|
||||||
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
blogContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
// Real repository
|
// Real repository
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||||
@@ -38,7 +43,7 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
|||||||
const repo = new ArticlesRepository(config, tracer, logger);
|
const repo = new ArticlesRepository(config, tracer, logger);
|
||||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
|
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(
|
const wrappedGetArticles = withSpan(
|
||||||
tracer,
|
tracer,
|
||||||
{ name: "blog.getArticles", op: "use-case" },
|
{ name: "blog.getArticles", op: "use-case" },
|
||||||
@@ -76,11 +81,15 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
|||||||
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleUseCase)) {
|
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleUseCase)) {
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleUseCase);
|
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleUseCase);
|
||||||
}
|
}
|
||||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||||||
|
.toConstantValue(wrappedGetArticles);
|
||||||
blogContainer
|
blogContainer
|
||||||
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
||||||
.toConstantValue(wrappedGetArticleBySlug);
|
.toConstantValue(wrappedGetArticleBySlug);
|
||||||
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
|
||||||
|
.toConstantValue(wrappedCreateArticle);
|
||||||
|
|
||||||
// Controllers — wrapped with span at bind time
|
// Controllers — wrapped with span at bind time
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
|
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
|
||||||
@@ -113,7 +122,11 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
|||||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
{
|
||||||
|
feature: "blog",
|
||||||
|
layer: "controller",
|
||||||
|
name: "blog.getArticleBySlug",
|
||||||
|
},
|
||||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type { BlogRouter } from "./integrations/api/router";
|
|||||||
export { ArticleNotFoundError } from "./entities/errors/article";
|
export { ArticleNotFoundError } from "./entities/errors/article";
|
||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
|
|
||||||
// Use case schemas + types (Plan 9 R18)
|
// Use case schemas + types
|
||||||
export {
|
export {
|
||||||
getArticlesInputSchema,
|
getArticlesInputSchema,
|
||||||
getArticlesOutputSchema,
|
getArticlesOutputSchema,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
|
// Mock repo also wraps in spans; easier to assert without booting Payload.
|
||||||
describe("MockArticlesRepository emits spans (R42)", () => {
|
describe("MockArticlesRepository emits spans", () => {
|
||||||
it("getArticles emits one span with op='repository'", async () => {
|
it("getArticles emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
@@ -31,7 +34,9 @@ describe("MockArticlesRepository emits spans (R42)", () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
expect(tracer.findSpan("articles.createArticle")).toBeDefined();
|
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 () => {
|
it("getArticle records found=false for missing id", async () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("blogRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("blogRouter (R26 error mapping)", () => {
|
describe("blogRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
blogContainer.load(BlogModule);
|
blogContainer.load(BlogModule);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// React Query option builders for blog feature procedures.
|
// 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 = {
|
type TrpcClient = {
|
||||||
blog: {
|
blog: {
|
||||||
@@ -23,7 +23,12 @@ export function articleBySlugQuery(client: TrpcClient, slug: string) {
|
|||||||
|
|
||||||
export function listArticlesQuery(
|
export function listArticlesQuery(
|
||||||
client: TrpcClient,
|
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);
|
return client.blog.listArticles.queryOptions(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// withSpan + withCapture and never double-captures the same error.
|
||||||
//
|
//
|
||||||
// Each layer's withCapture catch checks the __sentryReported flag (set by
|
// 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 { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
|
||||||
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
|
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 () => {
|
it("an error originated in the repo is captured exactly once with repo tags", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
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
|
// Mirror what the real repo does: capture with repo tags, mark the
|
||||||
// flag (RecordingLogger.captureException does this for us now).
|
// flag (RecordingLogger.captureException does this for us now).
|
||||||
logger.captureException(err, {
|
logger.captureException(err, {
|
||||||
tags: { feature: "blog", repo: "articles", method: "getArticleBySlug" },
|
tags: {
|
||||||
|
feature: "blog",
|
||||||
|
repo: "articles",
|
||||||
|
method: "getArticleBySlug",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
throw err;
|
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);
|
expect(logger.captures).toHaveLength(1);
|
||||||
const only = logger.captures[0];
|
const only = logger.captures[0];
|
||||||
expect(only?.kind).toBe("exception");
|
expect(only?.kind).toBe("exception");
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export type BindAuditOpts = {
|
|||||||
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
||||||
* salt that an attacker could reverse.
|
* 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
|
* so all sinks receive AuditEntry.correlationId auto-populated from the
|
||||||
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +34,10 @@ export function bindAudit(
|
|||||||
container: Container,
|
container: Container,
|
||||||
opts: BindAuditOpts = {},
|
opts: BindAuditOpts = {},
|
||||||
): { auditLog: IAuditLog } {
|
): { 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(
|
throw new Error(
|
||||||
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
||||||
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
||||||
@@ -52,8 +55,10 @@ export function bindAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inner: IAuditLog =
|
const inner: IAuditLog =
|
||||||
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
sinks.length > 1
|
||||||
: sinks.length === 1 ? sinks[0]!
|
? new MultiSinkAuditLog(sinks)
|
||||||
|
: sinks.length === 1
|
||||||
|
? sinks[0]!
|
||||||
: new NoopAuditLog();
|
: new NoopAuditLog();
|
||||||
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export type { IAuditLog } from "./audit-log.interface";
|
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 { NoopAuditLog } from "./noop-audit-log";
|
||||||
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||||
export { PayloadAuditLog } from "./payload-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 { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
||||||
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||||
export { AUDIT_SYMBOLS } from "./di/symbols";
|
export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||||
// Phase 3 — GDPR erasure
|
// GDPR erasure
|
||||||
export { pseudonymize } from "./pseudonymize";
|
export { pseudonymize } from "./pseudonymize";
|
||||||
export {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
} from "./hooks/audit-erasure-hook";
|
} from "./hooks/audit-erasure-hook";
|
||||||
// Phase 5 — VIEW capture
|
// VIEW capture
|
||||||
export {
|
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
||||||
createAuditAfterReadHook,
|
|
||||||
type AuditAfterReadHookOpts,
|
|
||||||
} from "./hooks";
|
|
||||||
export {
|
export {
|
||||||
createAuditRouter,
|
createAuditRouter,
|
||||||
auditRouter,
|
auditRouter,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type Audited<F> = F & { readonly __audited: true };
|
|||||||
* tests).
|
* tests).
|
||||||
*/
|
*/
|
||||||
export function withAudit<Args extends unknown[], R>(
|
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:
|
// `audits[]` declarations. For now, the wrapper exists to:
|
||||||
// (1) require callers to pass the auditLog at bind time (dep is available)
|
// (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
|
// (2) attach the `__audited` brand so the boot-time assertion can verify
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type AuditFrom = {
|
|||||||
*/
|
*/
|
||||||
export type AuditEntry = {
|
export type AuditEntry = {
|
||||||
// WHO
|
// 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;
|
actorId: string;
|
||||||
actorType: "user" | "system" | "service";
|
actorType: "user" | "system" | "service";
|
||||||
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
|
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
|||||||
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
|
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
|
||||||
import { buildResource } from "./resource";
|
import { buildResource } from "./resource";
|
||||||
import { createSentryOtelBridge } from "./sentry-bridge";
|
import { createSentryOtelBridge } from "./sentry-bridge";
|
||||||
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
|
import {
|
||||||
|
PiiScrubSpanProcessor,
|
||||||
|
PiiScrubLogRecordProcessor,
|
||||||
|
} from "./pii-scrub-processor";
|
||||||
|
|
||||||
const { BatchSpanProcessor } = tracing;
|
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
|
// `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
|
// 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;
|
// 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.
|
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
|
||||||
new BatchSpanProcessor(bridge.spanProcessor as never),
|
new BatchSpanProcessor(bridge.spanProcessor as never),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
|
// 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+)
|
// 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
|
// are listed here so they are key-redacted in addition to the value-level regex
|
||||||
// scrubbing in pii-scrub-processor.ts.
|
// scrubbing in pii-scrub-processor.ts.
|
||||||
@@ -28,7 +28,7 @@ export const PII_KEY_SUBSTRINGS = [
|
|||||||
"host.ip",
|
"host.ip",
|
||||||
] as const;
|
] 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 = [
|
export const PII_QUERY_PARAM_SUBSTRINGS = [
|
||||||
"token",
|
"token",
|
||||||
"email",
|
"email",
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import {
|
|||||||
SimpleLogRecordProcessor,
|
SimpleLogRecordProcessor,
|
||||||
} from "@opentelemetry/sdk-logs";
|
} from "@opentelemetry/sdk-logs";
|
||||||
import { SeverityNumber } from "@opentelemetry/api-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 spanExporter = new InMemorySpanExporter();
|
||||||
const tracerProvider = new BasicTracerProvider({
|
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.
|
// Use addLogRecordProcessor to chain processors in the right order.
|
||||||
@@ -43,7 +49,7 @@ describe("PiiScrubSpanProcessor", () => {
|
|||||||
const exported = spanExporter.getFinishedSpans();
|
const exported = spanExporter.getFinishedSpans();
|
||||||
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
|
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
|
||||||
expect(exported[0]!.attributes["auth.token"]).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");
|
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +125,7 @@ describe("PiiScrubLogRecordProcessor", () => {
|
|||||||
expect(records[0]!.body).toBe("user signed in successfully");
|
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");
|
const logger = logProvider.getLogger("test");
|
||||||
logger.emit({
|
logger.emit({
|
||||||
severityNumber: SeverityNumber.INFO,
|
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", () => {
|
it("scrubs IPv4 addresses in attribute values", () => {
|
||||||
const tracer = tracerProvider.getTracer("test");
|
const tracer = tracerProvider.getTracer("test");
|
||||||
const span = tracer.startSpan("test-span", {
|
const span = tracer.startSpan("test-span", {
|
||||||
@@ -140,7 +146,9 @@ describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
|
|||||||
});
|
});
|
||||||
span.end();
|
span.end();
|
||||||
const exported = spanExporter.getFinishedSpans();
|
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", () => {
|
it("scrubs IPv6 addresses in attribute values", () => {
|
||||||
|
|||||||
@@ -3,14 +3,23 @@
|
|||||||
// PII scrub processors for OTel spans and log records.
|
// PII scrub processors for OTel spans and log records.
|
||||||
// These run FIRST in their respective processor chains so downstream exporters
|
// These run FIRST in their respective processor chains so downstream exporters
|
||||||
// (including the Sentry exporter) see scrubbed data. This replaces the old
|
// (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.
|
// 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 { Span } from "@opentelemetry/api";
|
||||||
import type { Context } from "@opentelemetry/api";
|
import type { Context } from "@opentelemetry/api";
|
||||||
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
|
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 {
|
function isPiiKey(key: string): boolean {
|
||||||
const lower = key.toLowerCase();
|
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
|
* 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
|
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
|
||||||
* replicate it here so IP addresses embedded in non-IP-keyed attributes
|
* 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 {
|
function scrubValue(value: unknown): unknown {
|
||||||
if (typeof value !== "string") return value;
|
if (typeof value !== "string") return value;
|
||||||
@@ -39,7 +48,9 @@ function scrubValue(value: unknown): unknown {
|
|||||||
return scrubbed;
|
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> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
if (isPiiKey(key)) {
|
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.
|
* 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).
|
* 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 {
|
export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||||
forceFlush(): Promise<void> {
|
forceFlush(): Promise<void> {
|
||||||
@@ -70,7 +81,9 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onEnd(span: ReadableSpan): void {
|
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);
|
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 attributes (key-based substring match, case-insensitive).
|
||||||
* - Strips PII from the log body string (substring match — if any PII substring
|
* - 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).
|
* 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 {
|
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||||
forceFlush(): Promise<void> {
|
forceFlush(): Promise<void> {
|
||||||
@@ -93,7 +106,9 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
|||||||
|
|
||||||
onEmit(record: LogRecord): void {
|
onEmit(record: LogRecord): void {
|
||||||
if (record.attributes) {
|
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);
|
Object.assign(record.attributes, scrubbed);
|
||||||
}
|
}
|
||||||
if (typeof record.body === "string") {
|
if (typeof record.body === "string") {
|
||||||
@@ -103,7 +118,7 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
|||||||
record.body = REDACTED_VALUE;
|
record.body = REDACTED_VALUE;
|
||||||
} else {
|
} else {
|
||||||
// No PII keyword, but may still contain IP addresses embedded in text.
|
// 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;
|
record.body = scrubValue(record.body) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("createSentryOtelBridge", () => {
|
|||||||
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
||||||
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
||||||
expect(bridge.spanProcessor).toBeDefined();
|
expect(bridge.spanProcessor).toBeDefined();
|
||||||
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
|
// logRecordProcessor is wired (SentryLogRecordForwarder)
|
||||||
expect(bridge.logRecordProcessor).toBeDefined();
|
expect(bridge.logRecordProcessor).toBeDefined();
|
||||||
expect(bridge.logRecordProcessor).not.toBeNull();
|
expect(bridge.logRecordProcessor).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -50,7 +50,10 @@ describe("SentryLogRecordForwarder", () => {
|
|||||||
|
|
||||||
const captureException = vi.fn();
|
const captureException = vi.fn();
|
||||||
const captureMessage = vi.fn();
|
const captureMessage = vi.fn();
|
||||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
const forwarder = new SentryLogRecordForwarder({
|
||||||
|
captureException,
|
||||||
|
captureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
severityNumber: SEVERITY_ERROR,
|
severityNumber: SEVERITY_ERROR,
|
||||||
@@ -84,7 +87,10 @@ describe("SentryLogRecordForwarder", () => {
|
|||||||
|
|
||||||
const captureException = vi.fn();
|
const captureException = vi.fn();
|
||||||
const captureMessage = vi.fn();
|
const captureMessage = vi.fn();
|
||||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
const forwarder = new SentryLogRecordForwarder({
|
||||||
|
captureException,
|
||||||
|
captureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
severityNumber: SEVERITY_ERROR,
|
severityNumber: SEVERITY_ERROR,
|
||||||
@@ -110,7 +116,10 @@ describe("SentryLogRecordForwarder", () => {
|
|||||||
|
|
||||||
const captureException = vi.fn();
|
const captureException = vi.fn();
|
||||||
const captureMessage = vi.fn();
|
const captureMessage = vi.fn();
|
||||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
const forwarder = new SentryLogRecordForwarder({
|
||||||
|
captureException,
|
||||||
|
captureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
severityNumber,
|
severityNumber,
|
||||||
|
|||||||
@@ -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 { SeverityNumber } from "@opentelemetry/api-logs";
|
||||||
import type { LogRecord } from "@opentelemetry/sdk-logs";
|
import type { LogRecord } from "@opentelemetry/sdk-logs";
|
||||||
|
|
||||||
@@ -64,7 +67,8 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
|||||||
if (severityNumber >= SeverityNumber.ERROR) {
|
if (severityNumber >= SeverityNumber.ERROR) {
|
||||||
// Reconstruct the error from OTel semantic convention attributes
|
// Reconstruct the error from OTel semantic convention attributes
|
||||||
const message =
|
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);
|
const err = new Error(message);
|
||||||
if (attrs["exception.type"]) {
|
if (attrs["exception.type"]) {
|
||||||
err.name = attrs["exception.type"] as string;
|
err.name = attrs["exception.type"] as string;
|
||||||
@@ -77,7 +81,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
|||||||
? (attrs["sentry.fingerprint"] as string).split("|")
|
? (attrs["sentry.fingerprint"] as string).split("|")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
Sentry.captureException(err, { tags, extra, ...(fingerprint ? { fingerprint } : {}) });
|
Sentry.captureException(err, {
|
||||||
|
tags,
|
||||||
|
extra,
|
||||||
|
...(fingerprint ? { fingerprint } : {}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Map severity to Sentry level
|
// Map severity to Sentry level
|
||||||
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
|
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
|
* 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
|
* forward spans and log records to Sentry. This is the ONLY file in
|
||||||
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
|
* 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) {
|
if (!opts.dsn) {
|
||||||
return { spanProcessor: null, logRecordProcessor: null };
|
return { spanProcessor: null, logRecordProcessor: null };
|
||||||
}
|
}
|
||||||
@@ -125,7 +135,9 @@ function extractTags(attrs: Record<string, unknown>): Record<string, string> {
|
|||||||
return tags;
|
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> = {};
|
const extras: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(attrs)) {
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
if (k.startsWith("extra.")) {
|
if (k.startsWith("extra.")) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const { replayIntegration } = vi.hoisted(() => {
|
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 };
|
return { replayIntegration };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,43 +22,35 @@ describe("initSentryClientReact", () => {
|
|||||||
vi.clearAllMocks();
|
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" });
|
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(call["sendDefaultPii"]).toBe(false);
|
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" });
|
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(replayOpts["maskAllText"]).toBe(true);
|
expect(replayOpts["maskAllText"]).toBe(true);
|
||||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||||
expect(replayOpts["blockAllMedia"]).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" });
|
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(typeof call["beforeSend"]).toBe("function");
|
expect(typeof call["beforeSend"]).toBe("function");
|
||||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
IPV6_REGEX,
|
IPV6_REGEX,
|
||||||
} from "../otel/pii-fields";
|
} 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 {
|
function keyContainsPii(key: string): boolean {
|
||||||
const lower = key.toLowerCase();
|
const lower = key.toLowerCase();
|
||||||
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
||||||
@@ -33,7 +33,9 @@ function redactString(s: string): string {
|
|||||||
function deepScrub(value: unknown, parentKey = ""): unknown {
|
function deepScrub(value: unknown, parentKey = ""): unknown {
|
||||||
if (value === null || value === undefined) return value;
|
if (value === null || value === undefined) return value;
|
||||||
if (typeof value === "string") {
|
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") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
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).
|
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
|
||||||
* Mirrors init-client.ts but uses @sentry/react directly. R31, R32, R33,
|
* Mirrors init-client.ts but uses @sentry/react directly. Same PII,
|
||||||
* R34, R35, R37 still apply.
|
* replay, and scrubbing requirements apply.
|
||||||
*/
|
*/
|
||||||
export function initSentryClientReact(opts: InitClientOpts): void {
|
export function initSentryClientReact(opts: InitClientOpts): void {
|
||||||
if (!opts.dsn) return;
|
if (!opts.dsn) return;
|
||||||
@@ -78,31 +80,48 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
|||||||
: 1.0;
|
: 1.0;
|
||||||
|
|
||||||
const environment =
|
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";
|
const release = opts.release ?? "unknown";
|
||||||
|
|
||||||
type InitOpts = Parameters<typeof SentryReact.init>[0];
|
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({
|
SentryReact.init({
|
||||||
dsn: opts.dsn,
|
dsn: opts.dsn,
|
||||||
environment,
|
environment,
|
||||||
release,
|
release,
|
||||||
tracesSampleRate,
|
tracesSampleRate,
|
||||||
sendDefaultPii: false, // R31
|
sendDefaultPii: false,
|
||||||
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"], // R32
|
beforeSend: ((event: SentryEvent) =>
|
||||||
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
|
||||||
|
beforeSendTransaction: ((event: SentryEvent) => {
|
||||||
const out = { ...event };
|
const out = { ...event };
|
||||||
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
if (out.request?.url)
|
||||||
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
|
out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||||
|
if (
|
||||||
|
out.transaction &&
|
||||||
|
(out.transaction.includes("?") || out.transaction.includes("="))
|
||||||
|
) {
|
||||||
out.transaction = scrubUrl(out.transaction);
|
out.transaction = scrubUrl(out.transaction);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
|
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
|
||||||
replaysSessionSampleRate: 0.0, // R37
|
replaysSessionSampleRate: 0.0,
|
||||||
replaysOnErrorSampleRate: 1.0, // R37
|
replaysOnErrorSampleRate: 1.0,
|
||||||
integrations: [
|
integrations: [
|
||||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
// mandatory mask flags; allowlist starts empty
|
||||||
SentryReact.replayIntegration({
|
SentryReact.replayIntegration({
|
||||||
maskAllText: true,
|
maskAllText: true,
|
||||||
maskAllInputs: true,
|
maskAllInputs: true,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const { replayIntegration } = vi.hoisted(() => {
|
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 };
|
return { replayIntegration };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,51 +22,41 @@ describe("initSentryClient", () => {
|
|||||||
vi.clearAllMocks();
|
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" });
|
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(call["sendDefaultPii"]).toBe(false);
|
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" });
|
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(replayOpts["maskAllText"]).toBe(true);
|
expect(replayOpts["maskAllText"]).toBe(true);
|
||||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||||
expect(replayOpts["blockAllMedia"]).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" });
|
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
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" });
|
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches beforeSend + beforeSendTransaction", () => {
|
it("attaches beforeSend + beforeSendTransaction", () => {
|
||||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||||
string,
|
.calls[0]![0] as Record<string, unknown>;
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(typeof call["beforeSend"]).toBe("function");
|
expect(typeof call["beforeSend"]).toBe("function");
|
||||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type InitClientOpts = {
|
|||||||
release?: string;
|
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 {
|
function keyContainsPii(key: string): boolean {
|
||||||
const lower = key.toLowerCase();
|
const lower = key.toLowerCase();
|
||||||
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
||||||
@@ -38,7 +38,9 @@ function redactString(s: string): string {
|
|||||||
function deepScrub(value: unknown, parentKey = ""): unknown {
|
function deepScrub(value: unknown, parentKey = ""): unknown {
|
||||||
if (value === null || value === undefined) return value;
|
if (value === null || value === undefined) return value;
|
||||||
if (typeof value === "string") {
|
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") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
||||||
@@ -78,31 +80,48 @@ export function initSentryClient(opts: InitClientOpts): void {
|
|||||||
: 1.0;
|
: 1.0;
|
||||||
|
|
||||||
const environment =
|
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";
|
const release = opts.release ?? "unknown";
|
||||||
|
|
||||||
type InitOpts = Parameters<typeof Sentry.init>[0];
|
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({
|
Sentry.init({
|
||||||
dsn: opts.dsn,
|
dsn: opts.dsn,
|
||||||
environment,
|
environment,
|
||||||
release,
|
release,
|
||||||
tracesSampleRate,
|
tracesSampleRate,
|
||||||
sendDefaultPii: false, // R31
|
sendDefaultPii: false,
|
||||||
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as InitOpts["beforeSend"], // R32
|
beforeSend: ((event: SentryEvent) =>
|
||||||
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
deepScrub(event)) as unknown as InitOpts["beforeSend"],
|
||||||
|
beforeSendTransaction: ((event: SentryEvent) => {
|
||||||
const out = { ...event };
|
const out = { ...event };
|
||||||
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
if (out.request?.url)
|
||||||
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
|
out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||||
|
if (
|
||||||
|
out.transaction &&
|
||||||
|
(out.transaction.includes("?") || out.transaction.includes("="))
|
||||||
|
) {
|
||||||
out.transaction = scrubUrl(out.transaction);
|
out.transaction = scrubUrl(out.transaction);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}) as unknown as InitOpts["beforeSendTransaction"],
|
}) as unknown as InitOpts["beforeSendTransaction"],
|
||||||
replaysSessionSampleRate: 0.0, // R37 — privacy default
|
replaysSessionSampleRate: 0.0, // privacy default
|
||||||
replaysOnErrorSampleRate: 1.0, // R37
|
replaysOnErrorSampleRate: 1.0,
|
||||||
integrations: [
|
integrations: [
|
||||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
// mandatory mask flags; allowlist starts empty
|
||||||
Sentry.replayIntegration({
|
Sentry.replayIntegration({
|
||||||
maskAllText: true,
|
maskAllText: true,
|
||||||
maskAllInputs: true,
|
maskAllInputs: true,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ interface Adder {
|
|||||||
add(a: number, b: number): number;
|
add(a: number, b: number): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) => {
|
const adderContract = defineContractSuite<Adder>(
|
||||||
|
"Adder",
|
||||||
|
({ buildSubject }) => {
|
||||||
it("adds two positive numbers", async () => {
|
it("adds two positive numbers", async () => {
|
||||||
const subject = await buildSubject();
|
const subject = await buildSubject();
|
||||||
expect(subject.add(2, 3)).toBe(5);
|
expect(subject.add(2, 3)).toBe(5);
|
||||||
@@ -15,7 +17,8 @@ const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) =>
|
|||||||
const subject = await buildSubject();
|
const subject = await buildSubject();
|
||||||
expect(subject.add(0, 0)).toBe(0);
|
expect(subject.add(0, 0)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
class RealAdder implements Adder {
|
class RealAdder implements Adder {
|
||||||
add(a: number, b: number) {
|
add(a: number, b: number) {
|
||||||
@@ -27,17 +30,20 @@ describe("RealAdder satisfies Adder contract", () => {
|
|||||||
adderContract.run(() => new RealAdder());
|
adderContract.run(() => new RealAdder());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("defineContractSuite — getTracer plumbing (R50)", () => {
|
describe("defineContractSuite — getTracer plumbing", () => {
|
||||||
it("passes the tracer accessor into the suite", () => {
|
it("passes the tracer accessor into the suite", () => {
|
||||||
let receivedTracer: RecordingTracer | undefined;
|
let receivedTracer: RecordingTracer | undefined;
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => {
|
const suite = defineContractSuite<{ foo: string }>(
|
||||||
|
"Test",
|
||||||
|
({ buildSubject, getTracer }) => {
|
||||||
it("can read tracer", async () => {
|
it("can read tracer", async () => {
|
||||||
const subject = await buildSubject();
|
const subject = await buildSubject();
|
||||||
expect(subject.foo).toBe("bar");
|
expect(subject.foo).toBe("bar");
|
||||||
receivedTracer = getTracer?.();
|
receivedTracer = getTracer?.();
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
|
suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
|
||||||
// Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after.
|
// 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.)
|
// (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)", () => {
|
it("getTracer is undefined when opts.tracer not provided (backward compat)", () => {
|
||||||
let receivedAccessor: unknown = undefined;
|
let receivedAccessor: unknown = undefined;
|
||||||
const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => {
|
const suite = defineContractSuite<{ x: number }>(
|
||||||
|
"Test",
|
||||||
|
({ buildSubject, getTracer }) => {
|
||||||
it("accessor undefined", async () => {
|
it("accessor undefined", async () => {
|
||||||
await buildSubject();
|
await buildSubject();
|
||||||
receivedAccessor = getTracer;
|
receivedAccessor = getTracer;
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
suite.run(() => ({ x: 1 }));
|
suite.run(() => ({ x: 1 }));
|
||||||
// No tracer opts → accessor is undefined inside the suite body.
|
// No tracer opts → accessor is undefined inside the suite body.
|
||||||
// (Exact assertion happens via type, not runtime — typecheck gates this.)
|
// (Exact assertion happens via type, not runtime — typecheck gates this.)
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ export const CONTRACT_PAGES_SEED: Page[] = [
|
|||||||
* must return a repo pre-loaded with CONTRACT_PAGES_SEED (two pages:
|
* must return a repo pre-loaded with CONTRACT_PAGES_SEED (two pages:
|
||||||
* one published with slug "about", one draft with slug "draft-page").
|
* one published with slug "about", one draft with slug "draft-page").
|
||||||
*/
|
*/
|
||||||
export const pagesRepositoryContract =
|
export const pagesRepositoryContract = defineContractSuite<IPagesRepository>(
|
||||||
defineContractSuite<IPagesRepository>(
|
|
||||||
"IPagesRepository",
|
"IPagesRepository",
|
||||||
({ buildSubject, getTracer }) => {
|
({ buildSubject, getTracer }) => {
|
||||||
let repo: IPagesRepository;
|
let repo: IPagesRepository;
|
||||||
@@ -96,7 +95,7 @@ export const pagesRepositoryContract =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
|
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
@@ -120,4 +119,4 @@ export const pagesRepositoryContract =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const siteSettingsRepositoryContract =
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getSiteSettings emits site-settings.getSiteSettings span with op=repository", async () => {
|
it("getSiteSettings emits site-settings.getSiteSettings span with op=repository", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("getPageBySlugUseCase", () => {
|
|||||||
expect(result).toBeUndefined();
|
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([
|
const repo = new MockPagesRepository([
|
||||||
{
|
{
|
||||||
id: "p-bad",
|
id: "p-bad",
|
||||||
@@ -34,6 +34,8 @@ describe("getPageBySlugUseCase", () => {
|
|||||||
} as never,
|
} as never,
|
||||||
]);
|
]);
|
||||||
const useCase = getPageBySlugUseCase(repo);
|
const useCase = getPageBySlugUseCase(repo);
|
||||||
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(ZodError);
|
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(
|
||||||
|
ZodError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("getSiteSettingsUseCase", () => {
|
|||||||
expect(result.siteName).toBe("My App");
|
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 = {
|
const malformedRepo = {
|
||||||
getSiteSettings: async () => ({ siteName: "" }),
|
getSiteSettings: async () => ({ siteName: "" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type { MarketingPagesRouter } from "./integrations/api/router";
|
|||||||
export { PageNotFoundError } from "./entities/errors/page";
|
export { PageNotFoundError } from "./entities/errors/page";
|
||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
|
|
||||||
// Use case schemas + types (Plan 9 R18)
|
// Use case schemas + types
|
||||||
export {
|
export {
|
||||||
getPageBySlugInputSchema,
|
getPageBySlugInputSchema,
|
||||||
getPageBySlugOutputSchema,
|
getPageBySlugOutputSchema,
|
||||||
@@ -26,4 +26,7 @@ export type { IGetSiteSettingsController } from "./interface-adapters/controller
|
|||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";
|
export {
|
||||||
|
marketingPagesManifest,
|
||||||
|
type MarketingPagesManifest,
|
||||||
|
} from "./feature.manifest";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42).
|
// Mock repo also wraps in spans.
|
||||||
describe("MockPagesRepository emits spans (R42)", () => {
|
describe("MockPagesRepository emits spans", () => {
|
||||||
it("getPageBySlug emits one span with op='repository'", async () => {
|
it("getPageBySlug emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
@@ -24,6 +27,8 @@ describe("MockPagesRepository emits spans (R42)", () => {
|
|||||||
await repo.getPages({ limit: 10 });
|
await repo.getPages({ limit: 10 });
|
||||||
expect(tracer.findSpan("pages.getPages")).toBeDefined();
|
expect(tracer.findSpan("pages.getPages")).toBeDefined();
|
||||||
expect(tracer.findSpan("pages.getPages")!.attributes.limit).toBe(10);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/site-settings.repository.mock";
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42).
|
// Mock repo also wraps in spans.
|
||||||
describe("MockSiteSettingsRepository emits spans (R42)", () => {
|
describe("MockSiteSettingsRepository emits spans", () => {
|
||||||
it("getSiteSettings emits one span with op='repository'", async () => {
|
it("getSiteSettings emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("marketingPagesRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("marketingPagesRouter (R26 error mapping)", () => {
|
describe("marketingPagesRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
marketingPagesContainer.unbindAll();
|
marketingPagesContainer.unbindAll();
|
||||||
marketingPagesContainer.load(MarketingPagesModule);
|
marketingPagesContainer.load(MarketingPagesModule);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { defineContractSuite } from "@repo/core-testing/contract";
|
|||||||
import type { IMediaRepository } from "../application/repositories/media.repository.interface.js";
|
import type { IMediaRepository } from "../application/repositories/media.repository.interface.js";
|
||||||
import { mediaFactory } from "../__factories__/media.factory.js";
|
import { mediaFactory } from "../__factories__/media.factory.js";
|
||||||
|
|
||||||
export const mediaRepositoryContract =
|
export const mediaRepositoryContract = defineContractSuite<IMediaRepository>(
|
||||||
defineContractSuite<IMediaRepository>(
|
|
||||||
"IMediaRepository",
|
"IMediaRepository",
|
||||||
({ buildSubject, getTracer }) => {
|
({ buildSubject, getTracer }) => {
|
||||||
let repo: IMediaRepository;
|
let repo: IMediaRepository;
|
||||||
@@ -58,7 +57,7 @@ export const mediaRepositoryContract =
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getMedia emits media.getMedia span with id attribute", async () => {
|
it("getMedia emits media.getMedia span with id attribute", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
@@ -93,4 +92,4 @@ export const mediaRepositoryContract =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||||
import { MediaNotFoundError } from "@/entities/errors/media";
|
import { MediaNotFoundError } from "@/entities/errors/media";
|
||||||
import { mediaFactory } from "@/__factories__/media.factory";
|
import { mediaFactory } from "@/__factories__/media.factory";
|
||||||
@@ -22,15 +25,17 @@ describe("getMediaUseCase", () => {
|
|||||||
const repo = new MockMediaRepository();
|
const repo = new MockMediaRepository();
|
||||||
const useCase = getMediaUseCase(repo);
|
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" });
|
const validMedia = mediaFactory.build({ id: "m-r25" });
|
||||||
expect(() => getMediaOutputSchema.parse(validMedia)).not.toThrow();
|
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();
|
const repo = new MockMediaRepository();
|
||||||
// Store a malformed object (missing required fields) via type cast
|
// Store a malformed object (missing required fields) via type cast
|
||||||
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ZodError } from "zod";
|
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 { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||||
import { mediaFactory } from "@/__factories__/media.factory";
|
import { mediaFactory } from "@/__factories__/media.factory";
|
||||||
|
|
||||||
@@ -33,12 +36,12 @@ describe("listMediaUseCase", () => {
|
|||||||
expect(result).toHaveLength(2);
|
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()];
|
const items = [mediaFactory.build(), mediaFactory.build()];
|
||||||
expect(() => listMediaOutputSchema.parse(items)).not.toThrow();
|
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();
|
const repo = new MockMediaRepository();
|
||||||
// Store a malformed object (missing required fields)
|
// Store a malformed object (missing required fields)
|
||||||
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
|
|||||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
mediaContainer
|
||||||
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
mediaContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
||||||
mediaContainer.unbind(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);
|
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
|
||||||
}
|
}
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
|
mediaContainer
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
|
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
|
.toConstantValue(wrappedGetMedia);
|
||||||
|
mediaContainer
|
||||||
|
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
|
||||||
|
.toConstantValue(wrappedListMedia);
|
||||||
|
mediaContainer
|
||||||
|
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
|
||||||
|
.toConstantValue(wrappedDeleteMedia);
|
||||||
|
|
||||||
mediaContainer
|
mediaContainer
|
||||||
.bind(MEDIA_SYMBOLS.IGetMediaController)
|
.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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { listMediaController } from "../interface-adapters/controllers/list-medi
|
|||||||
import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller";
|
import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller";
|
||||||
|
|
||||||
export function bindProductionMedia(ctx: BindProductionContext): void {
|
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
|
// Bind shared instrumentation into feature container
|
||||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -28,17 +29,19 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
|
|||||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
mediaContainer
|
||||||
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
mediaContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
// Real repository
|
// Real repository
|
||||||
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
||||||
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
|
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
|
||||||
}
|
}
|
||||||
const repo = new MediaRepository(config, tracer, logger);
|
const repo = new MediaRepository(config, tracer, logger);
|
||||||
mediaContainer
|
mediaContainer.bind(MEDIA_SYMBOLS.IMediaRepository).toConstantValue(repo);
|
||||||
.bind(MEDIA_SYMBOLS.IMediaRepository)
|
|
||||||
.toConstantValue(repo);
|
|
||||||
|
|
||||||
// Use cases — wrapped with span + capture at bind time
|
// Use cases — wrapped with span + capture at bind time
|
||||||
const wrappedGetMedia = withSpan(
|
const wrappedGetMedia = withSpan(
|
||||||
@@ -76,9 +79,15 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
|
|||||||
]) {
|
]) {
|
||||||
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
|
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
|
||||||
}
|
}
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
|
mediaContainer
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
|
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
|
||||||
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
|
.toConstantValue(wrappedGetMedia);
|
||||||
|
mediaContainer
|
||||||
|
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
|
||||||
|
.toConstantValue(wrappedListMedia);
|
||||||
|
mediaContainer
|
||||||
|
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
|
||||||
|
.toConstantValue(wrappedDeleteMedia);
|
||||||
|
|
||||||
// Controllers — wrapped with span at bind time
|
// Controllers — wrapped with span at bind time
|
||||||
for (const sym of [
|
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
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { MediaNotFoundError } from "./entities/errors/media";
|
|||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
export type { MediaRouter } from "./integrations/api/router";
|
export type { MediaRouter } from "./integrations/api/router";
|
||||||
|
|
||||||
// Use case schemas + types (Plan 9 R18)
|
// Use case schemas + types
|
||||||
export {
|
export {
|
||||||
getMediaInputSchema,
|
getMediaInputSchema,
|
||||||
getMediaOutputSchema,
|
getMediaOutputSchema,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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 { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||||
import type { Media } from "@/entities/models/media";
|
import type { Media } from "@/entities/models/media";
|
||||||
|
|
||||||
@@ -12,8 +15,8 @@ const SAMPLE_MEDIA: Media = {
|
|||||||
filesize: 1024,
|
filesize: 1024,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42).
|
// Mock repo also wraps in spans.
|
||||||
describe("MockMediaRepository emits spans (R42)", () => {
|
describe("MockMediaRepository emits spans", () => {
|
||||||
it("getMedia emits one span with op='repository' and found attribute", async () => {
|
it("getMedia emits one span with op='repository' and found attribute", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("mediaRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mediaRouter (R26 error mapping)", () => {
|
describe("mediaRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mediaContainer.unbindAll();
|
mediaContainer.unbindAll();
|
||||||
mediaContainer.load(MediaModule);
|
mediaContainer.load(MediaModule);
|
||||||
@@ -123,7 +123,9 @@ describe("mediaRouter (R26 error mapping)", () => {
|
|||||||
mediaContainer
|
mediaContainer
|
||||||
.bind(MEDIA_SYMBOLS.IDeleteMediaController)
|
.bind(MEDIA_SYMBOLS.IDeleteMediaController)
|
||||||
.toDynamicValue((ctx) =>
|
.toDynamicValue((ctx) =>
|
||||||
deleteMediaController(ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase)),
|
deleteMediaController(
|
||||||
|
ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const caller = mediaRouter.createCaller({});
|
const caller = mediaRouter.createCaller({});
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export const CONTRACT_HEADER_SEED: Header = {
|
|||||||
* Header is a singleton (Payload Global). The interface exposes only
|
* Header is a singleton (Payload Global). The interface exposes only
|
||||||
* getHeader(). The contract verifies the shape, count, and order of items.
|
* getHeader(). The contract verifies the shape, count, and order of items.
|
||||||
*/
|
*/
|
||||||
export const headerRepositoryContract =
|
export const headerRepositoryContract = defineContractSuite<IHeaderRepository>(
|
||||||
defineContractSuite<IHeaderRepository>(
|
|
||||||
"IHeaderRepository",
|
"IHeaderRepository",
|
||||||
({ buildSubject, getTracer }) => {
|
({ buildSubject, getTracer }) => {
|
||||||
let repo: IHeaderRepository;
|
let repo: IHeaderRepository;
|
||||||
@@ -72,7 +71,7 @@ export const headerRepositoryContract =
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("span emission (R50)", () => {
|
describe("span emission", () => {
|
||||||
it("getHeader emits header.getHeader span with op=repository", async () => {
|
it("getHeader emits header.getHeader span with op=repository", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
const tracer = getTracer();
|
const tracer = getTracer();
|
||||||
@@ -84,4 +83,4 @@ export const headerRepositoryContract =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe("getHeaderUseCase", () => {
|
|||||||
expect(result.items[0]?.label).toBe("Home");
|
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 = {
|
const malformedRepo = {
|
||||||
getHeader: async () =>
|
getHeader: async () =>
|
||||||
({ items: [{ label: "", href: "/", external: false }] }) as never,
|
({ items: [{ label: "", href: "/", external: false }] }) as never,
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
|||||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
navigationContainer
|
||||||
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
navigationContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||||
navigationContainer.unbind(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" },
|
{ name: "navigation.getHeader", op: "use-case" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
|
{
|
||||||
|
feature: "navigation",
|
||||||
|
layer: "use-case",
|
||||||
|
name: "navigation.getHeader",
|
||||||
|
},
|
||||||
getHeaderUseCase(repo),
|
getHeaderUseCase(repo),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -77,13 +85,16 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
|||||||
{ name: "navigation.getHeader", op: "controller" },
|
{ name: "navigation.getHeader", op: "controller" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
|
{
|
||||||
|
feature: "navigation",
|
||||||
|
layer: "controller",
|
||||||
|
name: "navigation.getHeader",
|
||||||
|
},
|
||||||
getHeaderController(wrappedGetHeader),
|
getHeaderController(wrappedGetHeader),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
|||||||
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
||||||
|
|
||||||
export function bindProductionNavigation(ctx: BindProductionContext): void {
|
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
|
// Bind shared instrumentation into feature container
|
||||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -24,8 +25,12 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
|||||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||||
}
|
}
|
||||||
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
navigationContainer
|
||||||
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||||
|
.toConstantValue(tracer);
|
||||||
|
navigationContainer
|
||||||
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
|
.toConstantValue(logger);
|
||||||
|
|
||||||
// Real repository
|
// Real repository
|
||||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||||
@@ -42,7 +47,11 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
|||||||
{ name: "navigation.getHeader", op: "use-case" },
|
{ name: "navigation.getHeader", op: "use-case" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
|
{
|
||||||
|
feature: "navigation",
|
||||||
|
layer: "use-case",
|
||||||
|
name: "navigation.getHeader",
|
||||||
|
},
|
||||||
getHeaderUseCase(repo),
|
getHeaderUseCase(repo),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -66,13 +75,16 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
|||||||
{ name: "navigation.getHeader", op: "controller" },
|
{ name: "navigation.getHeader", op: "controller" },
|
||||||
withCapture(
|
withCapture(
|
||||||
logger,
|
logger,
|
||||||
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
|
{
|
||||||
|
feature: "navigation",
|
||||||
|
layer: "controller",
|
||||||
|
name: "navigation.getHeader",
|
||||||
|
},
|
||||||
getHeaderController(wrappedGetHeader),
|
getHeaderController(wrappedGetHeader),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
void realtime;
|
void realtime;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type { NavigationRouter } from "./integrations/api/router";
|
|||||||
export { HeaderNotFoundError } from "./entities/errors/header";
|
export { HeaderNotFoundError } from "./entities/errors/header";
|
||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
|
|
||||||
// Use case schemas + types (Plan 9 R18)
|
// Use case schemas + types
|
||||||
export {
|
export {
|
||||||
getHeaderInputSchema,
|
getHeaderInputSchema,
|
||||||
getHeaderOutputSchema,
|
getHeaderOutputSchema,
|
||||||
@@ -17,4 +17,7 @@ export type { IGetHeaderController } from "./interface-adapters/controllers/get-
|
|||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
export { navigationManifest, type NavigationManifest } from "./feature.manifest";
|
export {
|
||||||
|
navigationManifest,
|
||||||
|
type NavigationManifest,
|
||||||
|
} from "./feature.manifest";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||||
|
|
||||||
// Mock repo also wraps in spans (R42).
|
// Mock repo also wraps in spans.
|
||||||
describe("MockHeaderRepository emits spans (R42)", () => {
|
describe("MockHeaderRepository emits spans", () => {
|
||||||
it("getHeader emits one span with op='repository'", async () => {
|
it("getHeader emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const logger = new RecordingLogger();
|
const logger = new RecordingLogger();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("navigationRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("navigationRouter (R26 error mapping)", () => {
|
describe("navigationRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
navigationContainer.unbindAll();
|
navigationContainer.unbindAll();
|
||||||
navigationContainer.load(NavigationModule);
|
navigationContainer.load(NavigationModule);
|
||||||
@@ -43,7 +43,10 @@ describe("navigationRouter (R26 error mapping)", () => {
|
|||||||
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
|
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
|
||||||
const caller = navigationRouter.createCaller({});
|
const caller = navigationRouter.createCaller({});
|
||||||
try {
|
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");
|
throw new Error("expected throw");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).toBeInstanceOf(TRPCError);
|
expect(e).toBeInstanceOf(TRPCError);
|
||||||
@@ -66,12 +69,16 @@ describe("navigationRouter (R26 error mapping)", () => {
|
|||||||
navigationContainer
|
navigationContainer
|
||||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase)
|
.bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase)
|
||||||
.toDynamicValue((ctx) =>
|
.toDynamicValue((ctx) =>
|
||||||
getHeaderUseCase(ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository)),
|
getHeaderUseCase(
|
||||||
|
ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
navigationContainer
|
navigationContainer
|
||||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
||||||
.toDynamicValue((ctx) =>
|
.toDynamicValue((ctx) =>
|
||||||
getHeaderController(ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase)),
|
getHeaderController(
|
||||||
|
ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const caller = navigationRouter.createCaller({});
|
const caller = navigationRouter.createCaller({});
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Turbo generator: `feature`
|
* 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.
|
* matching the shape of the existing `navigation` reference feature.
|
||||||
*
|
*
|
||||||
* Phase 1 scope (intentionally limited):
|
* Scope (intentionally limited):
|
||||||
* - Single entity, single use case (`getX`)
|
* - Single entity, single use case (`getX`)
|
||||||
* - Skips Payload CMS collection/global templates (integrations/cms/**)
|
* - Skips Payload CMS collection/global templates (integrations/cms/**)
|
||||||
* - Skips UI query helpers (ui/query.ts) — emits an empty barrel
|
* - 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.setHelper("eq", (a: unknown, b: unknown) => a === b);
|
||||||
|
|
||||||
plop.setGenerator("feature", {
|
plop.setGenerator("feature", {
|
||||||
description:
|
description: "Scaffold a feature package (single entity / single use case)",
|
||||||
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
|
|
||||||
prompts: [
|
prompts: [
|
||||||
{
|
{
|
||||||
type: "input",
|
type: "input",
|
||||||
@@ -135,7 +134,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
{
|
{
|
||||||
type: "add",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts",
|
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",
|
type: "add",
|
||||||
@@ -255,7 +255,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
{
|
{
|
||||||
type: "add",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts",
|
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",
|
type: "add",
|
||||||
@@ -265,10 +266,11 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
{
|
{
|
||||||
type: "add",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts",
|
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",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
|
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
|
||||||
@@ -282,7 +284,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
{
|
{
|
||||||
type: "add",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts",
|
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",
|
type: "add",
|
||||||
@@ -291,7 +294,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
|||||||
"templates/feature/src/__contracts__/entity-repository.contract.ts.hbs",
|
"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",
|
type: "add",
|
||||||
path: "packages/{{kebabCase name}}/src/ui/index.ts",
|
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
|
// Final manual-wiring instructions printed to the user
|
||||||
function printNextSteps(answers: Record<string, unknown>): string {
|
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 kebab = a.name;
|
||||||
const constSym = a.name.toUpperCase().replace(/-/g, "_");
|
const constSym = a.name.toUpperCase().replace(/-/g, "_");
|
||||||
const pkg = `@repo/${kebab}`;
|
const pkg = `@repo/${kebab}`;
|
||||||
@@ -602,11 +609,18 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
"packages/core-eslint/rules",
|
"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.";
|
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.";
|
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"),
|
...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.";
|
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"),
|
...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.";
|
return "Added @repo/core-trpc to transpilePackages.";
|
||||||
},
|
},
|
||||||
printTrpcNextSteps,
|
printTrpcNextSteps,
|
||||||
@@ -666,7 +686,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
},
|
},
|
||||||
...emitTemplateTree("core-package/ui", "packages/core-ui"),
|
...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.";
|
return "Added @repo/core-ui to transpilePackages.";
|
||||||
},
|
},
|
||||||
printUiNextSteps,
|
printUiNextSteps,
|
||||||
@@ -678,7 +701,10 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
},
|
},
|
||||||
...emitTemplateTree("core-package/audit", "packages/core-audit"),
|
...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.";
|
return "Added @repo/core-audit to transpilePackages.";
|
||||||
},
|
},
|
||||||
printAuditNextSteps,
|
printAuditNextSteps,
|
||||||
@@ -686,7 +712,8 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
};
|
};
|
||||||
|
|
||||||
plop.setGenerator("core-package", {
|
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: [
|
prompts: [
|
||||||
{
|
{
|
||||||
type: "list",
|
type: "list",
|
||||||
@@ -949,7 +976,9 @@ function consumeActions(a: {
|
|||||||
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
||||||
return [
|
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(), bindProdFile, ["// <gen:event-handlers>"]);
|
||||||
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
||||||
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
||||||
@@ -1153,7 +1182,9 @@ function realtimeHandlerActions(a: {
|
|||||||
assertAnchors(process.cwd(), symbolFile, [
|
assertAnchors(process.cwd(), symbolFile, [
|
||||||
"// <gen:realtime-handler-symbols>",
|
"// <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>"]);
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
|
||||||
return "All required anchors present";
|
return "All required anchors present";
|
||||||
},
|
},
|
||||||
@@ -1241,7 +1272,7 @@ function printTrpcNextSteps(): string {
|
|||||||
"",
|
"",
|
||||||
" 2. apps/web-next/src/app/providers.tsx:",
|
" 2. apps/web-next/src/app/providers.tsx:",
|
||||||
' - import { NextTrpcProvider } from "@repo/core-trpc/next";',
|
' - 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:",
|
" 3. apps/web-next/src/app/api/trpc/[trpc]/route.ts:",
|
||||||
' - import { fetchRequestHandler } from "@trpc/server/adapters/fetch";',
|
' - import { fetchRequestHandler } from "@trpc/server/adapters/fetch";',
|
||||||
@@ -1250,7 +1281,7 @@ function printTrpcNextSteps(): string {
|
|||||||
"",
|
"",
|
||||||
" 4. apps/web-tanstack/src/routes/__root.tsx:",
|
" 4. apps/web-tanstack/src/routes/__root.tsx:",
|
||||||
' - import { TanstackTrpcProvider } from "@repo/core-trpc/tanstack";',
|
' - 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",
|
" 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:",
|
"3. Mount the admin tRPC router in packages/core-api/src/root.ts:",
|
||||||
' import { createAuditRouter } from "@repo/core-audit/api";',
|
' 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) },",
|
" // routers: { ..., audit: createAuditRouter(auditLog) },",
|
||||||
"",
|
"",
|
||||||
"4. Bind audit in apps/web-next/src/server/bind-production.ts:",
|
"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):",
|
"5. Install user-collection hooks (recommended for DPA compliance):",
|
||||||
" In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:",
|
" In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:",
|
||||||
" if (ctx.auditLog) {",
|
" if (ctx.auditLog) {",
|
||||||
' const { createAuditErasureHook, createAuditAfterReadHook } =',
|
" const { createAuditErasureHook, createAuditAfterReadHook } =",
|
||||||
' await import("@repo/core-audit/hooks");',
|
' await import("@repo/core-audit/hooks");',
|
||||||
" // wire onto users collection — see docs/guides/audit-and-compliance.md",
|
" // wire onto users collection — see docs/guides/audit-and-compliance.md",
|
||||||
" }",
|
" }",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export type BindAuditOpts = {
|
|||||||
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
||||||
* salt that an attacker could reverse.
|
* 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
|
* so all sinks receive AuditEntry.correlationId auto-populated from the
|
||||||
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ export { auditLogsCollection } from "./audit-logs-collection";
|
|||||||
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
||||||
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||||
export { AUDIT_SYMBOLS } from "./di/symbols";
|
export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||||
// Phase 3 — GDPR erasure
|
// GDPR erasure
|
||||||
export { pseudonymize } from "./pseudonymize";
|
export { pseudonymize } from "./pseudonymize";
|
||||||
export {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
} from "./hooks/audit-erasure-hook";
|
} from "./hooks/audit-erasure-hook";
|
||||||
// Phase 5 — VIEW capture
|
// VIEW capture
|
||||||
export {
|
export {
|
||||||
createAuditAfterReadHook,
|
createAuditAfterReadHook,
|
||||||
type AuditAfterReadHookOpts,
|
type AuditAfterReadHookOpts,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.
|
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
|
## Public exports
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Feature package scaffolded by `turbo gen feature`. Provides domain logic, reposi
|
|||||||
| `./api` | `{{camelCase name}}Router` (tRPC router) |
|
| `./api` | `{{camelCase name}}Router` (tRPC router) |
|
||||||
| `./di/bind-production` | `bindProduction{{pascalCase name}}(config, tracer, logger)` |
|
| `./di/bind-production` | `bindProduction{{pascalCase name}}(config, tracer, logger)` |
|
||||||
| `./di/bind-dev-seed` | `bindDevSeed{{pascalCase name}}(tracer, logger)` |
|
| `./di/bind-dev-seed` | `bindDevSeed{{pascalCase name}}(tracer, logger)` |
|
||||||
| `./ui` | (reserved — Phase 1 is empty) |
|
| `./ui` | (reserved — empty barrel) |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||||
import { Mock{{pascalCase entity}}Repository } from "@/infrastructure/repositories/{{kebabCase entity}}.repository.mock";
|
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", () => {
|
describe("Mock{{pascalCase entity}}Repository emits spans", () => {
|
||||||
it("get{{pascalCase entity}} emits one span with op='repository'", async () => {
|
it("get{{pascalCase entity}} emits one span with op='repository'", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
|
|||||||
Reference in New Issue
Block a user