chore(template): clean-slate template snapshot from bb4a0c7

Curated, product-agnostic snapshot of the post-story-04 tree: demo
content deleted, auth-only reference feature, web-next shell, all gates
green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor
library traces, and product naming are curated out; generic template
repairs (coverage provider devDeps, root test:coverage script, live
lint fixes, root-only release-please) are kept. See TEMPLATE.md for
provenance, curation list, and usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 20:40:54 +02:00
commit f77e6ea881
1062 changed files with 105156 additions and 0 deletions

162
packages/auth/AGENTS.md Normal file
View File

@@ -0,0 +1,162 @@
# AGENTS.md — auth
Users collection + authentication use cases (sign-in, sign-up, sign-out). Provides the Users Payload collection, AuthenticationService, and tRPC procedures for authentication workflows.
## Overview
`@repo/auth` owns: User/Session/Cookie domain models, auth-scoped errors, the `IUsersRepository` + `IAuthenticationService` interfaces, three use cases, three controllers, a real Payload-backed repository + service, and the tRPC `authRouter`. All procedures are mutations — there are no query builders.
## Layer responsibilities
| Layer | Key files |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **entities/models** | `user.ts`, `session.ts`, `cookie.ts` — Zod schemas + inferred types |
| **entities/errors** | `auth.ts` (AuthenticationError, UnauthenticatedError, UnauthorizedError), `common.ts` (InputParseError) |
| **application/use-cases** | `sign-in.use-case.ts`, `sign-up.use-case.ts`, `sign-out.use-case.ts` — factory functions + exported schemas |
| **application/repositories** | `users.repository.interface.ts` — `IUsersRepository` |
| **application/services** | `authentication.service.interface.ts` — `IAuthenticationService` |
| **infrastructure/repositories** | `users.repository.ts` (real Payload-backed), `users.repository.mock.ts` (in-memory) |
| **infrastructure/services** | `authentication.service.ts` (real Payload-backed), `authentication.service.mock.ts` (in-memory) |
| **interface-adapters/controllers** | `sign-in.controller.ts`, `sign-up.controller.ts`, `sign-out.controller.ts` — one file per use case |
| **di** | `symbols.ts` (AUTH_SYMBOLS), `module.ts`, `container.ts`, `bind-production.ts` |
| **integrations/api** | `procedures.ts` (authProcedure), `router.ts` (authRouter) |
| **integrations/cms** | `collections/users.ts` — Payload Users CollectionConfig |
| **ui** | `src/ui/index.ts` — placeholder (auth is mutations only; no query builders today) |
## Public exports
| Subpath | Contents |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.` | `User`, `Session`, `Cookie` types; `AuthenticationError`, `UnauthenticatedError`, `UnauthorizedError`, `InputParseError`; `SESSION_COOKIE`; all use-case schemas + input/output types + `IXUseCase` aliases; `IXController` type aliases; `AuthRouter` type |
| `./ui` | Placeholder — extend here when auth gains React Query builders, never re-add to root |
| `./api` | `authRouter` (tRPC router) |
| `./cms` | Payload Users collection definition |
| `./di/bind-production` | `bindProductionAuth(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
| `./di/bind-dev-seed` | `bindDevSeedAuth(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
| `./di/container` | `authContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) |
| `./di/symbols` | `AUTH_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) |
## Use-case + controller patterns
See `CLAUDE.md` Key Conventions and `docs/architecture/overview.md` for the canonical factory templates.
### Use cases
| Use case | Input schema | Output schema | Notes |
| ---------------- | ------------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- |
| `signInUseCase` | `signInInputSchema` — `{ username, password }` | `signInOutputSchema` — `{ session, cookie }` | Throws `AuthenticationError` on bad credentials |
| `signUpUseCase` | `signUpInputSchema` — `{ username, password, confirmPassword }` with `.refine` | `signUpOutputSchema` — `{ session, cookie }` | Throws `AuthenticationError` on taken username |
| `signOutUseCase` | `signOutInputSchema` — `{ sessionId }` | void (no `xOutputSchema`) | Calls `authenticationService.invalidateSession` |
### Controllers
| Controller | Presenter | Return type |
| ------------------- | ------------------------------------------- | --------------------------------------- |
| `signInController` | `presenter(value) { return value.cookie; }` | `ReturnType<typeof presenter>` (Cookie) |
| `signUpController` | `presenter(value) { return value.cookie; }` | `ReturnType<typeof presenter>` (Cookie) |
| `signOutController` | none (void) | `Promise<void>` |
Controllers accept `unknown` input and `safeParse` with the use-case's `xInputSchema`, throwing `InputParseError` on failure.
## Real Payload implementations
- `UsersRepository` (`infrastructure/repositories/users.repository.ts`) — calls `getPayload({ config })` for `getUser`, `getUserByUsername`, and `createUser`. Receives `SanitizedConfig` at constructor time.
- `AuthenticationService` (`infrastructure/services/authentication.service.ts`) — implements `hashPassword` and `verifyPassword` with Node.js `crypto` (pbkdf2). Three session-related methods (`createSession`, `validateSession`, `invalidateSession`) are **deferred** — they throw `NotImplementedError`. The mock (`authentication.service.mock.ts`) handles all test paths.
## Errors → tRPC codes
| Error class | tRPC code | Thrown by |
| ---------------------- | -------------- | ------------------------------- |
| `InputParseError` | `BAD_REQUEST` | controllers (safeParse failure) |
| `AuthenticationError` | `UNAUTHORIZED` | sign-in / sign-up use cases |
| `UnauthenticatedError` | `UNAUTHORIZED` | future session-guard middleware |
| `UnauthorizedError` | `FORBIDDEN` | future authorization checks |
Defined in `src/integrations/api/procedures.ts` via `authProcedure = t.procedure.use(defineErrorMiddleware([...]))`.
## Tests
- **Factories:** `src/__factories__/user.factory.ts`, `src/__factories__/session.factory.ts`
- **Contract suite:** `src/__contracts__/users-repository.contract.ts` — runs against mock and real `UsersRepository`
- **Unit tests:** colocated `*.test.ts` next to each source file
- **Feature integration:** `tests/sign-in-flow.feature.test.ts` — full slice: tRPC caller → controller → use case → mock repo/service
- **R25** (output validation): `sign-in.use-case.test.ts` and `sign-up.use-case.test.ts` each have a test that injects a malformed service mock and asserts `.rejects.toBeInstanceOf(ZodError)`. `signOut` is void — no R25.
- **R26** (router error mapping): `router.test.ts` has `UNAUTHORIZED` on bad credentials and `BAD_REQUEST` on schema failure.
- **R27/R28** (presenter shape): sign-in and sign-up controller tests assert `result.name`, `result.value`, etc. (Cookie shape), not the full `{ session, cookie }` use-case output.
```bash
pnpm test --filter @repo/auth
pnpm test --filter @repo/auth -- --watch
```
See `docs/guides/tdd-workflow.md` for the full cycle.
## Directory structure
```
src/
entities/
models/
user.ts
session.ts
cookie.ts
errors/
auth.ts # AuthenticationError, UnauthenticatedError, UnauthorizedError
common.ts # InputParseError
application/
repositories/
users.repository.interface.ts
services/
authentication.service.interface.ts
use-cases/
sign-in.use-case.ts
sign-up.use-case.ts
sign-out.use-case.ts
infrastructure/
repositories/
users.repository.ts # real Payload-backed
users.repository.mock.ts
services/
authentication.service.ts # real (session methods deferred)
authentication.service.mock.ts
interface-adapters/
controllers/
sign-in.controller.ts
sign-up.controller.ts
sign-out.controller.ts
integrations/
api/
procedures.ts # authProcedure
router.ts # authRouter
cms/
collections/
users.ts
index.ts
di/
symbols.ts # AUTH_SYMBOLS
module.ts
container.ts
bind-production.ts
ui/
index.ts # placeholder
index.ts
__factories__/
user.factory.ts
session.factory.ts
__contracts__/
users-repository.contract.ts
tests/
sign-in-flow.feature.test.ts
```
## What it must NOT import
- Any other feature package
- Any app package
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared`
> Note: `@repo/core-trpc` and `@repo/core-ui` are optional packages scaffolded via `pnpm turbo gen core-package trpc` / `ui`. If not present, these constraints still apply to any future installation.
## Cross-links
- ADR-012 (`docs/decisions/adr-012-feature-conventions.md`) — factory-style use cases, per-use-case controllers, file-naming conventions
- ADR-013 (`docs/decisions/adr-013-input-output-unification.md`) — schemas-in-use-case, presenter, `./ui` subpath, error middleware

View File

@@ -0,0 +1,11 @@
# Changelog — @repo/auth
All notable changes to the `auth` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
## 0.1.0 (2026-05-13)
### Initial baseline
The `auth` feature established at v0.1.0 alongside the hybrid versioning rollout (ADR-021). The feature has been stable since the template-reset cleanup; this is the first formally versioned baseline.
Future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/auth/**` since the last release.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,38 @@
{
"name": "@repo/auth",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./ui": "./src/ui/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts",
"./di/bind-production": "./src/di/bind-production.ts",
"./di/bind-dev-seed": "./src/di/bind-dev-seed.ts",
"./di/container": "./src/di/container.ts",
"./di/symbols": "./src/di/symbols.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

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

View File

@@ -0,0 +1,2 @@
export { userFactory } from "./user.factory";
export { sessionFactory } from "./session.factory";

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach } from "vitest";
import { sessionFactory } from "@/__factories__/session.factory";
describe("sessionFactory", () => {
beforeEach(() => sessionFactory.reset());
it("returns a Session with stable defaults", () => {
const s = sessionFactory.build();
expect(s.id).toBe("session-1");
expect(s.userId).toBe("user-1");
expect(s.expiresAt).toBeInstanceOf(Date);
});
it("applies overrides", () => {
const s = sessionFactory.build({ userId: "user-42" });
expect(s.userId).toBe("user-42");
expect(s.id).toBe("session-1");
});
it("increments sequence per build", () => {
const a = sessionFactory.build();
const b = sessionFactory.build();
expect(a.id).toBe("session-1");
expect(b.id).toBe("session-2");
});
});

View File

@@ -0,0 +1,8 @@
import { defineFactory } from "@repo/core-testing/factory";
import type { Session } from "../entities/models/session";
export const sessionFactory = defineFactory<Session>(({ sequence }) => ({
id: `session-${sequence}`,
userId: "user-1",
expiresAt: new Date("2026-12-31T23:59:59Z"),
}));

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach } from "vitest";
import { userFactory } from "@/__factories__/user.factory";
describe("userFactory", () => {
beforeEach(() => userFactory.reset());
it("returns a User with stable defaults", () => {
const u = userFactory.build();
expect(u.id).toBe("user-1");
expect(u.username).toBe("user1");
expect(u.passwordHash).toHaveLength(60);
});
it("applies overrides", () => {
const u = userFactory.build({ username: "alice" });
expect(u.username).toBe("alice");
expect(u.id).toBe("user-1");
});
it("increments sequence per build", () => {
const a = userFactory.build();
const b = userFactory.build();
expect(a.id).toBe("user-1");
expect(b.id).toBe("user-2");
});
});

View File

@@ -0,0 +1,8 @@
import { defineFactory } from "@repo/core-testing/factory";
import type { User } from "../entities/models/user";
export const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
username: `user${sequence}`,
passwordHash: `$2b$10$stablehashfortest${sequence}`.padEnd(60, "x"),
}));

View File

@@ -0,0 +1,27 @@
import { userFactory } from "../__factories__/user.factory";
import type { User } from "../entities/models/user";
/**
* Realistic auth seed for dev mode + storybook stories.
*
* Built from `userFactory` so factory defaults take care of boring fields
* and we only override what makes the data look like a real user database.
*
* Lazily produced so importing this module is side-effect-free — the
* factory's sequence counter only advances when a binder calls
* `buildDevUsers()`.
*/
export function buildDevUsers(): User[] {
return [
userFactory.build({
id: "alice",
username: "alice",
passwordHash: "hashed_secret_alice",
}),
userFactory.build({
id: "bob",
username: "bob",
passwordHash: "hashed_secret_bob",
}),
];
}

View File

@@ -0,0 +1,7 @@
import type { User } from "../../entities/models/user";
export interface IUsersRepository {
getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(input: User): Promise<User>;
}

View File

@@ -0,0 +1,12 @@
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
export interface IAuthenticationService {
generateUserId(): string;
hashPassword(password: string): Promise<string>;
verifyPassword(hash: string, password: string): Promise<boolean>;
validateSession(sessionId: string): Promise<{ user: User; session: Session }>;
createSession(user: User): Promise<{ session: Session; cookie: Cookie }>;
invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>;
}

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import {
signInUseCase,
signInOutputSchema,
} from "@/application/use-cases/sign-in.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import {
AuthenticationError,
TooManyRequestsError,
} from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory";
import { NoopRateLimit, InMemoryRateLimit } from "@repo/core-shared/rate-limit";
import { RecordingRateLimit } from "@repo/core-testing/rate-limit";
describe("signInUseCase", () => {
it("returns a session + cookie on valid credentials", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
const result = await useCase({
username: "alice",
password: "testpassword",
});
expect(result.session.userId).toBe(seedUser.id);
expect(result.cookie.name).toBe("session");
});
it("throws AuthenticationError when user does not exist", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
await expect(
useCase({ username: "ghost", password: "anything" }),
).rejects.toBeInstanceOf(AuthenticationError);
});
it("throws AuthenticationError on wrong password", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(
userFactory.build({
username: "alice",
passwordHash: "hashed_correctpassword",
}),
);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
await expect(
useCase({ username: "alice", password: "wrong" }),
).rejects.toBeInstanceOf(AuthenticationError);
});
it("captures both ip and account consume calls via RecordingRateLimit", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const rl = new RecordingRateLimit();
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth, rl);
await useCase({
username: "alice",
password: "testpassword",
clientIp: "1.2.3.4",
});
expect(rl.consumeCalls).toHaveLength(2);
expect(rl.consumeCalls[0]).toMatchObject({
budgetName: "ip",
key: "signIn:ip:1.2.3.4",
});
expect(rl.consumeCalls[1]).toMatchObject({
budgetName: "account",
key: "signIn:account:alice",
});
});
it("throws TooManyRequestsError when ip budget is exhausted", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const rl = new InMemoryRateLimit([
{ name: "ip", window: "1m", budget: 1 },
{ name: "account", window: "1h", budget: 10 },
]);
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth, rl);
await useCase({
username: "alice",
password: "testpassword",
clientIp: "1.2.3.4",
});
await expect(
useCase({
username: "alice",
password: "testpassword",
clientIp: "1.2.3.4",
}),
).rejects.toBeInstanceOf(TooManyRequestsError);
});
it("throws TooManyRequestsError when account budget is exhausted", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const rl = new InMemoryRateLimit([
{ name: "ip", window: "1m", budget: 100 },
{ name: "account", window: "1h", budget: 1 },
]);
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth, rl);
// First call succeeds (ip allows, account allows, credentials ok)
await useCase({
username: "alice",
password: "testpassword",
clientIp: "1.2.3.4",
});
// Second call: ip still allows (high budget), account is exhausted
await expect(
useCase({
username: "alice",
password: "testpassword",
clientIp: "5.6.7.8",
}),
).rejects.toBeInstanceOf(TooManyRequestsError);
});
});
describe("signInUseCase output validation", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
const seed = userFactory.build({ username: "alice" });
await users.createUser(seed);
const auth = {
verifyPassword: async () => true,
// session missing required fields → should fail signInOutputSchema.parse
createSession: async () => ({ session: { id: 123 }, cookie: null }),
} as unknown as IAuthenticationService;
const useCase = signInUseCase(users, auth, new NoopRateLimit());
await expect(
useCase({ username: "alice", password: "x" }),
).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that mirrors the success shape", () => {
expect(signInOutputSchema).toBeDefined();
const parsed = signInOutputSchema.safeParse({
session: { id: "s1", userId: "u1", expiresAt: new Date() },
cookie: { name: "session", value: "s1", attributes: {} },
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import type { IRateLimit } from "@repo/core-shared/rate-limit";
import {
AuthenticationError,
TooManyRequestsError,
} from "../../entities/errors/auth";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signInInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
clientIp: z.string().optional(),
})
.strict();
export type SignInInput = z.infer<typeof signInInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
rateLimit: IRateLimit,
) =>
async (input: SignInInput): Promise<SignInOutput> => {
const { allowed: ipAllowed } = await rateLimit.consume(
"ip",
`signIn:ip:${input.clientIp ?? ""}`,
);
if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts");
const { allowed: accountAllowed } = await rateLimit.consume(
"account",
`signIn:account:${input.username}`,
);
if (!accountAllowed)
throw new TooManyRequestsError("Too many sign-in attempts");
const existingUser = await usersRepository.getUserByUsername(
input.username,
);
if (!existingUser) {
throw new AuthenticationError("User does not exist");
}
const validPassword = await authenticationService.verifyPassword(
existingUser.passwordHash,
input.password,
);
if (!validPassword) {
throw new AuthenticationError("Incorrect username or password");
}
const result = await authenticationService.createSession(existingUser);
return signInOutputSchema.parse(result);
};

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
describe("signOutUseCase", () => {
it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const result = await useCase({ sessionId: "session_1" });
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
export type SignOutInput = z.infer<typeof signOutInputSchema>;
// No xOutputSchema — use case returns void.
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
export const signOutUseCase =
(authenticationService: IAuthenticationService) =>
async (input: SignOutInput): Promise<void> => {
await authenticationService.invalidateSession(input.sessionId);
};

View File

@@ -0,0 +1,238 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import {
RecordingEventBus,
RecordingConsent,
} from "@repo/core-testing/instrumentation";
import {
signUpUseCase,
signUpOutputSchema,
} from "@/application/use-cases/sign-up.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { AuthenticationError } from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory";
describe("signUpUseCase", () => {
it("creates a new user and returns session + cookie", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "carol",
password: "secret_password",
confirmPassword: "secret_password",
});
expect(result.session.userId).toBeTruthy();
expect(result.cookie.name).toBe("session");
});
it("throws AuthenticationError when username taken", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "alice",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(AuthenticationError);
});
it("publishes auth.user.signed-up after creating the user", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
await useCase({
username: "dave",
password: "secret_password",
confirmPassword: "secret_password",
});
expect(bus.published).toHaveLength(1);
const published = bus.published[0]!;
expect(published.name).toBe("auth.user.signed-up");
expect(published.payload).toEqual(
expect.objectContaining({
userId: expect.any(String),
email: expect.stringMatching(/^dave@/),
}),
);
});
it("works without an event bus (welcome email skipped silently)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth, undefined, undefined);
const result = await useCase({
username: "frank",
password: "secret_password",
confirmPassword: "secret_password",
});
expect(result.session.userId).toBeTruthy();
expect(result.cookie.name).toBe("session");
});
it("does NOT publish when sign-up fails (username taken)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "eve" }));
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "eve",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(AuthenticationError);
expect(bus.published).toHaveLength(0);
});
it("migrates anonymous consent when cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "grace",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=necessary,analytics; session=xyz",
});
expect(consent.grants).toHaveLength(2);
expect(consent.grants[0]).toEqual({
category: "necessary",
meta: { method: "signup-migration" },
});
expect(consent.grants[1]).toEqual({
category: "analytics",
meta: { method: "signup-migration" },
});
expect(result.clearCookie).toBeDefined();
expect(result.clearCookie?.name).toBe("cc_consent");
expect(result.clearCookie?.attributes.maxAge).toBe(0);
});
it("does not migrate consent when no cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "henry",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "session=xyz",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when consentFactory is absent", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "iris",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=analytics",
});
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when cc_consent cookie has no value", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "jake",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("parses cookie header with malformed parts (no = sign)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "kate",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "malformedcookie; cc_consent=necessary",
});
expect(consent.grants).toHaveLength(1);
expect(result.clearCookie).toBeDefined();
});
});
describe("signUpUseCase output validation", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
const auth = {
hashPassword: async () => "hashed_x",
generateUserId: () => "uid1",
verifyPassword: async () => true,
// session missing required fields → should fail signUpOutputSchema.parse
createSession: async () => ({ session: { id: 123 }, cookie: null }),
} as unknown as IAuthenticationService;
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "carol",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that mirrors the success shape", () => {
expect(signUpOutputSchema).toBeDefined();
const parsed = signUpOutputSchema.safeParse({
session: { id: "s1", userId: "u1", expiresAt: new Date() },
cookie: { name: "session", value: "s1", attributes: {} },
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,119 @@
import { z } from "zod";
import type {
EventBusProtocol,
ConsentFactoryProtocol,
} from "@repo/core-shared/di";
import { userSignedUpEvent } from "../../events/user-signed-up.event";
import { AuthenticationError } from "../../entities/errors/auth";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
for (const part of cookieHeader.split(";")) {
const eqIdx = part.indexOf("=");
if (eqIdx === -1) continue;
const name = part.slice(0, eqIdx).trim();
if (name !== ANONYMOUS_CONSENT_COOKIE) continue;
const value = part.slice(eqIdx + 1).trim();
const cats = value
.split(",")
.map((c) => c.trim())
.filter(Boolean);
return cats.length > 0 ? cats : null;
}
return null;
}
// ── Input ────────────────────────────────────────────────────────────────
export const signUpInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
cookieHeader: z.string().optional(),
})
.strict()
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignUpInput = z.infer<typeof signUpInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
clearCookie: cookieSchema.optional(),
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignUpUseCase = ReturnType<typeof signUpUseCase>;
export const signUpUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
bus: EventBusProtocol | undefined,
consentFactory: ConsentFactoryProtocol | undefined,
) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
const existingUser = await usersRepository.getUserByUsername(
input.username,
);
if (existingUser) {
throw new AuthenticationError("Username taken");
}
const passwordHash = await authenticationService.hashPassword(
input.password,
);
const userId = authenticationService.generateUserId();
const newUser = await usersRepository.createUser({
id: userId,
username: input.username,
passwordHash,
});
const { cookie, session } =
await authenticationService.createSession(newUser);
// Auth is username-based — synthesize a deterministic email so the event
// payload validates against userSignedUpEventSchema.email().
// bus is optional: absent when core-events is not wired.
if (bus) {
await bus.publish(userSignedUpEvent, {
userId: newUser.id,
email: `${newUser.username}@example.local`,
signedUpAt: new Date().toISOString(),
});
}
// Migrate anonymous consent when both a cookie header and a consent factory
// are present. consentFactory is optional: absent when core-consent is not wired.
const cookieState = input.cookieHeader
? extractConsentFromCookieHeader(input.cookieHeader)
: null;
let clearCookie: z.infer<typeof cookieSchema> | undefined;
if (cookieState && consentFactory) {
const consent = await consentFactory(newUser.id);
for (const category of cookieState) {
await consent.grant(category, { method: "signup-migration" });
}
clearCookie = {
name: ANONYMOUS_CONSENT_COOKIE,
value: "",
attributes: { maxAge: 0, path: "/" },
};
}
return signUpOutputSchema.parse({ session, cookie, clearCookie });
};

View File

@@ -0,0 +1 @@
export const SESSION_COOKIE = "session";

View File

@@ -0,0 +1,96 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import {
RecordingEventBus,
RecordingJobQueue,
} from "@repo/core-testing/instrumentation";
import { bindDevSeedAuth } from "@/di/bind-dev-seed";
import { authContainer } from "@/di/container";
import { AUTH_SYMBOLS } from "@/di/symbols";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
describe("bindDevSeedAuth", () => {
// Each test starts from the default empty-mock binding and tears down
// afterwards so the global authContainer state stays clean for siblings.
beforeEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.to(MockUsersRepository);
});
afterEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.to(MockUsersRepository);
});
it("populates the repository with the dev users", async () => {
await bindDevSeedAuth({
...noop,
bus: new RecordingEventBus(),
queue: new RecordingJobQueue(),
});
const repo = authContainer.get<IUsersRepository>(
AUTH_SYMBOLS.IUsersRepository,
);
const alice = await repo.getUserByUsername("alice");
const bob = await repo.getUserByUsername("bob");
expect(alice).toBeDefined();
expect(bob).toBeDefined();
});
it("seeds alice reachable by username", async () => {
await bindDevSeedAuth({
...noop,
bus: new RecordingEventBus(),
queue: new RecordingJobQueue(),
});
const repo = authContainer.get<IUsersRepository>(
AUTH_SYMBOLS.IUsersRepository,
);
const alice = await repo.getUserByUsername("alice");
expect(alice).toBeDefined();
expect(alice?.id).toBe("alice");
expect(alice?.passwordHash).toBe("hashed_secret_alice");
});
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
await bindDevSeedAuth({
...noop,
bus: new RecordingEventBus(),
queue: new RecordingJobQueue(),
});
const before = authContainer.get<IUsersRepository>(
AUTH_SYMBOLS.IUsersRepository,
);
const beforeAlice = await before.getUserByUsername("alice");
await bindDevSeedAuth({
...noop,
bus: new RecordingEventBus(),
queue: new RecordingJobQueue(),
});
const after = authContainer.get<IUsersRepository>(
AUTH_SYMBOLS.IUsersRepository,
);
const afterAlice = await after.getUserByUsername("alice");
expect(afterAlice?.username).toBe(beforeAlice?.username);
// It's a fresh instance — not the previous one.
expect(after).not.toBe(before);
});
});

View File

@@ -0,0 +1,187 @@
import {
withSpan,
withCapture,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import {
assertFeatureConformance,
wireUseCase,
} from "@repo/core-shared/conformance";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { authManifest } from "../feature.manifest";
import { authContainer } from "./container";
import { AUTH_SYMBOLS } from "./symbols";
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock";
import { buildDevUsers } from "../__seeds__/dev";
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
import { signUpUseCase } from "../application/use-cases/sign-up.use-case";
import { signOutUseCase } from "../application/use-cases/sign-out.use-case";
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
import { signUpController } from "../interface-adapters/controllers/sign-up.controller";
import { signOutController } from "../interface-adapters/controllers/sign-out.controller";
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
/**
* Replace the default mock with a populated one for dev mode + storybook.
*
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
* `bindProductionAuth(config)`. Tests must NOT call this — they construct
* `new MockUsersRepository()` directly and seed via factories per-test.
*
* The `IAuthenticationService` binding is left untouched; it resolves users
* through DI from the newly seeded repo.
*
* Idempotent: safe to call multiple times; each call rebuilds a fresh
* populated repo and rebinds the symbol.
*/
export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
const {
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
consentFactory,
} = ctx;
// Bind shared instrumentation into feature container
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
authContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
authContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
const repo = new MockUsersRepository([], tracer, logger);
for (const user of buildDevUsers()) {
await repo.createUser(user);
}
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.toConstantValue(repo);
// Need auth service from container for use cases
const authService = authContainer.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
);
// Use cases
const wrappedSignIn = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignInUseCase,
factory: signInUseCase,
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
});
const wrappedSignUp = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignUpUseCase,
factory: signUpUseCase,
deps: [repo, authService, bus, consentFactory],
feature: "auth",
layer: "use-case",
name: "signUp",
tracer,
logger,
});
const wrappedSignOut = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignOutUseCase,
factory: signOutUseCase,
deps: [authService],
feature: "auth",
layer: "use-case",
name: "signOut",
tracer,
logger,
});
// Controllers
for (const sym of [
AUTH_SYMBOLS.ISignInController,
AUTH_SYMBOLS.ISignUpController,
AUTH_SYMBOLS.ISignOutController,
]) {
if (authContainer.isBound(sym)) authContainer.unbind(sym);
}
authContainer
.bind(AUTH_SYMBOLS.ISignInController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signIn", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signIn" },
signInController(wrappedSignIn),
),
),
);
authContainer
.bind(AUTH_SYMBOLS.ISignUpController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signUp", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signUp" },
signUpController(wrappedSignUp),
),
),
);
authContainer
.bind(AUTH_SYMBOLS.ISignOutController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signOut", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signOut" },
signOutController(wrappedSignOut),
),
),
);
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;
void realtimeRegistry;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
authContainer,
authManifest,
{
signIn: AUTH_SYMBOLS.ISignInUseCase,
signUp: AUTH_SYMBOLS.ISignUpUseCase,
signOut: AUTH_SYMBOLS.ISignOutUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,17 @@
import "reflect-metadata";
import { describe, expect, it } from "vitest";
import type { SanitizedConfig } from "payload";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import { bindProductionAuth } from "@/di/bind-production";
describe("bindProductionAuth — boot-time conformance", () => {
it("binds every manifest use case through withSpan + withCapture", () => {
expect(() =>
bindProductionAuth({
config: {} as SanitizedConfig,
tracer: new NoopTracer(),
logger: new NoopLogger(),
}),
).not.toThrow();
});
});

View File

@@ -0,0 +1,180 @@
import {
withSpan,
withCapture,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import {
assertFeatureConformance,
wireUseCase,
} from "@repo/core-shared/conformance";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { authManifest } from "../feature.manifest";
import { authContainer } from "./container";
import { AUTH_SYMBOLS } from "./symbols";
import { UsersRepository } from "../infrastructure/repositories/users.repository";
import { AuthenticationService } from "../infrastructure/services/authentication.service";
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
import { signUpUseCase } from "../application/use-cases/sign-up.use-case";
import { signOutUseCase } from "../application/use-cases/sign-out.use-case";
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
import { signUpController } from "../interface-adapters/controllers/sign-up.controller";
import { signOutController } from "../interface-adapters/controllers/sign-out.controller";
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
let bound = false;
export function bindProductionAuth(ctx: BindProductionContext): void {
if (bound) return;
bound = true;
const {
config,
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
consentFactory,
} = ctx;
// Bind shared instrumentation into feature container
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
authContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
authContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repositories
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
const repo = new UsersRepository(config, tracer, logger);
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.toConstantValue(repo);
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
}
const authService = new AuthenticationService(config);
authContainer
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
.toConstantValue(authService);
// Use cases
const wrappedSignIn = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignInUseCase,
factory: signInUseCase,
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
});
const wrappedSignUp = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignUpUseCase,
factory: signUpUseCase,
deps: [repo, authService, bus, consentFactory],
feature: "auth",
layer: "use-case",
name: "signUp",
tracer,
logger,
});
const wrappedSignOut = wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignOutUseCase,
factory: signOutUseCase,
deps: [authService],
feature: "auth",
layer: "use-case",
name: "signOut",
tracer,
logger,
});
// Controllers — wrapped with span at bind time
for (const sym of [
AUTH_SYMBOLS.ISignInController,
AUTH_SYMBOLS.ISignUpController,
AUTH_SYMBOLS.ISignOutController,
]) {
if (authContainer.isBound(sym)) authContainer.unbind(sym);
}
authContainer
.bind(AUTH_SYMBOLS.ISignInController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signIn", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signIn" },
signInController(wrappedSignIn),
),
),
);
authContainer
.bind(AUTH_SYMBOLS.ISignUpController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signUp", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signUp" },
signUpController(wrappedSignUp),
),
),
);
authContainer
.bind(AUTH_SYMBOLS.ISignOutController)
.toConstantValue(
withSpan(
tracer,
{ name: "auth.signOut", op: "controller" },
withCapture(
logger,
{ feature: "auth", layer: "controller", name: "auth.signOut" },
signOutController(wrappedSignOut),
),
),
);
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;
void realtimeRegistry;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check: refuses to start if any use-case binding
// is missing a required brand (withSpan / withCapture / withAudit).
assertFeatureConformance(
authContainer,
authManifest,
{
signIn: AUTH_SYMBOLS.ISignInUseCase,
signUp: AUTH_SYMBOLS.ISignUpUseCase,
signOut: AUTH_SYMBOLS.ISignOutUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,29 @@
import { describe, it } from "vitest";
import type { ProductionUseCase } from "@repo/core-shared/conformance";
import type { AuthManifest } from "@/feature.manifest";
import type {
SignInInput,
SignInOutput,
} from "@/application/use-cases/sign-in.use-case";
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
describe("auth.signIn binding slot (type-level)", () => {
it("rejects an unwrapped factory", () => {
type Slot = ProductionUseCase<
SignInInput,
SignInOutput,
AuthManifest["useCases"]["signIn"]
>;
// Build the unwrapped factory exactly as it would be at the use-case file.
// It returns a function with no brand attached — must not be assignable.
const fakeRepo = {} as never;
const fakeAuth = {} as never;
const fakeRateLimit = {} as never;
const unwrapped = signInUseCase(fakeRepo, fakeAuth, fakeRateLimit);
// @ts-expect-error — unwrapped factory has no __instrumented / __captured brand
const _bad: Slot = unwrapped;
void _bad;
});
});

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { authContainer } from "./container";
import { AUTH_SYMBOLS } from "./symbols";
import { AuthModule } from "./module";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import type { ISignInUseCase } from "@/application/use-cases/sign-in.use-case";
import type { ISignInController } from "@/interface-adapters/controllers/sign-in.controller";
import { userFactory } from "@/__factories__/user.factory";
describe("authContainer", () => {
beforeEach(() => {
authContainer.unbindAll();
authContainer.load(AuthModule);
});
afterEach(() => {
authContainer.unbindAll();
});
it("resolves IUsersRepository to MockUsersRepository by default", () => {
const repo = authContainer.get<IUsersRepository>(
AUTH_SYMBOLS.IUsersRepository,
);
expect(repo).toBeInstanceOf(MockUsersRepository);
});
it("resolves IAuthenticationService to MockAuthenticationService by default", () => {
const service = authContainer.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
);
expect(service).toBeInstanceOf(MockAuthenticationService);
});
it("authentication service receives users repository via constructor injection", async () => {
const service = authContainer.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
);
const user = userFactory.build({
id: "1",
username: "alice",
passwordHash: "hashed_password_alice",
});
const { session, cookie } = await service.createSession(user);
expect(session.userId).toBe("1");
expect(cookie.value).toBe(session.id);
const validated = await service.validateSession(session.id);
expect(validated.user.username).toBe("alice");
});
it("resolves ISignInUseCase via toDynamicValue binding", () => {
const useCase = authContainer.get<ISignInUseCase>(
AUTH_SYMBOLS.ISignInUseCase,
);
expect(typeof useCase).toBe("function");
});
it("resolves ISignInController via toDynamicValue binding", () => {
const controller = authContainer.get<ISignInController>(
AUTH_SYMBOLS.ISignInController,
);
expect(typeof controller).toBe("function");
});
});

View File

@@ -0,0 +1,6 @@
import "reflect-metadata";
import { Container } from "inversify";
import { AuthModule } from "./module";
export const authContainer = new Container({ defaultScope: "Singleton" });
authContainer.load(AuthModule);

View File

@@ -0,0 +1,92 @@
import { ContainerModule, type interfaces } from "inversify";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "../infrastructure/services/authentication.service.mock";
import {
signInUseCase,
type ISignInUseCase,
} from "../application/use-cases/sign-in.use-case";
import {
signUpUseCase,
type ISignUpUseCase,
} from "../application/use-cases/sign-up.use-case";
import {
signOutUseCase,
type ISignOutUseCase,
} from "../application/use-cases/sign-out.use-case";
import {
signInController,
type ISignInController,
} from "../interface-adapters/controllers/sign-in.controller";
import {
signUpController,
type ISignUpController,
} from "../interface-adapters/controllers/sign-up.controller";
import {
signOutController,
type ISignOutController,
} from "../interface-adapters/controllers/sign-out.controller";
import { AUTH_SYMBOLS } from "./symbols";
export const AuthModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository);
bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService).to(
MockAuthenticationService,
);
bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) =>
signInUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
new NoopRateLimit(),
),
);
bind<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase).toDynamicValue((ctx) =>
// No default bus — real cross-feature wiring runs through
// bindProductionAuth / bindDevSeedAuth where bindAll() passes a shared
// bus instance when @repo/core-events is scaffolded.
signUpUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
undefined,
undefined,
),
);
bind<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase).toDynamicValue((ctx) =>
signOutUseCase(
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
),
);
bind<ISignInController>(AUTH_SYMBOLS.ISignInController).toDynamicValue(
(ctx) =>
signInController(
ctx.container.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase),
),
);
bind<ISignUpController>(AUTH_SYMBOLS.ISignUpController).toDynamicValue(
(ctx) =>
signUpController(
ctx.container.get<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase),
),
);
bind<ISignOutController>(AUTH_SYMBOLS.ISignOutController).toDynamicValue(
(ctx) =>
signOutController(
ctx.container.get<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase),
),
);
});

View File

@@ -0,0 +1,15 @@
export const AUTH_SYMBOLS = {
IUsersRepository: Symbol.for("auth:IUsersRepository"),
IAuthenticationService: Symbol.for("auth:IAuthenticationService"),
// Use cases
ISignInUseCase: Symbol.for("auth:ISignInUseCase"),
ISignUpUseCase: Symbol.for("auth:ISignUpUseCase"),
ISignOutUseCase: Symbol.for("auth:ISignOutUseCase"),
// Controllers
ISignInController: Symbol.for("auth:ISignInController"),
ISignUpController: Symbol.for("auth:ISignUpController"),
ISignOutController: Symbol.for("auth:ISignOutController"),
// <gen:event-handler-symbols>
// <gen:job-symbols>
// <gen:realtime-handler-symbols>
} as const;

View File

@@ -0,0 +1,27 @@
export class AuthenticationError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "AuthenticationError";
}
}
export class UnauthenticatedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "UnauthenticatedError";
}
}
export class UnauthorizedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "UnauthorizedError";
}
}
export class TooManyRequestsError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "TooManyRequestsError";
}
}

View File

@@ -0,0 +1,6 @@
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "InputParseError";
}
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
TooManyRequestsError,
} from "./auth";
import { InputParseError } from "./common";
describe("AuthenticationError", () => {
it("is an instance of Error with the given message", () => {
const err = new AuthenticationError("bad credentials");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("bad credentials");
});
});
describe("UnauthenticatedError", () => {
it("is an instance of Error with the given message", () => {
const err = new UnauthenticatedError("not logged in");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("not logged in");
});
});
describe("UnauthorizedError", () => {
it("is an instance of Error with the given message", () => {
const err = new UnauthorizedError("forbidden");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("forbidden");
});
});
describe("InputParseError", () => {
it("is an instance of Error with the given message", () => {
const err = new InputParseError("invalid input");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("invalid input");
});
});
describe("TooManyRequestsError", () => {
it("is an instance of Error with the given message", () => {
const err = new TooManyRequestsError("too many attempts");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("too many attempts");
expect(err.name).toBe("TooManyRequestsError");
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { cookieSchema } from "./cookie";
describe("cookieSchema", () => {
it("accepts a minimal cookie with empty attributes", () => {
const result = cookieSchema.parse({
name: "session",
value: "abc123",
attributes: {},
});
expect(result.name).toBe("session");
expect(result.value).toBe("abc123");
});
it("accepts a fully-populated cookie", () => {
const result = cookieSchema.parse({
name: "session",
value: "abc123",
attributes: {
secure: true,
path: "/",
domain: "example.com",
sameSite: "strict",
httpOnly: true,
maxAge: 3600,
expires: new Date(),
},
});
expect(result.attributes.sameSite).toBe("strict");
});
it("rejects an invalid sameSite value", () => {
expect(() =>
cookieSchema.parse({
name: "session",
value: "abc123",
attributes: { sameSite: "invalid" },
}),
).toThrow();
});
it("rejects a missing required field", () => {
expect(() =>
cookieSchema.parse({ name: "session", attributes: {} }),
).toThrow();
});
});

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
const cookieAttributesSchema = z.object({
secure: z.boolean().optional(),
path: z.string().optional(),
domain: z.string().optional(),
sameSite: z.enum(["lax", "strict", "none"]).optional(),
httpOnly: z.boolean().optional(),
maxAge: z.number().optional(),
expires: z.date().optional(),
});
export const cookieSchema = z.object({
name: z.string(),
value: z.string(),
attributes: cookieAttributesSchema,
});
export type Cookie = z.infer<typeof cookieSchema>;

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { sessionSchema } from "./session";
describe("sessionSchema", () => {
it("accepts a valid session", () => {
const result = sessionSchema.parse({
id: "session_1",
userId: "1",
expiresAt: new Date(),
});
expect(result.userId).toBe("1");
});
it("rejects non-Date expiresAt", () => {
expect(() =>
sessionSchema.parse({
id: "session_1",
userId: "1",
expiresAt: "2026-05-04",
}),
).toThrow();
});
});

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.date(),
});
export type Session = z.infer<typeof sessionSchema>;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { userSchema } from "./user";
describe("userSchema", () => {
it("accepts a valid user", () => {
const result = userSchema.parse({
id: "1",
username: "alice",
passwordHash: "hashed_password_1",
});
expect(result.username).toBe("alice");
});
it("rejects username shorter than 3 chars", () => {
expect(() =>
userSchema.parse({
id: "1",
username: "ab",
passwordHash: "hashed_password_1",
}),
).toThrow();
});
it("rejects passwordHash shorter than 6 chars", () => {
expect(() =>
userSchema.parse({
id: "1",
username: "alice",
passwordHash: "abc",
}),
).toThrow();
});
});

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const userSchema = z.object({
id: z.string(),
username: z.string().min(3).max(31),
passwordHash: z.string().min(6).max(255),
});
export type User = z.infer<typeof userSchema>;

View File

@@ -0,0 +1,52 @@
// packages/auth/src/events/user-signed-up.event.test.ts
import { describe, it, expect } from "vitest";
import {
userSignedUpEventSchema,
userSignedUpEvent,
} from "@/events/user-signed-up.event";
describe("userSignedUpEvent", () => {
it("has the correct wire name", () => {
expect(userSignedUpEvent.name).toBe("auth.user.signed-up");
});
it("accepts a valid payload", () => {
const payload = {
userId: "user_123",
email: "alice@example.com",
signedUpAt: "2026-05-08T12:00:00.000Z",
};
expect(() => userSignedUpEventSchema.parse(payload)).not.toThrow();
});
it("rejects invalid email", () => {
expect(() =>
userSignedUpEventSchema.parse({
userId: "u1",
email: "not-an-email",
signedUpAt: "2026-05-08T12:00:00.000Z",
}),
).toThrow();
});
it("rejects invalid datetime", () => {
expect(() =>
userSignedUpEventSchema.parse({
userId: "u1",
email: "alice@example.com",
signedUpAt: "yesterday",
}),
).toThrow();
});
it("rejects unknown fields (strict)", () => {
expect(() =>
userSignedUpEventSchema.parse({
userId: "u1",
email: "alice@example.com",
signedUpAt: "2026-05-08T12:00:00.000Z",
extraField: "no",
}),
).toThrow();
});
});

View File

@@ -0,0 +1,19 @@
// packages/auth/src/events/user-signed-up.event.ts
import { z } from "zod";
export const userSignedUpEventSchema = z
.object({
userId: z.string(),
email: z.string().email(),
signedUpAt: z.string().datetime(),
})
.strict();
export type UserSignedUpEvent = z.infer<typeof userSignedUpEventSchema>;
// Inline event descriptor — core-events is optional. The shape { name, schema }
// satisfies EventBusProtocol.publish / subscribe when the bus is present.
export const userSignedUpEvent = {
name: "auth.user.signed-up" as const,
schema: userSignedUpEventSchema,
};

View File

@@ -0,0 +1,65 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The auth feature's conformance manifest. Drives binding-slot types in
* `di/bind-production.ts` and is read by ESLint, the boot assertion, and
* the CI drift gate (later milestones).
*
* Conventions:
* - `mutates: true` for any use case that creates, updates, or deletes state
* - `audits` lists every audit event the use case emits (must match calls
* to `auditLog.record(...)` in the factory body — ESLint enforces this
* in a later story)
* - `publishes` / `consumes` cover cross-feature events through `IEventBus`
*/
export const authManifest = defineFeature({
name: "auth",
requiredCores: [],
useCases: {
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
rateLimit: [
{ name: "ip", window: "1m", budget: 5 },
{ name: "account", window: "1h", budget: 10 },
],
},
signUp: {
mutates: true,
audits: [],
publishes: ["auth.user.signed-up"],
consumes: [],
},
signOut: {
mutates: true,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
coverage: {
bands: {
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
"use-cases": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
controllers: {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
},
mutationTargets: ["entities", "use-cases"],
},
} as const);
export type AuthManifest = typeof authManifest;

View File

@@ -0,0 +1,50 @@
export type { User } from "./entities/models/user";
export type { Session } from "./entities/models/session";
export type { Cookie } from "./entities/models/cookie";
export type { AuthRouter } from "./integrations/api/router";
export {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
TooManyRequestsError,
} from "./entities/errors/auth";
export { InputParseError } from "./entities/errors/common";
export { SESSION_COOKIE } from "./config";
// Use case schemas + types
export {
signInInputSchema,
signInOutputSchema,
type SignInInput,
type SignInOutput,
type ISignInUseCase,
} from "./application/use-cases/sign-in.use-case";
export {
signUpInputSchema,
signUpOutputSchema,
type SignUpInput,
type SignUpOutput,
type ISignUpUseCase,
} from "./application/use-cases/sign-up.use-case";
export {
signOutInputSchema,
type SignOutInput,
type ISignOutUseCase,
} from "./application/use-cases/sign-out.use-case";
// Controller type aliases
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";
// <gen:events>
export {
userSignedUpEvent,
userSignedUpEventSchema,
type UserSignedUpEvent,
} from "./events/user-signed-up.event";
// <gen:realtime-channels>
// Feature conformance manifest — declares this feature's use cases, audits,
// publishes, and consumes. Read by the boot-time assertion + ESLint rules.
export { authManifest, type AuthManifest } from "./feature.manifest";

View File

@@ -0,0 +1,12 @@
import { describe } from "vitest";
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { usersRepositoryContract } from "@/__contracts__/users-repository.contract";
describe("MockUsersRepository", () => {
const tracer = new RecordingTracer();
// Start with empty store so contract tests run from a clean slate.
usersRepositoryContract.run(() => new MockUsersRepository([], tracer), {
tracer: () => tracer,
});
});

View File

@@ -0,0 +1,79 @@
import "reflect-metadata";
import { injectable } from "inversify";
import {
NoopTracer,
NoopLogger,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
import type { User } from "../../entities/models/user";
const DEFAULT_SEED: User[] = [
{ id: "1", username: "alice", passwordHash: "hashed_password_alice" },
{ id: "2", username: "bob", passwordHash: "hashed_password_bob" },
];
@injectable()
export class MockUsersRepository implements IUsersRepository {
private _users: User[];
private tracer: ITracer;
private logger: ILogger;
constructor(
initialUsers: User[] = DEFAULT_SEED,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this._users = [...initialUsers];
this.tracer = tracer;
this.logger = logger;
void this.logger; // currently unused; reserved for future mock-thrown captures
}
async getUser(id: string): Promise<User | undefined> {
return this.tracer.startSpan(
{ name: "users.getUser", op: "repository", attributes: { id } },
async (span) => {
const found = this._users.find((u) => u.id === id);
span.setAttribute("found", Boolean(found));
return found;
},
);
}
async getUserByUsername(username: string): Promise<User | undefined> {
return this.tracer.startSpan(
{
name: "users.getUserByUsername",
op: "repository",
attributes: {
emailDomain: username.includes("@")
? (username.split("@")[1] ?? "(invalid)")
: username,
},
},
async (span) => {
const found = this._users.find((u) => u.username === username);
span.setAttribute("found", Boolean(found));
return found;
},
);
}
async createUser(input: User): Promise<User> {
return this.tracer.startSpan(
{
name: "users.createUser",
op: "repository",
attributes: { id: input.id },
},
async (span) => {
this._users.push(input);
span.setAttribute("created", true);
return input;
},
);
}
}

View File

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

View File

@@ -0,0 +1,76 @@
import { describe, vi, beforeEach } from "vitest";
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { UsersRepository } from "@/infrastructure/repositories/users.repository";
import { usersRepositoryContract } from "@/__contracts__/users-repository.contract";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
vi.mock("payload", () => ({ getPayload: vi.fn() }));
function buildPayloadStub() {
const store = new Map<string, Record<string, unknown>>();
return {
create: vi.fn(
async ({
data,
}: {
collection: string;
data: Record<string, unknown>;
overrideAccess?: boolean;
}) => {
const doc = { ...data };
store.set(String(doc.id), doc);
return doc;
},
),
find: vi.fn(
async ({
where,
}: {
collection: string;
where?: { username?: { equals: string } };
limit?: number;
overrideAccess?: boolean;
}) => {
const all = Array.from(store.values());
if (where?.username?.equals) {
return {
docs: all.filter((u) => u.username === where.username?.equals),
};
}
return { docs: all };
},
),
findByID: vi.fn(
async ({
id,
}: {
collection: string;
id: string;
overrideAccess?: boolean;
}) => {
return store.get(String(id)) ?? null;
},
),
};
}
describe("UsersRepository", () => {
describe("contract", () => {
const tracer = new RecordingTracer();
beforeEach(() => {
vi.clearAllMocks();
});
usersRepositoryContract.run(
async () => {
const stub = buildPayloadStub();
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
return new UsersRepository(stubPayloadConfig, tracer);
},
{ tracer: () => tracer },
);
});
});

View File

@@ -0,0 +1,150 @@
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import {
NoopTracer,
NoopLogger,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
import { type User } from "../../entities/models/user";
const FEATURE = "auth" as const;
const REPO = "users" as const;
export class UsersRepository implements IUsersRepository {
private config: SanitizedConfig;
private tracer: ITracer;
private logger: ILogger;
constructor(
config: SanitizedConfig,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this.config = config;
this.tracer = tracer;
this.logger = logger;
}
async getUser(id: string): Promise<User | undefined> {
return this.tracer.startSpan(
{ name: "users.getUser", op: "repository", attributes: { id } },
async (span) => {
try {
const payload = await getPayload({ config: this.config });
const result = await payload.findByID({
collection: "users",
id,
overrideAccess: true,
});
const found = Boolean(result);
span.setAttribute("found", found);
return result
? this.toDomain(result as Record<string, unknown>)
: undefined;
} catch (err) {
if (
err &&
typeof err === "object" &&
"status" in err &&
(err as { status: unknown }).status === 404
) {
span.setAttribute("found", false);
return undefined;
}
this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "getUser" },
});
span.setStatus(
"error",
err instanceof Error ? err.message : String(err),
);
throw err;
}
},
);
}
async getUserByUsername(username: string): Promise<User | undefined> {
return this.tracer.startSpan(
{
name: "users.getUserByUsername",
op: "repository",
attributes: {
emailDomain: username.includes("@")
? (username.split("@")[1] ?? "(invalid)")
: username,
},
},
async (span) => {
try {
const payload = await getPayload({ config: this.config });
const { docs } = await payload.find({
collection: "users",
where: { username: { equals: username } },
limit: 1,
overrideAccess: true,
});
const doc = docs[0];
span.setAttribute("found", Boolean(doc));
return doc
? this.toDomain(doc as Record<string, unknown>)
: undefined;
} catch (err) {
this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "getUserByUsername" },
});
span.setStatus(
"error",
err instanceof Error ? err.message : String(err),
);
throw err;
}
},
);
}
async createUser(input: User): Promise<User> {
return this.tracer.startSpan(
{
name: "users.createUser",
op: "repository",
attributes: { id: input.id },
},
async (span) => {
try {
const payload = await getPayload({ config: this.config });
const created = await payload.create({
collection: "users",
data: {
id: input.id,
username: input.username,
passwordHash: input.passwordHash,
},
overrideAccess: true,
});
span.setAttribute("created", true);
return this.toDomain(created as Record<string, unknown>);
} catch (err) {
this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "createUser" },
});
span.setStatus(
"error",
err instanceof Error ? err.message : String(err),
);
throw err;
}
},
);
}
private toDomain(doc: Record<string, unknown>): User {
return {
id: doc.id as string,
username: doc.username as string,
passwordHash: doc.passwordHash as string,
};
}
}

View File

@@ -0,0 +1,71 @@
import "reflect-metadata";
import { inject, injectable } from "inversify";
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
import { UnauthenticatedError } from "../../entities/errors/auth";
import { sessionSchema, type Session } from "../../entities/models/session";
import type { Cookie } from "../../entities/models/cookie";
import type { User } from "../../entities/models/user";
import { AUTH_SYMBOLS } from "../../di/symbols";
import { SESSION_COOKIE } from "../../config";
@injectable()
export class MockAuthenticationService implements IAuthenticationService {
private _sessions: Record<string, { session: Session; user: User }> = {};
constructor(
@inject(AUTH_SYMBOLS.IUsersRepository)
private _usersRepository: IUsersRepository,
) {}
generateUserId(): string {
return (Math.random() + 1).toString(36).substring(7);
}
async hashPassword(password: string): Promise<string> {
return `hashed_${password}`;
}
async verifyPassword(hash: string, password: string): Promise<boolean> {
return hash === `hashed_${password}`;
}
async validateSession(
sessionId: string,
): Promise<{ user: User; session: Session }> {
const result = this._sessions[sessionId];
if (!result) {
throw new UnauthenticatedError("Unauthenticated");
}
const user = await this._usersRepository.getUser(result.user.id);
if (!user) {
throw new UnauthenticatedError("Unauthenticated");
}
return { user, session: result.session };
}
async createSession(
user: User,
): Promise<{ session: Session; cookie: Cookie }> {
const session = sessionSchema.parse({
id: "session_" + user.id,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000 * 7),
});
const cookie: Cookie = {
name: SESSION_COOKIE,
value: session.id,
attributes: {},
};
this._sessions[session.id] = { session, user };
return { session, cookie };
}
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
delete this._sessions[sessionId];
return {
blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} },
};
}
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
describe("AuthenticationService", () => {
const service = new AuthenticationService(stubPayloadConfig);
describe("generateUserId", () => {
it("returns a UUID string", () => {
const id = service.generateUserId();
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it("returns unique values", () => {
const ids = Array.from({ length: 5 }, () => service.generateUserId());
const unique = new Set(ids);
expect(unique.size).toBe(5);
});
});
describe("hashPassword + verifyPassword", () => {
it("round-trips: hash then verify returns true", async () => {
const hash = await service.hashPassword("my-secret");
const valid = await service.verifyPassword(hash, "my-secret");
expect(valid).toBe(true);
});
it("returns false for wrong password", async () => {
const hash = await service.hashPassword("my-secret");
const valid = await service.verifyPassword(hash, "wrong-password");
expect(valid).toBe(false);
});
it("returns false for malformed stored hash", async () => {
const valid = await service.verifyPassword(
"not-a-valid-hash",
"anything",
);
expect(valid).toBe(false);
});
});
describe("session methods (require Payload)", () => {
// createSession and validateSession call getPayload() internally,
// so they require a running Payload instance. These are exercised
// by the mock service in use-case tests and by integration tests.
// Here we only test invalidateSession (no Payload dependency).
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
const { blankCookie } = await service.invalidateSession("any-token");
expect(blankCookie.name).toBe("payload-token");
expect(blankCookie.value).toBe("");
expect(blankCookie.attributes.maxAge).toBe(0);
expect(blankCookie.attributes.httpOnly).toBe(true);
expect(blankCookie.attributes.path).toBe("/");
});
});
});

View File

@@ -0,0 +1,180 @@
import crypto from "node:crypto";
import { getPayload, type SanitizedConfig } from "payload";
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
const SALT_LENGTH = 16;
const KEY_LENGTH = 64;
const ITERATIONS = 100_000;
const DIGEST = "sha512";
const SEPARATOR = ":";
const COOKIE_NAME = "payload-token";
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
export class AuthenticationService implements IAuthenticationService {
constructor(private config: SanitizedConfig) {}
generateUserId(): string {
return crypto.randomUUID();
}
async hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
const hash = await new Promise<string>((resolve, reject) => {
crypto.pbkdf2(
password,
salt,
ITERATIONS,
KEY_LENGTH,
DIGEST,
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
},
);
});
return `${salt}${SEPARATOR}${hash}`;
}
async verifyPassword(storedHash: string, password: string): Promise<boolean> {
const parts = storedHash.split(SEPARATOR);
if (parts.length !== 2) return false;
const salt = parts[0]!;
const expectedHash = parts[1]!;
const actualHash = await new Promise<string>((resolve, reject) => {
crypto.pbkdf2(
password,
salt,
ITERATIONS,
KEY_LENGTH,
DIGEST,
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey.toString("hex"));
},
);
});
return crypto.timingSafeEqual(
Buffer.from(expectedHash, "hex"),
Buffer.from(actualHash, "hex"),
);
}
async createSession(
user: User,
): Promise<{ session: Session; cookie: Cookie }> {
const payload = await getPayload({ config: this.config });
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
const token = this.signToken(user.id, payload.secret);
const session: Session = {
id: crypto.randomUUID(),
userId: user.id,
expiresAt,
};
const cookie: Cookie = {
name: COOKIE_NAME,
value: token,
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: SESSION_DURATION_SECONDS,
},
};
return { session, cookie };
}
async validateSession(
token: string,
): Promise<{ user: User; session: Session }> {
const payload = await getPayload({ config: this.config });
const decoded = this.verifyToken(token, payload.secret);
if (!decoded) throw new Error("Invalid or expired session token");
const userDoc = await payload.findByID({
collection: "users" as const,
id: decoded.id,
overrideAccess: true,
});
const user: User = {
id: userDoc.id as string,
username: (userDoc as Record<string, unknown>).username as string,
passwordHash: (userDoc as Record<string, unknown>).passwordHash as string,
};
const session: Session = {
id: token,
userId: user.id,
expiresAt: new Date(decoded.exp * 1000),
};
return { user, session };
}
async invalidateSession(
_sessionId: string,
): Promise<{ blankCookie: Cookie }> {
return {
blankCookie: {
name: COOKIE_NAME,
value: "",
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 0,
},
},
};
}
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
private signToken(userId: string, secret: string): string {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
const body = Buffer.from(
JSON.stringify({ id: userId, collection: "users", exp }),
).toString("base64url");
const signature = crypto
.createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest("base64url");
return `${header}.${body}.${signature}`;
}
/** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */
private verifyToken(
token: string,
secret: string,
): { id: string; exp: number } | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, signature] = parts as [string, string, string];
const expected = crypto
.createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest("base64url");
if (signature !== expected) return null;
try {
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
id: string;
exp: number;
};
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
return decoded;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,20 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
TooManyRequestsError,
} from "../../entities/errors/auth";
import { InputParseError } from "../../entities/errors/common";
export const authProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[AuthenticationError, "UNAUTHORIZED"],
[UnauthenticatedError, "UNAUTHORIZED"],
[UnauthorizedError, "FORBIDDEN"],
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
]),
);

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { authRouter } from "@/integrations/api/router";
import { authContainer } from "@/di/container";
import { AUTH_SYMBOLS } from "@/di/symbols";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
describe("authRouter", () => {
it("exposes signIn, signUp, signOut procedures", () => {
const names = Object.keys(authRouter._def.procedures);
expect(names).toContain("signIn");
expect(names).toContain("signUp");
expect(names).toContain("signOut");
});
it("signIn returns a cookie via container-resolved controller", async () => {
// The router resolves controllers via the authContainer (default mock bindings).
// MockUsersRepository is seeded with alice/password_alice by default.
const caller = authRouter.createCaller({});
const result = await caller.signIn({
username: "alice",
password: "password_alice",
});
expect(result.name).toBe("session");
});
});
describe("authRouter error mapping", () => {
beforeEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
}
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
authContainer
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
.toConstantValue(users);
authContainer
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
.toConstantValue(auth);
});
it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ghost", password: "long-enough" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("UNAUTHORIZED");
}
});
it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ab", password: "x" } as unknown as {
username: string;
password: string;
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});

View File

@@ -0,0 +1,39 @@
import { router } from "@repo/core-shared/trpc/init";
import { authContainer } from "../../di/container";
import { AUTH_SYMBOLS } from "../../di/symbols";
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
import { authProcedure } from "./procedures";
export const authRouter = router({
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignInController>(
AUTH_SYMBOLS.ISignInController,
);
return ctrl(input);
}),
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignUpController>(
AUTH_SYMBOLS.ISignUpController,
);
return ctrl(input);
}),
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignOutController>(
AUTH_SYMBOLS.ISignOutController,
);
return ctrl(input);
}),
});
export type AuthRouter = typeof authRouter;

View File

@@ -0,0 +1,49 @@
import type { CollectionConfig } from "payload";
export const users: CollectionConfig = {
slug: "users",
auth: true,
admin: {
useAsTitle: "email",
},
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
subject: { kind: "self", field: "id" },
},
fields: [
{
name: "displayName",
type: "text",
custom: {
pii: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
{
name: "role",
type: "select",
options: [
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Author", value: "author" },
],
defaultValue: "author",
required: true,
},
{
name: "consentState",
type: "json",
},
],
};

View File

@@ -0,0 +1,2 @@
export { users } from "./collections/users";
// <gen:job-tasks>

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
describe("signInController", () => {
it("returns a cookie on successful sign-in", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
const controller = signInController(useCase);
const result = await controller({
username: "alice",
password: "testpassword",
});
expect(result.name).toBe("session");
expect(result.value).toBeTruthy();
});
it("throws InputParseError on invalid input", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
const controller = signInController(useCase);
await expect(
controller({ username: "ab" } as unknown),
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError when input is not an object", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth, new NoopRateLimit());
const controller = signInController(useCase);
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(
InputParseError,
);
});
});

View File

@@ -0,0 +1,25 @@
import { InputParseError } from "../../entities/errors/common";
import {
signInInputSchema,
type ISignInUseCase,
type SignInOutput,
} from "../../application/use-cases/sign-in.use-case";
function presenter(value: SignInOutput) {
return value.cookie;
}
export type ISignInController = ReturnType<typeof signInController>;
export const signInController =
(signInUseCase: ISignInUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", {
cause: parsed.error,
});
}
const result = await signInUseCase(parsed.data);
return presenter(result);
};

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { InputParseError } from "@/entities/errors/common";
describe("signOutController", () => {
it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
const result = await controller({ sessionId: "any" });
expect(result).toBeUndefined();
});
it("throws InputParseError on missing sessionId", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
await expect(controller({} as unknown)).rejects.toBeInstanceOf(
InputParseError,
);
});
});

View File

@@ -0,0 +1,19 @@
import { InputParseError } from "../../entities/errors/common";
import {
signOutInputSchema,
type ISignOutUseCase,
} from "../../application/use-cases/sign-out.use-case";
export type ISignOutController = ReturnType<typeof signOutController>;
export const signOutController =
(signOutUseCase: ISignOutUseCase) =>
async (input: unknown): Promise<void> => {
const parsed = signOutInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-out input", {
cause: parsed.error,
});
}
await signOutUseCase(parsed.data);
};

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import { signUpController } from "@/interface-adapters/controllers/sign-up.controller";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { signUpUseCase } from "@/application/use-cases/sign-up.use-case";
import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory";
describe("signUpController", () => {
it("returns a cookie on successful sign-up", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
const result = await controller({
username: "carol",
password: "secret_password",
confirmPassword: "secret_password",
});
expect(result.name).toBe("session");
expect(result.value).toBeTruthy();
});
it("throws InputParseError when passwords do not match", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
await expect(
controller({
username: "dave",
password: "secret_password",
confirmPassword: "different_password",
}),
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError when username is too short", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
await expect(
controller({
username: "ab",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -0,0 +1,25 @@
import { InputParseError } from "../../entities/errors/common";
import {
signUpInputSchema,
type ISignUpUseCase,
type SignUpOutput,
} from "../../application/use-cases/sign-up.use-case";
function presenter(value: SignUpOutput) {
return value.cookie;
}
export type ISignUpController = ReturnType<typeof signUpController>;
export const signUpController =
(signUpUseCase: ISignUpUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signUpInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-up input", {
cause: parsed.error,
});
}
const result = await signUpUseCase(parsed.data);
return presenter(result);
};

View File

@@ -0,0 +1,4 @@
// Auth has no React Query option builders today (all auth procedures are
// mutations). This file is the public UI surface for future components
// and queries — extend rather than re-add to root index.ts.
export {};

View File

@@ -0,0 +1,5 @@
// React Query option builders for auth feature procedures.
// Sign-in/up/out are mutations — no query options needed.
// This file is intentionally minimal; expand if read procedures get added.
export {};

View File

@@ -0,0 +1,5 @@
{
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"_comment": "Auth feature mutation testing config. Extends @repo/core-testing/stryker.base.json (ADR-020 L3). Run with `pnpm mutate --filter @repo/auth`.",
"extends": "@repo/core-testing/stryker.base.json"
}

View File

@@ -0,0 +1,50 @@
// Feature-level test: sign-up, then sign-in with the new credentials, then sign-out.
// Constructs the full chain via direct injection (no container rebinding).
import { describe, it, expect } from "vitest";
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { MockUsersRepository } from "../src/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "../src/infrastructure/services/authentication.service.mock";
import { signInUseCase } from "../src/application/use-cases/sign-in.use-case";
import { signUpUseCase } from "../src/application/use-cases/sign-up.use-case";
import { signOutUseCase } from "../src/application/use-cases/sign-out.use-case";
import { signInController } from "../src/interface-adapters/controllers/sign-in.controller";
import { signUpController } from "../src/interface-adapters/controllers/sign-up.controller";
import { signOutController } from "../src/interface-adapters/controllers/sign-out.controller";
describe("auth feature: sign-up → sign-in → sign-out", () => {
it("a new user can sign up, then sign in, then sign out", async () => {
// Construct the full chain via direct injection
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const signIn = signInController(
signInUseCase(users, auth, new NoopRateLimit()),
);
const signUp = signUpController(
signUpUseCase(users, auth, new RecordingEventBus(), undefined),
);
const signOut = signOutController(signOutUseCase(auth));
// signUp returns a cookie (presenter shape)
const signUpCookie = await signUp({
username: "newperson",
password: "verysecret",
confirmPassword: "verysecret",
});
expect(signUpCookie.name).toBe("session");
expect(signUpCookie.value).toBeTruthy();
const signInCookie = await signIn({
username: "newperson",
password: "verysecret",
});
expect(signInCookie.name).toBe("session");
expect(signInCookie.value).toBeTruthy();
// signOut takes { sessionId } and returns void
const signOutResult = await signOut({ sessionId: signInCookie.value });
expect(signOutResult).toBeUndefined();
});
});

View File

@@ -0,0 +1,14 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

4
packages/auth/turbo.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["feature"]
}

View File

@@ -0,0 +1,38 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
import {
DEFAULT_COVERAGE_BANDS,
vitestThresholdsFromBands,
} from "@repo/core-shared/conformance/coverage";
// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared
// helper — one source of truth for the conventional band shape across all
// features (ADR-020). The feature.manifest.ts `coverage.bands` section also
// declares these for boot-time `assertFeatureConformance` (which reads the
// manifest directly, not the vitest config). For features that need
// non-default bands, override here AND in the manifest, then add a drift
// test in core-shared/conformance/.
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
"src/application/services/**",
// Payload CMS collection config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// Pure type-alias file — no executable code
"src/entities/cookie.ts",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

View File

@@ -0,0 +1,32 @@
# @repo/core-analytics
Optional core package providing a vendor-neutral product analytics interface. Scaffold via `pnpm turbo gen core-package analytics`.
## Structure
```
src/
analytics.interface.ts # IAnalytics — track, identify, pageView, flush
noop-analytics.ts # NoopAnalytics (default no-op implementation)
index.ts # Barrel export
```
## Design
`IAnalytics` exposes four methods:
- `track(event, attributes?)` — record a named event with optional attributes
- `identify(user)` — associate subsequent events with a user
- `pageView(path, attributes?)` — record a page-view event
- `flush()` — drain any in-flight queued events (returns `Promise<void>`)
The interface is vendor-neutral: no third-party analytics SDK is bundled. Feature
packages depend on `IAnalytics` only; concrete implementations (e.g. a PostHog
or Segment adapter) are wired at DI bind time in `bind-production`.
`NoopAnalytics` is the default implementation — all methods are no-ops and
`flush()` resolves immediately via `Promise.resolve()`. Use it in dev-seed
bindings and unit tests.
See `docs/architecture/agent-first-workflow-and-conformance.md` for the
dependency-injection conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,39 @@
{
"name": "@repo/core-analytics",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./react": "./src/react/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.0.0",
"jsdom": "^25.0.0",
"react": "^19.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,21 @@
export type AnalyticsAttributeValue = string | number | boolean;
export type AnalyticsUser = {
id: string;
};
export interface IAnalytics {
track(
event: string,
attributes?: Record<string, AnalyticsAttributeValue>,
): void;
identify(
user: AnalyticsUser,
attributes?: Record<string, AnalyticsAttributeValue>,
): void;
pageView(
path: string,
attributes?: Record<string, AnalyticsAttributeValue>,
): void;
flush(): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
export type {
AnalyticsAttributeValue,
AnalyticsUser,
IAnalytics,
} from "./analytics.interface";
export { NoopAnalytics } from "./noop-analytics";
export type { Analyzed } from "./with-analytics";
export { withAnalytics } from "./with-analytics";

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { NoopAnalytics } from "@/noop-analytics";
describe("NoopAnalytics", () => {
it("track() does not throw with event name only", () => {
const analytics = new NoopAnalytics();
expect(() => analytics.track("page_viewed")).not.toThrow();
});
it("track() does not throw with event name and attributes", () => {
const analytics = new NoopAnalytics();
expect(() =>
analytics.track("button_clicked", {
label: "signup",
count: 1,
active: true,
}),
).not.toThrow();
});
it("identify() does not throw with user only", () => {
const analytics = new NoopAnalytics();
expect(() => analytics.identify({ id: "user-123" })).not.toThrow();
});
it("identify() does not throw with user and attributes", () => {
const analytics = new NoopAnalytics();
expect(() =>
analytics.identify({ id: "user-123" }, { plan: "pro", trial: false }),
).not.toThrow();
});
it("pageView() does not throw with path only", () => {
const analytics = new NoopAnalytics();
expect(() => analytics.pageView("/dashboard")).not.toThrow();
});
it("pageView() does not throw with path and attributes", () => {
const analytics = new NoopAnalytics();
expect(() =>
analytics.pageView("/dashboard", { referrer: "/home", duration: 120 }),
).not.toThrow();
});
it("flush() resolves without throwing", async () => {
const analytics = new NoopAnalytics();
await expect(analytics.flush()).resolves.toBeUndefined();
});
it("flush() returns a Promise", () => {
const analytics = new NoopAnalytics();
expect(analytics.flush()).toBeInstanceOf(Promise);
});
});

View File

@@ -0,0 +1,23 @@
import type {
AnalyticsAttributeValue,
AnalyticsUser,
IAnalytics,
} from "./analytics.interface";
export class NoopAnalytics implements IAnalytics {
track(
_event: string,
_attributes?: Record<string, AnalyticsAttributeValue>,
): void {}
identify(
_user: AnalyticsUser,
_attributes?: Record<string, AnalyticsAttributeValue>,
): void {}
pageView(
_path: string,
_attributes?: Record<string, AnalyticsAttributeValue>,
): void {}
flush(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { render, renderHook } from "@testing-library/react";
import { RecordingAnalytics } from "@repo/core-testing";
import {
AnalyticsContextError,
AnalyticsProvider,
useAnalytics,
} from "@/react/index";
function Tracker() {
const analytics = useAnalytics();
analytics.track("test.event");
return null;
}
describe("AnalyticsProvider", () => {
it("makes analytics available through context and track flows through", () => {
const recording = new RecordingAnalytics();
render(
<AnalyticsProvider value={recording}>
<Tracker />
</AnalyticsProvider>,
);
expect(recording.tracked).toContainEqual({ event: "test.event" });
});
});
describe("useAnalytics", () => {
it("throws AnalyticsContextError when called outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(() => renderHook(() => useAnalytics())).toThrow(
AnalyticsContextError,
);
} finally {
spy.mockRestore();
}
});
});

View File

@@ -0,0 +1,33 @@
import { createContext, useContext, type ReactNode } from "react";
import type { IAnalytics } from "../analytics.interface";
const AnalyticsContext = createContext<IAnalytics | null>(null);
export class AnalyticsContextError extends Error {
constructor() {
super("useAnalytics() must be called within an <AnalyticsProvider>.");
this.name = "AnalyticsContextError";
}
}
export function AnalyticsProvider({
value,
children,
}: {
value: IAnalytics;
children: ReactNode;
}) {
return (
<AnalyticsContext.Provider value={value}>
{children}
</AnalyticsContext.Provider>
);
}
export function useAnalytics(): IAnalytics {
const analytics = useContext(AnalyticsContext);
if (analytics === null) {
throw new AnalyticsContextError();
}
return analytics;
}

View File

@@ -0,0 +1,5 @@
export {
AnalyticsProvider,
useAnalytics,
AnalyticsContextError,
} from "./analytics-provider";

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import { withAnalytics, type Analyzed } from "@/with-analytics";
import type { IAnalytics } from "@/analytics.interface";
import { isAnalyzed } from "@repo/core-shared/conformance";
function makeAnalytics(): IAnalytics {
return {
track: () => undefined,
identify: () => undefined,
pageView: () => undefined,
flush: () => Promise.resolve(),
};
}
describe("withAnalytics", () => {
it("returns an Analyzed<F>", () => {
const analytics = makeAnalytics();
const fn = async (_input: { id: string }) => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expectTypeOf(wrapped).toMatchTypeOf<Analyzed<typeof fn>>();
});
it("attaches __analyzed as a non-enumerable property on the wrapped function", () => {
const analytics = makeAnalytics();
const fn = async () => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expect(isAnalyzed(wrapped)).toBe(true);
expect(Object.keys(wrapped)).not.toContain("__analyzed");
});
it("does NOT pollute the original input function with the brand", () => {
const analytics = makeAnalytics();
const fn = async () => ({ ok: true });
const wrapped = withAnalytics(analytics, fn);
expect(isAnalyzed(fn)).toBe(false);
expect(wrapped).not.toBe(fn);
});
it("passes input and output through unchanged", async () => {
const analytics = makeAnalytics();
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
const wrapped = withAnalytics(analytics, fn);
const result = await wrapped({ id: "abc" });
expect(result).toEqual({ ok: true, id: "abc" });
});
it("propagates errors", async () => {
const analytics = makeAnalytics();
const err = new Error("boom");
const wrapped = withAnalytics(analytics, async () => {
throw err;
});
await expect(wrapped()).rejects.toBe(err);
});
});

View File

@@ -0,0 +1,35 @@
import type { IAnalytics } from "./analytics.interface";
import { attachBrand } from "@repo/core-shared/conformance";
/**
* Phantom-type brand attached at wrap time by `withAnalytics`. The conformance
* system uses this as the type-level seam for use cases that declare
* `analyticsEvents: [...]` in their manifest — without `__analyzed`, the
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands it.
* At runtime the brand is a non-enumerable property attached by `attachBrand`
* from `@repo/core-shared/conformance`, so the boot-time assertion can verify
* the binding went through the analytics-aware path.
*/
export type Analyzed<F> = F & { readonly __analyzed: true };
/**
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
* that forwards to `fn` unchanged and carries the `__analyzed` brand. The
* forward closure (instead of returning `fn` directly) keeps the brand on
* a fresh function so the caller's original `fn` is not mutated — important
* when the same factory output is used elsewhere unwrapped (dev-seed paths,
* tests).
*/
export function withAnalytics<Args extends unknown[], R>(
// The wrapper attaches the brand and ensures the analytics dependency is
// available at bind time. Actual `analytics.track()` calls live in the
// use case body — only the use case knows which properties to extract
// from its input/output for the analytics event.
analytics: IAnalytics,
fn: (...args: Args) => Promise<R>,
): Analyzed<(...args: Args) => Promise<R>> {
void analytics;
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
attachBrand(wrapped, "__analyzed");
return wrapped as Analyzed<(...args: Args) => Promise<R>>;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,17 @@
import path from "node:path";
import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(
nodeVitestConfig,
defineConfig({
test: {
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
setupFiles: ["@repo/core-testing/setup/jsdom"],
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
}),
);

View File

@@ -0,0 +1,51 @@
# AGENTS.md — core-api
**Tag:** core-composition
**Composition-only package** that aggregates feature tRPC routers into a single root `appRouter`. It does not define procedures; instead, it imports them from feature packages.
## Responsibilities
- **Compose tRPC appRouter** — merges feature routers into `t.router({ ... })`
- **Type export** — exports `AppRouter` type for frontend type safety
- **No procedure definitions** — all routers owned by their respective features (`@repo/auth`, `@repo/blog`, etc.)
- **No business logic** — purely structural assembly
## Allowed imports
- **`@repo/<feature>/api`** subpath exports only (to get tRPC routers)
- e.g., `import { authRouter } from "@repo/auth/api"`
- e.g., `import { blogRouter } from "@repo/blog/api"`
- `@repo/core-shared/trpc/init` — for `t.router()` builder
## Must NOT import
- Any feature's root package or other subpaths (e.g., NOT `@repo/blog/di`, NOT `@repo/blog/entities`)
- Any app package
- `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui`
## Public exports
From `package.json`:
- `.` — `appRouter` and `AppRouter` type
Example usage:
```typescript
import { appRouter, type AppRouter } from "@repo/core-api";
```
## Test conventions
- No unit tests (composition layer)
- Verify at app boot: `pnpm dev --filter @repo/web-next` succeeds and tRPC client fetches data
- Run router health check: `pnpm typecheck` confirms `AppRouter` type is valid
## Structure
```
src/
root.ts # t.router({ ... }) aggregating all feature routers
index.ts # re-exports appRouter + type
```

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,30 @@
{
"name": "@repo/core-api",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/core-consent": "workspace:*",
"@repo/core-dsr": "workspace:*",
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1 @@
export { appRouter, type AppRouter } from "./root";

View File

@@ -0,0 +1,13 @@
import { router } from "@repo/core-shared/trpc/init";
import { authRouter } from "@repo/auth/api";
import { dsrRouter } from "@repo/core-dsr";
import { consentRouter } from "@repo/core-consent";
export const appRouter = router({
auth: authRouter,
// gen:routers — optional-core routers composed below
dsr: dsrRouter,
consent: consentRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,384 @@
import { describe, it, expect, beforeEach } from "vitest";
import { router } from "@repo/core-shared/trpc/init";
import { appRouter } from "./root";
import { createDsrRouter } from "@repo/core-dsr";
import type { DsrBinding } from "@repo/core-dsr";
import type { DsrTrpcUser } from "@repo/core-dsr";
import { consentRouter } from "@repo/core-consent";
import type { ConsentFactory } from "@repo/core-consent";
import {
RecordingDataExport,
RecordingDataDelete,
RecordingDataRectify,
RecordingProcessingRestriction,
RecordingConsent,
} from "@repo/core-testing/instrumentation";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeDsrBinding() {
return {
dataExport: new RecordingDataExport(),
dataDelete: new RecordingDataDelete(),
dataRectify: new RecordingDataRectify(),
processingRestriction: new RecordingProcessingRestriction(),
};
}
type DsrTestBinding = ReturnType<typeof makeDsrBinding>;
function makeIntegrationRouter(dsrBinding: DsrTestBinding) {
return router({
dsr: createDsrRouter(dsrBinding as unknown as DsrBinding),
consent: consentRouter,
});
}
type IntegrationRouter = ReturnType<typeof makeIntegrationRouter>;
function makeCaller(
testRouter: IntegrationRouter,
userId: string,
consentFactory: ConsentFactory,
roles: string[] = ["user"],
) {
return testRouter.createCaller({
user: { id: userId, roles } as DsrTrpcUser,
userId,
consentFactory,
} as Record<string, unknown>);
}
function makeUnauthCaller(
testRouter: IntegrationRouter,
consentFactory: ConsentFactory,
) {
return testRouter.createCaller({
consentFactory,
} as Record<string, unknown>);
}
// ---------------------------------------------------------------------------
// Structure tests — appRouter composition
// ---------------------------------------------------------------------------
describe("appRouter composition", () => {
it("exposes auth router", () => {
const procedures = appRouter._def.procedures;
const keys = Object.keys(procedures);
expect(keys.some((k) => k.startsWith("auth."))).toBe(true);
});
it("exposes dsr and consent routers", () => {
const keys = Object.keys(appRouter._def.procedures);
expect(keys.some((k) => k.startsWith("dsr."))).toBe(true);
expect(keys.some((k) => k.startsWith("consent."))).toBe(true);
});
it("dsr router exposes all four procedures", () => {
const procedures = appRouter._def.procedures;
expect(procedures).toHaveProperty("dsr.export");
expect(procedures).toHaveProperty("dsr.delete");
expect(procedures).toHaveProperty("dsr.rectify");
expect(procedures).toHaveProperty("dsr.restrict");
});
it("consent router exposes all four procedures", () => {
const procedures = appRouter._def.procedures;
expect(procedures).toHaveProperty("consent.grant");
expect(procedures).toHaveProperty("consent.withdraw");
expect(procedures).toHaveProperty("consent.isGranted");
expect(procedures).toHaveProperty("consent.getCategories");
});
});
// ---------------------------------------------------------------------------
// Integration tests — dsr procedures
// ---------------------------------------------------------------------------
describe("dsr.export — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with UserDataBundle body for authenticated user", async () => {
const result = await caller.dsr.export({
subjectId: "alice",
format: "json",
});
expect(result.subjectId).toBe("alice");
expect(result.format).toBe("json");
expect(binding.dataExport.calls).toHaveLength(1);
});
it("resolves with json-ld format", async () => {
const result = await caller.dsr.export({
subjectId: "alice",
format: "json-ld",
});
expect(result.format).toBe("json-ld");
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.export({ subjectId: "alice", format: "json" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.delete — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
});
it("resolves with DeletionCertificate for soft mode (authenticated user)", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const caller = makeCaller(testRouter, "alice", factory);
const result = await caller.dsr.delete({
subjectId: "alice",
mode: "soft",
});
expect(result.subjectId).toBe("alice");
expect(result.mode).toBe("soft");
expect(binding.dataDelete.calls).toHaveLength(1);
});
it("resolves with DeletionCertificate for cascade-hard mode (admin)", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const adminCaller = makeCaller(testRouter, "admin", factory, ["admin"]);
const result = await adminCaller.dsr.delete({
subjectId: "alice",
mode: "cascade-hard",
});
expect(result.mode).toBe("cascade-hard");
});
it("throws FORBIDDEN for cascade-hard when user lacks admin role", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const caller = makeCaller(testRouter, "alice", factory, ["user"]);
await expect(
caller.dsr.delete({ subjectId: "alice", mode: "cascade-hard" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.delete({ subjectId: "alice", mode: "soft" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.rectify — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with { ok: true } for authenticated user", async () => {
const result = await caller.dsr.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice Updated",
});
expect(result).toEqual({ ok: true });
expect(binding.dataRectify.calls[0]).toMatchObject({
subjectId: "alice",
collection: "users",
field: "name",
});
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "x",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.restrict — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with { ok: true } when granting restriction", async () => {
const result = await caller.dsr.restrict({
subjectId: "alice",
granted: true,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]).toMatchObject({
subjectId: "alice",
granted: true,
});
});
it("resolves with { ok: true } when lifting restriction", async () => {
const result = await caller.dsr.restrict({
subjectId: "alice",
granted: false,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.restrict({ subjectId: "alice", granted: true }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
// ---------------------------------------------------------------------------
// Integration tests — consent procedures
// ---------------------------------------------------------------------------
describe("consent — integration", () => {
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
const binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "user-1", factory);
});
describe("consent.grant", () => {
it("resolves with { success: true } and records the grant", async () => {
const result = await caller.consent.grant({ category: "analytics" });
expect(result).toEqual({ success: true });
expect(consent.grants).toHaveLength(1);
expect(consent.grants[0]!.category).toBe("analytics");
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.grant({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.withdraw", () => {
it("resolves with { success: true } and records the withdrawal", async () => {
await caller.consent.grant({ category: "marketing" });
const result = await caller.consent.withdraw({ category: "marketing" });
expect(result).toEqual({ success: true });
expect(consent.withdrawals).toHaveLength(1);
expect(consent.withdrawals[0]).toBe("marketing");
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.withdraw({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.isGranted", () => {
it("resolves with { granted: false } before any grant", async () => {
const result = await caller.consent.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: false });
});
it("resolves with { granted: true } after grant", async () => {
await caller.consent.grant({ category: "analytics" });
const result = await caller.consent.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: true });
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.isGranted({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.getCategories", () => {
it("resolves with { categories: [] } initially", async () => {
const result = await caller.consent.getCategories({});
expect(result).toEqual({ categories: [] });
});
it("resolves with all granted categories", async () => {
await caller.consent.grant({ category: "necessary" });
await caller.consent.grant({ category: "analytics" });
const { categories } = await caller.consent.getCategories({});
expect(categories).toHaveLength(2);
const names = categories.map((c) => c.category).sort();
expect(names).toEqual(["analytics", "necessary"]);
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.getCategories({}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"declaration": false,
"declarationMap": false,
"types": ["vitest/globals"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core-composition"]
}

View File

@@ -0,0 +1,7 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

View File

@@ -0,0 +1,28 @@
# @repo/core-audit
Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`.
## Structure
```
src/
audit-log.interface.ts # IAuditLog extends AuditLogProtocol
audit-logs-collection.ts # Payload collection (append-only)
noop-audit-log.ts # NoopAuditLog
payload-audit-log.ts # PayloadAuditLog (local cache impl)
stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink)
multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper)
trace-id-enriching-audit-log.ts # OTel correlation decorator
pseudonymize.ts # sha256-with-salt for GDPR pseudonymization
di/bind-audit.ts # bindAudit binder
integrations/api/router.ts # admin tRPC procedure
hooks/ # Payload hook factories
```
## Compliance posture
- `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log".
- Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path.
- `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time.
See `docs/guides/audit-and-compliance.md` for the full guide.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,47 @@
{
"name": "@repo/core-audit",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./collection": "./src/audit-logs-collection.ts",
"./di": "./src/di/bind-audit.ts",
"./hooks": "./src/hooks/index.ts",
"./api": "./src/integrations/api/router.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"zod": "^3.23.0"
},
"peerDependencies": {
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": {
"optional": true
}
},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/context-async-hooks": "^1.28.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.2.4",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,22 @@
import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols";
import type { AuditEntry } from "@repo/core-shared/audit";
/**
* Full audit log interface. Extends the minimal `AuditLogProtocol` from
* core-shared with the privileged `eraseSubject` op for GDPR erasure.
*
* Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol`
* (record). Admin-path code that needs erasure imports this full interface.
*
* The `extends` link forces typecheck failure if either side narrows below
* the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster,
* IRealtimeHandlerRegistry, IMetrics.
*/
export interface IAuditLog extends AuditLogProtocol {
// record(entry: AuditEntry): Promise<void> — inherited from protocol
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
}
// Re-export AuditEntry for convenience (so consumers don't always need
// to dual-import from @repo/core-shared/audit).
export type { AuditEntry };

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { auditLogsCollection } from "./audit-logs-collection";
describe("auditLogsCollection", () => {
it("uses slug 'audit-logs'", () => {
expect(auditLogsCollection.slug).toBe("audit-logs");
});
it("is append-only (update: () => false)", () => {
const access = auditLogsCollection.access as Record<
string,
(() => boolean) | undefined
>;
expect(access["update"]?.()).toBe(false);
});
it("has the required fields", () => {
const fieldNames = (
auditLogsCollection.fields as Array<{ name: string }>
).map((f) => f.name);
// WHO
expect(fieldNames).toContain("actorId");
expect(fieldNames).toContain("actorType");
expect(fieldNames).toContain("actorRoles");
// WHAT
expect(fieldNames).toContain("action");
expect(fieldNames).toContain("resourceType");
expect(fieldNames).toContain("resourceId");
expect(fieldNames).toContain("changedFields");
// SCOPE
expect(fieldNames).toContain("scopeFeature");
expect(fieldNames).toContain("scopeEnvironment");
expect(fieldNames).toContain("scopeTenant");
// WHY
expect(fieldNames).toContain("reason");
expect(fieldNames).toContain("correlationId");
expect(fieldNames).toContain("requestId");
// FROM
expect(fieldNames).toContain("ipTruncated");
expect(fieldNames).toContain("userAgent");
// PII
expect(fieldNames).toContain("containsPii");
expect(fieldNames).toContain("piiCategories");
// OUTCOME
expect(fieldNames).toContain("outcome");
expect(fieldNames).toContain("errorCode");
});
it("enables timestamps so createdAt maps to AuditEntry.at", () => {
expect(auditLogsCollection.timestamps).toBe(true);
});
});

View File

@@ -0,0 +1,89 @@
import type { CollectionConfig } from "payload";
/**
* Append-only Payload collection for audit entries. Mounted by core-cms
* when this package is scaffolded (manual wiring step printed by generator).
*
* Access rules:
* - read: admins only
* - create: any authenticated context (filtered upstream by PayloadAuditLog)
* - update: NEVER (compliance requires append-only)
* - delete: admins only (used by the GDPR erasure path with overrideAccess)
*
* The `update: () => false` rule is the compliance backbone. The erasure
* path uses `overrideAccess: true` to bypass for pseudonymization — that's
* Payload's documented escape hatch for privileged operations.
*/
export const auditLogsCollection: CollectionConfig = {
slug: "audit-logs",
access: {
read: ({ req }) => {
const user = req.user as { roles?: string[] } | null | undefined;
return Array.isArray(user?.roles) && user.roles.includes("admin");
},
create: () => true,
update: () => false,
delete: ({ req }) => {
const user = req.user as { roles?: string[] } | null | undefined;
return Array.isArray(user?.roles) && user.roles.includes("admin");
},
},
timestamps: true,
fields: [
// WHO
{ name: "actorId", type: "text", required: true, index: true },
{
name: "actorType",
type: "select",
options: ["user", "system", "service"],
required: true,
},
{ name: "actorRoles", type: "json", required: true },
// WHAT
{
name: "action",
type: "select",
options: [
"VIEW",
"CREATE",
"UPDATE",
"DELETE",
"EXPORT",
"PERMISSION_CHANGE",
],
required: true,
index: true,
},
{ name: "resourceType", type: "text", required: true, index: true },
{ name: "resourceId", type: "text" },
{ name: "changedFields", type: "json" },
// SCOPE
{ name: "scopeFeature", type: "text", required: true, index: true },
{ name: "scopeEnvironment", type: "text", required: true },
{ name: "scopeTenant", type: "text", required: true, index: true },
// WHY
{ name: "reason", type: "text" },
{ name: "correlationId", type: "text", index: true },
{ name: "requestId", type: "text" },
// FROM
{ name: "ipTruncated", type: "text", required: true },
{ name: "userAgent", type: "text", required: true },
// PII
{ name: "containsPii", type: "checkbox", required: true },
{ name: "piiCategories", type: "json" },
// OUTCOME
{
name: "outcome",
type: "select",
options: ["success", "denied", "error"],
required: true,
},
{ name: "errorCode", type: "text" },
],
};

View File

@@ -0,0 +1,66 @@
import "reflect-metadata";
import { describe, it, expect } from "vitest";
import { Container } from "inversify";
import { bindAudit } from "./bind-audit";
import { AUDIT_SYMBOLS } from "./symbols";
import { NoopAuditLog } from "../noop-audit-log";
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
import { PayloadAuditLog } from "../payload-audit-log";
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
import type { IAuditLog } from "../audit-log.interface";
describe("bindAudit", () => {
it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => {
const container = new Container();
bindAudit(container, { payloadConfig: {} as never });
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(
MultiSinkAuditLog,
);
});
it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => {
const container = new Container();
bindAudit(container, {});
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(
StdoutJsonAuditLog,
);
});
it("returns NoopAuditLog when sinks=[]", () => {
const container = new Container();
bindAudit(container, { sinks: [] });
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(
NoopAuditLog,
);
});
it("returns PayloadAuditLog when sinks=['payload'] only", () => {
const container = new Container();
bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] });
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(
PayloadAuditLog,
);
});
it("validates AUDIT_PSEUDONYM_SALT in production", () => {
const env = process.env as Record<string, string | undefined>;
const oldEnv = env["NODE_ENV"];
const oldSalt = env["AUDIT_PSEUDONYM_SALT"];
env["NODE_ENV"] = "production";
delete env["AUDIT_PSEUDONYM_SALT"];
expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow(
/AUDIT_PSEUDONYM_SALT/,
);
env["NODE_ENV"] = oldEnv;
if (oldSalt) env["AUDIT_PSEUDONYM_SALT"] = oldSalt;
});
});

View File

@@ -0,0 +1,71 @@
import "reflect-metadata";
import type { Container } from "inversify";
import { getPayload as _getPayload, type SanitizedConfig } from "payload";
import { NoopAuditLog } from "../noop-audit-log";
import { PayloadAuditLog } from "../payload-audit-log";
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
import type { IAuditLog } from "../audit-log.interface";
import { AUDIT_SYMBOLS } from "./symbols";
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
export type BindAuditOpts = {
/** Payload config; required if "payload" is in sinks. */
payloadConfig?: SanitizedConfig;
/** Sink selection. Default ["payload", "stdout"]. */
sinks?: ("payload" | "stdout")[];
};
/**
* Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`.
*
* Default sink set: ["payload", "stdout"] — Payload local cache + structured
* JSON to stdout (operator wires a log shipper to the centralized aggregator).
*
* In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast
* if not — better to refuse to start than to ship audit data with a dev-fallback
* salt that an attacker could reverse.
*
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
* so all sinks receive AuditEntry.correlationId auto-populated from the
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
*/
export function bindAudit(
container: Container,
opts: BindAuditOpts = {},
): { auditLog: IAuditLog } {
if (
process.env.NODE_ENV === "production" &&
!process.env.AUDIT_PSEUDONYM_SALT
) {
throw new Error(
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
);
}
const sinkList = opts.sinks ?? ["payload", "stdout"];
const sinks: IAuditLog[] = [];
if (sinkList.includes("payload") && opts.payloadConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sinks.push(new PayloadAuditLog(opts.payloadConfig, _getPayload as any));
}
if (sinkList.includes("stdout")) {
sinks.push(new StdoutJsonAuditLog());
}
const inner: IAuditLog =
sinks.length > 1
? new MultiSinkAuditLog(sinks)
: sinks.length === 1
? sinks[0]!
: new NoopAuditLog();
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
container.unbind(AUDIT_SYMBOLS.IAuditLog);
}
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
return { auditLog };
}

Some files were not shown because too many files have changed in this diff Show More