Compare commits
73 Commits
3eaf50151f
...
baseline
| Author | SHA1 | Date | |
|---|---|---|---|
| 0003a276a5 | |||
| e71b66908f | |||
| 9f90f0513f | |||
| d95ae74aed | |||
| 68a142fa6b | |||
| 08cf939e1f | |||
| a2be5d5488 | |||
| 7b0c2ea590 | |||
| 413ac0273c | |||
| d09b3e2cdd | |||
| 49241845b5 | |||
| 8b78563881 | |||
| e2a4657471 | |||
| 805a09bfe5 | |||
| 6f25b1699c | |||
| ce9f8becce | |||
| c551b33bfb | |||
| 5047953c9a | |||
| c60277ebe6 | |||
| 1883883911 | |||
| bab8fb634f | |||
| dec24feaa0 | |||
| 88ac2649b7 | |||
| 50c30e9c1b | |||
| b66759a1ab | |||
| bae2686832 | |||
| 21860784be | |||
| 4d6734448a | |||
| cd1c0334af | |||
| db2afde0dc | |||
| c7d1dd8055 | |||
| cd61b31e65 | |||
| 32163312e3 | |||
| 00fcc9d9a1 | |||
| dcbf782e21 | |||
| 2747feab46 | |||
| 27787193c0 | |||
| c9db7c8cd7 | |||
| bb2751eede | |||
| 3cf2572c85 | |||
| eccd8b0cc1 | |||
| 5f38a3efce | |||
| c0dbadf1c2 | |||
| a17b984675 | |||
| 1e3220aef5 | |||
| 16310c5d62 | |||
| 0234e18425 | |||
| 8476712620 | |||
| 6fd746d3bd | |||
| 9b04fae975 | |||
| bf04ad70b2 | |||
| 498f1fb57a | |||
| 90fc48db81 | |||
| 0c1df7f5d4 | |||
| 3bf0c652e7 | |||
| d8a3250c12 | |||
|
|
0a34b45bb7 | ||
|
|
0fbb880c82 | ||
|
|
5b74939a51 | ||
|
|
b97e6105d3 | ||
|
|
d4ce68d738 | ||
|
|
6a5d602b3b | ||
|
|
8bc32095c1 | ||
|
|
ee45bfe932 | ||
|
|
7ef0411ffa | ||
|
|
9cfa54d382 | ||
|
|
15d603b00c | ||
|
|
d71e30bb3a | ||
|
|
e734530ffe | ||
|
|
a28de6884c | ||
|
|
3ce71447b3 | ||
|
|
bca04f4cef | ||
|
|
29eaefe47e |
@@ -17,7 +17,8 @@
|
|||||||
"apps/**/instrumentation.ts",
|
"apps/**/instrumentation.ts",
|
||||||
"apps/**/instrumentation-client.ts",
|
"apps/**/instrumentation-client.ts",
|
||||||
"apps/storybook/test-runner.config.ts",
|
"apps/storybook/test-runner.config.ts",
|
||||||
"scripts/**/*.mjs"
|
"scripts/**/*.mjs",
|
||||||
|
"turbo/generators/**/*.test.mjs"
|
||||||
],
|
],
|
||||||
"publicPackages": ["@repo/core-*"],
|
"publicPackages": ["@repo/core-*"],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
@@ -42,7 +43,9 @@
|
|||||||
"@opentelemetry/sdk-node",
|
"@opentelemetry/sdk-node",
|
||||||
"@sentry/opentelemetry",
|
"@sentry/opentelemetry",
|
||||||
"@stryker-mutator/core",
|
"@stryker-mutator/core",
|
||||||
"@stryker-mutator/vitest-runner"
|
"@stryker-mutator/vitest-runner",
|
||||||
|
"@trpc/client",
|
||||||
|
"@trpc/react-query"
|
||||||
],
|
],
|
||||||
"ignoreExportsUsedInFile": true,
|
"ignoreExportsUsedInFile": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -54,11 +57,53 @@
|
|||||||
"unused-dev-dependencies": "warn",
|
"unused-dev-dependencies": "warn",
|
||||||
"unlisted-dependencies": "warn",
|
"unlisted-dependencies": "warn",
|
||||||
"circular-dependencies": "error",
|
"circular-dependencies": "error",
|
||||||
"duplicate-code": "warn"
|
"duplicate-code": "warn",
|
||||||
|
"duplicate-exports": "off"
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"maxCyclomatic": 25,
|
"maxCyclomatic": 25,
|
||||||
"maxCognitive": 30,
|
"maxCognitive": 30,
|
||||||
"maxCrap": 400
|
"maxCrap": 400
|
||||||
|
},
|
||||||
|
"usedClassMembers": ["validateSession"],
|
||||||
|
"duplicates": {
|
||||||
|
"ignore": [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"**/*.stories.tsx",
|
||||||
|
"**/__fixtures__/**",
|
||||||
|
"**/__factories__/**",
|
||||||
|
"**/__seeds__/**",
|
||||||
|
"**/di/bind-production.ts",
|
||||||
|
"**/di/bind-dev-seed.ts",
|
||||||
|
"**/di/symbols.ts",
|
||||||
|
"**/integrations/api/**",
|
||||||
|
"**/ui/trpc.ts",
|
||||||
|
"**/feature.manifest.ts",
|
||||||
|
"**/vitest.config.ts",
|
||||||
|
"scripts/work/**",
|
||||||
|
"**/*.test.mjs",
|
||||||
|
"**/*.test.js",
|
||||||
|
"**/*.mock.ts",
|
||||||
|
"**/instrumentation/sentry/init-client*.ts",
|
||||||
|
"**/instrumentation/di/bind-*.ts",
|
||||||
|
"packages/core-eslint/rules/component-must-have-*.js",
|
||||||
|
"scripts/compliance/**",
|
||||||
|
"**/setup/no-instrumentation.ts",
|
||||||
|
"packages/core-eslint/rules/no-undeclared-*.js"
|
||||||
|
],
|
||||||
|
"minOccurrences": 2,
|
||||||
|
"minTokens": 70,
|
||||||
|
"threshold": 3.0
|
||||||
|
},
|
||||||
|
"ignoreExports": [
|
||||||
|
{
|
||||||
|
"file": "apps/cms/src/app/**/not-found.tsx",
|
||||||
|
"exports": ["generateMetadata"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/web-next/src/server/bind-production.ts",
|
||||||
|
"exports": ["__getInstrumentationForTests"]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -51,20 +51,40 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Audit package signatures
|
# pnpm has no `audit signatures` (that's an npm feature) — the old
|
||||||
run: pnpm audit signatures --audit-level=high
|
# step silently ignored "signatures" and ran a plain full audit that
|
||||||
|
# hard-fails on unfixable transitive dev-tooling advisories. Gate on
|
||||||
|
# CRITICAL production-path advisories; Renovate + the weekly trace
|
||||||
|
# revalidation own the long tail.
|
||||||
|
- name: Dependency vulnerability audit (critical, prod paths)
|
||||||
|
run: pnpm audit --prod --audit-level=critical
|
||||||
|
# The real Socket CLI package is `socket` — `socket-cli` is a 0.0.1
|
||||||
|
# stub on npm; failures must be loud, not silently green.
|
||||||
- name: Socket supply-chain scan
|
- name: Socket supply-chain scan
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
if git diff --name-only origin/${{ github.base_ref }}...HEAD \
|
if git diff --name-only origin/${{ github.base_ref }}...HEAD \
|
||||||
| grep -qE '(^|/)package\.json$|(^|/)pnpm-lock\.yaml$'; then
|
| grep -qE '(^|/)package\.json$|(^|/)pnpm-lock\.yaml$'; then
|
||||||
npx --yes socket-cli@latest scan .
|
npx --yes socket@latest scan create --report . || {
|
||||||
|
echo "Socket scan failed (missing SOCKET_SECURITY_API_KEY?) — failing loudly rather than silently skipping."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
else
|
else
|
||||||
echo "No package.json or pnpm-lock.yaml changes — skipping Socket scan."
|
echo "No package.json or pnpm-lock.yaml changes — skipping Socket scan."
|
||||||
fi
|
fi
|
||||||
|
# ADR-023 escalation matrix: Renovate major bumps re-run the library
|
||||||
|
# evaluation; the gate blocks a renovate/* PR whose lockfile majors a
|
||||||
|
# traced dependency without a refreshed trace. No-op on ordinary PRs.
|
||||||
|
- name: Library-trace major-bump gate
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: node scripts/library-decisions/check.mjs --renovate-pr
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
- run: pnpm conformance
|
- run: pnpm conformance
|
||||||
|
# scripts/ is not a workspace package, so `pnpm test` (turbo) never
|
||||||
|
# reaches its test files — they get their own vitest run.
|
||||||
|
- name: Root scripts test suite
|
||||||
|
run: pnpm test:scripts
|
||||||
- name: Compliance manifest drift check
|
- name: Compliance manifest drift check
|
||||||
run: |
|
run: |
|
||||||
pnpm compliance:emit-all --check || {
|
pnpm compliance:emit-all --check || {
|
||||||
@@ -156,7 +176,5 @@ jobs:
|
|||||||
- name: Build Storybook
|
- name: Build Storybook
|
||||||
run: pnpm --filter @repo/storybook build:storybook
|
run: pnpm --filter @repo/storybook build:storybook
|
||||||
- run: pnpm test:stories
|
- run: pnpm test:stories
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: pnpm exec playwright install chromium --with-deps
|
|
||||||
- name: Visual regression
|
- name: Visual regression
|
||||||
run: pnpm test:visual
|
run: pnpm test:visual
|
||||||
|
|||||||
6
.github/workflows/mutation-nightly.yml
vendored
6
.github/workflows/mutation-nightly.yml
vendored
@@ -53,6 +53,7 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Run mutation testing
|
- name: Run mutation testing
|
||||||
|
id: mutate
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
||||||
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
||||||
@@ -70,8 +71,11 @@ jobs:
|
|||||||
name: mutation-reports
|
name: mutation-reports
|
||||||
path: packages/*/reports/mutation/
|
path: packages/*/reports/mutation/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
# `continue-on-error: true` means the JOB never reports failure — gate
|
||||||
|
# the tracking issue on the STEP outcome instead (if: failure() was
|
||||||
|
# unreachable and the issue could never open).
|
||||||
- name: Open tracking issue on >5% score drop
|
- name: Open tracking issue on >5% score drop
|
||||||
if: failure()
|
if: steps.mutate.outcome == 'failure'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules
|
|||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
|
# TypeScript incremental build state
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|||||||
25
AGENTS.md
25
AGENTS.md
@@ -308,13 +308,32 @@ Each feature package exposes exactly these subpath exports:
|
|||||||
| Subpath | What it exports | Who consumes |
|
| Subpath | What it exports | Who consumes |
|
||||||
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
|
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
|
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
|
||||||
| `./ui` | Query builders (`queryOptions`), UI components | App packages |
|
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
|
||||||
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
||||||
| `./cms` | Payload collections | `@repo/core-cms` only |
|
| `./cms` | Payload collections | `@repo/core-cms` only |
|
||||||
|
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract) | Other feature packages |
|
||||||
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
||||||
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
||||||
|
|
||||||
Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||||||
|
|
||||||
|
### Feature UI structure
|
||||||
|
|
||||||
|
Each feature's `src/ui/` follows this layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ui/
|
||||||
|
index.ts # Barrel — exports server components as public API
|
||||||
|
query.ts # Query builder functions (framework-agnostic)
|
||||||
|
hooks/
|
||||||
|
use-<entity>.ts # "use client" — wraps useTRPC + useSuspenseQuery
|
||||||
|
components/
|
||||||
|
<entity>-list.server.tsx # Server — DI + prefetch + HydrationBoundary (public)
|
||||||
|
<entity>-list.client.tsx # "use client" — calls hook (internal only)
|
||||||
|
<entity>-card.tsx # Presentational (receives props)
|
||||||
|
```
|
||||||
|
|
||||||
|
Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<ArticleList />`, `<PageContent slug="about" />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide.
|
||||||
|
|
||||||
### Payload-backed features use constructor injection
|
### Payload-backed features use constructor injection
|
||||||
|
|
||||||
@@ -398,6 +417,8 @@ Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProducti
|
|||||||
|
|
||||||
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
||||||
|
|
||||||
|
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath. See the cross-feature readers ADR for full design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Conformance contract (every feature)
|
### Conformance contract (every feature)
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -23,6 +23,7 @@ pnpm turbo gen feature # Scaffold a new feature package
|
|||||||
pnpm turbo gen event # Scaffold an event contract or handler
|
pnpm turbo gen event # Scaffold an event contract or handler
|
||||||
pnpm turbo gen job # Scaffold a background job
|
pnpm turbo gen job # Scaffold a background job
|
||||||
pnpm turbo gen realtime # Scaffold a realtime channel or handler
|
pnpm turbo gen realtime # Scaffold a realtime channel or handler
|
||||||
|
pnpm turbo gen reader # Scaffold a cross-feature reader
|
||||||
pnpm turbo gen core-package # Scaffold an optional core package
|
pnpm turbo gen core-package # Scaffold an optional core package
|
||||||
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
|
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
|
||||||
docker compose up -d # Start PostgreSQL
|
docker compose up -d # Start PostgreSQL
|
||||||
@@ -59,11 +60,12 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
|
|||||||
- `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020)
|
- `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020)
|
||||||
- `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)
|
- `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)
|
||||||
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
|
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
|
||||||
|
- `docs/guides/building-feature-ui.md` — Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiring
|
||||||
- `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
|
- `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
|
||||||
|
|
||||||
## Conformance system
|
## Conformance system
|
||||||
|
|
||||||
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, required cores, `rateLimit?: RateLimitBudget[]` (when applicable, for per-use-case rate-limit budgets), and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies:
|
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, reads (cross-feature reader deps), required cores, `rateLimit?: RateLimitBudget[]` (when applicable, for per-use-case rate-limit budgets), and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies:
|
||||||
|
|
||||||
| Layer | Latency | Catches |
|
| Layer | Latency | Catches |
|
||||||
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -73,7 +75,7 @@ Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, p
|
|||||||
| **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features |
|
| **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features |
|
||||||
| **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
|
| **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
|
||||||
|
|
||||||
The fifteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
|
The sixteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `no-undeclared-reader` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
|
||||||
|
|
||||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference.
|
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference.
|
||||||
|
|
||||||
@@ -105,7 +107,8 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
|
|||||||
- **Schemas in the use-case file** — Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer<typeof xInputSchema>` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data)
|
- **Schemas in the use-case file** — Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer<typeof xInputSchema>` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data)
|
||||||
- **Controllers receive `unknown` + presenter** — Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise<ReturnType<typeof presenter>>` (identity is fine — `return value`); void controllers return `Promise<void>` with no presenter
|
- **Controllers receive `unknown` + presenter** — Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise<ReturnType<typeof presenter>>` (identity is fine — `return value`); void controllers return `Promise<void>` with no presenter
|
||||||
- **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes
|
- **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes
|
||||||
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind `./ui` (`src/ui/index.ts`). Apps import hooks/components from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||||
|
- **Feature UI owns its data fetching** — Each feature's `src/ui/hooks/` contains `"use client"` hooks that wrap `useTRPC` + `useSuspenseQuery`. Connected components in `src/ui/components/` call these hooks. App pages prefetch via `appRouter.createCaller({})` and hydrate via `HydrationBoundary` + `dehydrate` + `setQueryData`. See `docs/guides/building-feature-ui.md`
|
||||||
- **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency
|
- **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency
|
||||||
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
|
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
|
||||||
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
|
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
|
||||||
@@ -122,9 +125,16 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
|
|||||||
- **Realtime is for state delivery, not for replacing tRPC (R0)** — Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
|
- **Realtime is for state delivery, not for replacing tRPC (R0)** — Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
|
||||||
- **Realtime channel descriptors are exported; handlers are private (R1)** — A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
|
- **Realtime channel descriptors are exported; handlers are private (R1)** — A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
|
||||||
- **`socket.io` lives in `@repo/core-realtime` only (R2)** — Feature packages MUST NOT import `socket.io` or `socket.io-client`. ESLint rule `no-direct-socket-io` enforces this; allowlist covers `core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`
|
- **`socket.io` lives in `@repo/core-realtime` only (R2)** — Feature packages MUST NOT import `socket.io` or `socket.io-client`. ESLint rule `no-direct-socket-io` enforces this; allowlist covers `core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`
|
||||||
|
- **Cross-feature domain queries go through readers (Q0)** — When a use case needs another vertical's domain-evaluated answer on the request path (e.g., permission check), use a reader (`I<Feature>Reader`). For raw data joins, use Payload `relationTo`. For reactions/side effects, use the event bus
|
||||||
|
- **Reader contracts are public; implementations are private (Q1)** — The owning feature exports `I<Feature>Reader` from `./reader` subpath (`integrations/readers/`). The implementation (`<Feature>Reader`) is internal, constructed by the binder. Consumers import the type only
|
||||||
|
- **Readers are strictly read-only; cross-feature writes go through events (Q2)** — A reader may only wrap use cases declared `mutates: false`. Enforced by `ReadOnly<F>` brand at compile time and `assertReaderPurity` at boot time
|
||||||
|
- **Reader cycles are a design error (Q3)** — If Feature A reads from Feature B and vice versa, the boundaries are wrong. Break via: (a) UI composition at app layer, (b) event for one direction, (c) merge the features
|
||||||
|
- **Readers wrap existing use cases, not repositories** — The reader is a thin facade; if the domain logic doesn't exist as a use case yet, create the use case first (manifest-first). No `MockReader` needed — same class works in dev-seed because the use cases beneath it are backed by mock repos
|
||||||
|
- **Manifest `reads` field** — Use cases that query another feature's reader declare `reads: ["<feature>"]` in `feature.manifest.ts`. Verified by `assertFeatureConformance` at boot and `no-undeclared-reader` ESLint rule
|
||||||
|
- **Binders return readers; `bindAll()` threads them** — `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. `bindAll()` passes it: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after
|
||||||
- **Manifest-first ordering** — for any new use case, the workflow is **(1) manifest entry** → **(2) contracts** (`xInputSchema`, `xOutputSchema`, `IXUseCase`) → **(3) tests (red)** → **(4) implementation (green)**. The generator emits the manifest + a self-asserting `bind-production.ts` so new features are conformance-compliant by default
|
- **Manifest-first ordering** — for any new use case, the workflow is **(1) manifest entry** → **(2) contracts** (`xInputSchema`, `xOutputSchema`, `IXUseCase`) → **(3) tests (red)** → **(4) implementation (green)**. The generator emits the manifest + a self-asserting `bind-production.ts` so new features are conformance-compliant by default
|
||||||
- **Self-asserting `bindProductionX(ctx)`** — every feature's bind-production calls `assertFeatureConformance(container, manifest, symbols, ctx)` at its tail. `pnpm dev` refuses to boot on drift
|
- **Self-asserting `bindProductionX(ctx)`** — every feature's bind-production calls `assertFeatureConformance(container, manifest, symbols, ctx)` at its tail. `pnpm dev` refuses to boot on drift
|
||||||
- **`pnpm conformance`** — cross-feature event-closure check; fails CI on orphan consumers
|
- **`pnpm conformance`** — cross-feature event-closure and reader-closure check; fails CI on orphan consumers or unresolvable `reads` entries
|
||||||
- **New runtime dependencies require a library trace** — adding a runtime dependency to a feature- or core-tier package requires a trace at `docs/library-decisions/<date>-<name>.md` produced by the `/evaluate-library` skill; see ADR-022 and `docs/guides/adding-a-library.md`
|
- **New runtime dependencies require a library trace** — adding a runtime dependency to a feature- or core-tier package requires a trace at `docs/library-decisions/<date>-<name>.md` produced by the `/evaluate-library` skill; see ADR-022 and `docs/guides/adding-a-library.md`
|
||||||
- **CI security + supply-chain enforcement** — Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 + `docs/guides/ci-security.md`
|
- **CI security + supply-chain enforcement** — Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 + `docs/guides/ci-security.md`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
import baseConfig from "@repo/core-eslint/base";
|
import baseConfig from "@repo/core-eslint/base";
|
||||||
|
|
||||||
export default baseConfig;
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ["next-env.d.ts"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/triple-slash-reference": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest, NextResponse } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export function middleware(_request: NextRequest): NextResponse {
|
// Payload's admin UI is served by this Next.js app and is always dynamically
|
||||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
// rendered, so the shared nonce-based middleware works here: it generates a
|
||||||
const secHeaders = buildSecurityHeaders({ mode });
|
// per-request nonce, threads it into the CSP, and sets the CSP on the
|
||||||
|
// forwarded request headers — which is how Next propagates the nonce onto
|
||||||
const response = NextResponse.next();
|
// the admin's scripts. Without a nonce, the prod CSP's `strict-dynamic`
|
||||||
for (const [name, value] of Object.entries(secHeaders)) {
|
// script-src would block every Payload admin script.
|
||||||
response.headers.set(name, value);
|
export function middleware(request: NextRequest): NextResponse {
|
||||||
}
|
return withSecurityHeaders(request);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"vitest": "^3.0.0"
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ describe("cms middleware", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not set a nonce header", () => {
|
it("sets a per-request nonce header on the response", () => {
|
||||||
middleware(makeRequest());
|
middleware(makeRequest());
|
||||||
|
|
||||||
expect(mock._store.has("x-nonce")).toBe(false);
|
const nonce = mock._store.get("x-nonce");
|
||||||
|
expect(nonce).toBeDefined();
|
||||||
|
expect((nonce as string).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CSP is permissive in development mode", () => {
|
it("CSP is permissive in development mode", () => {
|
||||||
@@ -70,12 +72,31 @@ describe("cms middleware", () => {
|
|||||||
expect(csp).toContain("'unsafe-inline'");
|
expect(csp).toContain("'unsafe-inline'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CSP uses strict-dynamic in production mode", () => {
|
it("production CSP uses strict-dynamic seeded with the nonce", () => {
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
|
||||||
middleware(makeRequest());
|
middleware(makeRequest());
|
||||||
|
|
||||||
const csp = mock._store.get("Content-Security-Policy");
|
const csp = mock._store.get("Content-Security-Policy");
|
||||||
|
const nonce = mock._store.get("x-nonce");
|
||||||
expect(csp).toContain("'strict-dynamic'");
|
expect(csp).toContain("'strict-dynamic'");
|
||||||
|
expect(csp).toContain(`'nonce-${nonce}'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the CSP + nonce on the request headers so Next can propagate it to Payload's scripts", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
|
||||||
|
middleware(makeRequest());
|
||||||
|
|
||||||
|
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
|
||||||
|
{ request?: { headers?: Headers } } | undefined,
|
||||||
|
];
|
||||||
|
const requestHeaders = call[0]?.request?.headers;
|
||||||
|
const requestCsp = requestHeaders?.get("Content-Security-Policy");
|
||||||
|
const nonce = requestHeaders?.get("x-nonce");
|
||||||
|
expect(requestCsp).toBeTruthy();
|
||||||
|
expect(nonce).toBeTruthy();
|
||||||
|
expect(requestCsp).toContain(`'nonce-${nonce}'`);
|
||||||
|
expect(requestCsp).toBe(mock._store.get("Content-Security-Policy"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -2,18 +2,18 @@ import type { StorybookConfig } from "@storybook/react-vite";
|
|||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
framework: "@storybook/react-vite",
|
framework: "@storybook/react-vite",
|
||||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
// ALL workspace stories: core-ui atoms/molecules AND every feature
|
||||||
|
// package's src/ui/components — a story that isn't globbed here is
|
||||||
|
// invisible in Storybook and skipped by pnpm test:stories.
|
||||||
|
stories: ["../../../packages/*/src/**/*.stories.@(ts|tsx)"],
|
||||||
addons: ["@storybook/addon-essentials"],
|
addons: ["@storybook/addon-essentials"],
|
||||||
docs: {
|
docs: {
|
||||||
autodocs: "tag",
|
autodocs: "tag",
|
||||||
},
|
},
|
||||||
async viteFinal(config) {
|
async viteFinal(config) {
|
||||||
const { mergeConfig } = await import("vite");
|
|
||||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||||
|
config.plugins = [tailwindPlugin.default(), ...(config.plugins || [])];
|
||||||
return mergeConfig(config, {
|
return config;
|
||||||
plugins: [tailwindPlugin.default()],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "./storybook.css";
|
||||||
import type { Preview } from "@storybook/react";
|
import type { Preview } from "@storybook/react";
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
|||||||
4
apps/storybook/.storybook/storybook.css
Normal file
4
apps/storybook/.storybook/storybook.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@source "../../../packages/core-ui/src";
|
||||||
|
|
||||||
|
@import "../../../packages/core-ui/src/styles/theme.css";
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
|
"@repo/core-ui": "workspace:*",
|
||||||
"@storybook/addon-essentials": "^8.6.0",
|
"@storybook/addon-essentials": "^8.6.0",
|
||||||
"@storybook/react": "^8.6.0",
|
"@storybook/react": "^8.6.0",
|
||||||
"@storybook/react-vite": "^8.6.0",
|
"@storybook/react-vite": "^8.6.0",
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "*.ts", "*.tsx"],
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
".storybook/**/*.ts",
|
||||||
|
"*.ts",
|
||||||
|
"*.tsx"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const nextConfig = {
|
|||||||
"@repo/marketing-pages",
|
"@repo/marketing-pages",
|
||||||
"@repo/media",
|
"@repo/media",
|
||||||
"@repo/navigation",
|
"@repo/navigation",
|
||||||
|
"@repo/core-trpc",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,21 +17,27 @@
|
|||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/blog": "workspace:*",
|
"@repo/blog": "workspace:*",
|
||||||
"@repo/core-api": "workspace:*",
|
"@repo/core-api": "workspace:*",
|
||||||
|
"@repo/core-audit": "workspace:*",
|
||||||
"@repo/core-cms": "workspace:*",
|
"@repo/core-cms": "workspace:*",
|
||||||
|
"@repo/core-consent": "workspace:*",
|
||||||
|
"@repo/core-dsr": "workspace:*",
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
|
"@repo/core-trpc": "workspace:^",
|
||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
"@repo/media": "workspace:*",
|
"@repo/media": "workspace:*",
|
||||||
"@repo/navigation": "workspace:*",
|
"@repo/navigation": "workspace:*",
|
||||||
"@sentry/nextjs": "^10.51.0",
|
"@sentry/nextjs": "^10.51.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@tanstack/react-query": "^5.96.2",
|
||||||
|
"@trpc/server": "^11.18.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"superjson": "^2.2.1"
|
"superjson": "^2.2.1",
|
||||||
|
"tailwindcss": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.50.0",
|
"@playwright/test": "^1.50.0",
|
||||||
@@ -44,8 +50,9 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/web-next/postcss.config.mjs
Normal file
5
apps/web-next/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,31 +1,12 @@
|
|||||||
import { appRouter } from "@repo/core-api";
|
import { PageContent } from "@repo/marketing-pages/ui";
|
||||||
import { bindAll } from "../../server/bind-production";
|
import { bindAll } from "../../server/bind-production";
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
await bindAll();
|
await bindAll();
|
||||||
const caller = appRouter.createCaller({});
|
|
||||||
const page = await caller.marketingPages.pageBySlug({ slug: "about" });
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>About</h1>
|
|
||||||
<p>This page hasn't been published yet.</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="px-6 py-8">
|
||||||
<article>
|
<PageContent slug="about" />
|
||||||
<header>
|
|
||||||
<h1>{page.hero.heading}</h1>
|
|
||||||
{page.hero.subheading ? <p>{page.hero.subheading}</p> : null}
|
|
||||||
</header>
|
|
||||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{JSON.stringify(page.layout, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</article>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal file
18
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import { appRouter } from "@repo/core-api";
|
||||||
|
import { createWebNextTrpcContext } from "../../../../server/trpc-context";
|
||||||
|
|
||||||
|
const handler = async (req: Request) => {
|
||||||
|
return fetchRequestHandler({
|
||||||
|
endpoint: "/api/trpc",
|
||||||
|
req,
|
||||||
|
router: appRouter,
|
||||||
|
// Real per-request context (A11): server-derived clientIp (B2, trust
|
||||||
|
// caveat in core-shared/trpc/context.ts), the authenticated user resolved
|
||||||
|
// from the session cookie (B7), and the consent/dsr bindings that make
|
||||||
|
// the mounted compliance routers live.
|
||||||
|
createContext: () => createWebNextTrpcContext(req),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { ArticleDetail } from "@repo/blog/ui";
|
||||||
import { appRouter } from "@repo/core-api";
|
|
||||||
import { bindAll } from "../../../server/bind-production";
|
import { bindAll } from "../../../server/bind-production";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
@@ -9,26 +8,10 @@ type PageProps = {
|
|||||||
export default async function BlogPostPage({ params }: PageProps) {
|
export default async function BlogPostPage({ params }: PageProps) {
|
||||||
await bindAll();
|
await bindAll();
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const caller = appRouter.createCaller({});
|
|
||||||
const article = await caller.blog.articleBySlug({ slug });
|
|
||||||
|
|
||||||
if (!article) notFound();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="px-6 py-8">
|
||||||
<article>
|
<ArticleDetail slug={slug} />
|
||||||
<header>
|
|
||||||
<h1>{article.title}</h1>
|
|
||||||
{article.createdAt ? (
|
|
||||||
<time dateTime={article.createdAt.toISOString()}>
|
|
||||||
{article.createdAt.toLocaleDateString()}
|
|
||||||
</time>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{JSON.stringify(article.content, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</article>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import "../styles/app.css";
|
||||||
import { getNonce } from "@repo/core-shared/security/next";
|
import { getNonce } from "@repo/core-shared/security/next";
|
||||||
|
import { bindAll } from "../server/bind-production";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -12,6 +14,7 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
await bindAll();
|
||||||
const nonce = await getNonce();
|
const nonce = await getNonce();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,49 +1,15 @@
|
|||||||
import Link from "next/link";
|
import { ArticleList } from "@repo/blog/ui";
|
||||||
import { appRouter } from "@repo/core-api";
|
|
||||||
import { bindAll } from "../server/bind-production";
|
import { bindAll } from "../server/bind-production";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
await bindAll();
|
await bindAll();
|
||||||
const caller = appRouter.createCaller({});
|
|
||||||
|
|
||||||
const [siteSettings, header, articles] = await Promise.all([
|
|
||||||
caller.marketingPages.siteSettings({}),
|
|
||||||
caller.navigation.header({}),
|
|
||||||
caller.blog.listArticles({ status: "published", limit: 20 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||||
<header>
|
<h2 className="mb-4 text-2xl font-bold text-foreground">
|
||||||
<h1>{siteSettings.siteName}</h1>
|
Latest articles
|
||||||
{siteSettings.siteDescription ? (
|
</h2>
|
||||||
<p>{siteSettings.siteDescription}</p>
|
<ArticleList />
|
||||||
) : null}
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
{header.items.map((item) => (
|
|
||||||
<li key={item.href}>
|
|
||||||
<Link href={item.href}>{item.label}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Latest articles</h2>
|
|
||||||
{articles.length === 0 ? (
|
|
||||||
<p>No published articles yet.</p>
|
|
||||||
) : (
|
|
||||||
<ul>
|
|
||||||
{articles.map((a) => (
|
|
||||||
<li key={a.id}>
|
|
||||||
<Link href={`/blog/${a.slug}`}>{a.title}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return <>{children}</>;
|
return <NextTrpcProvider>{children}</NextTrpcProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/web-next/src/css.d.ts
vendored
Normal file
1
apps/web-next/src/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
97
apps/web-next/src/server/bind-production.rate-limit.test.ts
Normal file
97
apps/web-next/src/server/bind-production.rate-limit.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// A4/B3 regression: the PRODUCTION binder must enforce the auth manifest's
|
||||||
|
// rate-limit budgets. Unlike bind-production.test.ts (which mocks every
|
||||||
|
// feature binder), this file runs the REAL auth production binder against a
|
||||||
|
// stubbed Payload local API and drives sign-in through the app router.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||||
|
|
||||||
|
const payloadStub = vi.hoisted(() => ({
|
||||||
|
secret: "test-secret",
|
||||||
|
jobs: { queue: vi.fn() },
|
||||||
|
// No user ever matches → every sign-in fails and consumes budget.
|
||||||
|
find: vi.fn(async () => ({ docs: [] })),
|
||||||
|
findByID: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => data),
|
||||||
|
}));
|
||||||
|
vi.mock("payload", () => ({ getPayload: vi.fn(async () => payloadStub) }));
|
||||||
|
|
||||||
|
// Other features are irrelevant here — mock their binders so this test only
|
||||||
|
// boots the auth production path.
|
||||||
|
vi.mock("@repo/blog/di/bind-production", () => ({
|
||||||
|
bindProductionBlog: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@repo/marketing-pages/di/bind-production", () => ({
|
||||||
|
bindProductionMarketingPages: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@repo/navigation/di/bind-production", () => ({
|
||||||
|
bindProductionNavigation: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@repo/media/di/bind-production", () => ({
|
||||||
|
bindProductionMedia: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("bindAllProduction rate limiting (A4/B3)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns TOO_MANY_REQUESTS once the manifest ip budget is exhausted", async () => {
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
await bindAllProduction();
|
||||||
|
|
||||||
|
const { appRouter } = await import("@repo/core-api");
|
||||||
|
const { authManifest } = await import("@repo/auth");
|
||||||
|
const caller = appRouter.createCaller({ clientIp: "203.0.113.7" });
|
||||||
|
|
||||||
|
const attempt = () =>
|
||||||
|
caller.auth.signIn({ username: "ghost", password: "wrong-password" });
|
||||||
|
|
||||||
|
// The manifest is the budget's source of truth: 5 failed attempts pass
|
||||||
|
// through (UNAUTHORIZED), the 6th trips the ip bucket.
|
||||||
|
const ipBudget = authManifest.useCases.signIn.rateLimit.find(
|
||||||
|
(b) => b.name === "ip",
|
||||||
|
);
|
||||||
|
expect(ipBudget).toBeDefined();
|
||||||
|
for (let i = 0; i < ipBudget!.budget; i++) {
|
||||||
|
await expect(attempt()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
await expect(attempt()).rejects.toMatchObject({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other client IPs unaffected by an exhausted bucket", async () => {
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
await bindAllProduction();
|
||||||
|
|
||||||
|
const { appRouter } = await import("@repo/core-api");
|
||||||
|
const { authManifest } = await import("@repo/auth");
|
||||||
|
|
||||||
|
const throttled = appRouter.createCaller({ clientIp: "198.51.100.9" });
|
||||||
|
const ipBudget = authManifest.useCases.signIn.rateLimit.find(
|
||||||
|
(b) => b.name === "ip",
|
||||||
|
)!;
|
||||||
|
for (let i = 0; i < ipBudget.budget; i++) {
|
||||||
|
await expect(
|
||||||
|
throttled.auth.signIn({
|
||||||
|
username: `user${i}x`,
|
||||||
|
password: "wrong-password",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
throttled.auth.signIn({ username: "user0x", password: "wrong-password" }),
|
||||||
|
).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||||
|
|
||||||
|
// A different IP still gets an ordinary auth failure, not a throttle.
|
||||||
|
const fresh = appRouter.createCaller({ clientIp: "192.0.2.55" });
|
||||||
|
await expect(
|
||||||
|
fresh.auth.signIn({
|
||||||
|
username: "someoneelse",
|
||||||
|
password: "wrong-password",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
// bindAllProduction wires core-audit, which fails fast in NODE_ENV=production
|
||||||
|
// without a pseudonym salt (by design). Provide one for the whole suite.
|
||||||
|
process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production";
|
||||||
|
|
||||||
|
// Hoisted so the payload mock and assertions share the same jobs.queue spy.
|
||||||
|
const { jobsQueueMock } = vi.hoisted(() => ({
|
||||||
|
jobsQueueMock: vi.fn(async () => ({ id: "job-1" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@repo/core-cms", () => ({
|
||||||
|
default: Promise.resolve({
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
{ slug: "pages", fields: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
vi.mock("payload", () => ({
|
vi.mock("payload", () => ({
|
||||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@repo/blog/di/bind-production", () => ({
|
vi.mock("@repo/blog/di/bind-production", () => ({
|
||||||
bindProductionBlog: vi.fn(),
|
bindProductionBlog: vi.fn(),
|
||||||
@@ -69,12 +98,24 @@ describe("bindAllProduction", () => {
|
|||||||
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — second call does not re-bind", async () => {
|
it("registers retention purge jobs at production boot (A3)", async () => {
|
||||||
const { bindAllProduction } = await import("./bind-production");
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
await bindAllProduction();
|
||||||
|
|
||||||
|
// one enqueue per collection declaring custom.retention.purgeSchedule
|
||||||
|
expect(jobsQueueMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(jobsQueueMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "retention-purge--users" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
const { bindAll } = await import("./bind-production");
|
||||||
const { bindProductionBlog } =
|
const { bindProductionBlog } =
|
||||||
await import("@repo/blog/di/bind-production");
|
await import("@repo/blog/di/bind-production");
|
||||||
await bindAllProduction();
|
await bindAll();
|
||||||
await bindAllProduction();
|
await bindAll();
|
||||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +131,25 @@ describe("bindAllProduction", () => {
|
|||||||
expect(ctx.bus).toBeUndefined();
|
expect(ctx.bus).toBeUndefined();
|
||||||
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
|
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("binds a real InMemoryRateLimit seeded from the auth manifest (A4)", async () => {
|
||||||
|
const { bindAllProduction } = await import("./bind-production");
|
||||||
|
const { bindProductionAuth } =
|
||||||
|
await import("@repo/auth/di/bind-production");
|
||||||
|
const { InMemoryRateLimit } = await import("@repo/core-shared/rate-limit");
|
||||||
|
|
||||||
|
await bindAllProduction();
|
||||||
|
|
||||||
|
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
|
||||||
|
expect(ctx.rateLimit).toBeInstanceOf(InMemoryRateLimit);
|
||||||
|
// The manifest budgets must be resolvable — an unknown budget throws.
|
||||||
|
await expect(ctx.rateLimit!.consume("ip", "smoke")).resolves.toMatchObject({
|
||||||
|
allowed: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
ctx.rateLimit!.consume("account", "smoke"),
|
||||||
|
).resolves.toMatchObject({ allowed: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bindAllDevSeed", () => {
|
describe("bindAllDevSeed", () => {
|
||||||
@@ -109,6 +169,17 @@ describe("bindAllDevSeed", () => {
|
|||||||
expect(ctx.bus).toBeUndefined();
|
expect(ctx.bus).toBeUndefined();
|
||||||
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
|
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the no-op rate limiter on the dev-seed path (never throttles locally)", async () => {
|
||||||
|
const { bindAllDevSeed } = await import("./bind-production");
|
||||||
|
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||||
|
const { NoopRateLimit } = await import("@repo/core-shared/rate-limit");
|
||||||
|
|
||||||
|
await bindAllDevSeed();
|
||||||
|
|
||||||
|
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
|
||||||
|
expect(ctx.rateLimit).toBeInstanceOf(NoopRateLimit);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bindAll dispatcher", () => {
|
describe("bindAll dispatcher", () => {
|
||||||
|
|||||||
@@ -16,7 +16,27 @@ import {
|
|||||||
PayloadJobQueue,
|
PayloadJobQueue,
|
||||||
type IJobQueue,
|
type IJobQueue,
|
||||||
} from "@repo/core-shared/jobs";
|
} from "@repo/core-shared/jobs";
|
||||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
import {
|
||||||
|
InMemoryRateLimit,
|
||||||
|
NoopRateLimit,
|
||||||
|
type RateLimitBudget,
|
||||||
|
} from "@repo/core-shared/rate-limit";
|
||||||
|
import {
|
||||||
|
registerRetentionPurgeJobs,
|
||||||
|
type GetPayloadFn,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
|
import { bindAudit, type IAuditLog } from "@repo/core-audit";
|
||||||
|
import {
|
||||||
|
bindProductionConsent,
|
||||||
|
bindDevSeedConsent,
|
||||||
|
type ConsentFactory,
|
||||||
|
} from "@repo/core-consent";
|
||||||
|
import {
|
||||||
|
bindProductionDsr,
|
||||||
|
bindDevSeedDsr,
|
||||||
|
type DsrBinding,
|
||||||
|
} from "@repo/core-dsr";
|
||||||
|
import { authManifest } from "@repo/auth";
|
||||||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||||
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
||||||
@@ -28,7 +48,7 @@ import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-see
|
|||||||
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||||||
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
||||||
|
|
||||||
let bound = false;
|
let bindPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
// Shared container holds TRACER + LOGGER bindings; per-feature containers
|
// Shared container holds TRACER + LOGGER bindings; per-feature containers
|
||||||
// receive references via parameter passing. This separates the instrumentation
|
// receive references via parameter passing. This separates the instrumentation
|
||||||
@@ -39,6 +59,45 @@ let resolvedTracer: ITracer | null = null;
|
|||||||
let resolvedLogger: ILogger | null = null;
|
let resolvedLogger: ILogger | null = null;
|
||||||
let resolvedQueue: IJobQueue | null = null;
|
let resolvedQueue: IJobQueue | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compliance bindings constructed once per boot (audit finding A11): the
|
||||||
|
* consent factory and DSR binding that the tRPC createContext threads into
|
||||||
|
* every request so the mounted consent/dsr routers are live. `auditLog` is
|
||||||
|
* present only on the production path.
|
||||||
|
*/
|
||||||
|
export type ComplianceBindings = {
|
||||||
|
consentFactory: ConsentFactory;
|
||||||
|
dsrBinding: DsrBinding;
|
||||||
|
auditLog?: IAuditLog;
|
||||||
|
};
|
||||||
|
|
||||||
|
let complianceBindings: ComplianceBindings | null = null;
|
||||||
|
|
||||||
|
export type BindingMode = "production" | "dev-seed";
|
||||||
|
|
||||||
|
/** Env → binding mode, mirroring bindAll()'s resolution rules. */
|
||||||
|
export function resolveBindingMode(): BindingMode {
|
||||||
|
if (process.env.USE_DEV_SEED === "false") return "production";
|
||||||
|
if (process.env.USE_DEV_SEED === "true") return "dev-seed";
|
||||||
|
if (process.env.NODE_ENV === "production") return "production";
|
||||||
|
return "dev-seed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the boot-time compliance bindings, running bindAll() first if
|
||||||
|
* needed. Called by the app's tRPC createContext on every request (cheap
|
||||||
|
* after the first call — bindAll is memoized).
|
||||||
|
*/
|
||||||
|
export async function getComplianceBindings(): Promise<ComplianceBindings> {
|
||||||
|
await bindAll();
|
||||||
|
if (!complianceBindings) {
|
||||||
|
throw new Error(
|
||||||
|
"compliance bindings missing after bindAll() — binder did not construct them",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return complianceBindings;
|
||||||
|
}
|
||||||
|
|
||||||
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
|
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
|
||||||
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
||||||
if (resolvedTracer && resolvedLogger) {
|
if (resolvedTracer && resolvedLogger) {
|
||||||
@@ -81,24 +140,60 @@ function resolveJobsDevSeed(): { queue: IJobQueue } {
|
|||||||
return { queue };
|
return { queue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every per-use-case rate-limit budget declared in the feature
|
||||||
|
* manifests. Budgets are the manifests' source of truth (auth declares
|
||||||
|
* signIn ip/account budgets today); add further manifests here as features
|
||||||
|
* declare `rateLimit` entries.
|
||||||
|
*/
|
||||||
|
function collectManifestRateLimitBudgets(): RateLimitBudget[] {
|
||||||
|
return Object.values(authManifest.useCases).flatMap((useCase) =>
|
||||||
|
"rateLimit" in useCase && useCase.rateLimit ? [...useCase.rateLimit] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Production path: swap each feature's mock repository binding for the real
|
* Production path: swap each feature's mock repository binding for the real
|
||||||
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
||||||
* feature via `bindProductionX` exports.
|
* feature via `bindProductionX` exports.
|
||||||
*/
|
*/
|
||||||
export async function bindAllProduction(): Promise<void> {
|
export async function bindAllProduction(): Promise<void> {
|
||||||
if (bound) return;
|
|
||||||
bound = true;
|
|
||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { queue } = await resolveJobsProduction();
|
const { queue } = await resolveJobsProduction();
|
||||||
const resolvedConfig = await config;
|
const resolvedConfig = await config;
|
||||||
|
|
||||||
|
// Compliance cores (A6/A11): the audit log fans into the Payload
|
||||||
|
// `audit-logs` collection + stdout; consent + DSR bindings share it so
|
||||||
|
// every grant/withdraw/export/delete leaves an audit trail.
|
||||||
|
const { auditLog } = bindAudit(sharedContainer, {
|
||||||
|
payloadConfig: resolvedConfig,
|
||||||
|
});
|
||||||
|
const { consentFactory } = bindProductionConsent({
|
||||||
|
config: resolvedConfig,
|
||||||
|
auditLog,
|
||||||
|
});
|
||||||
|
const dsrBinding = bindProductionDsr({
|
||||||
|
config: resolvedConfig,
|
||||||
|
auditLog,
|
||||||
|
// cascade-hard deletions pseudonymize the subject's audit trail (A6)
|
||||||
|
auditErasure: auditLog,
|
||||||
|
});
|
||||||
|
complianceBindings = { consentFactory, dsrBinding, auditLog };
|
||||||
|
|
||||||
const ctx: BindProductionContext = {
|
const ctx: BindProductionContext = {
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
queue,
|
||||||
rateLimit: new NoopRateLimit(),
|
auditLog,
|
||||||
|
// Enables the anonymous→authenticated consent migration inside the auth
|
||||||
|
// sign-up use case (audit finding A12).
|
||||||
|
consentFactory,
|
||||||
|
// Real limiter in production (audit finding A4/B3): budgets come from the
|
||||||
|
// feature manifests, so manifest edits change enforcement without touching
|
||||||
|
// this file. In-memory ⇒ per-process counters; multi-instance deployments
|
||||||
|
// need a shared backend behind IRateLimit.
|
||||||
|
rateLimit: new InMemoryRateLimit(collectManifestRateLimitBudgets()),
|
||||||
};
|
};
|
||||||
|
|
||||||
bindProductionAuth(ctx);
|
bindProductionAuth(ctx);
|
||||||
@@ -106,6 +201,17 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
bindProductionMarketingPages(ctx);
|
bindProductionMarketingPages(ctx);
|
||||||
bindProductionNavigation(ctx);
|
bindProductionNavigation(ctx);
|
||||||
bindProductionMedia(ctx);
|
bindProductionMedia(ctx);
|
||||||
|
|
||||||
|
// Kick off the retention purge cycle (audit finding A3): enqueue the first
|
||||||
|
// `retention-purge--<slug>` job for every collection declaring a
|
||||||
|
// custom.retention.purgeSchedule. The task definitions live in the Payload
|
||||||
|
// config (core-cms jobs.tasks); each run re-enqueues the next cycle.
|
||||||
|
await registerRetentionPurgeJobs({
|
||||||
|
queue,
|
||||||
|
config: resolvedConfig,
|
||||||
|
getPayload: getPayload as unknown as GetPayloadFn,
|
||||||
|
auditLog,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,15 +220,22 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
||||||
*/
|
*/
|
||||||
export async function bindAllDevSeed(): Promise<void> {
|
export async function bindAllDevSeed(): Promise<void> {
|
||||||
if (bound) return;
|
|
||||||
bound = true;
|
|
||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { queue } = resolveJobsDevSeed();
|
const { queue } = resolveJobsDevSeed();
|
||||||
|
|
||||||
|
// In-memory compliance bindings so the mounted consent/dsr routers work
|
||||||
|
// without Payload booted (A11). No audit sink in dev seed.
|
||||||
|
const { consentFactory } = bindDevSeedConsent();
|
||||||
|
const dsrBinding = bindDevSeedDsr();
|
||||||
|
complianceBindings = { consentFactory, dsrBinding };
|
||||||
|
|
||||||
const ctx: BindContext = {
|
const ctx: BindContext = {
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
queue,
|
||||||
|
consentFactory,
|
||||||
|
// Dev seed intentionally keeps the no-op limiter so local iteration and
|
||||||
|
// seeded demos are never throttled; production binds InMemoryRateLimit.
|
||||||
rateLimit: new NoopRateLimit(),
|
rateLimit: new NoopRateLimit(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,24 +263,24 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
|
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
|
||||||
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
|
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
|
||||||
*/
|
*/
|
||||||
export async function bindAll(): Promise<void> {
|
export function bindAll(): Promise<void> {
|
||||||
if (process.env.USE_DEV_SEED === "true") {
|
if (bindPromise) return bindPromise;
|
||||||
await bindAllDevSeed();
|
|
||||||
return;
|
bindPromise =
|
||||||
}
|
resolveBindingMode() === "production"
|
||||||
if (process.env.NODE_ENV === "production") {
|
? bindAllProduction()
|
||||||
await bindAllProduction();
|
: bindAllDevSeed();
|
||||||
return;
|
|
||||||
}
|
return bindPromise;
|
||||||
await bindAllDevSeed();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
|
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
|
||||||
export function __resetBindStateForTests(): void {
|
export function __resetBindStateForTests(): void {
|
||||||
bound = false;
|
bindPromise = null;
|
||||||
resolvedTracer = null;
|
resolvedTracer = null;
|
||||||
resolvedLogger = null;
|
resolvedLogger = null;
|
||||||
resolvedQueue = null;
|
resolvedQueue = null;
|
||||||
|
complianceBindings = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only accessor for resolved instrumentation. */
|
/** Test-only accessor for resolved instrumentation. */
|
||||||
|
|||||||
90
apps/web-next/src/server/trpc-context.test.ts
Normal file
90
apps/web-next/src/server/trpc-context.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const findByID = vi.fn();
|
||||||
|
vi.mock("payload", () => ({
|
||||||
|
getPayload: vi.fn(async () => ({ findByID })),
|
||||||
|
}));
|
||||||
|
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||||
|
|
||||||
|
const validateSession = vi.fn();
|
||||||
|
vi.mock("@repo/auth/di/container", () => ({
|
||||||
|
authContainer: { get: () => ({ validateSession }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const consentFactory = vi.fn(async () => ({}));
|
||||||
|
const dsrBinding = { marker: "dsr-binding" };
|
||||||
|
const resolveBindingMode = vi.fn<() => "production" | "dev-seed">(
|
||||||
|
() => "production",
|
||||||
|
);
|
||||||
|
vi.mock("./bind-production", () => ({
|
||||||
|
bindAll: vi.fn(async () => {}),
|
||||||
|
getComplianceBindings: vi.fn(async () => ({ consentFactory, dsrBinding })),
|
||||||
|
resolveBindingMode: () => resolveBindingMode(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createWebNextTrpcContext } from "./trpc-context";
|
||||||
|
|
||||||
|
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||||
|
return new Request("https://example.test/api/trpc", { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createWebNextTrpcContext (A11)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resolveBindingMode.mockReturnValue("production");
|
||||||
|
findByID.mockResolvedValue({ id: "user-1", role: "admin" });
|
||||||
|
validateSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads compliance bindings for anonymous requests", async () => {
|
||||||
|
const ctx = await createWebNextTrpcContext(makeRequest());
|
||||||
|
expect(ctx.user).toBeUndefined();
|
||||||
|
expect(ctx.userId).toBeUndefined();
|
||||||
|
expect(ctx.consentFactory).toBe(consentFactory);
|
||||||
|
expect(ctx.dsrBinding).toBe(dsrBinding);
|
||||||
|
expect(validateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives clientIp from proxy headers (B2)", async () => {
|
||||||
|
const ctx = await createWebNextTrpcContext(
|
||||||
|
makeRequest({ "x-forwarded-for": "203.0.113.9" }),
|
||||||
|
);
|
||||||
|
expect(ctx.clientIp).toBe("203.0.113.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the user + role snapshot from the payload-token cookie", async () => {
|
||||||
|
const ctx = await createWebNextTrpcContext(
|
||||||
|
makeRequest({ cookie: "payload-token=jwt-abc; other=1" }),
|
||||||
|
);
|
||||||
|
expect(validateSession).toHaveBeenCalledWith("jwt-abc");
|
||||||
|
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
|
||||||
|
expect(ctx.userId).toBe("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the dev-seed session cookie name too", async () => {
|
||||||
|
resolveBindingMode.mockReturnValue("dev-seed");
|
||||||
|
const ctx = await createWebNextTrpcContext(
|
||||||
|
makeRequest({ cookie: "session=session_user-1" }),
|
||||||
|
);
|
||||||
|
expect(validateSession).toHaveBeenCalledWith("session_user-1");
|
||||||
|
// dev-seed has no Payload — role snapshot is empty
|
||||||
|
expect(ctx.user).toEqual({ id: "user-1", roles: [] });
|
||||||
|
expect(findByID).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an invalid/expired session as anonymous", async () => {
|
||||||
|
validateSession.mockRejectedValue(new Error("Invalid or expired"));
|
||||||
|
const ctx = await createWebNextTrpcContext(
|
||||||
|
makeRequest({ cookie: "payload-token=tampered" }),
|
||||||
|
);
|
||||||
|
expect(ctx.user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields no roles when the users doc has none", async () => {
|
||||||
|
findByID.mockResolvedValue({ id: "user-1" });
|
||||||
|
const ctx = await createWebNextTrpcContext(
|
||||||
|
makeRequest({ cookie: "payload-token=jwt-abc" }),
|
||||||
|
);
|
||||||
|
expect(ctx.user).toEqual({ id: "user-1", roles: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
111
apps/web-next/src/server/trpc-context.ts
Normal file
111
apps/web-next/src/server/trpc-context.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// apps/web-next/src/server/trpc-context.ts
|
||||||
|
// SERVER-ONLY: builds the per-request tRPC context (audit finding A11).
|
||||||
|
//
|
||||||
|
// Extends the shared `createTrpcContext` (which derives `clientIp`, B2) with:
|
||||||
|
// - the authenticated user, resolved server-side from the session cookie via
|
||||||
|
// the auth feature's IAuthenticationService.validateSession (never from
|
||||||
|
// client input), plus a role snapshot for role-gated procedures (B7/A1);
|
||||||
|
// - the boot-time compliance bindings (consent factory + DSR binding) so
|
||||||
|
// the mounted consent/dsr routers are live instead of dead stubs.
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import config from "@repo/core-cms";
|
||||||
|
import {
|
||||||
|
createTrpcContext,
|
||||||
|
type TrpcSessionUser,
|
||||||
|
} from "@repo/core-shared/trpc/context";
|
||||||
|
import { authContainer } from "@repo/auth/di/container";
|
||||||
|
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
|
||||||
|
import {
|
||||||
|
bindAll,
|
||||||
|
getComplianceBindings,
|
||||||
|
resolveBindingMode,
|
||||||
|
} from "./bind-production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural view of IAuthenticationService — only the method the context
|
||||||
|
* needs. Resolved from the auth container so production requests hit the
|
||||||
|
* same denylist-aware service instance that sign-in/sign-out use (B5).
|
||||||
|
*/
|
||||||
|
type SessionValidator = {
|
||||||
|
validateSession(token: string): Promise<{ user: { id: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie names carrying the session token: "payload-token" is written by the
|
||||||
|
* production AuthenticationService; "session" (SESSION_COOKIE) by the
|
||||||
|
* dev-seed MockAuthenticationService.
|
||||||
|
*/
|
||||||
|
const SESSION_COOKIE_NAMES = ["payload-token", "session"] as const;
|
||||||
|
|
||||||
|
function parseCookieHeader(cookieHeader: string): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const part of cookieHeader.split(";")) {
|
||||||
|
const eqIdx = part.indexOf("=");
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
const name = part.slice(0, eqIdx).trim();
|
||||||
|
const value = part.slice(eqIdx + 1).trim();
|
||||||
|
if (name) map.set(name, value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role snapshot for the authenticated user. The auth entity model carries no
|
||||||
|
* role, so production reads it from the users collection; dev seed has no
|
||||||
|
* Payload and yields no roles (admin-gated procedures are production-only).
|
||||||
|
*/
|
||||||
|
async function resolveRoles(userId: string): Promise<string[]> {
|
||||||
|
if (resolveBindingMode() !== "production") return [];
|
||||||
|
const resolvedConfig = await config;
|
||||||
|
const payload = await getPayload({ config: resolvedConfig });
|
||||||
|
const doc = (await payload.findByID({
|
||||||
|
collection: "users" as never,
|
||||||
|
id: userId,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as { role?: unknown };
|
||||||
|
return typeof doc.role === "string" && doc.role.length > 0 ? [doc.role] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the authenticated user from the request's session cookie.
|
||||||
|
* Returns null for anonymous/invalid/expired sessions — createTrpcContext
|
||||||
|
* treats resolver failures as anonymous, and procedures gate with
|
||||||
|
* UNAUTHORIZED/FORBIDDEN as needed.
|
||||||
|
*/
|
||||||
|
async function resolveUser(req: Request): Promise<TrpcSessionUser | null> {
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const cookies = parseCookieHeader(cookieHeader);
|
||||||
|
|
||||||
|
const validator = authContainer.get<SessionValidator>(
|
||||||
|
AUTH_SYMBOLS.IAuthenticationService,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of SESSION_COOKIE_NAMES) {
|
||||||
|
const token = cookies.get(name);
|
||||||
|
if (!token) continue;
|
||||||
|
try {
|
||||||
|
const { user } = await validator.validateSession(token);
|
||||||
|
return { id: user.id, roles: await resolveRoles(user.id) };
|
||||||
|
} catch {
|
||||||
|
// invalid/expired/revoked token under this cookie name — try the next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request tRPC context for web-next. Ensures DI is bound, then threads
|
||||||
|
* the server-derived fields + compliance bindings into every procedure.
|
||||||
|
*/
|
||||||
|
export async function createWebNextTrpcContext(req: Request) {
|
||||||
|
await bindAll();
|
||||||
|
const { consentFactory, dsrBinding } = await getComplianceBindings();
|
||||||
|
const base = await createTrpcContext(req, { resolveUser });
|
||||||
|
return { ...base, consentFactory, dsrBinding };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebNextTrpcContext = Awaited<
|
||||||
|
ReturnType<typeof createWebNextTrpcContext>
|
||||||
|
>;
|
||||||
10
apps/web-next/src/styles/app.css
Normal file
10
apps/web-next/src/styles/app.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@source "../../../../packages/core-ui/src";
|
||||||
|
@source "../../../../packages/navigation/src";
|
||||||
|
@source "../../../../packages/blog/src";
|
||||||
|
@source "../../../../packages/marketing-pages/src";
|
||||||
|
@source "../../../../packages/media/src";
|
||||||
|
@source "../../../../packages/auth/src";
|
||||||
|
@source "../";
|
||||||
|
|
||||||
|
@import "../../../../packages/core-ui/src/styles/theme.css";
|
||||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,8 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
const { nonce } = Route.useLoaderData();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* nonce exposed to client so instrumentation-client.ts can read it */}
|
||||||
|
<meta name="csp-nonce" content={nonce} />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -12,14 +23,5 @@ export const Route = createRootRoute({
|
|||||||
return { nonce: "" };
|
return { nonce: "" };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: () => {
|
component: RootComponent,
|
||||||
const { nonce } = Route.useLoaderData();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* nonce exposed to client so instrumentation-client.ts can read it */}
|
|
||||||
<meta name="csp-nonce" content={nonce} />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,4 +32,11 @@ collections:
|
|||||||
- transactional-notifications
|
- transactional-notifications
|
||||||
restrictable: true
|
restrictable: true
|
||||||
source: auth-default
|
source: auth-default
|
||||||
|
- category: identification-username
|
||||||
|
exportable: true
|
||||||
|
field: username
|
||||||
|
purpose:
|
||||||
|
- service-delivery
|
||||||
|
restrictable: true
|
||||||
|
source: field-tag
|
||||||
slug: users
|
slug: users
|
||||||
|
|||||||
208
docs/decisions/adr-026-cross-feature-readers.md
Normal file
208
docs/decisions/adr-026-cross-feature-readers.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# ADR-026 — Cross-feature synchronous readers
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The monorepo's vertical-slice architecture (ADR-006) enforces strict feature isolation: each vertical owns its data end-to-end, and cross-feature communication flows through the event bus (ADR-015, rule E0). This works well for **reactions** ("user signed up → send welcome email"), but the architecture has no mechanism for **synchronous domain queries** across features.
|
||||||
|
|
||||||
|
Three concrete scenarios expose the gap:
|
||||||
|
|
||||||
|
1. **Permission checks.** Blog's `createArticle` needs to verify the author has the "editor" role. The raw user record is available via Payload's `relationTo`, but evaluating "does role X grant permission Y in context Z?" is domain logic that belongs to the auth vertical.
|
||||||
|
2. **Computed state.** A billing feature needs to know whether a subscription is active after applying trial logic, grace periods, and plan rules. That evaluation belongs to the subscriptions vertical.
|
||||||
|
3. **Validated existence.** A comments feature needs to verify a referenced article exists and is in "published" status — a check that includes blog-domain invariants, not just a row lookup.
|
||||||
|
|
||||||
|
Payload's `relationTo` handles raw data joins at the database level (and should continue to be used for that), but it cannot evaluate business rules owned by another vertical. Events cannot answer synchronous questions. The architecture needs a third cross-feature mechanism.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. Introduce readers: synchronous, read-only cross-feature query contracts.**
|
||||||
|
|
||||||
|
A **reader** is a minimal interface exported by a feature that exposes domain queries to other verticals. It complements events (async reactions) and `relationTo` (raw data joins) without replacing either.
|
||||||
|
|
||||||
|
| Cross-feature need | Mechanism | Sync/Async | Example |
|
||||||
|
| ---------------------- | -------------------- | ----------- | ------------------------------ |
|
||||||
|
| Raw data join | Payload `relationTo` | Sync (DB) | Article card shows author name |
|
||||||
|
| Domain query | Reader | Sync (code) | "Does user have editor role?" |
|
||||||
|
| Reaction / side effect | Event bus (ADR-015) | Async | "User signed up → send email" |
|
||||||
|
| Deferred work | Job queue (ADR-015) | Async | "Resize uploaded image" |
|
||||||
|
| State delivery / push | Realtime (ADR-016) | Async | "New comment appeared" |
|
||||||
|
|
||||||
|
**2. Four rules, parallel to events (E0/E1) and jobs (J0).**
|
||||||
|
|
||||||
|
- **Q0 — Readers are for cross-feature synchronous domain queries only.** In-feature reads are direct use-case calls. If the caller and the data owner are in the same vertical, use the use case directly — don't route through a reader.
|
||||||
|
- **Q1 — Reader contracts (interfaces) are public; implementations are private.** The owning feature exports `I<Feature>Reader` from a `./reader` subpath. The implementation class (`<Feature>Reader`) is internal, constructed by the feature's binder. Consumers import the type only. Parallel to rule E1 for event handlers.
|
||||||
|
- **Q2 — Readers are strictly read-only. Cross-feature writes go through events.** A reader may only delegate to use cases declared `mutates: false` in the feature manifest. Enforced by `ReadOnly<F>` TypeScript brand at compile time and `assertReaderPurity` at boot time. If you need to tell another vertical that something happened, publish an event.
|
||||||
|
- **Q3 — Reader cycles are a design error.** If Feature A reads from Feature B and Feature B reads from Feature A, the boundaries are wrong. Resolution strategies: (a) one direction is a UI composition concern — compose at the app layer instead; (b) one direction can be async — use an event; (c) the two features should be one vertical.
|
||||||
|
|
||||||
|
**3. One reader per feature, grown on demand.**
|
||||||
|
|
||||||
|
Each feature that exposes cross-feature queries ships a single `I<Feature>Reader` interface (e.g., `IAuthReader`, `ITenantReader`). The interface starts minimal and grows as consumers need more methods. If the interface becomes bloated, that's a signal the vertical is too fat.
|
||||||
|
|
||||||
|
**4. Readers wrap existing use cases — they don't add domain logic.**
|
||||||
|
|
||||||
|
The reader is a thin facade over the owning feature's use cases. It does not contain business rules itself. If a reader needs logic that doesn't exist as a use case, the correct response is to create the use case first (manifest-first ordering), then have the reader delegate to it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/auth/src/infrastructure/readers/auth.reader.ts (INTERNAL)
|
||||||
|
export class AuthReader implements IAuthReader {
|
||||||
|
constructor(
|
||||||
|
private checkRole: ReadOnly<ICheckUserRoleUseCase>,
|
||||||
|
private getUser: ReadOnly<IGetUserUseCase>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async hasRole(userId: string, role: string): Promise<boolean> {
|
||||||
|
return this.checkRole({ userId, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(userId: string): Promise<boolean> {
|
||||||
|
const user = await this.getUser({ id: userId });
|
||||||
|
return user !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the reader wraps use cases, no `MockReader` class is needed. In dev-seed mode the same `AuthReader` class works — the use cases beneath it are backed by mock repositories populated with seed data. In consumer tests, an inline vitest mock of `IAuthReader` suffices.
|
||||||
|
|
||||||
|
**5. Readers live under `integrations/readers/`, exported via `./reader` subpath.**
|
||||||
|
|
||||||
|
The reader is an outward-facing integration boundary, parallel to `integrations/api/` (HTTP consumers) and `integrations/cms/` (Payload admin). File layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/<feature>/src/
|
||||||
|
integrations/
|
||||||
|
api/ # outward: HTTP consumers
|
||||||
|
cms/ # outward: Payload admin
|
||||||
|
readers/ # outward: other verticals
|
||||||
|
<feature>.reader.interface.ts # IFeatureReader (PUBLIC)
|
||||||
|
<feature>.reader.ts # FeatureReader (INTERNAL)
|
||||||
|
<feature>.reader.test.ts
|
||||||
|
index.ts # exports type { IFeatureReader } only
|
||||||
|
```
|
||||||
|
|
||||||
|
The `package.json` exports map gains a `./reader` entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "./reader": "./src/integrations/readers/index.ts" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Wiring: binder returns reader, `bindAll()` threads it to consumers.**
|
||||||
|
|
||||||
|
Feature binders that expose a reader return it:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// bindProductionAuth(ctx) returns { reader: IAuthReader }
|
||||||
|
const authResult = bindProductionAuth(ctx);
|
||||||
|
bindProductionBlog(ctx, { authReader: authResult.reader });
|
||||||
|
```
|
||||||
|
|
||||||
|
Consuming binders accept readers as a second parameter alongside `ctx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function bindProductionBlog(
|
||||||
|
ctx: BindProductionContext,
|
||||||
|
readers: { authReader: IAuthReader },
|
||||||
|
): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordering in `bindAll()` is explicit — the owning feature binds first, then consumers. A cycle in `bindAll()` is a compile-time error (TypeScript cannot type the return before the call), which enforces rule Q3 structurally.
|
||||||
|
|
||||||
|
**7. Manifest field: `reads: ["<feature>"]` per use case.**
|
||||||
|
|
||||||
|
The feature manifest declares cross-feature read dependencies:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useCases: {
|
||||||
|
createArticle: {
|
||||||
|
mutates: true, // this use case mutates its OWN feature's data
|
||||||
|
reads: ["auth"], // this use case queries ANOTHER feature's reader (read-only on auth side)
|
||||||
|
publishes: [],
|
||||||
|
consumes: [],
|
||||||
|
audits: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `mutates` and `reads` are orthogonal. `mutates` describes whether this use case writes to its own feature's repositories. `reads` describes which other features' readers it queries. A mutating use case can read from another feature's reader — the read-only constraint (Q2) is enforced on the **provider** side (the reader can only wrap non-mutating use cases), not on the consumer side.
|
||||||
|
|
||||||
|
Conformance gates verify:
|
||||||
|
|
||||||
|
- **ESLint rule `no-undeclared-reader`:** Code calls a reader method but manifest doesn't declare `reads`. (Parallel to `no-undeclared-event-publish`.)
|
||||||
|
- **Boot assertion `assertReaderPurity`:** Every use case wired into a reader is declared `mutates: false` in the manifest.
|
||||||
|
- **Boot assertion `assertFeatureConformance`:** Every `reads` entry has a corresponding reader injected into the binder.
|
||||||
|
- **`pnpm conformance`:** Cross-feature reader closure — every `reads: ["auth"]` resolves to a feature that exports `./reader`.
|
||||||
|
|
||||||
|
**8. Read-only enforcement via `ReadOnly<F>` brand.**
|
||||||
|
|
||||||
|
A new branded type prevents mutating use cases from being wired into readers at compile time:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ReadOnly<F> = F & { readonly __readonly: unique symbol };
|
||||||
|
```
|
||||||
|
|
||||||
|
Use cases declared `mutates: false` receive the `ReadOnly` brand at bind time. The reader constructor only accepts `ReadOnly`-branded use cases. Passing a mutating use case produces a TypeScript error.
|
||||||
|
|
||||||
|
The brand is verified at boot time by `assertReaderPurity`, which cross-references the reader's wired use cases against the manifest's `mutates` field. If a `mutates: true` use case is wired into a reader, the app refuses to boot.
|
||||||
|
|
||||||
|
**9. No reader-level instrumentation.**
|
||||||
|
|
||||||
|
Readers delegate to use cases that are already wrapped with `withSpan` and `withCapture` at bind time. Adding reader-level spans would create redundant parent spans for every cross-feature query. Use case spans are sufficient for tracing.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Events for everything (status quo).** Rejected for domain queries — events are async and fire-and-forget. You cannot `await bus.publish("auth.check-role")` and get an answer back. Forcing queries through the event bus would require request-scoped correlation IDs, reply channels, and timeouts — essentially rebuilding synchronous RPC over an async bus.
|
||||||
|
|
||||||
|
- **Direct use-case imports across features.** Rejected — violates vertical isolation. If blog imports `checkUserRoleUseCase` from auth, it takes a transitive dependency on auth's repository interfaces, DI symbols, and internal structure. A change inside auth's use case can break blog's compilation.
|
||||||
|
|
||||||
|
- **Shared query interfaces in `core-shared`.** Rejected — `core-shared` is infrastructure. Putting `IAuthReader` there means core-shared accumulates feature-specific domain types, which inverts the dependency direction (core depends on feature concepts).
|
||||||
|
|
||||||
|
- **A standalone `core-protocols` package.** Rejected as premature — adds a new package for what is currently a type-only export. If the number of readers grows beyond 5-6, this can be reconsidered. For now, the owning feature is the natural home.
|
||||||
|
|
||||||
|
- **Gateways (reader + writer in one interface).** Rejected — synchronous cross-feature writes are dangerous. A failure in the target feature's write path would fail the caller's request. Writes should be fire-and-forget (events) so the caller's request path is not coupled to the target's write availability. See rule Q2.
|
||||||
|
|
||||||
|
- **Bidirectional readers (allowing cycles).** Rejected — cycles indicate wrong feature boundaries. Three resolution strategies exist (UI composition, event for one direction, merge features), making a runtime cycle-breaking mechanism unnecessary. See rule Q3.
|
||||||
|
|
||||||
|
- **Rely solely on Payload `relationTo`.** Rejected as the sole mechanism — `relationTo` gives raw data, not domain-evaluated answers. It also doesn't work in dev-seed/test mode with mock repositories. However, `relationTo` remains the correct choice for raw data joins where no domain logic is needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- Verticals can answer synchronous domain queries for other verticals without violating isolation.
|
||||||
|
- The manifest's `reads` field makes cross-feature coupling visible, greppable, and agent-readable — same as `publishes`/`consumes` for events.
|
||||||
|
- Read-only enforcement (`ReadOnly<F>` brand + `assertReaderPurity`) prevents accidental cross-feature mutations.
|
||||||
|
- Cycle detection is structural (compile-time in `bindAll()`) — no runtime checks needed.
|
||||||
|
- No new mock infrastructure — existing use case mocks power the reader in dev-seed; inline vitest mocks suffice for consumer tests.
|
||||||
|
- The pattern is consistent with existing conventions: integration boundary (`integrations/readers/`), public contract + private implementation (rule Q1 parallels E1), manifest declaration + conformance check.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- Adds a fourth cross-feature coupling mechanism (alongside events, jobs, and realtime). Developers and agents must choose correctly. The decision matrix in section 1 mitigates this.
|
||||||
|
- Feature binders that expose readers change their return type (from `void` to `{ reader: I<Feature>Reader }`). `bindAll()` ordering becomes explicit. This is intentional — it makes the dependency graph visible — but it's a change to existing binder signatures.
|
||||||
|
- The `reads` manifest field and `no-undeclared-reader` ESLint rule are new conformance machinery. Implementation cost is bounded (follows the exact pattern of `publishes`/`consumes` + `no-undeclared-event-publish`).
|
||||||
|
- Reader interfaces can grow organically in ways that are hard to audit. Mitigated by the "one reader per feature, grown on demand" rule and the principle that a bloated reader signals a fat vertical.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- **Generator:** A `pnpm turbo gen reader` generator should be added to scaffold the `integrations/readers/` structure, add the `./reader` export to `package.json`, and create the interface + implementation + test files. Not required for day one — hand-authoring the first reader is acceptable while the pattern stabilizes.
|
||||||
|
- **Existing features:** None of the five template features (auth, blog, media, marketing-pages, navigation) currently need readers. The first reader will be created when a product vertical requires a cross-feature domain query. Auth is the most likely candidate (`IAuthReader` for permission checks).
|
||||||
|
- **`BindContext` is unchanged.** Readers flow as binder-to-binder parameters (via `bindAll()`), not through `ctx`. This keeps `BindContext` focused on infrastructure concerns.
|
||||||
|
- **Payload `relationTo` continues unchanged.** Readers supplement it, they don't replace it. Use `relationTo` for raw data joins; use readers for domain-evaluated queries.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
1. **ESLint rule `no-undeclared-reader`.** Follows the `no-undeclared-event-publish` pattern. Deferred until the first reader is exercised.
|
||||||
|
2. **Contract evolution / versioning for readers.** Same as event contracts (ADR-015 §deferred-3) — no migration story for breaking reader interface changes yet.
|
||||||
|
|
||||||
|
## Planned
|
||||||
|
|
||||||
|
1. **`pnpm turbo gen reader` generator.** Will scaffold `integrations/readers/` + `./reader` export subpath + interface + implementation + test. Follows the `gen event` Plop pattern with anchor protocol.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ADR-006 — Vertical feature packages (the isolation model readers operate within)
|
||||||
|
- ADR-008 — Per-feature DI containers (reader wiring uses the same container model)
|
||||||
|
- ADR-010 — Turborepo boundaries (feature → feature type imports are allowed; reader contracts are type-only)
|
||||||
|
- ADR-015 — Cross-feature events and background jobs (readers complement events; rules Q0–Q3 parallel E0/E1/J0)
|
||||||
@@ -241,6 +241,17 @@ The server-side push interface in `@repo/core-realtime`. Use cases call `broadca
|
|||||||
**Realtime handler**:
|
**Realtime handler**:
|
||||||
A consumer's reaction to an inbound client message on a channel. Lives at `packages/<feature>/src/realtime/handlers/*.handler.ts`. **Always private** — never re-exported (rule R1, enforced by `no-realtime-handler-reexport`).
|
A consumer's reaction to an inbound client message on a channel. Lives at `packages/<feature>/src/realtime/handlers/*.handler.ts`. **Always private** — never re-exported (rule R1, enforced by `no-realtime-handler-reexport`).
|
||||||
|
|
||||||
|
**Reader** (`I<Feature>Reader`):
|
||||||
|
A synchronous, read-only cross-feature query contract. Exported by the owning feature from `./reader` subpath; implementation is private. Wraps the feature's existing use cases (only those declared `mutates: false`). Lives at `packages/<feature>/src/integrations/readers/`.
|
||||||
|
_Use when:_ you need another vertical's **domain-evaluated** answer on the request path (e.g., "does this user have the editor role?"). **Don't use for raw data lookups** — that's Payload `relationTo`. **Don't use for side effects** — that's the event bus (rule Q2).
|
||||||
|
_Avoid:_ confusing readers with repositories (repositories are inward-facing data access; readers are outward-facing domain query contracts).
|
||||||
|
|
||||||
|
**`reads`** (manifest field):
|
||||||
|
Per-use-case array of feature names whose readers this use case depends on. Example: `reads: ["auth"]`. Parallel to `publishes`/`consumes` for events. Verified by `assertFeatureConformance` at boot and `no-undeclared-reader` ESLint rule at lint time.
|
||||||
|
|
||||||
|
**`ReadOnly<F>`** (brand):
|
||||||
|
A TypeScript phantom type applied to use cases declared `mutates: false`. Readers only accept `ReadOnly`-branded use cases in their constructor — prevents mutating use cases from being wired into a reader at compile time. Verified at boot by `assertReaderPurity`.
|
||||||
|
|
||||||
**Audit log**:
|
**Audit log**:
|
||||||
A DPA-compliant record of a use case's side effects. Emitted via `auditLog.record(...)`; declared in the manifest's `audits:` array. See ADR-018.
|
A DPA-compliant record of a use case's side effects. Emitted via `auditLog.record(...)`; declared in the manifest's `audits:` array. See ADR-018.
|
||||||
|
|
||||||
@@ -405,7 +416,9 @@ The Renovate-triggered re-walk of `evaluate-library` when a runtime dep's major
|
|||||||
- A **Controller** has at most one **`presenter`** (omitted for void outputs).
|
- A **Controller** has at most one **`presenter`** (omitted for void outputs).
|
||||||
- A **Feature** owns its **Repositories**, **Services**, **Use cases**, **Controllers**, **Events**, **Jobs**, **Channels**, and **DI Container**.
|
- A **Feature** owns its **Repositories**, **Services**, **Use cases**, **Controllers**, **Events**, **Jobs**, **Channels**, and **DI Container**.
|
||||||
- **Cross-feature reactions** travel through the **Event bus**; **in-feature reactions** are direct use-case calls.
|
- **Cross-feature reactions** travel through the **Event bus**; **in-feature reactions** are direct use-case calls.
|
||||||
|
- **Cross-feature domain queries** travel through a **Reader**; raw data joins use Payload `relationTo`.
|
||||||
- An **Event descriptor** is public; its **Event handler** is always private.
|
- An **Event descriptor** is public; its **Event handler** is always private.
|
||||||
|
- A **Reader** contract (`I<Feature>Reader`) is public; its implementation is always private.
|
||||||
- A **Channel descriptor** is public; its **Realtime handler** is always private.
|
- A **Channel descriptor** is public; its **Realtime handler** is always private.
|
||||||
- **Brands** are attached only at **DI bind time**, by **`withSpan` / `withCapture` / `withAudit`**.
|
- **Brands** are attached only at **DI bind time**, by **`withSpan` / `withCapture` / `withAudit`**.
|
||||||
- **Conformance** asserts the **Manifest** and code agree, at five latency tiers.
|
- **Conformance** asserts the **Manifest** and code agree, at five latency tiers.
|
||||||
@@ -416,6 +429,7 @@ The Renovate-triggered re-walk of `evaluate-library` when a runtime dep's major
|
|||||||
|
|
||||||
- **"feature"** — always a vertical feature package; never a CMS-collection field or a generic capability.
|
- **"feature"** — always a vertical feature package; never a CMS-collection field or a generic capability.
|
||||||
- **"service"** — a DI-injected port (e.g. `IAuthenticationService`); not a Kubernetes service, Payload collection, or generic "service object".
|
- **"service"** — a DI-injected port (e.g. `IAuthenticationService`); not a Kubernetes service, Payload collection, or generic "service object".
|
||||||
|
- **"reader"** — always `I<Feature>Reader` (cross-feature synchronous domain query); not a file reader, stream reader, or CQRS read model.
|
||||||
- **"handler"** — qualify by context: **event handler** (cross-feature) | **realtime handler** (inbound socket message) | **task handler** (Payload job).
|
- **"handler"** — qualify by context: **event handler** (cross-feature) | **realtime handler** (inbound socket message) | **task handler** (Payload job).
|
||||||
- **"schema"** — qualify: **Zod schema** (input/output contracts) | **Payload collection schema** (CMS field definitions).
|
- **"schema"** — qualify: **Zod schema** (input/output contracts) | **Payload collection schema** (CMS field definitions).
|
||||||
- **"config"** — qualify: **Payload config** | **Next config** | **Vitest config** | **TS config**.
|
- **"config"** — qualify: **Payload config** | **Next config** | **Vitest config** | **TS config**.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ per-use-case patterns below.
|
|||||||
|
|
||||||
For any new use case, follow these four steps in order:
|
For any new use case, follow these four steps in order:
|
||||||
|
|
||||||
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` arrays.
|
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` / `reads` arrays.
|
||||||
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
|
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
|
||||||
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
|
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
|
||||||
4. **Implementation (green)** — fill the factory body until the tests pass.
|
4. **Implementation (green)** — fill the factory body until the tests pass.
|
||||||
@@ -55,6 +55,7 @@ Every feature package owns:
|
|||||||
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
|
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
|
||||||
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
|
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
|
||||||
| `integrations/cms/` | Payload collection/global configs |
|
| `integrations/cms/` | Payload collection/global configs |
|
||||||
|
| `integrations/readers/` | `I<Feature>Reader` interface + implementation (when feature exposes cross-feature queries) |
|
||||||
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
|
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
|
||||||
| `__factories__/` | Test data factories |
|
| `__factories__/` | Test data factories |
|
||||||
| `__contracts__/` | Contract suites shared by mock and real repository tests |
|
| `__contracts__/` | Contract suites shared by mock and real repository tests |
|
||||||
|
|||||||
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
|
||||||
@@ -21,6 +21,7 @@ export const fooManifest = defineFeature({
|
|||||||
audits: ["thing.created"],
|
audits: ["thing.created"],
|
||||||
publishes: ["foo.thing-created"],
|
publishes: ["foo.thing-created"],
|
||||||
consumes: [],
|
consumes: [],
|
||||||
|
reads: ["auth"], // cross-feature reader dependency
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
realtimeChannels: [],
|
realtimeChannels: [],
|
||||||
@@ -40,6 +41,7 @@ Field reference:
|
|||||||
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
|
| `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>.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>.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 |
|
| `realtimeChannels` | string[] | Realtime channels this feature owns |
|
||||||
| `jobs` | string[] | Job slugs this feature enqueues |
|
| `jobs` | string[] | Job slugs this feature enqueues |
|
||||||
| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` |
|
| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` |
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ pnpm turbo gen event consume # consumer handler + Payload event-task
|
|||||||
pnpm turbo gen job # background job + TaskConfig
|
pnpm turbo gen job # background job + TaskConfig
|
||||||
pnpm turbo gen realtime channel # realtime channel descriptor (ADR-016)
|
pnpm turbo gen realtime channel # realtime channel descriptor (ADR-016)
|
||||||
pnpm turbo gen realtime handler # inbound realtime handler (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 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.
|
||||||
@@ -127,4 +128,5 @@ The realtime generators insert at three additional fixed `// <gen:realtime-*>` a
|
|||||||
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
|
- `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-014-instrumentation-sentry.md` — span + capture wiring
|
||||||
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
|
- `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
|
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers
|
||||||
|
|||||||
69
docs/library-decisions/2026-05-14-@trpc/react-query.md
Normal file
69
docs/library-decisions/2026-05-14-@trpc/react-query.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
package: "@trpc/react-query"
|
||||||
|
version: "^11.0.0"
|
||||||
|
tier: core
|
||||||
|
decision: approved
|
||||||
|
date: 2026-05-14
|
||||||
|
deciders: [scaffolded]
|
||||||
|
adr: null
|
||||||
|
is-sub-processor: false
|
||||||
|
processes-pii: false
|
||||||
|
filter-results:
|
||||||
|
license: MIT
|
||||||
|
types: native
|
||||||
|
maintenance: active
|
||||||
|
boundary-fit: pass
|
||||||
|
shadow-check: pass
|
||||||
|
eu-residency: n/a
|
||||||
|
cve-scan: clean
|
||||||
|
named-consumer: pass
|
||||||
|
socketRisk: skip
|
||||||
|
verification-commands:
|
||||||
|
- pnpm audit --audit-level=moderate
|
||||||
|
- npm view @trpc/react-query license
|
||||||
|
accepted-cves: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter: license
|
||||||
|
|
||||||
|
MIT — on the workspace allowlist.
|
||||||
|
|
||||||
|
## Filter: types
|
||||||
|
|
||||||
|
Ships first-party TypeScript types; deeply integrated with tRPC's type inference.
|
||||||
|
|
||||||
|
## Filter: maintenance
|
||||||
|
|
||||||
|
Active. Maintained by the tRPC team alongside `@trpc/server` and `@trpc/client`.
|
||||||
|
|
||||||
|
## Filter: boundary-fit
|
||||||
|
|
||||||
|
Core package. The React Query integration bridge belongs in `core-trpc` alongside its sibling tRPC packages. No boundary rule violation.
|
||||||
|
|
||||||
|
## Filter: shadow-check
|
||||||
|
|
||||||
|
No other tRPC–React Query bridge in the workspace. No shadow.
|
||||||
|
|
||||||
|
## Filter: eu-residency
|
||||||
|
|
||||||
|
Client-side integration adapter; no vendor data transmission. n/a.
|
||||||
|
|
||||||
|
## Filter: cve-scan
|
||||||
|
|
||||||
|
No advisories at adoption time.
|
||||||
|
|
||||||
|
## Filter: named-consumer
|
||||||
|
|
||||||
|
`core-trpc` re-exports `@trpc/react-query` hooks for use in Next.js feature pages.
|
||||||
|
|
||||||
|
## Prompt: replaces
|
||||||
|
|
||||||
|
Nothing — this is the initial tRPC scaffold.
|
||||||
|
|
||||||
|
## Prompt: migration-cost-out
|
||||||
|
|
||||||
|
Hard: hooks are tRPC-procedure-typed; migrating away requires replacing all call sites.
|
||||||
|
|
||||||
|
## Prompt: alternatives-considered
|
||||||
|
|
||||||
|
This package is the canonical integration point between `@trpc/client` and `@tanstack/react-query`. No viable alternative exists.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
package: "@trpc/tanstack-react-query"
|
||||||
|
version: "^11.1.0"
|
||||||
|
tier: core
|
||||||
|
decision: approved
|
||||||
|
date: 2026-05-14
|
||||||
|
deciders: [scaffolded]
|
||||||
|
adr: null
|
||||||
|
is-sub-processor: false
|
||||||
|
processes-pii: false
|
||||||
|
filter-results:
|
||||||
|
license: MIT
|
||||||
|
types: native
|
||||||
|
maintenance: active
|
||||||
|
boundary-fit: pass
|
||||||
|
shadow-check: pass
|
||||||
|
eu-residency: n/a
|
||||||
|
cve-scan: clean
|
||||||
|
named-consumer: pass
|
||||||
|
socketRisk: skip
|
||||||
|
verification-commands:
|
||||||
|
- pnpm audit --audit-level=moderate
|
||||||
|
- npm view @trpc/tanstack-react-query license
|
||||||
|
accepted-cves: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter: license
|
||||||
|
|
||||||
|
MIT — on the workspace allowlist.
|
||||||
|
|
||||||
|
## Filter: types
|
||||||
|
|
||||||
|
Ships first-party TypeScript types; part of the tRPC v11 adapter suite.
|
||||||
|
|
||||||
|
## Filter: maintenance
|
||||||
|
|
||||||
|
Active. Maintained by the tRPC team as part of the v11 TanStack Start integration.
|
||||||
|
|
||||||
|
## Filter: boundary-fit
|
||||||
|
|
||||||
|
Core package. Required for the TanStack Start provider (`core-trpc/tanstack`). No boundary rule violation.
|
||||||
|
|
||||||
|
## Filter: shadow-check
|
||||||
|
|
||||||
|
No duplicate TanStack adapter in the workspace. No shadow.
|
||||||
|
|
||||||
|
## Filter: eu-residency
|
||||||
|
|
||||||
|
Client-side integration adapter; no vendor data transmission. n/a.
|
||||||
|
|
||||||
|
## Filter: cve-scan
|
||||||
|
|
||||||
|
No advisories at adoption time.
|
||||||
|
|
||||||
|
## Filter: named-consumer
|
||||||
|
|
||||||
|
`core-trpc` exposes a TanStack Start provider via `@trpc/tanstack-react-query` for `apps/web-tanstack`.
|
||||||
|
|
||||||
|
## Prompt: replaces
|
||||||
|
|
||||||
|
Nothing — this is the initial tRPC scaffold.
|
||||||
|
|
||||||
|
## Prompt: migration-cost-out
|
||||||
|
|
||||||
|
Hard: the TanStack provider is shaped around this adapter's API; replacing requires re-implementing the provider.
|
||||||
|
|
||||||
|
## Prompt: alternatives-considered
|
||||||
|
|
||||||
|
This is the official tRPC adapter for TanStack Start. No viable alternative exists.
|
||||||
15
package.json
15
package.json
@@ -7,13 +7,14 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "dotenv -- turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "dotenv -- turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"test:e2e": "turbo run test:e2e",
|
"test:e2e": "turbo run test:e2e",
|
||||||
|
"test:scripts": "vitest run --config vitest.scripts.config.mjs",
|
||||||
"test:stories": "turbo run test:stories",
|
"test:stories": "turbo run test:stories",
|
||||||
"test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test'",
|
"test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test --config ../../playwright.config.ts'",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"conformance": "node scripts/conformance.mjs",
|
"conformance": "node scripts/conformance.mjs",
|
||||||
"coverage:diff": "node scripts/coverage/diff.mjs",
|
"coverage:diff": "node scripts/coverage/diff.mjs",
|
||||||
@@ -32,19 +33,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-hero/sandcastle": "^0.5.10",
|
"@ai-hero/sandcastle": "^0.5.10",
|
||||||
"@typescript-eslint/parser": "^8.25.0",
|
|
||||||
"zod": "^3.25.0",
|
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@stryker-mutator/core": "^8.7.0",
|
"@stryker-mutator/core": "^8.7.0",
|
||||||
"@stryker-mutator/vitest-runner": "^8.7.0",
|
"@stryker-mutator/vitest-runner": "^8.7.0",
|
||||||
"@turbo/gen": "^2.4.0",
|
"@turbo/gen": "^2.4.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"fallow": "^2.73.0",
|
"fallow": "^2.73.0",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"lint-staged": "^16.0.0",
|
"lint-staged": "^16.0.0",
|
||||||
"prettier": "^3.5.0",
|
"prettier": "^3.5.0",
|
||||||
"turbo": "^2.4.0",
|
"turbo": "^2.4.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^3.2.7",
|
||||||
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs,jsx}": [
|
"*.{ts,tsx,js,mjs,jsx}": [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.18.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { it, expect, beforeEach, describe } from "vitest";
|
import { it, expect, beforeEach, describe } from "vitest";
|
||||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface.js";
|
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
|
||||||
import { userFactory } from "../__factories__/user.factory.js";
|
import { userFactory } from "../__factories__/user.factory";
|
||||||
|
|
||||||
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
|
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
|
||||||
"IUsersRepository",
|
"IUsersRepository",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { userFactory } from "./user.factory.js";
|
export { userFactory } from "./user.factory";
|
||||||
export { sessionFactory } from "./session.factory.js";
|
export { sessionFactory } from "./session.factory";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineFactory } from "@repo/core-testing/factory";
|
import { defineFactory } from "@repo/core-testing/factory";
|
||||||
import type { Session } from "../entities/models/session.js";
|
import type { Session } from "../entities/models/session";
|
||||||
|
|
||||||
export const sessionFactory = defineFactory<Session>(({ sequence }) => ({
|
export const sessionFactory = defineFactory<Session>(({ sequence }) => ({
|
||||||
id: `session-${sequence}`,
|
id: `session-${sequence}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineFactory } from "@repo/core-testing/factory";
|
import { defineFactory } from "@repo/core-testing/factory";
|
||||||
import type { User } from "../entities/models/user.js";
|
import type { User } from "../entities/models/user";
|
||||||
|
|
||||||
export const userFactory = defineFactory<User>(({ sequence }) => ({
|
export const userFactory = defineFactory<User>(({ sequence }) => ({
|
||||||
id: `user-${sequence}`,
|
id: `user-${sequence}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { userFactory } from "../__factories__/user.factory.js";
|
import { userFactory } from "../__factories__/user.factory";
|
||||||
import type { User } from "../entities/models/user.js";
|
import type { User } from "../entities/models/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Realistic auth seed for dev mode + storybook stories.
|
* Realistic auth seed for dev mode + storybook stories.
|
||||||
|
|||||||
@@ -11,15 +11,27 @@ import type { IUsersRepository } from "../repositories/users.repository.interfac
|
|||||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||||
|
|
||||||
// ── Input ────────────────────────────────────────────────────────────────
|
// ── Input ────────────────────────────────────────────────────────────────
|
||||||
|
// `.strict()` + no clientIp field: a client submitting clientIp is rejected
|
||||||
|
// at the procedure boundary (audit finding B2).
|
||||||
export const signInInputSchema = z
|
export const signInInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z.string().min(3).max(31),
|
username: z.string().min(3).max(31),
|
||||||
password: z.string().min(6).max(255),
|
password: z.string().min(6).max(255),
|
||||||
clientIp: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
export type SignInInput = z.infer<typeof signInInputSchema>;
|
export type SignInInput = z.infer<typeof signInInputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-derived per-request context, typed OUTSIDE the public input schema
|
||||||
|
* so it can never be client-supplied (audit finding B2). The tRPC adapter
|
||||||
|
* derives `clientIp` from trusted proxy headers and the controller threads
|
||||||
|
* it through; `undefined` means "no proxy header present" and falls into a
|
||||||
|
* shared bucket.
|
||||||
|
*/
|
||||||
|
export type SignInRequestContext = {
|
||||||
|
clientIp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Output ───────────────────────────────────────────────────────────────
|
// ── Output ───────────────────────────────────────────────────────────────
|
||||||
export const signInOutputSchema = z.object({
|
export const signInOutputSchema = z.object({
|
||||||
session: sessionSchema,
|
session: sessionSchema,
|
||||||
@@ -36,7 +48,7 @@ export const signInUseCase =
|
|||||||
authenticationService: IAuthenticationService,
|
authenticationService: IAuthenticationService,
|
||||||
rateLimit: IRateLimit,
|
rateLimit: IRateLimit,
|
||||||
) =>
|
) =>
|
||||||
async (input: SignInInput): Promise<SignInOutput> => {
|
async (input: SignInInput & SignInRequestContext): Promise<SignInOutput> => {
|
||||||
const { allowed: ipAllowed } = await rateLimit.consume(
|
const { allowed: ipAllowed } = await rateLimit.consume(
|
||||||
"ip",
|
"ip",
|
||||||
`signIn:ip:${input.clientIp ?? ""}`,
|
`signIn:ip:${input.clientIp ?? ""}`,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||||
|
import { UnauthenticatedError } from "@/entities/errors/auth";
|
||||||
|
import { userFactory } from "@/__factories__/user.factory";
|
||||||
|
|
||||||
describe("signOutUseCase", () => {
|
describe("signOutUseCase", () => {
|
||||||
it("returns void on successful sign-out", async () => {
|
it("returns void on successful sign-out", async () => {
|
||||||
@@ -12,4 +14,22 @@ describe("signOutUseCase", () => {
|
|||||||
const result = await useCase({ sessionId: "session_1" });
|
const result = await useCase({ sessionId: "session_1" });
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("revokes the session server-side: validateSession rejects it afterwards", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const user = userFactory.build({ username: "alice" });
|
||||||
|
await users.createUser(user);
|
||||||
|
const { session } = await auth.createSession(user);
|
||||||
|
|
||||||
|
// Sanity: session is valid before sign-out.
|
||||||
|
await expect(auth.validateSession(session.id)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await signOutUseCase(auth)({ sessionId: session.id });
|
||||||
|
|
||||||
|
// B5: sign-out must invalidate server-side, not just clear the cookie.
|
||||||
|
await expect(auth.validateSession(session.id)).rejects.toBeInstanceOf(
|
||||||
|
UnauthenticatedError,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,6 +131,43 @@ describe("signUpUseCase", () => {
|
|||||||
expect(result.clearCookie?.attributes.maxAge).toBe(0);
|
expect(result.clearCookie?.attributes.maxAge).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops unknown categories from the client-controlled cookie (A12)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const bus = new RecordingEventBus();
|
||||||
|
const consent = new RecordingConsent();
|
||||||
|
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||||
|
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||||
|
|
||||||
|
await useCase({
|
||||||
|
username: "ivy",
|
||||||
|
password: "secret_password",
|
||||||
|
confirmPassword: "secret_password",
|
||||||
|
cookieHeader: "cc_consent=analytics,evil-made-up,__proto__; session=x",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consent.grants.map((g) => g.category)).toEqual(["analytics"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not migrate consent when every cookie category is unknown (A12)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const bus = new RecordingEventBus();
|
||||||
|
const consent = new RecordingConsent();
|
||||||
|
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||||
|
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||||
|
|
||||||
|
const result = await useCase({
|
||||||
|
username: "jack",
|
||||||
|
password: "secret_password",
|
||||||
|
confirmPassword: "secret_password",
|
||||||
|
cookieHeader: "cc_consent=hax,not-a-category",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consent.grants).toHaveLength(0);
|
||||||
|
expect(result.clearCookie).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not migrate consent when no cc_consent cookie is present", async () => {
|
it("does not migrate consent when no cc_consent cookie is present", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import type { IAuthenticationService } from "../services/authentication.service.
|
|||||||
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
|
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
|
||||||
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
|
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
|
||||||
|
|
||||||
|
// Category allow-list (mirrors KNOWN_CONSENT_CATEGORIES in @repo/core-consent).
|
||||||
|
// The cookie is client-controlled: unknown strings are dropped, never granted
|
||||||
|
// (audit finding A12).
|
||||||
|
const KNOWN_CONSENT_CATEGORIES = [
|
||||||
|
"necessary",
|
||||||
|
"functional",
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
];
|
||||||
|
|
||||||
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
|
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
|
||||||
for (const part of cookieHeader.split(";")) {
|
for (const part of cookieHeader.split(";")) {
|
||||||
const eqIdx = part.indexOf("=");
|
const eqIdx = part.indexOf("=");
|
||||||
@@ -24,7 +34,8 @@ function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
|
|||||||
const cats = value
|
const cats = value
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((c) => c.trim())
|
.map((c) => c.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean)
|
||||||
|
.filter((c) => KNOWN_CONSENT_CATEGORIES.includes(c));
|
||||||
return cats.length > 0 ? cats : null;
|
return cats.length > 0 ? cats : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export class MockUsersRepository implements IUsersRepository {
|
|||||||
{
|
{
|
||||||
name: "users.getUserByUsername",
|
name: "users.getUserByUsername",
|
||||||
op: "repository",
|
op: "repository",
|
||||||
attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username },
|
// Never emit the username (or any slice of it) — it is PII and the
|
||||||
|
// non-email branch used to leak the full username (audit finding B6).
|
||||||
|
attributes: { hasAtSign: username.includes("@") },
|
||||||
},
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
const found = this._users.find((u) => u.username === username);
|
const found = this._users.find((u) => u.username === username);
|
||||||
@@ -60,7 +62,11 @@ export class MockUsersRepository implements IUsersRepository {
|
|||||||
|
|
||||||
async createUser(input: User): Promise<User> {
|
async createUser(input: User): Promise<User> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "users.createUser", op: "repository", attributes: { id: input.id } },
|
{
|
||||||
|
name: "users.createUser",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { id: input.id },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
this._users.push(input);
|
this._users.push(input);
|
||||||
span.setAttribute("created", true);
|
span.setAttribute("created", true);
|
||||||
|
|||||||
@@ -21,17 +21,31 @@ describe("MockUsersRepository emits spans", () => {
|
|||||||
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getUserByUsername emits a span with emailDomain attribute", async () => {
|
it("getUserByUsername emits a span without any username-derived PII", async () => {
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
const repo = new MockUsersRepository(
|
const repo = new MockUsersRepository(
|
||||||
[{ id: "1", username: "alice", passwordHash: "hash" }],
|
[{ id: "1", username: "alice", passwordHash: "hash" }],
|
||||||
tracer,
|
tracer,
|
||||||
);
|
);
|
||||||
await repo.getUserByUsername("alice");
|
await repo.getUserByUsername("alice");
|
||||||
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
|
const span = tracer.findSpan("users.getUserByUsername");
|
||||||
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(
|
expect(span).toBeDefined();
|
||||||
true,
|
expect(span!.attributes.found).toBe(true);
|
||||||
);
|
// B6 regression guard: the old emailDomain attribute leaked the full
|
||||||
|
// username when it contained no "@". Only a boolean may be emitted.
|
||||||
|
expect(span!.attributes.emailDomain).toBeUndefined();
|
||||||
|
expect(span!.attributes.hasAtSign).toBe(false);
|
||||||
|
expect(Object.values(span!.attributes)).not.toContain("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getUserByUsername with an email-shaped username emits only the boolean", async () => {
|
||||||
|
const tracer = new RecordingTracer();
|
||||||
|
const repo = new MockUsersRepository([], tracer);
|
||||||
|
await repo.getUserByUsername("alice@example.com");
|
||||||
|
const span = tracer.findSpan("users.getUserByUsername");
|
||||||
|
expect(span!.attributes.hasAtSign).toBe(true);
|
||||||
|
expect(span!.attributes.emailDomain).toBeUndefined();
|
||||||
|
expect(Object.values(span!.attributes)).not.toContain("example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createUser records created=true", async () => {
|
it("createUser records created=true", async () => {
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import { type User } from "../../entities/models/user";
|
|||||||
const FEATURE = "auth" as const;
|
const FEATURE = "auth" as const;
|
||||||
const REPO = "users" as const;
|
const REPO = "users" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every users-collection field this repository reads or writes, besides the
|
||||||
|
* implicit `id`. Pinned against the collection config by
|
||||||
|
* `integrations/cms/collections/users.test.ts` so repo <-> collection drift
|
||||||
|
* fails at test time without a database (audit finding B1).
|
||||||
|
*/
|
||||||
|
export const USERS_REPOSITORY_FIELDS = ["username", "passwordHash"] as const;
|
||||||
|
|
||||||
export class UsersRepository implements IUsersRepository {
|
export class UsersRepository implements IUsersRepository {
|
||||||
private config: SanitizedConfig;
|
private config: SanitizedConfig;
|
||||||
private tracer: ITracer;
|
private tracer: ITracer;
|
||||||
@@ -40,7 +48,9 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
});
|
});
|
||||||
const found = Boolean(result);
|
const found = Boolean(result);
|
||||||
span.setAttribute("found", found);
|
span.setAttribute("found", found);
|
||||||
return result ? this.toDomain(result as Record<string, unknown>) : undefined;
|
return result
|
||||||
|
? this.toDomain(result as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err &&
|
err &&
|
||||||
@@ -54,7 +64,10 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getUser" },
|
tags: { feature: FEATURE, repo: REPO, method: "getUser" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +79,9 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
{
|
{
|
||||||
name: "users.getUserByUsername",
|
name: "users.getUserByUsername",
|
||||||
op: "repository",
|
op: "repository",
|
||||||
attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username },
|
// Never emit the username (or any slice of it) — it is PII and the
|
||||||
|
// non-email branch used to leak the full username (audit finding B6).
|
||||||
|
attributes: { hasAtSign: username.includes("@") },
|
||||||
},
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
@@ -79,12 +94,17 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
});
|
});
|
||||||
const doc = docs[0];
|
const doc = docs[0];
|
||||||
span.setAttribute("found", Boolean(doc));
|
span.setAttribute("found", Boolean(doc));
|
||||||
return doc ? this.toDomain(doc as Record<string, unknown>) : undefined;
|
return doc
|
||||||
|
? this.toDomain(doc as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getUserByUsername" },
|
tags: { feature: FEATURE, repo: REPO, method: "getUserByUsername" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,7 +113,11 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
|
|
||||||
async createUser(input: User): Promise<User> {
|
async createUser(input: User): Promise<User> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "users.createUser", op: "repository", attributes: { id: input.id } },
|
{
|
||||||
|
name: "users.createUser",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { id: input.id },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
@@ -112,7 +136,10 @@ export class UsersRepository implements IUsersRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "createUser" },
|
tags: { feature: FEATURE, repo: REPO, method: "createUser" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import crypto from "node:crypto";
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
|
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
|
||||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||||
|
|
||||||
|
// The session methods only need `payload.secret` + `payload.findByID`, so a
|
||||||
|
// module-level stub covers the pure crypto paths without booting Payload.
|
||||||
|
const payloadStub = vi.hoisted(() => ({
|
||||||
|
secret: "test-secret",
|
||||||
|
findByID: vi.fn(
|
||||||
|
async ({ id }: { collection: string; id: string }): Promise<unknown> => ({
|
||||||
|
id,
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "stored-hash",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("payload", () => ({
|
||||||
|
getPayload: vi.fn(async () => payloadStub),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Craft a HS256 JWT directly so tests can control every claim (B8). */
|
||||||
|
function craftToken(payload: Record<string, unknown>, secret: string): string {
|
||||||
|
const header = Buffer.from(
|
||||||
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||||
|
).toString("base64url");
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
describe("AuthenticationService", () => {
|
describe("AuthenticationService", () => {
|
||||||
const service = new AuthenticationService(stubPayloadConfig);
|
const service = new AuthenticationService(stubPayloadConfig);
|
||||||
|
|
||||||
@@ -34,28 +65,145 @@ describe("AuthenticationService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns false for malformed stored hash", async () => {
|
it("returns false for malformed stored hash", async () => {
|
||||||
const valid = await service.verifyPassword("not-a-valid-hash", "anything");
|
const valid = await service.verifyPassword(
|
||||||
|
"not-a-valid-hash",
|
||||||
|
"anything",
|
||||||
|
);
|
||||||
expect(valid).toBe(false);
|
expect(valid).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deferred methods (NotImplementedError)", () => {
|
describe("session methods", () => {
|
||||||
const user = {
|
// getPayload is mocked module-wide (secret + findByID only), so these
|
||||||
id: "test-id",
|
// exercise the real signToken/verifyToken/validateSession crypto paths
|
||||||
username: "testuser",
|
// without a running Payload instance (audit finding B8).
|
||||||
passwordHash: "hashed_password",
|
const user = { id: "u1", username: "alice", passwordHash: "stored-hash" };
|
||||||
};
|
|
||||||
|
|
||||||
it("createSession throws NotImplementedError", async () => {
|
afterEach(() => {
|
||||||
await expect(service.createSession(user)).rejects.toThrow("NotImplemented");
|
vi.useRealTimers();
|
||||||
|
payloadStub.secret = "test-secret";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validateSession throws NotImplementedError", async () => {
|
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
||||||
await expect(service.validateSession("some-session")).rejects.toThrow("NotImplemented");
|
const { blankCookie } = await service.invalidateSession("any-token");
|
||||||
|
expect(blankCookie.name).toBe("payload-token");
|
||||||
|
expect(blankCookie.value).toBe("");
|
||||||
|
expect(blankCookie.attributes.maxAge).toBe(0);
|
||||||
|
expect(blankCookie.attributes.httpOnly).toBe(true);
|
||||||
|
expect(blankCookie.attributes.path).toBe("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidateSession throws NotImplementedError", async () => {
|
it("createSession then validateSession round-trips (jti = session.id)", async () => {
|
||||||
await expect(service.invalidateSession("some-session")).rejects.toThrow("NotImplemented");
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svc.createSession(user);
|
||||||
|
const validated = await svc.validateSession(cookie.value);
|
||||||
|
expect(validated.user.id).toBe("u1");
|
||||||
|
expect(validated.session.userId).toBe("u1");
|
||||||
|
// The session id is the JWT jti, minted once at createSession (B5).
|
||||||
|
expect(validated.session.id).toBe(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token with a tampered signature", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
const [header, body] = cookie.value.split(".");
|
||||||
|
const forged = `${header}.${body}.${Buffer.from("forged-signature").toString("base64url")}`;
|
||||||
|
await expect(svc.validateSession(forged)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token whose payload was swapped after signing", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
const [header, , signature] = cookie.value.split(".");
|
||||||
|
const swappedBody = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "attacker",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 9999,
|
||||||
|
jti: "attacker-jti",
|
||||||
|
}),
|
||||||
|
).toString("base64url");
|
||||||
|
await expect(
|
||||||
|
svc.validateSession(`${header}.${swappedBody}.${signature}`),
|
||||||
|
).rejects.toThrow(/invalid or expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired token", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
vi.advanceTimersByTime(3 * 60 * 60 * 1000); // 3h > 2h session duration
|
||||||
|
await expect(svc.validateSession(cookie.value)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["empty string", ""],
|
||||||
|
["one segment", "not-a-jwt"],
|
||||||
|
["two segments", "aaaa.bbbb"],
|
||||||
|
["four segments", "a.b.c.d"],
|
||||||
|
["garbage segments", "!!.??.%%"],
|
||||||
|
])("rejects a malformed token (%s)", async (_label, token) => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token signed with the wrong secret", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const token = craftToken(
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 600,
|
||||||
|
jti: "jti-1",
|
||||||
|
},
|
||||||
|
"some-other-secret",
|
||||||
|
);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a correctly signed token without a jti (fail closed)", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const token = craftToken(
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 600,
|
||||||
|
},
|
||||||
|
"test-secret",
|
||||||
|
);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a valid token after its session is invalidated (B5)", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svc.createSession(user);
|
||||||
|
// Sanity: valid before revocation.
|
||||||
|
await expect(svc.validateSession(cookie.value)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await svc.invalidateSession(session.id);
|
||||||
|
await expect(svc.validateSession(cookie.value)).rejects.toThrow(
|
||||||
|
/revoked/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revocation is per-service-instance (documented single-process limit)", async () => {
|
||||||
|
const svcA = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const svcB = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svcA.createSession(user);
|
||||||
|
await svcA.invalidateSession(session.id);
|
||||||
|
// A separate instance (≈ another process) still accepts the token —
|
||||||
|
// this pins the documented in-memory denylist limitation.
|
||||||
|
await expect(svcB.validateSession(cookie.value)).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { SanitizedConfig } from "payload";
|
import { getPayload, type SanitizedConfig } from "payload";
|
||||||
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
|
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
|
||||||
import type { Cookie } from "../../entities/models/cookie";
|
import type { Cookie } from "../../entities/models/cookie";
|
||||||
import type { Session } from "../../entities/models/session";
|
import type { Session } from "../../entities/models/session";
|
||||||
import type { User } from "../../entities/models/user";
|
import type { User } from "../../entities/models/user";
|
||||||
|
import { InMemorySessionDenylist } from "./session-denylist";
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Deferred methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// `createSession`, `validateSession`, and `invalidateSession` require Payload's
|
|
||||||
// internal JWT-based auth session machinery, which does not map cleanly to a
|
|
||||||
// generic session interface without deep integration with Payload's REST/local
|
|
||||||
// API and cookie infrastructure.
|
|
||||||
//
|
|
||||||
// TODO: Implement these three methods once the session
|
|
||||||
// cookie strategy is settled. Until then they throw NotImplementedError to
|
|
||||||
// keep the production-shaped file in place without silently no-oping.
|
|
||||||
//
|
|
||||||
// The mock (`authentication.service.mock.ts`) handles all test paths.
|
|
||||||
|
|
||||||
class NotImplementedError extends Error {
|
|
||||||
constructor(method: string) {
|
|
||||||
super(`NotImplemented: AuthenticationService.${method}`);
|
|
||||||
this.name = "NotImplementedError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SALT_LENGTH = 16;
|
const SALT_LENGTH = 16;
|
||||||
const KEY_LENGTH = 64;
|
const KEY_LENGTH = 64;
|
||||||
@@ -32,8 +12,16 @@ const ITERATIONS = 100_000;
|
|||||||
const DIGEST = "sha512";
|
const DIGEST = "sha512";
|
||||||
const SEPARATOR = ":";
|
const SEPARATOR = ":";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "payload-token";
|
||||||
|
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
||||||
|
|
||||||
export class AuthenticationService implements IAuthenticationService {
|
export class AuthenticationService implements IAuthenticationService {
|
||||||
constructor(private _config: SanitizedConfig) {}
|
constructor(
|
||||||
|
private config: SanitizedConfig,
|
||||||
|
// Server-side revocation (audit finding B5). In-memory: revocations are
|
||||||
|
// per-process — see session-denylist.ts for the limitation write-up.
|
||||||
|
private denylist: InMemorySessionDenylist = new InMemorySessionDenylist(),
|
||||||
|
) {}
|
||||||
|
|
||||||
generateUserId(): string {
|
generateUserId(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -81,30 +69,144 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement using Payload's local.login / JWT session issuance.
|
|
||||||
// Payload creates sessions via its REST auth endpoint; mapping that to a
|
|
||||||
// generic { session: Session; cookie: Cookie } shape requires understanding
|
|
||||||
// the JWT payload structure and the cookie name/attributes Payload uses.
|
|
||||||
async createSession(
|
async createSession(
|
||||||
_user: User,
|
user: User,
|
||||||
): Promise<{ session: Session; cookie: Cookie }> {
|
): Promise<{ session: Session; cookie: Cookie }> {
|
||||||
throw new NotImplementedError("createSession");
|
const payload = await getPayload({ config: this.config });
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
||||||
|
|
||||||
|
// The session id doubles as the JWT `jti` so the token can be revoked
|
||||||
|
// server-side via the denylist (audit finding B5).
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
const token = this.signToken(user.id, sessionId, payload.secret);
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
id: sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
const cookie: Cookie = {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: token,
|
||||||
|
attributes: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: SESSION_DURATION_SECONDS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { session, cookie };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement using Payload's JWT verify mechanism.
|
|
||||||
// Need to call Payload's local API to verify the token and retrieve the user.
|
|
||||||
async validateSession(
|
async validateSession(
|
||||||
_sessionId: string,
|
token: string,
|
||||||
): Promise<{ user: User; session: Session }> {
|
): Promise<{ user: User; session: Session }> {
|
||||||
throw new NotImplementedError("validateSession");
|
const payload = await getPayload({ config: this.config });
|
||||||
|
const decoded = this.verifyToken(token, payload.secret);
|
||||||
|
if (!decoded) throw new Error("Invalid or expired session token");
|
||||||
|
// Server-side revocation check (audit finding B5): a signed, unexpired
|
||||||
|
// token is still rejected once its jti has been invalidated.
|
||||||
|
if (this.denylist.isRevoked(decoded.jti)) {
|
||||||
|
throw new Error("Session has been revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement by clearing the session token.
|
const userDoc = await payload.findByID({
|
||||||
// Payload does not have a server-side session store by default; invalidation
|
collection: "users" as const,
|
||||||
// is typically done client-side by clearing the cookie.
|
id: decoded.id,
|
||||||
async invalidateSession(
|
overrideAccess: true,
|
||||||
_sessionId: string,
|
});
|
||||||
): Promise<{ blankCookie: Cookie }> {
|
|
||||||
throw new NotImplementedError("invalidateSession");
|
const user: User = {
|
||||||
|
id: userDoc.id as string,
|
||||||
|
username: (userDoc as Record<string, unknown>).username as string,
|
||||||
|
passwordHash: (userDoc as Record<string, unknown>).passwordHash as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
id: decoded.jti,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: new Date(decoded.exp * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { user, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
|
||||||
|
// `sessionId` is the JWT `jti` (the `session.id` returned by
|
||||||
|
// createSession/validateSession). Denylist it for the maximum token
|
||||||
|
// lifetime — beyond that, the token's own `exp` rejects it (B5).
|
||||||
|
this.denylist.revoke(sessionId, SESSION_DURATION_SECONDS);
|
||||||
|
return {
|
||||||
|
blankCookie: {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: "",
|
||||||
|
attributes: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
||||||
|
private signToken(userId: string, jti: string, secret: string): string {
|
||||||
|
const header = Buffer.from(
|
||||||
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||||
|
).toString("base64url");
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ id: userId, collection: "users", exp, jti }),
|
||||||
|
).toString("base64url");
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */
|
||||||
|
private verifyToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): { id: string; exp: number; jti: string } | null {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const [header, body, signature] = parts as [string, string, string];
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest();
|
||||||
|
const provided = Buffer.from(signature, "base64url");
|
||||||
|
// Constant-time comparison, mirroring verifyPassword (audit finding B4).
|
||||||
|
// timingSafeEqual requires equal-length buffers; a length mismatch is
|
||||||
|
// already an invalid signature, and the guard leaks nothing an attacker
|
||||||
|
// does not know (the expected HMAC-SHA256 length is public).
|
||||||
|
if (provided.length !== expected.length) return null;
|
||||||
|
if (!crypto.timingSafeEqual(provided, expected)) return null;
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
||||||
|
id?: unknown;
|
||||||
|
exp?: unknown;
|
||||||
|
jti?: unknown;
|
||||||
|
};
|
||||||
|
// Fail closed: tokens without a jti cannot be revoked, so they are
|
||||||
|
// not accepted (audit finding B5).
|
||||||
|
if (
|
||||||
|
typeof decoded.id !== "string" ||
|
||||||
|
typeof decoded.exp !== "number" ||
|
||||||
|
typeof decoded.jti !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return { id: decoded.id, exp: decoded.exp, jti: decoded.jti };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { InMemorySessionDenylist } from "@/infrastructure/services/session-denylist";
|
||||||
|
|
||||||
|
describe("InMemorySessionDenylist", () => {
|
||||||
|
it("reports a revoked jti as revoked", () => {
|
||||||
|
const denylist = new InMemorySessionDenylist();
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report unknown jtis as revoked", () => {
|
||||||
|
const denylist = new InMemorySessionDenylist();
|
||||||
|
expect(denylist.isRevoked("never-seen")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes entries after their ttl elapses", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
|
||||||
|
now += 60_000; // exactly at expiry — entry is prunable
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps entries alive until the ttl elapses", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
now += 59_999;
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes expired entries on revoke, not just on reads", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("old", 1);
|
||||||
|
now += 5_000;
|
||||||
|
denylist.revoke("new", 60);
|
||||||
|
// Reach into nothing — observable via isRevoked semantics only.
|
||||||
|
expect(denylist.isRevoked("old")).toBe(false);
|
||||||
|
expect(denylist.isRevoked("new")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* In-memory JWT `jti` denylist backing server-side session revocation
|
||||||
|
* (audit finding B5).
|
||||||
|
*
|
||||||
|
* `AuthenticationService.createSession` mints a session id and embeds it in
|
||||||
|
* the JWT as `jti`; `invalidateSession(jti)` records it here and
|
||||||
|
* `validateSession` rejects any token whose `jti` is denylisted. Entries
|
||||||
|
* expire with the token they revoke (max session lifetime), so the map is
|
||||||
|
* self-pruning and cannot grow past the number of sign-outs per lifetime
|
||||||
|
* window.
|
||||||
|
*
|
||||||
|
* SINGLE-PROCESS LIMITATION: this denylist lives in process memory. It is
|
||||||
|
* correct for a single server process (the template's deployment shape) but
|
||||||
|
* revocations are NOT shared across processes/instances and do not survive
|
||||||
|
* restarts — a restarted process accepts a signed, unexpired token again.
|
||||||
|
* Multi-instance deployments must swap this for a shared store (Redis, DB)
|
||||||
|
* behind the same two methods.
|
||||||
|
*/
|
||||||
|
export class InMemorySessionDenylist {
|
||||||
|
/** jti -> epoch-ms after which the entry may be pruned. */
|
||||||
|
private readonly revoked = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(private readonly clock: () => number = () => Date.now()) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a revoked `jti`. `ttlSeconds` should be the maximum remaining
|
||||||
|
* token lifetime — after that, the token's own `exp` rejects it anyway.
|
||||||
|
*/
|
||||||
|
revoke(jti: string, ttlSeconds: number): void {
|
||||||
|
this.prune();
|
||||||
|
this.revoked.set(jti, this.clock() + ttlSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRevoked(jti: string): boolean {
|
||||||
|
this.prune();
|
||||||
|
return this.revoked.has(jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expiry-based pruning — runs on every access; the map stays small. */
|
||||||
|
private prune(): void {
|
||||||
|
const now = this.clock();
|
||||||
|
for (const [jti, expiresAt] of this.revoked) {
|
||||||
|
if (expiresAt <= now) this.revoked.delete(jti);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { authRouter } from "@/integrations/api/router";
|
import { authRouter } from "@/integrations/api/router";
|
||||||
@@ -27,6 +27,45 @@ describe("authRouter", () => {
|
|||||||
});
|
});
|
||||||
expect(result.name).toBe("session");
|
expect(result.name).toBe("session");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a client-supplied clientIp at the procedure boundary (B2)", async () => {
|
||||||
|
const caller = authRouter.createCaller({});
|
||||||
|
try {
|
||||||
|
await caller.signIn({
|
||||||
|
username: "alice",
|
||||||
|
password: "password_alice",
|
||||||
|
clientIp: "6.6.6.6",
|
||||||
|
} as never);
|
||||||
|
throw new Error("expected throw");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(TRPCError);
|
||||||
|
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads ctx.clientIp (server-derived) into the controller (B2)", async () => {
|
||||||
|
const original = authContainer.get(AUTH_SYMBOLS.ISignInController);
|
||||||
|
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
|
||||||
|
const spy = vi.fn(async () => ({
|
||||||
|
name: "session",
|
||||||
|
value: "tok",
|
||||||
|
attributes: {},
|
||||||
|
}));
|
||||||
|
authContainer.bind(AUTH_SYMBOLS.ISignInController).toConstantValue(spy);
|
||||||
|
try {
|
||||||
|
const caller = authRouter.createCaller({ clientIp: "203.0.113.7" });
|
||||||
|
await caller.signIn({ username: "alice", password: "password_alice" });
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
{ username: "alice", password: "password_alice" },
|
||||||
|
{ clientIp: "203.0.113.7" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignInController)
|
||||||
|
.toConstantValue(original);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authRouter error mapping", () => {
|
describe("authRouter error mapping", () => {
|
||||||
|
|||||||
@@ -14,18 +14,29 @@ import type { ISignOutController } from "../../interface-adapters/controllers/si
|
|||||||
import { authProcedure } from "./procedures";
|
import { authProcedure } from "./procedures";
|
||||||
|
|
||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
|
signIn: authProcedure.input(signInInputSchema).mutation(({ input, ctx }) => {
|
||||||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
const ctrl = authContainer.get<ISignInController>(
|
||||||
return ctrl(input);
|
AUTH_SYMBOLS.ISignInController,
|
||||||
|
);
|
||||||
|
// clientIp is derived server-side by the adapter's createContext (from
|
||||||
|
// trusted proxy headers) — never from the client payload; the strict
|
||||||
|
// input schema rejects a client-supplied clientIp outright (B2). Same
|
||||||
|
// ctx-cast pattern as the dsr router until the shared t is context-typed.
|
||||||
|
const { clientIp } = ctx as { clientIp?: string };
|
||||||
|
return ctrl(input, { clientIp });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
||||||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
const ctrl = authContainer.get<ISignUpController>(
|
||||||
|
AUTH_SYMBOLS.ISignUpController,
|
||||||
|
);
|
||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
||||||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
const ctrl = authContainer.get<ISignOutController>(
|
||||||
|
AUTH_SYMBOLS.ISignOutController,
|
||||||
|
);
|
||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
59
packages/auth/src/integrations/cms/collections/users.test.ts
Normal file
59
packages/auth/src/integrations/cms/collections/users.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { users } from "@/integrations/cms/collections/users";
|
||||||
|
import { USERS_REPOSITORY_FIELDS } from "@/infrastructure/repositories/users.repository";
|
||||||
|
|
||||||
|
type NamedField = {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
unique?: boolean;
|
||||||
|
index?: boolean;
|
||||||
|
admin?: { hidden?: boolean };
|
||||||
|
access?: { read?: (args: unknown) => boolean | Promise<boolean> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldByName(name: string): NamedField | undefined {
|
||||||
|
return (users.fields as NamedField[]).find((f) => f.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract-shaped drift guard (audit finding B1): the production
|
||||||
|
// UsersRepository reads/writes these fields via the Payload local API, so the
|
||||||
|
// collection config must declare every one of them. No database needed —
|
||||||
|
// we parse the collection object directly.
|
||||||
|
describe("users collection <-> UsersRepository field contract", () => {
|
||||||
|
it.each([...USERS_REPOSITORY_FIELDS])(
|
||||||
|
"declares the '%s' field the repository reads/writes",
|
||||||
|
(name) => {
|
||||||
|
expect(fieldByName(name)).toBeDefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("username is a required, unique, indexed text field", () => {
|
||||||
|
const username = fieldByName("username");
|
||||||
|
expect(username).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passwordHash is required and hidden in the admin UI", () => {
|
||||||
|
const passwordHash = fieldByName("passwordHash");
|
||||||
|
expect(passwordHash).toBeDefined();
|
||||||
|
expect(passwordHash!.type).toBe("text");
|
||||||
|
expect(passwordHash!.required).toBe(true);
|
||||||
|
expect(passwordHash!.admin?.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passwordHash is never readable through the Payload API", async () => {
|
||||||
|
const passwordHash = fieldByName("passwordHash");
|
||||||
|
expect(passwordHash!.access?.read).toBeTypeOf("function");
|
||||||
|
// Field-level read access must deny unconditionally — even for admins —
|
||||||
|
// so the hash never serializes into REST/GraphQL/admin responses. The
|
||||||
|
// repository bypasses this via the local API's overrideAccess: true.
|
||||||
|
await expect(
|
||||||
|
Promise.resolve(passwordHash!.access!.read!({ req: {} })),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,8 +16,62 @@ export const users: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
subject: { kind: "self", field: "id" },
|
subject: { kind: "self", field: "id" },
|
||||||
|
// Collection-level PII map consumed by the DSR walkers (audit finding
|
||||||
|
// A5): export includes fields marked exportable; the soft-delete path
|
||||||
|
// redacts them. `email` is auto-added by Payload's `auth: true` and has
|
||||||
|
// no explicit field entry below, so it MUST be declared here or Art. 15
|
||||||
|
// export misses it and Art. 17 soft delete leaves it behind.
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication", "transactional-notifications"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
// Read/written by the production UsersRepository (getUserByUsername,
|
||||||
|
// createUser). Pinned by collections/users.test.ts against
|
||||||
|
// USERS_REPOSITORY_FIELDS so repo <-> collection drift fails fast.
|
||||||
|
name: "username",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
custom: {
|
||||||
|
pii: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Credential material — must never leave the server. `access.read`
|
||||||
|
// returns false unconditionally so the field is stripped from every
|
||||||
|
// REST/GraphQL/admin API response; the auth repository still reads it
|
||||||
|
// through the local API with `overrideAccess: true`.
|
||||||
|
name: "passwordHash",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
admin: { hidden: true },
|
||||||
|
access: { read: () => false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "displayName",
|
name: "displayName",
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MockAuthenticationService } from "@/infrastructure/services/authenticat
|
|||||||
import { InputParseError } from "@/entities/errors/common";
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
import { userFactory } from "@/__factories__/user.factory";
|
import { userFactory } from "@/__factories__/user.factory";
|
||||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||||
|
import { RecordingRateLimit } from "@repo/core-testing/rate-limit";
|
||||||
|
|
||||||
describe("signInController", () => {
|
describe("signInController", () => {
|
||||||
it("returns a cookie on successful sign-in", async () => {
|
it("returns a cookie on successful sign-in", async () => {
|
||||||
@@ -28,6 +29,45 @@ describe("signInController", () => {
|
|||||||
expect(result.value).toBeTruthy();
|
expect(result.value).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("threads the server-derived clientIp into the use case (B2)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const rl = new RecordingRateLimit();
|
||||||
|
await users.createUser(
|
||||||
|
userFactory.build({
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "hashed_testpassword",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const controller = signInController(signInUseCase(users, auth, rl));
|
||||||
|
await controller(
|
||||||
|
{ username: "alice", password: "testpassword" },
|
||||||
|
{ clientIp: "203.0.113.7" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rl.consumeCalls[0]).toMatchObject({
|
||||||
|
budgetName: "ip",
|
||||||
|
key: "signIn:ip:203.0.113.7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects clientIp inside the client payload (strict schema, B2)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const controller = signInController(
|
||||||
|
signInUseCase(users, auth, new NoopRateLimit()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({
|
||||||
|
username: "alice",
|
||||||
|
password: "testpassword",
|
||||||
|
clientIp: "6.6.6.6",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws InputParseError on invalid input", async () => {
|
it("throws InputParseError on invalid input", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
signInInputSchema,
|
signInInputSchema,
|
||||||
type ISignInUseCase,
|
type ISignInUseCase,
|
||||||
type SignInOutput,
|
type SignInOutput,
|
||||||
|
type SignInRequestContext,
|
||||||
} from "../../application/use-cases/sign-in.use-case";
|
} from "../../application/use-cases/sign-in.use-case";
|
||||||
|
|
||||||
function presenter(value: SignInOutput) {
|
function presenter(value: SignInOutput) {
|
||||||
@@ -13,11 +14,21 @@ export type ISignInController = ReturnType<typeof signInController>;
|
|||||||
|
|
||||||
export const signInController =
|
export const signInController =
|
||||||
(signInUseCase: ISignInUseCase) =>
|
(signInUseCase: ISignInUseCase) =>
|
||||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
async (
|
||||||
|
input: unknown,
|
||||||
|
// Server-derived, never part of the client-facing input schema (B2):
|
||||||
|
// the tRPC adapter builds it from trusted proxy headers.
|
||||||
|
requestContext?: SignInRequestContext,
|
||||||
|
): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = signInInputSchema.safeParse(input);
|
const parsed = signInInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
throw new InputParseError("Invalid sign-in input", {
|
||||||
|
cause: parsed.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const result = await signInUseCase(parsed.data);
|
const result = await signInUseCase({
|
||||||
|
...parsed.data,
|
||||||
|
clientIp: requestContext?.clientIp,
|
||||||
|
});
|
||||||
return presenter(result);
|
return presenter(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// React Query option builders for auth feature procedures.
|
|
||||||
// Sign-in/up/out are mutations — no query options needed.
|
|
||||||
// This file is intentionally minimal; expand if read procedures get added.
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -19,9 +19,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@repo/core-trpc": "workspace:^",
|
||||||
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@trpc/client": "^11.18.0",
|
||||||
|
"@trpc/server": "^11.18.0",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
@@ -30,7 +34,8 @@
|
|||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@types/react": "^19.0.0",
|
||||||
"vitest": "^3.1.0"
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { it, expect, beforeEach, describe } from "vitest";
|
import { it, expect, beforeEach, describe } from "vitest";
|
||||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface.js";
|
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||||
import { articleFactory } from "../__factories__/article.factory.js";
|
import { articleFactory } from "../__factories__/article.factory";
|
||||||
|
|
||||||
export const articlesRepositoryContract =
|
export const articlesRepositoryContract =
|
||||||
defineContractSuite<IArticlesRepository>(
|
defineContractSuite<IArticlesRepository>(
|
||||||
@@ -94,6 +94,46 @@ export const articlesRepositoryContract =
|
|||||||
expect(result[0]?.authorId).toBe("author-a");
|
expect(result[0]?.authorId).toBe("author-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getArticles returns the exact window for an aligned offset", async () => {
|
||||||
|
const created = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
created.push(await repo.createArticle(articleFactory.build()));
|
||||||
|
}
|
||||||
|
const window = await repo.getArticles({ offset: 2, limit: 2 });
|
||||||
|
expect(window.map((a) => a.id)).toEqual([
|
||||||
|
created[2]?.id,
|
||||||
|
created[3]?.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getArticles returns the exact window for a non-aligned offset", async () => {
|
||||||
|
const created = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
created.push(await repo.createArticle(articleFactory.build()));
|
||||||
|
}
|
||||||
|
// offset 3 with limit 2 straddles two limit-sized pages
|
||||||
|
const window = await repo.getArticles({ offset: 3, limit: 2 });
|
||||||
|
expect(window.map((a) => a.id)).toEqual([
|
||||||
|
created[3]?.id,
|
||||||
|
created[4]?.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getArticles non-aligned offset near the end returns only the remaining items", async () => {
|
||||||
|
const created = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
created.push(await repo.createArticle(articleFactory.build()));
|
||||||
|
}
|
||||||
|
const window = await repo.getArticles({ offset: 4, limit: 3 });
|
||||||
|
expect(window.map((a) => a.id)).toEqual([created[4]?.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getArticles offset past the end returns an empty array", async () => {
|
||||||
|
await repo.createArticle(articleFactory.build());
|
||||||
|
const window = await repo.getArticles({ offset: 7, limit: 3 });
|
||||||
|
expect(window).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// --- updateArticle ---
|
// --- updateArticle ---
|
||||||
|
|
||||||
it("updateArticle changes fields and returns updated article", async () => {
|
it("updateArticle changes fields and returns updated article", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineFactory } from "@repo/core-testing/factory";
|
import { defineFactory } from "@repo/core-testing/factory";
|
||||||
import type { Article } from "../entities/models/article.js";
|
import type { Article } from "../entities/models/article";
|
||||||
|
|
||||||
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
|
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
|
||||||
id: `article-${sequence}`,
|
id: `article-${sequence}`,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { articleFactory } from "./article.factory.js";
|
export { articleFactory } from "./article.factory";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { articleFactory } from "../__factories__/article.factory.js";
|
import { articleFactory } from "../__factories__/article.factory";
|
||||||
import type { Article } from "../entities/models/article.js";
|
import type { Article } from "../entities/models/article";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Realistic blog seed for dev mode + storybook stories.
|
* Realistic blog seed for dev mode + storybook stories.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function buildPayloadStub() {
|
|||||||
async ({
|
async ({
|
||||||
where,
|
where,
|
||||||
limit,
|
limit,
|
||||||
|
page,
|
||||||
}: {
|
}: {
|
||||||
collection: string;
|
collection: string;
|
||||||
where?: {
|
where?: {
|
||||||
@@ -54,17 +55,27 @@ function buildPayloadStub() {
|
|||||||
if (where?.author) {
|
if (where?.author) {
|
||||||
docs = docs.filter((d) => d.author === where.author?.equals);
|
docs = docs.filter((d) => d.author === where.author?.equals);
|
||||||
}
|
}
|
||||||
if (limit !== undefined) {
|
// Mirror real Payload pagination: page-based windows of size `limit`.
|
||||||
docs = docs.slice(0, limit);
|
const lim = limit ?? 50;
|
||||||
}
|
const pg = page ?? 1;
|
||||||
|
const start = (pg - 1) * lim;
|
||||||
|
docs = docs.slice(start, start + lim);
|
||||||
return { docs };
|
return { docs };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
findByID: vi.fn(
|
findByID: vi.fn(
|
||||||
async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => {
|
async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
collection: string;
|
||||||
|
id: string;
|
||||||
|
overrideAccess?: boolean;
|
||||||
|
}) => {
|
||||||
const doc = store.get(String(id));
|
const doc = store.get(String(id));
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 });
|
const err = Object.assign(new Error(`Not found: ${id}`), {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return doc;
|
return doc;
|
||||||
@@ -82,7 +93,9 @@ function buildPayloadStub() {
|
|||||||
}) => {
|
}) => {
|
||||||
const existing = store.get(String(id));
|
const existing = store.get(String(id));
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 });
|
const err = Object.assign(new Error(`Not found: ${id}`), {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const updated = { ...existing, ...data };
|
const updated = { ...existing, ...data };
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticle" },
|
tags: { feature: FEATURE, repo: REPO, method: "getArticle" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -97,7 +100,11 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
|
|
||||||
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "articles.getArticleBySlug", op: "repository", attributes: { slug } },
|
{
|
||||||
|
name: "articles.getArticleBySlug",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { slug },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
@@ -114,7 +121,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" },
|
tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -145,22 +155,42 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
if (options?.status) where.status = { equals: options.status };
|
if (options?.status) where.status = { equals: options.status };
|
||||||
if (options?.authorId) where.author = { equals: options.authorId };
|
if (options?.authorId) where.author = { equals: options.authorId };
|
||||||
|
|
||||||
const result = await payload.find({
|
const limit = options?.limit ?? 50;
|
||||||
|
const offset = options?.offset ?? 0;
|
||||||
|
// Payload paginates by page, not offset. A non-aligned offset
|
||||||
|
// (offset % limit !== 0) straddles two pages, so fetch both and
|
||||||
|
// slice out the exact [offset, offset + limit) window.
|
||||||
|
const page = limit > 0 ? Math.floor(offset / limit) + 1 : 1;
|
||||||
|
const remainder = limit > 0 ? offset % limit : 0;
|
||||||
|
|
||||||
|
const findPage = (p: number) =>
|
||||||
|
payload.find({
|
||||||
collection: "articles",
|
collection: "articles",
|
||||||
where: where as never,
|
where: where as never,
|
||||||
limit: options?.limit ?? 50,
|
limit,
|
||||||
page: options?.offset
|
page: p,
|
||||||
? Math.floor(options.offset / (options.limit ?? 50)) + 1
|
|
||||||
: 1,
|
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
span.setAttribute("count", result.docs.length);
|
|
||||||
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
|
const first = await findPage(page);
|
||||||
|
let docs = first.docs;
|
||||||
|
if (remainder > 0) {
|
||||||
|
if (docs.length === limit) {
|
||||||
|
const second = await findPage(page + 1);
|
||||||
|
docs = [...docs, ...second.docs];
|
||||||
|
}
|
||||||
|
docs = docs.slice(remainder, remainder + limit);
|
||||||
|
}
|
||||||
|
span.setAttribute("count", docs.length);
|
||||||
|
return docs.map((d) => mapDoc(d as PayloadArticleDoc));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticles" },
|
tags: { feature: FEATURE, repo: REPO, method: "getArticles" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -169,7 +199,11 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
|
|
||||||
async createArticle(input: Article): Promise<Article> {
|
async createArticle(input: Article): Promise<Article> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } },
|
{
|
||||||
|
name: "articles.createArticle",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { slug: input.slug },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
@@ -190,7 +224,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "createArticle" },
|
tags: { feature: FEATURE, repo: REPO, method: "createArticle" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -233,7 +270,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "updateArticle" },
|
tags: { feature: FEATURE, repo: REPO, method: "updateArticle" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t } from "@repo/core-shared/trpc/init";
|
import { t } from "@repo/core-shared/trpc/init";
|
||||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||||
|
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
|
||||||
|
|
||||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
@@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use(
|
|||||||
[ArticleNotFoundError, "NOT_FOUND"],
|
[ArticleNotFoundError, "NOT_FOUND"],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base procedure for MUTATING blog routes (audit finding B7): anonymous
|
||||||
|
* callers are rejected with UNAUTHORIZED before the controller runs.
|
||||||
|
* Read-only queries stay on `blogProcedure`.
|
||||||
|
*/
|
||||||
|
export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ describe("blogRouter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createArticle then articleBySlug returns the article", async () => {
|
it("createArticle then articleBySlug returns the article", async () => {
|
||||||
const caller = blogRouter.createCaller({});
|
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
|
||||||
|
const caller = blogRouter.createCaller({
|
||||||
|
user: { id: "u1", roles: [] },
|
||||||
|
});
|
||||||
|
|
||||||
const created = await caller.createArticle({
|
const created = await caller.createArticle({
|
||||||
title: "Router Test Article",
|
title: "Router Test Article",
|
||||||
@@ -45,6 +48,34 @@ describe("blogRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("blogRouter authorization (B7)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
blogContainer.unbindAll();
|
||||||
|
blogContainer.load(BlogModule);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
blogContainer.unbindAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createArticle rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||||
|
const caller = blogRouter.createCaller({});
|
||||||
|
await expect(
|
||||||
|
caller.createArticle({
|
||||||
|
title: "Nope",
|
||||||
|
content: null,
|
||||||
|
authorId: "u1",
|
||||||
|
slug: "nope",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read-only queries stay public", async () => {
|
||||||
|
const caller = blogRouter.createCaller({});
|
||||||
|
await expect(caller.listArticles({})).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("blogRouter error mapping", () => {
|
describe("blogRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller
|
|||||||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
|
||||||
import { blogProcedure } from "./procedures";
|
import { blogProcedure, blogProtectedProcedure } from "./procedures";
|
||||||
|
|
||||||
export const blogRouter = router({
|
export const blogRouter = router({
|
||||||
articleBySlug: blogProcedure
|
articleBySlug: blogProcedure
|
||||||
@@ -32,7 +32,8 @@ export const blogRouter = router({
|
|||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createArticle: blogProcedure
|
// Mutations require an authenticated caller (B7).
|
||||||
|
createArticle: blogProtectedProcedure
|
||||||
.input(createArticleInputSchema)
|
.input(createArticleInputSchema)
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
const ctrl = blogContainer.get<ICreateArticleController>(
|
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||||
@@ -43,3 +44,13 @@ export const blogRouter = router({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type BlogRouter = typeof blogRouter;
|
export type BlogRouter = typeof blogRouter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This feature's slice as it is mounted in the app router (the `blog` key
|
||||||
|
* in @repo/core-api). UI hooks pass it to `useTRPC<BlogAppSlice>()` so
|
||||||
|
* they stay fully typed without core-trpc (or this feature's client code)
|
||||||
|
* depending on the composition layer. Type-only: erased at compile time.
|
||||||
|
*/
|
||||||
|
export type BlogAppSlice = ReturnType<
|
||||||
|
typeof router<{ blog: typeof blogRouter }>
|
||||||
|
>;
|
||||||
|
|||||||
23
packages/blog/src/ui/components/article-card.tsx
Normal file
23
packages/blog/src/ui/components/article-card.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 transition-colors hover:bg-accent/50">
|
||||||
|
<a href={`/blog/${article.slug}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-card-foreground">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
</a>
|
||||||
|
<time
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
dateTime={article.createdAt.toISOString()}
|
||||||
|
>
|
||||||
|
{article.createdAt.toLocaleDateString()}
|
||||||
|
</time>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
packages/blog/src/ui/components/article-detail.client.tsx
Normal file
34
packages/blog/src/ui/components/article-detail.client.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useArticleBySlug } from "../hooks/use-article-by-slug";
|
||||||
|
|
||||||
|
export type ArticleDetailProps = {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArticleDetail({ slug }: ArticleDetailProps) {
|
||||||
|
const { data: article } = useArticleBySlug(slug);
|
||||||
|
|
||||||
|
if (!article) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{article.title}</h1>
|
||||||
|
{article.createdAt ? (
|
||||||
|
<time
|
||||||
|
className="mt-2 block text-sm text-muted-foreground"
|
||||||
|
dateTime={article.createdAt.toISOString()}
|
||||||
|
>
|
||||||
|
{article.createdAt.toLocaleDateString()}
|
||||||
|
</time>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
<div className="prose text-foreground">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm">
|
||||||
|
{JSON.stringify(article.content, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/blog/src/ui/components/article-detail.server.tsx
Normal file
24
packages/blog/src/ui/components/article-detail.server.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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 { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
import { ArticleDetail as ArticleDetailClient } from "./article-detail.client";
|
||||||
|
|
||||||
|
export async function ArticleDetail({ slug }: { slug: string }) {
|
||||||
|
const controller = blogContainer.get<IGetArticleBySlugController>(
|
||||||
|
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||||
|
);
|
||||||
|
const article = await controller({ slug });
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["blog", "articleBySlug", { input: { slug } }],
|
||||||
|
article,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ArticleDetailClient slug={slug} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
packages/blog/src/ui/components/article-list.client.tsx
Normal file
20
packages/blog/src/ui/components/article-list.client.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"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 published articles yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<ArticleCard key={article.id} article={article} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/blog/src/ui/components/article-list.server.tsx
Normal file
24
packages/blog/src/ui/components/article-list.server.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
packages/blog/src/ui/hooks/use-article-by-slug.ts
Normal file
13
packages/blog/src/ui/hooks/use-article-by-slug.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { useTRPC } from "@repo/core-trpc";
|
||||||
|
import type { BlogAppSlice } from "../../integrations/api/router";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
|
||||||
|
export function useArticleBySlug(slug: string) {
|
||||||
|
const trpc = useTRPC<BlogAppSlice>();
|
||||||
|
return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as {
|
||||||
|
data: Article | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
19
packages/blog/src/ui/hooks/use-article-list.ts
Normal file
19
packages/blog/src/ui/hooks/use-article-list.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { useTRPC } from "@repo/core-trpc";
|
||||||
|
import type { BlogAppSlice } from "../../integrations/api/router";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
|
||||||
|
export function useArticleList(options?: {
|
||||||
|
status?: "draft" | "published";
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const trpc = useTRPC<BlogAppSlice>();
|
||||||
|
return useSuspenseQuery(
|
||||||
|
trpc.blog.listArticles.queryOptions({
|
||||||
|
status: options?.status ?? "published",
|
||||||
|
limit: options?.limit ?? 20,
|
||||||
|
}),
|
||||||
|
) as { data: Article[] };
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
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";
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import type { AnalyticsProtocol } from "@repo/core-shared/di/bind-protocols";
|
||||||
|
|
||||||
export type AnalyticsAttributeValue = string | number | boolean;
|
export type AnalyticsAttributeValue = string | number | boolean;
|
||||||
|
|
||||||
export type AnalyticsUser = {
|
export type AnalyticsUser = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IAnalytics {
|
/**
|
||||||
|
* Product-analytics sink. Extends `AnalyticsProtocol` from
|
||||||
|
* `@repo/core-shared/di/bind-protocols` — the surface feature binders see via
|
||||||
|
* `ctx.analytics` — so narrowing the protocol fails typecheck here instead of
|
||||||
|
* silently drifting apart.
|
||||||
|
*/
|
||||||
|
export interface IAnalytics extends AnalyticsProtocol {
|
||||||
track(
|
track(
|
||||||
event: string,
|
event: string,
|
||||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ export type Analyzed<F> = F & { readonly __analyzed: true };
|
|||||||
* tests).
|
* tests).
|
||||||
*/
|
*/
|
||||||
export function withAnalytics<Args extends unknown[], R>(
|
export function withAnalytics<Args extends unknown[], R>(
|
||||||
// TODO: wire automated event recording from manifest declarations.
|
// The wrapper attaches the brand and ensures the analytics dependency is
|
||||||
// `analyticsEvents[]` declarations. For now, the wrapper exists to:
|
// available at bind time. Actual `analytics.track()` calls live in the
|
||||||
// (1) require callers to pass the analytics instance at bind time (dep is available)
|
// use case body — only the use case knows which properties to extract
|
||||||
// (2) attach the `__analyzed` brand so the boot-time assertion can verify
|
// from its input/output for the analytics event.
|
||||||
// use cases were bound through the analytics-aware path.
|
|
||||||
analytics: IAnalytics,
|
analytics: IAnalytics,
|
||||||
fn: (...args: Args) => Promise<R>,
|
fn: (...args: Args) => Promise<R>,
|
||||||
): Analyzed<(...args: Args) => Promise<R>> {
|
): Analyzed<(...args: Args) => Promise<R>> {
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
"@repo/media": "workspace:*",
|
"@repo/media": "workspace:*",
|
||||||
"@repo/navigation": "workspace:*",
|
"@repo/navigation": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0"
|
"@trpc/server": "^11.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.18.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -37,10 +37,11 @@
|
|||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { auditLogsCollection } from "./audit-logs-collection";
|
import { auditLogsCollection } from "./audit-logs-collection";
|
||||||
|
|
||||||
describe("auditLogsCollection", () => {
|
describe("auditLogsCollection", () => {
|
||||||
|
it("accepts every AuditAction enum value (A6)", () => {
|
||||||
|
const action = (
|
||||||
|
auditLogsCollection.fields as Array<{ name: string; options?: string[] }>
|
||||||
|
).find((f) => f.name === "action");
|
||||||
|
expect(action?.options).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"VIEW",
|
||||||
|
"CREATE",
|
||||||
|
"UPDATE",
|
||||||
|
"DELETE",
|
||||||
|
"EXPORT",
|
||||||
|
"PERMISSION_CHANGE",
|
||||||
|
"CONSENT_GRANT",
|
||||||
|
"CONSENT_WITHDRAW",
|
||||||
|
"RESTRICT",
|
||||||
|
"UNRESTRICT",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses slug 'audit-logs'", () => {
|
it("uses slug 'audit-logs'", () => {
|
||||||
expect(auditLogsCollection.slug).toBe("audit-logs");
|
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is append-only (update: () => false)", () => {
|
it("is append-only (update: () => false)", () => {
|
||||||
const access = auditLogsCollection.access as Record<string, (() => boolean) | undefined>;
|
const access = auditLogsCollection.access as Record<
|
||||||
|
string,
|
||||||
|
(() => boolean) | undefined
|
||||||
|
>;
|
||||||
expect(access["update"]?.()).toBe(false);
|
expect(access["update"]?.()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has the required fields", () => {
|
it("has the required fields", () => {
|
||||||
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
const fieldNames = (
|
||||||
|
auditLogsCollection.fields as Array<{ name: string }>
|
||||||
|
).map((f) => f.name);
|
||||||
// WHO
|
// WHO
|
||||||
expect(fieldNames).toContain("actorId");
|
expect(fieldNames).toContain("actorId");
|
||||||
expect(fieldNames).toContain("actorType");
|
expect(fieldNames).toContain("actorType");
|
||||||
|
|||||||
@@ -44,7 +44,21 @@ export const auditLogsCollection: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
// Mirrors the AuditAction enum in @repo/core-shared/audit — the DSR and
|
||||||
|
// consent cores record RESTRICT/UNRESTRICT/CONSENT_* entries, so the
|
||||||
|
// select must accept every enum value or record() fails validation (A6).
|
||||||
|
options: [
|
||||||
|
"VIEW",
|
||||||
|
"CREATE",
|
||||||
|
"UPDATE",
|
||||||
|
"DELETE",
|
||||||
|
"EXPORT",
|
||||||
|
"PERMISSION_CHANGE",
|
||||||
|
"CONSENT_GRANT",
|
||||||
|
"CONSENT_WITHDRAW",
|
||||||
|
"RESTRICT",
|
||||||
|
"UNRESTRICT",
|
||||||
|
],
|
||||||
required: true,
|
required: true,
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { createAuditErasureHook } from "./audit-erasure-hook";
|
import {
|
||||||
|
createAuditErasureHook,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
|
} from "./audit-erasure-hook";
|
||||||
import type { IAuditLog } from "../audit-log.interface";
|
import type { IAuditLog } from "../audit-log.interface";
|
||||||
|
|
||||||
function makeAuditLog(): IAuditLog {
|
function makeAuditLog(): IAuditLog {
|
||||||
@@ -25,7 +28,10 @@ describe("createAuditErasureHook", () => {
|
|||||||
const auditLog = makeAuditLog();
|
const auditLog = makeAuditLog();
|
||||||
const hook = createAuditErasureHook({ auditLog });
|
const hook = createAuditErasureHook({ auditLog });
|
||||||
await hook(hookArgs("user_1") as never);
|
await hook(hookArgs("user_1") as never);
|
||||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
expect(auditLog.eraseSubject).toHaveBeenCalledWith(
|
||||||
|
"user_1",
|
||||||
|
"pseudonymize",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects explicit mode='delete'", async () => {
|
it("respects explicit mode='delete'", async () => {
|
||||||
@@ -63,3 +69,78 @@ describe("createAuditErasureHook", () => {
|
|||||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createReqScopedAuditErasureHook (A6)", () => {
|
||||||
|
function makeReqPayload(withAuditCollection: boolean) {
|
||||||
|
const find = vi.fn().mockResolvedValue({ docs: [{ id: "log-1" }] });
|
||||||
|
const update = vi.fn().mockResolvedValue({});
|
||||||
|
const del = vi.fn().mockResolvedValue({});
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
collections: withAuditCollection ? [{ slug: "audit-logs" }] : [],
|
||||||
|
},
|
||||||
|
find,
|
||||||
|
update,
|
||||||
|
delete: del,
|
||||||
|
};
|
||||||
|
return { payload, find, update, del };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqHookArgs(id: unknown, payload: unknown) {
|
||||||
|
return {
|
||||||
|
doc: { id },
|
||||||
|
req: { payload } as never,
|
||||||
|
id: String(id),
|
||||||
|
collection: {} as never,
|
||||||
|
context: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pseudonymizes the deleted subject's audit entries via req.payload", async () => {
|
||||||
|
const { payload, find, update } = makeReqPayload(true);
|
||||||
|
const hook = createReqScopedAuditErasureHook();
|
||||||
|
await hook(reqHookArgs("user_1", payload) as never);
|
||||||
|
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "audit-logs",
|
||||||
|
where: { actorId: { equals: "user_1" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "audit-logs",
|
||||||
|
id: "log-1",
|
||||||
|
data: { actorId: expect.stringMatching(/^erased-/) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects mode='delete'", async () => {
|
||||||
|
const { payload, del } = makeReqPayload(true);
|
||||||
|
const hook = createReqScopedAuditErasureHook({ mode: "delete" });
|
||||||
|
await hook(reqHookArgs("user_2", payload) as never);
|
||||||
|
expect(del).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "audit-logs",
|
||||||
|
where: { actorId: { equals: "user_2" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when the audit-logs collection is not registered", async () => {
|
||||||
|
const { payload, find, update, del } = makeReqPayload(false);
|
||||||
|
const hook = createReqScopedAuditErasureHook();
|
||||||
|
await hook(reqHookArgs("user_1", payload) as never);
|
||||||
|
expect(find).not.toHaveBeenCalled();
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
expect(del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips invalid doc ids", async () => {
|
||||||
|
const { payload, find } = makeReqPayload(true);
|
||||||
|
const hook = createReqScopedAuditErasureHook();
|
||||||
|
await hook(reqHookArgs(undefined, payload) as never);
|
||||||
|
expect(find).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CollectionAfterDeleteHook } from "payload";
|
import type { CollectionAfterDeleteHook } from "payload";
|
||||||
import type { IAuditLog } from "../audit-log.interface";
|
import type { IAuditLog } from "../audit-log.interface";
|
||||||
|
import { PayloadAuditLog } from "../payload-audit-log";
|
||||||
|
|
||||||
export type AuditErasureHookOpts = {
|
export type AuditErasureHookOpts = {
|
||||||
/** The audit log impl that will perform the erasure. */
|
/** The audit log impl that will perform the erasure. */
|
||||||
@@ -36,3 +37,37 @@ export function createAuditErasureHook(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReqScopedAuditErasureHookOpts = {
|
||||||
|
/** Erasure mode — see AuditErasureHookOpts. Defaults to "pseudonymize". */
|
||||||
|
mode?: "pseudonymize" | "delete";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `createAuditErasureHook` for config-composition time (audit
|
||||||
|
* finding A6): a Payload collection config is built before any `IAuditLog`
|
||||||
|
* can exist (binding the audit log needs the built config), so this hook
|
||||||
|
* constructs a `PayloadAuditLog` lazily from the running instance on
|
||||||
|
* `req.payload` when the delete fires. No-ops when the `audit-logs`
|
||||||
|
* collection is not registered.
|
||||||
|
*/
|
||||||
|
export function createReqScopedAuditErasureHook(
|
||||||
|
opts: ReqScopedAuditErasureHookOpts = {},
|
||||||
|
): CollectionAfterDeleteHook {
|
||||||
|
const mode = opts.mode ?? "pseudonymize";
|
||||||
|
return async ({ doc, req }) => {
|
||||||
|
if (typeof doc.id !== "string" && typeof doc.id !== "number") return;
|
||||||
|
const payload = req.payload;
|
||||||
|
// `slug as string`: apps with generated CollectionSlug types narrow slug
|
||||||
|
// to their registered union, which need not include "audit-logs".
|
||||||
|
const hasAuditCollection = payload.config.collections?.some(
|
||||||
|
(c) => (c.slug as string) === "audit-logs",
|
||||||
|
);
|
||||||
|
if (!hasAuditCollection) return;
|
||||||
|
const auditLog = new PayloadAuditLog(
|
||||||
|
payload.config,
|
||||||
|
async () => payload as never,
|
||||||
|
);
|
||||||
|
await auditLog.eraseSubject(String(doc.id), mode);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
|
type ReqScopedAuditErasureHookOpts,
|
||||||
} from "./audit-erasure-hook";
|
} from "./audit-erasure-hook";
|
||||||
export {
|
export {
|
||||||
createAuditAfterReadHook,
|
createAuditAfterReadHook,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export { AUDIT_SYMBOLS } from "./di/symbols";
|
|||||||
export { pseudonymize } from "./pseudonymize";
|
export { pseudonymize } from "./pseudonymize";
|
||||||
export {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
|
type ReqScopedAuditErasureHookOpts,
|
||||||
} from "./hooks/audit-erasure-hook";
|
} from "./hooks/audit-erasure-hook";
|
||||||
// VIEW capture
|
// VIEW capture
|
||||||
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ describe("PayloadAuditLog.record", () => {
|
|||||||
await log.record(sample);
|
await log.record(sample);
|
||||||
|
|
||||||
expect(mockCreate).toHaveBeenCalledOnce();
|
expect(mockCreate).toHaveBeenCalledOnce();
|
||||||
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
const call = mockCreate.mock.calls[0]![0] as {
|
||||||
|
collection: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
expect(call.collection).toBe("audit-logs");
|
expect(call.collection).toBe("audit-logs");
|
||||||
expect(call.data.actorId).toBe("user_1");
|
expect(call.data.actorId).toBe("user_1");
|
||||||
expect(call.data.action).toBe("UPDATE");
|
expect(call.data.action).toBe("UPDATE");
|
||||||
@@ -101,14 +104,21 @@ describe("PayloadAuditLog.eraseSubject", () => {
|
|||||||
// update called for each doc
|
// update called for each doc
|
||||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||||
const updateCalls = mockUpdate.mock.calls as Array<
|
const updateCalls = mockUpdate.mock.calls as Array<
|
||||||
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
|
[
|
||||||
|
{
|
||||||
|
collection: string;
|
||||||
|
id: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
overrideAccess: boolean;
|
||||||
|
},
|
||||||
|
]
|
||||||
>;
|
>;
|
||||||
expect(updateCalls[0]![0].id).toBe("doc_a");
|
expect(updateCalls[0]![0].id).toBe("doc_a");
|
||||||
expect(updateCalls[1]![0].id).toBe("doc_b");
|
expect(updateCalls[1]![0].id).toBe("doc_b");
|
||||||
|
|
||||||
// both updates replace actorId with the same pseudonym
|
// both updates replace actorId with the same pseudonym
|
||||||
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
||||||
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
|
expect(pseudonym).toMatch(/^erased-[0-9a-f]{32}$/);
|
||||||
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
||||||
|
|
||||||
// overrideAccess bypasses the append-only rule
|
// overrideAccess bypasses the append-only rule
|
||||||
@@ -118,7 +128,9 @@ describe("PayloadAuditLog.eraseSubject", () => {
|
|||||||
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
||||||
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
||||||
const mockUpdate = vi.fn();
|
const mockUpdate = vi.fn();
|
||||||
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
|
const mockGetPayload = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ find: mockFind, update: mockUpdate });
|
||||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||||
|
|
||||||
await log.eraseSubject("unknown_user", "pseudonymize");
|
await log.eraseSubject("unknown_user", "pseudonymize");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user