Skip to content

chore: quality, performance, and CMS GA readiness#387

Closed
LennyObez wants to merge 1401 commits into
mainfrom
chore/quality-and-performance
Closed

chore: quality, performance, and CMS GA readiness#387
LennyObez wants to merge 1401 commits into
mainfrom
chore/quality-and-performance

Conversation

@LennyObez

@LennyObez LennyObez commented Mar 9, 2026

Copy link
Copy Markdown
Owner

Summary

Comprehensive quality, performance, and feature additions across the entire Pulsar framework — preparing the CMS extension and core framework for GA readiness.

Core Framework Enhancements

  • DI Container: Compiled containers, decorators, lazy services, scopes
  • Cache: PSR-6/PSR-16 with 6 drivers, tags, locks, encryption
  • i18n: Translator, catalogs, formatters, locale negotiation
  • REPL: Interactive shell with safe mode and audit logging
  • Queue: Priority, delay, batch processing, tenant-aware scheduling
  • Mail & Notifications: Full mail transport and notification system
  • Form Extension: Validation and processing pipeline
  • Workflow Engine: Saga orchestrator with step result storage
  • Multi-Runtime: FrankenPHP, RoadRunner, ReactPHP support
  • PSR-7/PSR-15/PSR-17: Canonical HTTP layer with optimized hot paths
  • Database: Pooling, routing, failover, cache, monitoring, portable SQL (UpsertBuilder, InListBuilder)
  • Performance Budgets: Tiered enforcement with PHPBench CI integration
  • Health Checks: HTTP endpoint (GET /health) with cache, queue, disk checks
  • URL-prefix locale routing with security hardening

Security & Compliance

  • Zero-trust architecture with NIST 800-207 compliance
  • OAuth2/OIDC and WebAuthn extensions
  • CSP, HSTS, cross-origin security header DTOs
  • Session management subsystem
  • Extension trust tiers with capability-gated proxies
  • Compliance events and specialized loggers
  • WCAG accessibility extension

CMS Extension

  • Contact Form Engine: Multi-layer spam protection (honeypot, timing, rate limit, content heuristics, proof-of-work)
  • Gallery Frontend: Masonry layout, dialog-based lightbox, before/after image comparison
  • Responsive Images: <picture> with WebP/AVIF srcset, lazy loading, blur-up placeholders
  • Content+Media Bundle Export/Import: ZIP bundles, integrity verification, duplicate resolution, selective export, smart import with rollback
  • Dark Mode: CSS custom properties with theme toggle Custom Element
  • Toast Notifications, Command Palette, Skeleton Screens: Admin UX patterns
  • Frontend Build Pipeline: esbuild with dev/prod modes, tree-shaking, single entry point
  • Visual Page Builder: Drag-and-drop, inline text editing, block inspector, undo/redo, responsive preview, 31 block types
  • Newsletter System: Double opt-in, campaign builder, sending pipeline, bounce handling, tracking
  • RSS/Atom Feed Generator: Per content type, auto-discovery middleware, caching
  • Comments Frontend: Threaded/flat views with Custom Elements
  • Rate Limiting Dashboard: Admin configuration and live monitoring
  • Content Types: Documentation system (versioned docs, sidebar, TOC), Showcase/Portfolio, Resume/CV with PDF export

Forum Extension

  • Reputation-to-privilege system with gated capabilities
  • Automated badge awarding based on user actions
  • Notification system (in-app + email with digest batching)
  • Enhanced moderation: auto-moderation rules, action logging, user banning
  • Full-text search (PostgreSQL tsvector, MySQL FULLTEXT, SQLite fallback)
  • Public user profiles with activity timeline and leaderboard

Commerce Extensions

  • ext-subscriptions: Google Play + App Store subscription verification, webhook processing, receipt encryption
  • ext-devices: Device management with token-based auth, rotation, device limits
  • ext-feedback: User feedback collection with admin triage and GitHub issue integration
  • ext-releases: Version management, beta signups, changelog rendering

Database Layer Audit

  • Portable SQL via UpsertBuilder + InListBuilder for MySQL/PostgreSQL/SQLite
  • 11 missing performance indexes added across CMS, Forum, Analytics
  • Tenant isolation fix in content repository
  • GDPR erasure completeness across all entity types
  • Revision retention policy with scheduled pruning

Developer Experience

  • Schema codegen for type-safe DTOs
  • Starter kits and project scaffolding
  • Testing utilities (helper traits and assertions)
  • OpenTelemetry OTLP extension
  • GraphQL extension (schema-first)
  • MCP Server extension

Testing & Quality

  • 85+ new test files covering all new extensions
  • CI coverage threshold restored to 70%
  • PHPStan level max: 0 errors
  • Psalm level 1: 0 errors
  • 21,000+ tests passing

Stats

  • 77 commits, 5,237 files changed
  • 633,506 insertions, 11,484 deletions

Closes #357

Test plan

  • composer qa passes with 0 errors
  • pnpm format:check && pnpm lint && pnpm typecheck && pnpm test all green
  • CI pipeline: quality, tests (70% coverage gate), benchmarks, JS/TS
  • PHPStan max level: 0 errors
  • Psalm level 1: 0 errors
  • Deptrac boundary check: clean

@LennyObez LennyObez self-assigned this Mar 9, 2026
@LennyObez LennyObez changed the title chore: quality improvements and performance optimizations chore: quality, performance, and CMS GA readiness Mar 9, 2026
@LennyObez LennyObez added enhancement New feature or request extension Extension system performance Performance optimization labels Mar 9, 2026
@LennyObez LennyObez added this to the 1.0.0 milestone Mar 9, 2026
@LennyObez LennyObez added testing Test coverage and quality security Security hardening, crypto, audit labels Mar 9, 2026
@LennyObez LennyObez moved this from Backlog to Review in Pulsar - Release & Delivery Mar 9, 2026
@github-actions

github-actions Bot commented Mar 9, 2026

Copy link
Copy Markdown

Qodana for PHP

27791 new problems were found

Inspection name Severity Problems
Annotator 🔴 Failure 179
Unresolved custom property 🔴 Failure 111
Top-level 'await' expression 🔴 Failure 3
Unused selector 🔶 Warning 1038
Property is incompatible with selected browsers 🔶 Warning 832
Unknown attribute 🔶 Warning 302
Function with multiple return points 🔶 Warning 220
Statement body without braces 🔶 Warning 194
Chained function call 🔶 Warning 193
Use of 'innerHTML' property 🔶 Warning 128
Unresolved file in a link 🔶 Warning 116
Annotator 🔶 Warning 94
Undefined method 🔶 Warning 87
Overly long function 🔶 Warning 55
Unused global symbol 🔶 Warning 50
Missing generic font family name 🔶 Warning 32
Function with multiple loops 🔶 Warning 29
Local variable naming convention 🔶 Warning 19
Obsolete attribute 🔶 Warning 17
Negated conditional expression 🔶 Warning 17
Overly complex boolean expression 🔶 Warning 16
Reuse of local variable 🔶 Warning 16
Missing required 'alt' attribute 🔶 Warning 15
Unnecessary statement use 🔶 Warning 15
Wrong attribute value 🔶 Warning 14
Incompatible XHTML usages 🔶 Warning 13
Overly complex function 🔶 Warning 12
Function naming convention 🔶 Warning 11
Missing associated label 🔶 Warning 11
Function with more than three negations 🔶 Warning 11
Unknown tag 🔶 Warning 10
Nested conditional expression 🔶 Warning 9
Mismatched dependencies in package.json 🔶 Warning 9
Negated 'if' statement 🔶 Warning 8
Anonymous capturing group or numeric back reference 🔶 Warning 6
Missing required attribute 🔶 Warning 5
Invalid type selector 🔶 Warning 4
Overwritten property 🔶 Warning 4
'if' statement with too many branches 🔶 Warning 4
Conditional expression with identical branches 🔶 Warning 3
Missing required 'lang' attribute 🔶 Warning 3
Result of increment or decrement used 🔶 Warning 3
Constant on left side of comparison 🔶 Warning 2
Redundant measure unit 🔶 Warning 2
Unresolved fragment in a link 🔶 Warning 2
Missed locally stored library for HTTP link 🔶 Warning 2
Unused 'catch' parameter 🔶 Warning 2
Confusing floating point literal 🔶 Warning 1
Unknown property 🔶 Warning 1
Empty 'catch' block 🔶 Warning 1
Function parameter naming convention 🔶 Warning 1
Inaccurate platform detection 🔶 Warning 1
Bitwise expression can be simplified 🔶 Warning 1
Function call is not compatible with 'callable' declaration 🔶 Warning 1
Unnecessary 'return' statement 🔶 Warning 1
Variable name is not following coding convention ◽️ Notice 13131
Method name is not following coding convention ◽️ Notice 3977
Unused declaration ◽️ Notice 3731
Class name is not following coding convention ◽️ Notice 1853
Missing semicolon ◽️ Notice 195
Usage of a silence operator ◽️ Notice 137
'var' is used instead of 'let' or 'const' ◽️ Notice 122
Missing parent call for method ◽️ Notice 119
Too many parameters in function declaration ◽️ Notice 79
Multiple class declarations ◽️ Notice 70
'var' declared not at the beginning of a function ◽️ Notice 66
Property name is not following coding convention ◽️ Notice 61
Duplicated code fragment ◽️ Notice 43
Link with unencrypted protocol ◽️ Notice 34
Missing strict types declaration ◽️ Notice 32
Getter and setter can be replaced with get and set property hooks ◽️ Notice 31
Unresolved reference ◽️ Notice 30
Fully qualified name usage ◽️ Notice 21
Unnecessary fully qualified name ◽️ Notice 17
Function name is not following coding convention ◽️ Notice 16
Unused import ◽️ Notice 15
Constant name is not following coding convention ◽️ Notice 14
Condition always evaluates to 'true/false' ◽️ Notice 8
Unnecessary curly braces syntax for variable ◽️ Notice 8
Properties may be safely replaced with a shorthand ◽️ Notice 5
Class can be 'readonly' ◽️ Notice 5
PHPDoc comment signature is not complete ◽️ Notice 4
Unnecessary double quotes ◽️ Notice 4
Redundant cast to string ◽️ Notice 4
Type tag without variable name ◽️ Notice 4
Redundant @var tag ◽️ Notice 3
Else if ◽️ Notice 3
Signature mismatch ◽️ Notice 2
Extension is missing in composer.json ◽️ Notice 2
Non-installed Composer packages ◽️ Notice 2
Type mismatch ◽️ Notice 1
Condition inside logical expression checked by subsequent condition ◽️ Notice 1
PHPDoc comment matches function/method signature ◽️ Notice 1
Loop can be converted to 'array_map()' call ◽️ Notice 1
Missing parameter list ◽️ Notice 1
Parameter's value is always the same ◽️ Notice 1
'str*()' calls can be replaced with PHP 8 'str_*()' calls ◽️ Notice 1

☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@LennyObez LennyObez force-pushed the chore/quality-and-performance branch 7 times, most recently from 21f947c to 6b124d7 Compare March 13, 2026 20:14
@LennyObez

Copy link
Copy Markdown
Owner Author

This PR should fix #389 (ExtensionBootstrap.loadFromPaths aborts all extension loading on single class-not-found).

@LennyObez

Copy link
Copy Markdown
Owner Author

Related issues from Studio Pair website project:

@LennyObez

Copy link
Copy Markdown
Owner Author

Also fixes #396 -- Container::singleton() not defined, breaks Messaging and Tickets registration.

@LennyObez LennyObez force-pushed the chore/quality-and-performance branch from 6b124d7 to 69106d9 Compare May 12, 2026 20:13
Copilot AI review requested due to automatic review settings May 12, 2026 20:13
LennyObez added a commit that referenced this pull request May 12, 2026
* cms MediaBundleImporter: reject unsafe storage_path/filename from imported
  ZIP manifests via isSafeRelativePath() guard. Closes the path traversal
  exposure where an attacker-crafted bundle could write outside the media
  root through MediaDiskInterface::write(). Failed paths are recorded as
  media errors, not silently skipped.
* cms AccountController + profile.pulse.php: pass `saved` flag through the
  view context instead of reading $_GET in the template. Removes a
  superglobal access from the rendering layer.
* forum ForumDashboardWidget + ForumCmsDashboardWidget: inline the two
  table names in distinct queries instead of interpolating $table into a
  shared SQL string. Removes the brittle pattern that invited future
  callers to pass user input into a query template.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request May 12, 2026
…d roots

* CmsPluginManager::removeDirectory: early-return unless realpath(path) resolves
  inside the configured plugin storage root or sys_get_temp_dir(). Guards the
  recursive unlink/rmdir loop against any future regression in manifest slug
  validation or DB tampering that could place an arbitrary path in
  $plugin->storagePath.
* StaticSiteChannel::removeStaticFile: skip the unlink unless the resolved file
  path lives inside the configured outputPath. Prevents an admin who controls
  $translation->path from escaping the static export directory through symlinks
  or relative segments.

Defence-in-depth on top of existing upstream validation. Semgrep mcp will keep
flagging the literal unlink($var) pattern (rule is purely syntactic), but the
runtime exposure is now closed.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request May 12, 2026
Same defence-in-depth pattern as CmsPluginManager: early-return unless
realpath(path) resolves inside the configured theme storage path, the public
asset directory ({publicPath}/cms-assets), or sys_get_temp_dir(). Guards the
recursive unlink/rmdir loop against any future regression in manifest slug
validation or DB tampering that could place an arbitrary path in
$theme->storagePath.

The Semgrep MCP rule on unlink($var) is purely syntactic and will keep firing,
but the runtime exposure is now closed.

SafeArchiveExtractor unlinks (lines 164/171) are already correct: they are
the explicit ZipSlip mitigation step that removes the offending file after
the realpath check fails - false positive on Semgrep's part.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request May 12, 2026
…on (10 files)

Plus a security hardening of CmsServeCommand: replaced passthru() with shell
command construction by proc_open() with an argv array (PHP 7.4+ shell-bypass
form). Added strict whitelist validation of the --host option (regex match
on host/IP characters only) and range validation of --port. Removed
escapeshellarg dependency since the shell is no longer involved.

@psalm-api markers on the Collaboration DTOs/repo interface, the two CMS
console commands, the Docs DocFeedback DTO + two interfaces, the CmsException
base class, and the CmsDdl migration helper utility.

Refs Sprint 0.8 burndown + PR #387 follow-up security review.
LennyObez added 17 commits June 26, 2026 10:15
New src/Security/AntiSpam/Risk: AdaptiveRiskEngine composes pluggable RiskSignalProviderInterface signals (probabilistic-OR: 1 - product(1 - s_i)) with RiskBypassProviderInterface short-circuit, and thresholds the score into a RiskDecision (Allow / Challenge / Block). Built-in BotScoreSignalProvider adapts the transparent BotDetector (0-100 -> 0-1) so it works out of the box.

AdaptiveChallengeMiddleware rejects high-risk requests (403) and attaches the RiskAssessment as a request attribute so forms and the managed-challenge renderer can present a challenge only when the decision is Challenge — progressive friction instead of an always-on challenge. The score is never leaked in a response header.

Wired globally by AntiSpamWiring when adaptive_risk.enabled (config/anti-spam.php). The architecture is the orchestrator for the remaining anti-bot work: JA4 fingerprinting plugs in as a signal provider, Private Access Tokens as a bypass provider, with no engine change. 17 tests; snapshot +7 #[Api].
Add an edge-supplied JA4/JA4+ TLS-fingerprint signal that feeds the
adaptive risk engine. The application cannot compute a JA4 fingerprint
(the TLS handshake is over by the time a request reaches PHP), so the
fingerprint is computed at the TLS-terminating edge and forwarded in a
header (default X-JA4).

Because a direct client could forge that header, it is honoured only for
requests arriving through a trusted proxy (trusted_proxies_only, on by
default, gated via TrustedProxy::isTrustedSource). It is a denylist
signal: an absent or unknown fingerprint adds no risk; an operator-
supplied known-bad fingerprint scores match_score and escalates the
engine decision.

- Ja4Config (#[Api]): opt-in, header name, trusted-proxy gate, operator
  denylist, match score; fromArray filters non-string fingerprints.
- Ja4SignalProvider (#[Internal]): RiskSignalProviderInterface; trust
  gate then denylist match.
- AntiSpamWiring: load Ja4Config, add the provider to the engine's signal
  set when both adaptive_risk and ja4 are enabled.
- config/anti-spam.php + docs/anti-spam.md: document the AI-scraper
  defense, adaptive risk engine, and JA4 signal (backfilling the config
  sample and docs for all three layers).
- Tests: config parsing, trust gate, spoofing defense, header name, and
  behavioural wiring proof (denylisted JA4 escalates to block; inert when
  disabled).
…ation

Add origin-side Private Access Token / Privacy Pass support (RFC 9577 /
RFC 9578, token type 0x0002 — Blind RSA, publicly verifiable). A valid
token proves the client passed an attester's checks without revealing its
identity, so it bypasses the adaptive challenge: real users sail through
while bots, which cannot obtain a token, still face the engine.

Crypto is verified against the official RFC 9578 known-answer vector:
RSASSA-PSS-VERIFY (SHA-384, MGF1-SHA-384, 48-byte salt) over the raw RSA
primitive (m = s^e mod n via gmp) plus EMSA-PSS-VERIFY (RFC 8017), with
token_key_id = SHA-256(issuer SPKI) and challenge_digest = SHA-256 of the
TokenChallenge wire form.

- TokenChallenge (#[Api]): RFC 9577 wire encoding + digest.
- PrivateAccessTokenVerifier (#[Api]): stateless, fail-closed verify of
  token type, key id, challenge digest, and the PSS signature.
- PrivacyPassConfig (#[Api]) + PrivacyPassChallengeIssuer (#[Api]).
- PrivateTokenBypassProvider: a risk-engine bypass (valid token => allow).
- PrivateTokenChallengeMiddleware: advertises WWW-Authenticate:
  PrivateToken on 401/403 so capable clients can redeem and retry.
- Internal: RsaSsaPssVerifier, IssuerPublicKey (SPKI DER walk),
  PrivateToken (parse), PrivateTokenHeaderParser.
- AntiSpamWiring: load PrivacyPassConfig, add the bypass to the engine and
  pipe the challenge advertiser (outermost) when configured and ext-gmp is
  present; degrade with a logged warning otherwise.
- config/anti-spam.php + docs/anti-spam.md document the feature; the
  verifier requires the gmp extension.

Verification is stateless (empty redemption_context); per-request
single-use binding, issuer-directory discovery, and key rotation sets are
documented follow-ups. Tests cover the RFC KAT, a self-issued round trip,
tamper/wrong-key/wrong-challenge rejection, the header parser, the bypass
provider, the challenge middleware, and the wiring (a valid token bypasses
the engine).
…Spam config

Add the advisory robots.txt layer for AI crawlers, driven by the same
AiCrawlerConfig as request-time enforcement. robots.txt is the only
control point for opt-out tokens that are never sent as a request
User-Agent (Google-Extended, Applebot-Extended), and a polite first
signal for the rest.

- AiCrawlerRobotsPolicy (#[Api]): renders a 'User-agent: <token>' /
  'Disallow: /' block for every crawler whose resolved action is Block
  (built-in and custom), including the opt-out-only tokens; allow and
  rate_limit crawlers are left unrestricted (rate limits are not
  expressible in robots.txt and are enforced at the middleware). Returns
  '' when the feature is disabled or nothing is blocked.
- CMS RobotsTxtGenerator: optionally consumes the policy and inserts the
  directives before the Sitemap line; wired in CmsCoreServiceProvider when
  AiCrawlerConfig is bound. Backward compatible (no policy => unchanged).
- docs/anti-spam.md documents the advisory layer.

Driving both the advisory and enforced policies from one config keeps them
from drifting apart. Tests cover the policy (block/allow/rate_limit,
overrides, custom crawlers, opt-out-only tokens, disabled) and the
generator integration (directives placed before the Sitemap).
…y rotation

Close the two highest-severity gaps from the anti-bot self-audit on the
PAT verifier, and tidy its public surface.

- Single-use: after the RSASSA-PSS signature is proven valid, the token's
  nonce is recorded in the cache; a replay of the same token is rejected.
  Without a cache it degrades to reuse-within-lifetime (logged), mirroring
  the managed-challenge replay pattern. The nonce is consumed only after a
  valid signature, so forged tokens cannot burn nonces or poison the cache.
- Key rotation: the verifier trusts a set of issuer keys at once, selecting
  by the token's token_key_id (token_key + token_keys), so a key can be
  rolled in and the old one retired with no downtime. The primary key is
  advertised in the challenge.
- API cleanup: the constructor is now private; build via fromBase64UrlKey /
  fromBase64UrlKeys so the #[Internal] IssuerPublicKey/RsaSsaPssVerifier
  types no longer appear on the #[Api] surface (refines the unreleased RC
  API).
- AntiSpamWiring resolves the TaggedCache for replay protection and builds
  the verifier from all configured keys; config/anti-spam.php + docs updated
  (token_keys, single_use, single_use_ttl_seconds).

Tests: rotation (token under any configured key), single-use accept-then-
reject with a cache, reuse-without-cache, empty key set rejected, plus the
RFC 9578 KAT and round-trip still green.
Let operators point Privacy Pass at an issuer directory instead of pasting
keys, completing the key-management story from the previous commit.

- PrivacyPassDirectory: total parser for the RFC 9576 directory document;
  extracts token type 0x0002 keys, de-duplicated, ignoring other types and
  malformed input.
- PrivacyPassDirectoryClient: fetches the directory over HTTP and caches the
  keys; cachedKeys() reads them. Resilient — a failed or non-200 refresh, or
  a directory with no usable keys, keeps the previously cached keys rather
  than dropping trust on a transient outage.
- PrivacyPassRefreshKeysCommand (privacy-pass:keys:refresh): runs the
  refresh from CLI/scheduler so the request path never makes a network call.
- AntiSpamWiring discovers directory keys from cache at boot and unions them
  with the static token_key/token_keys; registers the client + command when
  directory_url is set and HTTP + cache are bound.
- config/anti-spam.php + docs document directory_url and the refresh step.

Tests: parser (types, dedup, malformed), client (fetch/cache/read,
stale-on-failure, stale-on-throw, empty-directory), and a wiring proof that
a token signed under a directory-discovered key bypasses the engine.
Close the second high-severity gap from the anti-bot self-audit: a scraper
could forge an allowed crawler's User-Agent to slip past. When enabled, a
detected crawler's real client IP (resolved through trusted proxies) is
checked against the crawler's operator-published identity.

- CidrMatcher: IPv4/IPv6 CIDR membership (fail closed on malformed input).
- CrawlerDnsResolver + SystemCrawlerDnsResolver: DNS abstraction for
  forward-confirmed reverse DNS (FCrDNS), so the verifier is testable and
  the blocking lookups are swappable.
- CrawlerIdentityVerifier: Verified (IP in published ranges, or FCrDNS
  passes), Impersonator (verifiable but matches nothing), or Unverifiable
  (no data configured). FCrDNS results are cached off the hot path.
- AiCrawlerVerificationConfig (#[Api]) + CrawlerIdentity (#[Internal]).
- AiCrawlerMiddleware blocks impersonators with 403 regardless of the
  crawler's configured action; AntiSpamWiring builds the verifier (system
  DNS + cache) and passes it plus TrustedProxy when verification is enabled.
- config/anti-spam.php + docs document ai_crawler_verification.

Tests: CIDR matrix, verifier (range hit/miss, FCrDNS confirm/spoof/suffix-
mismatch, disabled), config parsing, middleware impersonator-blocking, and
a wiring proof that a forged GPTBot from a non-published IP is blocked.
Strengthen the JA4 TLS-fingerprint signal beyond exact-match denylisting,
closing a medium gap from the anti-bot self-audit.

- known_bad_prefixes: a family/partial match — when the fingerprint starts
  with a configured prefix (typically the JA4_a component, which summarises
  the TLS version and cipher list), it scores the lower-confidence
  partial_match_score. This catches a fingerprint family even as the later
  components vary.
- known_good_fingerprints: an exact allowlist that is always safe (score 0)
  and overrides the prefix denylist, so a flagged family can still permit
  known-good members.
- Precedence: allowlist > exact denylist > prefix denylist > no signal.
- config/anti-spam.php + docs document the new keys.

The constructor gains three optional parameters (named-arg compatible) and
fromArray funnels all list parsing through a single string-filtering helper.

Tests: prefix match, non-match, allowlist-overrides-prefix, exact-takes-
precedence, allowlist-zero, plus config parsing and non-string filtering.
Broaden the adaptive engine's signal set with two self-hosted, per-origin
signals — the local reputation it otherwise lacks — closing a medium gap
from the anti-bot self-audit.

- VelocitySignalProvider: counts a client's requests (keyed on the
  trusted-proxy-resolved IP) in a fixed window; below the threshold it adds
  no risk, above it the score scales with the overage up to max_score. The
  clock is injectable for deterministic tests.
- DatacenterIpSignalProvider: scores a client whose IP falls in
  operator-supplied datacenter CIDR ranges (no bundled ASN database — a
  deliberate no-external-data choice).
- CIDR matching is consolidated into a shared Pulsar\Support\Net\CidrMatcher
  (#[Api]); the AiCrawler-internal copy is removed and CrawlerIdentityVerifier
  now uses the shared one (whose FCrDNS cache key also drops the reserved ':').
- AntiSpamWiring resolves the trusted proxy once and feeds both signals into
  the engine when enabled; velocity also requires the cache.
- config/anti-spam.php + docs document velocity and datacenter.

Tests: velocity (below/above threshold, capping, window reset, disabled,
no-IP), datacenter (in/out of range, disabled, trusted-proxy resolution),
the shared CidrMatcher matrix, and wiring proofs that each signal escalates
the engine to a block.
…ed cache keys

Add an integration test that boots the real wiring (cache then anti-spam,
as the Kernel does) and dispatches HTTP requests through the global
middleware pipeline, proving the wired stack behaves end-to-end:

- a declared AI crawler is blocked (403) with the TDM reservation header;
- an ordinary request passes through;
- the adaptive engine blocks a high-risk request; and
- a valid Private Access Token bypasses that block, while the same request
  without a token is blocked.

That last case surfaced a latent bug the unit tests' permissive fake caches
hid: the per-token nonce, FCrDNS, and directory cache keys used ':', which
CacheKeyValidator rejects (PSR-6 reserved), so against the real cache the
PAT single-use check and directory read threw and failed closed. Switch
those keys to '.' separators (matching the managed-challenge convention).
…plaints)

The mail webhook handler stack (signature verifiers, replay window,
deduplication, bounce/complaint handlers) existed but nothing wired it, so
provider bounce/complaint notifications were never received. Make it a
turnkey, opt-in endpoint.

- MailWebhookConfig (#[Api]): provider, secret, path, replay window, IP
  allowlist. Disabled by default — an always-on webhook route is attack
  surface — and usable only with a provider + secret (SES is cert-based, so
  it needs no secret). Nested under MailConfig.webhooks.
- MailWebhookController: builds the WebhookRequest, enforces the optional
  source-IP allowlist, runs the secure handler, and dispatches bounce /
  complaint events to their handlers.
- CacheBackedDeduplicationStore: cross-process event dedup (the only existing
  store was per-process in-memory); keys are hashed so arbitrary tenant/event
  ids can never introduce PSR-6 reserved characters.
- MailWiring builds the verifier for the configured provider + dedup store
  (cache-backed when available) + handlers + controller and registers
  POST {path}; config/mail.php documents the block.

Tests: config (usability matrix incl. SES), dedup store (cross-tenant,
cache-safe keys via the real validator, no-op cleanup), controller
(bounce/complaint routing, 401 on rejection, 403 on disallowed IP), and
wiring (endpoint + route registered only when configured).
…systems

Register three built-but-unwired subsystems in the default wiring list.
All three default to off, so production behaviour is unchanged.

Edge: EdgeMiddleware adapts the incoming HTTP request to an EdgeRequest and
runs the configured edge-function pipeline (A/B tests, geo redirects),
short-circuiting with the redirect/block response a function returns.
Configured via config/edge.php; wired only when functions are present.

Documentation: opt-in versioned documentation serving. DocumentationWiring
registers the version registry and resolver middleware only when versions
are configured (config/documentation.php).

Profiler: an opt-in per-request profiler for dev/staging. ProfilerMiddleware
emits a Server-Timing response header (total, DB, cache). Query timings flow
through the monitored connection, including a profiler-only path so enabling
the profiler alone is sufficient to capture them; slow-query and audit
logging stay silent when SQL monitoring is off, so no audit lines leak.
Cache hits/misses are recorded through a new CacheManager event listener.

Register the three wirings as composition roots (ModuleMap) and reuse
Coerce::mapOfString in the edge config/adapter in place of bespoke filtering.
…misleading httpsEnforced flag

A literal `Strict-Transport-Security` or `Permissions-Policy` set in the `headers`
array of config/security.php was silently dropped. The structured HstsConfig
overwrote HSTS in the middleware, and PermissionsPolicyConfig (whose defaults are
always non-empty) overwrote any literal Permissions-Policy in effectiveHeaders().
A literal HSTS with `preload` therefore never reached the wire.

Literals are now authoritative — "what you write is what's emitted":

- SecurityHeadersConfig::effectiveHstsHeader() resolves the value to emit,
  preferring a literal `Strict-Transport-Security` over the structured `hsts`
  block. effectiveHeaders() drops any literal HSTS so it is never sent
  unconditionally, and the middleware emits it only on secure requests
  (RFC 6797 §7.2 — also fixing a latent leak of a literal HSTS over plain HTTP).
- A literal `Permissions-Policy` now wins over the structured config.
- literalHeader() matches case-insensitively (RFC 9110 §5.1).
- SecurityWiring logs a one-time boot warning (shadowedStructuredHeaders()) when a
  literal shadows an active structured block with a different value, so the
  override is never silent in either direction.

Separately, SecurityAssertionRunner's `httpsEnforced` constructor parameter is
renamed to `hstsEnabled`: it mirrors `headers.hsts.enabled` and only gates the
posture self-check — it emits no redirect, cookie, or header. The docblock now
states this explicitly.

Docs (security-baseline.md) and the config scaffold document literal precedence,
the secure-only HSTS rule, and the `include_sub_domains` (snake_case) key spelling.
…ry, not getenv()

A master key provided only in `.env` (the common production layout) was reported
missing by the security posture self-check, because SecurityAssertionRunner read
it through getenv() — which never sees `.env`-loaded values (they are parsed into
the Environment repository, not exported to the OS process env). Under PHP-FPM
this logged a critical `master_key_present` warning on nearly every worker boot.

The runtime crypto path (SecurityWiring -> MasterKey -> session/CSRF) already
resolved the key through `$configManager->environment()->get()`, so the key WAS
loaded for encryption; the assertion runner simply disagreed with it. This change
makes the posture check and the cache-invalidation fingerprint consistent with
the crypto path:

- SecurityAssertionRunner now receives the framework-resolved master key
  (`?string $masterKeyHex`) and checks that, instead of calling getenv() itself.
  SecurityWiring passes the same Environment-resolved value it already uses to
  build the MasterKey.
- FrameworkCache resolves its structural env-invalidation keys through the
  injected Environment (OS env + .env) when available, falling back to getenv()
  only when no Environment is wired (the dev bootstrap). A key present only in
  `.env` now participates in cache invalidation.

The security path intentionally does NOT add a raw getenv() fallback:
Environment::get() already merges OS env at load, and a bare getenv() would
bypass the F4.9 allowlist hardening.

Tests: a .env-only key (absent from the OS process env) now wires the MasterKey
and produces no `master_key_present` warning (SecurityWiringTest, end-to-end);
the runner honors a resolved key without getenv; the cache fingerprint tracks
the Environment-resolved value.
…ut app wiring

API-based mail transports (mailgun/ses/postmark/sendgrid) require a
MailHttpClientInterface, but the framework shipped no implementation and
MailWiring only injected one if the application had already bound it. So every
API driver threw "MailHttpClientInterface is required for API-based transports"
on first send; only smtp/log/array worked out of the box.

Ship two implementations and auto-bind a default:

- CurlMailHttpClient: the zero-config default (ext-curl is already required).
  Enforces TLS peer + host verification and bounded connect/total timeouts;
  returns 4xx/5xx as responses and throws only on transport-level failure
  (which the transport wraps in a driver-scoped MailException).
- Psr18MailHttpClient: adapts a container PSR-18 client (via the framework's
  PSR-17 request/stream factories) so mail reuses the application's HTTP stack
  — pooling, retries, proxy, observability, test doubles — when one is provided.

MailWiring now registers a default MailHttpClientInterface whenever mail is
enabled and the application has not bound one: it prefers a container PSR-18
client (Psr\Http\Client\ClientInterface), otherwise the cURL client. An
application-provided client is preserved.

Adds psr/http-client (^1.0) — a PHP-FIG interface-only package with no
transitive dependencies, the sibling of the already-present psr/http-factory.

Acceptance: with MAIL_DRIVER=mailgun and valid mailgun driver_options,
MailManager::driver('mailgun') resolves the transport instead of throwing, and
MailManager::raw() sends through the default client with no app-side binding.
…lues apply

The global env() helper read getenv() only, so a value provided solely in .env
(parsed into the Environment repository but never exported to the OS process
env) was invisible to env() — including the env() calls inside the framework's
own config/*.php files. .env effectively did not work for env().

Bind the loaded Environment as a process-global "active" instance:
- Environment::activate()/active()/resetActive(), set by ConfigManager::load()
  and loadFromCache() before any config file is required.
- env() now resolves: active environment (OS env + .env, merged, OS-wins)
  -> getenv() fallback -> default, keeping the existing true/false/null/empty
  coercion. Strictly additive: only .env-only keys that previously returned the
  default now resolve.
- A PHPUnit extension resets the active environment before every test, so the
  process-global cannot leak across the suite.
- ADR-0033 documents why this bootstrap global is acceptable under the project's
  "avoid global state" rule.

Also adds Environment::read() — an un-coerced, .env-aware accessor for opaque
secrets and paths — and routes the AWS / Azure / GCP credential getenv() reads
through it, so cloud credentials provided only in .env resolve too.

env() remains a bootstrap-time helper; runtime code should prefer typed config
(ConfigManager / config DTOs) or Environment::get().
…hardcoded page

CsrfMiddleware emitted a hardcoded <!DOCTYPE html> 403 (and a bespoke JSON body)
that bypassed the application's configured error renderer, so the CSRF rejection
could not be themed or localized like every other 4xx.

CsrfMiddleware now content-negotiates its 403:
- JSON for API clients (Accept: application/json, or X-Requested-With:
  XMLHttpRequest), preserving the prior XHR-as-JSON behaviour.
- HTML rendered via the application's configured ExceptionRendererInterface —
  themed and localized like every other error page — when one is wired, falling
  back to a minimal inline document otherwise.

The response is returned, not thrown. Throwing would let the Kernel's outer
catch build the error response after the middleware stack has unwound, stripping
the SecurityHeadersMiddleware headers off the 403; returning keeps the response
flowing back out through the stack so security headers still apply on rejection
(covered by SecurityPipelineTest::securityHeadersAreAppliedEvenOnCsrfRejection).

The renderer is wired by ExceptionHandlerWiring, which runs after SecurityWiring,
so SecurityWiring injects a lazy resolver closure (mirroring its own
templateEngineResolver pattern) that the middleware invokes at request time. The
optional constructor parameter is additive: callers that omit it get the minimal
fallback page, exactly as before.
@LennyObez LennyObez closed this Jun 30, 2026
@LennyObez LennyObez force-pushed the chore/quality-and-performance branch from 67941aa to e4b9478 Compare June 30, 2026 09:12
LennyObez added a commit that referenced this pull request Jun 30, 2026
* cms MediaBundleImporter: reject unsafe storage_path/filename from imported
  ZIP manifests via isSafeRelativePath() guard. Closes the path traversal
  exposure where an attacker-crafted bundle could write outside the media
  root through MediaDiskInterface::write(). Failed paths are recorded as
  media errors, not silently skipped.
* cms AccountController + profile.pulse.php: pass `saved` flag through the
  view context instead of reading $_GET in the template. Removes a
  superglobal access from the rendering layer.
* forum ForumDashboardWidget + ForumCmsDashboardWidget: inline the two
  table names in distinct queries instead of interpolating $table into a
  shared SQL string. Removes the brittle pattern that invited future
  callers to pass user input into a query template.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request Jun 30, 2026
…d roots

* CmsPluginManager::removeDirectory: early-return unless realpath(path) resolves
  inside the configured plugin storage root or sys_get_temp_dir(). Guards the
  recursive unlink/rmdir loop against any future regression in manifest slug
  validation or DB tampering that could place an arbitrary path in
  $plugin->storagePath.
* StaticSiteChannel::removeStaticFile: skip the unlink unless the resolved file
  path lives inside the configured outputPath. Prevents an admin who controls
  $translation->path from escaping the static export directory through symlinks
  or relative segments.

Defence-in-depth on top of existing upstream validation. Semgrep mcp will keep
flagging the literal unlink($var) pattern (rule is purely syntactic), but the
runtime exposure is now closed.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request Jun 30, 2026
Same defence-in-depth pattern as CmsPluginManager: early-return unless
realpath(path) resolves inside the configured theme storage path, the public
asset directory ({publicPath}/cms-assets), or sys_get_temp_dir(). Guards the
recursive unlink/rmdir loop against any future regression in manifest slug
validation or DB tampering that could place an arbitrary path in
$theme->storagePath.

The Semgrep MCP rule on unlink($var) is purely syntactic and will keep firing,
but the runtime exposure is now closed.

SafeArchiveExtractor unlinks (lines 164/171) are already correct: they are
the explicit ZipSlip mitigation step that removes the offending file after
the realpath check fails - false positive on Semgrep's part.

Refs PR #387 security review.
LennyObez added a commit that referenced this pull request Jun 30, 2026
…on (10 files)

Plus a security hardening of CmsServeCommand: replaced passthru() with shell
command construction by proc_open() with an argv array (PHP 7.4+ shell-bypass
form). Added strict whitelist validation of the --host option (regex match
on host/IP characters only) and range validation of --port. Removed
escapeshellarg dependency since the shell is no longer involved.

@psalm-api markers on the Collaboration DTOs/repo interface, the two CMS
console commands, the Docs DocFeedback DTO + two interfaces, the CmsException
base class, and the CmsDdl migration helper utility.

Refs Sprint 0.8 burndown + PR #387 follow-up security review.
LennyObez added a commit that referenced this pull request Jun 30, 2026
* AudioProcessor::cleanupTempFile / PdfThumbnailGenerator::cleanupTempFile
  appended DIRECTORY_SEPARATOR to the resolved sys_get_temp_dir() before the
  str_starts_with check. Without the trailing separator a directory whose
  name shared a prefix with the temp dir (e.g. "/tmp_evil/..." vs "/tmp")
  could pass the boundary check and have files unlinked from outside the
  temp area. Same hardening as CmsPluginManager / ThemeManager /
  StaticSiteChannel.
* PdfThumbnailGenerator::generateWithGhostscript: nosemgrep on the
  proc_open() call. Argv-array form bypasses the shell entirely, and both
  $this->ghostscriptPath (server config) and $pdfPath (validated upstream
  by FileValidator/PdfValidator) are non-shell inputs. Same pattern as
  CmsServeCommand.

Plus @psalm-api on cms public Audio + Document DTOs and processors:
AudioConfig, AudioMetadata, AudioTranscodePreset, DocumentMetadata.

Refs PR #387 security review + Sprint 0.8 burndown.
LennyObez added a commit that referenced this pull request Jun 30, 2026
…0031, F33.M2/F385.M1/F387.M1)

The project's de-facto release process bundles whole release cycles
into single mega-PRs:
- PR #33: 51 K LOC, 1 reviewer (F33.M2 critique)
- PR #380: 290 K LOC, closed without merge (F385.M3)
- PR #385: 408 K LOC, 50 commits squash-merged into rc.11 (F385.M1)
- PR #387 (open, GA target): 243 K LOC, 2 296 files (F387.M1)

A 400 K-line diff cannot be reviewed in any meaningful sense. For a
framework targeting banking / healthcare / legal compliance, the
"PR was peer-reviewed" claim becomes self-documenting fiction --
exactly the kind of audit-trail gap regulators pull on.

ADR-0031 establishes:

1. Hard cap: feature PRs MUST NOT exceed 1 500 LOC of substantive
   diff (excluding generated code, fixtures, lockfiles, renames).
   CI enforces via tools/ci/check-pr-size.sh against the diff vs main.
2. Soft target: ≤ 500 LOC where feasible.
3. Release PRs (the PR that bumps version + changelog) MUST be
   ≤ 100 LOC and may only touch composer.json/composer.lock,
   src/Core/Version.php, and CHANGELOG.md. Bundling features into
   a release PR is rejected.
4. Conventional Commits prefix MUST reflect actual scope: a PR that
   introduces a new src/<Module>/ MUST use feat(...), not chore(...)
   (closes F387.M2).
5. Closed-without-merge release PRs MUST carry a maintainer comment
   explaining why (closes the audit-trail gap from F385.2 + F385.4).
6. The 1.0.0 GA tag is gated on this policy being satisfied; PR #387
   in its current form is not eligible for merge.

Implementation phases: CI enforcement now, retrospective audit of
PR #385's 50 commits during rc.12, GA gate confirmation in the
release-PR description.

Closes audit findings F33.M2, F385.M1, F385.M3, F387.M1, F387.M2.
LennyObez added a commit that referenced this pull request Jun 30, 2026
…, F387.M3, F385.1)

The Version::PRERELEASE_SUFFIX constant has drifted from composer.json
on every release cycle since rc.5: identified at PR #23, ignored on
PRs #24-#32, fixed at PR #33, re-opened at rc.11 (PR #387 GA target).
Without an automated check, this pattern is guaranteed to recur at
rc.12, rc.13, and the GA tag.

Add tools/ci/check-version-consistency.sh which composes the runtime
version string from the parsed MAJOR/MINOR/PATCH ints + PRERELEASE_SUFFIX
constant in src/Core/Version.php and compares it against the version
field in composer.json. Mismatch exits non-zero with an explanation
that points to the recurring audit finding.

Wired into ci.yml's php-quality job before composer cs:check, so any
release PR that bumps composer.json::version without flipping the
Version constant (or vice-versa) is rejected before merge.

Bonus: harden two pre-existing GitHub Actions injection sinks flagged
by static analysis (CWE-78). git fetch origin ${{github.base_ref}} and
its sibling now route through env: variables and quoted "$BASE_REF"
substitution, eliminating the path that lets a maliciously named base
ref inject shell commands into the runner.

Closes audit findings F23.1 (recurring), F385.1, F385.M2, F387.1,
F387.M3.
LennyObez added a commit that referenced this pull request Jun 30, 2026
…0 carry-over)

RegistrationCeremonyTest::verifyLogsFailureOnException expected the
audit log to be invoked with `actor: null`, but the production code in
extensions/auth/src/WebAuthn/Ceremony/RegistrationCeremony.php:170
calls `actor: AuditActor::anonymous()` (the explicit-actor pattern
mandated since F25.10 / Session 1 commit 358f149a forbade the silent
'system' fallback for null actors).

The test was created together with the production code in rc.11 (PR
#387 release bundle, commit ca0edbda) but its actor expectation
predated the F25.10 fix. The earlier filtered test runs never
exercised this file, so the failure surfaced only when the WebAuthn
suite was widened in this Session 9 batch.

Replace the `null` matcher with a callback that asserts an
`AuditActor` of kind Anonymous (id 'anonymous'). 406 / 406 tests now
pass on the WebAuthn + OAuth2Extension subset.
LennyObez added a commit that referenced this pull request Jun 30, 2026
F387.4 / F387.M2 / F380.M1: PRs #380, #385, and #387 all used
`chore:` or `docs:` prefixes while adding new core modules
(`src/Workflow/`, `src/Saga/`, `src/Codegen/`,
`src/ServiceDiscovery/`) or new extensions (`extensions/accessibility/`).
Reviewers triage by title — a `chore:` invites less scrutiny
than a `feat(core):`, and that scrutiny gap is a compliance gap
for a banking framework.

Adds `tools/ci/check-pr-title-scope.sh`: reads $PR_TITLE and the
base-ref diff, fails the build when title prefix is `chore:` or
`docs:` but the diff adds a new top-level dir under `src/` or
`extensions/`. Wired into ci.yml on pull_request events only.

Allowed prefixes (feat, fix, security, perf, refactor, test, ci,
build) bypass the rule — only the two deception-prone prefixes
trigger it.

Smoke-tested locally: `feat(core):` + module add → exit 0,
`chore:` + diff containing src/X/foo.php → exit 1 with
human-readable error.
LennyObez added a commit that referenced this pull request Jun 30, 2026
F387.M1 / ADR-0031: the ADR was accepted but its CI
enforcement was unwired — the policy existed on paper, the
mega-PR pattern continued unchecked. This commit ships the
enforcement script.

`tools/ci/check-pr-size.sh`:
  - counts substantive diff (additions + deletions) between
    `origin/<base>` and HEAD, excluding paths in
    `.size-limit-ignore`;
  - exits 0 under the 1500-line cap, exits 1 over;
  - exemption requires BOTH the `oversize-pr-acknowledged`
    label AND a `/oversize-pr-approved` comment by a
    CODEOWNER who is NOT the PR author (mirrors the F28.3
    adr-exempt two-party pattern).

`.size-limit-ignore` ships sane defaults: lockfiles
(composer/pnpm/npm/yarn), the regenerated public-API snapshot,
and pack-scaffold lockfiles. Operators can extend the list
without touching the script.

`.github/workflows/ci.yml` invokes the check on every
`pull_request` event, with `PR_NUMBER` / `PR_AUTHOR` /
`BASE_REF` / `REPO_FULL` / `GH_TOKEN` wired through.

Smoke-tested locally:
  - missing BASE_REF (push event simulation) → skip + exit 0;
  - syntax check (`bash -n`) clean.

The ADR-0031 policy is now load-bearing: PR #387 (or any
successor that retains the mega-PR shape) cannot merge
without the explicit two-party exemption flow.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request extension Extension system performance Performance optimization security Security hardening, crypto, audit testing Test coverage and quality

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[S.GA] Pre-GA Housekeeping

2 participants