55 Commits

Author SHA1 Message Date
e71b66908f fix(core-shared): satisfy strict typecheck in purge job + audit hook
Some checks failed
CI / typecheck + lint + boundaries + test + build (pull_request) Has been cancelled
CodeQL / Analyze (javascript-typescript) (pull_request) Has been cancelled
Sentry PII guard (R31) / pii-guard (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled
CI / Storybook smoke tests + visual regression (pull_request) Has been cancelled
The hoisted applyAction closure loses the collection narrowing (TS18048)
and apps with generated CollectionSlug unions reject comparing slugs to
'audit-logs' (TS2367). Re-bind the narrowed collection and widen the
slug comparison to string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
9f90f0513f feat(core-dsr): include the subject's audit trail in DSR exports
UserDataBundle advertised an auditLog field that the export never
populated (audit finding A14). PayloadDataExport now queries the
audit-logs collection scoped to actorId === subjectId and reconstructs
AuditEntry values from the flat rows; when the audit core's collection
is not registered the field stays undefined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
d95ae74aed fix(core-audit): keyed 128-bit pseudonyms + salted DSR certificate
pseudonymize() used an unkeyed sha256 over 'salt:id' truncated to 64
bits, and the DSR deletion certificate hashed the raw subjectId with no
salt at all (audit finding A13). Both now use HMAC-SHA256 keyed by
AUDIT_PSEUDONYM_SALT, truncated to 128 bits. Rotation semantics are
documented on pseudonymize(): a key rotation changes future pseudonyms
only — stored rows keep old tokens and erasure still matches by real
actorId — and the certificate change likewise affects new certificates
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
68a142fa6b fix(core-consent): validate migrated categories against an allow-list
The anonymous consent cookie is client-controlled, yet its categories
were granted verbatim at sign-up migration (audit finding A12; the
migration itself is already invoked in the auth sign-up use case and
bindAllProduction now threads a consentFactory so it runs in
production). Adds KNOWN_CONSENT_CATEGORIES + isKnownConsentCategory to
core-consent, filters in extractAnonymousConsent and
migrateAnonymousConsent, and mirrors the allow-list in the auth
sign-up cookie extractor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
08cf939e1f fix(core-consent): merge per-category on persist instead of replacing
PayloadConsent.persist() wrote the WHOLE consentState array from a
per-request cache, so two interleaved grant/withdraw requests dropped
one another's categories (audit finding A7). Persist now re-reads the
freshest stored state immediately before writing, overlays ONLY the
mutated categories, and adopts the merged view locally. The residual
same-window race is documented in the method doc — Payload json fields
have no targeted array patch, so read-merge-write is the trade-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
a2be5d5488 feat(core-cms): register audit-logs + wire GDPR audit erasure
The audit-logs collection was never registered (record() would throw),
bindAudit/createAuditErasureHook were unused, and DSR cascade-hard never
touched the audit trail (audit finding A6). core-cms now registers the
collection and wires a req-scoped afterDelete erasure hook on users;
bindAllProduction binds the audit log into consent/DSR; cascade-hard
pseudonymizes the subject's audit entries; the action select accepts
the full AuditAction enum so consent/DSR entries pass validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
7b0c2ea590 fix(auth): declare users email/username/displayName in DSR pii map
The DSR walkers read the COLLECTION-level custom.pii map, which the
users collection never declared — Art. 15 export returned bare ids and
Art. 17 soft delete redacted nothing; the auth-injected email field in
particular was invisible (audit finding A5). Declares email (auto-added
by Payload auth: true), username and displayName as exportable +
restrictable; walker tests pin a users-shaped collection end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
413ac0273c feat(core-shared): grace-purge soft-deleted rows + boot registration
The retention purge job gated its whole body on activeRetention while
every collection declares only postDeletion, and no app ever called
registerRetentionPurgeJobs — retention was dead end to end (audit
findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone
on postDeletion collections (kept distinct from processingRestrictedAt
so an Art. 18 restriction never feeds the purge), the job grace-purges
tombstoned rows past postDeletion.duration with the declared action,
core-cms injects the tombstone field + Payload task definitions, and
bindAllProduction enqueues the first purge cycle at boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
d09b3e2cdd feat(core-shared): auth-gate mutating feature procedures
Adds a shared requireAuthenticated tRPC middleware (reads the server-
resolved ctx.user from createTrpcContext) and applies it to every
mutating feature procedure — blog.createArticle and media.deleteMedia
were anonymous-callable (audit finding B7). Read-only queries stay
public; features compose <x>ProtectedProcedure from their error-mapped
base procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
49241845b5 feat(web-next): resolve the session user + live compliance context
The tRPC createContext was () => ({}) — the mounted dsr/consent routers
401'd every call and the dsr singleton stub threw (audit finding A11).
createTrpcContext now accepts an app resolveUser hook; web-next resolves
the session cookie through the auth feature's validateSession (denylist
included) plus a role snapshot, and threads bindProductionDsr/Consent
(or dev-seed) bindings into every request. The dsr router resolves its
binding from ctx.dsrBinding per request instead of a throwing proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
8b78563881 fix(core-consent): build consent router from the shared superjson t
The router previously created its own initTRPC without superjson while
the app router uses the shared transformer-enabled instance — a wire
transformer mismatch that corrupts inputs (audit finding A10). Procedures
now build on @repo/core-shared/trpc/init's t; a real client+fetch-adapter
round-trip test pins Date revival through appRouter-style mounting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
e2a4657471 fix(core-dsr): scope DSR operations to the caller's own subject
Non-admin callers may export/rectify/restrict/delete ONLY their own
subjectId; a mismatch is rejected with FORBIDDEN instead of being
honored verbatim (IDOR, audit finding A1). Cross-subject operations
require the admin role; cascade-hard stays admin-only on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
805a09bfe5 chore: drop tsbuildinfo files from the index for real
The previous commit recorded regenerated working-tree content instead
of the staged deletion (pathspec commits snapshot the worktree). The
files are ignored now, so record the actual deletion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
6f25b1699c build: align @trpc/* on a single workspace version
@trpc/client resolved to 11.16.0 in some packages while @trpc/server
resolved to 11.17.0 in others, tripping the exact-version peer check.
Update every @trpc/* range in-range so the whole workspace resolves to
one version (11.18.0); install is peer-warning-free again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
ce9f8becce chore: untrack committed tsconfig.tsbuildinfo build artifacts
TypeScript incremental build state was checked in for apps/cms and
apps/web-next; it churns on every build and carries no source value.
Remove from tracking and ignore *.tsbuildinfo repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
c551b33bfb refactor(core-trpc): break the core-api package cycle
core-trpc imported AppRouter (type-only) from core-api, which depends
on the features, which depend on core-trpc — a cycle that killed every
turbo graph walk (lint/build/CI) and an illegal core -> core-composition
boundary edge. The tRPC context is now router-agnostic (AnyTRPCRouter);
useTRPC takes the router type as a generic and each feature exports a
type-only app-slice (e.g. BlogAppSlice) mirroring its mount key, so UI
hooks keep full procedure typing without touching the composition layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
5047953c9a fix(navigation): honest not-found contract; skip invalid header items
IHeaderRepository.getHeader() now returns Header | null, making the
use case's HeaderNotFoundError branch honestly reachable instead of
dead code behind a lying type (B12). The Payload repo also skips CMS
rows with empty label/href rather than emitting items that violate
headerItemSchema.min(1) and 500 at output validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
c60277ebe6 test: wire the orphaned scripts/ test suite to a vitest runner
scripts/*.test.mjs had NO runner: scripts/ is not a workspace package
(turbo never reaches it) and no CI step invoked node --test, so all 16
files — 241 assertions over conformance, coverage, compliance,
library-decisions and work tooling — were dead. Wire them via a root
vitest.scripts.config.mjs + 'pnpm test:scripts' + a CI validate step,
converting the node:test imports to vitest (node:assert kept, same
pattern as the generators' release-please-utils conversion). First-run
fallout fixed: the work fixtures missed the epics/ subdir buildState
walks, the expected epic shape lacked the prd field, the CLI dispatch
test assumed a ready task exists, and the state-sync-guard smoke held
an unused binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
1883883911 test(scripts): wire scripts/**/*.test.mjs under a vitest runner
241 tests across 16 script test files were wired to NO runner (the
generators vitest config never included them and several used node:test
imports that vitest silently skips). New root vitest.scripts.config.mjs
+ pnpm test:scripts + a CI validate step run them all; node:test imports
converted to vitest keeping node:assert. Split out of 8c88a9a where a
concurrent agent's staged files were swept into the marketing-pages
commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:43 +02:00
bab8fb634f fix(marketing-pages): throw PageNotFoundError for missing slugs
getPageBySlug returned undefined while the feature already mapped
PageNotFoundError to NOT_FOUND and blog throws for missing slugs (B11).
Unify on the throwing contract: use case throws, controller narrows to
a non-optional presenter, the server component catches the domain error
to render its not-found state, and the router now surfaces NOT_FOUND.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:41 +02:00
dec24feaa0 fix(web-next): enforce manifest rate limits on the production path
bindAllProduction injected NoopRateLimit, so the sign-in budgets the
auth manifest declares were never enforced in production (audit finding
A4/B3). The production ctx now binds InMemoryRateLimit seeded from the
manifest's rateLimit budgets (manifest stays the source of truth);
dev-seed intentionally keeps Noop so local iteration never throttles.
A regression test drives sign-in through the REAL auth production
binder + app router and asserts the 6th failed attempt returns
TOO_MANY_REQUESTS while other IPs stay unaffected. In-memory counters
are per-process; multi-instance deployments need a shared IRateLimit
backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:30:24 +02:00
88ac2649b7 fix(media): return the exact window for non-aligned offsets
Same B9 defect as blog: Payload paginates by page, so a non-aligned
offset returned the wrong window. Fetch the straddled pages and slice
the intra-page remainder. Identical window tests now run against the
mock and the fake-payload stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:29:16 +02:00
50c30e9c1b fix(blog): return the exact window for non-aligned offsets
Payload paginates by page, so floor(offset/limit)+1 alone returned the
wrong window whenever offset % limit != 0 (B9). Fetch the straddled
pages and slice the intra-page remainder. The test stub now honours
page like real Payload (it silently ignored it before), and the
contract pins aligned + non-aligned windows on mock and stub alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:29:06 +02:00
b66759a1ab fix(auth): derive clientIp server-side, drop it from sign-in input
clientIp was part of the public signInInputSchema, so any client could
spoof its own rate-limit bucket or dodge IP throttling entirely (audit
finding B2). The schema no longer carries it (strict parsing rejects it
with BAD_REQUEST); instead the web-next tRPC fetch adapter derives it in
createTrpcContext from x-forwarded-for (first hop) / x-real-ip — trust
caveat documented — and the router threads ctx.clientIp to the
controller as a second, server-only argument typed outside the input
schema (SignInRequestContext).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:28:56 +02:00
bae2686832 ci: drop duplicate chromium install in storybook job
The job already runs 'playwright install --with-deps chromium' right
after pnpm install; the second install step before the visual step
re-downloaded the same browser for nothing. (S10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:26:51 +02:00
21860784be chore: prune dead vars from turbo.json globalEnv
SENTRY_PROJECT_WEB_TANSTACK and VERCEL_ENV have zero consumers in the
tree (web-tanstack has no sentry upload config; nothing reads
VERCEL_ENV), so they only widened the global cache key. The other
Sentry/Vercel vars stay — next.config.mjs and init paths read them. (S9)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:26:34 +02:00
4d6734448a test(auth): cover signToken/verifyToken/validateSession crypto paths
The session methods had zero coverage because they call getPayload()
(audit finding B8). Stub the payload module (secret + findByID only) and
exercise the real crypto paths: round-trip, tampered signature, swapped
payload, expired exp, malformed segments, wrong secret, jti-less token
(fail closed), post-revocation rejection, and the per-instance denylist
limit. No running Payload needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:26:01 +02:00
cd1c0334af fix: resolve the root playwright config in pnpm test:visual
The visual-regression leg ran 'pnpm exec playwright test' from the
storybook package cwd, where the root playwright.config.ts is never
picked up — playwright fell back to a configless run with no chromium
project and an undefined baseURL for the story-index fetch. Pass the
root config explicitly. Verified via 'playwright test --list' from
apps/storybook: the [chromium] project and the visual spec resolve. (S8)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:25:57 +02:00
db2afde0dc feat(auth): revoke sessions server-side via a jti denylist
invalidateSession previously ignored its argument, so a stateless JWT
stayed valid until exp after logout (audit finding B5). createSession
now embeds the minted session.id in the token as jti; invalidateSession
records that jti in an in-memory denylist with expiry-based pruning, and
validateSession rejects denylisted (and jti-less, failing closed)
tokens. The denylist is per-process — the single-process limitation and
the shared-store upgrade path are documented in session-denylist.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:25:51 +02:00
c7d1dd8055 fix(storybook): glob all package stories + declare core-ui dep
The stories glob covered only packages/core-ui, so any feature story a
component-must-have-story fix produces would be invisible in Storybook
and skipped by pnpm test:stories. Glob every package's src tree and
declare the workspace dep on @repo/core-ui (the only storied package
today) so turbo's build cache invalidates when its stories change.
Verified: storybook build succeeds, 18 stories in index.json. (S7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:25:09 +02:00
cd61b31e65 fix(cms): thread a per-request nonce through the admin CSP
The prod CSP emitted script-src 'strict-dynamic' with no nonce seed,
blocking every Payload admin script (A8). Reuse the shared nonce-based
withSecurityHeaders: Payload admin pages are always dynamically
rendered, so Next propagates the nonce read from the forwarded
request's CSP header onto the admin's scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:25:04 +02:00
32163312e3 chore(core-typescript): put its TS sources under the typecheck gate
The package's vitest base configs were outside every gate — no tsconfig
and no typecheck script meant tsc never saw them. Add a tsconfig with
allowImportingTsExtensions (vitest.base.jsdom imports ./vitest.base.node.ts
by extension) and an explicit include of the base configs + vitest
config, plus the typescript/@types/node devDeps to run it. (S6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:24:09 +02:00
00fcc9d9a1 fix(core-shared): set CSP on forwarded request headers for nonce
Next.js only injects the nonce into its own scripts when it can read it
from the request's Content-Security-Policy header. Setting the CSP only
on the response left hydration scripts un-nonced in production (A9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:24:01 +02:00
dcbf782e21 test(generators): run release-please-utils tests under vitest
The file was doubly dead: the vitest include lacked lib/*.test.mjs and
the file imported node:test, so no runner ever executed it. The include
now picks up lib .mjs tests and the imports move to vitest (node:assert
keeps the original assertion style) — all 5 assertions live. (S5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:23:06 +02:00
2747feab46 fix(auth): stop leaking usernames via emailDomain span attribute
getUserByUsername emitted the FULL username as the emailDomain span
attribute whenever the username contained no "@" (audit finding B6),
violating the PII-free telemetry rule (ADR-017 §7). Emit only a boolean
hasAtSign in both the production repository and its mock; regression
tests pin that no username-derived string reaches span attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:22:00 +02:00
27787193c0 fix(core-testing): align RecordingEventBus with real bus semantics
Handlers and the published record now receive the zod-PARSED payload
(defaults/coercions applied) instead of the raw input, and fan-out uses
Promise.allSettled with errors swallowed by default plus an opt-in
failFast — matching the InMemoryEventBus the events core-package
generator scaffolds. The previous sequential fail-fast divergence was
undocumented, so it is aligned rather than kept. Regression tests pin
parsed-payload delivery, non-short-circuiting fan-out, and failFast
rethrow. (S4; the generator bus templates already carry the fix.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:21:52 +02:00
c9db7c8cd7 fix(auth): add username + passwordHash fields to users collection
The production UsersRepository reads and writes username + passwordHash
via the Payload local API, but the users collection never declared them,
so production sign-up/sign-in was broken (audit finding B1). passwordHash
uses access.read: () => false so credential material never serializes
through any Payload API surface; the repository still reads it with
overrideAccess: true. A contract-shaped test pins every repo-used field
(USERS_REPOSITORY_FIELDS) against the collection config so drift fails
at test time without a database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:21:16 +02:00
bb2751eede docs: correct stale no-op comments in node test setup
setup/node.ts installs the no-instrumentation guard (vi.mock against
real Sentry/OTel SDK init) via its import — it was never a no-op. Fix
the same lie in core-typescript's jsdom base-config test comments and
test title. (S2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:20:27 +02:00
3cf2572c85 chore(fallow): make the whole-codebase gate pass without hiding signal
pnpm fallow failed on 14 dead-code issues, 200 clone groups (4.3% >
3.0% threshold) and one cognitive-complexity breach. Changes:
- duplicates.ignore covers convention-mandated boilerplate only (test
  files, stories, fixtures/factories/seeds, binders, symbols, feature
  manifests, vitest configs, mock/repo twins, sentry init twins,
  compliance emitters, rule-meta boilerplate) at threshold 3.0
- usedClassMembers: validateSession (interface-implemented, not yet
  called); ignoreExports: Next's generateMetadata convention export +
  the __getInstrumentationForTests test knob
- duplicate-exports off: client/server RSC twins export the same
  component name by design
- @trpc/client + @trpc/react-query added to ignoreDependencies (peer
  resolution for feature ./ui hooks); *.test.mjs marked dynamically
  loaded (node:test files fallow saw as unreachable)
- delete packages/auth/src/ui/query.ts (empty export{} placeholder
  shadowed by ui/index.ts, genuinely dead)
- extract checkDepTrace/decisionError from checkLibraryDecisions
  (cognitive 32 > 30) — behavior unchanged, tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:53:16 +02:00
eccd8b0cc1 fix(library-decisions): traceSchema accepts the committed trace shape
The strict schema rejected 38 of 39 approved traces in
docs/library-decisions: lastRevalidated was required (most traces omit
it), and the compliance frontmatter fields the _template.md documents
(is-sub-processor, processes-pii, plus the sub-processor block:
data-sent, region, dpa-signed, sccs-required, contact) were
unrecognized keys. Make lastRevalidated optional, add the compliance
fields (booleanish coercion for YAML string scalars), and require
data-sent when is-sub-processor is true. New loop test asserts every
committed trace parses so the schema can never drift away from the
repo's own corpus again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:18 +02:00
5f38a3efce build: bump vitest past GHSA-5xrq-8626-4rwp; coverage-v8 everywhere
pnpm audit --prod --audit-level=critical (the new CI gate) failed on
vitest <3.2.6 (critical, arbitrary file read/execute via the UI
server). Bump vitest + @vitest/coverage-v8 in-range across the
workspace and add @vitest/coverage-v8 to every package that runs
vitest but lacked it (core-audit, core-cms, core-eslint, core-testing,
core-trpc, apps/cms, web-next, web-tanstack, turbo/generators) so
'pnpm test -- --coverage' works in every package. No compound test
scripts exist, so the vitest-last pass-through concern does not apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:44:48 +02:00
c0dbadf1c2 ci: honest audit gate, real Socket CLI, reachable mutation issue step
- 'pnpm audit signatures' is an npm-only feature; pnpm ignored the
  word and ran a plain full audit. Replace with an explicit
  'pnpm audit --prod --audit-level=critical' (documented rationale)
- socket-cli is a 0.0.1 stub on npm; use the real 'socket' CLI and
  fail loudly instead of silently passing
- mutation-nightly's issue-opening step was gated on if: failure(),
  unreachable under continue-on-error — gate on steps.mutate.outcome
- wire the ADR-023 renovate major-bump gate
  (scripts/library-decisions/check.mjs --renovate-pr) on PRs; verified
  it no-ops on non-renovate branches

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:43:32 +02:00
a17b984675 fix(generators): run UI tests, guard optional bus, harden e2e clones
Three generator fixes:
- templates/feature/vitest.config.ts.hbs lacked an include for
  .test.{ts,tsx}; the node base only includes .test.ts, so scaffolded
  UI component tests never executed
- gen event consume emitted an unguarded bus.subscribe although
  ctx.bus is optional in BindContext — now wrapped in if (bus) {}
- e2e repo clones now exclude /dist and /.next build outputs, and
  every dep-stripping e2e strips the scaffolded package from EVERY
  workspace package.json via globSync instead of a hardcoded dependent
  list that drifts as packages gain or drop the dependency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:42:46 +02:00
1e3220aef5 fix(core-ui): stop excluding Storybook stories from typecheck
*.stories.tsx / *.stories.ts were excluded from tsconfig, so story
files never typechecked and could drift against component props
silently. Include them (they already typecheck cleanly); build is
tsc --noEmit so nothing new is emitted. The Select scrollIntoView port
from the downstream fork does not apply — this tree has no Select atom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:33:41 +02:00
16310c5d62 test(core-shared): assert PII scrub runs before Sentry processors
The previous init-server-node tests only checked that an SDK handle
came back — the ADR-017 §7 invariant (PiiScrubSpanProcessor /
PiiScrubLogRecordProcessor registered BEFORE any exporter-facing
processor) was untested. Capture the NodeSDK constructor config via a
local vi.mock override and assert processor ordering for both the
empty-DSN and DSN-set paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:32:47 +02:00
0234e18425 fix(core-testing): handle batched paths + transformed errors in mock tRPC
httpBatchLink joins same-tick calls into one request whose path is a
comma-separated list of procedure paths; the stub matched the joined
string against a single mock key, so any batched pair failed with 'No
mock for a.one,a.two'. It now answers one element per procedure, in
order. Error bodies are also superjson-serialized — raw error JSON made
the client throw 'Unable to transform response' instead of surfacing
the intended error. Regression tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:32:03 +02:00
8476712620 fix(generators): event buses deliver the zod-parsed payload
InMemoryEventBus.publish and PayloadJobsEventBus.publish called
descriptor.schema.parse(payload) but discarded the result, so handlers
and enqueued jobs received the raw input — zod defaults, catches,
transforms and strips never applied. Both bus templates now fan out the
parsed value; regression tests added to both test templates and the
events snapshot hashes regenerated (template-tree sha over
hbs-stripped paths — only the four touched files differ).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:53 +02:00
6fd746d3bd fix(core-eslint): activate React rules-of-hooks for all TSX surfaces
next.js and react-internal.js are plain re-exports of base, so no
rules-of-hooks checking was active anywhere despite three React apps
and core-ui. Wire eslint-plugin-react-hooks in base.js scoped to
**/*.tsx (non-React packages untouched). Fixes the one violation it
surfaced: web-tanstack's root route called Route.useLoaderData inside
an anonymous component callback — extracted to a named RootComponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:27:16 +02:00
9b04fae975 fix(core-shared): derive Analyzed + RateLimited brands in binding slot
ProductionUseCase<I, O, M> only demanded Instrumented + Captured (+
Audited for mutating-with-audits). The boot assertion additionally
requires __analyzed for non-empty analyticsEvents and __rateLimited for
non-empty rateLimit, so the type-level gate under-promised what boot
enforces. The slot now derives both from the manifest entry; the
feature-scoped requiresConsent brand stays boot-only (documented).
Also make IAnalytics extend AnalyticsProtocol from
core-shared/di/bind-protocols so narrowing the ctx protocol fails
typecheck in core-analytics instead of drifting silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:25:09 +02:00
bf04ad70b2 chore: repair pre-existing lint failures blocking the lint gate
- turbo/generators/config.ts used require() inside the reader generator
  action (no-require-imports); use the top-level node:fs imports
- auth authentication.service.ts used a literal self-assertion
  ("users" as "users"); prefer-as-const
- apps/cms lacked web-next's next-env.d.ts triple-slash-reference
  override, and the committed next-env.d.ts now references
  .next/types/routes.d.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:21:26 +02:00
498f1fb57a fix(core-eslint): enforce ADR-017 OTel SDK import restriction
The base config documented (and carried allowlist off-blocks for) the
OTel-SDK import restriction but the no-restricted-imports pattern group
only covered @sentry/*. Add @opentelemetry/sdk-*, exporter-*,
instrumentation-*, resources and semantic-conventions to the restricted
group, and extend the off-block for core-audit's trace-id enrichment
test, which legitimately builds an in-memory sdk-trace-base tracer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:21:25 +02:00
90fc48db81 fix(conformance): fail the CI gate on unparseable manifests
parseManifestUseCases returning null for a manifest that exists made
the cross-feature gate silently skip that feature. findUnparseableManifests
now runs before the empty-graph early exit (an unparseable manifest
contributes zero events and would otherwise pass as nothing-to-check)
and any hit fails the run. Reader-closure from the downstream fork is
not ported: this tree has no reads: manifests or ./reader exports yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:14:13 +02:00
0c1df7f5d4 fix(core-eslint): fail loudly on unparseable manifest in wiring gate
usecase-must-be-wired returned {} when the manifest parsed to null,
silently disabling the error-level gate when the manifest existed but
could not be read. It now reports unparseableManifest on Program in
that case; a genuinely missing manifest stays a no-op (that is
feature-must-have-manifest's job).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:11:59 +02:00
3bf0c652e7 fix(core-eslint): parse satisfies-shaped manifests; unify manifest parser
A manifest written as `{...} satisfies FeatureManifest` (or the combined
`as const satisfies` idiom) parsed to null, silently no-oping the
error-level conformance rules. unwrapAsConst now strips TSAsExpression
and TSSatisfiesExpression in a loop. The file also carried a verbatim
second copy of its own parser for parseManifestFully; both public entry
points now share one implementation. The template's field set (audits,
rateLimit, requiresConsent) is preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:10:35 +02:00
d8a3250c12 test(generators): strip core-trpc dep from feature packages in e2e
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
The core-package trpc removal e2e was only stripping @repo/core-trpc
from the two app package.json files. blog, marketing-pages, and
navigation also depend on core-trpc for their ./ui hooks, so pnpm
install in the simulated post-removal state failed to resolve the
workspace dep. Strip the dep from every package that references it.
2026-06-03 13:27:53 +02:00
194 changed files with 5960 additions and 1192 deletions

View File

@@ -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"]
}
]
} }

View File

@@ -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

View File

@@ -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
View File

@@ -10,6 +10,9 @@ node_modules
# Turbo # Turbo
.turbo .turbo
# TypeScript incremental build state
*.tsbuildinfo
# Build outputs # Build outputs
dist dist
build build

View File

@@ -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",
},
},
];

View File

@@ -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 = {

View File

@@ -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"
} }
} }

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
} }
} }

View File

@@ -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),
}); });
}; };

View 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" });
});
});

View File

@@ -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", () => {

View File

@@ -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. */

View 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: [] });
});
});

View 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

View File

@@ -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"
} }
} }

View File

@@ -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 />
</>
);
},
}); });

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"
} }
} }

View File

@@ -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 ?? ""}`,

View File

@@ -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,
);
});
}); });

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 () => {

View File

@@ -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;
} }
}, },

View File

@@ -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();
});
}); });
}); });

View File

@@ -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;
} }

View File

@@ -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);
});
});

View File

@@ -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);
}
}
}

View File

@@ -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", () => {

View File

@@ -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);
}), }),
}); });

View 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);
});
});

View File

@@ -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",

View File

@@ -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);

View File

@@ -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);
}; };

View File

@@ -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 {};

View File

@@ -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"
} }
} }

View File

@@ -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 () => {

View File

@@ -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 };

View File

@@ -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;
collection: "articles", const offset = options?.offset ?? 0;
where: where as never, // Payload paginates by page, not offset. A non-aligned offset
limit: options?.limit ?? 50, // (offset % limit !== 0) straddles two pages, so fetch both and
page: options?.offset // slice out the exact [offset, offset + limit) window.
? Math.floor(options.offset / (options.limit ?? 50)) + 1 const page = limit > 0 ? Math.floor(offset / limit) + 1 : 1;
: 1, const remainder = limit > 0 ? offset % limit : 0;
overrideAccess: true,
}); const findPage = (p: number) =>
span.setAttribute("count", result.docs.length); payload.find({
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); collection: "articles",
where: where as never,
limit,
page: p,
overrideAccess: true,
});
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;
} }
}, },

View File

@@ -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);

View File

@@ -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();

View File

@@ -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 }>
>;

View File

@@ -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;
}; };

View File

@@ -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",

View File

@@ -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"
} }
} }

View File

@@ -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>,

View File

@@ -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"
} }
} }

View File

@@ -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"
} }
} }

View File

@@ -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");

View File

@@ -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,
}, },

View File

@@ -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();
});
});

View File

@@ -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);
};
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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");

View File

@@ -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}$/);
}); });
}); });

View File

@@ -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)}`;
} }

View File

@@ -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"
} }
} }

View File

@@ -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"]),
);
}); });
}); });

View File

@@ -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"),
}, },

View File

@@ -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"
} }
} }

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -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);
}), }),
}); });

View File

@@ -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";

View File

@@ -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",
]);
});
});

View File

@@ -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);
} }
} }

View File

@@ -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"]);
});
});

View File

@@ -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,

View File

@@ -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"
} }
} }

View File

@@ -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);
}); });
}); });

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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 res = await createDeleteHandler(binding.dataDelete)(input); const b = bindingFromCtx(ctx, binding);
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>;

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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"

View File

@@ -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;
} }

View File

@@ -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.

View File

@@ -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);

View File

@@ -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: {},

View File

@@ -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"
} }
} }

View File

@@ -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;

View File

@@ -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["audits"]["length"] extends 0 (M["mutates"] extends true
? unknown ? M["audits"]["length"] extends 0
: { readonly __audited: true } ? unknown
: unknown); : { 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);

View File

@@ -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);
}); });
}); });

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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,8 +108,64 @@ 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}`;
async function applyAction(
payload: PayloadPurgeApi,
doc: Record<string, unknown>,
action: RetentionActionName,
reason: string,
): Promise<void> {
const id = doc["id"] as string | number;
if (action === "pseudonymize") {
const piiFields: Record<string, null> = {};
for (const field of targetCollection.fields) {
const f = field as { name?: string; custom?: { pii?: unknown } };
if (f.name && f.custom?.pii) {
piiFields[f.name] = null;
}
}
if (Object.keys(piiFields).length > 0) {
await payload.update({
collection: collectionSlug,
id,
data: piiFields,
overrideAccess: true,
});
}
} else {
await payload.delete({
collection: collectionSlug,
id,
overrideAccess: true,
});
}
if (auditLog) {
await auditLog.record({
actorId: "system",
actorType: "system",
actorRoles: [],
action: "DELETE",
resource: { type: collectionSlug, id: String(id) },
at: new Date(),
scope: {
feature: "core-shared",
environment: process.env["NODE_ENV"] ?? "production",
tenant: "default",
},
reason,
from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false,
outcome: "success",
});
}
}
return async () => { return async () => {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const now = Date.now(); const now = Date.now();
@@ -123,51 +186,37 @@ export function buildPurgeHandler(
const action = retention.postDeletion?.action ?? "hard-delete"; const action = retention.postDeletion?.action ?? "hard-delete";
for (const doc of docs) { for (const doc of docs) {
const id = doc["id"] as string | number; await applyAction(payload, doc, action, "retention-policy");
}
}
if (action === "pseudonymize") { if (retention.postDeletion) {
const piiFields: Record<string, null> = {}; // Grace-period purge of soft-deleted rows (A2): the DSR soft delete
for (const field of collection.fields) { // stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
const f = field as { name?: string; custom?: { pii?: unknown } }; // declared action runs. Rows are re-checked client-side so a fake or
if (f.name && f.custom?.pii) { // permissive backend can never purge an un-tombstoned/unexpired row.
piiFields[f.name] = null; const { duration, action } = retention.postDeletion;
} const cutoffMs = now - parseDurationMs(duration);
} const cutoff = new Date(cutoffMs).toISOString();
if (Object.keys(piiFields).length > 0) {
await payload.update({
collection: collectionSlug,
id,
data: piiFields,
overrideAccess: true,
});
}
} else {
await payload.delete({
collection: collectionSlug,
id,
overrideAccess: true,
});
}
if (auditLog) { const { docs } = await payload.find({
await auditLog.record({ collection: collectionSlug,
actorId: "system", where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
actorType: "system", limit: 1000,
actorRoles: [], overrideAccess: true,
action: "DELETE", });
resource: { type: collectionSlug, id: String(id) },
at: new Date(), const expired = docs.filter((doc) => {
scope: { const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
feature: "core-shared", if (typeof tombstone !== "string" || tombstone.length === 0) {
environment: process.env["NODE_ENV"] ?? "production", return false;
tenant: "default",
},
reason: "retention-policy",
from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false,
outcome: "success",
});
} }
const tombstoneMs = Date.parse(tombstone);
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
});
for (const doc of expired) {
await applyAction(payload, doc, action, "retention-policy");
} }
} }
@@ -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;

View File

@@ -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" }),
);
});
});

View 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