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