Initial commit
This commit is contained in:
1263
docs/guides/adding-a-feature.md
Normal file
1263
docs/guides/adding-a-feature.md
Normal file
File diff suppressed because it is too large
Load Diff
170
docs/guides/adding-a-library.md
Normal file
170
docs/guides/adding-a-library.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Adding a library
|
||||
|
||||
Human reading-room guide for the library evaluation policy. For decision-record density, see [ADR-022](../decisions/adr-022-library-evaluation-policy.md). For the agent runbook, see [`.claude/skills/evaluate-library/SKILL.md`](../../.claude/skills/evaluate-library/SKILL.md).
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
This repo ships with a deliberately narrow third-party surface. Every feature package carries the same six runtime dependencies — `@repo/core-shared`, `@trpc/server`, `inversify`, `payload`, `reflect-metadata`, `zod` — and nothing else. That uniformity isn't an accident; it's the result of unstated discipline that the boundary-tag system and the manifest-first ordering silently reward.
|
||||
|
||||
The discipline was not codified. Before ADR-022, anyone — human or agent — could run `pnpm add <pkg>` with no checkpoint between intent and lockfile. Three signals made the gap undeniable:
|
||||
|
||||
1. **The 2026-05-14 OpenAPI near-miss.** An exploratory session nearly added `trpc-to-openapi`, `zod-to-json-schema`, and a build-time generator before someone asked "who calls this code path?" The honest answer was nobody — all callers were TypeScript using `createCaller`. The library would have shipped ~30 lines of `.meta({...})` annotations per router and a `superjson`-incompatible HTTP handler in exchange for zero consumers. Pure carrying cost, caught by a chance question, not by a system.
|
||||
|
||||
2. **Post-hoc ADRs don't prevent bad choices.** ADR-002 (Inversify), ADR-014 (Sentry), and ADR-017 (OpenTelemetry) were all written after adoption. By the time the record existed, the lockfile already held the dep. No mechanism existed to catch a bad choice before it became a migration project.
|
||||
|
||||
3. **No audit for unintended adoption.** `pnpm fallow` audits dead code; `pnpm conformance` audits manifest drift; `pnpm coverage:diff` audits change coverage. There was no equivalent for "did we just adopt a library nobody asked for?"
|
||||
|
||||
A fourth pressure: this template is EU-resident and GDPR-bound. A library that defaults to a US-only SaaS endpoint silently moves user data out of the EU the moment it's imported and configured with defaults. The old process had no point where that was caught.
|
||||
|
||||
ADR-022 codifies the de-facto discipline, formalises the enforcement stack, and makes rejection records first-class so future agents don't re-evaluate libraries that were already rejected for known reasons.
|
||||
|
||||
---
|
||||
|
||||
## When the policy applies (tier trigger)
|
||||
|
||||
The policy maps onto the workspace boundary-tag system already enforced by ESLint and Turborepo. No new mental model required.
|
||||
|
||||
| Where the dep lands | Process required | Companion record |
|
||||
| -------------------------------------------- | ------------------- | ---------------- |
|
||||
| `apps/<x>` (any app) | Author's call; none | – |
|
||||
| `devDependencies` in any package | Exempt | – |
|
||||
| `feature` package (e.g. `packages/auth`) | Trace required | – |
|
||||
| `core` package (e.g. `packages/core-shared`) | Trace required | ADR required |
|
||||
| New optional-core category | Trace required | ADR required |
|
||||
|
||||
**App-tier** additions (Next.js, Payload CMS, TanStack Start) are at the author's discretion. Apps have clear blast-radius bounds; the conformance system doesn't govern their `node_modules`.
|
||||
|
||||
**Dev-deps** (linters, test runners, type-only packages) are exempt. They never run in production, never move user data, and are already bounded by the tooling packages (`core-eslint`, `core-typescript`).
|
||||
|
||||
**Feature- and core-tier** runtime deps require a trace — this is where the boundary-tag rules govern what can be imported, and where a bad choice propagates across all apps that consume the feature.
|
||||
|
||||
---
|
||||
|
||||
## Four enforcement layers
|
||||
|
||||
The policy enforces at four latencies, mirroring the shape of the conformance system (ADR-012):
|
||||
|
||||
| Layer | Latency | What it catches |
|
||||
| ------------------------------------------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude `PreToolUse`/`PostToolUse` hook** | Inline (before `pnpm add` runs) | Agent skipping the skill before editing `package.json` or running `pnpm add`. Injects a `<system-reminder>` directing the agent to the skill — does **not** auto-deny, because app-tier and devdep additions are common and exempt. |
|
||||
| **`/evaluate-library` skill** | Seconds | The decision itself: walks the eight filters, writes the trace file to `docs/library-decisions/`, and returns pass/fail. |
|
||||
| **Git pre-commit hook** | Pre-commit | Humans or agents who ran `pnpm add` without invoking the skill. The hook detects a new runtime dep in a feature/core `package.json` without a corresponding trace file and blocks the commit. |
|
||||
| **Sandcastle reviewer prompt** | Per-slice | Bypasses that slipped past pre-commit (e.g. direct lockfile edits). The reviewer checks for unevaluated deps before approving a slice. |
|
||||
|
||||
The hook is a reminder-injector, not a blocker — false positives (app-tier, devdeps) are common. The pre-commit hook is the deterministic gate for humans. The skill is the natural path both layers point to.
|
||||
|
||||
---
|
||||
|
||||
## How to add a library
|
||||
|
||||
### 1. Check whether the policy applies
|
||||
|
||||
Is the dep going into a `feature` or `core` package as a runtime dependency? If yes, continue. If it's going into an `apps/<x>` directory or as a devDependency, you can skip the evaluation (see [tier trigger](#when-the-policy-applies-tier-trigger) above).
|
||||
|
||||
### 2. Check for an existing trace
|
||||
|
||||
```bash
|
||||
ls docs/library-decisions/ | grep <package-name>
|
||||
```
|
||||
|
||||
If a trace already exists and is marked `decision: rejected`, read it before proceeding. The rejection reasoning is the permanent record — if circumstances have changed (e.g. a named consumer now exists), you can re-evaluate and write a new trace. Otherwise, the existing rejection stands.
|
||||
|
||||
### 3. Invoke the evaluate-library skill
|
||||
|
||||
```
|
||||
/evaluate-library <package-name> --tier <feature|core> --target <package-path>
|
||||
```
|
||||
|
||||
The skill walks the eight filters in collect-cheap-skip-expensive order:
|
||||
|
||||
- **Cheap filters** (always complete, even on failure): `license`, `types`, `shadow-check`, `boundary-fit`
|
||||
- **Expensive filters** (short-circuit on first failure): `maintenance`, `cve-scan`, `eu-residency`, `named-consumer`
|
||||
|
||||
It then answers the three discussion prompts and writes a trace file to `docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`.
|
||||
|
||||
### 4. Read the result
|
||||
|
||||
The skill returns `approved` or `rejected` with the filter that caused rejection (if any). The trace file is the permanent record either way.
|
||||
|
||||
- **Approved** → the trace is written; proceed with `pnpm add`.
|
||||
- **Rejected** → the trace records the failure; do not add the library. If you believe the rejection was wrong, re-evaluate with new evidence rather than bypassing.
|
||||
|
||||
### 5. Add the library and commit
|
||||
|
||||
After an `approved` trace:
|
||||
|
||||
```bash
|
||||
pnpm add <package-name> --filter <target-package>
|
||||
```
|
||||
|
||||
Include the trace file in the same commit as the `package.json` and lockfile changes. The pre-commit hook verifies this pairing.
|
||||
|
||||
### 6. For core-tier additions: write the ADR
|
||||
|
||||
If the dep lands in a `core` package or new optional-core, write an ADR before or alongside the trace. The `alternatives-considered` section of the trace is duplicated into the ADR. Cite the ADR in the trace's `adr:` frontmatter field.
|
||||
|
||||
---
|
||||
|
||||
## Worked example: approved (`clsx`)
|
||||
|
||||
**Scenario:** Adding `clsx` to `packages/navigation` for conditional class-name composition in UI components.
|
||||
|
||||
**Tier:** `feature` (packages/navigation is a feature package).
|
||||
|
||||
**Evaluation summary:**
|
||||
|
||||
| Filter | Result |
|
||||
| -------------- | ------------------------------------------------------------------------ |
|
||||
| license | MIT — on allowlist |
|
||||
| types | Native `.d.ts` — pass |
|
||||
| maintenance | Last release 15 months ago, active PR triage — pass |
|
||||
| boundary-fit | Pure string utility, zero transitive deps, no vendor SDK — pass |
|
||||
| shadow-check | No existing class-composition utility in workspace — pass |
|
||||
| eu-residency | No network calls, no vendor endpoint — n/a |
|
||||
| cve-scan | 0 vulnerabilities — clean |
|
||||
| named-consumer | `navigation-menu.tsx` + `mobile-nav.tsx` both blocked on this — **pass** |
|
||||
|
||||
**Decision: approved.** Named consumer exists today (not hypothetically), migration cost is mechanical (swap back to ternaries), alternatives evaluated (`classnames`, inline ternaries, `tailwind-merge`).
|
||||
|
||||
Full trace: [`.claude/skills/evaluate-library/EXAMPLES/approved-example.md`](../../.claude/skills/evaluate-library/EXAMPLES/approved-example.md)
|
||||
|
||||
---
|
||||
|
||||
## Worked example: rejected (`trpc-to-openapi`)
|
||||
|
||||
**Scenario:** Exposing the tRPC router surface as a REST API for potential external consumers.
|
||||
|
||||
**Tier:** `core` (would land alongside the tRPC router configuration in a core package).
|
||||
|
||||
**Evaluation summary:**
|
||||
|
||||
| Filter | Result |
|
||||
| -------------- | ------------------------------------------------------------------- |
|
||||
| license | MIT — pass |
|
||||
| types | Native — pass |
|
||||
| maintenance | Active — pass |
|
||||
| boundary-fit | Imports `@trpc/server` and `zod` (both workspace must-haves) — pass |
|
||||
| shadow-check | No existing OpenAPI generator in workspace — pass |
|
||||
| eu-residency | Pure in-process, no vendor endpoint — n/a |
|
||||
| cve-scan | Clean — pass |
|
||||
| named-consumer | **No named consumer — FAIL** |
|
||||
|
||||
**Decision: rejected.** Seven of eight filters passed. The failure was `named-consumer`. All current API callers are TypeScript using `createCaller`; no HTTP REST clients exist, and no external consumers are blocked waiting for an OpenAPI spec. Speculative future partners do not count as consumers.
|
||||
|
||||
This trace is the permanent record. The next agent considering `trpc-to-openapi` finds it in `ls docs/library-decisions/` in under a second and does not re-litigate. If a concrete integration is later planned, re-open the evaluation with the integration as the named consumer.
|
||||
|
||||
Full trace: [`.claude/skills/evaluate-library/EXAMPLES/rejected-trpc-to-openapi.md`](../../.claude/skills/evaluate-library/EXAMPLES/rejected-trpc-to-openapi.md)
|
||||
|
||||
---
|
||||
|
||||
## Cross-links
|
||||
|
||||
- [ADR-022 — Library evaluation policy](../decisions/adr-022-library-evaluation-policy.md) — the authoritative decision record with full rationale, alternatives considered, and consequences
|
||||
- [`docs/library-decisions/_template.md`](../library-decisions/_template.md) — trace file template; the skill uses this shape automatically, but it's useful when writing or reviewing a trace manually
|
||||
- [`.claude/skills/evaluate-library/SKILL.md`](../../.claude/skills/evaluate-library/SKILL.md) — agent runbook; the authoritative guide for agents running the evaluation
|
||||
- [ADR-006](../decisions/adr-006-vertical-feature-packages.md) — vertical feature packages (the boundary-tag system the tier trigger maps to)
|
||||
- [ADR-010](../decisions/adr-010-turbo-boundaries.md) — Turborepo boundaries (enforcement substrate)
|
||||
- [ADR-017](../decisions/adr-017-opentelemetry-vendor-isolation.md) — vendor isolation pattern (motivates the boundary-fit and EU-residency filters)
|
||||
- [`docs/glossary.md`](../glossary.md) — entries for **Library trace** and **Pre-shipped trace**
|
||||
360
docs/guides/analytics.md
Normal file
360
docs/guides/analytics.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Product analytics
|
||||
|
||||
> **Prerequisite:** This guide assumes `@repo/core-analytics` is installed. If
|
||||
> your project started from the slim template, run
|
||||
> `pnpm turbo gen core-package analytics` first, then follow the wiring steps
|
||||
> below.
|
||||
|
||||
`@repo/core-analytics` is the fourth capture channel after tracing, error
|
||||
capture, and audit. It provides a typed, vendor-neutral `IAnalytics` interface
|
||||
that feature use cases call at the same call sites where they call
|
||||
`auditLog.record()` or `bus.publish()`. The conformance system applies the same
|
||||
five-latency drift detection (tsc → ESLint → boot assertion → conformance CI →
|
||||
fallow) that already protects every other channel.
|
||||
|
||||
For design rationale and alternatives considered see `docs/decisions/adr-024-product-analytics-channel.md`.
|
||||
|
||||
---
|
||||
|
||||
## Interface
|
||||
|
||||
```ts
|
||||
// @repo/core-analytics
|
||||
|
||||
export type AnalyticsAttributeValue = string | number | boolean;
|
||||
|
||||
export type AnalyticsUser = { id: string };
|
||||
|
||||
export interface IAnalytics {
|
||||
/** Emit a named event. Conforms to manifest's analyticsEvents. */
|
||||
track(
|
||||
event: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
|
||||
/** Associate attributes with a user. Not gated by the manifest. */
|
||||
identify(
|
||||
user: AnalyticsUser,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
|
||||
/** Client-side route change. Server impls no-op by convention. */
|
||||
pageView(
|
||||
path: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
|
||||
/** Drain the in-memory batch. Wire into graceful-shutdown and serverless-response-finish hooks. */
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
The template ships two implementations:
|
||||
|
||||
| Implementation | Package | Use |
|
||||
| -------------------- | ---------------------- | -------------------------------- |
|
||||
| `NoopAnalytics` | `@repo/core-analytics` | Dev-seed and test fallback |
|
||||
| `RecordingAnalytics` | `@repo/core-testing` | Unit and integration test double |
|
||||
|
||||
Your production backend (PostHog, Segment, Mixpanel, …) is a third
|
||||
implementation you add after the library evaluation step (§ Vendor evaluation
|
||||
below).
|
||||
|
||||
---
|
||||
|
||||
## Server-side wiring
|
||||
|
||||
### 1. Declare events in the manifest
|
||||
|
||||
Every analytics event a use case emits must be declared in
|
||||
`src/feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/feature.manifest.ts
|
||||
export const authManifest = defineFeature({
|
||||
name: "auth",
|
||||
requiredCores: ["analytics"],
|
||||
useCases: {
|
||||
signUp: {
|
||||
mutates: true,
|
||||
audits: ["user.created"],
|
||||
publishes: ["auth.user-signed-up"],
|
||||
consumes: [],
|
||||
analyticsEvents: ["user.signed-up"], // declare here
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
```
|
||||
|
||||
`requiredCores: ["analytics"]` tells the `required-cores-installed` ESLint rule
|
||||
to verify that `@repo/core-analytics` is present in the workspace.
|
||||
|
||||
### 2. Call `analytics.track()` in the use-case factory
|
||||
|
||||
The use case receives `analytics` as a dep, guarded with `?.` so the feature
|
||||
compiles when no analytics instance is provided (dev seed default):
|
||||
|
||||
```ts
|
||||
// packages/auth/src/application/use-cases/sign-up.use-case.ts
|
||||
import type { AnalyticsProtocol } from "@repo/core-shared/di";
|
||||
|
||||
export function signUpUseCase(deps: {
|
||||
usersRepo: IUsersRepository;
|
||||
analytics?: AnalyticsProtocol;
|
||||
}) {
|
||||
return async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
const user = await deps.usersRepo.create(input);
|
||||
deps.analytics?.track("user.signed-up", { plan: input.plan });
|
||||
return signUpOutputSchema.parse(user);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Use `AnalyticsProtocol` (from `@repo/core-shared/di`) as the dep type — it
|
||||
exposes only the `track` method, which is all a use case needs. Reserve
|
||||
`IAnalytics` for call sites that need `identify`, `pageView`, or `flush`.
|
||||
|
||||
### 3. Wrap with `withAnalytics` in `bind-production.ts`
|
||||
|
||||
Apply `withAnalytics` inside the existing wrapper chain. Composition order
|
||||
(innermost → outermost): `factory(deps)` → `withAnalytics` → `withAudit` →
|
||||
`withCapture` → `withSpan`.
|
||||
|
||||
```ts
|
||||
// packages/auth/src/di/bind-production.ts
|
||||
import { withAnalytics } from "@repo/core-analytics";
|
||||
|
||||
export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
const analytics = ctx.analytics;
|
||||
|
||||
authContainer
|
||||
.bind<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase)
|
||||
.toDynamicValue(() =>
|
||||
withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "auth.signUp" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "auth" },
|
||||
withAudit(
|
||||
ctx.auditLog,
|
||||
authManifest.useCases.signUp,
|
||||
withAnalytics(
|
||||
analytics,
|
||||
signUpUseCase({
|
||||
usersRepo: new UsersRepository(
|
||||
ctx.config,
|
||||
ctx.tracer,
|
||||
ctx.logger,
|
||||
),
|
||||
analytics,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assertFeatureConformance(
|
||||
authContainer,
|
||||
authManifest,
|
||||
{
|
||||
signUp: AUTH_SYMBOLS.ISignUpUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`assertFeatureConformance` will throw at boot if a use case declares
|
||||
`analyticsEvents` but its binding is missing the `__analyzed` brand — i.e. if
|
||||
`withAnalytics` was omitted.
|
||||
|
||||
### 4. Pass `analytics` from the app aggregator
|
||||
|
||||
In `apps/web-next/src/server/bind-production.ts`, construct your vendor impl
|
||||
(or `NoopAnalytics` for dev) and thread it through `ctx`:
|
||||
|
||||
```ts
|
||||
import { NoopAnalytics } from "@repo/core-analytics";
|
||||
// import { MyVendorAnalytics } from "@repo/core-analytics-posthog"; // after library eval
|
||||
|
||||
const analytics = process.env.ANALYTICS_WRITE_KEY
|
||||
? new MyVendorAnalytics(process.env.ANALYTICS_WRITE_KEY)
|
||||
: new NoopAnalytics();
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
tracer,
|
||||
logger,
|
||||
config,
|
||||
analytics,
|
||||
// ... other optional cores
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
// ... other features
|
||||
```
|
||||
|
||||
Wire `analytics.flush()` into your graceful-shutdown handler so in-flight
|
||||
batches are drained before process exit:
|
||||
|
||||
```ts
|
||||
process.on("SIGTERM", async () => {
|
||||
await analytics.flush();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client-side wiring
|
||||
|
||||
The `@repo/core-analytics/react` subpath exports `<AnalyticsProvider>` and
|
||||
`useAnalytics()`. Both sides share the same `IAnalytics` contract the server
|
||||
uses.
|
||||
|
||||
### 1. Mount the provider at the app boundary
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { AnalyticsProvider } from "@repo/core-analytics/react";
|
||||
import { clientAnalytics } from "@/lib/analytics"; // your vendor SDK wrapper
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<AnalyticsProvider value={clientAnalytics}>
|
||||
{children}
|
||||
</AnalyticsProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`clientAnalytics` is an `IAnalytics` instance backed by your vendor SDK. It
|
||||
lives at the app boundary — feature components never import the vendor SDK
|
||||
directly.
|
||||
|
||||
### 2. Call `useAnalytics()` in feature components
|
||||
|
||||
```tsx
|
||||
// packages/auth/src/ui/sign-up-button.tsx
|
||||
import { useAnalytics } from "@repo/core-analytics/react";
|
||||
|
||||
export function SignUpButton() {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
analytics.track("user.signed-up-click");
|
||||
}}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useAnalytics()` throws `AnalyticsContextError` if called outside a provider —
|
||||
the error message tells you where to add the provider.
|
||||
|
||||
### 3. Wire route-change events
|
||||
|
||||
The provider does **not** auto-wire framework route events. Wire your
|
||||
framework's route-changed hook to `pageView()` yourself:
|
||||
|
||||
```ts
|
||||
// apps/web-next/src/app/layout.tsx (inside a client component)
|
||||
const analytics = useAnalytics();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
analytics.pageView(pathname);
|
||||
}, [pathname]);
|
||||
```
|
||||
|
||||
### 4. Call `identify()` after login
|
||||
|
||||
```ts
|
||||
const analytics = useAnalytics();
|
||||
analytics.identify({ id: session.user.id });
|
||||
```
|
||||
|
||||
Call `identify` once per session or when the user changes. The provider keeps
|
||||
the same instance across renders so subsequent `track` calls share the same
|
||||
context in SDKs that maintain internal user state.
|
||||
|
||||
---
|
||||
|
||||
## Vendor evaluation
|
||||
|
||||
The template ships only `NoopAnalytics` (and `RecordingAnalytics` for tests).
|
||||
Before wiring a real backend, run the library evaluation gate:
|
||||
|
||||
```
|
||||
/evaluate-library <vendor-package-name>
|
||||
```
|
||||
|
||||
This invokes the 9-filter + 3-prompt evaluation protocol (ADR-022) and writes
|
||||
a decision trace to `docs/library-decisions/<date>-<name>.md`. The trace covers
|
||||
EU residency, license compatibility, Socket.dev supply-chain flags, and weekly
|
||||
revalidation requirements. Merge the trace alongside the implementation PR.
|
||||
|
||||
Common vendor packages to evaluate: `posthog-node` / `posthog-js`,
|
||||
`@segment/analytics-node` / `@segment/analytics-next`,
|
||||
`mixpanel` / `mixpanel-browser`.
|
||||
|
||||
The vendor implementation wraps the SDK and implements `IAnalytics` — it lives
|
||||
in a dedicated package (e.g. `@repo/core-analytics-posthog`) so the rest of the
|
||||
codebase stays vendor-neutral.
|
||||
|
||||
---
|
||||
|
||||
## PII boundary
|
||||
|
||||
The analytics surface has a **structurally different PII policy** from the
|
||||
observability surface (ADR-017 §7).
|
||||
|
||||
**Observability** (`ILogger`, `ITracer`) is id-only: `setUser({ id })` only,
|
||||
`sendDefaultPii: false` everywhere, CI grep gate, server-side scrubbing at the
|
||||
OTel processor layer. This policy is deliberate and remains untouched.
|
||||
|
||||
**Analytics** is structurally permissive. `AnalyticsUser.id` is the only
|
||||
required field; `attributes` accepts arbitrary values in `identify()`,
|
||||
`track()`, and `pageView()`. The template makes no claim about what's allowed
|
||||
in those attributes.
|
||||
|
||||
**You are responsible for:**
|
||||
|
||||
- Cookie consent and legal basis (e.g. GDPR Art. 6)
|
||||
- Retention policy in your analytics backend
|
||||
- Trait allowlist enforcement if you need stricter than permissive
|
||||
- DSAR / right-to-erasure plumbing in the vendor backend
|
||||
|
||||
There is no CI guardrail enforcing these — the actionable surface is covered
|
||||
by ADR-022 (library evaluation covering the backend) and ADR-023 (supply-chain
|
||||
scan). The separation of analytics from observability is explicit so that
|
||||
neither surface's policy silently relaxes the other's.
|
||||
|
||||
---
|
||||
|
||||
## Conformance reference
|
||||
|
||||
| What you changed | Gate that catches drift |
|
||||
| ----------------------------------------------------------- | -------------------------------------- |
|
||||
| Added `analyticsEvents` but no `withAnalytics` | tsc TS2322 + boot assertion |
|
||||
| Called `analytics.track("X")` but `X` not in manifest | `no-undeclared-analytics-event` (warn) |
|
||||
| Declared `requiredCores: ["analytics"]` but package missing | `required-cores-installed` (error) |
|
||||
| Manifest has use case, binder doesn't call `wireUseCase` | `usecase-must-be-wired` (error) |
|
||||
|
||||
See `docs/guides/conformance-quickref.md` for the full rule table and drift
|
||||
pattern catalogue.
|
||||
462
docs/guides/audit-and-compliance.md
Normal file
462
docs/guides/audit-and-compliance.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# Audit logging & DPA compliance
|
||||
|
||||
> **Prerequisite:** This guide assumes `@repo/core-audit` is scaffolded. If your
|
||||
> project started from the slim template, run `pnpm turbo gen core-package audit`
|
||||
> first, then follow the manual wiring steps in §4 below.
|
||||
|
||||
## What DPA requires
|
||||
|
||||
A Data Processing Agreement (DPA) typically mandates that any system handling
|
||||
personal data must keep a tamper-evident record of every access to that data.
|
||||
The ten action types covered by this template are: **VIEW**, **CREATE**,
|
||||
**UPDATE**, **DELETE**, **EXPORT**, **PERMISSION_CHANGE**, **CONSENT_GRANT**,
|
||||
**CONSENT_WITHDRAW**, **RESTRICT**, and **UNRESTRICT**. Each entry must
|
||||
capture four required fields:
|
||||
|
||||
| DPA field | Mapped to |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| **Who** performed the action | `actorId`, `actorType`, `actorRoles` |
|
||||
| **What** was acted on | `action`, `resource.type`, `resource.id` |
|
||||
| **When** it happened | `at` (server timestamp, ISO 8601) |
|
||||
| **From where** the request came | `from.ipTruncated`, `from.userAgent` |
|
||||
|
||||
**What NOT to log** — the DPA "exclusion list" is enforced by the `AuditEntry`
|
||||
type itself: there are no `payload`, `body`, `oldValue`, or `newValue` fields.
|
||||
UPDATE actions capture only `changedFields` (the names of modified fields, not
|
||||
their values). This is intentional — storing "what changed" rather than "what
|
||||
it changed to" prevents the audit log from becoming a secondary store of
|
||||
regulated data.
|
||||
|
||||
**Retention requirements** vary by jurisdiction, but a common baseline is 90 days
|
||||
in hot storage (queryable via Payload admin or API) and 12 months in cold archive
|
||||
(shipped to a log aggregator like Grafana Loki or Elasticsearch). The stdout JSON
|
||||
sink + log shipper pattern satisfies this: Payload holds the hot copy; the
|
||||
aggregator holds the archive.
|
||||
|
||||
**Immutability** is enforced by the Payload collection's `update: () => false`
|
||||
access rule. No user — including admins — can modify a written entry through the
|
||||
Payload API. The only write path is `IAuditLog.record()`. Erasure on GDPR request
|
||||
uses a privileged `overrideAccess: true` path that pseudonymizes or deletes the
|
||||
`actorId` field rather than altering the event itself.
|
||||
|
||||
## The two-pattern model
|
||||
|
||||
Two complementary ways to capture a read event:
|
||||
|
||||
### Pattern 1 — Use-case-level `record()` calls
|
||||
|
||||
In your feature's READ use cases, the developer explicitly calls
|
||||
`ctx.auditLog?.record({ action: "VIEW", ... })`. This gives you full control
|
||||
over the WHY (the `reason` field) and works in every context — HTTP requests,
|
||||
background jobs, CLI scripts, and tests.
|
||||
|
||||
```ts
|
||||
// packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
export function getArticleUseCase(deps: {
|
||||
articlesRepo: IArticlesRepository;
|
||||
auditLog?: AuditLogProtocol;
|
||||
}) {
|
||||
return async (input: GetArticleInput): Promise<GetArticleOutput> => {
|
||||
const article = await deps.articlesRepo.findById(input.id);
|
||||
await deps.auditLog?.record({
|
||||
actorId: input.userId,
|
||||
actorType: "user",
|
||||
actorRoles: input.userRoles,
|
||||
action: "VIEW",
|
||||
resource: { type: "articles", id: input.id },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "blog",
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
tenant: input.tenant ?? "default",
|
||||
},
|
||||
from: { ipTruncated: input.ipTruncated, userAgent: input.userAgent },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
reason: "article-page-render",
|
||||
});
|
||||
return getArticleOutputSchema.parse(article);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2 — Payload `afterRead` hook (automatic, opt-in)
|
||||
|
||||
Install `createAuditAfterReadHook(...)` on a collection's `afterRead` hook list.
|
||||
This captures **every** read of the collection — including admin UI reads, direct
|
||||
programmatic reads, and REST API reads — automatically, without per-use-case
|
||||
instrumentation.
|
||||
|
||||
```ts
|
||||
// packages/blog/src/integrations/cms/articles.collection.ts
|
||||
import { createAuditAfterReadHook } from "@repo/core-audit/hooks";
|
||||
|
||||
export const articlesCollection: CollectionConfig = {
|
||||
slug: "articles",
|
||||
hooks: {
|
||||
afterRead: [
|
||||
createAuditAfterReadHook({
|
||||
auditLog: ctx.auditLog,
|
||||
feature: "blog",
|
||||
tenant: "default",
|
||||
}),
|
||||
],
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
The hook fires asynchronously (fire-and-forget) so it never blocks the read
|
||||
response. It uses the sentinel IP/UA `"system"` / `"payload-admin"` for admin UI
|
||||
reads where no request context is available.
|
||||
|
||||
## When to use which pattern
|
||||
|
||||
| Read source | Recommended pattern |
|
||||
| -------------------------------- | ----------------------------------------------------------------------- |
|
||||
| tRPC procedure (app-facing read) | Use-case-level `record()` call — you have full request context |
|
||||
| Payload admin UI | Hook automatically captures (no request context needed) |
|
||||
| Background job / cron | Use-case-level `record()` call with `actorId: "system"`, sentinel IP/UA |
|
||||
| Direct programmatic / CMS REST | Hook automatically captures |
|
||||
| CLI / seed script | Use-case-level `record()` call with `actorId: "service-{name}"` |
|
||||
|
||||
Use **both** for collections under DPA scope. The hook covers reads you might
|
||||
forget at the use-case layer; the use-case calls add contextual `reason` and
|
||||
accurate IP/UA.
|
||||
|
||||
## Wiring core-audit into your app (7 steps)
|
||||
|
||||
After running `pnpm turbo gen core-package audit`, the package exists in
|
||||
`packages/core-audit/` but is not yet wired into your app. Complete these seven
|
||||
steps:
|
||||
|
||||
### Step 1 — Set `AUDIT_PSEUDONYM_SALT`
|
||||
|
||||
Generate a cryptographically random salt and store it in your deployment secrets
|
||||
manager. The salt is used for sha256 pseudonymization on GDPR erasure requests.
|
||||
|
||||
```bash
|
||||
export AUDIT_PSEUDONYM_SALT="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Add to your `.env` (development) and to your production secrets vault. The
|
||||
`bindAudit()` function throws at startup if `NODE_ENV=production` and this
|
||||
variable is not set — intentional fail-fast behavior.
|
||||
|
||||
### Step 2 — Mount the Payload collection
|
||||
|
||||
In `packages/core-cms/src/payload.config.ts`, import and register the
|
||||
append-only `auditLogsCollection`:
|
||||
|
||||
```ts
|
||||
import { auditLogsCollection } from "@repo/core-audit/collection";
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
// ... existing collections ...
|
||||
auditLogsCollection,
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The collection enforces `update: () => false` and `delete: () => false` at the
|
||||
access-control layer. The only write path is via the `IAuditLog.record()` API.
|
||||
|
||||
### Step 3 — Mount the admin tRPC router
|
||||
|
||||
In `packages/core-api/src/root.ts`, import `createAuditRouter` and wire it to
|
||||
the app router. The router requires an `IAuditLog` instance — pass the one
|
||||
returned by `bindAudit()`:
|
||||
|
||||
```ts
|
||||
import { createAuditRouter } from "@repo/core-audit/api";
|
||||
|
||||
// In your router factory (called after bindAudit):
|
||||
export function createAppRouter(auditLog: IAuditLog) {
|
||||
return t.router({
|
||||
// ... existing routers ...
|
||||
audit: createAuditRouter(auditLog),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This exposes `audit.eraseSubject` as an admin-only tRPC mutation. Protect it
|
||||
with your admin auth middleware (the `auditProcedure` base already requires a
|
||||
caller-supplied auth guard — see `src/integrations/api/procedures.ts`).
|
||||
|
||||
### Step 4 — Bind audit in `bind-production.ts`
|
||||
|
||||
In `apps/web-next/src/server/bind-production.ts`, call `bindAudit()` before
|
||||
any feature binder that uses `ctx.auditLog`:
|
||||
|
||||
```ts
|
||||
import { bindAudit } from "@repo/core-audit/di";
|
||||
|
||||
// Inside your resolveProductionContext():
|
||||
const { auditLog } = bindAudit(sharedContainer, {
|
||||
payloadConfig: resolvedConfig,
|
||||
sinks: ["payload", "stdout"],
|
||||
});
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
tracer,
|
||||
logger,
|
||||
config: resolvedConfig,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
auditLog, // <- new
|
||||
};
|
||||
```
|
||||
|
||||
The returned `auditLog` is already wrapped in `TraceIdEnrichingAuditLog`, so
|
||||
every entry receives `correlationId` from the active OTel span automatically.
|
||||
|
||||
### Step 5 — Install user-collection hooks (DPA recommended)
|
||||
|
||||
To automatically pseudonymize or delete a user's audit history when their account
|
||||
is deleted, install the erasure hook on your users Payload collection. In
|
||||
`packages/auth/src/di/bind-production.ts`:
|
||||
|
||||
```ts
|
||||
if (ctx.auditLog) {
|
||||
const { createAuditErasureHook, createAuditAfterReadHook } =
|
||||
await import("@repo/core-audit/hooks");
|
||||
|
||||
// Automatically erase audit entries when a user is deleted:
|
||||
usersCollection.hooks ??= {};
|
||||
usersCollection.hooks.afterDelete ??= [];
|
||||
usersCollection.hooks.afterDelete.push(
|
||||
createAuditErasureHook({ auditLog: ctx.auditLog, mode: "pseudonymize" }),
|
||||
);
|
||||
|
||||
// Optionally capture VIEW events for user profile reads:
|
||||
usersCollection.hooks.afterRead ??= [];
|
||||
usersCollection.hooks.afterRead.push(
|
||||
createAuditAfterReadHook({
|
||||
auditLog: ctx.auditLog,
|
||||
feature: "auth",
|
||||
tenant: "default",
|
||||
}),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This step is gated on `ctx.auditLog` being present, so it's safely skipped in
|
||||
slim-template projects where `@repo/core-audit` has not been scaffolded.
|
||||
|
||||
### Step 6 — Set up a log shipper
|
||||
|
||||
The `StdoutJsonAuditLog` sink writes one JSON line per audit entry to process
|
||||
stdout. A log shipper (Vector or Fluent Bit) reads this stdout stream and
|
||||
forwards entries to your centralized aggregator (Grafana Loki, Elasticsearch,
|
||||
Splunk, etc.). See §5 for sample configs.
|
||||
|
||||
### Step 7 — Verify
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm lint && pnpm typecheck && pnpm test
|
||||
pnpm turbo boundaries
|
||||
```
|
||||
|
||||
Run your app in development mode and trigger a VIEW event. You should see an
|
||||
`_type: "audit"` JSON line in stdout within 100 ms of the action.
|
||||
|
||||
## Sample log-shipper configs
|
||||
|
||||
### Vector (recommended)
|
||||
|
||||
Vector reads the container's stdout stream, filters for audit entries
|
||||
(distinguished by `_type: "audit"`), and ships them to Grafana Loki in the EU
|
||||
region.
|
||||
|
||||
```toml
|
||||
# vector.toml
|
||||
[sources.app_stdout]
|
||||
type = "stdin"
|
||||
|
||||
[transforms.parse_audit]
|
||||
type = "remap"
|
||||
inputs = ["app_stdout"]
|
||||
source = '''
|
||||
. = parse_json!(.message)
|
||||
if ._type != "audit" { abort }
|
||||
'''
|
||||
|
||||
[transforms.enrich_labels]
|
||||
type = "remap"
|
||||
inputs = ["parse_audit"]
|
||||
source = '''
|
||||
.labels = { "env": .scope.environment, "feature": .scope.feature, "tenant": .scope.tenant }
|
||||
'''
|
||||
|
||||
[sinks.loki_eu]
|
||||
type = "loki"
|
||||
inputs = ["enrich_labels"]
|
||||
endpoint = "https://logs-prod-eu-west-0.grafana.net"
|
||||
auth.strategy = "basic"
|
||||
auth.user = "${LOKI_USER}"
|
||||
auth.password = "${LOKI_API_KEY}"
|
||||
labels.job = "audit"
|
||||
labels.env = "{{ .labels.env }}"
|
||||
encoding.codec = "json"
|
||||
```
|
||||
|
||||
Set `LOKI_USER` and `LOKI_API_KEY` in your deployment environment. The filter
|
||||
`if ._type != "audit" { abort }` ensures only audit entries are forwarded;
|
||||
other stdout lines (application logs, framework output) pass through unshipped.
|
||||
|
||||
### Fluent Bit
|
||||
|
||||
```ini
|
||||
# fluent-bit.conf
|
||||
[INPUT]
|
||||
Name tail
|
||||
Path /var/log/app/stdout.log
|
||||
Parser json
|
||||
Tag app.stdout
|
||||
|
||||
[FILTER]
|
||||
Name grep
|
||||
Match app.stdout
|
||||
Regex _type audit
|
||||
|
||||
[OUTPUT]
|
||||
Name loki
|
||||
Match app.stdout
|
||||
Host logs-prod-eu-west-0.grafana.net
|
||||
Port 443
|
||||
TLS On
|
||||
Labels job=audit,env=${AUDIT_ENV}
|
||||
HTTP_User ${LOKI_USER}
|
||||
HTTP_Passwd ${LOKI_API_KEY}
|
||||
line_format json
|
||||
```
|
||||
|
||||
For containerized deployments (Docker / Kubernetes), configure the log driver to
|
||||
write stdout to a file or use Fluent Bit's `docker` input plugin instead of `tail`.
|
||||
|
||||
## GDPR erasure
|
||||
|
||||
GDPR Article 17 ("right to erasure") requires that a data subject can request
|
||||
deletion of their personal data. `@repo/core-audit` satisfies this via
|
||||
`IAuditLog.eraseSubject(actorId, mode)`.
|
||||
|
||||
### Trigger via admin tRPC
|
||||
|
||||
```bash
|
||||
# Replace <TOKEN> with a valid admin session token and <ACTOR_ID> with the user ID
|
||||
curl -X POST https://your-app.com/api/trpc/audit.eraseSubject \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-d '{"json":{"actorId":"user_123","mode":"pseudonymize"}}'
|
||||
```
|
||||
|
||||
The `pseudonymize` mode replaces `actorId` in every matching Payload audit entry
|
||||
with `erased-{sha256(salt+actorId)[0:16]}`. The entry itself is preserved (the
|
||||
event happened; only the identity is pseudonymized). The `delete` mode hard-deletes
|
||||
every entry for that actor — use only when the DPA or a court order requires it.
|
||||
|
||||
### Trigger via the `afterDelete` hook
|
||||
|
||||
If you installed the erasure hook in Step 5, deleting a user via Payload admin
|
||||
automatically triggers pseudonymization. No manual API call is needed for the
|
||||
standard deletion flow.
|
||||
|
||||
### What `eraseSubject` does NOT cover
|
||||
|
||||
`StdoutJsonAuditLog.eraseSubject()` writes a tombstone entry to stdout but cannot
|
||||
retroactively alter past stdout lines that have already been shipped to your
|
||||
aggregator. Handle this by issuing a deletion request to Loki/Elasticsearch for
|
||||
that `actorId` label after the Payload pseudonymization completes:
|
||||
|
||||
```bash
|
||||
# Grafana Loki: delete by label selector (requires delete permission)
|
||||
curl -X POST "https://logs-prod-eu-west-0.grafana.net/loki/api/v1/delete?query={job=\"audit\"}&start=0&end=$(date +%s)000000000" \
|
||||
-H "X-Scope-OrgID: ${LOKI_TENANT}" \
|
||||
--data-urlencode 'query={actorId="user_123"}'
|
||||
```
|
||||
|
||||
Consult your aggregator's deletion API for the exact syntax.
|
||||
|
||||
## Sample-week audit verification
|
||||
|
||||
"Can you tell who accessed any given article last Tuesday?"
|
||||
|
||||
1. Open Payload admin → Collections → Audit Logs
|
||||
2. Filter: `resource.type = "articles"` and `at` between Monday 00:00 and Sunday 23:59
|
||||
3. Each entry shows `actorId`, `actorType`, `actorRoles`, `action`, `at`, and `from.ipTruncated`
|
||||
4. Cross-reference `actorId` with the Users collection to get the display name (keep this lookup out of the audit log itself — names are PII)
|
||||
|
||||
For bulk queries, use your aggregator's log search. In Grafana Loki:
|
||||
|
||||
```logql
|
||||
{job="audit"} | json | resource_type="articles" | action="VIEW"
|
||||
| line_format "{{.at}} {{.actorId}} viewed {{.resource_id}}"
|
||||
```
|
||||
|
||||
The `correlationId` field (populated by `TraceIdEnrichingAuditLog`) links each
|
||||
audit entry to its OTel trace, enabling pivot from the compliance timeline to the
|
||||
full distributed trace in Grafana Tempo or Jaeger.
|
||||
|
||||
## Hostile-actor immutability test
|
||||
|
||||
The append-only guarantee is only as good as its enforcement. Verify it holds:
|
||||
|
||||
```bash
|
||||
# 1. Record a test entry
|
||||
curl -X POST http://localhost:3001/api/audit-logs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"actorId":"attacker","action":"VIEW","resource":{"type":"test"}}'
|
||||
# Expected: 403 Forbidden — the collection's create access is API-only via IAuditLog
|
||||
|
||||
# 2. Try to update an existing entry via Payload REST
|
||||
ENTRY_ID=$(curl -s "http://localhost:3001/api/audit-logs?limit=1" | jq -r '.docs[0].id')
|
||||
curl -X PATCH "http://localhost:3001/api/audit-logs/${ENTRY_ID}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"actorId":"tampered"}'
|
||||
# Expected: 403 Forbidden — update: () => false
|
||||
|
||||
# 3. Try to delete via Payload REST
|
||||
curl -X DELETE "http://localhost:3001/api/audit-logs/${ENTRY_ID}"
|
||||
# Expected: 403 Forbidden — delete: () => false
|
||||
```
|
||||
|
||||
Also verify that the stdout shipper is configured with an independent retention
|
||||
policy that does NOT depend on Payload. If a hostile actor gains DB access and
|
||||
truncates the `audit_logs` table, the shipped log lines in Loki/Elasticsearch
|
||||
remain as the authoritative record.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
**Forgetting `scope.tenant`** — the field is required, not optional. Single-tenant
|
||||
projects must explicitly pass `tenant: "default"`. TypeScript will catch this at
|
||||
compile time if you omit it.
|
||||
|
||||
**Setting `containsPii: false` on a collection that has PII** — for example, a
|
||||
"users" profile view logs `resource.type: "users"` but `containsPii: false`.
|
||||
Even though the audit entry itself doesn't store PII values, the _resource_ being
|
||||
accessed is PII-bearing. Set `containsPii: true` and list relevant categories in
|
||||
`piiCategories: ["profile", "email"]` so downstream retention systems apply the
|
||||
correct access policy to the audit entries themselves.
|
||||
|
||||
**Trying to add `oldValue`/`newValue` fields** — these fields do not exist on
|
||||
`AuditEntry` by design. The type system prevents this. If you need to capture the
|
||||
before/after state of a field for a specific compliance requirement, build a
|
||||
separate audit-detail mechanism outside this package — do not extend `AuditEntry`.
|
||||
|
||||
**Forgetting `AUDIT_PSEUDONYM_SALT` in production** — `bindAudit()` throws at
|
||||
startup with a clear message. Do not try to catch this error; the intent is to
|
||||
refuse to start rather than silently use a weak or predictable salt.
|
||||
|
||||
**Using `eraseSubject("delete")` as the default** — prefer `"pseudonymize"` for
|
||||
the standard GDPR erasure path. Hard delete removes all evidence that the events
|
||||
occurred, which can itself create compliance problems. Pseudonymization preserves
|
||||
the event record while making the actor unidentifiable.
|
||||
|
||||
**Not verifying the log shipper in staging** — deploy to staging with the same
|
||||
Vector/Fluent Bit configuration you'll use in production. Verify that audit
|
||||
entries appear in your aggregator before go-live. The `_type: "audit"` filter is
|
||||
your first line of defense against shipping non-audit data to the compliance log.
|
||||
498
docs/guides/building-feature-ui.md
Normal file
498
docs/guides/building-feature-ui.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Building Feature UI — Components, Hooks & Data Fetching
|
||||
|
||||
Each feature owns its UI layer inside `src/ui/`. This guide covers how to
|
||||
create React components that fetch their own data via tRPC + React Query,
|
||||
how to wire them into Next.js and TanStack Start apps, and how the server
|
||||
prefetch + client hydration pattern works.
|
||||
|
||||
> **Prerequisites:** `@repo/core-trpc` must be scaffolded
|
||||
> (`pnpm turbo gen core-package trpc`). The tRPC providers must be wired
|
||||
> into the app's root layout (see [App wiring](#app-wiring) below).
|
||||
|
||||
---
|
||||
|
||||
## Feature `src/ui/` folder structure
|
||||
|
||||
```
|
||||
packages/<feature>/src/ui/
|
||||
index.ts # Barrel — re-exports server components as public API
|
||||
query.ts # Query builder functions (framework-agnostic)
|
||||
hooks/
|
||||
use-<entity>.ts # "use client" hooks wrapping tRPC + useSuspenseQuery
|
||||
use-<entity>-list.ts
|
||||
components/
|
||||
<entity>-card.tsx # Presentational (receives props, no hooks)
|
||||
<entity>-list.server.tsx # Server component — DI + prefetch + HydrationBoundary
|
||||
<entity>-list.client.tsx # "use client" — calls hook, owns rendering
|
||||
<entity>-detail.server.tsx
|
||||
<entity>-detail.client.tsx
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
| File suffix | Directive | Role | Exported from barrel? |
|
||||
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `.server.tsx` | _(none — server by default)_ | Resolves controller from DI, prefetches data, wraps `.client` in `HydrationBoundary` | **Yes** — under the clean name (e.g. `ArticleList`) |
|
||||
| `.client.tsx` | `"use client"` | Calls hooks, renders UI | **No** — internal to the feature; only imported by its `.server` counterpart |
|
||||
| `.tsx` (no suffix) | _(none)_ | Presentational — receives data via props, no hooks | Yes, if useful standalone (e.g. `ArticleCard`) |
|
||||
|
||||
The **server component is the public face** — the barrel exports it under
|
||||
the clean component name (`ArticleList`, `ArticleDetail`, `PageContent`).
|
||||
The `.client.tsx` suffix signals "internal, not for direct consumption" —
|
||||
consumers never see it.
|
||||
|
||||
### Component roles
|
||||
|
||||
- **Server components** (`.server.tsx`) — resolve the controller from the
|
||||
feature's DI container, call it to prefetch data, seed the React Query
|
||||
cache via `setQueryData`, and wrap the client component in
|
||||
`HydrationBoundary`. This gives SSR + instant hydration.
|
||||
- **Client components** (`.client.tsx`) — `"use client"`. Call hooks from
|
||||
`hooks/` to get data via `useSuspenseQuery`. Handle rendering + interactivity.
|
||||
Never imported by app pages directly.
|
||||
- **Hooks** (`hooks/`) — own data fetching; one hook per query.
|
||||
Always `"use client"`. Import `useTRPC` from `@repo/core-trpc` and
|
||||
`useSuspenseQuery` from `@tanstack/react-query`.
|
||||
- **Presentational components** (`.tsx`, no suffix) — receive data via props.
|
||||
No `"use client"` unless they need browser APIs. Can be shared by
|
||||
multiple client components.
|
||||
|
||||
---
|
||||
|
||||
## Component composition & `@repo/core-ui` reuse
|
||||
|
||||
Feature components **must** reuse primitives from `@repo/core-ui` rather
|
||||
than hand-rolling HTML with raw Tailwind classes. `core-ui` follows
|
||||
**Atomic Design**:
|
||||
|
||||
| Tier | Location | Examples | Rule |
|
||||
| ------------- | ------------------------ | ----------------------------------- | --------------------------------------------- |
|
||||
| **Atoms** | `core-ui/src/atoms/` | `Button`, `Input`, `Label` | Smallest building blocks. No business logic. |
|
||||
| **Molecules** | `core-ui/src/molecules/` | `FormField` (Label + Input + error) | Compose atoms. Still generic. |
|
||||
| **Organisms** | `core-ui/src/organisms/` | `CookieConsentBanner` | Compose molecules/atoms. May own local state. |
|
||||
| **Templates** | `core-ui/src/templates/` | Page shells, layout grids | Structural — define slots, no data. |
|
||||
|
||||
**Import direction is strictly upward:** atoms never import molecules;
|
||||
molecules never import organisms. The ESLint rule
|
||||
`atomic-tier-import-direction` enforces this.
|
||||
|
||||
### Where feature components fit
|
||||
|
||||
Feature components are **consumers** of core-ui, not replacements.
|
||||
They sit above the atomic tiers:
|
||||
|
||||
```
|
||||
App page (imports feature component, passes route props)
|
||||
└── Feature server component (.server.tsx — DI + prefetch + HydrationBoundary)
|
||||
└── Feature client component (.client.tsx — "use client", calls hook)
|
||||
└── Feature presentational component (receives props)
|
||||
└── core-ui atoms/molecules (Button, Input, FormField, ...)
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- **Always check `core-ui` first.** Before creating a `<Card>` or
|
||||
`<Badge>` in a feature, check if `core-ui` already exports it. Use
|
||||
Storybook (`pnpm dev --filter @repo/storybook`) or the barrel at
|
||||
`packages/core-ui/src/index.ts`.
|
||||
- **If a primitive is missing, add it to `core-ui`** — not to the
|
||||
feature. Scaffold via `pnpm turbo gen core-ui-component`. Feature
|
||||
packages should not contain generic UI primitives.
|
||||
- **Feature components compose, not duplicate.** A feature's
|
||||
`<ArticleCard>` should render a `core-ui` `<Card>` (when it exists)
|
||||
with feature-specific content inside — not re-implement card styling.
|
||||
- **Tailwind utility classes are fine** for layout and spacing within
|
||||
feature components (flex, grid, padding, margin). But visual
|
||||
primitives (buttons, inputs, badges, cards) come from `core-ui`.
|
||||
|
||||
### Adding `core-ui` as a dependency
|
||||
|
||||
Feature packages that use core-ui atoms need:
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"dependencies": {
|
||||
"@repo/core-ui": "workspace:*"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create a hook
|
||||
|
||||
Hooks live in `src/ui/hooks/` and wrap a single tRPC query:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/hooks/use-article-list.ts
|
||||
"use client";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export function useArticleList(options?: {
|
||||
status?: "draft" | "published";
|
||||
limit?: number;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.blog.listArticles.queryOptions({
|
||||
status: options?.status ?? "published",
|
||||
limit: options?.limit ?? 20,
|
||||
}),
|
||||
) as { data: Article[] };
|
||||
}
|
||||
```
|
||||
|
||||
> **TS2742 workaround:** Feature packages set `declaration: true` (from
|
||||
> the base tsconfig). The `as { data: T }` cast avoids a non-portable
|
||||
> return type error caused by `@trpc/client` resolving to different
|
||||
> `.pnpm` paths per package.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Feature packages that have hooks need these dependencies:
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"dependencies": {
|
||||
"@repo/core-trpc": "workspace:*",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@trpc/client": "^11.17.0", // for type portability
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create components
|
||||
|
||||
### Server component (public face)
|
||||
|
||||
The server component resolves the controller from DI, prefetches, and
|
||||
wraps the client component in `HydrationBoundary`:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-list.server.tsx
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@repo/core-trpc";
|
||||
import { blogContainer } from "../../di/container";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||
import { ArticleList as ArticleListClient } from "./article-list.client";
|
||||
|
||||
export async function ArticleList() {
|
||||
const controller = blogContainer.get<IGetArticlesController>(
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
);
|
||||
const articles = await controller({ status: "published", limit: 20 });
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["blog", "listArticles", { input: { status: "published", limit: 20 } }],
|
||||
articles,
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ArticleListClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Client component (internal)
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-list.client.tsx
|
||||
"use client";
|
||||
|
||||
import { useArticleList } from "../hooks/use-article-list";
|
||||
import { ArticleCard } from "./article-card";
|
||||
|
||||
export function ArticleList() {
|
||||
const { data: articles } = useArticleList();
|
||||
|
||||
if (articles.length === 0) {
|
||||
return <p className="text-muted-foreground">No articles yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Presentational component (receives props)
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/components/article-card.tsx
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export type ArticleCardProps = { article: Article };
|
||||
|
||||
export function ArticleCard({ article }: ArticleCardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-card p-4">
|
||||
<a href={`/blog/${article.slug}`}>
|
||||
<h3 className="text-lg font-semibold">{article.title}</h3>
|
||||
</a>
|
||||
<time className="text-sm text-muted-foreground"
|
||||
dateTime={article.createdAt.toISOString()}>
|
||||
{article.createdAt.toLocaleDateString()}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **No `renderLink` props.** Client components are `"use client"` —
|
||||
> functions cannot be passed from server components. Use plain `<a>` tags
|
||||
> or import the framework's Link component directly if the feature has
|
||||
> that framework as a dependency.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Export from the barrel
|
||||
|
||||
The barrel exports **server components** under clean names. Client
|
||||
components are internal — never re-exported:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/ui/index.ts
|
||||
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
||||
export { useArticleList } from "./hooks/use-article-list";
|
||||
export { useArticleBySlug } from "./hooks/use-article-by-slug";
|
||||
export { ArticleCard, type ArticleCardProps } from "./components/article-card";
|
||||
export { ArticleList } from "./components/article-list.server";
|
||||
export { ArticleDetail } from "./components/article-detail.server";
|
||||
```
|
||||
|
||||
Apps import from `@repo/<feature>/ui` — they get the server component
|
||||
which handles prefetch + hydration internally:
|
||||
|
||||
```typescript
|
||||
import { ArticleList } from "@repo/blog/ui";
|
||||
import { SiteHeader } from "@repo/navigation/ui";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App wiring
|
||||
|
||||
### Next.js (`apps/web-next`)
|
||||
|
||||
#### Root layout — DI + providers
|
||||
|
||||
`bindAll()` runs once in the root layout. `NextTrpcProvider` wraps all
|
||||
pages with the tRPC client and React Query.
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { bindAll } from "../server/bind-production";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export default async function RootLayout({ children }) {
|
||||
await bindAll();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body><Providers>{children}</Providers></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/providers.tsx
|
||||
"use client";
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
|
||||
export function Providers({ children }) {
|
||||
return <NextTrpcProvider>{children}</NextTrpcProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Pages — just import and render
|
||||
|
||||
Feature server components handle prefetch + hydration internally. App
|
||||
pages are thin — they import the component and pass route-derived props
|
||||
(slug, id, etc.). No `appRouter`, no `queryClient`, no `HydrationBoundary`
|
||||
in the app layer:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/page.tsx
|
||||
import { ArticleList } from "@repo/blog/ui";
|
||||
|
||||
export default function Home() {
|
||||
return <ArticleList />;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/blog/[slug]/page.tsx
|
||||
import { ArticleDetail } from "@repo/blog/ui";
|
||||
|
||||
export default async function BlogPostPage({ params }) {
|
||||
const { slug } = await params;
|
||||
return <ArticleDetail slug={slug} />;
|
||||
}
|
||||
```
|
||||
|
||||
The server component inside the feature resolves its controller from DI,
|
||||
prefetches data, seeds the query cache, and wraps the client component
|
||||
in `HydrationBoundary`. This gives:
|
||||
|
||||
- Full HTML on first paint (SSR)
|
||||
- Instant hydration (no loading flash)
|
||||
- Background refetch on the client via `/api/trpc`
|
||||
|
||||
> **Cache key format:** tRPC generates keys as
|
||||
> `[routerName, procedureName, { input }]`. The `setQueryData` key in the
|
||||
> server component must match what the client hook's `queryOptions`
|
||||
> generates, or the client will re-fetch instead of hydrating.
|
||||
|
||||
#### tRPC HTTP endpoint
|
||||
|
||||
Client-side queries hit `/api/trpc` after hydration:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/api/trpc/[trpc]/route.ts
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
|
||||
const handler = async (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
### TanStack Start (`apps/web-tanstack`)
|
||||
|
||||
Same pattern but with `TanstackTrpcProvider` from `@repo/core-trpc/tanstack`
|
||||
in the root route, and TanStack Router loaders for server prefetch.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind CSS in the apps
|
||||
|
||||
Both apps and Storybook need their own CSS entry point because Tailwind v4
|
||||
scans for utility classes only in files it knows about. Monorepo packages
|
||||
live outside the app directory, so `@source` directives are required.
|
||||
|
||||
```css
|
||||
/* apps/web-next/src/styles/app.css */
|
||||
@import "tailwindcss";
|
||||
@source "../../../../packages/core-ui/src";
|
||||
@source "../../../../packages/navigation/src";
|
||||
@source "../../../../packages/blog/src";
|
||||
/* ... all feature packages with UI components */
|
||||
|
||||
@import "../../../../packages/core-ui/src/styles/theme.css";
|
||||
```
|
||||
|
||||
- **Next.js** uses `@tailwindcss/postcss` via `postcss.config.mjs`
|
||||
- **Storybook** uses `@tailwindcss/vite` prepended in `viteFinal`
|
||||
- **Theme tokens** live in `packages/core-ui/src/styles/theme.css` (single
|
||||
source of truth). Both `globals.css` and app CSS files import it.
|
||||
|
||||
---
|
||||
|
||||
## Seed data & DI binding
|
||||
|
||||
### Dev seed (`USE_DEV_SEED=true` or default in development)
|
||||
|
||||
Each feature has `src/__seeds__/dev.ts` that builds realistic mock data
|
||||
using factories from `src/__factories__/`. The dev-seed binder
|
||||
(`src/di/bind-dev-seed.ts`) populates the mock repository with this data.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/__seeds__/dev.ts
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
|
||||
export function buildDevArticles(): Article[] {
|
||||
return [
|
||||
articleFactory.build({
|
||||
slug: "hello-world",
|
||||
title: "Hello World",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
slug: "second-post",
|
||||
title: "Second Post",
|
||||
status: "published",
|
||||
}),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Production (`USE_DEV_SEED=false` or `NODE_ENV=production`)
|
||||
|
||||
Production binders (`src/di/bind-production.ts`) replace mock repositories
|
||||
with Payload-backed implementations. They receive a `BindProductionContext`
|
||||
with `config`, `tracer`, `logger`, `queue`.
|
||||
|
||||
### Boot dispatcher
|
||||
|
||||
`apps/web-next/src/server/bind-production.ts` picks the mode:
|
||||
|
||||
| Condition | Mode |
|
||||
| --------------------- | -------------------- |
|
||||
| `USE_DEV_SEED=false` | Production (Payload) |
|
||||
| `USE_DEV_SEED=true` | Dev seed (mocks) |
|
||||
| `NODE_ENV=production` | Production |
|
||||
| Default | Dev seed |
|
||||
|
||||
Root `.env` is loaded globally via `dotenv-cli` wrapping Turbo
|
||||
(`"dev": "dotenv -- turbo run dev"` in root `package.json`).
|
||||
|
||||
### Adding a new use case to an existing feature
|
||||
|
||||
1. Add the use case to `feature.manifest.ts`
|
||||
2. Create input/output schemas in the use-case file
|
||||
3. Write the use case factory + controller
|
||||
4. Add the tRPC procedure to `integrations/api/router.ts`
|
||||
5. Wire into both `bind-production.ts` and `bind-dev-seed.ts`
|
||||
6. Add seed data to `__seeds__/dev.ts` if applicable
|
||||
7. Create a hook in `src/ui/hooks/use-<name>.ts`
|
||||
8. Create client component in `src/ui/components/<name>.client.tsx`
|
||||
9. Create server component in `src/ui/components/<name>.server.tsx`
|
||||
10. Export the server component from `src/ui/index.ts` under the clean name
|
||||
|
||||
---
|
||||
|
||||
## Cross-feature boundaries in UI
|
||||
|
||||
- Features **may** import another feature's **root barrel** (types, schemas,
|
||||
errors) but **not** its `./ui` subpath. UI composition across features
|
||||
happens in the app layer.
|
||||
- Navigation's `<SiteHeader>` receives `siteName`/`siteDescription` as
|
||||
**props** — it does not import from `@repo/marketing-pages`. The app
|
||||
page passes these scalars (the only case where the app fetches data
|
||||
that crosses feature boundaries).
|
||||
- If a page renders components from multiple features, the app page
|
||||
imports and renders them side by side — each feature component handles
|
||||
its own data fetching internally.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for new feature UI
|
||||
|
||||
- [ ] Check `core-ui` for existing atoms/molecules before creating new primitives
|
||||
- [ ] Hook in `src/ui/hooks/use-<x>.ts` with `"use client"` + `useSuspenseQuery`
|
||||
- [ ] Component(s) in `src/ui/components/` composing `core-ui` primitives (atoms -> molecules -> organisms)
|
||||
- [ ] Barrel exports in `src/ui/index.ts`
|
||||
- [ ] Server component (`.server.tsx`) with DI resolve + prefetch + `HydrationBoundary`
|
||||
- [ ] Barrel exports server component under clean name (no `Server` suffix)
|
||||
- [ ] `@repo/core-trpc`, `@tanstack/react-query`, `@trpc/client`, `react` in `package.json`
|
||||
- [ ] App page just imports and renders: `<ArticleList />` or `<ArticleDetail slug={slug} />`
|
||||
- [ ] `@source` directive in app CSS for the feature package (if it has Tailwind classes)
|
||||
- [ ] Seed data in `__seeds__/dev.ts` (for dev mode)
|
||||
- [ ] Both `bind-production.ts` and `bind-dev-seed.ts` wire the new use case
|
||||
354
docs/guides/ci-security.md
Normal file
354
docs/guides/ci-security.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# CI security + supply-chain enforcement
|
||||
|
||||
Human reading-room for the four-pillar CI security stack. For decision-record density, see [ADR-023](../decisions/adr-023-ci-security-and-supply-chain.md). For the library evaluation policy that this stack extends, see [ADR-022](../decisions/adr-022-library-evaluation-policy.md) and [`docs/guides/adding-a-library.md`](./adding-a-library.md).
|
||||
|
||||
---
|
||||
|
||||
## Overview: the four pillars
|
||||
|
||||
ADR-022 closes the **decision** gate — every new runtime dependency is evaluated before it enters the lockfile. It does not close the **drift** gate. Once a library is in the lockfile, ADR-022 has nothing to say about CVEs that surface later, supply-chain compromises against trusted upstream maintainers, license relicensing, or EU-residency changes.
|
||||
|
||||
ADR-023 adds four pillars that close the drift gate:
|
||||
|
||||
| Pillar | Mechanism | Primary signal |
|
||||
| --------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **1 — Renovate** | `.github/renovate.json` | Keeps lockfile current; SHA-pins GitHub Actions |
|
||||
| **2 — Socket.dev** | GitHub App + `socket-cli` in CI | Supply-chain _behavior_ — malicious scripts, suspicious network access |
|
||||
| **3 — Trace revalidation** | `.github/workflows/trace-revalidation-weekly.yml` | CVE drift, license change, EU-residency flip, Socket-flag escalation |
|
||||
| **4 — GitHub-native gates** | CodeQL, push protection, `pnpm audit signatures`, gitleaks | Static analysis, secret patterns, sigstore provenance |
|
||||
|
||||
Each pillar enforces at a different latency, mirroring the multi-latency pattern of the conformance system (ADR-012):
|
||||
|
||||
```
|
||||
pre-commit → CI (per-PR) → server-side (GitHub edge) → weekly cron
|
||||
```
|
||||
|
||||
No single pillar sees everything. The composition is the enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Pillar 1 — Renovate (bumps + Action SHA pinning)
|
||||
|
||||
### What it does
|
||||
|
||||
Renovate bot manages all dependency bumps via `.github/renovate.json`. It:
|
||||
|
||||
- Opens weekly PRs grouping minor + patch bumps by ecosystem cluster (`@sentry/*`, `@opentelemetry/*`, etc.) and auto-merges them when CI is green.
|
||||
- Opens separate PRs for semver-major bumps. Major bumps do **not** auto-merge — they block until an agent re-runs `evaluate-library` and refreshes the trace.
|
||||
- Rewrites every `uses: <owner>/<repo>@<tag>` in `.github/workflows/*.yml` to `uses: <owner>/<repo>@<40-char-sha> # <tag>`. This closes the `tj-actions/changed-files` class of supply-chain attack permanently.
|
||||
|
||||
### What it catches
|
||||
|
||||
- **CVE patches** arriving as minor/patch releases — auto-merged once CI is green.
|
||||
- **License or behavior regressions** hidden behind semver-major bumps — blocked until the trace is re-walked.
|
||||
- **Action supply-chain attacks** where a compromised maintainer pushes a malicious tag — SHA pins mean the workflow ignores the new tag until Renovate opens a reviewed bump PR.
|
||||
|
||||
### Toggling in a consumer repo
|
||||
|
||||
Renovate requires a GitHub App install (see [GitHub Marketplace — Renovate](https://github.com/marketplace/renovate)) or a self-hosted Mend Renovate runner. The `.github/renovate.json` file ships with the template and works unchanged. To customize bump grouping, edit `packageRules` in that file.
|
||||
|
||||
---
|
||||
|
||||
## Pillar 2 — Socket.dev (supply-chain behavior detection)
|
||||
|
||||
### What it does
|
||||
|
||||
Socket detects supply-chain behavior, not just CVEs. It inspects what a package _does_ — post-install scripts, environment variable access, network calls at install time — not just what a CVE database _knows_. This catches the `event-stream` (2018) and `ua-parser-js` (2021) class of attacks that had no CVE when they shipped.
|
||||
|
||||
Two layers:
|
||||
|
||||
1. **Socket GitHub App** — posts a risk-score comment on every PR that touches `package.json` or `pnpm-lock.yaml`. Advisory; does not block merge. Free for open-source repos.
|
||||
2. **`socket-cli` CI step** in `ci.yml`'s `validate` job — runs `socket-cli scan` against the lockfile. Configured by `.socket.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"issueRules": {
|
||||
"critical": "error",
|
||||
"high": "warn",
|
||||
"medium": "ignore",
|
||||
"low": "ignore"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`critical` findings hard-block the PR. `high` and below are advisory comments only.
|
||||
|
||||
Socket is also the **9th hard filter** in the `evaluate-library` skill (ADR-022 adds 8; ADR-023 §6.3 adds this one). Trace frontmatter records the result:
|
||||
|
||||
```yaml
|
||||
filter-results:
|
||||
socket-risk: clean | flagged | "<finding-summary>"
|
||||
```
|
||||
|
||||
### What it catches
|
||||
|
||||
- Post-install scripts that write to `~/.ssh` or make outbound network calls.
|
||||
- Packages with maintainer-account-compromise indicators (new maintainer + immediate publish).
|
||||
- Dependency confusion and typosquat patterns.
|
||||
|
||||
### Toggling in a consumer repo
|
||||
|
||||
The CI step (`socket-cli scan`) runs unconditionally in CI regardless of App install — no configuration needed beyond the `.socket.json` that ships with the template. Adjust severity thresholds in `.socket.json` to match your threat model.
|
||||
|
||||
### Installing the Socket GitHub App
|
||||
|
||||
The App posts PR comments with a risk summary. It's free for public repos and most open-source use.
|
||||
|
||||
1. Navigate to [socket.dev](https://socket.dev) → **Get started** → **GitHub App**.
|
||||
2. Select the organization or personal account that owns your repo.
|
||||
3. Grant access to the specific repositories you want covered (or all repositories).
|
||||
4. The App will start posting on your next PR that touches `package.json` or `pnpm-lock.yaml`.
|
||||
|
||||
No secrets, no environment variables, and no config file changes are needed — the App reads from your public repo or an authenticated GitHub token.
|
||||
|
||||
---
|
||||
|
||||
## Pillar 3 — Trace revalidation (continuous ADR-022 validation)
|
||||
|
||||
### What it does
|
||||
|
||||
`.github/workflows/trace-revalidation-weekly.yml` runs every Monday at 06:30 UTC (and on demand via `workflow_dispatch`). For each approved or pre-shipped trace in `docs/library-decisions/`:
|
||||
|
||||
1. Re-runs the trace's `verification-commands` block.
|
||||
2. Compares output against the stored `filter-results` snapshot.
|
||||
3. Classifies divergence:
|
||||
- **Soft** — CVE count changed without crossing severity threshold; maintenance signal downgraded one level; transitive dep count changed.
|
||||
- **Hard** — license changed; named consumer no longer present; critical CVE disclosed; EU-residency flipped; Socket flag escalated to `critical`.
|
||||
|
||||
**Issue management:**
|
||||
|
||||
- **Soft divergence** — appends to a single rolling "library-trace dashboard" issue (labeled `library-policy/dashboard`), kept open continuously. One issue total; humans skim it periodically; most entries need no action.
|
||||
- **Hard divergence** — opens a fresh per-dep issue labeled `library-policy/re-evaluation`. Title: `[trace-revalidation] <package> — <reason>`. Body cites the trace path, verification output, and diff.
|
||||
|
||||
**What revalidation never does:**
|
||||
|
||||
- Edits a trace file. The re-walk needs the `evaluate-library` skill (8 filters + 3 prompts, with agent judgement). CI catches divergence; the dispatch loop fixes it.
|
||||
- Fails CI on `main`. Main keeps deploying while traces get re-walked. Blocking main on CVE data would stall release-please PRs every time a CVE drops upstream.
|
||||
- Auto-dispatches `library-policy/re-evaluation` issues. Issues are a human-triaged queue drained via `pnpm work dispatch`.
|
||||
|
||||
### What it catches
|
||||
|
||||
- CVEs published against a previously-clean dep.
|
||||
- License relicensing (MIT → BSL, MIT → SSPL) that ADR-022's adoption-time check didn't see.
|
||||
- EU-residency changes after a vendor infrastructure announcement.
|
||||
- Socket flag escalation (package that was `clean` is now flagged after a maintainer compromise).
|
||||
|
||||
### Toggling in a consumer repo
|
||||
|
||||
The workflow ships in `.github/workflows/trace-revalidation-weekly.yml` and works without modification. It creates GitHub issues using the `GITHUB_TOKEN` automatically provided in workflows — no additional secrets needed.
|
||||
|
||||
---
|
||||
|
||||
## Pillar 4 — GitHub-native gates (CodeQL, secret scanning, sigstore, gitleaks)
|
||||
|
||||
### What it does
|
||||
|
||||
Four independent mechanisms in this pillar:
|
||||
|
||||
**CodeQL** (`.github/workflows/codeql.yml`) — static analysis for JavaScript/TypeScript. Runs on push to `main`, on PRs, and weekly on Wednesdays. `error`-severity findings hard-block the PR; `warning` and `note` are advisory.
|
||||
|
||||
**GitHub native push protection** — server-side, GitHub edge. Scans for known secret token patterns (API keys, credentials) before accepting a push. Consumer must enable this in repo settings (see [Consumer-toggleable settings](#consumer-toggleable-settings)).
|
||||
|
||||
**`pnpm audit signatures`** — added as one step in `ci.yml`'s `validate` job. Verifies npm sigstore attestations. Fails CI when a package in the lockfile has an invalid or missing attestation at `--audit-level=high`. Roughly 40% of the registry is signed today and the percentage is increasing.
|
||||
|
||||
**gitleaks pre-commit hook** — catches custom secret patterns that GitHub's allowlist doesn't know about (internal API keys, self-hosted service tokens). The hook in `.husky/pre-commit` runs `gitleaks protect --staged --redact` and exits gracefully if the `gitleaks` binary is not installed, so it never blocks developers who haven't set it up yet.
|
||||
|
||||
### What it catches
|
||||
|
||||
- **CodeQL** — SQL injection, XSS, prototype pollution, command injection, unsafe regex.
|
||||
- **Push protection** — AWS keys, GitHub tokens, Stripe keys, and hundreds of other known provider patterns.
|
||||
- **`pnpm audit signatures`** — tampered or unsigned packages in the lockfile.
|
||||
- **gitleaks** — custom token patterns, internal service credentials, `.env`-style secrets accidentally staged.
|
||||
|
||||
### Toggling in a consumer repo
|
||||
|
||||
CodeQL, `pnpm audit signatures`, and the gitleaks hook all ship with the template and activate without per-consumer config. GitHub native push protection requires a one-time repo settings toggle (see [Consumer-toggleable settings](#consumer-toggleable-settings)).
|
||||
|
||||
#### CodeQL note for private repos
|
||||
|
||||
CodeQL is **free for public repos** and for repos on GitHub Pro/Team/Enterprise plans. Private repos on the free GitHub plan do not have access to GitHub Advanced Security, which CodeQL requires. If you're on a free plan with a private repo, the CodeQL workflow will fail with a clear error message from GitHub rather than silently no-op-ing. Either upgrade your plan or remove the `codeql.yml` workflow from your fork.
|
||||
|
||||
#### Installing gitleaks for developers
|
||||
|
||||
The pre-commit hook exits gracefully if `gitleaks` is not in `PATH` — it prints a one-line prompt to install and continues. No developer is blocked by a missing binary. To activate the hook:
|
||||
|
||||
**macOS (Homebrew):**
|
||||
|
||||
```bash
|
||||
brew install gitleaks
|
||||
```
|
||||
|
||||
**Linux (apt / package manager):**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian — check the gitleaks GitHub releases for the current version
|
||||
wget https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_amd64.tar.gz
|
||||
tar -xzf gitleaks_linux_amd64.tar.gz
|
||||
sudo mv gitleaks /usr/local/bin/
|
||||
```
|
||||
|
||||
**Linux (via go install):**
|
||||
|
||||
```bash
|
||||
go install github.com/gitleaks/gitleaks/v8@latest
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
gitleaks version
|
||||
```
|
||||
|
||||
After install, the pre-commit hook runs automatically on `git commit`. The `__seeds__` allowlist in `.gitleaks.toml` covers intentional test fixtures — add new allowlist entries there if a false positive blocks a commit.
|
||||
|
||||
---
|
||||
|
||||
## Failure-mode hierarchy
|
||||
|
||||
Two principles govern what blocks vs. what comments:
|
||||
|
||||
- **Boolean checks** (schema valid, signature verifies, secret present, trace file present) hard-block. They have a definite right answer.
|
||||
- **Judgment checks** (Socket risk score, CodeQL semantic finding) are advisory unless severity reaches `critical` / `error`. They can have false positives.
|
||||
|
||||
Full table — pillar, gate, trigger, action, GitHub label, who resolves:
|
||||
|
||||
| Pillar | Gate | Trigger | Action | Label | Who resolves |
|
||||
| ------------------ | ---------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------ | ------------------------------ | --------------------------------------------- |
|
||||
| Cross-cutting | `pnpm typecheck && test && lint && conformance && coverage:diff` | PR / push to main | Hard block PR | — | Developer / agent |
|
||||
| Cross-cutting | State-sync guard (pre-commit) | `git commit` | Block commit | — | Developer / agent |
|
||||
| GitHub-native | gitleaks (pre-commit) | `git commit` | Block commit | — | Developer |
|
||||
| Cross-cutting | Library-trace presence check (pre-commit) | `git commit` | Block commit | — | Developer / agent |
|
||||
| GitHub-native | GitHub native push protection | `git push` | Block push at GitHub edge | — | Developer |
|
||||
| Renovate | Minor / patch bump PR | New dep version available | Auto-merge when CI is green | `renovate/dashboard` | Renovate bot |
|
||||
| Renovate | Major bump PR | Semver-major dep version available | Block until `evaluate-library` re-run + `last-revalidated` refresh | `renovate/dashboard` | Agent (via `pnpm work dispatch`) |
|
||||
| Socket | `socket-cli` CI — `critical` finding | PR touching `package.json` / lockfile | Hard block PR | — | Developer / agent |
|
||||
| Socket | `socket-cli` CI — `high` or below | PR touching `package.json` / lockfile | Advisory CI annotation | — | Developer (optional) |
|
||||
| Socket | Socket GitHub App PR comment | PR touching `package.json` / lockfile | Advisory comment on PR | — | Developer (optional) |
|
||||
| GitHub-native | CodeQL — `error` severity | Push / PR / weekly schedule | Hard block PR | — | Developer / agent |
|
||||
| GitHub-native | CodeQL — `warning` / `note` | Push / PR / weekly schedule | Advisory annotation | — | Developer (optional) |
|
||||
| GitHub-native | `pnpm audit signatures` failure | PR / push CI | Hard block PR | — | Developer / agent |
|
||||
| GitHub-native | GitHub Dependabot vuln alerts | CVE publication (server-side) | Advisory alert in Security tab | — | Developer (schedule via Renovate bump) |
|
||||
| Trace revalidation | Soft divergence | Weekly cron | Append to dashboard issue | `library-policy/dashboard` | Developer (skim; usually no action) |
|
||||
| Trace revalidation | Hard divergence | Weekly cron | Open per-dep issue | `library-policy/re-evaluation` | Human triage → agent via `pnpm work dispatch` |
|
||||
|
||||
---
|
||||
|
||||
## Consumer-toggleable settings
|
||||
|
||||
These three settings are not configured by template files — they require one-time actions in your GitHub repo settings or a third-party install. The template documents them here so consumers know what's available and how to activate each.
|
||||
|
||||
### GitHub native push protection
|
||||
|
||||
Blocks pushes at the GitHub edge if they contain known secret patterns. Free for all plan tiers on public repos; available on GitHub Advanced Security plans for private repos.
|
||||
|
||||
**To enable:** Repo settings → Security → Code security and analysis → Secret scanning → Enable "Push protection".
|
||||
|
||||
Once enabled, pushes containing detected patterns are rejected with a link to review the finding. Developers can bypass with a justification if the detection is a false positive.
|
||||
|
||||
### Socket GitHub App
|
||||
|
||||
Adds risk-score comments on PRs that touch `package.json` or `pnpm-lock.yaml`. See [Installing the Socket GitHub App](#installing-the-socket-github-app) above.
|
||||
|
||||
The CI step (`socket-cli scan`) runs independently of the App install and hard-blocks on `critical` findings either way. The App adds the human-readable PR comment layer on top.
|
||||
|
||||
### Branch protection rules for `library-policy/*` labels
|
||||
|
||||
Optionally, configure branch protection to require human review before merging any PR labeled `library-policy/re-evaluation`. This prevents an agent loop from auto-closing a re-evaluation issue without a human sign-off.
|
||||
|
||||
**To enable:** Repo settings → Branches → Add rule for `main` → check "Require approvals" (≥1) → optionally add a status check that verifies the label is resolved before merge.
|
||||
|
||||
This is consumer-optional. The template does not enforce it because the right threshold varies by team.
|
||||
|
||||
---
|
||||
|
||||
## Worked examples
|
||||
|
||||
### Example 1: Passing Renovate minor-bump PR
|
||||
|
||||
**Scenario:** Renovate opens a weekly PR bumping `@sentry/node` from `8.3.0` to `8.5.0` (a minor bump) and `@sentry/nextjs` from `8.3.0` to `8.5.0` in the same PR (grouped by the `@sentry/*` cluster rule in `renovate.json`).
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. **Renovate opens the PR** — title: `chore(deps): bump @sentry/* packages`, labeled `renovate/dashboard`.
|
||||
2. **CI runs the `validate` job** — all five conformance gates pass (`typecheck`, `test`, `lint`, `conformance`, `coverage:diff`). `pnpm audit signatures` passes (no tampered packages). `socket-cli scan` returns `clean` (no new install scripts or network behaviors added by the patch).
|
||||
3. **CodeQL workflow runs** — no new findings.
|
||||
4. **Socket GitHub App** posts a comment: "No new issues found." Advisory; no action required.
|
||||
5. **All checks green** → Renovate auto-merges the PR per the `automergeMinor: true` rule.
|
||||
6. **release-please** reads the `chore(deps):` commit prefix — no version bump (deps bump commits don't trigger a feature-package version). The root template version increments if the commit path is cross-cutting.
|
||||
|
||||
**No human action required.** The full cycle — Renovate opens → CI passes → auto-merge — is self-contained.
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Blocked major-bump PR + hard-divergence revalidation issue
|
||||
|
||||
This example covers two related failure modes that appear in the same ecosystem: a Renovate major-bump PR that blocks, and a Socket-flagged hard-divergence issue opened by the weekly cron.
|
||||
|
||||
#### Part A — Blocked major-bump PR
|
||||
|
||||
**Scenario:** Renovate opens a PR bumping `zod` from `3.22.4` to `4.0.0` (a semver-major bump). The existing trace at `docs/library-decisions/2026-05-14-zod.md` has `last-revalidated: 2026-05-14` (set when Story 05 backfill ran) and `version: 3.22.4`.
|
||||
|
||||
**What the pre-commit check does (`scripts/library-decisions/check.mjs`):**
|
||||
|
||||
The script detects that `zod`'s version in `package.json` crosses a semver-major boundary relative to the trace's recorded `version` field and that `last-revalidated` predates the bump. The PR comment reads:
|
||||
|
||||
```
|
||||
[library-trace] zod: semver-major bump detected (3.22.4 → 4.0.0).
|
||||
Trace last-revalidated: 2026-05-14. Re-run /evaluate-library before merging.
|
||||
```
|
||||
|
||||
The check does **not** auto-fail CI — it opens a blocking PR comment. CI itself passes (the code may build fine). But the sandcastle reviewer prompt rejects the slice until the trace is refreshed.
|
||||
|
||||
**What the agent (or developer) does:**
|
||||
|
||||
1. Invoke the skill: `/evaluate-library zod --tier feature --target packages/auth`.
|
||||
2. The skill re-walks all 9 filters (including Socket scan of zod v4).
|
||||
3. If `zod` v4 passes: the trace at `docs/library-decisions/2026-05-14-zod.md` is updated in-place — `version`, `filter-results`, `verification-commands`, and `last-revalidated` are refreshed; the original `date` field is preserved.
|
||||
4. Commit the updated trace: `docs(library-decisions): re-evaluate zod 4.0.0 after major bump`.
|
||||
5. Push to the Renovate PR branch. The check re-runs, finds `last-revalidated` is current, and unblocks.
|
||||
6. CI + Socket + CodeQL all pass. The reviewer prompt approves. Renovate's PR merges.
|
||||
|
||||
If `zod` v4 **fails** a filter (e.g., license changed, Socket finds a new install script), the trace is updated with `decision: rejected` and the Renovate PR is closed.
|
||||
|
||||
#### Part B — Hard-divergence revalidation issue
|
||||
|
||||
**Scenario:** Two weeks later, `socket-cli` flags `lefthook` (a dev dep used in a downstream consumer's fork) with a `critical` finding after a maintainer-account compromise. The weekly cron runs on Monday morning.
|
||||
|
||||
**What the cron does:**
|
||||
|
||||
1. Re-runs `socket-cli scan lefthook` as part of `verification-commands`.
|
||||
2. Compares against the trace's `filter-results.socket-risk: clean` snapshot.
|
||||
3. Classifies as **hard divergence** (Socket flag escalated to `critical`).
|
||||
4. Opens a GitHub issue labeled `library-policy/re-evaluation`:
|
||||
|
||||
```
|
||||
Title: [trace-revalidation] lefthook — socket-risk escalated to critical
|
||||
Body:
|
||||
Trace: docs/library-decisions/2026-05-14-lefthook.md
|
||||
Previous socket-risk: clean
|
||||
Current socket-risk: critical — malicious post-install script detected
|
||||
Verification output: [full socket-cli output pasted here]
|
||||
Next step: run /evaluate-library lefthook and update the trace. If rejection is warranted, remove the package.
|
||||
```
|
||||
|
||||
**What the developer / agent does:**
|
||||
|
||||
1. Triage the issue via `pnpm work dispatch`. The dispatch loop surfaces it as the next ready task.
|
||||
2. Re-run `/evaluate-library lefthook --tier feature --target packages/auth`.
|
||||
3. If the Socket finding is confirmed: `decision: rejected`, trace updated, `pnpm remove lefthook --filter packages/auth`, commit.
|
||||
4. If it's a false positive (Socket has re-classified): `socket-risk: clean`, trace updated with `last-revalidated`, issue closed.
|
||||
5. The agent closes the `library-policy/re-evaluation` issue with a link to the updated trace commit.
|
||||
|
||||
**The weekly cron never auto-closes issues.** Closing requires an explicit agent or human action — the issue is the queue; the dispatch loop drains it.
|
||||
|
||||
---
|
||||
|
||||
## Cross-links
|
||||
|
||||
- [ADR-023 — CI security + supply-chain enforcement](../decisions/adr-023-ci-security-and-supply-chain.md) — the authoritative decision record with full rationale, alternatives considered, and failure-mode hierarchy (§5)
|
||||
- [ADR-022 — Library evaluation policy](../decisions/adr-022-library-evaluation-policy.md) — the adoption-time gate this stack builds on
|
||||
- [`docs/guides/adding-a-library.md`](./adding-a-library.md) — how to add a new runtime dependency (includes the 9-filter evaluation flow)
|
||||
- [`.claude/skills/evaluate-library/SKILL.md`](../../.claude/skills/evaluate-library/SKILL.md) — agent runbook for `evaluate-library`
|
||||
- [`docs/guides/releasing.md`](./releasing.md) — release-please workflow; how Renovate bump PRs interact with versioning
|
||||
- [`docs/glossary.md`](../glossary.md) — entries for **Library trace**, **Pre-shipped trace**, **Trace revalidation**, **Major-bump re-evaluation**
|
||||
- [ADR-019 — Sandcastle agent orchestration](../decisions/adr-019-sandcastle-agent-orchestration.md) — the reviewer prompt is the agent-loop enforcement surface for Socket + CodeQL gates (ADR-023 §7)
|
||||
133
docs/guides/compliance-overview.md
Normal file
133
docs/guides/compliance-overview.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Compliance overview
|
||||
|
||||
This hub maps each of the 22 sections of the DPA/GDPR compliance playbook reviewed in [ADR-025](../decisions/adr-025-eu-compliance-baseline.md) to the ADR, guide, template, or epic that covers it in this template. Use it as the entry point when answering "is feature X compliant?" or "where do I find the relevant doc?"
|
||||
|
||||
For the action-item checklist (pass/fail gate before EU go-live), see [pre-launch-compliance-checklist.md](./pre-launch-compliance-checklist.md). For architectural rationale and deferral decisions, see [ADR-025](../decisions/adr-025-eu-compliance-baseline.md).
|
||||
|
||||
---
|
||||
|
||||
## Coverage labels
|
||||
|
||||
| Label | Meaning |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **Shipped by template** | Mechanism is in the codebase. Run the inline verification command to produce audit evidence. |
|
||||
| **Consumer responsibility** | You own this obligation. The template ships fill-in templates or interfaces, not the values. |
|
||||
| **Infra responsibility** | Your deployment infrastructure owns this. No application-code change is sufficient. |
|
||||
| **Deferred** | Explicitly deferred in ADR-025 with a documented trigger condition. |
|
||||
|
||||
---
|
||||
|
||||
## 22-section map
|
||||
|
||||
| § | Playbook section | Coverage | Covering doc |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Infrastructure security baseline — EU/EEA region pinning, TLS termination, encryption-at-rest, VPN/bastion | **Infra responsibility** | [operator-checklist.md](./operator-checklist.md); [pre-launch-compliance-checklist.md §1](./pre-launch-compliance-checklist.md) |
|
||||
| 2 | Data governance and accountability — controller/processor identification, accountability framework | **Consumer responsibility** | [ADR-025](../decisions/adr-025-eu-compliance-baseline.md); [pre-launch-compliance-checklist.md §12](./pre-launch-compliance-checklist.md) |
|
||||
| 3 | PII inventory and data mapping — field-level `custom.pii` tags, `compliance/data-map.yml` | **Shipped by template** | [ADR-025 Epic A](../decisions/adr-025-eu-compliance-baseline.md); [docs/compliance/README.md](../compliance/README.md); [data-map.example.yml](../compliance/data-map.example.yml); [subject-linkage.example.md](../compliance/subject-linkage.example.md) |
|
||||
| 4 | Data retention schedules and purge — `custom.retention` per collection, background purge job | **Shipped by template** | [ADR-025 Epic A](../decisions/adr-025-eu-compliance-baseline.md); [docs/compliance/README.md](../compliance/README.md); [retention-policy.example.yml](../compliance/retention-policy.example.yml) |
|
||||
| 5 | Access control and rate limiting — `rateLimit` manifest field, `IRateLimit` / `withRateLimit` brand | **Shipped by template** | [ADR-025 Epic C](../decisions/adr-025-eu-compliance-baseline.md); [rate-limiting.md](./rate-limiting.md); [pre-launch-compliance-checklist.md §3](./pre-launch-compliance-checklist.md) |
|
||||
| 6 | Authentication policy — password complexity, rotation, lockout, MFA | **Consumer responsibility / Deferred** | [password-policy.template.md](../compliance/templates/password-policy.template.md); MFA + lockout deferred — see [Deferrals](#deferrals) |
|
||||
| 7 | Consent management — `requiresConsent` manifest field, `IConsent` / `withConsent`, consent grant/withdraw | **Shipped by template** | [ADR-025 Epic B](../decisions/adr-025-eu-compliance-baseline.md); [consent.md](./consent.md); [pre-launch-compliance-checklist.md §3](./pre-launch-compliance-checklist.md) |
|
||||
| 8 | Cookie notice and transparency — EU-prominent banner (`<CookieConsentBanner>`), granular categories | **Shipped by template** | [ADR-025 Epic B](../decisions/adr-025-eu-compliance-baseline.md); [consent.md](./consent.md) |
|
||||
| 9 | Data Subject Rights (Art. 15, 16, 17, 18, 20, 21) — `core-dsr`, four interfaces, GDPR endpoints | **Shipped by template** | [ADR-025 Epic B](../decisions/adr-025-eu-compliance-baseline.md); [dsr.md](./dsr.md); [dsr-procedure.template.md](../compliance/templates/dsr-procedure.template.md); [pre-launch-compliance-checklist.md §8](./pre-launch-compliance-checklist.md) |
|
||||
| 10 | Automated decision-making (Art. 22) — profiling, solely-automated decisions | **Deferred** | [ADR-025 §deferrals](../decisions/adr-025-eu-compliance-baseline.md); see [Deferrals](#deferrals) |
|
||||
| 11 | Privacy by Design and Default — PII scrubbing, `sendDefaultPii: false`, replay masking, id-only observability | **Shipped by template** | [ADR-017](../decisions/adr-017-opentelemetry-migration.md); [audit-and-compliance.md](./audit-and-compliance.md); [pre-launch-compliance-checklist.md §3](./pre-launch-compliance-checklist.md) |
|
||||
| 12 | Network security and backup strategy — firewall rules, bastion access, backup schedule, restore testing | **Infra / Consumer responsibility** | [backup-policy.template.md](../compliance/templates/backup-policy.template.md); [pre-launch-compliance-checklist.md §1, §9](./pre-launch-compliance-checklist.md) |
|
||||
| 13 | Data Protection Impact Assessment (DPIA, Art. 35) — high-risk processing assessment | **Consumer responsibility** | [pre-launch-compliance-checklist.md §12](./pre-launch-compliance-checklist.md) |
|
||||
| 14 | Device management — MDM enrollment, EDR, acceptable-use policy, lost/stolen response | **Consumer responsibility** | [device-policy.template.md](../compliance/templates/device-policy.template.md); [pre-launch-compliance-checklist.md §11](./pre-launch-compliance-checklist.md) |
|
||||
| 15 | Workforce management — onboarding access provisioning, offboarding revocation, NDAs, security training | **Consumer responsibility** | [onboarding.template.md](../compliance/templates/onboarding.template.md); [offboarding.template.md](../compliance/templates/offboarding.template.md); [pre-launch-compliance-checklist.md §11](./pre-launch-compliance-checklist.md) |
|
||||
| 16 | Audit logging and evidence artifacts — append-only `core-audit`, `withAudit` brand, `eraseSubject`, evidence YAML bundle | **Shipped by template** | [ADR-018](../decisions/adr-018-audit-and-compliance.md); [audit-and-compliance.md](./audit-and-compliance.md); [docs/compliance/README.md](../compliance/README.md); [pre-launch-compliance-checklist.md §6, §13](./pre-launch-compliance-checklist.md) |
|
||||
| 17 | Legal instruments — DPA, Privacy Policy, Terms of Service, SCCs for non-EU transfers, RoPA (Art. 30) | **Consumer responsibility** | [pre-launch-compliance-checklist.md §12](./pre-launch-compliance-checklist.md) |
|
||||
| 18 | Sub-processor management — extended ADR-022 library traces, `compliance/sub-processors.yml` generator | **Shipped by template** | [ADR-022](../decisions/adr-022-library-evaluation-policy.md); [ADR-025 Epic A](../decisions/adr-025-eu-compliance-baseline.md); [sub-processors.example.yml](../compliance/sub-processors.example.yml); [pre-launch-compliance-checklist.md §5](./pre-launch-compliance-checklist.md) |
|
||||
| 19 | Pre-launch compliance verification — gate checklist operationalising this ADR | **Shipped by template** | [pre-launch-compliance-checklist.md](./pre-launch-compliance-checklist.md) |
|
||||
| 20 | Breach detection and incident response — Sentry alerting, GDPR Art. 33/34 notification runbook | **Consumer responsibility / Deferred** | [incident-runbook.template.md](../compliance/templates/incident-runbook.template.md); [pre-launch-compliance-checklist.md §7](./pre-launch-compliance-checklist.md); breach-detection patterns deferred — see [Deferrals](#deferrals) |
|
||||
| 21 | SDLC security — Renovate, Socket.dev, CodeQL, gitleaks, SBOM (CycloneDX), trace revalidation | **Shipped by template** | [ADR-023](../decisions/adr-023-ci-security-and-supply-chain.md); [ci-security.md](./ci-security.md); [pre-launch-compliance-checklist.md §10](./pre-launch-compliance-checklist.md) |
|
||||
| 22 | Observability and PII boundary — `PiiScrubSpanProcessor`, `PiiScrubLogRecordProcessor`, OTel exporter pipeline | **Shipped by template** | [ADR-017](../decisions/adr-017-opentelemetry-migration.md); [audit-and-compliance.md](./audit-and-compliance.md); [pre-launch-compliance-checklist.md §3](./pre-launch-compliance-checklist.md) |
|
||||
|
||||
---
|
||||
|
||||
## Deferrals
|
||||
|
||||
Four items were explicitly deferred in ADR-025 because they require product-level shape before they can be meaningfully implemented. Each has a documented trigger so the decision-when belongs to the consumer, not the template authors.
|
||||
|
||||
| Deferred item | Why | Trigger to revisit |
|
||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **RBAC primitive** (roles, permissions, tenant scoping) | Requires product decisions: which roles exist, single- vs. multi-tenant, permission granularity | First downstream consumer ships with a stable role model |
|
||||
| **MFA + lockout** (`auth` feature extension) | Requires identity-infrastructure choices (TOTP/WebAuthn), OTP vendor (ADR-022 scope), threat-model-specific policy values | First downstream consumer establishes auth threat model |
|
||||
| **Breach detection patterns** (failed-login burst, bulk-access anomaly, off-hours admin) | Requires real auth flows, analytics backend, on-call infrastructure, product-specific anomaly thresholds | First downstream consumer has live traffic + observability backend |
|
||||
| **GDPR Art. 22** (automated decision-making and profiling) | Template has no ML or automated decisions | First downstream consumer adds automated decisions |
|
||||
|
||||
---
|
||||
|
||||
## Consumer and infra scope
|
||||
|
||||
The following playbook items are explicitly outside the template's scope. The template ships no meaningful implementation for them; coverage is consumer-authored or deployment-infrastructure decisions.
|
||||
|
||||
**Infrastructure (§1, §12)** — EU/EEA region pinning for compute, managed database, object storage, and backups; TLS termination and HTTPS enforcement at the deploy edge; encryption-at-rest configuration; VPN or bastion for admin access; firewall ingress rules; backup restore testing and RPO/RTO targets. See [operator-checklist.md](./operator-checklist.md).
|
||||
|
||||
**Legal instruments (§17)** — Data Processing Agreement (DPA) with every counterparty; Privacy Policy (GDPR Art. 13/14 notices); Terms of Service; Standard Contractual Clauses (SCCs) for data transfers outside EU/EEA; DPIA artifacts (Art. 35); Records of Processing Activities (RoPA, Art. 30). See [pre-launch-compliance-checklist.md §12](./pre-launch-compliance-checklist.md).
|
||||
|
||||
**MDM and organisational measures (§14, §15)** — MDM enrollment, EDR tooling, acceptable-use enforcement, lost/stolen device response; HR onboarding/offboarding execution; NDAs; security awareness training; background checks; quarterly privilege access reviews. The template ships fill-in templates for the policy documents; the values and execution are consumer-owned. See [device-policy.template.md](../compliance/templates/device-policy.template.md), [onboarding.template.md](../compliance/templates/onboarding.template.md), [offboarding.template.md](../compliance/templates/offboarding.template.md).
|
||||
|
||||
---
|
||||
|
||||
## Reference index
|
||||
|
||||
### ADRs
|
||||
|
||||
| ADR | Title | Compliance role |
|
||||
| --------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- |
|
||||
| [ADR-017](../decisions/adr-017-opentelemetry-migration.md) | OpenTelemetry migration | PII scrubbing on the observability pipeline (§11, §22) |
|
||||
| [ADR-018](../decisions/adr-018-audit-and-compliance.md) | Audit logging and DPA compliance | Audit baseline, `core-audit`, `eraseSubject` (§16) |
|
||||
| [ADR-022](../decisions/adr-022-library-evaluation-policy.md) | Library evaluation policy | EU residency filter, sub-processor frontmatter extension (§18, §21) |
|
||||
| [ADR-023](../decisions/adr-023-ci-security-and-supply-chain.md) | CI security and supply chain | Renovate, Socket.dev, CodeQL, gitleaks, SBOM (§21) |
|
||||
| [ADR-024](../decisions/adr-024-product-analytics-channel.md) | Product analytics channel | Analytics PII boundary and consent gating (§7) |
|
||||
| [ADR-025](../decisions/adr-025-eu-compliance-baseline.md) | EU compliance baseline | Master strategy; four epics, three deferrals, all manifest extensions |
|
||||
|
||||
### Guides
|
||||
|
||||
| Guide | Covers |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| [audit-and-compliance.md](./audit-and-compliance.md) | `core-audit` cookbook — wiring, action types, log-shipper config, `eraseSubject` (§16, §22) |
|
||||
| [ci-security.md](./ci-security.md) | Four-pillar supply-chain stack — Renovate, Socket.dev, trace revalidation, GitHub gates (§21) |
|
||||
| [consent.md](./consent.md) | `core-consent` cookbook — `IConsent`, `withConsent`, cookie banner, category versioning (§7, §8) |
|
||||
| [dsr.md](./dsr.md) | `core-dsr` cookbook — four interfaces, GDPR endpoints, multi-subject cascade, deletion modes (§9) |
|
||||
| [operator-checklist.md](./operator-checklist.md) | Repository secrets, GitHub Apps, branch protection setup (§1, §12) |
|
||||
| [pre-launch-compliance-checklist.md](./pre-launch-compliance-checklist.md) | 13-section launch gate — every obligation with coverage label and verification command (§19) |
|
||||
| [rate-limiting.md](./rate-limiting.md) | `IRateLimit` cookbook — manifest declaration, key naming, multi-budget patterns (§5) |
|
||||
| [security-headers.md](./security-headers.md) | Six security headers, CSP nonce wiring, per-framework middleware setup (§11) |
|
||||
| [analytics.md](./analytics.md) | `core-analytics` cookbook — consent-gated analytics events (§7) |
|
||||
|
||||
### Templates
|
||||
|
||||
| Template | Covers |
|
||||
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| [incident-runbook.template.md](../compliance/templates/incident-runbook.template.md) | GDPR Art. 33/34 breach response — 72-hour notification timeline, SA contact, subject notification (§20) |
|
||||
| [dsr-procedure.template.md](../compliance/templates/dsr-procedure.template.md) | DSR intake — identity validation, response log, per-article procedure (§9) |
|
||||
| [backup-policy.template.md](../compliance/templates/backup-policy.template.md) | Backup schedule, storage location (EU/EEA), encryption, restore testing, RPO/RTO (§12) |
|
||||
| [password-policy.template.md](../compliance/templates/password-policy.template.md) | Password complexity, rotation cadence, account lockout thresholds (§6) |
|
||||
| [device-policy.template.md](../compliance/templates/device-policy.template.md) | MDM enrollment, EDR, acceptable-use rules, lost/stolen response (§14) |
|
||||
| [onboarding.template.md](../compliance/templates/onboarding.template.md) | Staff access provisioning, security orientation, acknowledgement, 30-day review (§15) |
|
||||
| [offboarding.template.md](../compliance/templates/offboarding.template.md) | Access revocation checklist, device return, data handover, 30-day post-departure review (§15) |
|
||||
|
||||
### Schema examples
|
||||
|
||||
| File | Covers |
|
||||
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| [data-map.example.yml](../compliance/data-map.example.yml) | Field-level `custom.pii` annotation schema — `category`, `purpose`, `exportable`, `restrictable` (§3) |
|
||||
| [retention-policy.example.yml](../compliance/retention-policy.example.yml) | Collection-level `custom.retention` schema — `purgeSchedule`, `activeRetention`, `postDeletion` (§4) |
|
||||
| [sub-processors.example.yml](../compliance/sub-processors.example.yml) | Sub-processor inventory schema — library trace extensions + manual REST entries (§18) |
|
||||
| [subject-linkage.example.md](../compliance/subject-linkage.example.md) | Multi-subject DSR cascade pattern — scope declaration per collection (§9) |
|
||||
|
||||
### Epics
|
||||
|
||||
| Epic | PRD | Covers |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Epic A — Declarative compliance manifests | [PRD](../work/prds/compliance-manifests-pii-retention-subprocessors.prd.md) | §3 PII inventory, §4 retention, §18 sub-processors |
|
||||
| Epic B — DSR, consent, cookie banner | [PRD](../work/prds/dsr-consent-and-cookie-banner.prd.md) | §7 consent, §8 cookie notice, §9 DSR |
|
||||
| Epic C — Security hardening | [PRD](../work/prds/security-headers-rate-limit-sbom.prd.md) | §5 rate limiting, §11 security headers, §21 SBOM |
|
||||
| Epic D — Compliance docs scaffolds | [PRD](../work/prds/compliance-docs-scaffolds.prd.md) | §19 checklist, all fill-in templates |
|
||||
|
||||
---
|
||||
|
||||
_Governed by [ADR-025](../decisions/adr-025-eu-compliance-baseline.md). Part of [Epic D — Compliance docs scaffolds](../work/epics/compliance-docs-scaffolds/_epic.md)._
|
||||
151
docs/guides/conformance-quickref.md
Normal file
151
docs/guides/conformance-quickref.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Conformance system — quick reference
|
||||
|
||||
Day-to-day reference for the manifest-first workflow. For design rationale see `docs/architecture/agent-first-workflow-and-conformance.md` and the interactive `feature-conformance-explainer.html`.
|
||||
|
||||
---
|
||||
|
||||
## The manifest
|
||||
|
||||
Every feature has one at `src/feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
export const fooManifest = defineFeature({
|
||||
name: "foo",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
getThing: { mutates: false, audits: [], publishes: [], consumes: [] },
|
||||
createThing: {
|
||||
mutates: true,
|
||||
audits: ["thing.created"],
|
||||
publishes: ["foo.thing-created"],
|
||||
consumes: [],
|
||||
reads: ["auth"], // cross-feature reader dependency
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
|
||||
export type FooManifest = typeof fooManifest;
|
||||
```
|
||||
|
||||
Field reference:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | string literal | Feature name (kebab-case, matches package name) |
|
||||
| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) |
|
||||
| `useCases.<name>.mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required |
|
||||
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
|
||||
| `useCases.<name>.publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` |
|
||||
| `useCases.<name>.consumes` | string[] | Cross-feature events this use case consumes (via an event handler) |
|
||||
| `useCases.<name>.reads` | string[] | Other features whose readers this use case queries (e.g. `["auth"]`) |
|
||||
| `realtimeChannels` | string[] | Realtime channels this feature owns |
|
||||
| `jobs` | string[] | Job slugs this feature enqueues |
|
||||
| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` |
|
||||
| `rateLimit` | RateLimitBudget[] | Rate-limit budgets this feature's use cases enforce; drives `withRateLimit` wrapping + `no-undeclared-rate-limit` |
|
||||
|
||||
Re-export from `src/index.ts`:
|
||||
|
||||
```ts
|
||||
export { fooManifest, type FooManifest } from "./feature.manifest";
|
||||
```
|
||||
|
||||
## bindProductionX self-assertion
|
||||
|
||||
Every feature's `bind-production.ts` calls the assertion at the tail:
|
||||
|
||||
```ts
|
||||
import { assertFeatureConformance } from "@repo/core-shared/conformance";
|
||||
import { fooManifest } from "../feature.manifest";
|
||||
|
||||
export function bindProductionFoo(ctx: BindProductionContext): void {
|
||||
// ... bind use cases, wrapped with withSpan + withCapture + (if mutating + audits) withAudit ...
|
||||
|
||||
assertFeatureConformance(
|
||||
fooContainer,
|
||||
fooManifest,
|
||||
{
|
||||
getThing: FOO_SYMBOLS.IGetThingUseCase,
|
||||
createThing: FOO_SYMBOLS.ICreateThingUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The symbol map declares which container symbol each manifest use-case key resolves to.
|
||||
|
||||
## The five gates
|
||||
|
||||
| Gate | When it fires | What it catches | Severity |
|
||||
| ------------------ | --------------------- | ------------------------------------------------------------------------ | -------------------- |
|
||||
| `tsc` | on save | forgotten wrappers; manifest-derived slot type rejects unwrapped factory | error |
|
||||
| `eslint` | on save / `pnpm lint` | manifest ↔ code drift; missing sibling test; missing manifest | error or warn |
|
||||
| `pnpm dev` | at boot | binding lost its runtime brand; manifest declares more than wired | throws synchronously |
|
||||
| `pnpm conformance` | CI | orphan event consumers across features | exits non-zero |
|
||||
| `pnpm fallow` | ~30–60s | unused exports/files, dupes, circular deps, complexity, AI-change audit | warn (currently) |
|
||||
|
||||
## ESLint rules
|
||||
|
||||
| Rule | Severity | What it does |
|
||||
| ---------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest |
|
||||
| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` |
|
||||
| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-<name>` packages in pnpm-workspace.yaml |
|
||||
| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case |
|
||||
| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` |
|
||||
| `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "<key>" })` in `bind-production.ts` / `bind-dev-seed.ts` |
|
||||
| `conformance/no-undeclared-analytics-event` | warn | `analytics.track("X")` literal must match the manifest's `analyticsEvents` for the use case |
|
||||
| `conformance/pii-declaration-must-be-complete` | warn | `custom.pii` blocks in Payload config files must declare all required fields: `category`, `purpose`, `exportable`, `restrictable` |
|
||||
| `conformance/component-must-have-story` | warn | Every component file under `src/` must have a sibling `.stories.tsx` file |
|
||||
| `conformance/component-must-have-test` | warn | Every component file under `src/` must have a sibling `.test.tsx` file |
|
||||
| `conformance/atomic-tier-import-direction` | warn | Atomic-design import direction must flow downward (atoms ← molecules ← organisms ← templates ← pages); no upward imports |
|
||||
| `conformance/no-undeclared-consent-check` | warn | `consent.isGranted("X")` literal in a use-case file must match a category declared in `manifest.requiresConsent`; warns if declared categories are never checked |
|
||||
| `conformance/no-undeclared-rate-limit` | warn | `rateLimit.consume(budgetName, ...)` call in a use-case file must match a budget name declared in `manifest.rateLimit`; warns if declared budgets are never consumed |
|
||||
|
||||
## Workflow ordering for new use cases
|
||||
|
||||
1. **Manifest** — add the use case to `feature.manifest.ts` with empty `audits` / `publishes` / `consumes`
|
||||
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, `IXUseCase` from the use-case file (factory body throws "not implemented")
|
||||
3. **Tests (red)** — write the test importing the contracts; verify it fails
|
||||
4. **Implementation (green)** — fill the factory body until tests pass
|
||||
|
||||
For the fast path: `pnpm turbo gen feature <name>` scaffolds steps 1 + 2 in a single command.
|
||||
|
||||
## Common drift patterns and the gate that catches them
|
||||
|
||||
- **Forgot `withSpan` at bind time** → tsc TS2322 + `usecase-must-be-wired` ESLint error + boot assertion + bind-production smoke test
|
||||
- **Manifest entry has no `wireUseCase` call in either binder** → `usecase-must-be-wired` ESLint error + boot assertion + bind-production smoke test
|
||||
- **Manifest declares `audits: ["X"]` but factory doesn't call `auditLog.record({type:"X"})`** → no automatic catch yet; future story
|
||||
- **Factory calls `bus.publish("Y")` but manifest doesn't declare it** → `conformance/no-undeclared-event-publish` (warn)
|
||||
- **Feature has use cases but no manifest** → `conformance/feature-must-have-manifest` (error)
|
||||
- **Manifest references `requiredCores: ["X"]` but no `core-X` package exists** → `conformance/required-cores-installed` (error)
|
||||
- **One feature consumes `Y` but no feature publishes `Y`** → `pnpm conformance` orphan check (CI gate)
|
||||
- **Factory calls `analytics.track("X")` but manifest doesn't declare it in `analyticsEvents`** → `conformance/no-undeclared-analytics-event` (warn); add the event slug to the manifest or remove the call
|
||||
|
||||
## Pinning down a drift
|
||||
|
||||
When a gate fires, the error message tells you what to run. For example:
|
||||
|
||||
> `Feature blog has use cases but no feature.manifest.ts. Run 'pnpm turbo gen feature blog' or scaffold the manifest manually at packages/blog/src/feature.manifest.ts.`
|
||||
|
||||
That's the "fix" line — follow it.
|
||||
|
||||
## Fallow audit for AI changes
|
||||
|
||||
When you (the agent) finish a task and are about to commit, run:
|
||||
|
||||
```
|
||||
pnpm fallow:audit
|
||||
```
|
||||
|
||||
This runs `fallow audit --base main`, comparing your branch's diff against main. If your change adds dead exports, dupes, or complexity hotspots, fallow tells you exactly what and where. Fix or accept (with --gate flag to ignore inherited findings).
|
||||
|
||||
This is the catch-all for whole-codebase drift the per-file gates can't see.
|
||||
|
||||
---
|
||||
|
||||
For the deeper design rationale see `docs/architecture/agent-first-workflow-and-conformance.md` and the interactive `feature-conformance-explainer.html`.
|
||||
255
docs/guides/consent.md
Normal file
255
docs/guides/consent.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Consent guide
|
||||
|
||||
Consumer-facing reference for the `@repo/core-consent` optional core package. Covers the `requiresConsent` manifest field, `withConsent` DI wiring, runtime consent-check pattern, `IConsent` interface, anonymous→authenticated migration, cookie versioning policy, SSR-safe banner loading, and CNIL/EDPB equal-prominence requirements.
|
||||
|
||||
**Prerequisite:** scaffold the package if it isn't already present:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-package consent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Declaring `requiresConsent` in the feature manifest
|
||||
|
||||
Any feature that gates behaviour behind user consent declares the required categories in `feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/feature.manifest.ts
|
||||
export const fooManifest = defineFeature({
|
||||
name: "foo",
|
||||
requiredCores: ["consent"],
|
||||
requiresConsent: ["analytics", "marketing"],
|
||||
useCases: {
|
||||
trackEvent: { mutates: false, audits: [], publishes: [], consumes: [] },
|
||||
},
|
||||
// ...
|
||||
} as const);
|
||||
```
|
||||
|
||||
`requiresConsent` accepts an array of `ConsentCategory` values:
|
||||
|
||||
| Category | Typical use |
|
||||
| -------------- | ----------------------------------------------- |
|
||||
| `"necessary"` | Session management, CSRF protection (always on) |
|
||||
| `"functional"` | Preferences, language, personalised content |
|
||||
| `"analytics"` | Product analytics, funnels, cohort analysis |
|
||||
| `"marketing"` | Ad targeting, remarketing pixels |
|
||||
| `string & {}` | Custom categories (autocomplete still works) |
|
||||
|
||||
The ESLint rule `conformance/no-undeclared-consent-check` warns if:
|
||||
|
||||
- A use-case file calls `consent.isGranted("X")` for a category not in `manifest.requiresConsent`.
|
||||
- `manifest.requiresConsent` lists a category that is never checked in any use case.
|
||||
|
||||
---
|
||||
|
||||
## Wiring `withConsent` at DI bind time
|
||||
|
||||
Apply `withConsent` **inside** `withCapture`, **outermost** among the optional wrappers:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/di/bind-production.ts
|
||||
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
|
||||
import { withConsent } from "@repo/core-consent";
|
||||
|
||||
export function bindProductionFoo(ctx: BindProductionContext): void {
|
||||
const { tracer, logger, consent } = ctx;
|
||||
|
||||
// Full composition order (outermost → innermost):
|
||||
// withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)
|
||||
fooContainer
|
||||
.bind<ITrackEventUseCase>(FOO_SYMBOLS.ITrackEventUseCase)
|
||||
.toDynamicValue(() =>
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "foo.trackEvent" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ useCase: "foo.trackEvent" },
|
||||
withConsent(consent, trackEventUseCase({ analytics: ctx.analytics })),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assertFeatureConformance(
|
||||
fooContainer,
|
||||
fooManifest,
|
||||
{ trackEvent: FOO_SYMBOLS.ITrackEventUseCase },
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`withConsent` attaches the `ConsentChecked` brand at bind time. The boot assertion (`assertFeatureConformance`) verifies that every use case listed under `requiresConsent` in the manifest is wrapped. TypeScript rejects binding an unwrapped factory to a `ConsentChecked`-typed symbol.
|
||||
|
||||
---
|
||||
|
||||
## Runtime consent-check pattern
|
||||
|
||||
Inside a use case, call `consent.isGranted(category)` before performing the gated work:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/application/use-cases/track-event.use-case.ts
|
||||
import type { IConsent } from "@repo/core-consent";
|
||||
import { ConsentDeniedError } from "@repo/core-consent/errors";
|
||||
|
||||
export function trackEventUseCase(deps: {
|
||||
analytics: IAnalytics;
|
||||
consent: IConsent;
|
||||
}) {
|
||||
return async (input: TrackEventInput): Promise<void> => {
|
||||
if (!deps.consent.isGranted("analytics")) {
|
||||
throw new ConsentDeniedError("analytics");
|
||||
}
|
||||
await deps.analytics.track(input.event, input.properties);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`isGranted` is **synchronous** — it reads the in-memory consent state populated at request initialisation. Do not `await` it.
|
||||
|
||||
---
|
||||
|
||||
## `IConsent` interface
|
||||
|
||||
```ts
|
||||
interface IConsent {
|
||||
/** Synchronous: reads in-memory state. */
|
||||
isGranted(category: ConsentCategory): boolean;
|
||||
/** Records a consent grant; production impl writes to the audit log. */
|
||||
grant(category: ConsentCategory, meta?: ConsentGrantMeta): Promise<void>;
|
||||
/** Records a consent withdrawal; production impl writes to the audit log. */
|
||||
withdraw(category: ConsentCategory): Promise<void>;
|
||||
/** Returns per-category state for all categories the subject has interacted with. */
|
||||
getCategories(): Promise<UserConsentState[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### Audit trail
|
||||
|
||||
The production `IConsent` implementation calls `auditLog.record(...)` on every `grant` and `withdraw` call, creating an immutable audit entry with:
|
||||
|
||||
- `type`: `"consent.granted"` or `"consent.withdrawn"`
|
||||
- `category`: the `ConsentCategory` string
|
||||
- `method`: from `ConsentGrantMeta.method` (e.g., `"banner-accept"`, `"signup-migration"`)
|
||||
- `bannerVersion` / `policyVersion`: from `ConsentGrantMeta` when provided
|
||||
|
||||
This creates a traceable consent history for regulatory inspection.
|
||||
|
||||
---
|
||||
|
||||
## Anonymous → authenticated migration
|
||||
|
||||
When an anonymous visitor accepts the cookie banner, their choices are stored in the `cc_consent` cookie as a comma-separated list of granted categories (e.g., `necessary,analytics`).
|
||||
|
||||
On sign-up or sign-in, migrate the anonymous consent to the authenticated user record:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/application/use-cases/sign-up.use-case.ts
|
||||
import {
|
||||
extractAnonymousConsent,
|
||||
migrateAnonymousConsent,
|
||||
} from "@repo/core-consent/migration";
|
||||
|
||||
export function signUpUseCase(deps: {
|
||||
users: IUsersRepository;
|
||||
consent: IConsent;
|
||||
}) {
|
||||
return async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
const user = await deps.users.create({ email: input.email /* ... */ });
|
||||
|
||||
// Migrate consent from the anonymous banner cookie, if present.
|
||||
const cookieState = extractAnonymousConsent(input.cookieHeader ?? "");
|
||||
await migrateAnonymousConsent({
|
||||
consent: deps.consent,
|
||||
cookieState,
|
||||
bannerVersion: input.bannerVersion,
|
||||
policyVersion: input.policyVersion,
|
||||
});
|
||||
|
||||
return signUpOutputSchema.parse({ userId: user.id });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`migrateAnonymousConsent` calls `consent.grant(category, { method: "signup-migration" })` for each category in the cookie. It is a no-op when `cookieState` is `null`.
|
||||
|
||||
After migration, delete or expire the `cc_consent` cookie on the client to avoid double-migration on subsequent sign-ins.
|
||||
|
||||
---
|
||||
|
||||
## Cookie versioning policy
|
||||
|
||||
The client-side consent state is stored in the `__consent_state` cookie as JSON:
|
||||
|
||||
```ts
|
||||
type ConsentCookieState = {
|
||||
_v: number; // schema version (bump when categories change)
|
||||
categories: Record<string, boolean>; // category slug → granted
|
||||
};
|
||||
```
|
||||
|
||||
**When to increment `_v`:**
|
||||
|
||||
- You add a new non-necessary consent category.
|
||||
- You update the privacy policy in a way that requires renewed consent (e.g., new processing purpose).
|
||||
- You remove a category that users previously consented to and need to inform them of the change.
|
||||
|
||||
**Migration-on-read:** when `readConsentCookie()` reads a cookie with `_v < current`, treat the consent as `pending` and show the banner again. The user's previous granular choices are discarded because the categories or policy changed. Implement this in your `ConsentProvider` initialisation.
|
||||
|
||||
The `cc_consent` anonymous cookie (written before authentication) follows the same versioning scheme — pass `bannerVersion` through `extractAnonymousConsent` / `migrateAnonymousConsent` so the version is preserved in the audit log.
|
||||
|
||||
---
|
||||
|
||||
## SSR-safe banner loading
|
||||
|
||||
The cookie consent banner is a client-only component (reads `document.cookie`, attaches focus traps). Use `CookieConsentBannerLoader` instead of `CookieConsentBanner` directly to prevent SSR hydration mismatches:
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { CookieConsentBannerLoader } from "@repo/core-ui/cookie-consent-banner";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
<CookieConsentBannerLoader />{" "}
|
||||
{/* renders null on server, mounts on client */}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`CookieConsentBannerLoader` returns `null` during SSR and mounts the full `CookieConsentBanner` only after hydration, avoiding the `document is not defined` error and preventing layout shift from banner flicker on first load.
|
||||
|
||||
---
|
||||
|
||||
## CNIL / EDPB equal-prominence requirement
|
||||
|
||||
The French CNIL and the EDPB both require that the banner offers **equivalent visual weight** to accepting and rejecting consent — a prominent "Accept all" button paired with an equally-prominent "Reject all" button, with no pre-checked non-necessary categories.
|
||||
|
||||
The default `CookieConsentBanner` implements this:
|
||||
|
||||
- **Accept all** and **Reject all** buttons are rendered at the same visual prominence (identical `variant` prop).
|
||||
- Non-necessary category checkboxes default to **unchecked**.
|
||||
- ESC key triggers "Reject all" (keyboard-accessible rejection path).
|
||||
- The `required: true` flag on a `CookieCategory` marks it as always-on and renders a disabled, checked checkbox — do not use `required: true` for non-necessary categories.
|
||||
|
||||
If you customise the banner via render props (`renderActions`, `renderCategoryRow`), you must preserve these guarantees manually. Failing to offer an equivalent "Reject all" path is a CNIL/EDPB violation.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Cookie banner component: `@repo/core-ui` → `CookieConsentBanner`, `CookieConsentBannerLoader`
|
||||
- Anonymous migration: `@repo/core-consent/migration` → `extractAnonymousConsent`, `migrateAnonymousConsent`
|
||||
- Conformance rule: `no-undeclared-consent-check` in `docs/guides/conformance-quickref.md`
|
||||
- Glossary: `ConsentChecked`, `UserConsentState` in `docs/glossary.md`
|
||||
- ADR: ADR-025 (compliance baseline channels)
|
||||
270
docs/guides/coverage.md
Normal file
270
docs/guides/coverage.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Coverage
|
||||
|
||||
> **Architecture:** [ADR-020](../decisions/adr-020-coverage-architecture.md). **Glossary:** [docs/glossary.md → Coverage](../glossary.md#coverage).
|
||||
|
||||
The agent-first coverage architecture has four layers. This guide is the day-to-day reference for working with them.
|
||||
|
||||
## The four layers at a glance
|
||||
|
||||
| Layer | Question it answers | Command |
|
||||
| ---------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
|
||||
| **L0** Per-layer vitest thresholds | "Did the last test run meet the declared bands?" | `pnpm test -- --coverage` |
|
||||
| **L1** Diff coverage | "Did this PR/slice cover its own changed lines?" | `pnpm coverage:diff` |
|
||||
| **L2** Aggregate trend | "How is coverage trending across the repo?" | `pnpm coverage:aggregate` → `coverage/summary.json` |
|
||||
| **L3** Mutation testing | "Do my tests actually assert anything?" | `pnpm mutate` _(opt-in, not in default `pnpm test`)_ |
|
||||
|
||||
Each layer answers a distinct question. They compose, none replaces the others.
|
||||
|
||||
## Single source of truth: `feature.manifest.ts`
|
||||
|
||||
Every feature declares its coverage expectations once, in `feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
export const myFeatureManifest = defineFeature({
|
||||
// ...
|
||||
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);
|
||||
```
|
||||
|
||||
Two readers pick this up today:
|
||||
|
||||
1. **`vitest.config.ts`** — uses `vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS)` from `@repo/core-shared/conformance/coverage`. Most features import `DEFAULT_COVERAGE_BANDS` directly (the manifest's `coverage` section matches the defaults). For features with custom bands, override at the vitest config too.
|
||||
2. **`pnpm coverage:diff`** — uses the bands for per-path expectations against the merged lcov.
|
||||
|
||||
(A third reader, a boot-time `assertFeatureConformance` coverage check, was specified in the PRD and explicitly deferred per ADR-020 — when both readers above derive from the same manifest, the drift it was supposed to catch is mechanically impossible. The manifest's `coverage:` field remains the declarative source of truth regardless of how many readers consume it.)
|
||||
|
||||
**Edit the manifest. The other readers pick up the change.**
|
||||
|
||||
## Daily workflow
|
||||
|
||||
### Before pushing
|
||||
|
||||
```bash
|
||||
pnpm test -- --coverage # L0 — per-package thresholds enforced
|
||||
pnpm coverage:aggregate # L2 — produce coverage/lcov.info + summary.json
|
||||
pnpm coverage:diff # L1 — fails if changed lines aren't covered
|
||||
```
|
||||
|
||||
The diff coverage step compares against `origin/main` by default. To compare against a different base:
|
||||
|
||||
```bash
|
||||
pnpm coverage:diff -- --base HEAD~1
|
||||
pnpm coverage:diff -- --base origin/release
|
||||
```
|
||||
|
||||
For machine consumption (e.g., the agent dispatch loop):
|
||||
|
||||
```bash
|
||||
pnpm coverage:diff -- --json | jq .uncovered
|
||||
```
|
||||
|
||||
### Reading a failure
|
||||
|
||||
`pnpm coverage:diff` exits with code 1 and emits both stdout (JSON) and stderr (summary):
|
||||
|
||||
**stderr** (human):
|
||||
|
||||
```
|
||||
[coverage:diff] FAIL — 3 uncovered hit(s) across 2 file(s):
|
||||
packages/blog/src/application/use-cases/publish-article.use-case.ts
|
||||
uncovered lines: 47, 48
|
||||
packages/auth/src/entities/models/session.ts
|
||||
uncovered lines: 22
|
||||
```
|
||||
|
||||
**stdout** (JSON, also written for the dispatch loop):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"summary": {
|
||||
"filesChanged": 4,
|
||||
"filesGated": 2,
|
||||
"uncoveredCount": 3
|
||||
},
|
||||
"fileSummaries": [...],
|
||||
"uncovered": [
|
||||
{ "file": "...", "line": 47, "kind": "uncovered" },
|
||||
{ "file": "...", "line": 48, "kind": "uncovered" },
|
||||
{ "file": "...", "line": 22, "kind": "uncovered" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is one of:
|
||||
|
||||
- `uncovered` — line is executable per lcov, execution count is 0
|
||||
- `no-coverage-data` — entire file isn't in lcov (likely a new untested file)
|
||||
|
||||
### Fixing an uncovered slice
|
||||
|
||||
1. Read the JSON. For each `uncovered` hit, navigate to `<file>:<line>`.
|
||||
2. Identify which test would have exercised that line. Usually it's missing a branch case or an error path.
|
||||
3. Add the test (TDD: write failing test → make it green).
|
||||
4. Re-run `pnpm test --coverage --filter @repo/<feature>` to verify.
|
||||
5. Re-run `pnpm coverage:diff` to confirm exit 0.
|
||||
|
||||
For `no-coverage-data` hits, write the sibling test file — vitest's ESLint conformance rule `usecase-must-have-test-file` will start failing anyway if you don't.
|
||||
|
||||
### What's exempt (the allowlist)
|
||||
|
||||
The diff coverage gate skips:
|
||||
|
||||
- Test files (`*.test.ts`, `*.test.tsx`, `*.test.mjs`)
|
||||
- Fixtures, factories, contracts, seeds (`__fixtures__/`, `__factories__/`, `__contracts__/`, `__seeds__/`)
|
||||
- Config files (`*.config.{ts,js,mjs,cjs}`, `package.json`, `tsconfig.*.json`, `turbo.json`)
|
||||
- Docs and data (`*.md`, `*.json`, `*.yaml`, `.gitignore`, `.npmrc`)
|
||||
- Shell scripts (`*.sh`, `*.bash`)
|
||||
- Dev tooling under `scripts/` and `turbo/generators/`
|
||||
- Per-feature excludes mirrored from vitest (`di/bind-production.ts`, `application/repositories/**`, `application/services/**`, `integrations/cms/**`, `ui/**`, `*.interface.ts`, `index.ts` barrels)
|
||||
- Build artifacts (`dist/`, `.next/`, `.turbo/`, `node_modules/`, `coverage/`)
|
||||
|
||||
The allowlist lives in `scripts/coverage/diff.mjs` and is unit-tested.
|
||||
|
||||
## Adjusting bands
|
||||
|
||||
### To raise the bar on a feature
|
||||
|
||||
Edit `packages/<feature>/src/feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 90, branches: 85, functions: 90, lines: 90 }, // tighter
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": { statements: 100, branches: 100, functions: 100, lines: 100 }, // bumped branches
|
||||
controllers: { statements: 100, branches: 95, functions: 100, lines: 100 },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If the new bands are stricter than the defaults, also update `packages/<feature>/vitest.config.ts` to use `vitestThresholdsFromManifest(myFeatureManifest)` instead of `DEFAULT_COVERAGE_BANDS`. _(Note: importing the manifest from a vitest config has tooling constraints — see the `DEFAULT_COVERAGE_BANDS` route as the default path.)_
|
||||
|
||||
### To skip a layer
|
||||
|
||||
Omit it from `bands`. The layer falls through to `baseline`:
|
||||
|
||||
```ts
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { ... },
|
||||
entities: { ... },
|
||||
// controllers omitted -> matches baseline
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## CI behavior
|
||||
|
||||
`.github/workflows/ci.yml` (validate job) runs three coverage steps after the test step:
|
||||
|
||||
1. **Test with coverage** — produces per-package `coverage/lcov.info`
|
||||
2. **Coverage — aggregate (L2)** — merges to root `coverage/lcov.info` + `coverage/summary.json`
|
||||
3. **Coverage — diff (L1)** — only on pull requests, diffs against `origin/<base-ref>`
|
||||
|
||||
On merge to main, `.github/workflows/coverage-snapshot.yml` re-aggregates and commits the updated `coverage/summary.json` back to main. Trend history accumulates via `git log -- coverage/summary.json`.
|
||||
|
||||
## Reading the trend
|
||||
|
||||
```bash
|
||||
git log --oneline --follow -- coverage/summary.json | head -10
|
||||
git show <sha> -- coverage/summary.json | grep -E '"statements"|"branches"'
|
||||
```
|
||||
|
||||
`coverage/summary.json` is the only committed coverage artifact. Each snapshot includes:
|
||||
|
||||
- `generatedAt` — ISO timestamp
|
||||
- `commit` — short SHA
|
||||
- `repo` — repo-wide percentages + raw counts
|
||||
- `byPackage` — per-package percentages, keyed by `@repo/<name>`
|
||||
|
||||
## Mutation testing (L3)
|
||||
|
||||
Stryker mutation testing on `entities/` + `application/use-cases/` — the pure-business-logic surface. Not part of `pnpm test` (slow); runs on-demand and nightly via GH Action.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
pnpm mutate # every feature with a stryker.config.json
|
||||
pnpm mutate -- --filter @repo/auth # one feature
|
||||
pnpm mutate -- --since main # incremental against base ref
|
||||
pnpm mutate -- --json # machine-readable summary
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Each feature has a slim `stryker.config.json` that extends the shared base:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"extends": "@repo/core-testing/stryker.base.json"
|
||||
}
|
||||
```
|
||||
|
||||
The base lives at `packages/core-testing/stryker.base.json` and defines:
|
||||
|
||||
- **Test runner**: vitest (uses each feature's `vitest.config.ts`)
|
||||
- **Scope**: `src/entities/**/*.ts` and `src/application/use-cases/**/*.ts` (excludes tests/factories/contracts)
|
||||
- **Thresholds**: high 90 / low 80 / break 80 (`break` is the fail threshold)
|
||||
- **Reporters**: progress, html (`reports/mutation/index.html`), json (`reports/mutation/mutation.json`)
|
||||
- **Incremental mode**: enabled (subsequent runs skip mutants whose source + tests haven't changed)
|
||||
- **Concurrency**: 4 workers
|
||||
|
||||
To override per feature (rare), add fields to the feature's `stryker.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@repo/core-testing/stryker.base.json",
|
||||
"thresholds": { "high": 95, "low": 85, "break": 85 },
|
||||
"mutate": ["src/entities/**/*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
### CI: nightly run + on-demand
|
||||
|
||||
`.github/workflows/mutation-nightly.yml` runs Stryker across every feature at 02:30 UTC + on `workflow_dispatch`. The dispatch UI accepts a `filter` input (e.g. `@repo/auth`) for targeted reruns. Reports uploaded as the `mutation-reports` artifact (30-day retention). On meaningful score drops it opens a tracking issue labelled `mutation-testing`.
|
||||
|
||||
### What you're looking for
|
||||
|
||||
Stryker's `mutation.json` reports the **mutation score** (killed mutants / total) per file. A surviving mutant means: the mutator changed source code (e.g., `<` → `<=`, `&&` → `||`, removed a line, etc.), reran the tests, and they STILL passed. That's a test that exists + executes the code but doesn't actually assert behavior.
|
||||
|
||||
Fix: read the surviving mutant's diff in `reports/mutation/index.html`, identify the assertion that should have caught it, add the assertion.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Cannot find module '@vitest/coverage-v8'"** — your feature's `package.json` is missing `@vitest/coverage-v8` as a dev dep. Add it. (This was the issue surfaced for media during the L0 audit.)
|
||||
|
||||
**"Coverage for lines (X%) does not meet 'src/...' threshold (Y%)"** — L0 failure. Real test gap. Either write the missing test or adjust the manifest band downward (rare; band relaxation should be justified).
|
||||
|
||||
**`pnpm coverage:diff` says "lcov file not found"** — run `pnpm test -- --coverage && pnpm coverage:aggregate` first. The diff script reads the merged root `coverage/lcov.info`.
|
||||
|
||||
**`coverage/summary.json` differs every commit** — expected. It includes `generatedAt` (ISO timestamp) and `commit` (SHA). The snapshot workflow only commits it when the underlying numbers change; in local dev, regenerating it shows diff noise.
|
||||
|
||||
**Diff coverage flags a file I don't think should be gated** — check the allowlist in `scripts/coverage/diff.mjs`. If the file genuinely shouldn't be gated, extend the allowlist (and the tests in `diff.test.mjs`).
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR-020](../decisions/adr-020-coverage-architecture.md) — full architectural rationale
|
||||
- [ADR-011](../decisions/adr-011-tdd-foundation.md) — original TDD foundation (the thresholds originated here)
|
||||
- [PRD 2026-05-13-coverage-architecture](../work/prds/2026-05-13-coverage-architecture.prd.md) — implementation seed with audit findings
|
||||
- [docs/glossary.md](../glossary.md) — canonical vocabulary
|
||||
- [docs/guides/conformance-quickref.md](./conformance-quickref.md) — sibling reference for the 5-gate conformance system
|
||||
189
docs/guides/dsr.md
Normal file
189
docs/guides/dsr.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Data Subject Rights (DSR) guide
|
||||
|
||||
Consumer-facing reference for the `@repo/core-dsr` optional core package. Covers the four GDPR interfaces, tRPC procedure wiring, multi-subject collection handling, deletion semantics, `DeletionCertificate` format, and per-article compliance notes.
|
||||
|
||||
**Prerequisite:** scaffold the package if it isn't already present:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-package dsr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interfaces and GDPR article mapping
|
||||
|
||||
`@repo/core-dsr` exposes four vendor-neutral interfaces:
|
||||
|
||||
| Interface | tRPC procedure | GDPR article(s) | Mutation? |
|
||||
| ------------------------ | -------------- | ----------------------------------------- | ---------- |
|
||||
| `IDataExport` | `dsr.export` | Art. 15 (access) + Art. 20 (portability) | No (query) |
|
||||
| `IDataDelete` | `dsr.delete` | Art. 17 (erasure / right to be forgotten) | Yes |
|
||||
| `IDataRectify` | `dsr.rectify` | Art. 16 (rectification) | Yes |
|
||||
| `IProcessingRestriction` | `dsr.restrict` | Art. 18 (restriction of processing) | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Wiring the DSR router
|
||||
|
||||
`core-dsr` ships a pre-built tRPC router. Mount it once in your app router:
|
||||
|
||||
```ts
|
||||
// apps/web-next/src/server/router.ts
|
||||
import { createDsrRouter } from "@repo/core-dsr";
|
||||
import { bindProductionDsr } from "@repo/core-dsr/di/bind-production";
|
||||
|
||||
const dsrBinding = bindProductionDsr({ config: payloadConfig, auditLog });
|
||||
|
||||
export const appRouter = t.router({
|
||||
// ... other feature routers
|
||||
dsr: createDsrRouter(dsrBinding),
|
||||
});
|
||||
```
|
||||
|
||||
All four procedures require an authenticated user in `ctx.user`. `cascade-hard` deletion additionally requires `ctx.user.roles` to include `"admin"`.
|
||||
|
||||
### Input schemas
|
||||
|
||||
| Procedure | Input fields |
|
||||
| -------------- | ---------------------------------------------------------------------------- |
|
||||
| `dsr.export` | `subjectId: string`, `format: "json" \| "json-ld"` |
|
||||
| `dsr.delete` | `subjectId: string`, `mode: "soft" \| "cascade-hard"` |
|
||||
| `dsr.rectify` | `subjectId: string`, `collection: string`, `field: string`, `value: unknown` |
|
||||
| `dsr.restrict` | `subjectId: string`, `granted: boolean` |
|
||||
|
||||
---
|
||||
|
||||
## Multi-subject handling
|
||||
|
||||
A Payload collection can reference **multiple data subjects** — for example, a support ticket has both a submitter and an assignee. Declare subject relationships via `custom.subject` on the collection config:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/integrations/cms/support-tickets.collection.ts
|
||||
{
|
||||
slug: "support-tickets",
|
||||
custom: {
|
||||
subject: [
|
||||
{ field: "submittedBy", kind: "self", target: "users" },
|
||||
{ field: "assignedTo", kind: "reference", target: "users", role: "assignee" },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{ name: "submittedBy", type: "relationship", relationTo: "users" },
|
||||
{ name: "assignedTo", type: "relationship", relationTo: "users" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The DSR cascade walks each `SubjectLink` at runtime to determine scope:
|
||||
|
||||
| `kind` | Meaning | Export behaviour | Delete behaviour |
|
||||
| ------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | ---------------------------------- |
|
||||
| `"self"` | The subject **is** this row (e.g., the Users row itself) | Full row in `asSelf` | Row deleted / pseudonymized |
|
||||
| `"owner"` | The subject **created or owns** this row (e.g., posts authored by the user) | Full row in `asSelf` | Row deleted / pseudonymized |
|
||||
| `"reference"` | The subject is **referenced** in this row but does not own it (e.g., assignee on a ticket) | Row ID + link coords in `asReference` | Linked field NULLed; row preserved |
|
||||
|
||||
See `docs/compliance/subject-linkage.example.md` for a full annotated example.
|
||||
|
||||
---
|
||||
|
||||
## Deletion modes
|
||||
|
||||
### `soft` (default, no admin role required)
|
||||
|
||||
Redacts or pseudonymizes PII fields in rows the subject owns (`kind: "self" | "owner"`) while preserving foreign-key integrity. Reference rows (`kind: "reference"`) have the linking field NULLed. The row structure remains intact — useful for preserving order history, audit trails, and referential integrity.
|
||||
|
||||
### `cascade-hard` (admin role required)
|
||||
|
||||
Hard-deletes all rows the subject owns (where `postDeletion.action === "hard-delete"` in the collection's retention config), then NULLs reference fields. Use only when the subject's right to erasure overrides your referential-integrity requirements or when retention policy mandates hard deletion.
|
||||
|
||||
Retention-policy overrides apply: a collection with `postDeletion.action = "pseudonymize"` will pseudonymize rather than hard-delete even in `cascade-hard` mode.
|
||||
|
||||
---
|
||||
|
||||
## `DeletionCertificate` format
|
||||
|
||||
`IDataDelete.deleteSubjectData(subjectId, mode)` resolves to a `DeletionCertificate`:
|
||||
|
||||
```ts
|
||||
type DeletionCertificate = {
|
||||
subjectId: string; // or "erased-{hash}" if the ID itself was purged
|
||||
mode: "soft" | "cascade-hard";
|
||||
timestamp: string; // ISO 8601
|
||||
reason: "art-17-request" | "admin-expunge" | "retention-policy";
|
||||
affected: Array<{
|
||||
collection: string;
|
||||
rowsAffected: number;
|
||||
action: "deleted" | "redacted" | "pseudonymized";
|
||||
fields?: string[]; // PII field names NULLed when action === "redacted"
|
||||
}>;
|
||||
auditEntryId: string; // links to the immutable audit log entry
|
||||
};
|
||||
```
|
||||
|
||||
**Storage requirements:** persist the `DeletionCertificate` permanently — it is your Art. 17 compliance evidence for regulatory inspection. The `auditEntryId` forms a tamper-evident chain back to the `core-audit` log. Never mutate a certificate after creation.
|
||||
|
||||
---
|
||||
|
||||
## `UserDataBundle` format (export)
|
||||
|
||||
`IDataExport.exportSubjectData(subjectId, format)` resolves to a `UserDataBundle`:
|
||||
|
||||
```ts
|
||||
type UserDataBundle = {
|
||||
subjectId: string;
|
||||
exportedAt: string; // ISO 8601
|
||||
format: "json" | "json-ld";
|
||||
data: Record<
|
||||
string,
|
||||
{
|
||||
// keyed by collection slug
|
||||
asSelf?: Array<Record<string, unknown>>; // owned rows (PII-filtered)
|
||||
asReference?: Array<{
|
||||
rowId: string;
|
||||
linkedField: string;
|
||||
linkedThrough: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
auditLog?: AuditEntry[]; // subject-scoped audit history
|
||||
"@context"?: string | Record<string, unknown>; // JSON-LD only
|
||||
};
|
||||
```
|
||||
|
||||
Only fields marked `exportable: true` in their `custom.pii` block are included in `asSelf` rows.
|
||||
|
||||
---
|
||||
|
||||
## Compliance notes
|
||||
|
||||
### Art. 15 — Right of access
|
||||
|
||||
`dsr.export` with `format: "json"` satisfies the right of access. Return the `UserDataBundle` directly in the API response or as a downloadable JSON file. Response time: ≤ 30 days under GDPR.
|
||||
|
||||
### Art. 16 — Right to rectification
|
||||
|
||||
`dsr.rectify` targets a single field. For bulk updates, call it once per field. The implementation calls `IDataRectify.rectifySubjectData(subjectId, collection, field, value)` and records an audit entry.
|
||||
|
||||
### Art. 17 — Right to erasure
|
||||
|
||||
`dsr.delete` with `mode: "soft"` is the default erasure path. Use `mode: "cascade-hard"` only when data minimisation obligations outweigh referential integrity needs. Both paths produce a `DeletionCertificate`.
|
||||
|
||||
**Exemptions:** Art. 17(3) allows retention for legal obligations (e.g., invoices, tax records). Implement exemptions via a `postDeletion.action = "pseudonymize"` retention override on the affected collection — the DSR cascade respects this automatically.
|
||||
|
||||
### Art. 18 — Restriction of processing
|
||||
|
||||
`dsr.restrict` with `granted: true` flags the subject's data for restricted processing. Your application code must check `IProcessingRestriction.isRestricted(subjectId)` before performing processing operations on restricted subjects.
|
||||
|
||||
### Art. 20 — Right to data portability
|
||||
|
||||
`dsr.export` with `format: "json-ld"` produces a machine-readable JSON-LD export suitable for portability. The `@context` field is populated with the schema URI for downstream consumption.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Subject linkage patterns: `docs/compliance/subject-linkage.example.md`
|
||||
- PII field tagging: `docs/compliance/README.md` → "Annotating PII fields"
|
||||
- Retention policy: `docs/compliance/retention-policy.example.yml`
|
||||
- Audit log: `docs/guides/audit-and-compliance.md`
|
||||
- Glossary: `SubjectLink`, `DeletionCertificate` in `docs/glossary.md`
|
||||
287
docs/guides/events-and-jobs.md
Normal file
287
docs/guides/events-and-jobs.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Events and Jobs
|
||||
|
||||
Walkthrough for adding cross-feature events and background jobs to a feature. For the architectural rationale, see [ADR-015](../decisions/adr-015-events-and-jobs.md).
|
||||
|
||||
> **Prerequisite — `@repo/core-events` is optional.**
|
||||
> The event bus (`IEventBus`, `InMemoryEventBus`, `PayloadJobsEventBus`) lives
|
||||
> in `@repo/core-events`, which ships as a scaffoldable package rather than a
|
||||
> permanent fixture. If `packages/core-events/` does not exist in your repo,
|
||||
> run `pnpm turbo gen core-package events` first, then wire the bus into
|
||||
> `apps/web-next/src/server/bind-production.ts` as described in the generator's
|
||||
> next-steps output.
|
||||
>
|
||||
> Background jobs (`IJobQueue`, `gen job`) work without core-events — they only
|
||||
> require `@repo/core-shared/jobs`. Cross-feature event fanout (`gen event
|
||||
consume`) additionally requires core-events.
|
||||
|
||||
The three rules to keep in mind:
|
||||
|
||||
- **E0** — Events are for cross-feature decoupling. In-feature reactions are direct use-case calls.
|
||||
- **E1** — Event contracts are public; handlers are private (never re-exported, ESLint-enforced).
|
||||
- **J0** — Jobs are for _deferred_ work (latency, retries, cron). Synchronous code stays synchronous.
|
||||
|
||||
Three generators do the boilerplate. Each one inserts at fixed `// <gen:*>` anchor comments that are present in every feature.
|
||||
|
||||
```bash
|
||||
pnpm turbo gen event publish # publisher contract
|
||||
pnpm turbo gen event consume # consumer handler + Payload event-task
|
||||
pnpm turbo gen job # background job + Payload TaskConfig
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Publish an event
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen event --args publish auth user.signed-up
|
||||
```
|
||||
|
||||
This scaffolds:
|
||||
|
||||
- `packages/auth/src/events/user-signed-up.event.ts` (the contract — descriptor + Zod schema + type alias)
|
||||
- `packages/auth/src/events/user-signed-up.event.test.ts`
|
||||
- A re-export at the `// <gen:events>` anchor in `packages/auth/src/index.ts`
|
||||
|
||||
Then **fill in the schema** with the actual fields the event carries:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/events/user-signed-up.event.ts
|
||||
export const userSignedUpEventSchema = z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
email: z.string().email(),
|
||||
signedUpAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
```
|
||||
|
||||
Then **publish from a use case**. Add `bus: IEventBus` to the factory signature and call `bus.publish(...)` after the success path:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/application/use-cases/sign-up.use-case.ts
|
||||
import type { IEventBus } from "@repo/core-events";
|
||||
import { userSignedUpEvent } from "../../events/user-signed-up.event";
|
||||
|
||||
export const signUpUseCase =
|
||||
(
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
bus: IEventBus,
|
||||
) =>
|
||||
async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
// ... existing logic ...
|
||||
await bus.publish(userSignedUpEvent, {
|
||||
userId: newUser.id,
|
||||
email: `${newUser.username}@example.local`,
|
||||
signedUpAt: new Date().toISOString(),
|
||||
});
|
||||
return signUpOutputSchema.parse({ session, cookie });
|
||||
};
|
||||
```
|
||||
|
||||
Then **update DI**. Both `bind-production.ts` and `bind-dev-seed.ts` already receive `bus` as a parameter; just thread it into the factory call:
|
||||
|
||||
```ts
|
||||
const wrappedSignUp = withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signUp", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "use-case", name: "auth.signUp" },
|
||||
signUpUseCase(repo, authService, bus),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
If the feature has a default-fallback `module.ts` that resolves use cases via `.toDynamicValue()`, give it a fresh `new InMemoryEventBus()` per resolution — the module is the test-mock fallback path, not a runtime path.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/auth lint typecheck test
|
||||
```
|
||||
|
||||
The publishing test asserts on the `RecordingEventBus`'s `published` array. See `signUpUseCase`'s test for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## 2. Consume an event
|
||||
|
||||
Run from the consumer's perspective (here `marketing-pages` consumes `auth`):
|
||||
|
||||
```bash
|
||||
pnpm turbo gen event --args consume marketing-pages user.signed-up auth
|
||||
```
|
||||
|
||||
This scaffolds:
|
||||
|
||||
- `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts` + test
|
||||
- `packages/marketing-pages/src/integrations/cms/jobs/__events-auth-user-signed-up.task.ts` (the Payload event-task that closes the production-bus loop)
|
||||
|
||||
…and modifies four files at their anchors:
|
||||
|
||||
- `src/di/symbols.ts` — adds the handler symbol at `// <gen:event-handler-symbols>`
|
||||
- `src/di/bind-production.ts` — wraps the handler in span+capture, binds to the symbol, and calls `bus.subscribe(...)` at `// <gen:event-handlers>`
|
||||
- `src/di/bind-dev-seed.ts` — same as production, identical block
|
||||
- `src/integrations/cms/index.ts` — re-exports the event-task at `// <gen:job-tasks>` so `core-cms` aggregates it
|
||||
|
||||
The generator prints two manual edits:
|
||||
|
||||
**1. Add the imports** at the top of both bind files (the modify-block can't add imports):
|
||||
|
||||
```ts
|
||||
import { userSignedUpEvent } from "@repo/auth";
|
||||
import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler";
|
||||
```
|
||||
|
||||
**2. Add the cross-feature dep** to the consumer's `package.json`:
|
||||
|
||||
```json
|
||||
"@repo/auth": "workspace:*"
|
||||
```
|
||||
|
||||
Then **implement the handler body**. The factory shape is `(deps) => async (event) => Promise<void>`. Inject what the handler needs — typically a job queue if the reaction is deferred:
|
||||
|
||||
```ts
|
||||
// packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts
|
||||
import type { UserSignedUpEvent } from "@repo/auth";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
|
||||
export const onAuthUserSignedUpHandler =
|
||||
(queue: IJobQueue) =>
|
||||
async (event: UserSignedUpEvent): Promise<void> => {
|
||||
await queue.enqueue("marketing-pages.send-welcome-email", {
|
||||
userId: event.userId,
|
||||
email: event.email,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
The generator emitted `onAuthUserSignedUpHandler()` with no args in the bind block. Edit it to pass `queue`:
|
||||
|
||||
```ts
|
||||
onAuthUserSignedUpHandler(queue),
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/marketing-pages lint typecheck test
|
||||
pnpm --filter @repo/core-cms typecheck
|
||||
```
|
||||
|
||||
Handlers must NOT be re-exported from the consumer's public surface (rule E1 — enforced by `core-eslint`'s `no-handler-reexport` rule).
|
||||
|
||||
---
|
||||
|
||||
## 3. Add a job
|
||||
|
||||
```bash
|
||||
pnpm turbo gen job --args marketing-pages send-welcome-email typed
|
||||
```
|
||||
|
||||
The third arg picks the input shape: `void` for parameter-less jobs, `typed` for jobs that take a payload.
|
||||
|
||||
This scaffolds:
|
||||
|
||||
- `packages/marketing-pages/src/jobs/send-welcome-email.job.ts` + test
|
||||
- `packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts`
|
||||
|
||||
…and modifies four files at the job anchors (`<gen:job-symbols>`, both `<gen:jobs>`, `<gen:job-tasks>`).
|
||||
|
||||
**Fill in the schema and body.** Inject the dependencies you need:
|
||||
|
||||
```ts
|
||||
// packages/marketing-pages/src/jobs/send-welcome-email.job.ts
|
||||
import { z } from "zod";
|
||||
import type { IMailerService } from "../application/services/mailer.service.interface";
|
||||
|
||||
export const sendWelcomeEmailInputSchema = z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
email: z.string().email(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SendWelcomeEmailInput = z.infer<typeof sendWelcomeEmailInputSchema>;
|
||||
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;
|
||||
|
||||
export const sendWelcomeEmailJob =
|
||||
(mailer: IMailerService) =>
|
||||
async (input: SendWelcomeEmailInput): Promise<void> => {
|
||||
sendWelcomeEmailInputSchema.parse(input);
|
||||
await mailer.sendWelcome(input.userId, input.email);
|
||||
};
|
||||
```
|
||||
|
||||
**Wire the dependency in both binders.** The generator emitted `sendWelcomeEmailJob()`; edit to pass the mailer:
|
||||
|
||||
```ts
|
||||
sendWelcomeEmailJob(mailer),
|
||||
```
|
||||
|
||||
**For dev-seed, register the slug** with the `InMemoryJobQueue` so `enqueue` actually fires:
|
||||
|
||||
```ts
|
||||
if (
|
||||
"register" in queue &&
|
||||
typeof (queue as { register?: unknown }).register === "function"
|
||||
) {
|
||||
(
|
||||
queue as {
|
||||
register: (slug: string, h: (input: unknown) => Promise<void>) => void;
|
||||
}
|
||||
).register("marketing-pages.send-welcome-email", async (input) => {
|
||||
const wrapped = marketingPagesContainer.get<ISendWelcomeEmailJob>(
|
||||
MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
|
||||
);
|
||||
await wrapped(input as SendWelcomeEmailInput);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Production skips this — the generated Payload task in `integrations/cms/jobs/<job>.task.ts` resolves the wrapped job from the per-feature container at runtime.
|
||||
|
||||
**Edit Payload's `inputSchema`** in the generated `<job>.task.ts` to match your Zod schema (Payload's `inputSchema` is field-config, not a TypeScript shape):
|
||||
|
||||
```ts
|
||||
inputSchema: [
|
||||
{ name: "userId", type: "text", required: true },
|
||||
{ name: "email", type: "email", required: true },
|
||||
],
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/marketing-pages lint typecheck test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron schedules
|
||||
|
||||
Job cron schedules don't live in the feature's job file or generator output — they live in `core-cms`'s `buildConfig({ jobs: { ... } })`. If a job runs periodically, register it in the Payload jobs config alongside the task slug.
|
||||
|
||||
## Anchor protocol
|
||||
|
||||
Six fixed anchor comments live in every feature:
|
||||
|
||||
| File | Anchor | Used by |
|
||||
| ------------------------------- | -------------------------------- | ------------------------------ |
|
||||
| `src/index.ts` | `// <gen:events>` | `gen event publish` |
|
||||
| `src/di/symbols.ts` | `// <gen:event-handler-symbols>` | `gen event consume` |
|
||||
| `src/di/symbols.ts` | `// <gen:job-symbols>` | `gen job` |
|
||||
| `src/di/bind-production.ts` | `// <gen:event-handlers>` | `gen event consume` |
|
||||
| `src/di/bind-production.ts` | `// <gen:jobs>` | `gen job` |
|
||||
| `src/di/bind-dev-seed.ts` | `// <gen:event-handlers>` | `gen event consume` |
|
||||
| `src/di/bind-dev-seed.ts` | `// <gen:jobs>` | `gen job` |
|
||||
| `src/integrations/cms/index.ts` | `// <gen:job-tasks>` | `gen event consume`, `gen job` |
|
||||
|
||||
A CI guard at `packages/core-eslint/anchors.test.js` asserts the anchors stay present in every feature. Remove an anchor and CI fails — restore it and the test goes green.
|
||||
|
||||
## End-to-end test reference
|
||||
|
||||
`apps/web-next/src/__tests__/sign-up-welcome-email.test.ts` exercises the full chain in dev-seed mode: `bindAllDevSeed()` → `signUpController(...)` → `bus.publish` → consumer handler → `queue.enqueue` → InMemoryJobQueue dispatch → `mailer.sendWelcome` recorded. Use it as a template when adding cross-feature flows.
|
||||
129
docs/guides/frontend-work-shape.md
Normal file
129
docs/guides/frontend-work-shape.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Frontend work shape
|
||||
|
||||
How frontend work — components, pages, media UI, anything visual — flows through the agent-first workflow. For the broader workflow context see [`docs/architecture/agent-first-workflow-and-conformance.md`](../architecture/agent-first-workflow-and-conformance.md).
|
||||
|
||||
## When this guide applies
|
||||
|
||||
You're working on:
|
||||
|
||||
- A new or existing component (atom / molecule / organism / template / page)
|
||||
- A page in `apps/web-next` or `apps/web-tanstack`
|
||||
- A visual or interactive change in `@repo/core-ui` or `features/<feature>/src/ui/`
|
||||
- Storybook stories or visual regression tests
|
||||
|
||||
If you're working on a backend use case (factory, repository, manifest entry, event handler, job, realtime channel), use the default backend shape documented in the architecture doc — not this guide.
|
||||
|
||||
## Where frontend code lives
|
||||
|
||||
| Surface | Location | When to use |
|
||||
|---|---|---|
|
||||
| Atomic-design primitives | `@repo/core-ui/src/{atoms,molecules,organisms,templates}/` | Cross-feature reusables. Generated via `pnpm turbo gen core-ui-component`. |
|
||||
| Feature-scoped UI | `features/<feature>/src/ui/` | Components and queries specific to a feature. Exported behind the feature's `./ui` subpath, never the root barrel (see CLAUDE.md). |
|
||||
| Pages | `apps/<app>/src/...` (Next.js: `app/`, TanStack: `routes/`) | Page-level composition. Calls use cases via tRPC controllers; composes components. |
|
||||
|
||||
## Atomic design conventions
|
||||
|
||||
Tier rules (enforced by the `atomic-tier-import-direction` ESLint rule):
|
||||
|
||||
| Tier | May import from | May NOT import from |
|
||||
|---|---|---|
|
||||
| `atoms` | nothing else in `core-ui` | molecules / organisms / templates / pages / features |
|
||||
| `molecules` | atoms | organisms / templates / pages / features |
|
||||
| `organisms` | atoms, molecules | templates / pages / features |
|
||||
| `templates` | atoms, molecules, organisms | pages / features |
|
||||
| `pages` | all of the above | other pages |
|
||||
|
||||
Feature-scoped UI (`features/<feature>/src/ui/`) follows the same tier order internally, and may import from `@repo/core-ui` at any tier.
|
||||
|
||||
## Storybook is the spec
|
||||
|
||||
Every component has a sibling `.stories.tsx`. The story file:
|
||||
|
||||
- Declares one story per AC bullet (or one variant per AC bullet)
|
||||
- Uses Storybook's `play` function for interaction tests where applicable
|
||||
- Becomes the shared visual contract between human, implementer agent, reviewer agent
|
||||
|
||||
**Acceptance criteria for a frontend task map directly to story variants:**
|
||||
|
||||
```markdown
|
||||
## Acceptance criteria
|
||||
- [ ] Renders default variant
|
||||
- [ ] Renders loading state
|
||||
- [ ] Renders error state with `message` slot
|
||||
- [ ] Renders disabled state
|
||||
- [ ] Click handler fires with event payload
|
||||
```
|
||||
|
||||
Each bullet becomes one `Story` export (or one `play` step on a story).
|
||||
|
||||
## Test gates
|
||||
|
||||
| Gate | What it covers | Tool | Latency | When it runs |
|
||||
|---|---|---|---|---|
|
||||
| Component test | behavior, props, interactions | Vitest + Testing Library, or `play` on stories | <5s | pre-commit, CI |
|
||||
| Visual regression | rendered appearance vs. baseline | Playwright screenshot tests | 30–120s | CI only; blocks merge on unapproved diffs |
|
||||
|
||||
The visual regression infrastructure ships as part of the `work-system-v1` epic. Until it's in place, frontend work relies on component tests + manual visual review.
|
||||
|
||||
## Adapted four-step ordering
|
||||
|
||||
For a pure UI slice (no backend coupling):
|
||||
|
||||
1. **Story file** — write or extend `.stories.tsx`; declare the variant for this slice's AC bullet. *This is the visual spec — analogous to the manifest entry for backend work.*
|
||||
2. **Contracts** — props interface in the component file (factory body / render body may still throw `not implemented`)
|
||||
3. **Tests (red)** — component test + the new story (the rendered output is asserted as part of the visual regression baseline, or via Testing Library)
|
||||
4. **Implementation (green)** — render JSX, wire interactions, satisfy AC
|
||||
|
||||
For a UI slice that consumes a backend use case (e.g. a form component calling `auth.signUp`), the use case lives in a separate story with its own four steps; the UI story `depends-on` the use-case story.
|
||||
|
||||
## Reviewer integration
|
||||
|
||||
The reviewer agent for frontend tasks uses two extra inputs beyond the standard diff:
|
||||
|
||||
1. **Storybook MCP** at `http://localhost:6006/mcp` — query existing components, list variants, fetch the metadata for stories that already exist. The reviewer can `list-all-documentation` to verify no duplicate component was created.
|
||||
2. **Playwright screenshot CI verdict** — the visual diff status from CI. Unapproved diffs trigger `reject` regardless of code quality.
|
||||
|
||||
Reviewer checklist for frontend tasks:
|
||||
|
||||
- [ ] Every AC bullet has a matching story or `play` step
|
||||
- [ ] No accidental cross-tier imports (lint already catches this, but verify)
|
||||
- [ ] Component test exercises each AC bullet
|
||||
- [ ] Visual regression diff is either zero or human-approved
|
||||
- [ ] No new component created without checking for an existing equivalent via Storybook MCP
|
||||
|
||||
## Story split for page-level features
|
||||
|
||||
A user-facing feature like "sign up" decomposes into multiple stories under one epic:
|
||||
|
||||
```
|
||||
Epic: auth-v1
|
||||
├── Story (user-story): auth.signUp use case
|
||||
├── Story (technical-story): SignUpForm component
|
||||
│ depends-on: signUp use case
|
||||
└── Story (technical-story): /sign-up page
|
||||
depends-on: SignUpForm
|
||||
```
|
||||
|
||||
Each story has its own AC checklist; the `depends-on` edges enforce sequential dispatch by the orchestrator. The page story typically includes the Playwright E2E test for the full flow.
|
||||
|
||||
## Conformance for frontend
|
||||
|
||||
New ESLint rules added under the frontend work-shape:
|
||||
|
||||
- `component-must-have-story` — every `.tsx` file exporting a default OR named React component must have a sibling `.stories.tsx`
|
||||
- `component-must-have-test` — every component must have a sibling `.test.tsx` (or be covered by a `play` function on its story)
|
||||
- `atomic-tier-import-direction` — tier imports must respect the table above
|
||||
- `story-must-cover-all-prop-variants` (advisory) — discriminated-union props should have a story per variant
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Stale stories.** A component changes prop shape; stories don't get updated. The `component-must-have-story` rule catches missing stories but not stale ones. Reviewer should flag this; visual regression catches the rendered consequence.
|
||||
- **Visual-only changes without story update.** A CSS tweak that doesn't change behavior still needs the story to be re-snapshotted. Treat the visual regression diff approval as part of the slice.
|
||||
- **Cross-tier creep.** Tempting to import an organism from inside an atom for "convenience". The lint rule blocks it; refactor the shared bit down to an atom or molecule.
|
||||
- **Duplicate components.** An agent creates a `Button` in a feature's `ui/atoms/` when one already exists in `core-ui`. Reviewer queries Storybook MCP for `Button` before approval; if duplicated, rejects.
|
||||
|
||||
## Open items (filled in as we ship)
|
||||
|
||||
- Playwright screenshot infrastructure — exact setup, baseline storage, approval flow
|
||||
- Visual regression PR check — GitHub workflow + diff hosting
|
||||
- Storybook test integration (`test:stories`) — current status and what we extend
|
||||
154
docs/guides/infrastructure-work-shape.md
Normal file
154
docs/guides/infrastructure-work-shape.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Infrastructure work shape
|
||||
|
||||
How infrastructure work — new core packages, new external services, new database backends, new build/deploy primitives — flows through the agent-first workflow. For the broader workflow context see [`docs/architecture/agent-first-workflow-and-conformance.md`](../architecture/agent-first-workflow-and-conformance.md).
|
||||
|
||||
## When this guide applies
|
||||
|
||||
You're proposing or implementing:
|
||||
|
||||
- A new optional core package (`core-cache`, `core-email`, `core-feature-flags`, …)
|
||||
- A new external service or layer (Redis, CDN, alternative CMS, additional message bus, …)
|
||||
- A change to bootstrap, build, or CI infrastructure
|
||||
- A swap of an existing infrastructure component
|
||||
|
||||
If you're working on backend feature code or frontend, this is not your guide.
|
||||
|
||||
## Two categories
|
||||
|
||||
| Category | Example | Path |
|
||||
|---|---|---|
|
||||
| **A. New optional core package** | `core-cache`, `core-email` | `pnpm turbo gen core-package <name>` |
|
||||
| **B. New infrastructure layer** | Redis, CDN, deploy target, CI image swap | ADR → integration PRD → epic + stories |
|
||||
|
||||
Category A is well-trodden: the generator emits a canonical core-package shape; the conformance system already handles optional cores via `requiredCores` in manifests. No new conformance rules required.
|
||||
|
||||
Category B is the one that needs the ADR-first dance described below.
|
||||
|
||||
## ADRs precede infrastructure work
|
||||
|
||||
ADRs (Architecture Decision Records) live at `docs/adr/NNN-<slug>.md`. The pattern is established in this repo (ADR-018 captured the audit-and-compliance system, for example).
|
||||
|
||||
ADRs are to infrastructure what PRDs are to features: decision documents, written first, providing the rationale that downstream PRDs and stories implement. An ADR documents *what was decided and why*; a PRD documents *what to build*.
|
||||
|
||||
## ADR authoring flow
|
||||
|
||||
```
|
||||
1. Human runs `pnpm work adr-new "<one-line proposal>"`
|
||||
2. Dedicated ADR elicitation skill interviews the human:
|
||||
- Context (what's the situation today?)
|
||||
- Drivers (what's forcing a decision?)
|
||||
- Considered options (what alternatives are on the table?)
|
||||
- Trade-offs (what does each option cost?)
|
||||
- Decision (which option, and why?)
|
||||
- Consequences (what changes downstream?)
|
||||
3. Agent drafts ADR at docs/adr/NNN-<slug>.md with status: proposed
|
||||
4. Human reviews, edits, flips to status: accepted (or rejected / superseded)
|
||||
5. Accepted ADR(s) trigger one or more integration PRDs
|
||||
6. PRDs flow through the normal decompose → dispatch loop
|
||||
```
|
||||
|
||||
The ADR elicitation skill is distinct from the PRD elicitation skill. Same interview shape, different template and pushiness:
|
||||
|
||||
- PRD eliciter focuses on **problem framing** and **success criteria**
|
||||
- ADR eliciter focuses on **alternatives** and **trade-offs** — it actively pushes back if only one option is articulated
|
||||
|
||||
## ADR template
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: NNN
|
||||
title: <decision title>
|
||||
status: proposed | accepted | rejected | superseded
|
||||
date: YYYY-MM-DD
|
||||
supersedes: []
|
||||
superseded-by: null
|
||||
related-prds: []
|
||||
---
|
||||
|
||||
## Context
|
||||
What's the situation? What's broken or about to break? Who is affected?
|
||||
|
||||
## Drivers
|
||||
What's forcing this decision now?
|
||||
|
||||
## Considered options
|
||||
- Option 1: ...
|
||||
- Pros / Cons
|
||||
- Option 2: ...
|
||||
- Pros / Cons
|
||||
- Option 3: ...
|
||||
- Pros / Cons
|
||||
|
||||
## Decision
|
||||
We chose Option X because ...
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- ...
|
||||
|
||||
### Negative / accepted trade-offs
|
||||
- ...
|
||||
|
||||
### Follow-up work
|
||||
- PRD: ...
|
||||
- PRD: ...
|
||||
```
|
||||
|
||||
## Path A: new optional core package
|
||||
|
||||
When the new infrastructure is *small enough to be a single package* (cache, email, feature flags, etc.):
|
||||
|
||||
1. ADR (if the decision is non-trivial — for "add core-cache because we need response caching", a short ADR is still worthwhile)
|
||||
2. `pnpm turbo gen core-package <name>` — generator emits canonical shape
|
||||
3. Implement package contents (interface, impls, tests) — flows through normal story-and-task decomposition
|
||||
4. Add `<name>` to manifests' `requiredCores` in features that adopt it
|
||||
5. Wire into `BindContext` in `core-shared/di/`
|
||||
6. Wire into each app's `bindAll()` aggregator
|
||||
7. Update generator templates if features need to scaffold differently when this core is present (e.g., `core-events` causes `turbo gen feature` to emit event-handler stubs)
|
||||
|
||||
The conformance system catches forgotten wiring automatically:
|
||||
|
||||
- `required-cores-installed` ESLint rule flags manifests declaring `requiredCores: ["<name>"]` when the package isn't in `pnpm-workspace.yaml`
|
||||
- Boot assertion `assertConformance(ctx)` fails on missing bind-context entries
|
||||
- CI's `pnpm conformance` aggregates the cross-feature view
|
||||
|
||||
## Path B: new infrastructure layer
|
||||
|
||||
When the new infrastructure is bigger than one package — a new external service, a swap of an existing component, a deploy-target change:
|
||||
|
||||
1. **ADR** captures the decision and trade-offs
|
||||
2. **Integration PRD(s)** specify what to build/wire/migrate; one PRD per integration concern is common
|
||||
3. PRDs decompose into the normal Epic → Story → Task hierarchy
|
||||
4. Stories typically include: a new core package (if one fits), configuration plumbing, bootstrap wiring, feature adoption, documentation updates, and (often) a deprecation/removal of the prior approach
|
||||
|
||||
Examples of category B work:
|
||||
|
||||
- "Add Redis for response caching" — ADR + core-cache package + bind-context wiring + adoption stories per feature
|
||||
- "Replace Payload with Sanity" — ADR + repository implementation swap + migration scripts + docs rewrite
|
||||
- "Move from Vercel to Cloudflare Workers" — ADR + new deploy target stories per app + CI workflow updates
|
||||
|
||||
## Conformance for infrastructure
|
||||
|
||||
Two rules extend the conformance system for infra concerns:
|
||||
|
||||
| Rule | Layer | What it catches |
|
||||
|---|---|---|
|
||||
| `required-cores-in-workspace` | ESLint + CI | manifest declares a core that isn't in `pnpm-workspace.yaml` |
|
||||
| `core-package-shape-conforms-to-generator` | CI | a core package's structure has drifted from what `pnpm turbo gen core-package <name>` would produce today |
|
||||
|
||||
These extend the existing milestone iv work; no new tooling required.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Skipping the ADR for "small" infra changes.** A swap of a single dependency is still a decision worth recording. The ADR doesn't have to be long — but it should exist.
|
||||
- **Implementing before the ADR is accepted.** Don't decompose an integration PRD into stories until the ADR is `accepted`. The work-system orchestrator can refuse to dispatch tasks whose epic's ADR is still `proposed`.
|
||||
- **Manifest drift after infra adoption.** A new core is added but features that use it don't declare it in `requiredCores`. The ESLint rule catches the inverse (declared-but-not-installed); CI's full conformance run also checks the other direction.
|
||||
- **Bootstrap wiring forgotten.** A new core package is created but never bound in `apps/*/server/bind-production.ts`. Boot assertion catches this on first `pnpm dev`.
|
||||
- **Documentation lag.** New infra is shipped but `docs/guides/` and `CLAUDE.md` aren't updated. Make documentation a required story under any integration epic.
|
||||
|
||||
## Open items (filled in as we ship)
|
||||
|
||||
- ADR elicitation skill — exact prompt shape and heuristics
|
||||
- ADR ↔ PRD linkage — how an accepted ADR fans out into one or more PRDs (frontmatter `related-prds`?)
|
||||
- Refusal logic — should the work-system refuse to decompose a PRD whose triggering ADR is still proposed?
|
||||
248
docs/guides/operator-checklist.md
Normal file
248
docs/guides/operator-checklist.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Operator checklist
|
||||
|
||||
The decisions and code that make this template's security + supply-chain
|
||||
stack work are landed in code (ADR-022, ADR-023, the library-evaluation
|
||||
epic, the CI-security epic). This doc covers the **human-side actions**
|
||||
required to put it into operation in a real GitHub-hosted repo. Read top
|
||||
to bottom on first adoption; revisit the "Ongoing" section weekly.
|
||||
|
||||
The biggest leverage move is **#1 (push to a remote)** — until that
|
||||
happens, the entire `.github/workflows/` surface is inert. Everything
|
||||
else cascades from there.
|
||||
|
||||
---
|
||||
|
||||
## Right now (unblocks everything else)
|
||||
|
||||
**1. Push to a GitHub remote.** No remote is configured by default on a
|
||||
fresh template clone, which means CI doesn't run anywhere. Either:
|
||||
|
||||
- `gh repo create <owner>/template-vertical --source=. --private --push` (or `--public`)
|
||||
- Or push to an existing remote: `git remote add origin <url> && git push -u origin main`
|
||||
|
||||
**Decision attached:** public vs private. Public → CodeQL is free, Socket
|
||||
free tier just works. Private → CodeQL needs GitHub Pro/Team/Enterprise
|
||||
plan (workflow runs unconditionally but GitHub gates execution).
|
||||
|
||||
---
|
||||
|
||||
## During the dispatch loop (the agent drives this; you watch)
|
||||
|
||||
**2. Continue the in-flight library-evaluation epic.** Run
|
||||
`pnpm work dispatch --execute`. The dispatcher picks up the next unticked
|
||||
bullet and marches through.
|
||||
|
||||
Refresh `~/.claude/.credentials.json` from the macOS keychain when
|
||||
sandcastle returns 401 (the keychain one-liner; happens ~every 30 days):
|
||||
|
||||
```bash
|
||||
security find-generic-password -s "Claude Code-credentials" -a "$USER" -w \
|
||||
> ~/.claude/.credentials.json
|
||||
chmod 600 ~/.claude/.credentials.json
|
||||
```
|
||||
|
||||
**3. After library-evaluation epic completes**, the CI-security epic
|
||||
unblocks. `pnpm work dispatch --execute` picks up its story 01
|
||||
automatically.
|
||||
|
||||
---
|
||||
|
||||
## After all implementation lands (one-time setup, ~30 minutes total)
|
||||
|
||||
**4. Install GitHub Apps** (one click each, free tier):
|
||||
|
||||
- **Renovate** → `https://github.com/apps/renovate` → grant repo access.
|
||||
After install, Renovate opens an onboarding PR — merge it to enable.
|
||||
**First real PR is the Action SHA-pin sweep** (rewrites `@v4` →
|
||||
`@<sha>` across all workflows). Merge that too.
|
||||
- **Socket Security** → `https://github.com/apps/socket-security` →
|
||||
grant repo access. PR comments start appearing on the next
|
||||
`package.json` diff.
|
||||
|
||||
**5. Toggle GitHub repo settings** (Settings → Code security and
|
||||
analysis):
|
||||
|
||||
- ✅ Dependabot **alerts** (server-side vuln scan) — your passive
|
||||
monitoring surface
|
||||
- ✅ Dependabot **security updates** — OFF (Renovate handles bumps;
|
||||
alerts stay on for visibility)
|
||||
- ✅ Secret scanning + **push protection** — blocks known token
|
||||
patterns at the GitHub edge
|
||||
- ✅ CodeQL alerts (auto-enabled by the workflow)
|
||||
|
||||
**6. Configure branch protection on `main`** (Settings → Branches →
|
||||
main):
|
||||
|
||||
- Require status checks: `validate`, `socket-security`, `CodeQL`
|
||||
- Require linear history (matches release-please's expectations)
|
||||
- Do **not** add `library-policy/re-evaluation` as a blocker — ADR-023
|
||||
explicitly decided against gating main on revalidation issues
|
||||
|
||||
**7. Add `TURBO_TOKEN` + `TURBO_TEAM`** as repo secrets/variables for
|
||||
Turborepo remote caching (already documented in `ci.yml`'s comment
|
||||
block).
|
||||
|
||||
**8. Sentry DSNs** if you want production observability per ADR-014 /
|
||||
ADR-017 (`WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, etc.) — set as repo
|
||||
secrets for the apps you actually deploy.
|
||||
|
||||
---
|
||||
|
||||
## Ongoing (per-decision, weekly cadence)
|
||||
|
||||
**9. Renovate's weekly PR stream.**
|
||||
|
||||
- **Minor + patch** bumps auto-merge if green. Nothing to do.
|
||||
- **Major** bumps block until `evaluate-library` re-runs and refreshes
|
||||
the trace's `last-revalidated`. The dispatch loop can pick these up
|
||||
via story-style task — or you walk the skill manually
|
||||
(`/evaluate-library <name> --tier <feature|core> --target <path>`).
|
||||
|
||||
**10. Weekly trace-revalidation cron fires** every Monday 06:30 UTC.
|
||||
|
||||
- Soft divergence → appended to the rolling `library-policy/dashboard`
|
||||
issue. Skim weekly; mostly no-action.
|
||||
- Hard divergence → fresh `library-policy/re-evaluation` issue per
|
||||
affected dep. **Human triage required** (ADR-023 §3, no
|
||||
auto-dispatch). Decide: re-walk evaluate-library, accept-with-
|
||||
allowlist, or migrate off the library. Add the issue to the dispatch
|
||||
queue if the re-walk is mechanical.
|
||||
|
||||
**11. CVE accepted-risk decisions.** When `pnpm audit` flags something
|
||||
with no patch available, add `accepted-cves: [CVE-XXXX-YYYY]` to the
|
||||
relevant trace's frontmatter with a note explaining why the risk is
|
||||
accepted.
|
||||
|
||||
**12. License-allowlist requests.** ADR-022 names
|
||||
`MIT/Apache-2.0/BSD/ISC/MPL-2.0` as allowlisted. If a real need surfaces
|
||||
(e.g. a `GPL-3.0-with-classpath-exception` library), you decide whether
|
||||
to extend — and the decision becomes an ADR-022 amendment in a new ADR.
|
||||
|
||||
---
|
||||
|
||||
## Analytics backend (ADR-024)
|
||||
|
||||
**13. Choose and wire an analytics vendor — or skip.** `@repo/core-analytics` ships
|
||||
`IAnalytics` as a vendor-neutral contract with no default backend. The analytics
|
||||
backend is **consumer-chosen**: the template deliberately ships no vendor because
|
||||
the choice requires ADR-022's library evaluation gate (EU residency, license,
|
||||
Socket.dev) and your product's consent model.
|
||||
|
||||
If you want product analytics:
|
||||
|
||||
- **Evaluate the vendor first.** Run
|
||||
`/evaluate-library <name> --tier core --target packages/core-analytics`. The
|
||||
resulting trace at `docs/library-decisions/<date>-<name>.md` is the required
|
||||
evidence before adding the package to your lockfile.
|
||||
- **Implement `IAnalytics`.** Write a wrapper around the vendor SDK that satisfies
|
||||
the four-method interface (`track`, `identify`, `pageView`, `flush`).
|
||||
- **Wire at DI bind time.** Pass the implementation into `ctx.analytics` in
|
||||
`bind-production.ts`. Feature binders pick it up and compose it into the
|
||||
`withAnalytics(...)` wrapper chain automatically.
|
||||
- **Wire `flush()` into graceful shutdown.** Hook `analytics.flush()` into your
|
||||
app's `SIGTERM` / `beforeExit` handler to drain the in-memory batch before the
|
||||
process exits — event loss on container/serverless shutdown is the most common
|
||||
analytics bug.
|
||||
- **Client side (optional).** Wrap your app root in
|
||||
`<AnalyticsProvider value={...}>` from `@repo/core-analytics/react`; expose
|
||||
`IAnalytics` to components via `useAnalytics()`.
|
||||
|
||||
If you don't want analytics, do nothing — the template boots with `NoopAnalytics`
|
||||
by default and no wiring is required.
|
||||
|
||||
---
|
||||
|
||||
## Compliance directory (ADR-025)
|
||||
|
||||
**14. Generate and commit the `compliance/` YAML files.** Three audit-evidence
|
||||
files live at the repo root under `compliance/`, generated from Payload schema
|
||||
declarations and library traces:
|
||||
|
||||
```bash
|
||||
pnpm compliance:emit-all
|
||||
# produces:
|
||||
# compliance/data-map.yml (from Payload field custom.pii tags)
|
||||
# compliance/retention-policy.yml (from Payload collection custom.retention)
|
||||
# compliance/sub-processors.yml (from docs/library-decisions/ traces flagged
|
||||
# is-sub-processor: true)
|
||||
git add compliance/
|
||||
git commit -m "compliance: initial audit-evidence YAML files"
|
||||
```
|
||||
|
||||
Run once after your Payload schema stabilises. Re-run and commit whenever schema
|
||||
changes. The CI drift gate (`pnpm compliance:emit-all --check`) fails if generated
|
||||
output diverges from source.
|
||||
|
||||
**15. Verify the compliance drift gate in pre-commit and CI.**
|
||||
|
||||
- **Pre-commit:** `.husky/pre-commit` should include the
|
||||
`pnpm compliance:emit-all --check` step. Verify after `pnpm install`.
|
||||
- **CI:** `ci.yml`'s `validate` job runs the same check on every PR. Add it to
|
||||
branch-protection required checks (step **6** above) so drift can't land on
|
||||
`main`.
|
||||
|
||||
**16. Schedule the retention purge job.** `core-shared/jobs/retention-purge.job.ts`
|
||||
reads `custom.retention` from each Payload collection at boot and enqueues
|
||||
per-collection purge runs on the declared `purgeSchedule`. To activate:
|
||||
|
||||
1. Set `custom.retention: { activeRetention, postDeletion, purgeSchedule,
|
||||
hardDeleteAfter }` on each Payload collection you want auto-purged. See
|
||||
`docs/compliance/retention-policy.example.yml` for the schema.
|
||||
2. Confirm the job runner is live in production — the purge job enqueues via
|
||||
`IJobQueue`, which requires `@repo/core-events` or Payload's native jobs queue.
|
||||
3. Verify `audit_events` gains `{ action: "DELETE", reason: "retention-policy" }`
|
||||
entries after the first purge cycle.
|
||||
|
||||
**17. Hand-author `compliance/sub-processors.manual.yml` for non-npm vendors.**
|
||||
The sub-processors generator only surfaces vendors that have an npm package in
|
||||
`docs/library-decisions/`. Any vendor you call via a pure REST API — no SDK, no
|
||||
library trace — needs a manual entry:
|
||||
|
||||
```yaml
|
||||
# compliance/sub-processors.manual.yml
|
||||
- name: SendGrid
|
||||
is-sub-processor: true
|
||||
processes-pii: true
|
||||
data-sent: [email-address, display-name]
|
||||
region: US
|
||||
dpa-signed: 2026-01-15
|
||||
sccs-required: true
|
||||
contact: privacy@sendgrid.com
|
||||
```
|
||||
|
||||
Commit this file alongside `compliance/sub-processors.yml`. Both are treated as
|
||||
audit evidence; unlike the generated files, this one is always hand-maintained.
|
||||
|
||||
---
|
||||
|
||||
## Read once the docs land
|
||||
|
||||
- `docs/guides/adding-a-library.md` (library-evaluation epic story 05)
|
||||
— human reading-room for the 9 filters + 3 prompts
|
||||
- `docs/guides/ci-security.md` (CI-security epic story 09) — human
|
||||
reading-room for the four pillars + failure-mode hierarchy table
|
||||
|
||||
---
|
||||
|
||||
## Cleanup notes
|
||||
|
||||
- **Untracked files in repo root** — `check-shas-cjs.js`,
|
||||
`check-shas.mjs`, `check-shas2.mjs`, `check-shas3.mjs`. These appeared
|
||||
during SHA-experimentation work and aren't part of any commit. Decide
|
||||
whether to delete or `.gitignore`.
|
||||
- **`apps/*/tsconfig.tsbuildinfo`** continues to churn on every
|
||||
typecheck. A `chore: gitignore tsbuildinfo` commit would stop that
|
||||
drift.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- ADR-022 — Library evaluation policy
|
||||
- ADR-023 — CI security + supply-chain enforcement stack
|
||||
- ADR-024 — Product analytics channel
|
||||
- ADR-025 — EU compliance baseline (DPA/GDPR scope)
|
||||
- `docs/guides/runbook.md` — first-time template setup (Postgres, dev
|
||||
servers, sandcastle auth)
|
||||
- `docs/guides/adding-a-library.md` — adding a runtime dep
|
||||
- `docs/guides/ci-security.md` — security stack reference
|
||||
167
docs/guides/pre-launch-compliance-checklist.md
Normal file
167
docs/guides/pre-launch-compliance-checklist.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Pre-Launch Compliance Checklist
|
||||
|
||||
This checklist operationalises [ADR-025](../decisions/adr-025-eu-compliance-baseline.md)'s three-way coverage split into a checkable launch gate. Work through every section before onboarding paying customers in an EU-regulated context. For the full compliance map — ADR, guide, and template cross-references per section — see `docs/guides/compliance-overview.md` (forthcoming in story 05).
|
||||
|
||||
**Coverage labels**
|
||||
|
||||
| Label | Meaning |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| **Shipped by template** | The template provides this mechanically. Run the inline verification command to produce audit evidence on demand. |
|
||||
| **Consumer responsibility** | You own this obligation. The template ships fill-in templates or interfaces but not the values. |
|
||||
| **Infra responsibility** | Your deployment infrastructure owns this. No application-code change is sufficient. |
|
||||
| **Deferred** | Explicitly deferred in ADR-025. The documented trigger condition must be met before revisiting. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Infrastructure
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ------------------------------------------------------------------------------------------------------ | ------------------------ |
|
||||
| EU / EEA region pinning — compute, managed database, object storage, and backups must reside in EU/EEA | **Infra responsibility** |
|
||||
| TLS termination at the deploy edge — HTTPS everywhere, no plaintext HTTP fallback | **Infra responsibility** |
|
||||
| Encryption-at-rest for the PostgreSQL database and any object storage buckets | **Infra responsibility** |
|
||||
| Network boundary controls — VPN or bastion for admin access; firewall rules block unnecessary ingress | **Infra responsibility** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Data
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PII field inventory — every Payload collection's personal-data fields tagged with `custom.pii` (`category`, `purpose`, `exportable`, `restrictable`) | **Shipped by template** — `pnpm compliance:data-map` generates `compliance/data-map.yml`; `pnpm compliance:emit-all --check` validates drift against the live schema |
|
||||
| Data retention policy — every Payload collection containing PII carries a `custom.retention` schedule (`purgeSchedule` required, `activeRetention` / `coldArchive` / `postDeletion` as needed) | **Shipped by template** — `pnpm compliance:retention-policy` generates `compliance/retention-policy.yml`; `pnpm compliance:emit-all --check` validates it |
|
||||
| Subject linkage declared — DSR cascade scope defined per collection following the convention in [`docs/compliance/subject-linkage.example.md`](../compliance/subject-linkage.example.md) | **Shipped by template** — subject relationships are embedded in `compliance/data-map.yml`; `pnpm compliance:emit-all --check` |
|
||||
| Background retention purge wired — `core-shared/jobs/retention-purge.job.ts` reads `custom.retention` at boot and schedules per-collection purge cadence | **Shipped by template** — `pnpm test --filter @repo/core-shared -- --coverage` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Application
|
||||
|
||||
| Obligation | Coverage |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------- | -------- | ----------- | ------------------ |
|
||||
| Security headers present on all responses — HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, Content-Security-Policy | **Shipped by template** — scan the deployed origin with [securityheaders.com](https://securityheaders.com) or `curl -sI https://<your-domain> \| grep -i -E "strict-transport | x-frame | x-content-type | referrer | permissions | content-security"` |
|
||||
| Rate limiting declared on every auth, write, and export use case (`rateLimit` manifest field set) | **Shipped by template** — `pnpm lint` (rule: `no-undeclared-rate-limit` warns on missing declarations); `pnpm conformance` |
|
||||
| Cookie consent banner — EU-prominent Reject / Accept (equal visual weight) with granular consent categories | **Shipped by template** — `<CookieConsentBanner>` in `@repo/core-ui`; `pnpm test:stories` verifies the component story |
|
||||
| Consent gating on analytics and marketing use cases (`requiresConsent` manifest field set; `withConsent` wrapper wired at bind time) | **Shipped by template** — `pnpm lint` (rule: `no-undeclared-consent-check`); `pnpm conformance` |
|
||||
| PII scrubbing on the observability pipeline — `sendDefaultPii: false`; `PiiScrubSpanProcessor` and `PiiScrubLogRecordProcessor` wired before every exporter | **Shipped by template** — `grep -rn "sendDefaultPii: true" apps/` must return zero results (CI grep gate in ADR-017) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Secrets
|
||||
|
||||
| Obligation | Coverage |
|
||||
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| No secrets committed to the repository | **Shipped by template** — gitleaks runs in `ci.yml` (ADR-023); `pnpm fallow:audit` includes a secret-scan step |
|
||||
| Environment variables / secret-manager inventory documented; rotation cadence agreed | **Consumer responsibility** |
|
||||
| Repository secrets configured (`TURBO_TOKEN`, `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, etc.) | **Consumer responsibility** — see [operator-checklist.md](./operator-checklist.md) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sub-Processors
|
||||
|
||||
| Obligation | Coverage |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sub-processor inventory generated from library-decision traces | **Shipped by template** — `pnpm compliance:sub-processors` generates `compliance/sub-processors.yml`; `pnpm compliance:emit-all --check` validates it against traces in `docs/library-decisions/` |
|
||||
| Every SDK library marked `is-sub-processor: true` carries DPA-signed status, SCCs flag, `data-sent` declaration, and EU `region` in its library-decision trace frontmatter | **Shipped by template** — `pnpm lint` (rule: `pii-declaration-must-be-complete` catches incomplete sub-processor traces); ADR-022 |
|
||||
| Manual entries for REST sub-processors (third-party APIs without an SDK) authored in `compliance/sub-processors.manual.yml` | **Consumer responsibility** — the generator merges manual entries automatically; format is documented in [`docs/compliance/sub-processors.example.yml`](../compliance/sub-processors.example.yml) |
|
||||
| DPAs signed with all sub-processors that process personal data | **Consumer responsibility** |
|
||||
| SCCs executed for sub-processors outside the EU/EEA | **Consumer responsibility** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging
|
||||
|
||||
| Obligation | Coverage |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Audit logging wired for all data-access, mutation, consent, and DSR use cases (`audits` manifest field set; `withAudit` wrapper wired at bind time) | **Shipped by template** — `pnpm lint` (rule: `no-undeclared-audit` warns on missing declarations); `pnpm conformance` |
|
||||
| Audit trail shipped to an append-only log store (Vector / Fluent Bit pipeline configured) | **Consumer responsibility** — see [audit-and-compliance.md](./audit-and-compliance.md) for log-shipper configuration examples and hostile-actor immutability test |
|
||||
| Audit trail retention period set to ≥ 12 months (or applicable jurisdictional minimum) | **Consumer responsibility** — configure in the log-shipping sink; must survive the immutability test in [audit-and-compliance.md](./audit-and-compliance.md) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Breach
|
||||
|
||||
| Obligation | Coverage |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Breach detection surfaces active — Sentry error alerting, rate-limit exhaustion surfaced in metrics, audit-log anomaly queries enabled | **Shipped by template** — Sentry DSN configured per [operator-checklist.md](./operator-checklist.md); `pnpm conformance`; breach detection _patterns_ deferred per ADR-025 (trigger: first downstream consumer has live traffic + observability backend) |
|
||||
| Incident runbook authored with GDPR Art. 33 timeline, contact chain, and SA notification template | **Consumer responsibility** — fill in [`docs/compliance/templates/incident-runbook.template.md`](../compliance/templates/incident-runbook.template.md) and commit the filled copy to `compliance/` |
|
||||
| GDPR Art. 33 — Supervisory Authority notified within 72 hours of a qualifying personal-data breach | **Consumer responsibility** — runbook triggers; SA contact and submission URL filled in the runbook |
|
||||
| GDPR Art. 34 — High-risk data subjects notified without undue delay | **Consumer responsibility** — runbook triggers; subject-notification template filled in the runbook |
|
||||
|
||||
---
|
||||
|
||||
## 8. DSR
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Art. 15 (access) and Art. 20 (portability) — `IDataExport` interface + `/api/gdpr/export` endpoint wired | **Shipped by template** — `pnpm test --filter @repo/core-dsr -- --coverage` |
|
||||
| Art. 16 (rectification) — `IDataRectify` interface + `/api/gdpr/rectify` endpoint wired | **Shipped by template** — `pnpm test --filter @repo/core-dsr -- --coverage` |
|
||||
| Art. 17 (erasure) — `IDataDelete` interface + `/api/gdpr/delete` endpoint wired; `eraseSubject` pseudonymizes the audit trail | **Shipped by template** — `pnpm test --filter @repo/core-dsr -- --coverage` |
|
||||
| Art. 18 (restriction of processing) — `IProcessingRestriction` interface + `/api/gdpr/restrict` endpoint wired | **Shipped by template** — `pnpm test --filter @repo/core-dsr -- --coverage` |
|
||||
| Art. 21 (objection) — `IConsent.withdraw` wired; withdrawal propagates to all consent-gated use cases | **Shipped by template** — `pnpm test --filter @repo/core-consent -- --coverage` |
|
||||
| Art. 22 (automated decision-making and profiling) | **Deferred** — ADR-025: template has no ML; revisit when a downstream consumer adds automated decisions |
|
||||
| DSR intake procedure, identity-validation steps, and response-log template documented | **Consumer responsibility** — fill in [`docs/compliance/templates/dsr-procedure.template.md`](../compliance/templates/dsr-procedure.template.md) and commit the filled copy to `compliance/` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Backup
|
||||
|
||||
| Obligation | Coverage |
|
||||
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Backup schedule, storage location (EU/EEA), encryption, and access controls documented | **Consumer responsibility** — fill in [`docs/compliance/templates/backup-policy.template.md`](../compliance/templates/backup-policy.template.md) and commit to `compliance/` |
|
||||
| Restore procedure tested and results recorded; RPO / RTO targets declared | **Infra responsibility** — confirmed by executing the restore procedure documented in the filled backup policy |
|
||||
| Post-deletion data disposal method declared (secure wipe / crypto-shred) | **Consumer responsibility** — document in the filled backup policy |
|
||||
|
||||
---
|
||||
|
||||
## 10. SDLC
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dependency vulnerability scanning — Renovate auto-merges patch / minor; Socket.dev blocks supply-chain behavioural anomalies; `npm audit` runs in CI | **Shipped by template** — `pnpm audit`; Socket GitHub App installed per [operator-checklist.md](./operator-checklist.md); ADR-023 |
|
||||
| SBOM generated per release (CycloneDX format, uploaded as CI artifact) | **Shipped by template** — `cyclonedx-npm` step in `ci.yml` (ADR-023 amendment) |
|
||||
| Static analysis and secret scanning — CodeQL + gitleaks run in CI on every push | **Shipped by template** — CI workflow; `pnpm fallow:audit`; ADR-023 |
|
||||
| Library evaluation policy enforced — EU residency filter, socket-score gate, and library-decision trace required before any new runtime dependency | **Shipped by template** — `pnpm lint` (library-policy-nudge hook fires on new deps); ADR-022 |
|
||||
| Penetration test scheduled; scope, methodology, and remediation SLA agreed | **Consumer responsibility** |
|
||||
|
||||
---
|
||||
|
||||
## 11. Workforce
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Staff onboarding procedure — system access provisioning, security orientation, compliance acknowledgement, 30-day review | **Consumer responsibility** — fill in [`docs/compliance/templates/onboarding.template.md`](../compliance/templates/onboarding.template.md) and commit to `compliance/` |
|
||||
| Staff offboarding procedure — access revocation checklist, device return, data handover, 30-day post-departure review | **Consumer responsibility** — fill in [`docs/compliance/templates/offboarding.template.md`](../compliance/templates/offboarding.template.md) and commit to `compliance/` |
|
||||
| Device management policy — MDM enrollment, EDR, acceptable-use rules, lost / stolen device response | **Consumer responsibility** — fill in [`docs/compliance/templates/device-policy.template.md`](../compliance/templates/device-policy.template.md) and commit to `compliance/` |
|
||||
| Password and MFA policy — complexity rules, rotation cadence, account lockout thresholds | **Consumer responsibility** — fill in [`docs/compliance/templates/password-policy.template.md`](../compliance/templates/password-policy.template.md) and commit to `compliance/`; **MFA + lockout implementation deferred** (ADR-025: trigger is first downstream consumer establishing auth threat model) |
|
||||
| Background checks and NDAs for personnel with access to personal data | **Consumer responsibility** |
|
||||
| Quarterly access review — privilege audit against current job roles | **Consumer responsibility** |
|
||||
| Security awareness training completed and records kept | **Consumer responsibility** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Legal
|
||||
|
||||
| Obligation | Coverage |
|
||||
| ------------------------------------------------------------------------------------------------------------------ | --------------------------- |
|
||||
| Data Processing Agreement (DPA) executed with every controller / processor counterparty | **Consumer responsibility** |
|
||||
| Privacy Policy published — GDPR Art. 13 / 14 notices, lawful basis for each processing purpose declared | **Consumer responsibility** |
|
||||
| Terms of Service published | **Consumer responsibility** |
|
||||
| Standard Contractual Clauses (SCCs) executed for sub-processors and data transfers outside EU/EEA | **Consumer responsibility** |
|
||||
| Data Protection Impact Assessment (DPIA) conducted for high-risk processing (Art. 35) | **Consumer responsibility** |
|
||||
| Records of Processing Activities (RoPA) maintained and available to the Supervisory Authority on request (Art. 30) | **Consumer responsibility** |
|
||||
|
||||
---
|
||||
|
||||
## 13. Documentation
|
||||
|
||||
| Obligation | Coverage |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Compliance evidence bundle generated and committed — `compliance/data-map.yml`, `compliance/retention-policy.yml`, `compliance/sub-processors.yml` | **Shipped by template** — `pnpm compliance:emit-all --check`; format documented in [`docs/compliance/README.md`](../compliance/README.md) |
|
||||
| Filled policy artifacts committed to `compliance/` at repo root — one filled copy per template | **Consumer responsibility** — fill each template in [`docs/compliance/templates/`](../compliance/templates/) and commit the result to `compliance/` |
|
||||
| Operator checklist actions completed — GitHub Apps installed, branch protection enabled, repository secrets set | **Consumer responsibility** — see [operator-checklist.md](./operator-checklist.md) |
|
||||
| This checklist reviewed; every outstanding item resolved or explicitly accepted-as-risk before go-live | **Consumer responsibility** |
|
||||
|
||||
---
|
||||
|
||||
_Part of [Epic D — Compliance docs scaffolds](../work/epics/compliance-docs-scaffolds/_epic.md). Governed by [ADR-025](../decisions/adr-025-eu-compliance-baseline.md)._
|
||||
303
docs/guides/rate-limiting.md
Normal file
303
docs/guides/rate-limiting.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Rate limiting cookbook
|
||||
|
||||
Rate limiting is declared in the feature manifest, enforced at the use-case level via `IRateLimit`, and verified at boot time by `assertFeatureConformance`. This guide covers the manifest declaration, key-naming convention, multi-budget patterns, and backend wiring for each environment.
|
||||
|
||||
---
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
feature.manifest.ts
|
||||
└── rateLimit: [{ name, window, budget }, ...]
|
||||
│
|
||||
▼
|
||||
wireUseCase({ rateLimit: ctx.rateLimit ?? new NoopRateLimit() })
|
||||
└── withRateLimit(rateLimit, factory(deps)) ← attaches __rateLimited brand
|
||||
│
|
||||
▼
|
||||
assertFeatureConformance(container, manifest, symbols, ctx)
|
||||
└── checks __rateLimited brand when manifest.rateLimit.length > 0
|
||||
```
|
||||
|
||||
The conformance rule `no-undeclared-rate-limit` (ESLint, warn severity) verifies that every `rateLimit.consume("X", …)` call in a use-case file has a matching `{ name: "X" }` budget in the manifest, and that every declared budget is actually consumed.
|
||||
|
||||
---
|
||||
|
||||
## Manifest field declaration
|
||||
|
||||
Add `rateLimit` to the use-case entry inside `feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
export const fooManifest = defineFeature({
|
||||
name: "foo",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
submitOrder: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 10 },
|
||||
{ name: "user", window: "1h", budget: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
```
|
||||
|
||||
### `RateLimitBudget` fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `name` | `string` | Budget identifier; matches the first argument of `rateLimit.consume()` |
|
||||
| `window` | `string` | Rolling window duration. Accepted units: `ms`, `s`, `m`, `h`, `d` |
|
||||
| `budget` | `number` | Maximum requests (weight units) allowed within the window |
|
||||
|
||||
Window string examples: `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"1d"`.
|
||||
|
||||
Omitting `rateLimit` (or setting it to an empty array) means the use case is not rate-limited. `wireUseCase` still wraps it in `NoopRateLimit` so the slot type is always satisfied.
|
||||
|
||||
---
|
||||
|
||||
## Key-naming convention
|
||||
|
||||
Keys partition the budget across distinct entities. Use the pattern:
|
||||
|
||||
```
|
||||
<feature>:<scope>:<discriminator>
|
||||
```
|
||||
|
||||
| Segment | Example | Meaning |
|
||||
| ----------------- | ------------- | -------------------------------------------- |
|
||||
| `<feature>` | `signIn` | Use-case or feature slug (camelCase) |
|
||||
| `<scope>` | `ip` | Budget name — matches `rateLimit[].name` |
|
||||
| `<discriminator>` | `203.0.113.5` | Per-entity value (IP address, user ID, etc.) |
|
||||
|
||||
Canonical example from `auth/sign-in.use-case.ts`:
|
||||
|
||||
```ts
|
||||
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");
|
||||
```
|
||||
|
||||
**Do not** use bare IPs or usernames as keys — include the feature and scope prefix so buckets from different use cases never collide in shared backends.
|
||||
|
||||
---
|
||||
|
||||
## Multi-budget patterns
|
||||
|
||||
### IP + account (credential-stuffing defence)
|
||||
|
||||
Two independent budgets: one throttles by source IP, the other by target account. A single attacker cycling IPs can still be blocked by the account budget; many attackers hitting one account are caught by the per-IP budget.
|
||||
|
||||
```ts
|
||||
// Manifest
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
],
|
||||
```
|
||||
|
||||
```ts
|
||||
// Use case body — check IP first (cheaper lookup)
|
||||
const { allowed: ipOk } = await rateLimit.consume(
|
||||
"ip",
|
||||
`signIn:ip:${input.clientIp ?? ""}`,
|
||||
);
|
||||
if (!ipOk) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
|
||||
const { allowed: accountOk } = await rateLimit.consume(
|
||||
"account",
|
||||
`signIn:account:${input.username}`,
|
||||
);
|
||||
if (!accountOk) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
```
|
||||
|
||||
### Per-user action quota
|
||||
|
||||
One budget limits how many times a single authenticated user can trigger an action per day:
|
||||
|
||||
```ts
|
||||
// Manifest
|
||||
rateLimit: [
|
||||
{ name: "user", window: "1d", budget: 50 },
|
||||
],
|
||||
```
|
||||
|
||||
```ts
|
||||
// Use case body
|
||||
const { allowed } = await rateLimit.consume(
|
||||
"user",
|
||||
`exportReport:user:${input.userId}`,
|
||||
);
|
||||
if (!allowed) throw new TooManyRequestsError("Daily export limit reached");
|
||||
```
|
||||
|
||||
### Weighted consume
|
||||
|
||||
Pass a `weight` argument to consume multiple budget units in one call (e.g., bulk operations):
|
||||
|
||||
```ts
|
||||
// Costs 5 budget units instead of 1
|
||||
const { allowed } = await rateLimit.consume(
|
||||
"user",
|
||||
`sendEmails:user:${input.userId}`,
|
||||
input.recipients.length,
|
||||
);
|
||||
```
|
||||
|
||||
The weight defaults to `1` when omitted.
|
||||
|
||||
---
|
||||
|
||||
## Throwing on rate-limit exceeded
|
||||
|
||||
Throw `TooManyRequestsError` from `@repo/<feature>/entities/errors`. The tRPC error middleware maps this to HTTP 429 via the feature's `xProcedure`:
|
||||
|
||||
```ts
|
||||
import { TooManyRequestsError } from "../../entities/errors/auth";
|
||||
|
||||
if (!allowed) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
```
|
||||
|
||||
Declare `TooManyRequestsError` in the feature's error file and register it in `integrations/api/procedures.ts`:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/integrations/api/procedures.ts
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import { TooManyRequestsError } from "../../entities/errors/<feature>";
|
||||
|
||||
export const featureProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
|
||||
// …other error → tRPC code mappings
|
||||
]),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wiring a rate-limit backend
|
||||
|
||||
### Dev / test — `InMemoryRateLimit`
|
||||
|
||||
`InMemoryRateLimit` is a single-process, Map-backed implementation suitable for local development and unit tests. Buckets live only in memory — they reset on process restart.
|
||||
|
||||
```ts
|
||||
import { InMemoryRateLimit } from "@repo/core-shared/rate-limit";
|
||||
|
||||
const rateLimit = new InMemoryRateLimit([
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
]);
|
||||
```
|
||||
|
||||
Pass the same budget declarations as the manifest so dev behaviour matches production.
|
||||
|
||||
### Default (no backend) — `NoopRateLimit`
|
||||
|
||||
`NoopRateLimit` always allows every request (`allowed: true`, `remaining: Infinity`). It is the default when `ctx.rateLimit` is absent, so features boot cleanly without a rate-limit backend wired:
|
||||
|
||||
```ts
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
|
||||
const rateLimit = ctx.rateLimit ?? new NoopRateLimit();
|
||||
```
|
||||
|
||||
Use `NoopRateLimit` in unit tests that exercise the use-case logic but do not need to test throttling behaviour. Use `RecordingRateLimit` from `@repo/core-testing/rate-limit` in tests that must assert on `consume` / `reset` call counts.
|
||||
|
||||
### Production — external backend via `IRateLimit`
|
||||
|
||||
Wire a production backend by implementing `IRateLimit` and passing the instance through `ctx.rateLimit` in the app's `bindAll` aggregator:
|
||||
|
||||
```ts
|
||||
// apps/web-next/src/server/bind-production.ts (excerpt)
|
||||
import { RedisRateLimit } from "@repo/<your-adapter>/rate-limit"; // your implementation
|
||||
|
||||
const rateLimit = new RedisRateLimit(redisClient, {
|
||||
/* budget table loaded from manifest or config */
|
||||
});
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
queue,
|
||||
rateLimit, // passed to all feature binders
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
// …other features
|
||||
```
|
||||
|
||||
Every feature binder receives the same `IRateLimit` instance via `ctx.rateLimit`. Feature binders that declare `rateLimit` budgets in their manifest pass the instance to the use-case factory:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/di/bind-production.ts (excerpt)
|
||||
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(), // for brand attachment
|
||||
});
|
||||
```
|
||||
|
||||
`wireUseCase` wraps the factory output with `withRateLimit(rateLimit, fn)` and attaches the `__rateLimited` brand, which `assertFeatureConformance` checks at boot.
|
||||
|
||||
### Environment strategy summary
|
||||
|
||||
| Environment | Recommended backend | How to wire |
|
||||
| ---------------- | ----------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Unit tests | `NoopRateLimit` | Inject directly: `signInUseCase(repo, auth, noop)(…)` |
|
||||
| Rate-limit tests | `RecordingRateLimit` | Inject directly; assert `.calls` |
|
||||
| `pnpm dev` | `NoopRateLimit` | Default in `bindAllDevSeed` via `ctx.rateLimit` |
|
||||
| Staging / prod | `InMemoryRateLimit` or external Redis/Upstash adapter | Set `ctx.rateLimit` in `bindAllProduction` |
|
||||
|
||||
---
|
||||
|
||||
## Conformance gate
|
||||
|
||||
The ESLint rule `conformance/no-undeclared-rate-limit` (warn) fires when:
|
||||
|
||||
- A use-case file calls `rateLimit.consume("X", …)` but `"X"` is not in `feature.manifest.ts` → **undeclared budget**
|
||||
- A manifest entry declares `{ name: "X" }` but the use-case body never calls `rateLimit.consume("X", …)` → **unused declaration**
|
||||
|
||||
Fix by keeping the `rateLimit` array in the manifest in sync with the `rateLimit.consume()` calls in the factory body.
|
||||
|
||||
The boot-time assertion (`assertFeatureConformance`) also requires the `__rateLimited` brand when `rateLimit.length > 0` — the dev server refuses to start if the brand is missing.
|
||||
|
||||
---
|
||||
|
||||
## API surface quick-reference
|
||||
|
||||
| Export | Package path | Purpose |
|
||||
| -------------------- | ------------------------------- | -------------------------------------------------------- |
|
||||
| `IRateLimit` | `@repo/core-shared/rate-limit` | Protocol interface for all backends |
|
||||
| `RateLimitBudget` | `@repo/core-shared/rate-limit` | Manifest budget descriptor `{ name, window, budget }` |
|
||||
| `RateLimitDecision` | `@repo/core-shared/rate-limit` | Result of `consume()`: `{ allowed, remaining, resetAt }` |
|
||||
| `InMemoryRateLimit` | `@repo/core-shared/rate-limit` | Single-process Map-backed implementation |
|
||||
| `NoopRateLimit` | `@repo/core-shared/rate-limit` | Always-allow stub (dev default) |
|
||||
| `withRateLimit` | `@repo/core-shared/rate-limit` | DI wrapper; attaches `__rateLimited` brand |
|
||||
| `RecordingRateLimit` | `@repo/core-testing/rate-limit` | Test helper; records `consume` / `reset` calls |
|
||||
| `RateLimited<F>` | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping |
|
||||
298
docs/guides/realtime.md
Normal file
298
docs/guides/realtime.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Realtime
|
||||
|
||||
> **Prerequisite:** This guide assumes `@repo/core-realtime` is present. If you started from the slim template, run `pnpm turbo gen core-package realtime` first.
|
||||
|
||||
Walkthrough for adding Socket.IO realtime channels, broadcasts, and inbound handlers to a feature. For the architectural rationale, see [ADR-016](../decisions/adr-016-realtime-layer.md).
|
||||
|
||||
The three rules to keep in mind:
|
||||
|
||||
- **R0** — Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures.
|
||||
- **R1** — Channel descriptors are exported; handlers are private (never re-exported, ESLint-enforced via `no-realtime-handler-reexport`).
|
||||
- **R2** — `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Only `packages/core-realtime/src/socket-io-*.ts` and `apps/*/server.ts` are allowed.
|
||||
|
||||
Two generators do the boilerplate. Each inserts at fixed `// <gen:*>` anchor comments that are present in every feature.
|
||||
|
||||
```bash
|
||||
pnpm turbo gen realtime channel # channel descriptor
|
||||
pnpm turbo gen realtime handler # inbound realtime handler
|
||||
```
|
||||
|
||||
The bus-bridge pattern from [ADR-015](../decisions/adr-015-events-and-jobs.md) is also available: `bindRealtimeBridge(bus, broadcaster, allowlist)` in `apps/web-next/src/server/bind-production.ts` forwards allowlisted bus events onto realtime channels. The allowlist ships empty in v1 — see the spec §8 for the full hybrid pattern.
|
||||
|
||||
---
|
||||
|
||||
## 1. Declare a channel
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen realtime --args channel blog article-feed public
|
||||
```
|
||||
|
||||
The fourth argument is the channel scope. Valid values:
|
||||
|
||||
| Scope arg | What it means |
|
||||
| --------------- | -------------------------------------------------------------------------------- |
|
||||
| `public` | Any connected socket may subscribe (no auth required) |
|
||||
| `authenticated` | Socket must have a valid session (gate 1 cleared) |
|
||||
| `role:<name>` | Socket must have the named role in `roles[]` |
|
||||
| `user-scoped` | Socket must match `params.userId === socket.data.user.userId` (template channel) |
|
||||
|
||||
This scaffolds:
|
||||
|
||||
- `packages/blog/src/realtime/article-feed.channel.ts` (the descriptor — `defineRealtimeChannel` + Zod schema)
|
||||
- `packages/blog/src/realtime/article-feed.channel.test.ts`
|
||||
- A re-export at the `// <gen:realtime-channels>` anchor in `packages/blog/src/index.ts`
|
||||
|
||||
Then **fill in the schema** with the fields the channel's payload carries:
|
||||
|
||||
```ts
|
||||
// packages/blog/src/realtime/article-feed.channel.ts
|
||||
import { z } from "zod";
|
||||
import { defineRealtimeChannel } from "@repo/core-realtime";
|
||||
|
||||
export const articleFeedSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
title: z.string(),
|
||||
publishedAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ArticleFeedPayload = z.infer<typeof articleFeedSchema>;
|
||||
|
||||
export const articleFeedChannel = defineRealtimeChannel(
|
||||
"blog.article.feed",
|
||||
articleFeedSchema,
|
||||
{ scope: "public" },
|
||||
);
|
||||
```
|
||||
|
||||
The generator wires the re-export automatically — verify:
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/blog lint typecheck
|
||||
```
|
||||
|
||||
### User-scoped channels
|
||||
|
||||
For per-user delivery, use `user-scoped` scope and a template channel name:
|
||||
|
||||
```ts
|
||||
export const userNotificationsChannel = defineRealtimeChannel(
|
||||
"notifications.user.{userId}",
|
||||
notificationsSchema,
|
||||
{ scope: { userScoped: true, template: "notifications.user.{userId}" } },
|
||||
);
|
||||
```
|
||||
|
||||
Clients subscribe to `"notifications.user.user_42"`. The server matches the template, extracts `params = { userId: "user_42" }`, and `authorize` checks `params.userId === socket.data.user.userId`. Only the matching user's socket clears gate 2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Broadcast from a use case
|
||||
|
||||
Direct broadcast is the primary path — a use case adds `realtime: IRealtimeBroadcaster` to its factory signature and calls `realtime.broadcast(channel, payload)` after the success path.
|
||||
|
||||
```ts
|
||||
// packages/blog/src/application/use-cases/publish-article.use-case.ts
|
||||
import type { IRealtimeBroadcaster } from "@repo/core-realtime";
|
||||
import { articleFeedChannel } from "../../realtime/article-feed.channel";
|
||||
|
||||
export const publishArticleUseCase =
|
||||
(articles: IArticlesRepository, realtime: IRealtimeBroadcaster) =>
|
||||
async (input: PublishArticleInput): Promise<PublishArticleOutput> => {
|
||||
const article = await articles.publish(input.id);
|
||||
await realtime.broadcast(articleFeedChannel, {
|
||||
id: article.id,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
publishedAt: article.publishedAt,
|
||||
});
|
||||
return publishArticleOutputSchema.parse(article);
|
||||
};
|
||||
```
|
||||
|
||||
`realtime.broadcast` is type-safe — TypeScript infers the payload type from `articleFeedChannel.schema` and rejects a mismatched object at compile time.
|
||||
|
||||
**Update DI.** Both `bind-production.ts` and `bind-dev-seed.ts` receive `realtime` as a parameter. Thread it into the factory call:
|
||||
|
||||
```ts
|
||||
// packages/blog/src/di/bind-production.ts
|
||||
export function bindProductionBlog(
|
||||
config: SanitizedConfig,
|
||||
tracer: ITracer,
|
||||
logger: ILogger,
|
||||
bus: IEventBus,
|
||||
queue: IJobQueue,
|
||||
realtime: IRealtimeBroadcaster,
|
||||
realtimeRegistry: IRealtimeHandlerRegistry,
|
||||
): void {
|
||||
// ... existing bindings ...
|
||||
|
||||
const wrappedPublishArticle = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.publishArticle", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.publishArticle" },
|
||||
publishArticleUseCase(articlesRepo, realtime),
|
||||
),
|
||||
);
|
||||
blogContainer
|
||||
.bind<IPublishArticleUseCase>(BLOG_SYMBOLS.IPublishArticleUseCase)
|
||||
.toConstantValue(wrappedPublishArticle);
|
||||
}
|
||||
```
|
||||
|
||||
**Testing.** Inject `RecordingRealtimeBroadcaster` from `@repo/core-testing/instrumentation` and assert against its `broadcasts` array:
|
||||
|
||||
```ts
|
||||
import { RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
|
||||
|
||||
it("broadcasts the article after publish", async () => {
|
||||
const articles = new MockArticlesRepository([mockArticle]);
|
||||
const realtime = new RecordingRealtimeBroadcaster();
|
||||
|
||||
const useCase = publishArticleUseCase(articles, realtime);
|
||||
await useCase({ id: "article-1" });
|
||||
|
||||
expect(realtime.broadcasts).toHaveLength(1);
|
||||
expect(realtime.broadcasts[0]).toMatchObject({
|
||||
channel: "blog.article.feed",
|
||||
payload: { id: "article-1" },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/blog lint typecheck test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Receive client messages
|
||||
|
||||
Use an inbound handler when a connected client emits a message that the server should react to — presence pings, cursor positions, votes, ephemeral state that shouldn't go through tRPC.
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen realtime --args handler blog article-feed
|
||||
```
|
||||
|
||||
This scaffolds:
|
||||
|
||||
- `packages/blog/src/realtime/handlers/on-article-feed.handler.ts` + test
|
||||
- A symbol at `// <gen:realtime-handler-symbols>` in `packages/blog/src/di/symbols.ts`
|
||||
- A wrapped registration block at `// <gen:realtime-handlers>` in both `bind-production.ts` and `bind-dev-seed.ts`
|
||||
|
||||
The generator prints two manual steps.
|
||||
|
||||
**1. Add the imports** to the top of both bind files (the modify-block can't add imports):
|
||||
|
||||
```ts
|
||||
import { articleFeedChannel } from "../realtime/article-feed.channel";
|
||||
import { onArticleFeedHandler } from "../realtime/handlers/on-article-feed.handler";
|
||||
```
|
||||
|
||||
**2. Fill in the handler body.** The generated handler factory is `(deps) => async (input, ctx) => Promise<void>`. Inject what the handler needs and implement the body:
|
||||
|
||||
```ts
|
||||
// packages/blog/src/realtime/handlers/on-article-feed.handler.ts
|
||||
import type { ArticleFeedPayload } from "../article-feed.channel";
|
||||
import type { IPresenceService } from "../../application/services/presence.service.interface";
|
||||
import type { RealtimeContext } from "@repo/core-realtime";
|
||||
|
||||
export type IOnArticleFeedHandler = ReturnType<typeof onArticleFeedHandler>;
|
||||
|
||||
export const onArticleFeedHandler =
|
||||
(presence: IPresenceService) =>
|
||||
async (input: ArticleFeedPayload, ctx: RealtimeContext): Promise<void> => {
|
||||
if (!ctx.userId) return;
|
||||
await presence.markViewing(ctx.userId, input.id);
|
||||
};
|
||||
```
|
||||
|
||||
**3. Wire the dependency.** The generated bind-block emits `onArticleFeedHandler()` with no args. Edit it to pass the service:
|
||||
|
||||
```ts
|
||||
// packages/blog/src/di/bind-production.ts (excerpt at <gen:realtime-handlers>)
|
||||
const wrappedOnArticleFeed = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.onArticleFeed", op: "realtime-handler" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "realtime-handler", name: "blog.onArticleFeed" },
|
||||
onArticleFeedHandler(presenceService),
|
||||
),
|
||||
);
|
||||
realtimeRegistry.register({
|
||||
descriptor: articleFeedChannel,
|
||||
handler: wrappedOnArticleFeed,
|
||||
});
|
||||
```
|
||||
|
||||
The same block is generated in `bind-dev-seed.ts`. Edit both.
|
||||
|
||||
**Testing.** Unit-test the handler factory by injecting mocks directly — no server, no sockets:
|
||||
|
||||
```ts
|
||||
it("marks the user as viewing the article", async () => {
|
||||
const presence = new MockPresenceService();
|
||||
const handler = onArticleFeedHandler(presence);
|
||||
|
||||
await handler(
|
||||
{
|
||||
id: "article-1",
|
||||
slug: "hello",
|
||||
title: "Hello",
|
||||
publishedAt: "2026-05-08T00:00:00.000Z",
|
||||
},
|
||||
{ userId: "user_1", roles: [] },
|
||||
);
|
||||
|
||||
expect(presence.markViewingCalls).toHaveLength(1);
|
||||
expect(presence.markViewingCalls[0]).toMatchObject({
|
||||
userId: "user_1",
|
||||
articleId: "article-1",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Handlers MUST NOT be re-exported from the feature's public surface (enforced by `core-eslint`'s `no-realtime-handler-reexport` rule). The bind files wire handlers internally; `src/index.ts` must never re-export from `realtime/handlers/`.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/blog lint typecheck test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anchor protocol
|
||||
|
||||
Three fixed anchor comments live in every feature for realtime:
|
||||
|
||||
| File | Anchor | Used by |
|
||||
| --------------------------- | ----------------------------------- | ---------------------- |
|
||||
| `src/index.ts` | `// <gen:realtime-channels>` | `gen realtime channel` |
|
||||
| `src/di/symbols.ts` | `// <gen:realtime-handler-symbols>` | `gen realtime handler` |
|
||||
| `src/di/bind-production.ts` | `// <gen:realtime-handlers>` | `gen realtime handler` |
|
||||
| `src/di/bind-dev-seed.ts` | `// <gen:realtime-handlers>` | `gen realtime handler` |
|
||||
|
||||
The CI guard at `packages/core-eslint/anchors.test.js` asserts these stay present in every feature. Remove one and CI fails — restore it and the test goes green.
|
||||
|
||||
The six ADR-015 anchors (`<gen:events>`, `<gen:event-handler-symbols>`, `<gen:job-symbols>`, `<gen:event-handlers>` in both binders, `<gen:jobs>` in both binders) continue to exist alongside them. Each anchor is independent.
|
||||
|
||||
## Integration test reference
|
||||
|
||||
`apps/web-next/src/__tests__/realtime-ping.test.ts` exercises gates 1 + 2 over a real Socket.IO connection: build broadcaster + registry + stub authenticator → start Socket.IO server in-process → connect with a stub session cookie → subscribe → emit ping → receive pong. Gates 3 + 4 are covered in `packages/core-realtime/src/socket-io-realtime-server.test.ts` (inbound rejection of unknown channels and forbidden scope). Use this test as a template when adding cross-feature realtime flows.
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR-016](../decisions/adr-016-realtime-layer.md) — design decision record (full design including topology, auth gates, and v1 scope)
|
||||
- [ADR-015](../decisions/adr-015-events-and-jobs.md) — cross-feature events and background jobs (bus-bridge pattern)
|
||||
150
docs/guides/releasing.md
Normal file
150
docs/guides/releasing.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Releasing
|
||||
|
||||
> **Architecture:** [ADR-021](../decisions/adr-021-versioning-and-changelog.md). **Convention:** [Conventional Commits](https://www.conventionalcommits.org/) — see `CLAUDE.md` Key Conventions.
|
||||
|
||||
This template uses [release-please](https://github.com/googleapis/release-please) to derive versions + changelog entries from Conventional Commits. On every push to `main`, release-please updates a rolling release PR. Merging that PR cuts tags + GitHub releases.
|
||||
|
||||
## The six tracked packages
|
||||
|
||||
| Path | Package | Tag prefix | Initial version |
|
||||
| -------------------------- | ----------------------------------- | ---------------------- | --------------- |
|
||||
| `.` | `template-vertical` (root template) | `template-v...` | `0.1.0` |
|
||||
| `packages/auth` | `@repo/auth` | `auth-v...` | `0.1.0` |
|
||||
| `packages/blog` | `@repo/blog` | `blog-v...` | `0.1.0` |
|
||||
| `packages/media` | `@repo/media` | `media-v...` | `0.1.0` |
|
||||
| `packages/marketing-pages` | `@repo/marketing-pages` | `marketing-pages-v...` | `0.1.0` |
|
||||
| `packages/navigation` | `@repo/navigation` | `navigation-v...` | `0.1.0` |
|
||||
|
||||
Core packages, tooling packages, and apps are intentionally NOT versioned (ADR-021).
|
||||
|
||||
## How a commit lands in a release
|
||||
|
||||
1. Author the commit per Conventional Commits: `<type>(<scope>): <imperative subject>`.
|
||||
2. Push / merge to main.
|
||||
3. release-please's GH Action runs: scans commits since the last tag, groups by tracked package (using **commit-path**, not the conventional-commit scope), and updates the rolling release PR.
|
||||
4. When you (or a reviewer) merge the release PR, tags are cut + GitHub releases are created.
|
||||
|
||||
### Bump targeting
|
||||
|
||||
release-please looks at the **files changed** in each commit, not the commit's `(scope)`:
|
||||
|
||||
- A commit touching `packages/auth/**` → bumps `@repo/auth`.
|
||||
- A commit touching `docs/**`, `scripts/**`, `.github/**`, root config files → bumps the root template (`template-v...`).
|
||||
- A commit touching `packages/auth/**` + `docs/**` → bumps BOTH.
|
||||
- A commit touching ONLY `packages/core-shared/**` → bumps the root template (core packages aren't independently versioned).
|
||||
|
||||
The conventional-commit `(scope)` parenthetical is for human readability in the changelog — it doesn't drive routing.
|
||||
|
||||
### Pre-1.0 bump policy
|
||||
|
||||
While each package is `<1.0.0`:
|
||||
|
||||
| Commit | Bump |
|
||||
| --------------------------------------- | ----- |
|
||||
| `feat:` | patch |
|
||||
| `fix:` | patch |
|
||||
| `feat!:` or `BREAKING CHANGE:` footer | minor |
|
||||
| `perf:` | patch |
|
||||
| `refactor:` | patch |
|
||||
| `docs:` | patch |
|
||||
| `revert:` | patch |
|
||||
| `chore`, `ci`, `build`, `style`, `test` | none |
|
||||
|
||||
When a package crosses `1.0.0`, standard semver kicks in: `feat:` → minor, `feat!:` → major.
|
||||
|
||||
## Day-to-day commands
|
||||
|
||||
```bash
|
||||
# Inspect current versions
|
||||
cat .release-please-manifest.json
|
||||
node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)"
|
||||
git tag --list 'template-v*' --sort=-version:refname | head -5
|
||||
git tag --list 'auth-v*' --sort=-version:refname | head -5
|
||||
|
||||
# See what changed in a feature since a tag
|
||||
git log auth-v0.1.0..HEAD -- packages/auth/
|
||||
|
||||
# View a tag's changelog entry
|
||||
git show <tag>:CHANGELOG.md # root
|
||||
git show <tag>:packages/auth/CHANGELOG.md # feature
|
||||
```
|
||||
|
||||
## Common scenarios
|
||||
|
||||
### "I want to ship the open release PR right now"
|
||||
|
||||
Merge it. The Action cuts tags + creates GitHub releases for each affected package.
|
||||
|
||||
### "The release PR's grouping looks wrong"
|
||||
|
||||
It's reproducible from commits — you can't directly edit. Either:
|
||||
|
||||
- (a) Land a follow-up commit and let release-please regenerate, OR
|
||||
- (b) Close the PR, push the missing/fixing commits, release-please will reopen with the new state.
|
||||
|
||||
### "I want to bypass a bump for a specific commit"
|
||||
|
||||
Use `chore:` / `ci:` / `build:` / `style:` / `test:` — those types are configured as hidden + non-bumping. Or extend the commit body with the manual override directive (see release-please [release-as: footer](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md#release-as-vs-release-as)).
|
||||
|
||||
### "I want a manual bump to a specific version"
|
||||
|
||||
Add this trailer to your commit body:
|
||||
|
||||
```
|
||||
Release-As: 0.5.0
|
||||
```
|
||||
|
||||
release-please will honor it for the package whose path the commit touches.
|
||||
|
||||
### "I want to skip releasing entirely for one merge"
|
||||
|
||||
Add `[skip release-please]` to the commit subject. The Action will still run but produce no PR changes.
|
||||
|
||||
### "I need to mark a package as breaking-changes-allowed-pre-1.0"
|
||||
|
||||
By default the pre-1.0 policy treats `feat!:` as a minor bump (not major). That's typically what you want — pre-1.0 means the surface can change. If you genuinely want to start signalling stability earlier, edit `bump-patch-for-minor-pre-major: false` in `release-please-config.json`. Restart from a fresh major when crossing 1.0.
|
||||
|
||||
### "Where do I see the per-package CHANGELOG?"
|
||||
|
||||
- Root template: `CHANGELOG.md` at the repo root
|
||||
- Per-feature: `packages/<feature>/CHANGELOG.md`
|
||||
|
||||
Don't edit these manually — release-please regenerates them. The exception is the initial `0.1.0` baseline content, which was hand-seeded.
|
||||
|
||||
## Cutting a 1.0.0 release
|
||||
|
||||
The Conventional Commits pre-1.0 policy says `feat!:` bumps minor. To explicitly cross 1.0:
|
||||
|
||||
```
|
||||
feat!: cross 1.0 stability boundary
|
||||
|
||||
Release-As: 1.0.0
|
||||
```
|
||||
|
||||
The `Release-As:` trailer overrides the auto-derived bump. From `1.0.0` onward, `feat:` → minor and `feat!:` → major per standard semver.
|
||||
|
||||
## Verifying release-please locally
|
||||
|
||||
```bash
|
||||
# Install once
|
||||
pnpm dlx release-please --help
|
||||
|
||||
# Dry-run the manifest workflow
|
||||
pnpm dlx release-please manifest-pr \
|
||||
--token "$GITHUB_TOKEN" \
|
||||
--repo-url "$(git remote get-url origin)" \
|
||||
--config-file release-please-config.json \
|
||||
--manifest-file .release-please-manifest.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
(The CI workflow itself does this on every push to main; local dry-runs are mostly for debugging config changes.)
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [ADR-021](../decisions/adr-021-versioning-and-changelog.md) — the architecture + design rationale
|
||||
- [Conventional Commits spec](https://www.conventionalcommits.org/)
|
||||
- [release-please docs](https://github.com/googleapis/release-please)
|
||||
- `release-please-config.json` — package list + section mapping + bump policy
|
||||
- `.release-please-manifest.json` — current version per tracked package
|
||||
- `.github/workflows/release-please.yml` — CI wiring
|
||||
496
docs/guides/runbook.md
Normal file
496
docs/guides/runbook.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Developer Runbook
|
||||
|
||||
You just cloned this repo. This is the only doc you need to read end-to-end. Everything else is reference.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Why |
|
||||
| ------- | ------- | ------------------------------------- |
|
||||
| Node.js | 22+ | Runtime for all apps + scripts |
|
||||
| pnpm | 9+ | Package manager (workspace-aware) |
|
||||
| Docker | 24+ | Local Postgres + sandcastle sandboxes |
|
||||
| Git | 2.40+ | Version control + worktrees |
|
||||
|
||||
Recommended editor: VS Code or Cursor with the official TypeScript, ESLint, and Prettier extensions.
|
||||
|
||||
---
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# 1. Clone + install
|
||||
git clone <repo-url> template-vertical
|
||||
cd template-vertical
|
||||
pnpm install
|
||||
|
||||
# 2. Start Postgres (background)
|
||||
docker compose up -d
|
||||
|
||||
# 3. Copy env template and fill in secrets
|
||||
cp .env.example .env
|
||||
# Edit .env (see "Environment variables" section below for what each one does)
|
||||
|
||||
# 4. Verify the gate stack is green
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm lint
|
||||
pnpm conformance
|
||||
pnpm fallow
|
||||
pnpm turbo boundaries
|
||||
```
|
||||
|
||||
All six should exit 0. If any fails on a fresh clone, file an issue — the main branch is supposed to stay green.
|
||||
|
||||
```bash
|
||||
# 5. Start the dev servers
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This runs Next.js (3000), Payload CMS (3001), TanStack Start (3002), and Storybook (6006) in parallel. The `bindAll()` dispatcher in each app picks the dev-seed binders by default (mock repositories, no Payload connection needed beyond Postgres).
|
||||
|
||||
---
|
||||
|
||||
## Daily commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # all dev servers
|
||||
pnpm dev --filter @repo/web-next # one app
|
||||
|
||||
# Tests
|
||||
pnpm test # everything
|
||||
pnpm test --filter @repo/auth # one package
|
||||
pnpm test:e2e # Playwright e2e
|
||||
pnpm test:stories # Storybook smoke tests
|
||||
pnpm test:visual # visual regression (Playwright screenshots)
|
||||
|
||||
# Linting + type checking
|
||||
pnpm typecheck # tsc across all packages
|
||||
pnpm lint # ESLint across all packages
|
||||
pnpm format # Prettier write
|
||||
pnpm format:check # Prettier check (CI mode)
|
||||
|
||||
# Conformance gates
|
||||
pnpm conformance # cross-feature event closure
|
||||
pnpm fallow # whole-codebase: dead exports, dupes, complexity
|
||||
pnpm fallow:audit # AI-change audit (run before commits)
|
||||
|
||||
# Boundary validation
|
||||
pnpm turbo boundaries # workspace dependency graph
|
||||
|
||||
# Work system
|
||||
pnpm work status # tree of epics + stories
|
||||
pnpm work next # next ready story
|
||||
pnpm work ready # all ready stories
|
||||
pnpm work blocked # blocked stories + what they wait on
|
||||
pnpm work rebuild-state # regenerate docs/work/_system/_state.json
|
||||
pnpm work dispatch # print next dispatch plan
|
||||
pnpm work dispatch --execute # invoke sandcastle (subscription or API key — see runbook)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill what you need. NOT every variable is required for `pnpm dev` — defaults are dev-friendly.
|
||||
|
||||
### Required for `pnpm dev`
|
||||
|
||||
| Var | Example | Why |
|
||||
| ---------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5433/template` | Postgres connection (docker compose default) |
|
||||
| `PAYLOAD_SECRET` | `your-secret-here` | Payload CMS encryption key (any random 32+ char string in dev) |
|
||||
|
||||
### Optional — app URLs (defaults work in dev)
|
||||
|
||||
| Var | Default | Why |
|
||||
| --------------------- | ----------------------- | ------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_APP_URL` | `http://localhost:3000` | Public-facing web-next URL |
|
||||
| `CMS_URL` | `http://localhost:3001` | Payload CMS URL |
|
||||
| `USE_DEV_SEED` | `true` in dev | Force dev-seed binders (mock repos) instead of Payload |
|
||||
| `NODE_ENV` | inherited | `production` flips bind dispatcher to real Payload |
|
||||
|
||||
### Optional — Sentry observability (no DSN = no-op tracer/logger)
|
||||
|
||||
| Var | Why |
|
||||
| ----------------------------------------------------- | ------------------------------------------------- |
|
||||
| `WEB_NEXT_SENTRY_DSN` | Server-side OTel + Sentry for web-next |
|
||||
| `NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN` | Browser Sentry for web-next |
|
||||
| `CMS_SENTRY_DSN` | Server-side for Payload CMS |
|
||||
| `WEB_TANSTACK_SENTRY_DSN` | Server-side for TanStack Start |
|
||||
| `VITE_WEB_TANSTACK_SENTRY_DSN` | Browser-side for TanStack Start |
|
||||
| `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT_*` | Source-map upload at build time |
|
||||
| `SENTRY_TRACES_SAMPLE_RATE` | OTel trace sample rate (`0.1` recommended in dev) |
|
||||
| `SENTRY_ENVIRONMENT` | `development` / `staging` / `production` |
|
||||
|
||||
### Optional — Git commit SHA for releases
|
||||
|
||||
| Var | Why |
|
||||
| ------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `VERCEL_GIT_COMMIT_SHA` / `VITE_GIT_COMMIT_SHA` / `NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA` | Surfaces commit SHA in Sentry releases + UI footers |
|
||||
|
||||
### Optional — core-audit (only when `gen core-package audit` is scaffolded)
|
||||
|
||||
| Var | Why |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `AUDIT_PSEUDONYM_SALT` | Salt for the audit log's GDPR-erasure pseudonymisation (production only — must be a stable secret) |
|
||||
|
||||
### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`)
|
||||
|
||||
Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback.
|
||||
|
||||
| Var | Why |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers |
|
||||
| `OPENAI_API_KEY` | OpenAI/Codex alternative (fallback) |
|
||||
| `SANDCASTLE_CLAUDE_CREDS_DIR` | Override host Claude creds path (default: `~/.claude/`) |
|
||||
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator |
|
||||
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
|
||||
|
||||
---
|
||||
|
||||
## The agent-first workflow
|
||||
|
||||
This template enforces a **manifest-first, generator-driven, gate-protected** workflow.
|
||||
|
||||
### When adding a new feature
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature <name>
|
||||
```
|
||||
|
||||
This emits:
|
||||
|
||||
- `packages/<name>/src/feature.manifest.ts` — the conformance manifest (use cases, audits, publishes, consumes)
|
||||
- `packages/<name>/src/di/bind-production.ts` with `assertFeatureConformance(...)` at the tail (refuses to boot on drift)
|
||||
- Mock repository, factory, seed, entity, use-case, controller, tests — full canonical shape
|
||||
- `packages/<name>/src/index.ts` exports
|
||||
|
||||
After scaffolding, the four-step ordering for any new use case:
|
||||
|
||||
1. **Manifest entry** — declare the use case in `feature.manifest.ts`
|
||||
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, `IXUseCase` (factory body throws `not implemented`)
|
||||
3. **Tests (red)** — write the failing test
|
||||
4. **Implementation (green)** — fill the factory body
|
||||
|
||||
The five conformance gates catch drift at every step. See `docs/guides/conformance-quickref.md` for the manifest field reference.
|
||||
|
||||
### When adding cross-feature primitives
|
||||
|
||||
```bash
|
||||
pnpm turbo gen event # event contract or handler (needs gen core-package events)
|
||||
pnpm turbo gen job # background job
|
||||
pnpm turbo gen realtime # realtime channel or handler (needs gen core-package realtime)
|
||||
pnpm turbo gen core-package <x> # optional core package (events/realtime/trpc/ui/audit)
|
||||
pnpm turbo gen core-ui-component <x> # atomic-design component (needs gen core-package ui)
|
||||
```
|
||||
|
||||
**Always prefer generators over hand-rolling.** The generators emit the canonical shape; hand-rolled code drifts from generator output and breaks the CI scaffold-drift check.
|
||||
|
||||
### Tracking work
|
||||
|
||||
The repo uses `docs/work/` for epic/story/task tracking:
|
||||
|
||||
```
|
||||
docs/work/
|
||||
├── README.md
|
||||
├── _state.json # derived, regenerated by pre-commit hook
|
||||
├── prds/ # PRDs go here
|
||||
├── _templates/ # markdown templates
|
||||
└── <epic-slug>/
|
||||
├── _epic.md
|
||||
└── <story-slug>/
|
||||
└── _story.md # contains the Tasks checklist
|
||||
```
|
||||
|
||||
Use `pnpm work next` to see what's ready. Use `pnpm work dispatch` to plan the next sandcastle dispatch.
|
||||
|
||||
---
|
||||
|
||||
## The five conformance gates
|
||||
|
||||
| Gate | Latency | What it catches | Runs when |
|
||||
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | --------------------- |
|
||||
| TypeScript brands | 0s | forgotten `withSpan` / `withCapture` / `withAudit`; manifest ↔ binding-slot type mismatch | on save (IDE) |
|
||||
| ESLint (8 conformance/\* rules) | <1s | manifest ↔ code drift; missing sibling test; missing manifest; atomic-tier import direction | on save / `pnpm lint` |
|
||||
| Boot assertion | ~3s | runtime binding without required brand; manifest edited without rebinder | `pnpm dev` startup |
|
||||
| `pnpm conformance` | ~120s | orphan event consumers across features | CI |
|
||||
| `pnpm fallow` | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit | CI |
|
||||
|
||||
For the full design see `docs/architecture/agent-first-workflow-and-conformance.md`. For the daily reference see `docs/guides/conformance-quickref.md`.
|
||||
|
||||
---
|
||||
|
||||
## Using Sandcastle for agent dispatch
|
||||
|
||||
[Sandcastle](https://github.com/mattpocock/sandcastle) is the substrate that takes a markdown task description, hands it to a Claude / Codex agent running inside an isolated Docker sandbox, captures the agent's commits, and returns them so the orchestrator can route the diff to a reviewer agent. The repo's `pnpm work dispatch` wraps sandcastle for the manifest-first workflow.
|
||||
|
||||
### When to use Sandcastle
|
||||
|
||||
- **Routine, well-specified tasks** — adding a behaviour slice to an existing use case, migrating a feature to a new convention, scaffolding new packages. The task description is the contract; sandcastle automates the rest.
|
||||
- **Parallel work** — dispatch multiple independent tasks at once; each runs in its own sandbox branch.
|
||||
- **Reviewer-loop verification** — the reviewer agent reads the diff against the task spec and either approves or sends feedback for another implementer pass.
|
||||
|
||||
### When NOT to use Sandcastle
|
||||
|
||||
- **Exploratory / design work** — when the right answer isn't known, write it yourself. Sandcastle thrives when the task is "implement this", not "figure out what to do".
|
||||
- **Cross-cutting refactors** — dispatch is per-task; many tasks that touch unrelated files at once is better done in one human-driven session.
|
||||
- **First-time integrations** (e.g., adopting a new SDK) — better to walk through it manually, then capture the pattern as a generator for future sandcastle dispatches.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Docker running** — sandcastle uses Docker for the sandbox by default. `docker info` should succeed.
|
||||
2. **Sandcastle image built (one-time)** — sandcastle dispatches into a tagged Docker image; you build it once per clone:
|
||||
|
||||
```bash
|
||||
pnpm exec sandcastle docker build-image
|
||||
# Tags as: sandcastle:template-vertical (derived from root package.json name)
|
||||
```
|
||||
|
||||
If you see `Image 'sandcastle:template-vertical' not found locally. Build it first with 'sandcastle docker build-image'` on dispatch, this step was skipped.
|
||||
|
||||
To rebuild after editing `.sandcastle/Dockerfile`:
|
||||
|
||||
```bash
|
||||
pnpm exec sandcastle docker remove-image
|
||||
pnpm exec sandcastle docker build-image
|
||||
```
|
||||
|
||||
3. **Authentication — pick ONE:**
|
||||
- **Recommended: Claude Pro / Max subscription.** Run `claude login` once on the host. Sandcastle's sandbox bind-mounts your `~/.claude/` into the container so the Claude Code CLI inside the sandbox uses your subscription session. Zero per-task token spend for subscribers.
|
||||
|
||||
**macOS quirk:** Claude Code stores credentials in the macOS Keychain, NOT in `~/.claude/.credentials.json` — so the bind-mount finds nothing. If you hit `Not logged in · Please run /login` inside the sandbox, extract the keychain credentials to a file once:
|
||||
|
||||
```bash
|
||||
security find-generic-password -s "Claude Code-credentials" -a "$USER" -w \
|
||||
> ~/.claude/.credentials.json
|
||||
chmod 600 ~/.claude/.credentials.json
|
||||
```
|
||||
|
||||
Trade-off: credentials now live as a plaintext file at the path; the macOS Keychain isolation is replaced by filesystem permissions (chmod 600 + your home dir's mode). When the token expires (~30 days), re-run the same one-liner. Linux + WSL hosts write `~/.claude/.credentials.json` directly during `claude login`, so this step is macOS-only.
|
||||
|
||||
- **Alternative: API key.** Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in your environment. Falls back automatically when `~/.claude/` is absent. Use this if you don't want a plaintext credentials file on disk.
|
||||
- **Override the creds path** via `SANDCASTLE_CLAUDE_CREDS_DIR` if your Claude Code config lives somewhere non-standard.
|
||||
|
||||
4. **GitHub token** (optional) — `GITHUB_TOKEN` if you want the orchestrator to create PRs.
|
||||
5. **`.sandcastle/` config present** — already in tree:
|
||||
- `Dockerfile` — node:22 + pnpm + Claude Code CLI; reads creds from `~/.claude/` inside the container
|
||||
- `prd-eliciter.prompt.md`, `adr-eliciter.prompt.md`, `decomposer.prompt.md`, `implementer.prompt.md`, `reviewer.prompt.md` — the five role prompts
|
||||
|
||||
### The dispatch flow
|
||||
|
||||
```
|
||||
pnpm work next → identifies the next ready story (DAG-aware)
|
||||
pnpm work dispatch → prints what WOULD be dispatched (no Sandcastle call)
|
||||
pnpm work dispatch --execute
|
||||
→ invokes sandcastle.run(implementer prompt + task spec)
|
||||
→ sandcastle returns { branch, commits, stdout, ... }
|
||||
→ orchestrator computes `git diff main..<branch>`
|
||||
→ invokes sandcastle.run(reviewer prompt + diff)
|
||||
→ reviewer returns approve / reject + notes
|
||||
→ orchestrator prints suggested state mutation
|
||||
(in v1: human ticks the bullet + commits manually)
|
||||
```
|
||||
|
||||
### Worked example — dispatch a real task
|
||||
|
||||
Suppose `pnpm work next` reports:
|
||||
|
||||
```
|
||||
auth-v1 / 02-sign-up — Sign up with email and password
|
||||
status: in-progress, tasks: 3/7
|
||||
```
|
||||
|
||||
The story file `docs/work/auth-v1/02-sign-up/_story.md` has a Tasks list with the next unchecked bullet:
|
||||
|
||||
```
|
||||
- [ ] Hash password using injected IPasswordHasher before persisting
|
||||
```
|
||||
|
||||
**Step 1 — Plan**
|
||||
|
||||
```bash
|
||||
pnpm work dispatch
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
=== Dispatch plan ===
|
||||
Epic: auth-v1
|
||||
Story: 02-sign-up — Sign up with email and password
|
||||
Bullet: - [ ] Hash password using injected IPasswordHasher before persisting
|
||||
Prompt: .sandcastle/implementer.prompt.md
|
||||
|
||||
To execute this dispatch, run:
|
||||
ANTHROPIC_API_KEY=... pnpm work dispatch --execute
|
||||
```
|
||||
|
||||
This is safe to run anywhere — it never invokes Sandcastle.
|
||||
|
||||
**Step 2 — Execute**
|
||||
|
||||
```bash
|
||||
# Subscription mode (recommended):
|
||||
claude login # one-time, host
|
||||
pnpm work dispatch --execute # uses ~/.claude/
|
||||
|
||||
# API-key mode (fallback):
|
||||
ANTHROPIC_API_KEY=sk-ant-... pnpm work dispatch --execute
|
||||
```
|
||||
|
||||
The orchestrator:
|
||||
|
||||
1. Builds the task spec (story metadata + the current bullet + full story context)
|
||||
2. Calls `sandcastle.run({ promptFile: ".sandcastle/implementer.prompt.md", promptArgs: { TASK_FILE_CONTENT: spec }, ... })`
|
||||
3. Sandcastle pulls the Docker image, mounts the repo into `/workspace`, runs `claudeCode` with the implementer prompt template populated
|
||||
4. The implementer agent (inside the sandbox):
|
||||
- Reads the task spec
|
||||
- Runs `pnpm install --frozen-lockfile`
|
||||
- Locates the use case: `packages/auth/src/application/use-cases/sign-up.use-case.ts`
|
||||
- Writes a red test asserting `hasher.hash` is called before `repo.create`
|
||||
- Runs `pnpm test --filter @repo/auth` — sees the red test fail
|
||||
- Adds `IPasswordHasher` to the factory deps; calls `hasher.hash(input.password)` before `repo.create`
|
||||
- Runs `pnpm test --filter @repo/auth` — green
|
||||
- Runs `pnpm typecheck`, `pnpm lint`, `pnpm conformance`, `pnpm fallow:audit` — all five gates green
|
||||
- Commits on a sandbox branch (`task/02-sign-up-hash-password` or similar)
|
||||
5. Sandcastle returns: `{ branch: "task/02-sign-up-hash-password", commits: [{sha: "..."}], stdout: "...", ... }`
|
||||
|
||||
**Step 3 — Review**
|
||||
|
||||
The orchestrator immediately runs the reviewer:
|
||||
|
||||
1. Computes `git diff main..task/02-sign-up-hash-password`
|
||||
2. Calls `sandcastle.run({ promptFile: ".sandcastle/reviewer.prompt.md", promptArgs: { TASK_FILE_CONTENT: spec, DIFF: diff }, ... })`
|
||||
3. The reviewer agent reads the diff + task + story; verifies:
|
||||
- The AC bullet is satisfied (test was added; impl calls `hasher.hash`)
|
||||
- Nothing in the "Out of scope" section was touched (no drive-by edits)
|
||||
- All gates were run
|
||||
- The implementer ran `pnpm fallow:audit`
|
||||
- Generator-first was respected (no hand-rolled scaffolding)
|
||||
4. Returns `{ decision: "approve", ac_verified: [4], scope_violations: [], notes: "..." }`
|
||||
|
||||
**Step 4 — State mutation (v1: manual)**
|
||||
|
||||
The orchestrator prints:
|
||||
|
||||
```
|
||||
=== Suggested state mutation ===
|
||||
Edit docs/work/auth-v1/02-sign-up/_story.md — tick the bullet:
|
||||
- [x] Hash password using injected IPasswordHasher before persisting
|
||||
Then: pnpm work rebuild-state && git add -A && git commit -m "..."
|
||||
|
||||
(Automatic state mutation by the orchestrator is v2.)
|
||||
```
|
||||
|
||||
You (the human) then:
|
||||
|
||||
1. Merge the sandbox branch: `git merge --no-ff task/02-sign-up-hash-password`
|
||||
2. Tick the bullet in the story markdown
|
||||
3. The pre-commit hook auto-runs `pnpm work rebuild-state` + re-stages `_state.json`
|
||||
4. Push. CI runs the full gate stack (typecheck + test + lint + conformance + fallow + boundaries + visual regression).
|
||||
|
||||
### Troubleshooting Sandcastle
|
||||
|
||||
**`✗ --execute requires either: 1. Claude Code logged in on host ... 2. ANTHROPIC_API_KEY ...`**
|
||||
— No auth resolved. Run `claude login` to enable subscription mode (recommended), OR set `ANTHROPIC_API_KEY` (fallback). Override the host creds path via `SANDCASTLE_CLAUDE_CREDS_DIR`.
|
||||
|
||||
**`Error: Cannot find module '@ai-hero/sandcastle'`**
|
||||
— Run `pnpm install`. Sandcastle is a dev dependency at the workspace root.
|
||||
|
||||
**`Error: docker: command not found`** or sandcastle hangs at "starting sandbox"
|
||||
— Docker isn't running. `docker info` to confirm. On macOS, start Docker Desktop.
|
||||
|
||||
**The implementer agent times out**
|
||||
— Default `idleTimeoutSeconds` is 600 (10 minutes). For complex tasks, increase via `dispatch.mjs` (look for the `run({...})` call and add `idleTimeoutSeconds: 1800`).
|
||||
|
||||
**The reviewer rejects with `generator_skipped: true`**
|
||||
— The implementer hand-rolled what should have been generator output. Either re-dispatch (it gets the reviewer notes), or delete the implementer's diff and run `pnpm turbo gen <kind>` manually first, then dispatch the customisation as a separate task.
|
||||
|
||||
**The reviewer rejects with `scope_violations: [...]`**
|
||||
— The implementer touched files outside the AC. Re-dispatch with stricter scope; the rejection notes are passed back as context.
|
||||
|
||||
**Cost control** — each dispatch typically uses 50K–200K agent tokens depending on task complexity. The orchestrator does NOT cap retries; if you want to limit, set `max-attempts: 1` in the task's frontmatter (the orchestrator respects this in v2 — for now, just don't re-run dispatch after a reject).
|
||||
|
||||
**Sandbox boots but Claude Code inside it says "Not authenticated" / "API key required"**
|
||||
— The host `~/.claude/` mount didn't make it into the sandbox, OR your local Claude Code session expired. On the host, run `claude` once to confirm your session is live, then re-dispatch. If you're on Linux + SELinux, the mount may have been blocked — check the sandcastle output for SELinux warnings; set `selinuxLabel: "z"` or `false` in dispatch.mjs's docker opts if needed.
|
||||
|
||||
### Cost-aware variant: planning-only loop
|
||||
|
||||
If you want sandcastle's structure without the agent spend, use planning mode + manual execution:
|
||||
|
||||
```bash
|
||||
pnpm work dispatch # prints the plan
|
||||
# (you implement the bullet manually in your editor)
|
||||
# tick the bullet in docs/work/.../...story.md
|
||||
# commit; pre-commit auto-rebuilds _state.json
|
||||
pnpm work dispatch # prints the NEXT plan
|
||||
```
|
||||
|
||||
This gives you the same DAG-aware "what's next?" without invoking any agent. Useful for exploratory work or low-budget contexts.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`pnpm dev` refuses to boot with `ConformanceError`**
|
||||
— A feature's binding lost a required brand. The error message tells you which use case + which brand. Re-bind through `withSpan` / `withCapture` / `withAudit` as needed.
|
||||
|
||||
**`pnpm lint` errors with `conformance/feature-must-have-manifest`**
|
||||
— You created a feature with use cases but no `feature.manifest.ts`. Run `pnpm turbo gen feature <name>` to scaffold the canonical shape, or hand-write the manifest at `packages/<feature>/src/feature.manifest.ts`.
|
||||
|
||||
**`pnpm conformance` says "orphan consumer"**
|
||||
— A feature declares `consumes: ["X"]` but no feature publishes `X`. Either add the publish to the producing feature's manifest + factory, or remove the consumer.
|
||||
|
||||
**`pnpm fallow` reports new dead exports or dupes**
|
||||
— Your change added unused exports or duplicated logic. Either remove the dead code or accept with `pnpm fallow:audit --gate all` (the audit considers the baseline; only NEW findings fail).
|
||||
|
||||
**Pre-commit hook refuses to commit with "state-sync-guard"**
|
||||
— You staged `docs/work/_system/_state.json` but it's not byte-identical to `pnpm work rebuild-state` output. Run `pnpm work rebuild-state && git add docs/work/_system/_state.json` and try again.
|
||||
|
||||
**Tests fail in `@repo/turbo-generators` with Vitest worker timeouts**
|
||||
— Known flaky on slow machines. Re-run; if persistent, increase the `turbo-generators` package's vitest `testTimeout`.
|
||||
|
||||
**`pnpm work dispatch --execute` errors with "requires either: 1. Claude Code logged in..."**
|
||||
— No auth source found. Run `claude login` (subscription mode, recommended), or set `ANTHROPIC_API_KEY` (fallback). Run `pnpm work dispatch` (no flag) to just print the plan without auth.
|
||||
|
||||
---
|
||||
|
||||
## Where to read next
|
||||
|
||||
Once you've got `pnpm dev` running:
|
||||
|
||||
1. **`AGENTS.md`** — package map, boundary rules, per-package conventions
|
||||
2. **`CLAUDE.md`** — full convention reference (manifest-first ordering, factory patterns, instrumentation rules)
|
||||
3. **`docs/guides/conformance-quickref.md`** — daily manifest + gates reference
|
||||
4. **`docs/guides/tdd-workflow.md`** — red-green-refactor with the gate stack
|
||||
5. **`docs/guides/scaffolding-a-feature.md`** — `pnpm turbo gen feature` reference
|
||||
6. **`docs/guides/adding-a-feature.md`** — end-to-end walkthrough
|
||||
7. **`docs/architecture/agent-first-workflow-and-conformance.md`** — the full design
|
||||
8. **`docs/architecture/feature-conformance-explainer.html`** — interactive explainer (open in browser)
|
||||
|
||||
For deeper topics:
|
||||
|
||||
- **`docs/guides/events-and-jobs.md`** — cross-feature events (requires `gen core-package events`)
|
||||
- **`docs/guides/realtime.md`** — Socket.IO channels (requires `gen core-package realtime`)
|
||||
- **`docs/guides/audit-and-compliance.md`** — DPA-compliant audit logging (requires `gen core-package audit`)
|
||||
- **`docs/guides/frontend-work-shape.md`** — atomic design + Storybook conventions
|
||||
- **`docs/guides/infrastructure-work-shape.md`** — ADR-first flow for new infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Skipping the generator.** Always run `pnpm turbo gen <kind>` before hand-rolling. Generators emit the canonical shape; the CI scaffold-drift check will fail on hand-rolled features.
|
||||
- **Forgetting `pnpm work rebuild-state` after editing `docs/work/` markdown.** The pre-commit hook handles this automatically when you stage markdown; only matters if you push without committing.
|
||||
- **Bypassing `--no-verify` on commits.** The pre-commit hook catches drift early. If it's blocking a legitimate change, fix the underlying issue, not the hook.
|
||||
- **Hand-editing `_state.json`.** Don't. The state-sync-guard refuses commits that drift from rebuild output. Edit the markdown; let the rebuild script propagate.
|
||||
- **Committing `.env`.** It's gitignored. Use `.env.example` for new vars.
|
||||
|
||||
---
|
||||
|
||||
For deeper philosophy: this template is built around the assumption that **AI agents will author most feature work**. The conformance system is designed as an agent feedback loop. Latency-layered gates compound: 0s + <1s + 3s + 120s + 60s. The faster the inner loop, the more iterations agents can make per task.
|
||||
|
||||
If you're a human contributor, the same workflow applies — the gates aren't punitive, they're navigational aids.
|
||||
132
docs/guides/scaffolding-a-feature.md
Normal file
132
docs/guides/scaffolding-a-feature.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Scaffolding a feature
|
||||
|
||||
`turbo gen feature` produces a feature package under
|
||||
`packages/<name>/` matching the shape of the reference `navigation` feature.
|
||||
|
||||
## Invoking the generator
|
||||
|
||||
Interactive (recommended for first runs — Plop will prompt for each value):
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature
|
||||
```
|
||||
|
||||
Non-interactive (positional bypass — order matches the prompts in
|
||||
`turbo/generators/config.ts`):
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature --args <name> <Entity> <entities-plural>
|
||||
```
|
||||
|
||||
| Position | Prompt | Example | Conventions |
|
||||
| ------------------- | -------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` |
|
||||
| `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names |
|
||||
| `<entities-plural>` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug |
|
||||
|
||||
Example end-to-end:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature --args widgets Widget widgets
|
||||
pnpm install # link the new workspace package
|
||||
pnpm --filter @repo/widgets lint typecheck test
|
||||
```
|
||||
|
||||
## Conformance-ready by default
|
||||
|
||||
Since milestone v of the conformance system, `pnpm turbo gen feature <name>` emits two conformance artefacts:
|
||||
|
||||
- **`src/feature.manifest.ts`** declaring the scaffolded `getX` use case
|
||||
- **`src/di/bind-production.ts`** with `assertFeatureConformance(...)` called at the tail
|
||||
|
||||
Run `pnpm conformance` after generating a feature — it should pass cleanly. If you add `bus.publish("X")` calls in a factory body, you'll also need to add `"X"` to the manifest's `publishes[]` array for that use case, or the `no-undeclared-event-publish` ESLint rule will warn.
|
||||
|
||||
See `docs/guides/conformance-quickref.md` for the manifest field reference.
|
||||
|
||||
## What it generates
|
||||
|
||||
- Package files: `package.json`, `tsconfig.json`, `vitest.config.ts`,
|
||||
`eslint.config.js`, `turbo.json`, `AGENTS.md`
|
||||
- One entity (`src/entities/models/<entity>.ts`) with a Zod schema +
|
||||
unit test
|
||||
- One use case (`src/application/use-cases/get-<entity>.use-case.ts`) with
|
||||
exported input/output schemas, factory function, and tests
|
||||
- One controller (`src/interface-adapters/controllers/get-<entity>.controller.ts`)
|
||||
with the canonical safeParse → presenter shape
|
||||
- Mock + real repository (`src/infrastructure/repositories/<entity>.repository{,.mock}.ts`).
|
||||
Both wrap calls in `tracer.startSpan`; the real repo also calls
|
||||
`logger.captureException` on errors. The real repo body is a
|
||||
stub that returns `null` until you wire a Payload collection.
|
||||
- DI: `symbols.ts`, `module.ts`, `container.ts`, plus
|
||||
`bind-production.ts` and `bind-dev-seed.ts` that compose
|
||||
`withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at
|
||||
bind time (span + capture sandwich pattern)
|
||||
- tRPC integration: `procedures.ts` (feature-scoped error middleware) and
|
||||
`router.ts` exposing `get<Entity>` with full router tests including
|
||||
`BAD_REQUEST` / `NOT_FOUND` mapping
|
||||
- Contract suite (`__contracts__/`), dev seed (`__seeds__/dev.ts`), and
|
||||
empty stubs for `__factories__/` and `ui/`
|
||||
|
||||
## Scope (intentionally limited)
|
||||
|
||||
The generator does NOT yet emit:
|
||||
|
||||
- Payload CMS collection / global templates (`integrations/cms/**`)
|
||||
- React Query option builders (`ui/query.ts`)
|
||||
- Faker-driven `defineFactory<Entity>` factories (only stubs)
|
||||
- Multi-entity / multi-use-case (one `get<Entity>` only)
|
||||
- Aggregator wiring across the monorepo
|
||||
|
||||
Add these by hand once the entity stabilises. The generator's stub files
|
||||
clearly mark each `TODO`.
|
||||
|
||||
## Manual aggregator wiring (printed on success)
|
||||
|
||||
After running the generator, hand-edit these files to mount the new
|
||||
feature on the app's runtime composition graph:
|
||||
|
||||
1. **`apps/web-next/src/server/bind-production.ts`** — import
|
||||
`bindProduction<Name>` and `bindDevSeed<Name>` and call them from the
|
||||
`bindAll()` dispatcher (production branch + dev-seed branch).
|
||||
2. **`packages/core-api/src/root.ts`** — import `<name>Router` from
|
||||
`@repo/<name>/api` and mount it on the app router.
|
||||
3. **`packages/core-api/package.json`** — add `"@repo/<name>": "workspace:*"`
|
||||
to dependencies.
|
||||
4. **`apps/web-next/package.json`** — add `"@repo/<name>": "workspace:*"`
|
||||
to dependencies.
|
||||
5. **(Later) Payload CMS** — add a collection at
|
||||
`packages/<name>/src/integrations/cms/collections/<entities-plural>.ts`
|
||||
and register it in `packages/core-cms/...`.
|
||||
6. **Verify**: `pnpm --filter @repo/<name> lint typecheck test`
|
||||
|
||||
The same checklist is printed by the generator when it finishes.
|
||||
|
||||
## Adding events and jobs to a feature
|
||||
|
||||
Once a feature exists, augment it with cross-feature events or background jobs using the dedicated generators. See [`docs/guides/events-and-jobs.md`](./events-and-jobs.md) for full walkthroughs.
|
||||
|
||||
```bash
|
||||
pnpm turbo gen event publish # publisher contract
|
||||
pnpm turbo gen event consume # consumer handler + Payload event-task
|
||||
pnpm turbo gen job # background job + TaskConfig
|
||||
pnpm turbo gen realtime channel # realtime channel descriptor (ADR-016)
|
||||
pnpm turbo gen realtime handler # inbound realtime handler (ADR-016)
|
||||
pnpm turbo gen reader # cross-feature reader interface + implementation
|
||||
```
|
||||
|
||||
The event/job generators insert at six fixed `// <gen:*>` anchor comments. Generated features include four of them automatically (the `// <gen:job-tasks>` location is in `integrations/cms/index.ts`, which is manually authored as part of the post-scaffold wiring); pre-existing features were retrofitted in ADR-015.
|
||||
|
||||
The realtime generators insert at three additional fixed `// <gen:realtime-*>` anchor comments (`// <gen:realtime-channels>` in `src/index.ts`, `// <gen:realtime-handler-symbols>` in `src/di/symbols.ts`, `// <gen:realtime-handlers>` in both `bind-*.ts` files). Generated features include all three automatically; pre-existing features were retrofitted in ADR-016.
|
||||
|
||||
## Cross-links
|
||||
|
||||
- `CLAUDE.md` — Key Conventions (factory-style use cases, `.toDynamicValue()`,
|
||||
schemas-in-use-case, three binding modes per feature, span + capture sandwich)
|
||||
- `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror
|
||||
- `docs/architecture/vertical-feature-spec.md` — design rationale for the layout
|
||||
- `docs/decisions/adr-012-feature-conventions.md` — file naming + factory pattern
|
||||
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
|
||||
- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring
|
||||
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
|
||||
- `docs/decisions/adr-026-cross-feature-readers.md` — cross-feature synchronous readers
|
||||
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers
|
||||
39
docs/guides/scaffolding-core-package.md
Normal file
39
docs/guides/scaffolding-core-package.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Core-package generator
|
||||
|
||||
`pnpm turbo gen core-package` scaffolds an optional core package back into a slimmed template. Each name maps to a verbatim copy of the package as it shipped at the time the generator was added.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-package
|
||||
# → Which optional core package? (use arrow keys)
|
||||
# ❯ realtime
|
||||
# events
|
||||
# trpc
|
||||
# ui
|
||||
```
|
||||
|
||||
The generator emits the package files, updates consuming-app config (e.g. `apps/web-next/next.config.mjs` `transpilePackages`), patches `packages/core-eslint/base.js` to re-add any package-specific lint rules, then prints the manual app/server wiring needed to bring the package fully online.
|
||||
|
||||
## Available templates
|
||||
|
||||
| Name | Description |
|
||||
| ---------- | -------------------------------------------------------- |
|
||||
| `realtime` | Socket.IO realtime layer (ADR-016) |
|
||||
| `events` | Cross-feature event bus + Payload jobs adapter (ADR-015) |
|
||||
| `trpc` | tRPC server setup |
|
||||
| `ui` | Design-system package |
|
||||
| `audit` | DPA-compliant audit logging (ADR-018) |
|
||||
|
||||
## Verifying an existing project
|
||||
|
||||
If your project already has a core-\* package and you want to verify the generator's template hasn't drifted from the shipped source, use the byte-identical reconstruction snapshot:
|
||||
|
||||
```bash
|
||||
git stash -u
|
||||
pnpm turbo gen core-package <name>
|
||||
git diff packages/core-<name>/
|
||||
# Expect: zero diff (modulo .hbs strip + trailing-newline normalization)
|
||||
```
|
||||
|
||||
Snapshots live at `turbo/generators/__snapshots__/core-package/<name>.snapshot.json`.
|
||||
62
docs/guides/scaffolding-core-ui-component.md
Normal file
62
docs/guides/scaffolding-core-ui-component.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# core-ui component generator
|
||||
|
||||
`pnpm turbo gen core-ui-component` scaffolds an atomic-design component (atom / molecule / organism) into `packages/core-ui/` using the established 4-file pattern.
|
||||
|
||||
**Prerequisite:** `packages/core-ui/` must exist. If your project started from the slim template, scaffold core-ui first via `pnpm turbo gen core-package ui`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-ui-component
|
||||
# → Tier: (use arrow keys)
|
||||
# ❯ atom
|
||||
# molecule
|
||||
# organism
|
||||
# → Component name (PascalCase, e.g. Spinner):
|
||||
# › Spinner
|
||||
```
|
||||
|
||||
The generator emits 4 files into `packages/core-ui/src/<tier>s/<kebab-name>/`:
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `<kebab-name>.tsx` | Component implementation (forwardRef + cn + className passthrough) |
|
||||
| `<kebab-name>.stories.tsx` | Storybook stories (Meta + StoryObj + one Default story; tier-prefixed title) |
|
||||
| `<kebab-name>.test.tsx` | Vitest + Testing Library smoke tests (renders, className passthrough, ref forwarding) |
|
||||
| `index.ts` | Barrel that re-exports the component + Props type |
|
||||
|
||||
The new export is also spliced into `packages/core-ui/src/<tier>s/index.ts` immediately after the `// <gen:<tier>s>` anchor, so the component is reachable from the tier barrel (and transitively from the root `@repo/core-ui` export) without any manual wiring.
|
||||
|
||||
## Generated scaffold (example: `pnpm turbo gen core-ui-component` → atom → Spinner)
|
||||
|
||||
```
|
||||
packages/core-ui/src/atoms/spinner/
|
||||
├── spinner.tsx # forwardRef<HTMLDivElement, SpinnerProps>
|
||||
├── spinner.stories.tsx # title: "Atoms/Spinner"
|
||||
├── spinner.test.tsx # 3 smoke tests
|
||||
└── index.ts # export { Spinner, type SpinnerProps }
|
||||
```
|
||||
|
||||
And in `packages/core-ui/src/atoms/index.ts`:
|
||||
|
||||
```ts
|
||||
// <gen:atoms>
|
||||
export { Spinner, type SpinnerProps } from "./spinner/index";
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
// ...
|
||||
```
|
||||
|
||||
## Customizing the scaffold
|
||||
|
||||
The generated component is a minimal `<div>` passthrough — change the element/type and add variants/sizes to fit. The existing `button.tsx` in `src/atoms/button/` is the canonical reference for a richer component with `variant` and `size` props plus variant lookup tables.
|
||||
|
||||
## Verification
|
||||
|
||||
After scaffolding:
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/core-ui lint typecheck test
|
||||
pnpm dev --filter @repo/storybook # view the new component in Storybook
|
||||
```
|
||||
|
||||
The Storybook stories glob (`packages/core-ui/src/**/*.stories.@(ts|tsx)`) picks up the new file automatically — no Storybook config changes needed.
|
||||
378
docs/guides/security-headers.md
Normal file
378
docs/guides/security-headers.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Security headers cookbook
|
||||
|
||||
Every response from every app emits six security headers and a per-request CSP nonce. This guide walks the wiring for each framework, shows how consumer code threads the nonce into inline scripts, explains CSP allowlist customisation, and covers Sentry nonce integration and verification.
|
||||
|
||||
---
|
||||
|
||||
## The six headers
|
||||
|
||||
`buildSecurityHeaders(opts: SecurityHeadersConfig)` from `@repo/core-shared/security` always emits:
|
||||
|
||||
| Header | Value |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |
|
||||
| `Content-Security-Policy` | mode-dependent (see [CSP modes](#csp-modes) below) |
|
||||
|
||||
`x-nonce` is also forwarded as an internal request header so server components can retrieve the nonce without another round trip.
|
||||
|
||||
---
|
||||
|
||||
## CSP modes
|
||||
|
||||
### `prod` — strict-dynamic + nonce
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'strict-dynamic' 'nonce-{NONCE}';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
|
||||
font-src 'self' {ALLOWED_FONT_ORIGINS};
|
||||
connect-src 'self' {ALLOWED_CONNECT_ORIGINS};
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
```
|
||||
|
||||
`strict-dynamic` allows scripts loaded by a nonce-bearing script to run without listing each origin explicitly.
|
||||
|
||||
### `dev` — permissive for local tooling
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
|
||||
font-src 'self' {ALLOWED_FONT_ORIGINS};
|
||||
connect-src 'self' ws: localhost:* 127.0.0.1:* {ALLOWED_CONNECT_ORIGINS};
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
```
|
||||
|
||||
`unsafe-inline` / `unsafe-eval` permit Vite HMR and React DevTools. The `ws:` and `localhost:*` entries allow HMR websockets and local API calls. These never appear in `prod`.
|
||||
|
||||
Both modes are selected automatically from `NODE_ENV`. No manual config is required.
|
||||
|
||||
---
|
||||
|
||||
## Per-framework wiring
|
||||
|
||||
### Next.js (`apps/web-next`)
|
||||
|
||||
Create or update `middleware.ts` at the app root:
|
||||
|
||||
```ts
|
||||
// apps/web-next/middleware.ts
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return withSecurityHeaders(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
`withSecurityHeaders` generates a fresh nonce per request, sets all six headers on the response, and forwards the nonce in both the downstream request headers (`x-nonce`) and the response headers so server components can read it.
|
||||
|
||||
### TanStack Start (`apps/web-tanstack`)
|
||||
|
||||
Security headers are applied via a Nitro/H3 server hook in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
// apps/web-tanstack/app.config.ts
|
||||
import { defineConfig } from "@tanstack/start/config";
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
||||
|
||||
interface H3SecurityEvent {
|
||||
node: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
res: { setHeader: (name: string, value: string) => void };
|
||||
};
|
||||
}
|
||||
|
||||
function applySecurityHeaders(event: H3SecurityEvent): void {
|
||||
const { nonce, headers } = withSecurityHeaders();
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
event.node.res.setHeader(k, v);
|
||||
}
|
||||
event.node.req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
server: { hooks: { request: applySecurityHeaders } },
|
||||
});
|
||||
```
|
||||
|
||||
`withSecurityHeaders()` returns `{ nonce, headers }`. The hook applies the headers to the response and stores the nonce on the request so `getNonce(req)` can read it from any server loader.
|
||||
|
||||
### Payload CMS (`apps/cms`)
|
||||
|
||||
The CMS is server-rendered without client-side JavaScript hydration, so no nonce is needed:
|
||||
|
||||
```ts
|
||||
// apps/cms/middleware.ts
|
||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest): NextResponse {
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({ mode });
|
||||
|
||||
const response = NextResponse.next();
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nonce threading for inline scripts
|
||||
|
||||
### Reading the nonce on the server
|
||||
|
||||
**Next.js** — `getNonce()` reads the `x-nonce` header injected by `withSecurityHeaders`:
|
||||
|
||||
```ts
|
||||
import { getNonce } from "@repo/core-shared/security/next";
|
||||
|
||||
// Inside a Server Component or route handler:
|
||||
const nonce = await getNonce();
|
||||
```
|
||||
|
||||
**TanStack Start** — `getNonce(req)` reads from the H3 request:
|
||||
|
||||
```ts
|
||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||
import { getEvent } from "vinxi/http";
|
||||
|
||||
// Inside a loader:
|
||||
const nonce = getNonce(getEvent().node.req);
|
||||
```
|
||||
|
||||
### Exposing the nonce to the browser
|
||||
|
||||
Expose the nonce via a `<meta>` tag so client-side code can read it without re-fetching:
|
||||
|
||||
**Next.js root layout:**
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { getNonce } from "@repo/core-shared/security/next";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const nonce = await getNonce();
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**TanStack Start root route:**
|
||||
|
||||
```tsx
|
||||
// apps/web-tanstack/src/routes/__root.tsx
|
||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => {
|
||||
try {
|
||||
const { getEvent } = await import("vinxi/http");
|
||||
return { nonce: getNonce(getEvent().node.req) };
|
||||
} catch {
|
||||
return { nonce: "" }; // client-side navigation — nonce already in DOM
|
||||
}
|
||||
},
|
||||
component: () => {
|
||||
const { nonce } = Route.useLoaderData();
|
||||
return (
|
||||
<>
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Using the nonce in your inline scripts
|
||||
|
||||
Pass the nonce as the `nonce` attribute on any `<script>` tag you add. In prod mode, `strict-dynamic` propagates trust to scripts loaded by a nonce-bearing script, so third-party scripts loaded dynamically at runtime do not need individual nonces.
|
||||
|
||||
```tsx
|
||||
// In a Server Component (Next.js):
|
||||
const nonce = await getNonce();
|
||||
return (
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: "/* your inline script */" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Do not set `nonce` on scripts that ship as static `*.js` files — the nonce changes per request and will not match cached assets.
|
||||
|
||||
---
|
||||
|
||||
## CSP allowlist customisation
|
||||
|
||||
`buildSecurityHeaders` accepts three optional allowlists. Each entry must be a valid URL string (validated by the `URL` constructor at call time):
|
||||
|
||||
| Option | Default | Controls |
|
||||
| ----------------------- | ------- | ------------------------------------- |
|
||||
| `allowedConnectOrigins` | `[]` | Appended to `connect-src` |
|
||||
| `allowedImgOrigins` | `[]` | Appended to `img-src` (after `data:`) |
|
||||
| `allowedFontOrigins` | `[]` | Appended to `font-src` |
|
||||
|
||||
Example — connect to a remote API and load images from a CDN:
|
||||
|
||||
```ts
|
||||
buildSecurityHeaders({
|
||||
mode: "prod",
|
||||
nonce,
|
||||
allowedConnectOrigins: ["https://api.example.com"],
|
||||
allowedImgOrigins: ["https://cdn.example.com"],
|
||||
});
|
||||
```
|
||||
|
||||
Pass the same options in the framework-level middleware by calling `buildSecurityHeaders` directly instead of `withSecurityHeaders` (the latter calls `buildSecurityHeaders` with no allowlists):
|
||||
|
||||
```ts
|
||||
// apps/web-next/middleware.ts — custom allowlists
|
||||
import {
|
||||
generateNonce,
|
||||
buildSecurityHeaders,
|
||||
} from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = generateNonce();
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({
|
||||
mode,
|
||||
nonce,
|
||||
allowedConnectOrigins: ["https://api.example.com"],
|
||||
allowedImgOrigins: ["https://cdn.example.com"],
|
||||
});
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
response.headers.set("x-nonce", nonce);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sentry nonce integration
|
||||
|
||||
Sentry's browser SDK injects a small inline script during initialisation. In `prod` mode that script is blocked by the nonce-based CSP unless you pass the nonce to `initSentryClientReact` (or the equivalent init function).
|
||||
|
||||
### Reading the nonce on the client
|
||||
|
||||
After the root layout writes `<meta name="csp-nonce">`, client-side code reads it from the DOM:
|
||||
|
||||
```ts
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the nonce to Sentry
|
||||
|
||||
```ts
|
||||
// apps/web-tanstack/src/instrumentation-client.ts
|
||||
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClientReact({
|
||||
dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
|
||||
app: "web-tanstack",
|
||||
release: import.meta.env["VITE_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
```
|
||||
|
||||
The nonce is forwarded to Sentry's `BrowserTracing` integration so Sentry's injected `<script>` elements carry the same nonce as the page and are allowed by the CSP.
|
||||
|
||||
> If Sentry scripts are blocked in prod, open DevTools → Console. The error message will mention a nonce or CSP violation. Check that the `<meta name="csp-nonce">` tag is present in the HTML and that `getNonce()` returns a non-empty string before Sentry initialises.
|
||||
|
||||
---
|
||||
|
||||
## securityheaders.com verification
|
||||
|
||||
1. **Deploy to a staging or production URL** — `securityheaders.com` requires a publicly reachable HTTPS endpoint. Localhost is not supported.
|
||||
2. **Run the scan** — Enter your URL at [https://securityheaders.com](https://securityheaders.com) and click "Scan".
|
||||
3. **Expected grade** — An A or A+ grade with all six headers present and no warnings.
|
||||
4. **Common issues and fixes**:
|
||||
|
||||
| Warning | Cause | Fix |
|
||||
| ---------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `Content-Security-Policy` missing | Middleware matcher excluded the scanned path | Verify `config.matcher` in `middleware.ts` covers the target path |
|
||||
| `unsafe-inline` in prod CSP | `NODE_ENV` not set to `"production"` on the server | Confirm `NODE_ENV=production` in the deployment environment |
|
||||
| Nonce appears as literal `{NONCE}` | `buildSecurityHeaders` called without `nonce` in prod | Ensure the middleware generates a nonce and passes it to the builder |
|
||||
| `connect-src` missing an external origin | API origin not in `allowedConnectOrigins` | Add the origin URL to `allowedConnectOrigins` |
|
||||
| `Permissions-Policy` flagged | Browser support varies; not a blocking issue | No action required — the header is correct |
|
||||
|
||||
5. **Recheck after CSP changes** — each `buildSecurityHeaders` call change should be followed by a re-scan.
|
||||
|
||||
---
|
||||
|
||||
## API surface quick-reference
|
||||
|
||||
| Export | Package path | Purpose |
|
||||
| ------------------------------ | ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `buildSecurityHeaders(opts)` | `@repo/core-shared/security` | Low-level builder; returns `Record<string, string>` |
|
||||
| `generateNonce()` | `@repo/core-shared/security` | 16-byte crypto-random base64 string |
|
||||
| `withSecurityHeaders(request)` | `@repo/core-shared/security/next` | Next.js middleware helper (generates nonce + sets headers) |
|
||||
| `getNonce()` | `@repo/core-shared/security/next` | Read `x-nonce` from Next.js `headers()` |
|
||||
| `withSecurityHeaders()` | `@repo/core-shared/security/tanstack` | TanStack helper; returns `{ nonce, headers }` |
|
||||
| `getNonce(req)` | `@repo/core-shared/security/tanstack` | Read `x-nonce` from an H3 `NodeRequest` |
|
||||
| `SecurityHeadersConfig` | `@repo/core-shared/security` | Config type (`mode`, `nonce?`, `allowed*Origins[]`) |
|
||||
| `InvalidSecurityHeadersConfig` | `@repo/core-shared/security` | Thrown when an origin URL fails `URL` validation |
|
||||
749
docs/guides/tdd-workflow.md
Normal file
749
docs/guides/tdd-workflow.md
Normal file
@@ -0,0 +1,749 @@
|
||||
# TDD Workflow
|
||||
|
||||
TDD in this monorepo is not dogma — it is a feedback mechanism. Writing a test first forces you to design the interface before the implementation, surface integration issues early, and guarantee that every line of production code is covered by an intentional assertion. The cycle keeps each increment small: one failing test, the minimal code to pass it, a clean refactor. Nothing more.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Red-Green-Refactor Cycle
|
||||
|
||||
### Worked example: `getArticleBySlug`
|
||||
|
||||
**Step 1 — Write the failing test (RED)**
|
||||
|
||||
`packages/blog/src/application/repositories/articles.repository.interface.ts` defines `getArticleBySlug(slug: string): Promise<Article | undefined>`. Before implementing anything, write a controller test that constructs the dependencies directly — no container rebinding.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getArticleBySlugController } from "@/interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("getArticleBySlugController", () => {
|
||||
it("returns the article when the slug exists", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ slug: "hello-world", authorId: "u1" }),
|
||||
);
|
||||
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const controller = getArticleBySlugController(useCase);
|
||||
|
||||
const result = await controller({ slug: "hello-world" });
|
||||
expect(result?.slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("throws ArticleNotFoundError for a missing slug", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const controller = getArticleBySlugController(useCase);
|
||||
|
||||
await expect(controller({ slug: "no-such-slug" })).rejects.toBeInstanceOf(
|
||||
ArticleNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Run it — confirm RED:**
|
||||
|
||||
```
|
||||
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
|
||||
|
||||
FAIL src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
|
||||
getArticleBySlugController
|
||||
× returns the article when the slug exists
|
||||
AssertionError: expected undefined to equal "hello-world"
|
||||
```
|
||||
|
||||
**Step 2 — Write the minimal implementation (GREEN)**
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
|
||||
import type {
|
||||
IGetArticleBySlugUseCase,
|
||||
GetArticleBySlugOutput,
|
||||
} from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { InputParseError } from "../entities/errors/common";
|
||||
|
||||
function presenter(value: GetArticleBySlugOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
|
||||
return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
throw new InputParseError("Invalid get-article-by-slug input", {
|
||||
cause: parsed.error,
|
||||
});
|
||||
return presenter(await useCase(parsed.data));
|
||||
};
|
||||
}
|
||||
|
||||
export type IGetArticleBySlugController = ReturnType<
|
||||
typeof getArticleBySlugController
|
||||
>;
|
||||
```
|
||||
|
||||
**Run again — confirm GREEN:**
|
||||
|
||||
```
|
||||
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
|
||||
|
||||
PASS src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
|
||||
getArticleBySlugController
|
||||
✓ returns the article when the slug exists
|
||||
✓ throws ArticleNotFoundError for a missing slug
|
||||
```
|
||||
|
||||
**Step 3 — Refactor**
|
||||
|
||||
If the same `safeParse` + `InputParseError` throw pattern appears in multiple controllers, extract a shared `parseOrThrow` helper:
|
||||
|
||||
```typescript
|
||||
function parseOrThrow<T>(schema: z.ZodSchema<T>, raw: unknown, msg: string): T {
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) throw new InputParseError(msg, { cause: parsed.error });
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
Re-run tests — still GREEN. Commit.
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Naming
|
||||
|
||||
Every test file follows these conventions:
|
||||
|
||||
```
|
||||
describe(<SubjectUnderTest>)
|
||||
it("<does X> when <condition Y>")
|
||||
```
|
||||
|
||||
**Three examples:**
|
||||
|
||||
```typescript
|
||||
// Entity validation
|
||||
describe("articleSchema", () => {
|
||||
it("accepts a minimal valid article with default status", () => { ... });
|
||||
it("rejects empty title", () => { ... });
|
||||
it("rejects title over 255 chars", () => { ... });
|
||||
});
|
||||
|
||||
// Use case
|
||||
describe("getArticlesUseCase", () => {
|
||||
it("returns all articles with no filters", async () => { ... });
|
||||
it("filters by status when status is provided", async () => { ... });
|
||||
});
|
||||
|
||||
// Controller
|
||||
describe("getArticleBySlugController", () => {
|
||||
it("returns the article when the slug exists", async () => { ... });
|
||||
it("throws ArticleNotFoundError for a missing slug", async () => { ... });
|
||||
it("throws InputParseError on invalid input shape", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `describe` names the class or function under test — not the file.
|
||||
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
|
||||
- Conditions go after `when`: `it("returns undefined when slug is missing")`.
|
||||
- No `should` — it adds words without meaning.
|
||||
|
||||
---
|
||||
|
||||
## 3. Arrange / Act / Assert (AAA)
|
||||
|
||||
Every test body has three clearly separated sections. No logic between Act and Assert.
|
||||
|
||||
**Example 1 — use case test:**
|
||||
|
||||
```typescript
|
||||
it("filters by status when status is provided", async () => {
|
||||
// Arrange
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
||||
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
|
||||
// Act
|
||||
const result = await useCase({ status: "published" });
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.status).toBe("published");
|
||||
});
|
||||
```
|
||||
|
||||
**Example 2 — React component test:**
|
||||
|
||||
```typescript
|
||||
it("renders the article title when data loads", async () => {
|
||||
// Arrange
|
||||
const article = articleFactory.build({ title: "My Post" });
|
||||
const screen = renderWithProviders(<ArticleCard article={article} />);
|
||||
|
||||
// Act
|
||||
// (render is the act; no user interaction needed here)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText("My Post")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
Keep Arrange lean — use factories, not hand-rolled objects. Keep Assert specific — test exactly what the step under test owns, not downstream effects.
|
||||
|
||||
---
|
||||
|
||||
## 4. When to Mock — Decision Tree
|
||||
|
||||
```
|
||||
Is it a pure function (entity validation, slug generation)?
|
||||
→ No mock. Pass inputs, assert output.
|
||||
|
||||
Is it a use case test?
|
||||
→ Construct new MockXRepository() and inject directly into the factory:
|
||||
const repo = new MockXRepository();
|
||||
const useCase = xUseCase(repo);
|
||||
await useCase(input);
|
||||
→ No container unbind/rebind.
|
||||
|
||||
Is it a controller test?
|
||||
→ Construct the mock repo, build the use case, inject into the controller factory:
|
||||
const repo = new MockXRepository();
|
||||
const useCase = xUseCase(repo);
|
||||
const controller = xController(useCase);
|
||||
await controller(input);
|
||||
|
||||
Is it a repository test (Payload implementation)?
|
||||
→ vi.mock('payload') at the top of the file.
|
||||
→ Provide a stub via stubPayloadConfig from @repo/core-testing/payload.
|
||||
→ Run the contract suite to prove correctness.
|
||||
|
||||
Is it a React component that fetches data?
|
||||
→ renderWithProviders from @repo/core-testing/react with tRPC mocks.
|
||||
→ Do not mock fetch or XMLHttpRequest directly.
|
||||
|
||||
Is it a route handler / tRPC procedure?
|
||||
→ Use blogContainer / xContainer with unbindAll + load(XModule) in
|
||||
beforeEach/afterEach. Call the procedure through xRouter.createCaller({})
|
||||
— do not mock the router internals.
|
||||
```
|
||||
|
||||
The rule: mock the thing your layer depends on, never the thing under test.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Pyramid for This Monorepo
|
||||
|
||||
| Layer | Tool | Target ratio | Location pattern |
|
||||
| ---------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------- |
|
||||
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` |
|
||||
| Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` |
|
||||
| Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` |
|
||||
| Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` |
|
||||
| Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` |
|
||||
| Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` |
|
||||
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
|
||||
|
||||
Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack.
|
||||
|
||||
---
|
||||
|
||||
## 6. What NOT to Test
|
||||
|
||||
- **Plain getters/setters** — if a function only returns `this.field`, the test adds no signal.
|
||||
- **Framework code** — do not test that Next.js routes requests correctly; test the handler that Next.js calls.
|
||||
- **Third-party libraries** — do not test that Zod parses a `z.string()` correctly; test that your schema rejects your domain-specific invalid inputs.
|
||||
- **Types-only modules** — a file containing only `export type Foo = ...` cannot have runtime behavior; skip it.
|
||||
- **Generated code** — Payload-generated types in `node_modules/.payload/`, migration files; these are not your code.
|
||||
- **`console.log` calls** — test observable output, not side-channel logging.
|
||||
- **Private implementation details** — if refactoring internals breaks a test without breaking any observable behavior, the test was testing the wrong thing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Coverage Targets
|
||||
|
||||
| Scope | Statements | Branches | Functions | Lines |
|
||||
| ----------------------- | ---------- | -------- | --------- | ----- |
|
||||
| Baseline (all packages) | 80% | 75% | 80% | 80% |
|
||||
| Entities | 100% | 100% | 100% | 100% |
|
||||
| Use cases | 100% | 100% | 100% | 100% |
|
||||
| Controllers | 100% | 100% | 100% | 100% |
|
||||
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
|
||||
|
||||
**Inspect coverage locally:**
|
||||
|
||||
```bash
|
||||
pnpm test --coverage --filter @repo/blog
|
||||
```
|
||||
|
||||
HTML report lands at `packages/blog/coverage/index.html` — open it in a browser to see uncovered branches highlighted in red.
|
||||
|
||||
To run coverage across all packages:
|
||||
|
||||
```bash
|
||||
pnpm test -- --coverage
|
||||
```
|
||||
|
||||
Coverage thresholds are enforced in `packages/core-typescript/vitest.base.ts` and inherited by every package's `vitest.config.ts` via `nodeVitestConfig` / `jsdomVitestConfig`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Factory Usage
|
||||
|
||||
**When to use `factory.build()`**
|
||||
|
||||
Use `articleFactory.build()` (from `packages/blog/src/__factories__/article.factory.ts`) whenever you need a valid `Article` in a test and you do not care about specific field values. Override only what the test assertion depends on:
|
||||
|
||||
```typescript
|
||||
// Good — only override what the test cares about
|
||||
articleFactory.build({ slug: "my-slug", status: "published" })
|
||||
|
||||
// Avoid — hand-crafting the full object obscures intent
|
||||
{
|
||||
id: "abc",
|
||||
title: "Article 1",
|
||||
slug: "my-slug",
|
||||
content: null,
|
||||
status: "published",
|
||||
authorId: "user-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}
|
||||
```
|
||||
|
||||
Always call `factory.reset()` in `beforeEach` (or inline before use) to restart the sequence counter.
|
||||
|
||||
**When to hand-craft**
|
||||
|
||||
Hand-craft objects only when testing boundary values (empty string, max-length title, null content) where the exact shape matters more than the valid-object semantics a factory provides.
|
||||
|
||||
**Adding a new factory**
|
||||
|
||||
1. Create `packages/<feature>/src/__factories__/<entity>.factory.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { Comment } from "../entities/models/comment";
|
||||
|
||||
export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
|
||||
id: `comment-${sequence}`,
|
||||
articleId: "article-1",
|
||||
body: `Comment body ${sequence}`,
|
||||
authorId: "user-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}));
|
||||
```
|
||||
|
||||
2. Re-export from `packages/<feature>/src/__factories__/index.ts`.
|
||||
3. Call `commentFactory.reset()` in every `beforeEach` (or inline) that uses it.
|
||||
|
||||
The `defineFactory` function lives in `packages/core-testing/src/factory/define-factory.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Output Validation Tests
|
||||
|
||||
Every **non-void** use case must have an output-validation test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories.
|
||||
|
||||
**Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` — they have no output schema.
|
||||
|
||||
**Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:**
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
getArticlesUseCase,
|
||||
getArticlesOutputSchema,
|
||||
} from "@/application/use-cases/get-articles.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
|
||||
describe("getArticlesUseCase output validation", () => {
|
||||
it("throws when the repository returns a malformed article", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
// bypass typed createArticle by reaching into the backing array directly
|
||||
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors Article[]", () => {
|
||||
expect(getArticlesOutputSchema).toBeDefined();
|
||||
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern B — inline stub when pattern A is impractical (service layer or complex dependency):**
|
||||
|
||||
```typescript
|
||||
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
|
||||
describe("signInUseCase output validation", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
await users.createUser(userFactory.build({ username: "alice" }));
|
||||
|
||||
const auth = {
|
||||
verifyPassword: async () => true,
|
||||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "x" }),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Group output-validation tests in a separate `describe` block labelled `"<useCase> output validation"` so they are easy to grep.
|
||||
|
||||
---
|
||||
|
||||
## 10. Router Error-Mapping Tests
|
||||
|
||||
Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings.
|
||||
|
||||
The `beforeEach` / `afterEach` blocks reload the DI module so each test gets a fresh mock repository:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/integrations/api/router.test.ts
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { blogContainer } from "@/di/container";
|
||||
import { BlogModule } from "@/di/module";
|
||||
import { blogRouter } from "@/integrations/api/router";
|
||||
|
||||
describe("blogRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
try {
|
||||
await caller.articleBySlug({ slug: "missing" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
|
||||
it("translates zod parse failure → BAD_REQUEST", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
try {
|
||||
await caller.articleBySlug({} as unknown as { slug: string });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
For features where a domain error can only be triggered by an empty store (e.g. `navigation`'s `HeaderNotFoundError`), inline a `NullXRepository` and rebind the container:
|
||||
|
||||
```typescript
|
||||
it("translates HeaderNotFoundError → NOT_FOUND", async () => {
|
||||
@injectable()
|
||||
class NullHeaderRepository implements IHeaderRepository {
|
||||
async getHeader() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||
.to(NullHeaderRepository);
|
||||
|
||||
const caller = navigationRouter.createCaller({});
|
||||
try {
|
||||
await caller.header({});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Every feature needs at least one `NOT_FOUND` (or domain-error) test and one `BAD_REQUEST` (schema validation) test.
|
||||
|
||||
---
|
||||
|
||||
## 11. Presenter Shape Tests
|
||||
|
||||
When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output.
|
||||
|
||||
**Example — auth `sign-in` (non-identity presenter):**
|
||||
|
||||
The `signInUseCase` returns `{ session, cookie }`. The presenter extracts `cookie` and returns it directly. The controller test must assert the cookie's shape:
|
||||
|
||||
```typescript
|
||||
// packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts
|
||||
describe("signInController", () => {
|
||||
it("returns a cookie on successful sign-in", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
await users.createUser(
|
||||
userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const controller = signInController(useCase);
|
||||
|
||||
const result = await controller({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
// assert the VIEW shape (cookie), not the use-case output ({ session, cookie })
|
||||
expect(result.name).toBe("session");
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform.
|
||||
|
||||
**Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the _view_, not on `XOutput`.
|
||||
|
||||
Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies.
|
||||
|
||||
---
|
||||
|
||||
## 12. Contract Suite Usage
|
||||
|
||||
A contract suite asserts that every implementation of a repository interface satisfies the same behavioral contract. The suite runs once per implementation; the implementation is supplied via `buildSubject`.
|
||||
|
||||
**How to add a contract suite for a new repository**
|
||||
|
||||
1. Define the contract in `packages/<feature>/src/__contracts__/<entity>-repository.contract.ts`:
|
||||
|
||||
```typescript
|
||||
import { it, expect, beforeEach } from "vitest";
|
||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { ICommentsRepository } from "../application/repositories/comments.repository.interface";
|
||||
import { commentFactory } from "../__factories__/comment.factory";
|
||||
|
||||
export const commentsRepositoryContract =
|
||||
defineContractSuite<ICommentsRepository>(
|
||||
"ICommentsRepository",
|
||||
({ buildSubject }) => {
|
||||
let repo: ICommentsRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
commentFactory.reset();
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
it("createComment returns a comment with the correct fields", async () => {
|
||||
const seed = commentFactory.build({ body: "Hello" });
|
||||
const created = await repo.createComment(seed);
|
||||
expect(typeof created.id).toBe("string");
|
||||
expect(created.body).toBe("Hello");
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
2. Run the contract against the mock implementation:
|
||||
|
||||
```typescript
|
||||
// packages/<feature>/src/infrastructure/repositories/comments.repository.mock.test.ts
|
||||
import { describe } from "vitest";
|
||||
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
|
||||
import { MockCommentsRepository } from "./comments.repository.mock";
|
||||
|
||||
describe("MockCommentsRepository", () => {
|
||||
commentsRepositoryContract.run(async () => new MockCommentsRepository());
|
||||
});
|
||||
```
|
||||
|
||||
3. Run the contract against the Payload implementation (with `vi.mock('payload')`):
|
||||
|
||||
```typescript
|
||||
// packages/<feature>/src/infrastructure/repositories/comments.repository.test.ts
|
||||
import { describe, vi } from "vitest";
|
||||
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
|
||||
import { CommentsRepository } from "./comments.repository";
|
||||
import { stubPayloadConfig } from "@repo/core-testing/payload";
|
||||
|
||||
vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
||||
|
||||
describe("CommentsRepository", () => {
|
||||
commentsRepositoryContract.run(async () => {
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(buildStub());
|
||||
return new CommentsRepository(stubPayloadConfig);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
4. Run tests. Fix until green.
|
||||
|
||||
The `buildSubject` pattern ensures each `run()` call supplies a fresh instance — every contract `it()` starts with a clean repository.
|
||||
|
||||
**File naming convention:**
|
||||
|
||||
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
|
||||
- Mock test: `<x>.repository.mock.test.ts`
|
||||
- Real implementation: `<x>.repository.ts` (no `payload-` prefix)
|
||||
- Interface: `<x>.repository.interface.ts`
|
||||
|
||||
---
|
||||
|
||||
## 13. Running Tests
|
||||
|
||||
**Watch mode (recommended during development):**
|
||||
|
||||
```bash
|
||||
pnpm test --watch --filter @repo/blog
|
||||
```
|
||||
|
||||
Vitest re-runs only the affected files on save. Use this instead of manual re-runs.
|
||||
|
||||
**Focus a single test:**
|
||||
|
||||
```typescript
|
||||
it.only("returns undefined for a missing slug", async () => { ... });
|
||||
```
|
||||
|
||||
Run the file directly:
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
|
||||
```
|
||||
|
||||
**Debug a failing test:**
|
||||
|
||||
Add `console.log` inline, or launch with the Vitest inspector:
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/blog -- --reporter=verbose
|
||||
```
|
||||
|
||||
For node-level debugging:
|
||||
|
||||
```bash
|
||||
node --inspect-brk node_modules/.bin/vitest run src/interface-adapters/controllers/get-article-by-slug.controller.test.ts
|
||||
```
|
||||
|
||||
Then attach Chrome DevTools at `chrome://inspect`.
|
||||
|
||||
**Coverage:**
|
||||
|
||||
```bash
|
||||
pnpm test --coverage --filter @repo/blog
|
||||
# HTML report: packages/blog/coverage/index.html
|
||||
|
||||
pnpm test -- --coverage
|
||||
# HTML reports: packages/*/coverage/index.html
|
||||
```
|
||||
|
||||
**Storybook smoke tests:**
|
||||
|
||||
```bash
|
||||
pnpm test:stories
|
||||
```
|
||||
|
||||
Requires Storybook running on port 6006. Start it first: `pnpm dev --filter @repo/storybook`.
|
||||
|
||||
**Playwright e2e:**
|
||||
|
||||
```bash
|
||||
pnpm test:e2e # headless
|
||||
pnpm test:e2e -- --ui # interactive UI mode
|
||||
pnpm test:e2e -- --headed # visible browser
|
||||
```
|
||||
|
||||
E2E tests live in `apps/web-next/e2e/`. The `webServer` block in `apps/web-next/playwright.config.ts` starts the dev server automatically.
|
||||
|
||||
---
|
||||
|
||||
## Asserting spans and captures
|
||||
|
||||
Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Repositories also call `logger.captureException` inline; use cases and controllers get capture composed in via `withCapture` at DI bind time. Tests that need to assert either inject `RecordingTracer` + `RecordingLogger`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||
|
||||
describe("blog.getArticles use case", () => {
|
||||
it("emits a use-case span when invoked", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockArticlesRepository(tracer, logger);
|
||||
// Use cases are wrapped at DI bind time. For direct-injection tests,
|
||||
// mirror the binder's sandwich: withSpan(withCapture(factory(deps))).
|
||||
const wrapped = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
|
||||
getArticlesUseCase(repo),
|
||||
),
|
||||
);
|
||||
await wrapped({ limit: 10 });
|
||||
expect(tracer.findSpan("blog.getArticles")?.op).toBe("use-case");
|
||||
expect(tracer.findSpan("articles.getArticles")?.op).toBe("repository");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Capture assertions** use `RecordingLogger`. Note: `RecordingLogger.captureException` honours the `__sentryReported` flag — so when a repo's catch block captures and the outer `withCapture` sees the bubbled error, only the inner-most call records:
|
||||
|
||||
```ts
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockArticlesRepository(tracer, logger);
|
||||
// Force an infra error in your test setup, then:
|
||||
expect(logger.captures).toHaveLength(1); // exactly one — flag prevents double
|
||||
expect(logger.captures[0]).toMatchObject({
|
||||
kind: "exception",
|
||||
ctx: { tags: { feature: "blog", repo: "articles", method: "getArticles" } },
|
||||
});
|
||||
```
|
||||
|
||||
For an end-to-end example (controller → use case → repo, all wrapped, asserting no double-capture across layers), see `packages/blog/tests/r44-no-double-capture.test.ts`.
|
||||
|
||||
**Default mocks** (when you don't need assertions): construct `new MockArticlesRepository()` with no args — the constructor defaults bind `NoopTracer` + `NoopLogger`.
|
||||
|
||||
---
|
||||
|
||||
## Conformance gates (post-TDD)
|
||||
|
||||
After your tests are green and the impl is committed, four gates check that the new code stays consistent with the feature's manifest:
|
||||
|
||||
1. **TypeScript brands** — the `ProductionUseCase<I, O, M>` slot in `bind-production.ts` only accepts factories wrapped through `withSpan` + `withCapture` + (if mutating with audits) `withAudit`.
|
||||
2. **ESLint rules** — five `conformance/*` rules check manifest ↔ code drift; see `docs/guides/conformance-quickref.md`.
|
||||
3. **Boot assertion** — `assertFeatureConformance` runs at the tail of every `bindProductionX(ctx)`; `pnpm dev` refuses to start on drift.
|
||||
4. **CI drift gate** — `pnpm conformance` runs after `pnpm lint` in CI; fails on orphan event consumers across features.
|
||||
|
||||
The TDD red-green cycle covers behavioural correctness; the conformance gates cover architectural correctness.
|
||||
341
docs/guides/testing-strategy.md
Normal file
341
docs/guides/testing-strategy.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Testing Strategy
|
||||
|
||||
A layered approach: direct factory injection + colocated unit tests + Playwright e2e.
|
||||
|
||||
For the _how_ of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test _placement_ and infrastructure.
|
||||
|
||||
For the full output-validation, error-mapping, and view-shape patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md).
|
||||
|
||||
Related ADRs: [ADR-012](../decisions/adr-012-feature-conventions.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware.
|
||||
|
||||
## Test placement
|
||||
|
||||
| Level | Location | Tool | Example |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------- |
|
||||
| **Unit (colocated)** | `packages/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards |
|
||||
| **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + output-validation |
|
||||
| **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + input validation + view shape |
|
||||
| **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance |
|
||||
| **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | Domain → TRPCError mapping |
|
||||
| **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection |
|
||||
| **E2E (app)** | `apps/web-next/e2e/<name>.spec.ts` | Playwright | Full user flow across frontend + backend |
|
||||
|
||||
**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers.
|
||||
|
||||
## Per-feature DI in tests
|
||||
|
||||
### Default: direct factory injection (use-case + controller tests)
|
||||
|
||||
Use cases and controllers are factory functions. Tests construct mock repositories directly and pass them in — no container, no rebinding helpers.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("getArticlesUseCase", () => {
|
||||
it("filters by status", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "1", status: "draft" }),
|
||||
);
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "2", status: "published" }),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo); // direct injection
|
||||
const result = await useCase({ status: "published" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Controller tests follow the same pattern — construct the use case with mocks, then construct the controller with that use case:
|
||||
|
||||
```typescript
|
||||
// packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts
|
||||
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";
|
||||
|
||||
describe("signInController", () => {
|
||||
it("returns a cookie on successful sign-in", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const controller = signInController(useCase); // direct injection
|
||||
|
||||
const result = await controller({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
expect(result.name).toBe("session"); // presenter view shape
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
There is no `getTestContainer()` or `rebindRepository()` helper in use-case or controller tests. Those helpers are no longer needed.
|
||||
|
||||
### Router-level tests: container rebind
|
||||
|
||||
The tRPC router resolves controllers from the feature's DI container (a singleton). Router tests must reload the container state around each test:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/integrations/api/router.test.ts
|
||||
import { beforeEach, afterEach, describe, it, expect } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { blogContainer } from "@/di/container";
|
||||
import { BlogModule } from "@/di/module";
|
||||
import { blogRouter } from "@/integrations/api/router";
|
||||
|
||||
describe("blogRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule); // loads default mock bindings
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
await expect(caller.articleBySlug({ slug: "missing" })).rejects.toSatisfy(
|
||||
(e: unknown) => e instanceof TRPCError && e.code === "NOT_FOUND",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
When a test needs a specific repo behaviour at the router level, bind it directly:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.toConstantValue(new AlwaysEmptyRepo());
|
||||
});
|
||||
```
|
||||
|
||||
This is the **only** place container-level rebinding belongs. Use-case and controller tests never touch the container.
|
||||
|
||||
## Mocking Payload in feature tests
|
||||
|
||||
### Option 1: Direct factory injection of mock repos (default)
|
||||
|
||||
Construct a `MockXRepository` and pass it directly to the factory:
|
||||
|
||||
```typescript
|
||||
const repo = new MockArticlesRepository();
|
||||
// seed data
|
||||
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({});
|
||||
expect(result).toHaveLength(1);
|
||||
```
|
||||
|
||||
The mock repository satisfies the repository interface without touching Payload at all.
|
||||
|
||||
### Option 2: Router-level — use the feature's DI container
|
||||
|
||||
When testing the tRPC router (error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.toConstantValue(new MockArticlesRepository());
|
||||
});
|
||||
```
|
||||
|
||||
For infrastructure tests that exercise the Payload repository implementation directly, mock the `payload` module:
|
||||
|
||||
```typescript
|
||||
import { vi } from "vitest";
|
||||
vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
||||
```
|
||||
|
||||
Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern).
|
||||
|
||||
## Test obligations per layer
|
||||
|
||||
| Layer | Test type | Required by spec | Example |
|
||||
| ------------------------ | ----------------------- | ---------------- | ------------------------------------------------------------------------ |
|
||||
| Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid |
|
||||
| Use case (input) | Behavior | — | factory injection; assert result shape and filtering |
|
||||
| Use case (output) | Runtime guarantee | — | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` |
|
||||
| Controller (input) | Validation | — | `controller({} as unknown)` → `.rejects.toBeInstanceOf(InputParseError)` |
|
||||
| Controller (presenter) | View shape | when reshaping | `expect(result.name).toBe("session")` (not `result.session`) |
|
||||
| Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl |
|
||||
| Router | Domain → TRPCError | — | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` |
|
||||
| Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) |
|
||||
| E2E | Full user flow | — | Playwright |
|
||||
|
||||
**Output validation** — Every non-void use case ends with `xOutputSchema.parse(result)`. The test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates.
|
||||
|
||||
**Error mapping** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The router test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`).
|
||||
|
||||
**View shape** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output.
|
||||
|
||||
## Vitest setup per package
|
||||
|
||||
Each feature package has `vitest.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
setupFiles: [],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `@/` alias resolves to `src/` — use it in **test files only** to import from the feature: `import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"`. Source files (`src/`) must use relative imports (`../../repositories/...`), not the `@/` alias.
|
||||
|
||||
Also set `"rootDir": "."` in the package's `tsconfig.json` so TypeScript finds both `src/` and test files that sit at the package root.
|
||||
|
||||
## Playwright setup (apps)
|
||||
|
||||
Each app has `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `webServer` block auto-starts the dev server before tests. Set `reuseExistingServer: true` locally to reuse a running dev server; CI always starts fresh.
|
||||
|
||||
## Smoke spec example
|
||||
|
||||
`apps/web-next/e2e/home.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("home page renders site name + nav", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
await expect(page.locator("nav a").first()).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# All tests (unit + feature + e2e)
|
||||
pnpm test
|
||||
|
||||
# Just unit tests
|
||||
pnpm test --filter "@repo/blog" -- src/
|
||||
|
||||
# Just feature tests
|
||||
pnpm test --filter "@repo/blog" -- tests/
|
||||
|
||||
# Just e2e
|
||||
pnpm test:e2e
|
||||
|
||||
# E2E with UI
|
||||
pnpm test:e2e -- --ui
|
||||
```
|
||||
|
||||
## CI integration
|
||||
|
||||
Root `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "turbo run test",
|
||||
"test:e2e": "turbo run test:e2e"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Root `turbo.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"test": {
|
||||
"outputs": ["coverage/**"],
|
||||
"cache": false
|
||||
},
|
||||
"test:e2e": {
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key principles
|
||||
|
||||
1. **Colocated unit tests** validate single functions/classes in isolation
|
||||
2. **Feature-level tests** exercise the full feature with mocked repos (direct injection, no container)
|
||||
3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially)
|
||||
4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container)
|
||||
|
||||
## Instrumentation testing
|
||||
|
||||
**No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file.
|
||||
|
||||
**Repository contracts assert span shape.** Every `__contracts__/<x>-repository.contract.ts` includes a `span emission` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site:
|
||||
|
||||
```ts
|
||||
const tracer = new RecordingTracer();
|
||||
articlesRepositoryContract.run(() => new MockArticlesRepository(tracer), {
|
||||
tracer: () => tracer,
|
||||
});
|
||||
```
|
||||
|
||||
**Capture vs span assertions:**
|
||||
|
||||
- `RecordingTracer.spans` — every span emitted with `{ name, op, attributes, status, durationMs }`.
|
||||
- `RecordingLogger.captures` — every `captureException` / `captureMessage` call.
|
||||
- `RecordingLogger.breadcrumbs` — every breadcrumb added.
|
||||
- `RecordingLogger.users` — every `setUser` call (history).
|
||||
- `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert.
|
||||
|
||||
**Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests
|
||||
Reference in New Issue
Block a user