Compare commits
56 Commits
0a34b45bb7
...
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 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,10 @@ 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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
"@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/core-trpc": "workspace:^",
|
||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
"@sentry/nextjs": "^10.51.0",
|
"@sentry/nextjs": "^10.51.0",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.96.2",
|
"@tanstack/react-query": "^5.96.2",
|
||||||
"@trpc/server": "^11.17.0",
|
"@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",
|
||||||
@@ -47,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
import { appRouter } from "@repo/core-api";
|
import { appRouter } from "@repo/core-api";
|
||||||
|
import { createWebNextTrpcContext } from "../../../../server/trpc-context";
|
||||||
|
|
||||||
const handler = async (req: Request) => {
|
const handler = async (req: Request) => {
|
||||||
return fetchRequestHandler({
|
return fetchRequestHandler({
|
||||||
endpoint: "/api/trpc",
|
endpoint: "/api/trpc",
|
||||||
req,
|
req,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: () => ({}),
|
// 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),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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,6 +98,17 @@ describe("bindAllProduction", () => {
|
|||||||
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
expect(bindProductionMedia).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers retention purge jobs at production boot (A3)", async () => {
|
||||||
|
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 () => {
|
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
const { bindAll } = await import("./bind-production");
|
const { bindAll } = await import("./bind-production");
|
||||||
@@ -91,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", () => {
|
||||||
@@ -110,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";
|
||||||
@@ -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,6 +140,18 @@ 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
|
||||||
@@ -91,12 +162,38 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
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);
|
||||||
@@ -104,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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,10 +223,19 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,15 +266,10 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
export function bindAll(): Promise<void> {
|
export function bindAll(): Promise<void> {
|
||||||
if (bindPromise) return bindPromise;
|
if (bindPromise) return bindPromise;
|
||||||
|
|
||||||
if (process.env.USE_DEV_SEED === "false") {
|
bindPromise =
|
||||||
bindPromise = bindAllProduction();
|
resolveBindingMode() === "production"
|
||||||
} else if (process.env.USE_DEV_SEED === "true") {
|
? bindAllProduction()
|
||||||
bindPromise = bindAllDevSeed();
|
: bindAllDevSeed();
|
||||||
} else if (process.env.NODE_ENV === "production") {
|
|
||||||
bindPromise = bindAllProduction();
|
|
||||||
} else {
|
|
||||||
bindPromise = bindAllDevSeed();
|
|
||||||
}
|
|
||||||
|
|
||||||
return bindPromise;
|
return bindPromise;
|
||||||
}
|
}
|
||||||
@@ -168,6 +280,7 @@ export function __resetBindStateForTests(): void {
|
|||||||
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>
|
||||||
|
>;
|
||||||
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
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
"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",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"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"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -42,11 +73,16 @@ describe("AuthenticationService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session methods (require Payload)", () => {
|
describe("session methods", () => {
|
||||||
// createSession and validateSession call getPayload() internally,
|
// getPayload is mocked module-wide (secret + findByID only), so these
|
||||||
// so they require a running Payload instance. These are exercised
|
// exercise the real signToken/verifyToken/validateSession crypto paths
|
||||||
// by the mock service in use-case tests and by integration tests.
|
// without a running Payload instance (audit finding B8).
|
||||||
// Here we only test invalidateSession (no Payload dependency).
|
const user = { id: "u1", username: "alice", passwordHash: "stored-hash" };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
payloadStub.secret = "test-secret";
|
||||||
|
});
|
||||||
|
|
||||||
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
||||||
const { blankCookie } = await service.invalidateSession("any-token");
|
const { blankCookie } = await service.invalidateSession("any-token");
|
||||||
@@ -56,5 +92,118 @@ describe("AuthenticationService", () => {
|
|||||||
expect(blankCookie.attributes.httpOnly).toBe(true);
|
expect(blankCookie.attributes.httpOnly).toBe(true);
|
||||||
expect(blankCookie.attributes.path).toBe("/");
|
expect(blankCookie.attributes.path).toBe("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createSession then validateSession round-trips (jti = session.id)", async () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { IAuthenticationService } from "../../application/services/authenti
|
|||||||
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";
|
||||||
|
|
||||||
const SALT_LENGTH = 16;
|
const SALT_LENGTH = 16;
|
||||||
const KEY_LENGTH = 64;
|
const KEY_LENGTH = 64;
|
||||||
@@ -15,7 +16,12 @@ const COOKIE_NAME = "payload-token";
|
|||||||
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
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();
|
||||||
@@ -69,10 +75,13 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
||||||
|
|
||||||
const token = this.signToken(user.id, payload.secret);
|
// 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 = {
|
const session: Session = {
|
||||||
id: crypto.randomUUID(),
|
id: sessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
@@ -97,9 +106,14 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const decoded = this.verifyToken(token, payload.secret);
|
const decoded = this.verifyToken(token, payload.secret);
|
||||||
if (!decoded) throw new Error("Invalid or expired session token");
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
const userDoc = await payload.findByID({
|
const userDoc = await payload.findByID({
|
||||||
collection: "users" as "users",
|
collection: "users" as const,
|
||||||
id: decoded.id,
|
id: decoded.id,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
@@ -111,7 +125,7 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: token,
|
id: decoded.jti,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt: new Date(decoded.exp * 1000),
|
expiresAt: new Date(decoded.exp * 1000),
|
||||||
};
|
};
|
||||||
@@ -119,9 +133,11 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
return { user, session };
|
return { user, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
async invalidateSession(
|
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
|
||||||
_sessionId: string,
|
// `sessionId` is the JWT `jti` (the `session.id` returned by
|
||||||
): Promise<{ blankCookie: Cookie }> {
|
// 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 {
|
return {
|
||||||
blankCookie: {
|
blankCookie: {
|
||||||
name: COOKIE_NAME,
|
name: COOKIE_NAME,
|
||||||
@@ -138,13 +154,13 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
||||||
private signToken(userId: string, secret: string): string {
|
private signToken(userId: string, jti: string, secret: string): string {
|
||||||
const header = Buffer.from(
|
const header = Buffer.from(
|
||||||
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||||
).toString("base64url");
|
).toString("base64url");
|
||||||
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify({ id: userId, collection: "users", exp }),
|
JSON.stringify({ id: userId, collection: "users", exp, jti }),
|
||||||
).toString("base64url");
|
).toString("base64url");
|
||||||
const signature = crypto
|
const signature = crypto
|
||||||
.createHmac("sha256", secret)
|
.createHmac("sha256", secret)
|
||||||
@@ -157,22 +173,38 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
private verifyToken(
|
private verifyToken(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
): { id: string; exp: number } | null {
|
): { id: string; exp: number; jti: string } | null {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
const [header, body, signature] = parts as [string, string, string];
|
const [header, body, signature] = parts as [string, string, string];
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac("sha256", secret)
|
.createHmac("sha256", secret)
|
||||||
.update(`${header}.${body}`)
|
.update(`${header}.${body}`)
|
||||||
.digest("base64url");
|
.digest();
|
||||||
if (signature !== expected) return null;
|
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 {
|
try {
|
||||||
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
||||||
id: string;
|
id?: unknown;
|
||||||
exp: number;
|
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;
|
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
return decoded;
|
return { id: decoded.id, exp: decoded.exp, jti: decoded.jti };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 {};
|
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/core-trpc": "workspace:^",
|
"@repo/core-trpc": "workspace:^",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@trpc/client": "^11.17.0",
|
"@trpc/client": "^11.18.0",
|
||||||
"@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",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 }>
|
||||||
|
>;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useTRPC } from "@repo/core-trpc";
|
import { useTRPC } from "@repo/core-trpc";
|
||||||
|
import type { BlogAppSlice } from "../../integrations/api/router";
|
||||||
import type { Article } from "../../entities/models/article";
|
import type { Article } from "../../entities/models/article";
|
||||||
|
|
||||||
export function useArticleBySlug(slug: string) {
|
export function useArticleBySlug(slug: string) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC<BlogAppSlice>();
|
||||||
return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as {
|
return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as {
|
||||||
data: Article | null;
|
data: Article | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useTRPC } from "@repo/core-trpc";
|
import { useTRPC } from "@repo/core-trpc";
|
||||||
|
import type { BlogAppSlice } from "../../integrations/api/router";
|
||||||
import type { Article } from "../../entities/models/article";
|
import type { Article } from "../../entities/models/article";
|
||||||
|
|
||||||
export function useArticleList(options?: {
|
export function useArticleList(options?: {
|
||||||
status?: "draft" | "published";
|
status?: "draft" | "published";
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) {
|
}) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC<BlogAppSlice>();
|
||||||
return useSuspenseQuery(
|
return useSuspenseQuery(
|
||||||
trpc.blog.listArticles.queryOptions({
|
trpc.blog.listArticles.queryOptions({
|
||||||
status: options?.status ?? "published",
|
status: options?.status ?? "published",
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
|||||||
@@ -21,13 +21,28 @@ describe("pseudonymize", () => {
|
|||||||
expect(result).toMatch(/^erased-/);
|
expect(result).toMatch(/^erased-/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces exactly 16 hex chars after the prefix", () => {
|
it("produces exactly 32 hex chars (128 bits) after the prefix (A13)", () => {
|
||||||
const result = pseudonymize("user_42");
|
const result = pseudonymize("user_42");
|
||||||
const hex = result.slice("erased-".length);
|
const hex = result.slice("erased-".length);
|
||||||
expect(hex).toHaveLength(16);
|
expect(hex).toHaveLength(32);
|
||||||
expect(hex).toMatch(/^[0-9a-f]+$/);
|
expect(hex).toMatch(/^[0-9a-f]+$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches HMAC-SHA256(key, actorId) - keyed, not a bare hash (A13)", async () => {
|
||||||
|
const { createHmac, createHash } = await import("node:crypto");
|
||||||
|
const expected = createHmac("sha256", "test-salt-1")
|
||||||
|
.update("user_42")
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
expect(pseudonymize("user_42")).toBe(`erased-` + expected);
|
||||||
|
// and it must NOT be the legacy unkeyed sha256("salt:id") scheme
|
||||||
|
const legacy = createHash("sha256")
|
||||||
|
.update("test-salt-1:user_42")
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
expect(pseudonymize("user_42")).not.toBe(`erased-` + legacy);
|
||||||
|
});
|
||||||
|
|
||||||
it("is deterministic — same salt + actorId always yields the same token", () => {
|
it("is deterministic — same salt + actorId always yields the same token", () => {
|
||||||
const a = pseudonymize("user_42");
|
const a = pseudonymize("user_42");
|
||||||
const b = pseudonymize("user_42");
|
const b = pseudonymize("user_42");
|
||||||
@@ -53,6 +68,6 @@ describe("pseudonymize", () => {
|
|||||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||||
// Should not throw; just use the fallback.
|
// Should not throw; just use the fallback.
|
||||||
const result = pseudonymize("user_1");
|
const result = pseudonymize("user_1");
|
||||||
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
|
expect(result).toMatch(/^erased-[0-9a-f]{32}$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
||||||
*
|
*
|
||||||
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
|
* Format: `erased-<first-32-hex-chars-of-HMAC-SHA256(key, actorId)>` —
|
||||||
|
* 128 bits of a KEYED digest (audit finding A13). The previous scheme was an
|
||||||
|
* unkeyed `sha256("salt:actorId")` truncated to 64 bits, which invited both
|
||||||
|
* brute-force reversal of small id spaces and birthday collisions.
|
||||||
*
|
*
|
||||||
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
|
* The HMAC key is read from `AUDIT_PSEUDONYM_SALT` at call time so that
|
||||||
* production binding can pre-validate the var at boot (see `bindAudit`)
|
* production binding can pre-validate the var at boot (see `bindAudit`)
|
||||||
* while tests can override it per-test via `process.env`.
|
* while tests can override it per-test via `process.env`.
|
||||||
*
|
*
|
||||||
* Fallback salt is intentionally weak and labelled so that any token
|
* Rotation expectations (documented, by design):
|
||||||
|
* - Rotating the key changes pseudonyms produced FROM THEN ON only. Audit
|
||||||
|
* rows already pseudonymized keep tokens derived from the previous key;
|
||||||
|
* nothing re-keys stored rows, so a subject's pre- and post-rotation
|
||||||
|
* tokens no longer correlate. That linkage break is acceptable — the
|
||||||
|
* token's only job is severing PII linkage, not long-term correlation.
|
||||||
|
* - Re-erasing a subject after rotation still works: erasure matches rows
|
||||||
|
* by the REAL actorId, not by a previous pseudonym.
|
||||||
|
* - Rotate by replacing the env value (e.g. `openssl rand -hex 32`); keep
|
||||||
|
* retired keys only if you have an explicit need to re-correlate old rows.
|
||||||
|
*
|
||||||
|
* Fallback key is intentionally weak and labelled so that any token
|
||||||
* produced with it is recognisable as a dev/test artefact.
|
* produced with it is recognisable as a dev/test artefact.
|
||||||
*/
|
*/
|
||||||
export function pseudonymize(actorId: string): string {
|
export function pseudonymize(actorId: string): string {
|
||||||
const salt =
|
const key =
|
||||||
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
||||||
const hash = createHash("sha256")
|
const digest = createHmac("sha256", key).update(actorId).digest("hex");
|
||||||
.update(`${salt}:${actorId}`)
|
return `erased-${digest.slice(0, 32)}`;
|
||||||
.digest("hex");
|
|
||||||
return `erased-${hash.slice(0, 16)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,20 +14,23 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@payloadcms/db-postgres": "^3.14.0",
|
||||||
|
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/blog": "workspace:*",
|
"@repo/blog": "workspace:*",
|
||||||
"@repo/media": "workspace:*",
|
"@repo/core-audit": "workspace:*",
|
||||||
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
|
"@repo/media": "workspace:*",
|
||||||
"@repo/navigation": "workspace:*",
|
"@repo/navigation": "workspace:*",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0"
|
||||||
"@payloadcms/db-postgres": "^3.14.0",
|
|
||||||
"@payloadcms/richtext-lexical": "^3.14.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": "^3.0.0"
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,46 @@ describe("payloadConfig composition", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
for (const slug of ["users", "articles", "media"]) {
|
||||||
|
const collection = resolved.collections?.find((c) => c.slug === slug);
|
||||||
|
const names =
|
||||||
|
collection?.fields.map((f) => (f as { name?: string }).name) ?? [];
|
||||||
|
expect(names, `collection ${slug}`).toContain("deletedAt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a retention purge task per purgeSchedule collection (A3)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
const taskSlugs =
|
||||||
|
(
|
||||||
|
resolved.jobs as { tasks?: Array<{ slug: string }> } | undefined
|
||||||
|
)?.tasks?.map((t) => t.slug) ?? [];
|
||||||
|
expect(taskSlugs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"retention-purge--users",
|
||||||
|
"retention-purge--articles",
|
||||||
|
"retention-purge--media",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers the audit-logs collection (A6)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||||
|
expect(slugs).toContain("audit-logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wires the audit erasure afterDelete hook on users (A6)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
const users = resolved.collections?.find((c) => c.slug === "users");
|
||||||
|
expect(users?.hooks?.afterDelete?.length ?? 0).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers all feature globals", async () => {
|
it("registers all feature globals", async () => {
|
||||||
const resolved = await config;
|
const resolved = await config;
|
||||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||||
expect(slugs).toEqual(
|
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
|
||||||
expect.arrayContaining(["site-settings", "header"]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { users } from "@repo/auth/cms";
|
import {
|
||||||
|
withRetentionTombstone,
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
|
import {
|
||||||
|
auditLogsCollection,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
|
} from "@repo/core-audit";
|
||||||
|
import { users as usersBase } from "@repo/auth/cms";
|
||||||
import { articles } from "@repo/blog/cms";
|
import { articles } from "@repo/blog/cms";
|
||||||
import { media } from "@repo/media/cms";
|
import { media } from "@repo/media/cms";
|
||||||
import { pages, siteSettings } from "@repo/marketing-pages/cms";
|
import { pages, siteSettings } from "@repo/marketing-pages/cms";
|
||||||
@@ -13,9 +21,32 @@ import { header } from "@repo/navigation/cms";
|
|||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
|
// GDPR audit erasure (audit finding A6): when a users row is hard-deleted
|
||||||
|
// (admin expunge, DSR cascade-hard, retention purge), pseudonymize that
|
||||||
|
// subject's audit-log entries so the trail keeps its shape without PII linkage.
|
||||||
|
const users = {
|
||||||
|
...usersBase,
|
||||||
|
hooks: {
|
||||||
|
...usersBase.hooks,
|
||||||
|
afterDelete: [
|
||||||
|
...(usersBase.hooks?.afterDelete ?? []),
|
||||||
|
createReqScopedAuditErasureHook(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collections declaring custom.retention.postDeletion get the soft-delete
|
||||||
|
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
|
||||||
|
// retention purge job can grace-purge them (audit finding A2).
|
||||||
|
const collections = [
|
||||||
|
...[users, articles, pages, media].map(withRetentionTombstone),
|
||||||
|
// Local audit sink (A6) — required for PayloadAuditLog.record() to work.
|
||||||
|
auditLogsCollection,
|
||||||
|
];
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
collections: [users, articles, pages, media],
|
collections,
|
||||||
globals: [siteSettings, header],
|
globals: [siteSettings, header],
|
||||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
@@ -25,6 +56,14 @@ export default buildConfig({
|
|||||||
"postgresql://postgres:postgres@localhost:5433/template",
|
"postgresql://postgres:postgres@localhost:5433/template",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
jobs: {
|
||||||
|
// Task definitions for the retention purge (audit finding A3):
|
||||||
|
// registerRetentionPurgeJobs (called from bindAllProduction) enqueues
|
||||||
|
// `retention-purge--<slug>` jobs; these definitions let Payload run them.
|
||||||
|
tasks: collections
|
||||||
|
.filter((c) => Boolean(c.custom?.retention?.purgeSchedule))
|
||||||
|
.map((c) => buildRetentionPurgeTask(c.slug)) as never,
|
||||||
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, "generated-types.ts"),
|
outputFile: path.resolve(dirname, "generated-types.ts"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.18.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -36,12 +36,14 @@
|
|||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@trpc/client": "^11.18.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,27 @@ export type ConsentCategory =
|
|||||||
| "marketing"
|
| "marketing"
|
||||||
| (string & {});
|
| (string & {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The known consent categories (audit finding A12). Untrusted inputs — e.g.
|
||||||
|
* the anonymous banner cookie migrated at sign-up — MUST be validated against
|
||||||
|
* this list before being granted; the open ConsentCategory union is for
|
||||||
|
* first-party code registering custom categories deliberately, not for
|
||||||
|
* client-controlled strings.
|
||||||
|
*/
|
||||||
|
export const KNOWN_CONSENT_CATEGORIES = [
|
||||||
|
"necessary",
|
||||||
|
"functional",
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Type guard for the allow-list above. */
|
||||||
|
export function isKnownConsentCategory(
|
||||||
|
value: string,
|
||||||
|
): value is (typeof KNOWN_CONSENT_CATEGORIES)[number] {
|
||||||
|
return (KNOWN_CONSENT_CATEGORIES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a subject has granted or denied consent for a category. */
|
/** Whether a subject has granted or denied consent for a category. */
|
||||||
export type ConsentState = "granted" | "denied" | "pending";
|
export type ConsentState = "granted" | "denied" | "pending";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||||
|
import superjson from "superjson";
|
||||||
import { RecordingConsent } from "@repo/core-testing/instrumentation";
|
import { RecordingConsent } from "@repo/core-testing/instrumentation";
|
||||||
|
import { router } from "@repo/core-shared/trpc/init";
|
||||||
import { consentRouter } from "@/consent.router";
|
import { consentRouter } from "@/consent.router";
|
||||||
import type { ConsentRouterContext } from "@/consent.router";
|
import type { ConsentRouterContext } from "@/consent.router";
|
||||||
import type { IConsent } from "@/consent.interface";
|
import type { IConsent } from "@/consent.interface";
|
||||||
|
import { InMemoryConsent } from "@/in-memory-consent";
|
||||||
|
|
||||||
function makeContext(
|
function makeContext(
|
||||||
consent: RecordingConsent,
|
consent: RecordingConsent,
|
||||||
@@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("consentRouter — context guard", () => {
|
||||||
|
it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => {
|
||||||
|
const caller = consentRouter.createCaller({
|
||||||
|
userId: "user-1",
|
||||||
|
} as unknown as ConsentRouterContext);
|
||||||
|
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
|
||||||
|
{
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: expect.stringContaining("consentFactory missing"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consentRouter — superjson wire round-trip (A10)", () => {
|
||||||
|
// The consent router MUST be built from the shared `t` (which is created
|
||||||
|
// with the superjson transformer). This test drives a real tRPC HTTP
|
||||||
|
// round-trip — client link + fetch adapter — so a transformer mismatch
|
||||||
|
// between the mounted router and the app client fails loudly here.
|
||||||
|
function makeClient(ctx: ConsentRouterContext) {
|
||||||
|
const appLikeRouter = router({ consent: consentRouter });
|
||||||
|
return createTRPCClient<typeof appLikeRouter>({
|
||||||
|
links: [
|
||||||
|
httpLink({
|
||||||
|
url: "http://localhost/api/trpc",
|
||||||
|
transformer: superjson,
|
||||||
|
fetch: (input, init) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: "/api/trpc",
|
||||||
|
req: new Request(input, init as RequestInit),
|
||||||
|
router: appLikeRouter,
|
||||||
|
createContext: () => ctx,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips grant + getCategories, reviving Date fields", async () => {
|
||||||
|
const consent = new InMemoryConsent();
|
||||||
|
const client = makeClient({
|
||||||
|
userId: "user-1",
|
||||||
|
consentFactory: async () => consent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantRes = await client.consent.grant.mutate({
|
||||||
|
category: "analytics",
|
||||||
|
});
|
||||||
|
expect(grantRes).toEqual({ success: true });
|
||||||
|
|
||||||
|
const { categories } = await client.consent.getCategories.query({});
|
||||||
|
expect(categories).toHaveLength(1);
|
||||||
|
expect(categories[0]!.category).toBe("analytics");
|
||||||
|
// superjson revives Dates across the wire; plain JSON would yield a string.
|
||||||
|
expect(categories[0]!.grantedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips isGranted through the wire", async () => {
|
||||||
|
const consent = new InMemoryConsent();
|
||||||
|
const client = makeClient({
|
||||||
|
userId: "user-1",
|
||||||
|
consentFactory: async () => consent,
|
||||||
|
});
|
||||||
|
await client.consent.grant.mutate({ category: "marketing" });
|
||||||
|
const res = await client.consent.isGranted.query({
|
||||||
|
category: "marketing",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ granted: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("consentRouter — error passthrough", () => {
|
describe("consentRouter — error passthrough", () => {
|
||||||
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
||||||
const brokenConsent: IConsent = {
|
const brokenConsent: IConsent = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initTRPC } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
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 type { ConsentFactory } from "./di/bind-production";
|
import type { ConsentFactory } from "./di/bind-production";
|
||||||
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
|
|||||||
consentFactory: ConsentFactory;
|
consentFactory: ConsentFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tc = initTRPC.context<ConsentRouterContext>().create();
|
/**
|
||||||
|
* Consent procedures build on the SHARED `t` instance from
|
||||||
const consentProcedure = tc.procedure
|
* `@repo/core-shared/trpc/init` (audit finding A10): the app router is created
|
||||||
|
* with the superjson transformer, and a router built from a private `initTRPC`
|
||||||
|
* without superjson would corrupt every input/output that crosses the wire.
|
||||||
|
*
|
||||||
|
* The shared `t` is context-untyped, so the middleware narrows `ctx` to
|
||||||
|
* `ConsentRouterContext` at runtime — same cast pattern as the dsr router.
|
||||||
|
*/
|
||||||
|
const consentProcedure = t.procedure
|
||||||
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
||||||
.use(async ({ ctx, next }) => {
|
.use(async ({ ctx, next }) => {
|
||||||
if (!ctx.userId) throw new UnauthenticatedError();
|
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
|
||||||
return next();
|
if (!userId) throw new UnauthenticatedError();
|
||||||
|
if (!consentFactory) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
"consentFactory missing from tRPC context — wire the binding from " +
|
||||||
|
"bindProductionConsent/bindDevSeedConsent into createContext",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ ctx: { ...ctx, userId, consentFactory } });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const consentRouter = tc.router({
|
export const consentRouter = t.router({
|
||||||
grant: consentProcedure
|
grant: consentProcedure
|
||||||
.input(grantHandlerInputSchema)
|
.input(grantHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return grantHandler(consent, input);
|
return grantHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
withdraw: consentProcedure
|
withdraw: consentProcedure
|
||||||
.input(withdrawHandlerInputSchema)
|
.input(withdrawHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return withdrawHandler(consent, input);
|
return withdrawHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
isGranted: consentProcedure
|
isGranted: consentProcedure
|
||||||
.input(isGrantedHandlerInputSchema)
|
.input(isGrantedHandlerInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return isGrantedHandler(consent, input);
|
return isGrantedHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCategories: consentProcedure
|
getCategories: consentProcedure
|
||||||
.input(z.object({}).strict())
|
.input(z.object({}).strict())
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return getCategoriesHandler(consent);
|
return getCategoriesHandler(consent);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export type {
|
|||||||
UserConsentState,
|
UserConsentState,
|
||||||
ConsentGrantMeta,
|
ConsentGrantMeta,
|
||||||
} from "./consent-types";
|
} from "./consent-types";
|
||||||
|
export {
|
||||||
|
KNOWN_CONSENT_CATEGORIES,
|
||||||
|
isKnownConsentCategory,
|
||||||
|
} from "./consent-types";
|
||||||
export type { IConsent } from "./consent.interface";
|
export type { IConsent } from "./consent.interface";
|
||||||
export type { ConsentChecked } from "./with-consent";
|
export type { ConsentChecked } from "./with-consent";
|
||||||
export { withConsent } from "./with-consent";
|
export { withConsent } from "./with-consent";
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractAnonymousConsent — category allow-list (A12)", () => {
|
||||||
|
it("drops unknown categories from the client-controlled cookie", () => {
|
||||||
|
const result = extractAnonymousConsent(
|
||||||
|
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(["necessary", "analytics"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when every category is unknown", () => {
|
||||||
|
expect(
|
||||||
|
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("migrateAnonymousConsent", () => {
|
describe("migrateAnonymousConsent", () => {
|
||||||
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
||||||
const consent = new RecordingConsent();
|
const consent = new RecordingConsent();
|
||||||
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
|
|||||||
expect(consent.isGranted("marketing")).toBe(true);
|
expect(consent.isGranted("marketing")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
|
||||||
|
it("never grants unknown categories even when passed directly", async () => {
|
||||||
|
const consent = new RecordingConsent();
|
||||||
|
await migrateAnonymousConsent({
|
||||||
|
consent,
|
||||||
|
cookieState: ["analytics", "totally-made-up", "marketing"],
|
||||||
|
});
|
||||||
|
expect(consent.grants.map((g) => g.category)).toEqual([
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
|
import {
|
||||||
|
isKnownConsentCategory,
|
||||||
|
type ConsentCategory,
|
||||||
|
type ConsentGrantMeta,
|
||||||
|
} from "./consent-types";
|
||||||
import type { IConsent } from "./consent.interface";
|
import type { IConsent } from "./consent.interface";
|
||||||
|
|
||||||
/** Cookie name written by the anonymous consent banner. */
|
/** Cookie name written by the anonymous consent banner. */
|
||||||
@@ -11,6 +15,10 @@ export const CONSENT_COOKIE_NAME = "cc_consent";
|
|||||||
*
|
*
|
||||||
* Expected cookie value format: comma-separated category names,
|
* Expected cookie value format: comma-separated category names,
|
||||||
* e.g. "necessary,analytics,marketing".
|
* e.g. "necessary,analytics,marketing".
|
||||||
|
*
|
||||||
|
* The cookie is client-controlled, so values are validated against
|
||||||
|
* KNOWN_CONSENT_CATEGORIES (audit finding A12) — unknown strings are
|
||||||
|
* dropped rather than granted.
|
||||||
*/
|
*/
|
||||||
export function extractAnonymousConsent(
|
export function extractAnonymousConsent(
|
||||||
cookieHeader: string,
|
cookieHeader: string,
|
||||||
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
|
|||||||
const categories = raw
|
const categories = raw
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((c) => c.trim())
|
.map((c) => c.trim())
|
||||||
.filter(Boolean) as ConsentCategory[];
|
.filter(Boolean)
|
||||||
|
.filter(isKnownConsentCategory) as ConsentCategory[];
|
||||||
return categories.length > 0 ? categories : null;
|
return categories.length > 0 ? categories : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
|
|||||||
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
||||||
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
||||||
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
|
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
|
||||||
for (const category of cookieState) {
|
// Defense in depth (A12): even a caller that bypassed
|
||||||
|
// extractAnonymousConsent cannot grant unknown categories.
|
||||||
|
for (const category of cookieState.filter(isKnownConsentCategory)) {
|
||||||
await consent.grant(category, meta);
|
await consent.grant(category, meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
|
|||||||
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
|
||||||
|
async function makeTwoConsents() {
|
||||||
|
const mock = makePayloadMock();
|
||||||
|
const a = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
const b = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
// Both instances hydrate from the SAME empty snapshot — the per-request
|
||||||
|
// cache staleness that caused the lost update.
|
||||||
|
await a.load();
|
||||||
|
await b.load();
|
||||||
|
return { a, b, ...mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedCategories(db: Record<string, unknown[]>): string[] {
|
||||||
|
return (db["user_1"] ?? [])
|
||||||
|
.map((e) => (e as { category: string }).category)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("two interleaved grants from stale caches both survive", async () => {
|
||||||
|
const { a, b, db } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
|
||||||
|
|
||||||
|
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a grant and a withdraw on different categories both survive", async () => {
|
||||||
|
const { a, b, db } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing");
|
||||||
|
await a.withdraw("analytics");
|
||||||
|
|
||||||
|
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||||
|
const analytics = (
|
||||||
|
db["user_1"] as Array<{ category: string; state: string }>
|
||||||
|
).find((e) => e.category === "analytics");
|
||||||
|
expect(analytics?.state).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts concurrent writers' entries into the local cache after persist", async () => {
|
||||||
|
const { a, b } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing");
|
||||||
|
|
||||||
|
// b re-read the freshest state during persist, so it now sees a's grant.
|
||||||
|
expect(b.isGranted("analytics")).toBe(true);
|
||||||
|
expect(b.isGranted("marketing")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
|
||||||
|
const mock = makePayloadMock();
|
||||||
|
const a = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
const b = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
await a.load();
|
||||||
|
await b.load();
|
||||||
|
|
||||||
|
// Gate b's persist-read until a's write has committed — the ordering the
|
||||||
|
// read-merge-write strategy is designed for. (A same-window overlap is
|
||||||
|
// the documented residual race.)
|
||||||
|
let releaseB: () => void = () => {};
|
||||||
|
const bGate = new Promise<void>((resolve) => {
|
||||||
|
releaseB = resolve;
|
||||||
|
});
|
||||||
|
const originalFindByID = mock.findByID.getMockImplementation()!;
|
||||||
|
let firstPersistRead = true;
|
||||||
|
// a loads+persists first; instrument findByID so b's persist read waits.
|
||||||
|
mock.findByID.mockImplementation(async (args: { id: string }) => {
|
||||||
|
if (!firstPersistRead) await bGate;
|
||||||
|
firstPersistRead = false;
|
||||||
|
return originalFindByID(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const aDone = a.grant("analytics").then(() => releaseB());
|
||||||
|
const bDone = b.grant("marketing");
|
||||||
|
await Promise.all([aDone, bDone]);
|
||||||
|
|
||||||
|
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
|
|||||||
method: meta?.method,
|
method: meta?.method,
|
||||||
};
|
};
|
||||||
this.cache.set(category, entry);
|
this.cache.set(category, entry);
|
||||||
await this.persist();
|
await this.persist([category]);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: this.userId,
|
actorId: this.userId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
|
|||||||
withdrawnAt: now,
|
withdrawnAt: now,
|
||||||
};
|
};
|
||||||
this.cache.set(category, entry);
|
this.cache.set(category, entry);
|
||||||
await this.persist();
|
await this.persist([category]);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: this.userId,
|
actorId: this.userId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
|
|||||||
return Array.from(this.cache.values());
|
return Array.from(this.cache.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
/**
|
||||||
|
* Read-merge-write persistence (audit finding A7 — lost-update race).
|
||||||
|
*
|
||||||
|
* Payload's `update` on a json field replaces the WHOLE value; there is no
|
||||||
|
* targeted array-element patch. Writing this instance's per-request cache
|
||||||
|
* verbatim would drop any category another request persisted since our
|
||||||
|
* `load()`. Instead we re-read the freshest stored state immediately
|
||||||
|
* before writing and overlay ONLY the categories this call mutated, so
|
||||||
|
* two interleaved writers touching different categories both survive.
|
||||||
|
*
|
||||||
|
* Residual window (documented, accepted): between this read and the write,
|
||||||
|
* a concurrent writer to the SAME category is last-writer-wins, and a
|
||||||
|
* concurrent writer to a different category that lands inside the window
|
||||||
|
* can still be overwritten. Closing it fully needs a DB-level transaction
|
||||||
|
* or JSON-patch support in Payload; for consent state (idempotent,
|
||||||
|
* per-subject, low frequency) read-merge-write is the accepted trade-off.
|
||||||
|
*/
|
||||||
|
private async persist(mutated: ConsentCategory[]): Promise<void> {
|
||||||
const payload = await this.getPayloadFn({ config: this.config });
|
const payload = await this.getPayloadFn({ config: this.config });
|
||||||
const state = Array.from(this.cache.values()).map((entry) => ({
|
|
||||||
|
// Freshest stored state, immediately before the write.
|
||||||
|
const doc = await payload.findByID({
|
||||||
|
collection: "users",
|
||||||
|
id: this.userId,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const merged = new Map<ConsentCategory, UserConsentState>();
|
||||||
|
const rawState = doc["consentState"];
|
||||||
|
if (Array.isArray(rawState)) {
|
||||||
|
for (const raw of rawState) {
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const entry = deserializeEntry(raw as Record<string, unknown>);
|
||||||
|
merged.set(entry.category, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay only what this call changed.
|
||||||
|
for (const category of mutated) {
|
||||||
|
const entry = this.cache.get(category);
|
||||||
|
if (entry) merged.set(category, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt the merged view locally so isGranted/getCategories reflect
|
||||||
|
// concurrent writers' entries too.
|
||||||
|
this.cache = merged;
|
||||||
|
|
||||||
|
const state = Array.from(merged.values()).map((entry) => ({
|
||||||
category: entry.category,
|
category: entry.category,
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.18.0",
|
||||||
"payload": "^3.0.0",
|
"payload": "^3.0.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"@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.0.0",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,15 +208,127 @@ describe("dsrRouter.restrict", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dsrRouter singleton guard", () => {
|
describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
||||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
it("rejects a non-admin export for another subject with FORBIDDEN", async () => {
|
||||||
// The singleton uses a Proxy that throws on any binding property access.
|
const binding = makeBinding();
|
||||||
// Procedures access binding lazily, so the Proxy error surfaces at call time.
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "bob", format: "json" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin delete for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.delete({ subjectId: "bob", mode: "soft" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin rectify for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.rectify({
|
||||||
|
subjectId: "bob",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "x",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin restrict for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.restrict({ subjectId: "bob", granted: true }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin user without an id acting on any subject", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, { roles: ["user"] });
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a non-admin to act on themselves for every operation", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||||
|
await caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice",
|
||||||
|
});
|
||||||
|
await caller.restrict({ subjectId: "alice", granted: true });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to act cross-subject on every operation", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, adminUser);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||||
|
await caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice",
|
||||||
|
});
|
||||||
|
await caller.restrict({ subjectId: "alice", granted: true });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dsrRouter singleton (context-time binding, A11)", () => {
|
||||||
|
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => {
|
||||||
const caller = dsrRouter.createCaller({
|
const caller = dsrRouter.createCaller({
|
||||||
user: authenticatedUser,
|
user: authenticatedUser,
|
||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>);
|
||||||
await expect(
|
await expect(
|
||||||
caller.export({ subjectId: "alice", format: "json" }),
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
).rejects.toThrow(/dsrRouter singleton/);
|
).rejects.toMatchObject({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: expect.stringContaining("DsrBinding missing"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves requests when the app provides ctx.dsrBinding", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = dsrRouter.createCaller({
|
||||||
|
user: authenticatedUser,
|
||||||
|
dsrBinding: binding,
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
expect(result.subjectId).toBe("alice");
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers ctx.dsrBinding over the creation-time binding", async () => {
|
||||||
|
const creationBinding = makeBinding();
|
||||||
|
const ctxBinding = makeBinding();
|
||||||
|
const router = createDsrRouter(creationBinding as unknown as DsrBinding);
|
||||||
|
const caller = router.createCaller({
|
||||||
|
user: authenticatedUser,
|
||||||
|
dsrBinding: ctxBinding,
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
expect(ctxBinding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(creationBinding.dataExport.calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,6 +97,152 @@ describe("PayloadDataDelete", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
deletedAt: expect.any(String),
|
||||||
|
processingRestrictedAt: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData).not.toHaveProperty("deletedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "orders",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "userId", kind: "owner" },
|
||||||
|
pii: { shippingAddress: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P90D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData["deletedAt"]).toEqual(expect.any(String));
|
||||||
|
expect(updateData).not.toHaveProperty("processingRestrictedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts the auth-injected email field for a users-shaped collection (A5)", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication"],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "alice@example.com", username: "alice" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
email: null,
|
||||||
|
username: null,
|
||||||
|
displayName: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(cert.affected[0]?.fields).toEqual(
|
||||||
|
expect.arrayContaining(["email", "username", "displayName"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("owner role: does NOT set processingRestrictedAt", async () => {
|
it("owner role: does NOT set processingRestrictedAt", async () => {
|
||||||
const config = makeMockConfig([
|
const config = makeMockConfig([
|
||||||
{
|
{
|
||||||
@@ -367,3 +513,67 @@ describe("PayloadDataDelete", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cascade-hard audit erasure (A6)", () => {
|
||||||
|
it("calls auditErasure.eraseSubject with pseudonymize after cascade-hard", async () => {
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const mockPayload = {
|
||||||
|
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
delete: vi.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
const eraseSubject = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const config = {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as SanitizedConfig;
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(
|
||||||
|
config,
|
||||||
|
auditLog,
|
||||||
|
vi.fn().mockResolvedValue(mockPayload),
|
||||||
|
{ eraseSubject },
|
||||||
|
);
|
||||||
|
await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||||
|
|
||||||
|
expect(eraseSubject).toHaveBeenCalledWith("alice", "pseudonymize");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not erase audit entries on soft delete", async () => {
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const mockPayload = {
|
||||||
|
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
delete: vi.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
const eraseSubject = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const config = {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as SanitizedConfig;
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(
|
||||||
|
config,
|
||||||
|
auditLog,
|
||||||
|
vi.fn().mockResolvedValue(mockPayload),
|
||||||
|
{ eraseSubject },
|
||||||
|
);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(eraseSubject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,6 +72,60 @@ describe("PayloadDataExport", () => {
|
|||||||
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports the auth-injected email field for a users-shaped collection (A5)", async () => {
|
||||||
|
// Mirrors packages/auth users collection: email is auto-added by Payload
|
||||||
|
// `auth: true` and declared only in the collection-level custom.pii map.
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication"],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: "alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
username: "alice",
|
||||||
|
displayName: "Alice",
|
||||||
|
passwordHash: "secret-hash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
|
||||||
|
const row = bundle.data["users"]!.asSelf![0]!;
|
||||||
|
expect(row["email"]).toBe("alice@example.com");
|
||||||
|
expect(row["username"]).toBe("alice");
|
||||||
|
expect(row["displayName"]).toBe("Alice");
|
||||||
|
expect(row).not.toHaveProperty("passwordHash");
|
||||||
|
});
|
||||||
|
|
||||||
it("happy path — owner role: includes exportable PII fields", async () => {
|
it("happy path — owner role: includes exportable PII fields", async () => {
|
||||||
const config = makeMockConfig([
|
const config = makeMockConfig([
|
||||||
{
|
{
|
||||||
@@ -207,3 +261,103 @@ describe("PayloadDataExport", () => {
|
|||||||
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PayloadDataExport — audit log in the bundle (A14)", () => {
|
||||||
|
it("populates bundle.auditLog from the audit-logs collection, scoped to the subject", async () => {
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const find = vi.fn(async (args: { collection: string }) => {
|
||||||
|
if (args.collection === "audit-logs") {
|
||||||
|
return {
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: "log-1",
|
||||||
|
actorId: "alice",
|
||||||
|
actorType: "user",
|
||||||
|
actorRoles: ["author"],
|
||||||
|
action: "EXPORT",
|
||||||
|
resourceType: "subject-data",
|
||||||
|
resourceId: null,
|
||||||
|
changedFields: null,
|
||||||
|
scopeFeature: "core-dsr",
|
||||||
|
scopeEnvironment: "test",
|
||||||
|
scopeTenant: "default",
|
||||||
|
reason: null,
|
||||||
|
correlationId: "corr-1",
|
||||||
|
requestId: null,
|
||||||
|
ipTruncated: "system",
|
||||||
|
userAgent: "system",
|
||||||
|
containsPii: false,
|
||||||
|
piiCategories: null,
|
||||||
|
outcome: "success",
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { docs: [{ id: "alice", email: "a@ex.com" }] };
|
||||||
|
});
|
||||||
|
const getPayload = vi.fn(async () => ({ find }));
|
||||||
|
const config = {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ slug: "audit-logs" },
|
||||||
|
],
|
||||||
|
} as unknown as SanitizedConfig;
|
||||||
|
|
||||||
|
const exporter = new PayloadDataExport(config, auditLog, getPayload);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "audit-logs",
|
||||||
|
where: { actorId: { equals: "alice" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bundle.auditLog).toHaveLength(1);
|
||||||
|
const entry = bundle.auditLog![0]!;
|
||||||
|
expect(entry.actorId).toBe("alice");
|
||||||
|
expect(entry.action).toBe("EXPORT");
|
||||||
|
expect(entry.correlationId).toBe("corr-1");
|
||||||
|
expect(entry.at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
expect(entry.scope).toEqual({
|
||||||
|
feature: "core-dsr",
|
||||||
|
environment: "test",
|
||||||
|
tenant: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves bundle.auditLog undefined when the audit-logs collection is absent", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const mock = makeMockPayload();
|
||||||
|
mock.find.mockResolvedValue({ docs: [{ id: "alice", email: "x" }] });
|
||||||
|
const exporter = new PayloadDataExport(
|
||||||
|
config,
|
||||||
|
auditLog,
|
||||||
|
vi.fn().mockResolvedValue(mock),
|
||||||
|
);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
expect(bundle.auditLog).toBeUndefined();
|
||||||
|
// no stray find against a non-registered collection
|
||||||
|
expect(
|
||||||
|
mock.find.mock.calls.some(
|
||||||
|
(c) => (c[0] as { collection: string }).collection === "audit-logs",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { IDataDelete } from "../data-delete.interface";
|
|||||||
import type { IDataRectify } from "../data-rectify.interface";
|
import type { IDataRectify } from "../data-rectify.interface";
|
||||||
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||||
import { PayloadDataExport } from "../payload-data-export";
|
import { PayloadDataExport } from "../payload-data-export";
|
||||||
import { PayloadDataDelete } from "../payload-data-delete";
|
import { PayloadDataDelete, type AuditErasure } from "../payload-data-delete";
|
||||||
import { PayloadDataRectify } from "../payload-data-rectify";
|
import { PayloadDataRectify } from "../payload-data-rectify";
|
||||||
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
||||||
|
|
||||||
@@ -19,6 +19,12 @@ export type DsrBinding = {
|
|||||||
export type BindProductionDsrOpts = {
|
export type BindProductionDsrOpts = {
|
||||||
config: SanitizedConfig;
|
config: SanitizedConfig;
|
||||||
auditLog?: AuditLogProtocol;
|
auditLog?: AuditLogProtocol;
|
||||||
|
/**
|
||||||
|
* Privileged audit-erasure surface (core-audit's IAuditLog satisfies it).
|
||||||
|
* When present, cascade-hard deletions pseudonymize the subject's
|
||||||
|
* audit-log entries (A6).
|
||||||
|
*/
|
||||||
|
auditErasure?: AuditErasure;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
|
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
|
||||||
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
|
|||||||
const auditLog = opts.auditLog ?? noopAuditLog;
|
const auditLog = opts.auditLog ?? noopAuditLog;
|
||||||
return {
|
return {
|
||||||
dataExport: new PayloadDataExport(opts.config, auditLog),
|
dataExport: new PayloadDataExport(opts.config, auditLog),
|
||||||
dataDelete: new PayloadDataDelete(opts.config, auditLog),
|
dataDelete: new PayloadDataDelete(
|
||||||
|
opts.config,
|
||||||
|
auditLog,
|
||||||
|
undefined,
|
||||||
|
opts.auditErasure,
|
||||||
|
),
|
||||||
dataRectify: new PayloadDataRectify(opts.config, auditLog),
|
dataRectify: new PayloadDataRectify(opts.config, auditLog),
|
||||||
processingRestriction: new PayloadProcessingRestriction(
|
processingRestriction: new PayloadProcessingRestriction(
|
||||||
opts.config,
|
opts.config,
|
||||||
|
|||||||
@@ -29,21 +29,65 @@ const dsrProcedure = t.procedure
|
|||||||
.use(requireAuthenticated)
|
.use(requireAuthenticated)
|
||||||
.use(defineErrorMiddleware([]));
|
.use(defineErrorMiddleware([]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject-scope guard (audit finding A1 — DSR IDOR).
|
||||||
|
*
|
||||||
|
* Non-admin callers may act ONLY on themselves: a request whose
|
||||||
|
* `input.subjectId` differs from the authenticated user's id is rejected
|
||||||
|
* with FORBIDDEN (rejected, not silently rewritten, so the mismatch is
|
||||||
|
* visible to the caller). Any cross-subject operation requires the
|
||||||
|
* "admin" role.
|
||||||
|
*/
|
||||||
|
function assertSubjectScope(user: DsrTrpcUser, subjectId: string): void {
|
||||||
|
if (user.roles?.includes("admin")) return;
|
||||||
|
if (user.id !== undefined && user.id === subjectId) return;
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "DSR operations on another subject require the admin role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFromCtx(ctx: object): DsrTrpcUser {
|
||||||
|
return (ctx as { user: DsrTrpcUser }).user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tRPC context consumed by the DSR router (provided by the app's createContext). */
|
||||||
|
export type DsrRouterContext = {
|
||||||
|
user?: DsrTrpcUser;
|
||||||
|
/** Per-request DSR binding — the app wires bindProductionDsr/bindDevSeedDsr output here. */
|
||||||
|
dsrBinding?: DsrBinding;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bindingFromCtx(ctx: object, fallback?: DsrBinding): DsrBinding {
|
||||||
|
const fromCtx = (ctx as DsrRouterContext).dsrBinding;
|
||||||
|
if (fromCtx) return fromCtx;
|
||||||
|
if (fallback) return fallback;
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
"DsrBinding missing — provide ctx.dsrBinding from createContext " +
|
||||||
|
"(bindProductionDsr/bindDevSeedDsr) or pass a binding to createDsrRouter",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the DSR tRPC router.
|
* Creates the DSR tRPC router.
|
||||||
*
|
*
|
||||||
* Capture `binding` at router-creation time. Apps that mount this router
|
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
|
||||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
* A11 — the mounted router must be live, not a dead stub), falling back to
|
||||||
|
* the optional `binding` captured at router-creation time.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
|
* // creation-time binding
|
||||||
* const binding = bindProductionDsr({ config, auditLog });
|
* const binding = bindProductionDsr({ config, auditLog });
|
||||||
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
||||||
|
*
|
||||||
|
* // or context-time binding (what apps mounting the `dsrRouter` singleton do)
|
||||||
|
* createContext: () => ({ user, dsrBinding })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createDsrRouter(binding: DsrBinding) {
|
export function createDsrRouter(binding?: DsrBinding) {
|
||||||
// Handlers are created lazily (inside procedure closures) so that the
|
|
||||||
// dsrRouter singleton proxy doesn't trigger at module init time.
|
|
||||||
return t.router({
|
return t.router({
|
||||||
export: dsrProcedure
|
export: dsrProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -54,8 +98,10 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const res = await createExportHandler(binding.dataExport)(input);
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
|
const b = bindingFromCtx(ctx, binding);
|
||||||
|
const res = await createExportHandler(b.dataExport)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -69,16 +115,16 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.mode === "cascade-hard") {
|
const user = userFromCtx(ctx);
|
||||||
const user = (ctx as { user: DsrTrpcUser }).user;
|
assertSubjectScope(user, input.subjectId);
|
||||||
if (!user.roles?.includes("admin")) {
|
if (input.mode === "cascade-hard" && !user.roles?.includes("admin")) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Admin role required for cascade-hard deletion",
|
message: "Admin role required for cascade-hard deletion",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
const b = bindingFromCtx(ctx, binding);
|
||||||
const res = await createDeleteHandler(binding.dataDelete)(input);
|
const res = await createDeleteHandler(b.dataDelete)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -93,9 +139,11 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
|
const b = bindingFromCtx(ctx, binding);
|
||||||
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
||||||
const res = await createRectifyHandler(binding.dataRectify)(
|
const res = await createRectifyHandler(b.dataRectify)(
|
||||||
input as RectifyHandlerInput,
|
input as RectifyHandlerInput,
|
||||||
);
|
);
|
||||||
return res.body;
|
return res.body;
|
||||||
@@ -110,30 +158,21 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
input,
|
const b = bindingFromCtx(ctx, binding);
|
||||||
);
|
const res = await createRestrictHandler(b.processingRestriction)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience singleton for projects with a single DSR binding instance.
|
* Router singleton mounted by the app router. It has no creation-time
|
||||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
|
||||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
* `createContext` supplies per request (A11). Calls without a context
|
||||||
|
* binding fail with INTERNAL_SERVER_ERROR at request time.
|
||||||
*/
|
*/
|
||||||
export const dsrRouter = createDsrRouter(
|
export const dsrRouter = createDsrRouter();
|
||||||
new Proxy({} as DsrBinding, {
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === "then") return undefined; // not a Promise
|
|
||||||
throw new Error(
|
|
||||||
`dsrRouter singleton used without providing a DsrBinding. ` +
|
|
||||||
`Use createDsrRouter(binding) instead.`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type {
|
|||||||
|
|
||||||
export { PayloadDataExport } from "./payload-data-export";
|
export { PayloadDataExport } from "./payload-data-export";
|
||||||
export { PayloadDataDelete } from "./payload-data-delete";
|
export { PayloadDataDelete } from "./payload-data-delete";
|
||||||
|
export type { AuditErasure } from "./payload-data-delete";
|
||||||
export { PayloadDataRectify } from "./payload-data-rectify";
|
export { PayloadDataRectify } from "./payload-data-rectify";
|
||||||
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
|
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
|||||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||||
|
|
||||||
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
||||||
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
|
export type { DsrRouter, DsrTrpcUser, DsrRouterContext } from "./dsr.router";
|
||||||
|
|
||||||
export type { HandlerResponse } from "./handlers/handler-types";
|
export type { HandlerResponse } from "./handlers/handler-types";
|
||||||
export { createExportHandler } from "./handlers/export-handler";
|
export { createExportHandler } from "./handlers/export-handler";
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { getPayload as _getPayload } from "payload";
|
import { getPayload as _getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID, createHmac } from "node:crypto";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||||
|
import {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
import type { IDataDelete } from "./data-delete.interface";
|
import type { IDataDelete } from "./data-delete.interface";
|
||||||
import type {
|
import type {
|
||||||
DeletionMode,
|
DeletionMode,
|
||||||
@@ -35,6 +38,15 @@ type PayloadAPI = {
|
|||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privileged audit-erasure surface (structural subset of core-audit's
|
||||||
|
* IAuditLog — core-dsr must not depend on the optional audit package).
|
||||||
|
* Wired by the app binder; used on the cascade-hard path (A6).
|
||||||
|
*/
|
||||||
|
export type AuditErasure = {
|
||||||
|
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
|
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
|
||||||
return field === "id"
|
return field === "id"
|
||||||
? { id: { equals: subjectId } }
|
? { id: { equals: subjectId } }
|
||||||
@@ -108,6 +120,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
private readonly config: SanitizedConfig,
|
private readonly config: SanitizedConfig,
|
||||||
private readonly auditLog: AuditLogProtocol,
|
private readonly auditLog: AuditLogProtocol,
|
||||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||||
|
private readonly auditErasure?: AuditErasure,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async deleteSubjectData(
|
async deleteSubjectData(
|
||||||
@@ -142,6 +155,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId,
|
subjectId,
|
||||||
correlationId,
|
correlationId,
|
||||||
affected,
|
affected,
|
||||||
|
hasPostDeletionPolicy(collection),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.processReferenceRows(
|
await this.processReferenceRows(
|
||||||
@@ -157,6 +171,14 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "cascade-hard" && this.auditErasure) {
|
||||||
|
// Erase the subject's audit-log linkage (A6): pseudonymize rather than
|
||||||
|
// delete so the audit trail keeps its shape for compliance sampling.
|
||||||
|
// The users afterDelete hook covers Payload-initiated deletes; this
|
||||||
|
// covers the DSR cascade explicitly and is idempotent with the hook.
|
||||||
|
await this.auditErasure.eraseSubject(subjectId, "pseudonymize");
|
||||||
|
}
|
||||||
|
|
||||||
return this.buildCertificate(
|
return this.buildCertificate(
|
||||||
subjectId,
|
subjectId,
|
||||||
mode,
|
mode,
|
||||||
@@ -175,6 +197,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId: string,
|
subjectId: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
affected: DeletionAffected[],
|
affected: DeletionAffected[],
|
||||||
|
postDeletionPolicy: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const piiMeta = custom.pii ?? {};
|
const piiMeta = custom.pii ?? {};
|
||||||
const exportableFields = Object.entries(piiMeta)
|
const exportableFields = Object.entries(piiMeta)
|
||||||
@@ -183,10 +206,17 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
|
|
||||||
if (mode === "soft") {
|
if (mode === "soft") {
|
||||||
const kind = custom.subject?.kind;
|
const kind = custom.subject?.kind;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
const extraData: Record<string, unknown> =
|
const extraData: Record<string, unknown> =
|
||||||
kind === "self"
|
kind === "self" ? { processingRestrictedAt: nowIso } : {};
|
||||||
? { processingRestrictedAt: new Date().toISOString() }
|
if (postDeletionPolicy) {
|
||||||
: {};
|
// Soft-delete tombstone (A2): collections with a
|
||||||
|
// custom.retention.postDeletion policy get stamped so the retention
|
||||||
|
// purge job can hard-delete/pseudonymize them once the grace period
|
||||||
|
// elapses. Kept separate from processingRestrictedAt — an Art. 18
|
||||||
|
// restriction alone must never trigger the purge.
|
||||||
|
extraData[RETENTION_TOMBSTONE_FIELD] = nowIso;
|
||||||
|
}
|
||||||
await softRedactOwnerRows(
|
await softRedactOwnerRows(
|
||||||
payload,
|
payload,
|
||||||
slug,
|
slug,
|
||||||
@@ -290,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
correlationId: string,
|
correlationId: string,
|
||||||
affected: DeletionAffected[],
|
affected: DeletionAffected[],
|
||||||
): DeletionCertificate {
|
): DeletionCertificate {
|
||||||
|
// Salted, keyed pseudonym (audit finding A13): the certificate used to
|
||||||
|
// hash the raw subjectId with NO salt (truncated to 64 bits), letting a
|
||||||
|
// certificate holder brute-force small id spaces offline. Now
|
||||||
|
// HMAC-SHA256 keyed by the operator secret AUDIT_PSEUDONYM_SALT (the
|
||||||
|
// same secret the audit-log pseudonymizer uses), truncated to 128 bits.
|
||||||
|
// NOTE: this changes tokens on FUTURE certificates only — certificates
|
||||||
|
// issued under the old scheme keep their historical value, and rotating
|
||||||
|
// the key likewise affects only certificates issued afterwards.
|
||||||
|
const certKey =
|
||||||
|
process.env["AUDIT_PSEUDONYM_SALT"] ??
|
||||||
|
"dev-fallback-salt-replace-in-prod";
|
||||||
const certSubjectId =
|
const certSubjectId =
|
||||||
mode === "cascade-hard"
|
mode === "cascade-hard"
|
||||||
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
|
? `erased-${createHmac("sha256", certKey).update(subjectId).digest("hex").slice(0, 32)}`
|
||||||
: subjectId;
|
: subjectId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPayload as _getPayload } from "payload";
|
import { getPayload as _getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||||
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
import type { IDataExport } from "./data-export.interface";
|
import type { IDataExport } from "./data-export.interface";
|
||||||
import type {
|
import type {
|
||||||
DsrFormat,
|
DsrFormat,
|
||||||
@@ -42,6 +43,54 @@ type PayloadAPI = {
|
|||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||||
|
|
||||||
|
const AUDIT_LOGS_SLUG = "audit-logs";
|
||||||
|
|
||||||
|
/** Reconstruct an AuditEntry from its flat audit-logs collection row. */
|
||||||
|
function docToAuditEntry(doc: PayloadDoc): AuditEntry {
|
||||||
|
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||||
|
const opt = (v: unknown): string | undefined =>
|
||||||
|
v == null ? undefined : String(v);
|
||||||
|
const entry: AuditEntry = {
|
||||||
|
actorId: str(doc["actorId"]),
|
||||||
|
actorType: (doc["actorType"] as AuditEntry["actorType"]) ?? "user",
|
||||||
|
actorRoles: Array.isArray(doc["actorRoles"])
|
||||||
|
? (doc["actorRoles"] as string[])
|
||||||
|
: [],
|
||||||
|
action: doc["action"] as AuditEntry["action"],
|
||||||
|
resource: {
|
||||||
|
type: str(doc["resourceType"]),
|
||||||
|
...(doc["resourceId"] != null ? { id: String(doc["resourceId"]) } : {}),
|
||||||
|
},
|
||||||
|
at: new Date(str(doc["createdAt"])),
|
||||||
|
scope: {
|
||||||
|
feature: str(doc["scopeFeature"]),
|
||||||
|
environment: str(doc["scopeEnvironment"]),
|
||||||
|
tenant: str(doc["scopeTenant"]),
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
ipTruncated: str(doc["ipTruncated"]),
|
||||||
|
userAgent: str(doc["userAgent"]),
|
||||||
|
},
|
||||||
|
containsPii: Boolean(doc["containsPii"]),
|
||||||
|
outcome: (doc["outcome"] as AuditEntry["outcome"]) ?? "success",
|
||||||
|
};
|
||||||
|
if (Array.isArray(doc["changedFields"])) {
|
||||||
|
entry.changedFields = doc["changedFields"] as string[];
|
||||||
|
}
|
||||||
|
if (Array.isArray(doc["piiCategories"])) {
|
||||||
|
entry.piiCategories = doc["piiCategories"] as string[];
|
||||||
|
}
|
||||||
|
const reason = opt(doc["reason"]);
|
||||||
|
if (reason) entry.reason = reason;
|
||||||
|
const correlationId = opt(doc["correlationId"]);
|
||||||
|
if (correlationId) entry.correlationId = correlationId;
|
||||||
|
const requestId = opt(doc["requestId"]);
|
||||||
|
if (requestId) entry.requestId = requestId;
|
||||||
|
const errorCode = opt(doc["errorCode"]);
|
||||||
|
if (errorCode) entry.errorCode = errorCode;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload-backed IDataExport. Walks all collections annotated with
|
* Payload-backed IDataExport. Walks all collections annotated with
|
||||||
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
||||||
@@ -112,6 +161,21 @@ export class PayloadDataExport implements IDataExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GDPR Art. 15(1) includes the processing record: populate the subject's
|
||||||
|
// audit-log entries when the local audit sink is registered (audit
|
||||||
|
// finding A14). Scoped strictly to actorId === subjectId; absent
|
||||||
|
// collection (audit core not scaffolded) → field stays undefined.
|
||||||
|
let auditEntries: AuditEntry[] | undefined;
|
||||||
|
if (this.config.collections.some((c) => c.slug === AUDIT_LOGS_SLUG)) {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: AUDIT_LOGS_SLUG,
|
||||||
|
where: { actorId: { equals: subjectId } },
|
||||||
|
overrideAccess: true,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
auditEntries = result.docs.map(docToAuditEntry);
|
||||||
|
}
|
||||||
|
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: subjectId,
|
actorId: subjectId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport {
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (auditEntries) {
|
||||||
|
bundle.auditLog = auditEntries;
|
||||||
|
}
|
||||||
|
|
||||||
if (format === "json-ld") {
|
if (format === "json-ld") {
|
||||||
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import eslintConfigPrettier from "eslint-config-prettier";
|
|||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import turboPlugin from "eslint-plugin-turbo";
|
import turboPlugin from "eslint-plugin-turbo";
|
||||||
import boundaries from "eslint-plugin-boundaries";
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import conformancePlugin from "./plugin.js";
|
import conformancePlugin from "./plugin.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -118,6 +119,17 @@ export default [
|
|||||||
message:
|
message:
|
||||||
"Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly.",
|
"Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
"@opentelemetry/sdk-*",
|
||||||
|
"@opentelemetry/exporter-*",
|
||||||
|
"@opentelemetry/instrumentation-*",
|
||||||
|
"@opentelemetry/resources",
|
||||||
|
"@opentelemetry/semantic-conventions",
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
"OTel SDK imports are restricted to core-shared/instrumentation/otel/ and app init paths — import @repo/core-shared/instrumentation instead (ADR-017).",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -176,11 +188,21 @@ export default [
|
|||||||
"**/instrumentation.{ts,js,mjs}",
|
"**/instrumentation.{ts,js,mjs}",
|
||||||
"**/next.config.{mjs,ts,js}",
|
"**/next.config.{mjs,ts,js}",
|
||||||
"**/vite.config.{ts,mjs,js}",
|
"**/vite.config.{ts,mjs,js}",
|
||||||
|
// core-audit's trace-id enrichment test builds a real in-memory tracer
|
||||||
|
// (@opentelemetry/sdk-trace-base) to assert trace-id propagation.
|
||||||
|
"**/trace-id-enriching-audit-log.test.{ts,js}",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"no-restricted-imports": "off",
|
"no-restricted-imports": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// React rules-of-hooks for every TSX surface (apps, core-ui, feature
|
||||||
|
// ui/**). Scoped to .tsx so non-React packages are untouched.
|
||||||
|
{
|
||||||
|
files: ["**/*.tsx"],
|
||||||
|
plugins: { "react-hooks": reactHooks },
|
||||||
|
rules: reactHooks.configs.recommended.rules,
|
||||||
|
},
|
||||||
// E1 — Event handlers must not be re-exported. Wire them only inside the
|
// E1 — Event handlers must not be re-exported. Wire them only inside the
|
||||||
// consumer feature's bind-production / bind-dev-seed (spec § 2.2 Rule E1).
|
// consumer feature's bind-production / bind-dev-seed (spec § 2.2 Rule E1).
|
||||||
// J — Direct `payload.jobs.*` access is forbidden outside the integration
|
// J — Direct `payload.jobs.*` access is forbidden outside the integration
|
||||||
|
|||||||
@@ -16,12 +16,14 @@
|
|||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||||
"@typescript-eslint/parser": "^8.25.0",
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.20.0",
|
||||||
"eslint-config-prettier": "^10.1.0",
|
"eslint-config-prettier": "^10.1.0",
|
||||||
"eslint-plugin-boundaries": "^4.2.2",
|
"eslint-plugin-boundaries": "^4.2.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-turbo": "^2.4.0",
|
"eslint-plugin-turbo": "^2.4.0",
|
||||||
"typescript-eslint": "^8.25.0",
|
"typescript-eslint": "^8.25.0",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.2.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"globals": "^17.6.0"
|
"globals": "^17.6.0"
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { parse } from "@typescript-eslint/parser";
|
import { parse } from "@typescript-eslint/parser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE parser for feature.manifest.ts, shared by every conformance rule and
|
||||||
|
* by scripts/conformance.mjs. Walks the AST to the `defineFeature({...})`
|
||||||
|
* call (unwrapping `as const` / `satisfies` / both) and reads literal
|
||||||
|
* values from its argument object.
|
||||||
|
*
|
||||||
|
* The file used to carry a verbatim second copy of this walk for
|
||||||
|
* `parseManifestFully` — both public entry points now share the single
|
||||||
|
* implementation below.
|
||||||
|
*/
|
||||||
|
|
||||||
function extractStringLiterals(arrayExpr) {
|
function extractStringLiterals(arrayExpr) {
|
||||||
return arrayExpr.elements
|
return arrayExpr.elements
|
||||||
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
|
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
|
||||||
@@ -32,55 +43,6 @@ function extractRateLimitNames(arrayExpr) {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a feature.manifest.ts file and extract per-use-case attributes.
|
|
||||||
* Walks the AST to find the `defineFeature({...} as const)` call expression
|
|
||||||
* and reads literal values from its argument object.
|
|
||||||
*
|
|
||||||
* Returns: { [useCaseName]: { mutates, audits[], publishes[], consumes[] } }
|
|
||||||
* Returns null if the file is missing or doesn't match the expected shape.
|
|
||||||
*/
|
|
||||||
export function parseManifestUseCases(manifestPath) {
|
|
||||||
let src;
|
|
||||||
try {
|
|
||||||
src = fs.readFileSync(manifestPath, "utf8");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let ast;
|
|
||||||
try {
|
|
||||||
ast = parse(src, {
|
|
||||||
sourceType: "module",
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
loc: false,
|
|
||||||
range: false,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const defineCall = findDefineFeatureCall(ast);
|
|
||||||
if (!defineCall) return null;
|
|
||||||
const arg = unwrapAsConst(defineCall.arguments[0]);
|
|
||||||
if (!arg || arg.type !== "ObjectExpression") return null;
|
|
||||||
const useCasesProp = arg.properties.find(
|
|
||||||
(p) =>
|
|
||||||
p.type === "Property" &&
|
|
||||||
p.key.type === "Identifier" &&
|
|
||||||
p.key.name === "useCases",
|
|
||||||
);
|
|
||||||
if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression")
|
|
||||||
return {};
|
|
||||||
const result = {};
|
|
||||||
for (const entry of useCasesProp.value.properties) {
|
|
||||||
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression")
|
|
||||||
continue;
|
|
||||||
const name =
|
|
||||||
entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
|
|
||||||
result[name] = extractUseCaseEntry(entry.value);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDefineFeatureCall(ast) {
|
function findDefineFeatureCall(ast) {
|
||||||
for (const node of ast.body) {
|
for (const node of ast.body) {
|
||||||
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
|
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
|
||||||
@@ -101,7 +63,15 @@ function findDefineFeatureCall(ast) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unwrapAsConst(node) {
|
function unwrapAsConst(node) {
|
||||||
if (node && node.type === "TSAsExpression") return node.expression;
|
// Unwrap BOTH wrapper kinds, including the combined
|
||||||
|
// `{...} as const satisfies FeatureManifest` idiom — a `satisfies`-shaped
|
||||||
|
// manifest must never silently no-op the error-level conformance rules.
|
||||||
|
while (
|
||||||
|
node &&
|
||||||
|
(node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
|
||||||
|
) {
|
||||||
|
node = node.expression;
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,17 +104,23 @@ function extractUseCaseEntry(objExpr) {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractUseCasesMap(useCasesObjExpr) {
|
||||||
|
const result = {};
|
||||||
|
for (const entry of useCasesObjExpr.properties) {
|
||||||
|
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression")
|
||||||
|
continue;
|
||||||
|
const name =
|
||||||
|
entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
|
||||||
|
result[name] = extractUseCaseEntry(entry.value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a feature.manifest.ts and return the full manifest shape:
|
* Read + parse the manifest and return the `defineFeature` argument object,
|
||||||
* { name, requiredCores: string[], useCases: { [name]: {...} } }
|
* or null when the file is missing / unparseable / not the expected shape.
|
||||||
*
|
|
||||||
* Same AST walking as parseManifestUseCases but additionally extracts
|
|
||||||
* the top-level name + requiredCores fields. Returns null on parse failure.
|
|
||||||
*/
|
*/
|
||||||
export function parseManifestFully(manifestPath) {
|
function parseManifestArg(manifestPath) {
|
||||||
// Reuse the AST parser used by parseManifestUseCases by inlining the
|
|
||||||
// file-read + AST walk. We need access to the manifest's top-level
|
|
||||||
// argument object beyond just useCases.
|
|
||||||
let src;
|
let src;
|
||||||
try {
|
try {
|
||||||
src = fs.readFileSync(manifestPath, "utf8");
|
src = fs.readFileSync(manifestPath, "utf8");
|
||||||
@@ -162,10 +138,21 @@ export function parseManifestFully(manifestPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const defineCall = findDefineFeatureCallFromBody(ast);
|
const defineCall = findDefineFeatureCall(ast);
|
||||||
if (!defineCall) return null;
|
if (!defineCall) return null;
|
||||||
const arg = unwrapAsConstNode(defineCall.arguments[0]);
|
const arg = unwrapAsConst(defineCall.arguments[0]);
|
||||||
if (!arg || arg.type !== "ObjectExpression") return null;
|
if (!arg || arg.type !== "ObjectExpression") return null;
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a feature.manifest.ts and return the full manifest shape:
|
||||||
|
* { name, requiredCores: string[], requiresConsent: string[], useCases: { [name]: {...} } }
|
||||||
|
* Returns null on parse failure or when the manifest has no `name`.
|
||||||
|
*/
|
||||||
|
export function parseManifestFully(manifestPath) {
|
||||||
|
const arg = parseManifestArg(manifestPath);
|
||||||
|
if (arg === null) return null;
|
||||||
|
|
||||||
let name = null;
|
let name = null;
|
||||||
let requiredCores = [];
|
let requiredCores = [];
|
||||||
@@ -202,69 +189,22 @@ export function parseManifestFully(manifestPath) {
|
|||||||
return { name, requiredCores, requiresConsent, useCases };
|
return { name, requiredCores, requiresConsent, useCases };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUseCasesMap(useCasesObjExpr) {
|
/**
|
||||||
const result = {};
|
* Parse a feature.manifest.ts and extract per-use-case attributes only:
|
||||||
for (const entry of useCasesObjExpr.properties) {
|
* { [useCaseName]: { mutates, audits[], publishes[], consumes[], analyticsEvents[], rateLimit[] } }
|
||||||
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression")
|
* Returns null if the file is missing or doesn't match the expected shape;
|
||||||
continue;
|
* returns {} for a manifest whose useCases object is empty/absent.
|
||||||
const ucName =
|
*/
|
||||||
entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
|
export function parseManifestUseCases(manifestPath) {
|
||||||
result[ucName] = extractUseCaseEntryFromObj(entry.value);
|
const arg = parseManifestArg(manifestPath);
|
||||||
}
|
if (arg === null) return null;
|
||||||
return result;
|
const useCasesProp = arg.properties.find(
|
||||||
}
|
(p) =>
|
||||||
|
p.type === "Property" &&
|
||||||
// Helper aliases for the existing private functions — exposed under
|
p.key.type === "Identifier" &&
|
||||||
// different names to avoid touching existing code paths.
|
p.key.name === "useCases",
|
||||||
function findDefineFeatureCallFromBody(ast) {
|
);
|
||||||
for (const node of ast.body) {
|
if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression")
|
||||||
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
|
return {};
|
||||||
if (node.declaration.type !== "VariableDeclaration") continue;
|
return extractUseCasesMap(useCasesProp.value);
|
||||||
for (const decl of node.declaration.declarations) {
|
|
||||||
const init = decl.init;
|
|
||||||
if (!init) continue;
|
|
||||||
if (
|
|
||||||
init.type === "CallExpression" &&
|
|
||||||
init.callee.type === "Identifier" &&
|
|
||||||
init.callee.name === "defineFeature"
|
|
||||||
) {
|
|
||||||
return init;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapAsConstNode(node) {
|
|
||||||
if (node && node.type === "TSAsExpression") return node.expression;
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractUseCaseEntryFromObj(objExpr) {
|
|
||||||
const entry = {
|
|
||||||
mutates: false,
|
|
||||||
audits: [],
|
|
||||||
publishes: [],
|
|
||||||
consumes: [],
|
|
||||||
analyticsEvents: [],
|
|
||||||
rateLimit: [],
|
|
||||||
};
|
|
||||||
for (const prop of objExpr.properties) {
|
|
||||||
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
|
|
||||||
const key = prop.key.name;
|
|
||||||
if (key === "mutates" && prop.value.type === "Literal") {
|
|
||||||
entry.mutates = prop.value.value === true;
|
|
||||||
} else if (
|
|
||||||
(key === "audits" ||
|
|
||||||
key === "publishes" ||
|
|
||||||
key === "consumes" ||
|
|
||||||
key === "analyticsEvents") &&
|
|
||||||
prop.value.type === "ArrayExpression"
|
|
||||||
) {
|
|
||||||
entry[key] = extractStringLiterals(prop.value);
|
|
||||||
} else if (key === "rateLimit" && prop.value.type === "ArrayExpression") {
|
|
||||||
entry[key] = extractRateLimitNames(prop.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,51 @@ describe("parseManifestFully", () => {
|
|||||||
expect(parseManifestFully(fp)).toBeNull();
|
expect(parseManifestFully(fp)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses a `satisfies FeatureManifest` manifest (must not silently no-op the gates)", () => {
|
||||||
|
const fp = writeManifest(`export const xManifest = defineFeature({
|
||||||
|
name: "x",
|
||||||
|
requiredCores: [],
|
||||||
|
useCases: {
|
||||||
|
doThing: { mutates: true, audits: ["x.done"], publishes: ["x.done"], consumes: [] },
|
||||||
|
},
|
||||||
|
realtimeChannels: [],
|
||||||
|
jobs: [],
|
||||||
|
} satisfies FeatureManifest);`);
|
||||||
|
expect(parseManifestUseCases(fp)).toEqual({
|
||||||
|
doThing: {
|
||||||
|
mutates: true,
|
||||||
|
audits: ["x.done"],
|
||||||
|
publishes: ["x.done"],
|
||||||
|
consumes: [],
|
||||||
|
analyticsEvents: [],
|
||||||
|
rateLimit: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parseManifestFully(fp)?.name).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the combined `as const satisfies FeatureManifest` idiom", () => {
|
||||||
|
const fp = writeManifest(`export const xManifest = defineFeature({
|
||||||
|
name: "x",
|
||||||
|
requiredCores: [],
|
||||||
|
useCases: {
|
||||||
|
doThing: { mutates: false, audits: [], publishes: [], consumes: [] },
|
||||||
|
},
|
||||||
|
realtimeChannels: [],
|
||||||
|
jobs: [],
|
||||||
|
} as const satisfies FeatureManifest);`);
|
||||||
|
expect(parseManifestUseCases(fp)).toEqual({
|
||||||
|
doThing: {
|
||||||
|
mutates: false,
|
||||||
|
audits: [],
|
||||||
|
publishes: [],
|
||||||
|
consumes: [],
|
||||||
|
analyticsEvents: [],
|
||||||
|
rateLimit: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("is not fooled by 'name:' inside a JSDoc comment (regex would false-match)", () => {
|
it("is not fooled by 'name:' inside a JSDoc comment (regex would false-match)", () => {
|
||||||
const fp = writeManifest(`/**
|
const fp = writeManifest(`/**
|
||||||
* Sample comment with name: "fake" embedded in it.
|
* Sample comment with name: "fake" embedded in it.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { parseManifestUseCases } from "./_manifest-ast.js";
|
import { parseManifestUseCases } from "./_manifest-ast.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,8 @@ export default {
|
|||||||
messages: {
|
messages: {
|
||||||
missing:
|
missing:
|
||||||
'Use case "{{useCase}}" is declared in {{feature}}.manifest.ts but not wired through wireUseCase({ name: "{{useCase}}", ... }) in this binder. Add the wireUseCase call or remove the manifest entry.',
|
'Use case "{{useCase}}" is declared in {{feature}}.manifest.ts but not wired through wireUseCase({ name: "{{useCase}}", ... }) in this binder. Add the wireUseCase call or remove the manifest entry.',
|
||||||
|
unparseableManifest:
|
||||||
|
"feature.manifest.ts for {{feature}} exists but could not be parsed by the conformance rules; the wiring gate cannot run. Fix the manifest shape (defineFeature({...} as const)).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
@@ -49,8 +52,23 @@ export default {
|
|||||||
|
|
||||||
const featureRoot = featureRootForFile(filename, repoRoot);
|
const featureRoot = featureRootForFile(filename, repoRoot);
|
||||||
if (!featureRoot) return {};
|
if (!featureRoot) return {};
|
||||||
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
|
const manifestPath = manifestPathForFeature(featureRoot);
|
||||||
if (!manifest) return {};
|
const manifest = parseManifestUseCases(manifestPath);
|
||||||
|
if (!manifest) {
|
||||||
|
// An unparseable manifest must FAIL the gate, not silently disable it.
|
||||||
|
if (fs.existsSync(manifestPath)) {
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "unparseableManifest",
|
||||||
|
data: { feature: path.basename(featureRoot) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
const declaredNames = Object.keys(manifest);
|
const declaredNames = Object.keys(manifest);
|
||||||
if (declaredNames.length === 0) return {};
|
if (declaredNames.length === 0) return {};
|
||||||
const featureName = path.basename(featureRoot);
|
const featureName = path.basename(featureRoot);
|
||||||
|
|||||||
@@ -153,6 +153,35 @@ describe("usecase-must-be-wired", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports unparseableManifest when the manifest exists but cannot be parsed", () => {
|
||||||
|
const { repoRoot, binderFile } = makeFixture({
|
||||||
|
manifestUseCases: {},
|
||||||
|
binderBody: `export function bindProductionDemo(ctx) {}`,
|
||||||
|
});
|
||||||
|
// Overwrite the manifest with a shape the AST walker cannot read
|
||||||
|
// (no defineFeature call) — the gate must fail loudly, not no-op.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoRoot, "packages", "demo", "src", "feature.manifest.ts"),
|
||||||
|
`export const demoManifest = { name: "demo" };`,
|
||||||
|
);
|
||||||
|
tester.run("usecase-must-be-wired", rule, {
|
||||||
|
valid: [],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
filename: binderFile,
|
||||||
|
code: fs.readFileSync(binderFile, "utf8"),
|
||||||
|
options: [{ repoRoot }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
messageId: "unparseableManifest",
|
||||||
|
data: { feature: "demo" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("is a no-op when the feature manifest declares no use cases", () => {
|
it("is a no-op when the feature manifest declares no use cases", () => {
|
||||||
const { repoRoot, binderFile } = makeFixture({
|
const { repoRoot, binderFile } = makeFixture({
|
||||||
manifestUseCases: {},
|
manifestUseCases: {},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"./payload": "./src/payload/index.ts",
|
"./payload": "./src/payload/index.ts",
|
||||||
"./trpc/init": "./src/trpc/init.ts",
|
"./trpc/init": "./src/trpc/init.ts",
|
||||||
"./trpc/context": "./src/trpc/context.ts",
|
"./trpc/context": "./src/trpc/context.ts",
|
||||||
|
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
|
||||||
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
||||||
"./instrumentation": "./src/instrumentation/index.ts",
|
"./instrumentation": "./src/instrumentation/index.ts",
|
||||||
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"@opentelemetry/semantic-conventions": "^1.27.0",
|
"@opentelemetry/semantic-conventions": "^1.27.0",
|
||||||
"@sentry/nextjs": "^10.51.0",
|
"@sentry/nextjs": "^10.51.0",
|
||||||
"@sentry/opentelemetry": "^10.51.0",
|
"@sentry/opentelemetry": "^10.51.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.18.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
@@ -69,7 +70,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"next": "^15.3.0",
|
|
||||||
"@opentelemetry/context-async-hooks": "^1.28.0",
|
"@opentelemetry/context-async-hooks": "^1.28.0",
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
@@ -77,9 +77,10 @@
|
|||||||
"@sentry/node": "^10.51.0",
|
"@sentry/node": "^10.51.0",
|
||||||
"@sentry/react": "^10.51.0",
|
"@sentry/react": "^10.51.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.7",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
|
"next": "^15.3.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,50 @@ describe("ProductionUseCase<I, O, M>", () => {
|
|||||||
consumes: readonly [];
|
consumes: readonly [];
|
||||||
};
|
};
|
||||||
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
|
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
|
||||||
type Wrapped = Instrumented<(input: { x: number }) => Promise<{ y: string }>> &
|
type Wrapped = Instrumented<
|
||||||
|
(input: { x: number }) => Promise<{ y: string }>
|
||||||
|
> &
|
||||||
Captured<(input: { x: number }) => Promise<{ y: string }>>;
|
Captured<(input: { x: number }) => Promise<{ y: string }>>;
|
||||||
|
|
||||||
expectTypeOf<Wrapped>().toMatchTypeOf<Slot>();
|
expectTypeOf<Wrapped>().toMatchTypeOf<Slot>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires Analyzed when the manifest declares analyticsEvents", () => {
|
||||||
|
type Manifest = {
|
||||||
|
mutates: false;
|
||||||
|
audits: readonly [];
|
||||||
|
publishes: readonly [];
|
||||||
|
consumes: readonly [];
|
||||||
|
analyticsEvents: readonly ["auth.signed_in"];
|
||||||
|
};
|
||||||
|
type Fn = (input: { x: number }) => Promise<{ y: string }>;
|
||||||
|
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
|
||||||
|
type WithoutAnalyzed = Instrumented<Fn> & Captured<Fn>;
|
||||||
|
type WithAnalyzed = WithoutAnalyzed & { readonly __analyzed: true };
|
||||||
|
|
||||||
|
expectTypeOf<WithAnalyzed>().toMatchTypeOf<Slot>();
|
||||||
|
expectTypeOf<WithoutAnalyzed>().not.toMatchTypeOf<Slot>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires RateLimited when the manifest declares rateLimit budgets", () => {
|
||||||
|
type Manifest = {
|
||||||
|
mutates: true;
|
||||||
|
audits: readonly [];
|
||||||
|
publishes: readonly [];
|
||||||
|
consumes: readonly [];
|
||||||
|
rateLimit: readonly [{ name: "ip"; window: "1m"; budget: 5 }];
|
||||||
|
};
|
||||||
|
type Fn = (input: { x: number }) => Promise<{ y: string }>;
|
||||||
|
type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>;
|
||||||
|
type WithoutRateLimited = Instrumented<Fn> & Captured<Fn>;
|
||||||
|
type WithRateLimited = WithoutRateLimited & {
|
||||||
|
readonly __rateLimited: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
expectTypeOf<WithRateLimited>().toMatchTypeOf<Slot>();
|
||||||
|
expectTypeOf<WithoutRateLimited>().not.toMatchTypeOf<Slot>();
|
||||||
|
});
|
||||||
|
|
||||||
it("a plain factory is NOT assignable to the slot", () => {
|
it("a plain factory is NOT assignable to the slot", () => {
|
||||||
type Manifest = {
|
type Manifest = {
|
||||||
mutates: false;
|
mutates: false;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { UseCaseManifest } from "./define-feature";
|
import type { UseCaseManifest } from "./define-feature";
|
||||||
import type { Instrumented, Captured } from "./brands";
|
import type { Instrumented, Captured, Analyzed, RateLimited } from "./brands";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-level binding slot for production use cases. Derived from the manifest
|
* Type-level binding slot for production use cases. Derived from the manifest
|
||||||
* entry: every binding must be Instrumented + Captured; mutating use cases
|
* entry: every binding must be Instrumented + Captured; mutating use cases
|
||||||
* that declare audits additionally must be Audited. The Audited brand lives
|
* that declare audits additionally must be Audited; use cases that declare
|
||||||
|
* non-empty `analyticsEvents` must be Analyzed; use cases that declare
|
||||||
|
* non-empty `rateLimit` must be RateLimited. The Audited brand lives
|
||||||
* in `@repo/core-audit` because the wrap helper that attaches it depends on
|
* in `@repo/core-audit` because the wrap helper that attaches it depends on
|
||||||
* `IAuditLog` — feature packages import the merged slot type implicitly
|
* `IAuditLog` — feature packages import the merged slot type implicitly
|
||||||
* by typing their bindings as `ProductionUseCase<I, O, AuthManifest["useCases"]["signIn"]>`.
|
* by typing their bindings as `ProductionUseCase<I, O, AuthManifest["useCases"]["signIn"]>`.
|
||||||
@@ -13,12 +15,24 @@ import type { Instrumented, Captured } from "./brands";
|
|||||||
* without depending on core-audit. When `mutates: true` AND `audits` is
|
* without depending on core-audit. When `mutates: true` AND `audits` is
|
||||||
* non-empty, the slot demands a marker type with a `__audited` flag; the
|
* non-empty, the slot demands a marker type with a `__audited` flag; the
|
||||||
* concrete `Audited<F>` from core-audit satisfies it.
|
* concrete `Audited<F>` from core-audit satisfies it.
|
||||||
|
*
|
||||||
|
* `requiresConsent` is feature-scoped (a `FeatureManifest` field, not a
|
||||||
|
* per-use-case one), so the `__consentChecked` brand cannot be derived from
|
||||||
|
* the use-case entry here — it is enforced at boot by
|
||||||
|
* `assertFeatureConformance` instead.
|
||||||
*/
|
*/
|
||||||
export type ProductionUseCase<I, O, M extends UseCaseManifest> =
|
export type ProductionUseCase<I, O, M extends UseCaseManifest> = Instrumented<
|
||||||
& Instrumented<(input: I) => Promise<O>>
|
(input: I) => Promise<O>
|
||||||
& Captured<(input: I) => Promise<O>>
|
> &
|
||||||
& (M["mutates"] extends true
|
Captured<(input: I) => Promise<O>> &
|
||||||
|
(M["mutates"] extends true
|
||||||
? M["audits"]["length"] extends 0
|
? M["audits"]["length"] extends 0
|
||||||
? unknown
|
? unknown
|
||||||
: { readonly __audited: true }
|
: { readonly __audited: true }
|
||||||
|
: unknown) &
|
||||||
|
(M["analyticsEvents"] extends readonly [string, ...string[]]
|
||||||
|
? Analyzed<(input: I) => Promise<O>>
|
||||||
|
: unknown) &
|
||||||
|
(M["rateLimit"] extends readonly [unknown, ...unknown[]]
|
||||||
|
? RateLimited<(input: I) => Promise<O>>
|
||||||
: unknown);
|
: unknown);
|
||||||
|
|||||||
@@ -1,5 +1,70 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { initOtelServerNode } from "./init-server-node";
|
import { initOtelServerNode } from "./init-server-node";
|
||||||
|
import {
|
||||||
|
PiiScrubSpanProcessor,
|
||||||
|
PiiScrubLogRecordProcessor,
|
||||||
|
} from "./pii-scrub-processor";
|
||||||
|
import { SentryLogRecordForwarder } from "./sentry-bridge";
|
||||||
|
|
||||||
|
const captured = vi.hoisted(() => ({ configs: [] as unknown[] }));
|
||||||
|
|
||||||
|
// Hoisted alongside `captured`: the vi.mock factory below is hoisted above
|
||||||
|
// every top-level statement, so a plain top-level class would still be in its
|
||||||
|
// temporal dead zone when the factory runs (ReferenceError).
|
||||||
|
const FakeBatchSpanProcessor = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class FakeBatchSpanProcessor {
|
||||||
|
constructor(public readonly wrapped: unknown) {}
|
||||||
|
onStart() {}
|
||||||
|
onEnd() {}
|
||||||
|
forceFlush() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
shutdown() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local mock overrides the setup-file stub (the pattern no-instrumentation.ts
|
||||||
|
// documents) so the NodeSDK constructor CONFIG is capturable — the previous
|
||||||
|
// tests asserted nothing about it, leaving the ADR-017 §7 security invariant
|
||||||
|
// (PII scrub runs before any exporter) untested.
|
||||||
|
vi.mock("@opentelemetry/sdk-node", () => ({
|
||||||
|
NodeSDK: class {
|
||||||
|
constructor(cfg: unknown) {
|
||||||
|
captured.configs.push(cfg);
|
||||||
|
}
|
||||||
|
start() {}
|
||||||
|
shutdown() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tracing: { BatchSpanProcessor: FakeBatchSpanProcessor },
|
||||||
|
}));
|
||||||
|
// Instrumentations auto-enable (patch http/undici/pg globals) in their
|
||||||
|
// constructors — stub them out entirely, not just registerInstrumentations.
|
||||||
|
vi.mock("@opentelemetry/instrumentation", () => ({
|
||||||
|
registerInstrumentations: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@opentelemetry/instrumentation-http", () => ({
|
||||||
|
HttpInstrumentation: class {},
|
||||||
|
}));
|
||||||
|
vi.mock("@opentelemetry/instrumentation-undici", () => ({
|
||||||
|
UndiciInstrumentation: class {},
|
||||||
|
}));
|
||||||
|
vi.mock("@opentelemetry/instrumentation-pg", () => ({
|
||||||
|
PgInstrumentation: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type SdkConfig = {
|
||||||
|
spanProcessors: unknown[];
|
||||||
|
logRecordProcessors: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function lastConfig(): SdkConfig {
|
||||||
|
return captured.configs[captured.configs.length - 1] as SdkConfig;
|
||||||
|
}
|
||||||
|
|
||||||
describe("initOtelServerNode", () => {
|
describe("initOtelServerNode", () => {
|
||||||
it("returns an SDK handle with shutdown()", () => {
|
it("returns an SDK handle with shutdown()", () => {
|
||||||
@@ -8,16 +73,38 @@ describe("initOtelServerNode", () => {
|
|||||||
serviceName: "test-service",
|
serviceName: "test-service",
|
||||||
environment: "test",
|
environment: "test",
|
||||||
});
|
});
|
||||||
expect(sdk).toBeDefined();
|
|
||||||
expect(typeof sdk.shutdown).toBe("function");
|
expect(typeof sdk.shutdown).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a DSN and wires the Sentry bridge", () => {
|
it("registers only PII scrub processors when DSN is empty", () => {
|
||||||
const sdk = initOtelServerNode({
|
initOtelServerNode({
|
||||||
|
dsn: "",
|
||||||
|
serviceName: "test-service",
|
||||||
|
environment: "test",
|
||||||
|
});
|
||||||
|
const cfg = lastConfig();
|
||||||
|
expect(cfg.spanProcessors).toHaveLength(1);
|
||||||
|
expect(cfg.spanProcessors[0]).toBeInstanceOf(PiiScrubSpanProcessor);
|
||||||
|
expect(cfg.logRecordProcessors).toHaveLength(1);
|
||||||
|
expect(cfg.logRecordProcessors[0]).toBeInstanceOf(
|
||||||
|
PiiScrubLogRecordProcessor,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers PII scrub processors BEFORE the Sentry processors when DSN is set (ADR-017 §7)", () => {
|
||||||
|
initOtelServerNode({
|
||||||
dsn: "https://test@sentry.io/1",
|
dsn: "https://test@sentry.io/1",
|
||||||
serviceName: "test-service",
|
serviceName: "test-service",
|
||||||
environment: "test",
|
environment: "test",
|
||||||
});
|
});
|
||||||
expect(sdk).toBeDefined();
|
const cfg = lastConfig();
|
||||||
|
expect(cfg.spanProcessors).toHaveLength(2);
|
||||||
|
expect(cfg.spanProcessors[0]).toBeInstanceOf(PiiScrubSpanProcessor);
|
||||||
|
expect(cfg.spanProcessors[1]).toBeInstanceOf(FakeBatchSpanProcessor);
|
||||||
|
expect(cfg.logRecordProcessors).toHaveLength(2);
|
||||||
|
expect(cfg.logRecordProcessors[0]).toBeInstanceOf(
|
||||||
|
PiiScrubLogRecordProcessor,
|
||||||
|
);
|
||||||
|
expect(cfg.logRecordProcessors[1]).toBeInstanceOf(SentryLogRecordForwarder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export {
|
|||||||
buildPurgeHandler,
|
buildPurgeHandler,
|
||||||
registerRetentionPurgeJobs,
|
registerRetentionPurgeJobs,
|
||||||
} from "./retention-purge/retention-purge.job";
|
} from "./retention-purge/retention-purge.job";
|
||||||
|
export {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
withRetentionTombstone,
|
||||||
|
} from "./retention-purge/tombstone";
|
||||||
|
export {
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
type RetentionPurgeTask,
|
||||||
|
} from "./retention-purge/task";
|
||||||
export type {
|
export type {
|
||||||
PayloadPurgeApi,
|
PayloadPurgeApi,
|
||||||
GetPayloadFn,
|
GetPayloadFn,
|
||||||
|
|||||||
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||||
|
|
||||||
|
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function postDeletionOnlyConfig(
|
||||||
|
action: "hard-delete" | "pseudonymize",
|
||||||
|
fields: MockCollection["fields"] = [],
|
||||||
|
) {
|
||||||
|
return makeConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
action,
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: {
|
||||||
|
deletedAt: {
|
||||||
|
less_than: new Date(
|
||||||
|
Date.now() - parseDurationMs("P30D"),
|
||||||
|
).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||||
|
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||||
|
{ id: "row-live" }, // never soft-deleted
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(payload.delete).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("pseudonymize", [
|
||||||
|
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||||
|
{ name: "status" },
|
||||||
|
]),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.update).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
data: { email: null },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
expect(payload.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a retention-policy audit entry per purged row", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const { auditLog, record } = makeAuditLog();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
auditLog,
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "DELETE",
|
||||||
|
reason: "retention-policy",
|
||||||
|
resource: { type: "users", id: "row-old" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs activeRetention alongside postDeletion", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const config = makeConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||||
|
postDeletion: {
|
||||||
|
action: "hard-delete",
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config,
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||||
|
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||||
|
// the fake returns the tombstoned row for both branches: the
|
||||||
|
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||||
|
// tombstone — 2 delete calls for the same doc through different policies.
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||||
|
const { queue, enqueue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(enqueue).toHaveBeenCalledWith(
|
||||||
|
"retention-purge--users",
|
||||||
|
{},
|
||||||
|
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||||
|
|
||||||
describe("buildPurgeHandler — pseudonymize", () => {
|
describe("buildPurgeHandler — pseudonymize", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||||
|
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Payload API surface needed by the retention purge job.
|
* Minimal Payload API surface needed by the retention purge job.
|
||||||
@@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number {
|
|||||||
return MS_PER_DAY; // "daily" and cron fallback
|
return MS_PER_DAY; // "daily" and cron fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the purge handler for a single collection. The returned async function
|
* Build the purge handler for a single collection. The returned async function
|
||||||
* is intended to be registered as a Payload job task handler.
|
* is intended to be registered as a Payload job task handler.
|
||||||
*
|
*
|
||||||
* Per run:
|
* Per run:
|
||||||
* 1. Query rows past their activeRetention period.
|
* 1. activeRetention (when declared): query rows past the active period
|
||||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
* (createdAt/updatedAt) and apply the retention action.
|
||||||
|
* 2. postDeletion (when declared, audit finding A2): query soft-deleted rows
|
||||||
|
* — tombstoned with `deletedAt` by the DSR soft-delete path — whose
|
||||||
|
* tombstone is older than postDeletion.duration, and apply
|
||||||
|
* postDeletion.action (pseudonymize | hard-delete).
|
||||||
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
||||||
* 4. Re-enqueue itself for the next purge cycle.
|
* 4. Re-enqueue itself for the next purge cycle.
|
||||||
*
|
*
|
||||||
@@ -101,33 +108,22 @@ export function buildPurgeHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-bind after the guard: narrowing does not flow into hoisted closures.
|
||||||
|
const targetCollection = collection;
|
||||||
|
|
||||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||||
|
|
||||||
return async () => {
|
async function applyAction(
|
||||||
const payload = await getPayload({ config });
|
payload: PayloadPurgeApi,
|
||||||
const now = Date.now();
|
doc: Record<string, unknown>,
|
||||||
|
action: RetentionActionName,
|
||||||
if (retention.activeRetention) {
|
reason: string,
|
||||||
const { duration, trigger } = retention.activeRetention;
|
): Promise<void> {
|
||||||
const retentionMs = parseDurationMs(duration);
|
|
||||||
const cutoff = new Date(now - retentionMs).toISOString();
|
|
||||||
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
|
|
||||||
|
|
||||||
const { docs } = await payload.find({
|
|
||||||
collection: collectionSlug,
|
|
||||||
where: { [dateField]: { less_than: cutoff } },
|
|
||||||
limit: 1000,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
|
||||||
|
|
||||||
for (const doc of docs) {
|
|
||||||
const id = doc["id"] as string | number;
|
const id = doc["id"] as string | number;
|
||||||
|
|
||||||
if (action === "pseudonymize") {
|
if (action === "pseudonymize") {
|
||||||
const piiFields: Record<string, null> = {};
|
const piiFields: Record<string, null> = {};
|
||||||
for (const field of collection.fields) {
|
for (const field of targetCollection.fields) {
|
||||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||||
if (f.name && f.custom?.pii) {
|
if (f.name && f.custom?.pii) {
|
||||||
piiFields[f.name] = null;
|
piiFields[f.name] = null;
|
||||||
@@ -162,13 +158,66 @@ export function buildPurgeHandler(
|
|||||||
environment: process.env["NODE_ENV"] ?? "production",
|
environment: process.env["NODE_ENV"] ?? "production",
|
||||||
tenant: "default",
|
tenant: "default",
|
||||||
},
|
},
|
||||||
reason: "retention-policy",
|
reason,
|
||||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||||
containsPii: false,
|
containsPii: false,
|
||||||
outcome: "success",
|
outcome: "success",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (retention.activeRetention) {
|
||||||
|
const { duration, trigger } = retention.activeRetention;
|
||||||
|
const retentionMs = parseDurationMs(duration);
|
||||||
|
const cutoff = new Date(now - retentionMs).toISOString();
|
||||||
|
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: collectionSlug,
|
||||||
|
where: { [dateField]: { less_than: cutoff } },
|
||||||
|
limit: 1000,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retention.postDeletion) {
|
||||||
|
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
|
||||||
|
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
|
||||||
|
// declared action runs. Rows are re-checked client-side so a fake or
|
||||||
|
// permissive backend can never purge an un-tombstoned/unexpired row.
|
||||||
|
const { duration, action } = retention.postDeletion;
|
||||||
|
const cutoffMs = now - parseDurationMs(duration);
|
||||||
|
const cutoff = new Date(cutoffMs).toISOString();
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: collectionSlug,
|
||||||
|
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
|
||||||
|
limit: 1000,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expired = docs.filter((doc) => {
|
||||||
|
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
|
||||||
|
if (typeof tombstone !== "string" || tombstone.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tombstoneMs = Date.parse(tombstone);
|
||||||
|
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of expired) {
|
||||||
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = scheduleDelayMs(retention.purgeSchedule);
|
const delay = scheduleDelayMs(retention.purgeSchedule);
|
||||||
@@ -189,7 +238,7 @@ export async function registerRetentionPurgeJobs(
|
|||||||
const { queue, config } = deps;
|
const { queue, config } = deps;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const collection of config.collections) {
|
for (const collection of config.collections ?? []) {
|
||||||
const retention = collection.custom?.retention;
|
const retention = collection.custom?.retention;
|
||||||
if (!retention?.purgeSchedule) continue;
|
if (!retention?.purgeSchedule) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
|
||||||
|
|
||||||
|
function makeFakePayload() {
|
||||||
|
const find = vi.fn().mockResolvedValue({ docs: [] });
|
||||||
|
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
find,
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
jobs: { queue: jobsQueue },
|
||||||
|
} as unknown as Payload;
|
||||||
|
return { payload, find, jobsQueue };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildRetentionPurgeTask (A3)", () => {
|
||||||
|
it("uses the retention-purge--<slug> task slug", () => {
|
||||||
|
expect(buildRetentionPurgeTask("users").slug).toBe(
|
||||||
|
"retention-purge--users",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
|
||||||
|
const { payload, find, jobsQueue } = makeFakePayload();
|
||||||
|
const task = buildRetentionPurgeTask("users");
|
||||||
|
|
||||||
|
const result = await task.handler({ req: { payload } });
|
||||||
|
|
||||||
|
expect(result).toEqual({ output: {} });
|
||||||
|
// postDeletion branch queried the tombstone field
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: { deletedAt: expect.anything() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// self-re-enqueue went through the payload job queue
|
||||||
|
expect(jobsQueue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "retention-purge--users" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Payload } from "payload";
|
||||||
|
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
|
||||||
|
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal shape of a Payload job-task definition — enough for
|
||||||
|
* `payload.config.ts` `jobs.tasks` composition without dragging the full
|
||||||
|
* generated TaskConfig generics through core-shared.
|
||||||
|
*/
|
||||||
|
export type RetentionPurgeTask = {
|
||||||
|
slug: string;
|
||||||
|
handler: (args: { req: { payload: Payload } }) => Promise<{
|
||||||
|
output: Record<string, never>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Payload job-task definition for one collection's retention purge
|
||||||
|
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
|
||||||
|
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
|
||||||
|
* Payload able to RUN them. Everything the handler needs comes from the
|
||||||
|
* running instance on `req.payload` (config, local API, job queue for the
|
||||||
|
* self-re-enqueue), so the task can be declared at config-composition time
|
||||||
|
* with no bootstrapping order problems.
|
||||||
|
*/
|
||||||
|
export function buildRetentionPurgeTask(
|
||||||
|
collectionSlug: string,
|
||||||
|
): RetentionPurgeTask {
|
||||||
|
return {
|
||||||
|
slug: `retention-purge--${collectionSlug}`,
|
||||||
|
handler: async ({ req }) => {
|
||||||
|
const payload = req.payload;
|
||||||
|
const run = buildPurgeHandler(collectionSlug, {
|
||||||
|
queue: new PayloadJobQueue(payload),
|
||||||
|
config: payload.config,
|
||||||
|
getPayload: async () => payload as unknown as PayloadPurgeApi,
|
||||||
|
});
|
||||||
|
await run();
|
||||||
|
return { output: {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user