fix(templates): enforce read-only default templates server-side (#865) - #870
Merged
Conversation
Closes a deviation from #865 decision #11: the #869 edit metabox let an operator edit/rename/delete a shipped default template, but defaults must be read-only — customizing one means Duplicate → an editable user template. - save_edit_metabox: never rewrite a default's HTML (skips update_html; the textarea is also rendered `readonly` with an explanatory notice). Visibility stays togglable for defaults (a default can be hidden, not edited). - protect_default_caps (map_meta_cap, priority 20 so it runs after CptCapPolicy's map at 10): returns do_not_allow for delete_post on a default — a real server-side block, not just hiding the Trash/Delete row action. - preserve_default_title (wp_insert_post_data): restores a default's shipped title on save, blocking rename. Tests cover all three guards plus the readonly render. User templates are unaffected (edit/rename/delete as before). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3
rpgmem
enabled auto-merge (squash)
August 5, 2026 01:31
…fault_title (#865) PHPStan level 8: get_post_field() is typed array<int>|int|string, so the blind (string) cast could stringify an array. Guard with is_string() instead — functionally identical for post_title (always a string), PHPStan-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3
…test (#865) The #870 read-only guard added a CertTemplateReader::is_default() call (→ get_post_meta) at the top of save_edit_metabox, so the pre-existing test_save_edit_metabox_persists_html_and_visibility (from #869) now needs get_post_meta mocked. Return '' (a non-default template) so the HTML update still runs and the assertion holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3
Coverage Report for CI Build 30967811239Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 88.831%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
12 tasks
rpgmem
added a commit
that referenced
this pull request
Aug 8, 2026
…r REST API, encryption key rotation (#897) * feat: self-hosted plugin updater via GitHub Releases (#820) (#821) * feat: self-hosted plugin updater via GitHub Releases (#820) Teach WordPress's native plugin-update check to see this plugin's GitHub Releases: the update surfaces in Dashboard -> Updates, the details modal renders the release changelog, and WP's per-plugin auto-update toggle becomes available (opt-in; auto_update_plugin is never forced). - Integrations\GithubUpdater hooks pre_set_site_transient_update_plugins (response when newer, no_update when current so the toggle shows), plugins_api (details), and upgrader_pre_download (SHA-256 verify). - Points package at the built ffcertificate-X.Y.Z.zip release asset (not the source zipball -> correct folder, no dev cruft), 12h site-transient cache + ETag If-None-Match against the 60/h rate limit, and aborts the install on a checksum mismatch. - Update URI header added so wordpress.org can't hijack the slug. - Wired always-on in Loader (not is_admin: the scan runs in cron). - 15 unit tests (mock wp_remote_get/transient); class at 95% line coverage. No new module-boundary edge (Root->Integrations exists). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 * fix(updater): drop redundant null-coalesce flagged by PHPStan The empty($raw['package']) guard already proves the offset exists and is truthy, so $raw['package'] ?? '' tripped PHPStan's nullCoalesce.offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 --------- Co-authored-by: Claude <noreply@anthropic.com> * chore(activator): flag-guard the FK migration + document the integrity policy (#822) (#823) The user_id -> wp_users foreign-key migration ran on every activation, writing an all-skipped migration_foreign_keys audit-log line each time — the only migration in the activator without a completion-flag guard. - Guard maybe_add_foreign_keys() on ffc_foreign_keys_db_version like the sibling migrations; pin the version only once every FK exists so a MyISAM host / not-yet-created table keeps retrying (renamed from the private add_foreign_keys(); now public + testable). - Document why the migration is still necessary (dbDelta can't emit FKs -> it's the sole path, absent on fresh installs) and that the covered set is a deliberate subset mirroring UserCleanup + the activity_log audit table, not every user_id column. - Correct the recruitment activator's misleading 'no FK = the plugin convention' note; add the policy to CLAUDE.md section 4. - 3 guard tests. Behaviour-preserving; the known reregistration/ custom-field user_id gap is tracked in #822 (out of scope here). Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * chore(deps): update vendored thumbmarkjs 1.9.1 -> 1.10.1 (#809) (#824) Drop-in dependency refresh. The UMD API surface is identical (ThumbmarkJS.getFingerprintData / setOption / stableStringify), so ffc-device-signals.js needs no logic change — only the version constant and a path comment. Verified the privacy-critical setOption('logging', false) still gates the sampling beacon to api.thumbmarkjs.com in 1.10.1 (logging && !sessionStorage && random < 1e-4). Vendored file integrity cross-checked: jsdelivr == unpkg for 1.10.1, and the CDN 1.9.1 matched the previously-vendored file byte-for-byte. 20 device-signals JS tests green; minified bundle unchanged (comment-only source edit). Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * chore: remove the CptEditorCompat deprecation shim (#739) (#825) Scheduled removal (REMOVE_IN=6.18.0). The shim re-granted ffc_manage_forms / ffc_manage_calendars to WordPress Editors (edit_others_posts holders) for a two-release window after the 6.16.0 CPT-capability decoupling. That window has closed, so remove the class, its Loader init() call, and its test. WP Editors now need the explicit FFC role/capability to manage forms/calendars — completing the #739 security decoupling. CptCapPolicy (the permanent per-post write gate) stays; its docblock reference to the removed shim is reworded. Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * fix(admin): diagnosable user-role toggle + honest chips for WP admins (#826) On the user-edit / profile screen the per-user FFC role toggle collapsed every AJAX failure into one generic "reload and try again" alert, so a #739 policy rejection (a WordPress administrator may only be granted the FFC Administrator role — every other role returns 409 cannot_edit_admin) was indistinguishable from an expired nonce. - JS: map the server's reason (cannot_edit_admin / role_not_assignable / forbidden / user_not_found), detect the bare `-1` nonce die as a session-expired message, and use a network-specific message on reject. - PHP: localize the reason-specific strings; render the non-aggregator role chips disabled (omitting ones the admin doesn't hold) for a manage_options target, mirroring the server policy so the UI never offers a click that always 409s. - Tests: JS coverage for every reason branch; PHP coverage for the aggregator-only + held-disabled admin rendering. Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * chore: remove scheduled legacy shims — pre-6.2.0 cap rename + pre-4.6.15 cron cleanup (#809) (#827) Fase 2 of #809 retires the two shims the §5 audit log scheduled for 6.18.0: - ensure_legacy_caps_renamed() + CapabilityMigrator::migrate_legacy_certificate_caps() / legacy_cap_renames(): the pre-6.2.0 view_own_certificates → ffc_view_own_certificates rename, dormant on every install that passed through 6.2.0–6.17.0. A site jumping straight from pre-6.2.0 to 6.18.0 skips it (documented in the CHANGELOG banner). - The pre-4.6.15 orphan-cron cleanup (3× wp_clear_scheduled_hook) in the activator and both deactivator paths — those hooks have not been registered since 4.6.15, so any leftover entry is inert. Tests updated: drop the legacy row from the Loader migration provider, the legacy_cap_renames assertion, the activate() legacy-cron test, and the legacy hooks from the deactivate()/uninstall cron-clear counts (6→3, 7→4). CLAUDE.md §5 inventory emptied (both rows removed). Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * test(audience): cover NotificationHandler flows + AdminBookings render (#809 Fase 2) (#828) Tests only, no product change. - AudienceNotificationHandlerFlowTest (new, process-isolated): the two orchestration entry points (booking created / cancelled) and their helpers — admin-notification opt-in, recipient CSV parse + site-admin fallback, ICS attachment temp-file lifecycle, and the per-user send loop with skip-on-missing-email. Lines ~16% → ~96%. - AudienceAdminBookingsRenderTest (new, process-isolated): the populated render_page() path — active vs cancelled rows, batched creator-name map with Unknown fallback, known/unknown booking-type labels, export button gated on ffc_export_audiences, and the environment filter branch. Lines ~51% → ~97%. Both new classes run @runTestsInSeparateProcesses because they alias-mock the reader/repository/mailer collaborators; the existing sibling tests (real collaborators) are left untouched. Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * fix(admin): preserve FFC roles across WordPress' native profile save (follow-up #826) (#829) The per-user FFC role chip applies the role via AJAX as an *additional* role, but WordPress' native "Update user" save posts the single-role `<select name="role">`; core then runs WP_User::set_role(), collapsing a multi-role user to that one role and silently dropping the FFC role. This hit plain and administrator users alike. Snapshot the user's FFC preset roles on personal_options_update / edit_user_profile_update (which fire before edit_user()/set_role), then re-apply the dropped ones on profile_update (fires after set_role). Only roles the user already held are restored — never a fresh grant — and the snapshot is consumed once per request. Per-user capability grants and audience memberships were already unaffected (set_role only swaps role keys, not the individual cap grants). Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * chore(deps)(deps-dev): bump globals from 17.7.0 to 17.8.0 (#831) Bumps [globals](https://github.com/sindresorhus/globals) from 17.7.0 to 17.8.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.7.0...v17.8.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.8.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps)(deps-dev): bump phpstan/phpstan from 2.2.5 to 2.2.6 (#830) * chore(deps)(deps-dev): bump phpstan/phpstan from 2.2.5 to 2.2.6 Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.5 to 2.2.6. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * fix(recruitment): drop redundant null-check flagged by PHPStan 2.2.6 PHPStan 2.2.6 (bumped in this PR) tightens notIdentical.alwaysTrue detection: `isset( $x ) && null !== $x` is redundant because isset() already excludes null, so the second operand is always true. Simplify both preview_reason_id guards to `isset( $x )`; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alex Meusburger <rpgmem@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> * chore(deps)(deps-dev): bump eslint from 10.7.0 to 10.8.0 (#832) Bumps [eslint](https://github.com/eslint/eslint) from 10.7.0 to 10.8.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.7.0...v10.8.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.8.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * docs(#822): record user-deletion retain-orphan policy + fix appointments migration table name (#833) Resolves the #822 decision for `ffc_reregistration_submissions`: its NOT NULL `user_id` is an **accepted orphan** — a reregistration is a retained record and the identifying PII lives in the `data` JSON body, so nulling the FK alone would be a half-measure. Documented at the source (reregistration activator) and, plugin-wide, in CLAUDE.md §4 with the governing "retain records, drop relationships" principle plus the full inventory of still-uncovered user references (attribution `*_by` columns, post-promotion recruitment candidate, row-body PII cleanup) — correcting the prior note that implied a separate custom-field value table (values live in the `data` column, not a separate table). Also corrects `maybe_migrate_sibling_instants_to_unix()` to target the real `ffc_self_scheduling_appointments` table instead of a non-existent `ffc_appointments` (a silent no-op). Behaviour-preserving on every current install — those columns already ship as BIGINT on 6.6.0+ and the migration helper fast-returns on already-int columns. No product change; docs + a comment + one string literal. Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * test(#809): scaffold tests/Integration/ with a plugin-bootstrap wiring smoke (#835) Introduce the previously-empty integration test layer (Fase 3 structural gap). PluginBootstrapSmokeTest boots the real plugin object graph via Loader::init_plugin() over a stubbed WordPress boundary and asserts it composes without a fatal and lands its bootstrap hooks / crons / roles, in both the frontend and admin contexts -- the wiring the unit LoaderTest deliberately excludes ("would require mocking the entire plugin graph"). - IntegrationTestCase: reusable base that installs a recording WP boundary (add_action/add_filter/add_shortcode/cron/REST captured; permissive no-op defaults elsewhere) and a helper to seed one-shot migration flags so the boot takes its steady-state path. - Register an `integration` PHPUnit suite (runs in the plain phpunit gate). The coverage shards still glob tests/Unit only, so the coverage floor is untouched. - Add WP table-name properties ($posts, $users, ...) to the wpdb stub in tests/bootstrap.php so repositories reading $wpdb->posts (FormRepository) resolve during a full-graph boot. Claude-Session: https://claude.ai/code/session_019BWjtbKXTR3yo22dMVM5p8 Co-authored-by: Claude <noreply@anthropic.com> * chore(deps-dev): bump wp-coding-standards/wpcs 3.4.0 → 3.4.1 (CVE-2026-45293) (#843) Clears the high-severity advisory CVE-2026-45293 (arbitrary code execution in WordPressCS >=0.14.1,<3.4.1) that was failing the `Composer audit` gate on develop and every open PR. wpcs is a dev/CI-only dependency (the WPCS gate), so this rides the develop batch per the dev/CI-only-dep policy rather than a hotfix. Lock-only change within the existing `^3.1` constraint; also pulls the matching phpcsstandards/phpcsextra 1.5.1 + phpcsutils 1.2.3. Verified locally: `composer audit` reports no advisories after the bump. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * security(csv): minimize public export + noindex /valid (#836) (#842) Hardens the public certificate CSV export ([ffc_csv_download]) and the /valid verification page. - V2: drop the magic-link `Token` column from the public CSV row. The token is a live access credential (the magic link renders the full-PII PDF) and was being dumped in bulk for every submission. - P1: drop the User IP, Consent Given/Date/IP/Text and Status columns from the public export — data minimization. The admin export (SubmissionsExportSource) keeps its full layout; the two are no longer byte-identical by design. - P2: emit `noindex` on the [ffc_verification] page (configured page id or shortcode) via the wp_robots filter — belt-and-suspenders on top of the fragment-based magic link. Public CSV column layout changed (8 fixed columns: ID, Form, User ID, Submission Date, E-mail, CPF, RF, Auth Code). Tests updated for the new layout; added coverage for verification_page_robots(). Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * security(js): encode quotes in attribute-context escaping (#837) (#844) Two JS attribute-context escaping gaps (same bug class as the #564 stored-XSS), fixed mechanically: S4 (stored XSS, user → admin) — ffc-admin-submission-edit.js built its user-search result data-*="…" attributes with a createTextNode+innerHTML escapeHtml that encodes < > & but NOT quotes. A logged-in user's display_name (sanitize_text_field keeps quotes) is echoed unescaped by the admin AJAX search, so a value like `a" onmouseover="…"` broke out of the attribute and ran a handler in the admin's session. escapeHtml now also encodes " and '. S12 (self-XSS) — ffc-user-dashboard-profile.js used esc() (no quote encoding) inside the edit-form value="…" inputs and the audience-group chip style="…". Switched those five sites to the attribute-safe escAttr helper already defined in the dashboard core. Rebuilt the two .min.js bundles. Added Vitest regression coverage: a quote-laden display_name in the admin search and in the profile edit form / group chip no longer injects a rogue onmouseover attribute. CHANGELOG under [Unreleased] → Security. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * security(rest): mask PII on /submissions + /verify, reuse the /valid masks (#838) (#845) Two REST endpoints returned certificate PII unmasked, bypassing the "mask unless a PII cap is held" convention the wp-admin + /valid surfaces already enforce. Fixed by reusing the *existing* masks — no new masking logic: S3 (public POST /verify) — the endpoint masked the top-level email/cpf/rf columns but returned the decrypted `data` blob raw, leaking every form-collected PII field (cpf/rf/rg/email inside `data`) to any caller with a valid auth code. It now runs `data` through the same per-key mask the /valid page uses. S2 (GET /submissions[/{id}]) — gated only by the read-only `ffc_view_certificates` (view) tier, it returned raw email + full `data` (single also returned unmasked cpf/rf via format_document). It now masks email/cpf/rf/data by default and returns plaintext only for the unmasked (`ffc_certificates_admin` role / super-admin) and reveal (`ffc_view_certificates_pii` cap) tiers via the shared PiiAccessPolicy, auditing each reveal exactly as the admin reveal endpoint does — so a PII-blind operator can no longer bulk-extract certificate PII over REST without a trail. Reuse detail: the per-key masking that lived inline in VerificationResponseRenderer::format_field_value() is extracted into the shared DocumentFormatter::mask_field_value() (cpf/cpf_rf/rg -> mask_cpf, rf -> mask_rf, email -> mask_email; non-PII fields untouched). Both the /valid renderer and the REST controller now call it, so the two paths can't drift apart again. Top-level fields reuse the existing mask_cpf/mask_rf/mask_email + PiiAccessPolicy. No one-shot cap migration: the roles that legitimately need plaintext (manager, admin) already hold `ffc_view_certificates_pii`; viewer/operator are PII-blind by design, so auto-seeding would defeat the fix. Breaking for any external REST consumer authenticating as a view/operator role — grant them the PII cap to keep plaintext (noted in CHANGELOG). Tests: DocumentFormatterTest covers mask_field_value (PII keys masked, non-PII/empty/non-scalar passthrough); SubmissionVerifyRestControllerTest adds masked-tier + reveal-tier /submissions cases and the /verify data-blob mask. Could not run composer suites locally (proxy blocks composer's GitHub auth); php -l clean, CI runs the authoritative gates. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * security(hygiene): rate-limit PII hash, CSV import limits, no-JS verify throttle, nginx doc (#839) (#847) * security(hygiene): rate-limit PII hash, CSV import limits, no-JS verify throttle, nginx doc (#839) Four independent low-risk hygiene items from #839 (S7a — the key/salt health warning — is split into sub-issue #846 with its own PR): S5 — ffc_rate_limits stored the rate-limit identifier (email / normalized CPF) in plaintext, unlike the sibling ffc_rate_limit_logs which hashes. RateLimitRepository now routes every identifier through a private storage_identifier(): non-IP types are sha256-hashed at rest (a counter only needs a stable equality key), IP stays plaintext for the stats "top IPs" view. Applied on both the read (get_count / is_blocked) and write (increment / block) sides so lookups stay consistent; existing raw rows lapse after their window (one-time reset at most). S9 — the recruitment classifications CSV import read the whole upload with file_get_contents() with no size or type guard (memory DoS). A shared validate_csv_upload() now enforces an extension allowlist + a 10 MB cap before the read, across all three import entry points (import_csv / import_job_start / definitive-import promote). S11 — the no-JS [ffc_verification] shortcode fallback (VerificationHandler::verify_certificate) skipped the verification rate limit that the AJAX handlers apply, leaving an un-throttled auth-code probing path. It now calls RateLimiter::check_verification before any lookup, matching the AJAX paths. S8 — documented the nginx `deny` rule for the CSV-export temp dir (wp-content/uploads/ffc-tmp/, .htaccess is Apache-only) in a new docs/DEPLOYMENT.md + a readme.txt FAQ, and pointed the in-code comment at it. Tests: RateLimitRepositoryTest (non-IP identifier hashed on write + read, IP plaintext); RecruitmentClassificationsRestControllerTest (bad extension → 400, oversize → 413; realistic $_FILES fixture); VerificationHandlerAjaxTest (no-JS fallback is rate-limited). Could not run composer suites locally (proxy blocks composer's GitHub auth); php -l clean, CI runs the authoritative gates. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(#839): align $we assignment in increment_counter (WPCS) Inserting the S5 storage_identifier() call re-aligned the $t/$ws assignments to the longer $identifier LHS but left the trailing $we line at its old single-space alignment, tripping the WPCS "Equals sign not aligned" sniff. Whitespace-only. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#839): stop leaking an undefined $_POST across shard neighbours Coverage shard 3 failed on TabRateLimitTest with "Undefined global variable $_POST". Root cause: SelfSchedulingCleanupHandlerTest::tearDown did `unset( $_POST )`, which removes the superglobal entirely and leaves it *undefined* for the next test in the shard. TabRateLimitTest's render()-under-test reads `if ( $_POST … )` and errored. The failure only surfaced now because this PR adds test methods, and the test-count-weighted shard packer reshuffled which test lands after the cleanup test. Fix at the root: reset `$_POST = array()` instead of unsetting it, so no neighbour ever sees it undefined. Also self-initialize `$_POST` in TabRateLimitTest::setUp as belt-and-suspenders. Test-only. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * security(encryption): key/salt health warning + status panel (#846, #839 S7a) (#848) * security(encryption): key/salt health warning + status panel (#846, #839 S7a) Adds a non-blocking surface for unsafe encryption secret material — the case where the WordPress secret keys (or the FFC decoupling constants) that derive the encryption key + search-hash salt are missing, weak, or still the wp-config-sample placeholder, which silently makes the key predictable while is_configured() still reports "configured". Core: - Encryption::key_health() → ok|weak|placeholder|empty, evaluating the *active* secrets (honors FFC_ENCRYPTION_KEY / FFC_HASH_SALT decoupling, else the WP salts each half falls back to). - Encryption::key_health_report() → status + decoupled flags + human key/salt sources + a non-reversible key fingerprint (domain-separated HMAC slice) so an operator can confirm a rotation without seeing the key. - get_info() now carries key_health. is_configured() is deliberately UNCHANGED — flipping it to false on a placeholder would disable encryption and store PII in plaintext, which is worse. UI (3 layers, per the issue): - Global dismissible admin notice (EncryptionKeyHealthNotice) shown only when health != ok; dismissal is keyed to a status+fingerprint signature so a worsening state or a key rotation re-surfaces it. Points at the status panel. - Advanced-tab "Encryption Key Health" card (id=ffc-encryption-health, placed directly above the Danger Zone): status, sources, fingerprint, and a soft "decouple with FFC_ENCRYPTION_KEY" advisory when healthy but coupled. Guidance distinguishes a fresh install (generate salts) from a site with data (rotation caution — do not edit WP salts blindly; S7b). - Generic reusable dismiss JS (ffc-dismissible-notice.js on .ffc-js-dismiss-notice) driving the persistent dismissal. Absorbs the old S6 (search-hash reversal) — no hash-algorithm migration (the only mass-reversal scenario needs a weak salt, which this detects). Tests: EncryptionTest (secret classifier via reflection; report shape; get_info); EncryptionKeyHealthNoticeTest (gating, signature-based re-show, ajax dismiss); dismissible-notice.test.js (JS). Could not run composer suites locally (proxy blocks composer's GitHub auth); php -l clean, CI runs the authoritative gates. Note: the notice class still shares the init/dismiss/cap skeleton with DeviceThresholdUpgradeNotice; extracting a shared AbstractDismissibleNotice base is pending a scope decision. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#846): PHPStan redundant isset + WPCS Yoda/alignment on the key-health notice CI on the S7a PR flagged three lint issues (couldn't run composer locally — proxy blocks its GitHub auth): - PHPStan: two `isset()` guards on key_health_report() offsets that the declared array shape guarantees always exist → dropped, read directly. - WPCS Yoda: `$dismissed !== self::current_signature()` → put the non-variable operand on the left. - WPCS alignment: `$ffc_kh_label` was grouped with the earlier assignment block across the multi-line labels array on the Advanced view; a blank line breaks the group so its single-space `=` is correct. Verified the alignment fix with a standalone phpcs Generic.Formatting.MultipleStatementAlignment run (clean). Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#846): stub wp_kses() in TabAdvancedTest for the S7a card TabAdvancedTest::test_render_includes_real_view renders the real Advanced view, which now includes the Encryption Key Health card. With the unit bootstrap's short WP salts, key_health() is 'weak', so the card's warning branch runs and calls wp_kses() (explicit allowlist) — a function the test didn't stub, tripping "Call to undefined function wp_kses()". Added the stub alongside the existing wp_kses_post one. Test-only. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor(admin): AbstractDismissibleNotice base + wp_admin_notice; English updater string (#849) (#852) * refactor(admin): AbstractDismissibleNotice base + wp_admin_notice; en source string (#849) Follow-up after #839/#846 closed (agreed in-session). Notice de-duplication: - New AbstractDismissibleNotice base owns the identical plumbing the two persistent-dismiss admin notices copy-pasted: hook registration, the wrapper markup, the capability gate, the dismissed-state gate, the AJAX dismiss endpoint, and the shared dismiss-script enqueue. Subclasses now supply only their specifics (option key, action, should_show(), dismiss_signature(), notice_message(), type, extra class). - Migrated both DeviceThresholdUpgradeNotice and EncryptionKeyHealthNotice onto it. Behaviour + markup preserved (each keeps its stable ffc-*-notice class via extra_class, so existing tests' class assertions still hold). - Adopted WordPress's canonical wp_admin_notice() (WP 6.4+) for the wrapper instead of hand-building the <div> — core builds the notice/dismissible classes and escapes classes + attributes. Bumped `Requires at least` 6.2 -> 6.4 in the plugin header + readme (the target already runs 6.5+; the l10n catalog is 6.5-format). - Removed the now-dead ffc-device-threshold-notice.js (+ min): the device notice uses the shared ffc-dismissible-notice.js (.ffc-js-dismiss-notice). i18n: - The GitHub-updater SHA-256 mismatch error was hard-coded in Portuguese; the source string is now English so pt_BR flows through the catalog instead of overriding every locale. (pt_BR entry regenerated via Loco.) Tests: added init()/ajax-forbidden coverage for the shared base through the encryption subclass; stubbed wp_admin_notice() in both notice tests; removed the dead device JS test. JS suite 1637/1637, coverage 99.11%. Could not run composer suites locally (proxy blocks composer's GitHub auth); php -l clean, WPCS alignment verified via standalone phpcs, CI runs the authoritative gates. Follow-ups tracked: #850 (wp_admin_notice sweep of inline notice markup), #851 (suggest a random FFC_ENCRYPTION_KEY on the health card). Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#849): WPCS missing docblocks + PHPStan redundant array_values CI on the notice-abstraction PR: - WPCS: the concrete abstract-method implementations in the two notice subclasses lacked doc comments (option_key/action/notice_type/ extra_class/notice_message) — added one-line docblocks. - PHPStan: array_values() in the base's maybe_render() was a no-op (the additional_classes array is already a list — index 0 is always the 'ffc-js-dismiss-notice' constant), so it's dropped. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor(admin): adopt wp_admin_notice() across inline notice markup (#850) (#853) * refactor(admin): adopt wp_admin_notice() across inline notice markup (#850) Sweep the plugin's ~50 hand-built `<div class="notice …">` admin-notice snippets onto WordPress's canonical `wp_admin_notice()` (WP 6.4+, established by #849), so type / dismissibility / additional-class markup is core-escaped and consistent instead of copy-pasted per call site. Covers settings tabs + views (incl. the S7a Encryption Key Health card and the 11-notice migrations view), the admin pages (settings, role editor, submission-edit, activity-log, form-editor save errors, bulk-move notice), recruitment edit pages + admin renderer, self-scheduling admin + appointments list, and the recruitment welcome / url-shortener templates. Dynamic-type and rich-body notices (with <strong>/<ul>/links) use the `paragraph_wrap => false` + `ob_start()`/`ob_get_clean()` capture form so the readable interspersed markup stays intact. Behaviour-preserving. Tests: stub `wp_admin_notice()` (a faithful marker echoing the canonical `notice notice-{type}` markup) in every test that renders a migrated path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(admin): satisfy PHPStan + WPCS on the wp_admin_notice sweep (#850) - PHPStan: cast `(string) ob_get_clean()` at every `wp_admin_notice()` call site (ob_get_clean returns string|false; the param expects string) — activity-log-page, submission-edit, form-editor save errors, and the advanced/general/migrations views + recruitment-welcome template. - WPCS ControlSignature: split `ob_start()` off the alternative-syntax branch line (`if (…) : ob_start(); ?>` → `if (…) : ?>` + its own `<?php ob_start(); ?>`) in the advanced + general views. - WPCS I18n: expand the QR-cache notice to the multi-line sprintf form so the `translators:` comment sits directly above the `__()` call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(settings): FFC_ENCRYPTION_KEY suggestion on the Encryption Key Health card (#851) (#856) On Settings → Advanced, whenever FFC's encryption key still derives from the shared WordPress salts (encryption_decoupled === false — any status while not decoupled: ok / weak / placeholder / empty), the Encryption Key Health card now offers a ready-to-paste `define( 'FFC_ENCRYPTION_KEY', '…' )` snippet in a read-only field, with "Generate another" and Copy buttons. - The 64-char key is generated client-side (crypto.getRandomValues) over a charset safe for a single-quoted PHP literal (excludes ' " \ $) and is never saved or sent — the plugin never writes wp-config.php (the S7a decision). - Mandatory rotation caveat: apply directly only on a new install; a site with data whose salts are weak/placeholder needs a planned rotation (S7b). - Hidden once already decoupled (nothing to suggest). - New vanilla-JS `ffc-encryption-key-suggest.js` (pure generator exposed on window for testing) enqueued only on the Advanced tab + a Vitest suite (length, safe charset, regenerate changes value, copy feedback). Builds on #846 / #839 S7a. Behaviour-preserving; purely additive UX. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * chore(deps)(deps-dev): bump phpstan/phpstan from 2.2.6 to 2.2.7 (#854) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.6 to 2.2.7. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.7 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alex Meusburger <rpgmem@gmail.com> * chore(deps)(deps-dev): bump jsdom from 29.1.1 to 30.0.1 (#855) Bumps [jsdom](https://github.com/jsdom/jsdom) from 29.1.1 to 30.0.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alex Meusburger <rpgmem@gmail.com> * security(encryption): key rotation migration (S7b) + WP-derived decrypt fallback (#857) (#859) * security(encryption): key rotation migration (S7b) + WP-derived decrypt fallback (#857, #840) Adds the S7b encryption-key rotation feature (split from #840 Part A): - Encryption::decrypt() gains a WP-derived-key fallback so rows encrypted under the WordPress salts stay readable the moment a strong FFC_ENCRYPTION_KEY is defined (zero downtime), until re-encrypted. Key derivation is refactored behind is_decoupled() + wp_derived_* helpers; a parameterized try_decrypt()/encrypt_with_keys() seam backs both the active key and the fallback. New public key_fingerprint(). - New KeyRotationMigrationStrategy (Architecture A): re-encrypts submissions + appointments PII under the active key and rebuilds the CPF/RF/email search hashes under the active salt. Cursor-based progress, keyed on the non-reversible key fingerprint so changing the key re-arms the migration instead of silently reporting complete; a row it cannot decrypt is reported as an error and left untouched (never orphaned). - Registered in MigrationRegistry + MigrationStatusCalculator; the Settings -> Migrations card auto-renders. Execution is gated by the existing ffc_manage_settings_dangerzone AJAX/no-JS endpoints; can_run blocks until FFC_ENCRYPTION_KEY is decoupled. Tests: Encryption fallback/mechanism/fingerprint (3), KeyRotation strategy (10), registry/calculator updates. CHANGELOG Security entry. No assets changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(s7b): satisfy WPCS translators comment + PHPStan constant guard - Move the translators comment directly above the __() call inside the process_table sprintf so WPCS.WP.I18n accepts it (the __() was on its own wrapped line). - Read FFC_ENCRYPTION_KEY via constant() at the three usages now guarded by is_decoupled() instead of an inline defined() — PHPStan's constant.notFound only recognises a same-expression defined() guard. Behaviour-identical at runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(api): authenticated operator REST API — issuance + PDF (#858) (#860) * feat(api): authenticated operator REST API — issuance + PDF (#858, #840 Part B) Adds two ffc/v1 routes for a field-operator app authenticating via WordPress Application Passwords, in a new OperatorCertificatesRestController: - A1 POST /operator/certificates — issues a certificate, gated by ffc_manage_certificates (admins via manage_options). Reuses the same SubmissionHandler + field/CPF/email validation as the public form, but deliberately omits the anti-anonymous defenses (captcha/geofence/per-IP+ email+CPF rate-limit) — inappropriate for an authenticated, cap-gated caller — applying a generous per-user_id rate-limit as a runaway guard plus an audit-log entry per issuance. - A2 GET /operator/certificates/{id}/pdf — returns the certificate's pdf_data (the same client-rendered payload the web path uses; the plugin has no server-side PDF binary renderer), gated by the management capability OR ownership of that certificate. Additive: the public POST /forms/{id}/submit and the admin-ajax handlers are untouched. A5 (migrating specific admin-ajax ops to REST) is deferred until a concrete app driver exists. Wired in RestController::register_routes(). 14 unit tests cover route registration, both permission gates, the issuance happy path + validation/ rate-limit/not-found error paths, and PDF cap/ownership/forbidden/not-found. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(api): WPCS const alignment + drop redundant is_array (#858) - Align the RATE_* const equals signs (WPCS MultipleStatementAlignment). - Drop the redundant `! is_array( $params )` after `empty( $params )` — get_json_params() is typed array, so PHPStan flags it as always-true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * security(user-deletion): attribution + promoted-candidate coverage (#834) (#861) Follow-up to #822. Extends the deleted_user hook (UserCleanup) with an authorship sweep that SET-NULLs, on every deletion: - the nullable actor-attribution columns — submissions.edited_by; appointments.approved_by/cancelled_by; self_scheduling_calendars .created_by/updated_by; blocked_dates.created_by; audience_bookings .cancelled_by; reregistration_submissions.reviewed_by; recruitment_call .cancelled_by; short_urls.created_by (cluster 1); and - recruitment_candidate.user_id, the promoted-candidate link (cluster 2). The sweep runs outside the #322 data-subject footprint short-circuit because a pure actor (admin/operator) or promoted candidate need not have their own footprint. Identifiers go through %i (no interpolation); each UPDATE is guarded by table_exists + column_exists. The six NOT NULL created_by columns stay accepted orphans (a plain SET NULL would be rejected). App-layer only — no FK backstop for attribution columns. Cluster 3 (row-body PII): documented as a decided policy, not a code change — routine account deletion anonymises the link only; scrubbing the encrypted *_encrypted/*_hash body stays the manual PrivacyErasers (LGPD erasure) path, so certificates/appointments remain verifiable until a subject actually requests erasure. CLAUDE.md §4 rewritten to reflect the new coverage; 3 new UserCleanupTest cases; CHANGELOG entry. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * feat(advanced): FFC_HASH_SALT suggestion + self-documenting snippets + rotation gating (#862) * feat(advanced): FFC_HASH_SALT suggestion + self-documenting snippets + rotation gating Extends the Encryption Key Health panel (Settings → Advanced) and the S7b key-rotation flow, all client-side / no secret ever exposed: - Add a FFC_HASH_SALT suggestion field alongside FFC_ENCRYPTION_KEY. The generator JS is generalised to any number of widgets (data-ffc-const), each shown only until that constant is decoupled. - Each copyable snippet now carries a self-identifying phpdoc comment (constant, purpose, FFC version) mirroring the commented WordPress salt block; the field becomes a textarea to hold the multi-line block. Comment bodies are localised (i18n, FFC_VERSION embedded) via wp_localize_script. - Encryption Key Rotation now requires BOTH FFC_ENCRYPTION_KEY and FFC_HASH_SALT: the migration card (Settings → Migrations) stays hidden and can_run refuses until both are defined — rotating with only the key set would rebuild the search hashes under the still-shared WordPress salt, forcing a second rotation when the salt is later decoupled. - Close the S7b UX gap: once both constants are defined (the generator block hides exactly then), the panel surfaces a pending-record count linking to the rotation card, so the adopt→rotate loop is discoverable. Tests: JS comment/multi-widget coverage (14 suggest tests); strategy can_run now asserts both constant names. Full JS suite 1651 green, 99.03% lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * docs(advanced): correct FFC_ENCRYPTION_KEY snippet comment for the one-way constraint The generated snippet's comment said changing the key 'requires a key rotation' — true only for the first WP→FFC adoption. Rotating one FFC key to another is not supported by the current single-WP-derived fallback (tracked as a follow-up), so the comment now states the accurate contract: once records are stored under the key, treat the value as permanent — changing/removing it makes them unreadable until it is restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * i18n(advanced): keep the wp-config snippet comment English on every locale The generated FFC_ENCRYPTION_KEY / FFC_HASH_SALT snippet comment lands inside wp-config.php (a code file), so it must stay English regardless of the admin's locale — going through __() would emit a translated (e.g. pt_BR) comment into code. Drop the i18n wrappers on the two comment bodies; the copy-feedback UI strings stay localized. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * Language Update * security(encryption): FFC→FFC re-keying via previous-key fallback (#863) (#864) * security(encryption): FFC→FFC re-keying via previous-key fallback (#863) Enables rotating one FFC_ENCRYPTION_KEY to another without downtime — the missing direction after #857 (which only bridged WordPress-derived → FFC). Core (Encryption): - decrypt_internal() gains a previous-key candidate: chain is now active → previous-FFC → WP-derived. When FFC_ENCRYPTION_KEY_PREVIOUS (≥32 chars) is defined, rows still under the prior FFC key stay readable while new writes use the active key; the WP→FFC fallback is unchanged, so both transitions can be in flight at once. - has_previous_key()/previous_encryption_key()/previous_hmac_key() mirror the decoupled active-key derivation; key_fingerprint() refactored onto a shared fingerprint_of(); key_health_report() adds rekey_in_progress + previous_fingerprint. - The salt needs no previous — search hashes are rebuilt from plaintext. Panel (Settings → Advanced): - Previous-key fingerprint row during a re-key. - Rotation notice is re-key-aware: in-progress (run rotation, then remove _PREVIOUS, don't change keys until 100%) vs complete (remove _PREVIOUS). - "Rotate to a new key" helper (decoupled, not re-keying): generates the new key + the step-by-step wp-config edits, reusing the #862 generator. Rotation strategy unchanged — process_table already decrypts via the full chain, so it re-encrypts previous-key rows under the active key automatically. Tests: separate-process re-key decrypt roundtrip + health-report assertions; default no-rekey report. EncryptionTest 35 green; rotation/status/notice suites unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * docs(tab): document Encryption Key Health, decoupling & re-key on the Advanced page (#863) The Documentation → Configuration → Advanced page covered the activity log, editor prefs, debug toggles, download limit and Danger Zone but nothing about the Encryption Key Health card, the FFC_ENCRYPTION_KEY / FFC_HASH_SALT decoupling constants, the Encryption Key Rotation migration, or the new FFC→FFC re-key flow. Adds a focused section: what the panel reports, decoupling as optional hardening, the one-way caveat, and the 4-step re-key procedure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * docs(tab): fill documentation gaps for operator API, privacy/LGPD, modules, activity-log, updates & url-shortener export Cross-checked the in-plugin Documentation tab against shipped features and filled the gaps: - Developer page: document the authenticated operator REST API — POST /ffc/v1/operator/certificates + GET .../{id}/pdf, Application-Password auth, ffc_manage_certificates gate, per-operator rate-limit rationale (#858). - New "Privacy & LGPD" page: PII at rest (AES-256-CBC + HMAC, salted hash, masked display), WordPress export/erase integration (6 exporters + 1 eraser), account-deletion anonymization vs. erasure, suggested policy text. - New "Modules" page: per-module enable/disable, default-on, disable hides screens + stops runtime/crons but never deletes data (#800). - New "Activity Log" page: the viewer tab (filters/stats/CSV export) split from the logging config, gated by ffc_view/export_activity_log (#802). - New "Updates" page: GitHub-Releases auto-update — notify + native opt-in, SHA-256 verify, 12h ETag cache, no token (#820). - Short URLs page: add the missing ffc_export_url_shortener capability and the CSV export (#711/#761). Registry gets four new nodes; no product code touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * docs(tab): fix WPCS PrecisionAlignment — collapse operator-API curl to one line The multi-line curl example indented its continuation lines with 2 spaces, tripping Universal.WhiteSpace.PrecisionAlignment three times. Collapse to a single line, matching the existing single-line curl example in the same file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(templates): certificate-template pool — DB-backed models (#865, phase 1) (#867) * feat(templates): certificate-template pool data layer — CPT + seeder + reader (#865) First step of the #865 template-pool feature: a database-backed pool of reusable certificate templates, replacing the fragile "drop a .html in the plugin's html/ folder" extension point (wiped on every update). This commit is the data layer only — fully additive and behaviour-preserving (the editor still uses the legacy html/ glob; the rewire lands next): - CertTemplateCpt: registers the `ffc_cert_template` CPT, gated by the FFC `ffc_manage_forms` / `ffc_view_forms` caps via capability_type + map_meta_cap, mirroring `ffc_form`'s #739 decoupling. `show_ui` is false for now (the management submenu is a follow-up phase); per-post writes route through CptCapPolicy (new `ffc_cert_template => ffc_manage_forms` mapping). - CertTemplateSeeder: non-destructive, versioned seed of the three shipped defaults. Canonical HTML source now lives in templates/certificate-defaults/ (copied from html/); seeds only missing default slugs, so it doubles as the future "Restore defaults" without clobbering user edits. - CertTemplateReader: read side for the editor "Load" flow — lists visible templates (defaults first) and resolves a template's HTML by id. - Wired in Loader under the certificates module; seeding on admin_init. Storage contract (meta): _ffc_template_html, _ffc_is_default, _ffc_default_slug, _ffc_template_visible. All in the Admin module — no new module-boundary edge. Tests: CertTemplateCptTest, CertTemplateReaderTest, CertTemplateSeederTest, and a CptCapPolicyTest case for the new mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(templates): drop two PHPStan-redundant guards in the pool data layer (#865) CI PHPStan (level 8) flagged two always-true/false checks whose types the WP stubs already guarantee: - CertTemplateReader::list_for_editor — get_posts() (no `fields`) is typed WP_Post[], so the per-item `instanceof WP_Post` is always true; removed it. - CertTemplateSeeder::seed — wp_insert_post( …, true ) returns int<1,max>|WP_Error, so `$id <= 0` is always false; is_int() alone rejects the error case. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(templates): rewire the editor "Load" flow to the DB template pool (#865) (#868) * feat(templates): rewire the editor "Load" flow to the DB template pool (#865) Second step of the #865 template pool (follows the merged data layer): the form-editor "Load" control now reads the ffc_cert_template pool instead of globbing html/, resolving template HTML server-side by post id. - FormEditorLayoutMetabox::render — the layout <select> is populated from CertTemplateReader::list_for_editor(), grouped defaults / user templates, each <option> valued by post id (was: html/ glob keyed by filename). - AdminAssetsManager::discover_layout_templates — returns the reader list ([id,label,is_default,file]) for the "Load" modal; falls back to the legacy html/ glob (id 0 + file basename) when the pool is empty. - FormEditor::ajax_load_template — accepts template_id (int) and returns CertTemplateReader::get_html($id) under the existing ffc_admin_pdf_nonce + current_user_can_admin_or('ffc_manage_forms') gates; keeps the filename path as a deprecated fallback. - ffc-admin-pdf.js — loadTemplateFile now POSTs ffc_load_template with template_id (or the legacy filename when id 0), replacing the unauthenticated direct fetch() of /wp-content/plugins/.../html/; modal options carry data-id. Re-minified. The legacy html/ glob is retained as a documented deprecated fallback, to be removed once the pool is guaranteed seeded on every install. Tests: FormEditorTest (load by id: success + not-in-pool error), AdminAssetsManagerTest (localized catalog keyed by id), FormEditorLayoutMetaboxTest (grouped pool select), admin-pdf.test.js (ajax path + id-based modal + legacy filename fallback). CHANGELOG under [Unreleased] → Changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * test(templates): stub get_posts in FormEditorMetaboxRendererTest (#865) The layout metabox's "Load" dropdown now lists the DB template pool via CertTemplateReader::list_for_editor() → get_posts(); FormEditorMetaboxRendererTest renders that metabox through render_box_layout / render_tabbed_container but did not mock get_posts, so Brain\Monkey erred with "get_posts is not defined nor mocked" (cascading to the coverage shards). Stub it to an empty pool, matching FormEditorLayoutMetaboxTest / AdminAssetsManagerTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(templates): certificate-template pool management screen (#865, PR B) (#869) * feat(templates): pool management screen — list columns + visibility toggle (#865) PR B of #865 (management UI), first slice. Surfaces the ffc_cert_template pool in wp-admin and adds the write side the authoring flows need. - CertTemplateCpt: flip show_ui => true and show_in_menu => 'edit.php?post_type=ffc_form', so the pool appears as a "Templates" submenu under the Certificate menu (native list table + edit screen), still gated by the ffc_view_forms / ffc_manage_forms caps. - CertTemplateWriter (new): write side for the pool — create() a user template (html + visible + is_default=0), set_visibility() and update_html(), each fenced to the ffc_cert_template post type. Auth stays the caller's job. - CertTemplateAdminScreen (new): list-table Type (Default/Custom) + Visible columns and a nonce-protected Show/Hide row action that flips META_VISIBLE via the writer (manage cap only; read-only for viewers). Shipped defaults have their Trash/Delete row actions removed (the seeder re-creates them). - Loader: construct CertTemplateAdminScreen under is_admin() alongside the CPT. Tests: CertTemplateWriterTest, CertTemplateAdminScreenTest, and the updated CertTemplateCptTest (show_ui true + submenu). CHANGELOG under [Unreleased] → Added. Follow-ups (same #865 PR B): the HTML-editing metabox on the CPT edit screen and the editor's "Save as model" button + ffc_save_template AJAX. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(templates): satisfy WPCS + PHPStan on the pool admin screen (#865) CI on the foundation slice flagged three issues in CertTemplateAdminScreen that the sandbox (no vendor/) couldn't catch locally: - WPCS: @param docblock alignment for row_actions (\WP_Post over-padded by one). - WPCS: $label assignment not aligned with the surrounding $visible/$url block. - PHPStan (level 8): `$post instanceof \WP_Post` is always true — the post_row_actions filter types $post as WP_Post — so the redundant guard is dropped, keeping only the post-type fence. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * feat(templates): edit-screen HTML metabox + editor "Save as model" (#865) PR B follow-ups completing the #865 management UI. - CertTemplateAdminScreen: HTML-body + visibility metabox on the ffc_cert_template edit screen (add_meta_boxes_/save_post_ hooks). The CPT only supports 'title', so the template body needs its own field; the save handler guards autosave, verifies a nonce, checks ffc_manage_forms, and sanitizes the body through the HtmlPolicy allowlist (same as the form-layout save) before CertTemplateWriter::update_html + set_visibility. - FormEditor::ajax_save_template (new ffc_save_template AJAX): stores the current layout HTML as a new pool template via CertTemplateWriter::create, under the existing ffc_admin_pdf_nonce + ffc_manage_forms gate, HtmlPolicy-sanitized. - FormEditorLayoutMetabox: "Save as model" button in the Actions row. - ffc-admin-pdf.js: button handler prompts for a name, flushes CodeMirror, and POSTs ffc_save_template; two new localized strings (saveModelPrompt, templateSaved). Re-minified. Tests: FormEditorTest (ajax_save_template success/denied/empty-title), CertTemplateAdminScreenTest (add/render/save metabox paths), admin-pdf.test.js (save-as-model POST + cancel), FormEditorLayoutMetaboxTest (button present). CHANGELOG entry extended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(templates): relocate default certificate images to assets/ (#865) (#871) #865 crit #7: the shipped background + signature images the default certificate templates use move from the update-fragile html/ folder to the versioned assets/img/certificate-defaults/ (survives updates; a step toward retiring html/). The seed templates (templates/certificate-defaults/*.html) now reference them under assets/. Non-breaking: the images are COPIED, not moved — the html/ originals stay in place through the deprecation window, so existing installs whose seeded/user templates still reference html/*.png keep working until the Phase 3 migration. The instance-specific logos (Logo_Cidade_SP, Logo_DRE — already .distignore'd from the release zip) are intentionally NOT relocated; they become the configurable {{logo_gov}}/{{logo_org}} branding tokens next (Phase 2). Adds a seeder guard test asserting the seed HTML references assets/ and never plugins/ffcertificate/html/. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * fix(templates): enforce read-only default templates server-side (#865) (#870) * fix(templates): enforce read-only default templates server-side (#865) Closes a deviation from #865 decision #11: the #869 edit metabox let an operator edit/rename/delete a shipped default template, but defaults must be read-only — customizing one means Duplicate → an editable user template. - save_edit_metabox: never rewrite a default's HTML (skips update_html; the textarea is also rendered `readonly` with an explanatory notice). Visibility stays togglable for defaults (a default can be hidden, not edited). - protect_default_caps (map_meta_cap, priority 20 so it runs after CptCapPolicy's map at 10): returns do_not_allow for delete_post on a default — a real server-side block, not just hiding the Trash/Delete row action. - preserve_default_title (wp_insert_post_data): restores a default's shipped title on save, blocking rename. Tests cover all three guards plus the readonly render. User templates are unaffected (edit/rename/delete as before). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(templates): avoid casting get_post_field to string in preserve_default_title (#865) PHPStan level 8: get_post_field() is typed array<int>|int|string, so the blind (string) cast could stringify an array. Guard with is_string() instead — functionally identical for post_title (always a string), PHPStan-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * test(templates): stub get_post_meta in the pre-existing metabox-save test (#865) The #870 read-only guard added a CertTemplateReader::is_default() call (→ get_post_meta) at the top of save_edit_metabox, so the pre-existing test_save_edit_metabox_persists_html_and_visibility (from #869) now needs get_post_meta mocked. Return '' (a non-default template) so the HTML update still runs and the assertion holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(templates): branding logo tokens {{logo_gov}}/{{logo_org}} (#865 Phase 2) (#872) * feat(templates): branding logo tokens {{logo_gov}}/{{logo_org}} (#865 Phase 2) De-hardcodes the instance-specific logos from the default ficha/receipt templates behind two configurable, site-level branding tokens. - BrandingTokens (new Core helper): resolves {{logo_gov}}/{{logo_org}} to the Settings→General URL, or a generic shipped placeholder (assets/img/certificate-defaults/logo-placeholder.svg) when unset. One resolver + fallback shared by both PDF renderers. - Renderer injection: PdfHtmlRenderer::generate_html (covers certificates AND appointment receipts, which route through it) and the reregistration FichaGenerator both inject logo_gov/logo_org as bare URLs into their token pass (author owns the <img>). - Settings→General "Branding": two logo URL fields with a Media Library picker (new generic data-ffc-media-target handler ffc-branding-media.js, enqueued on the settings page; esc_url_raw on save). - Default html/default_ficha_template.html + html/default_appointment_receipt_1.html now use {{logo_gov}}/{{logo_org}} instead of the .distignore'd SP-specific Logo_*.png. Documented in the token reference. Tests: BrandingTokensTest (configured/fallback/blank), SettingsSaveHandlerTest (esc_url_raw), branding-media.test.js (picker + clear). CHANGELOG under Added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 * fix(templates): CI — module boundary + test stubs for branding tokens (#865) Four CI failures from the Phase 2 branding-tokens PR: - ModuleBoundaryTest: the BrandingTokens (Core) docblock referenced \FreeFormCertificate\Generators\PdfHtmlRenderer, which the boundary scan reads as a new Core→Generators edge. Reworded the docblock to drop the FQCN (the only real cross-module ref is Core→Settings, already in the baseline). - FichaGeneratorTest (2 tests): FichaGenerator::generate now resolves the logo tokens via BrandingTokens → SettingsReader → get_option(); stub get_option in setUp (returns configured logos, so no FFC_PLUGIN_URL fallback needed). - SettingsSaveHandlerTest: the new branding-logo test is the first to exercise esc_url_raw in the general save path; stub it in setUp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor(templates): relocate ficha & receipt defaults out of html/ (#865) (#873) Moves the two document templates out of the update-fragile html/ folder to the versioned templates/documents/, continuing to empty html/ of shipped assets ahead of retiring it (only the legacy html/default_certificate_* glob fallback remains there now). - git mv html/default_ficha_template.html + html/default_appointment_receipt_1.html → templates/documents/. - FichaGenerator::load_template and PdfHtmlRenderer::get_appointment_receipt_template now read from templates/documents/. Both are read fresh from FFC_PLUGIN_DIR at render time (no DB-saved path), so the file+code move is atomic and safe. - feature-ficha.php docs path + the .distignore comment updated. Test: FichaGeneratorTest guards the relocation (files exist at the new path, gone from html/). CHANGELOG under Changed. Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3 Co-authored-by: Claude <noreply@anthropic.com> * feat(migrations): import legacy html/ certificate templates into the pool (#865) (#875) Adds an "Import Legacy Certificate Templates" migration card (Settings → Migrations) that lifts certificate layouts a site left in the plugin's html/ drop-folder into the ffc_cert_template pool as user templates, advancing the retirement of the update-fragile html/ folder. - ImportLegacyTemplatesMigrationStrategy: scans html/*.html for basenames containing "certificate" (the #443 convention), excludes the three shipped defaults (already seeded from templates/certificate-defaults/), and imports each via CertTemplateWriter::create(). Non-destructive and idempotent — every handled basename is recorded in the ffc_imported_legacy_templates option, and a file that fails to import is still marked handled so the batch runner terminates on is_complete. Legacy dir is constructor-injectable for tests. - Registry entry + status-calculator switch case wire the card. Auth is inherited from MigrationActionsAjaxEndpoint (ffc_manage_settings_dangerzone). - Adds the Migrations>Admin edge to the module-boundary baseline (a migration touching the Admin-owned pool, mirroring Migrations>Reregistration). - Tests co…
Closed
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
html/#865 decision chore(deps)(deps-dev): bump yoast/phpunit-polyfills from 2.0.5 to 4.0.0 #11 introduced by the feat(templates): pool management screen — list columns + visibility toggle (#865) #869 management screen: the edit metabox let an operator edit / rename / delete a shipped default template. Defaults must be read-only — customizing one means Duplicate → editable user template.save_edit_metaboxnever rewrites a default's body (the textarea is alsoreadonlywith a notice). Visibility stays togglable (a default can be hidden, not edited).protect_default_capsonmap_meta_cap(priority 20, afterCptCapPolicy's map at 10) returnsdo_not_allowfordelete_poston a default. The Trash/Delete row actions were already hidden; this makes it real.preserve_default_titleonwp_insert_post_datarestores a default's shipped title on save.Type of change
Test plan
CertTemplateAdminScreenTest:protect_default_caps(deny default / allow user / ignore non-delete),preserve_default_title(restore default / leave user),save_edit_metaboxskips a default's HTML but still applies visibility, and the readonly render.php -lclean; array alignment checked;readonlyemitted viawp_readonly()(avoids the WPCS EscapeOutput false-positive on a literal ternary).composer test/composer lint— validated by CI (no localvendor/).Checklist
CHANGELOG.mdentry — this corrects behavior described by the still-[Unreleased]Modelos & assets de certificado: pool no banco + imagens na Media Library + tokens de branding; aposentar o depósitohtml/#865 management-screen bullet within the same release window (folded into that entry rather than adding a redundant line). Happy to add a dedicated bullet if preferred.Admin).Part of #865 (decision #11).
🤖 Generated with Claude Code
https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3
Generated by Claude Code