Skip to content

chore(deps)(deps-dev): bump yoast/phpunit-polyfills from 2.0.5 to 4.0.0 - #11

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/composer/yoast/phpunit-polyfills-4.0.0
Closed

chore(deps)(deps-dev): bump yoast/phpunit-polyfills from 2.0.5 to 4.0.0#11
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/composer/yoast/phpunit-polyfills-4.0.0

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Apr 14, 2026

Copy link
Copy Markdown
Contributor

Bumps yoast/phpunit-polyfills from 2.0.5 to 4.0.0.

Release notes

Sourced from yoast/phpunit-polyfills's releases.

4.0.0 - 2025-02-09

PHPUnit 12 support

This release updates the PHPUnit Polyfills to allow for "writing your tests for PHPUnit 12 and running them all the way back to PHPUnit 7". [*]

Please keep in mind that the PHPUnit Polyfills provide forward-compatibility. This means that features which PHPUnit no longer supports in PHPUnit 12.x, are also no longer supported in the 4.0 release of the PHPUnit Polyfills.

Please refer to the PHPUnit 12 release notification and PHPUnit 12 changelog to inform your decision on whether or not to upgrade (yet).

Projects which don't use any of the new or removed functionality in their test suite, can, of course, use the PHPUnit Polyfills 1.x, 2.x, 3.x and 4.x series side-by-side, like so composer require --dev yoast/phpunit-polyfills:"^1.0 || ^2.0 || ^3.0 || ^4.0".

[*]: Note: Releases from the PHPUnit Polyfills 4.x branch will support running tests on PHPUnit 7.5.0 - 9.x, 11.x and 12.x, but will not allow for running tests on PHPUnit 10 (for reasons explained in #200). In practical terms, the net effect of this is that tests on PHP 8.1 will run on PHPUnit 9 instead of PHPUnit 10. Other than that, there is no impact.

Changelog

Changed

  • Composer: allow for installation of PHPUnit 12.x. PR #247
  • Scalar parameter type declarations and return type declarations are now used where possible. PR #241
  • General housekeeping.

Removed

  • Support for PHP < 7.1. PR #238.
  • Support for PHPUnit < 7.5.0. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertIsType trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertEqualsSpecializations trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.

3.1.2 - 2025-02-09

This is a maintenance release.

Changed

  • README: updated sub-section about PHPUnit version support with information about the PHPUnit Polyfills 4.x branch. PR #249
  • General housekeeping.

3.1.1 - 2025-01-12

Fixed

  • AssertContainsOnly::assertContainsNotOnlyInstancesOf(): incorrect parameter name. PR #235

Changed

  • General housekeeping.

3.1.0 - 2025-01-08

Added

  • Yoast\PHPUnitPolyfills\Polyfills\AssertContainsOnly trait to polyfill the specialized Assert::assertContains[Not]Only*() methods as introduced in PHPUnit 11.5.0. PR #225.

Changed

  • README: fix links which were broken due to an upstream branch removal. PR #213.
  • README: fixed a few broken badges.

... (truncated)

Changelog

Sourced from yoast/phpunit-polyfills's changelog.

[4.0.0] - 2025-02-09

PHPUnit 12 support

This release updates the PHPUnit Polyfills to allow for "writing your tests for PHPUnit 12 and running them all the way back to PHPUnit 7". [*]

Please keep in mind that the PHPUnit Polyfills provide forward-compatibility. This means that features which PHPUnit no longer supports in PHPUnit 12.x, are also no longer supported in the 4.0 release of the PHPUnit Polyfills.

Please refer to the PHPUnit 12 release notification and PHPUnit 12 changelog to inform your decision on whether or not to upgrade (yet).

Projects which don't use any of the new or removed functionality in their test suite, can, of course, use the PHPUnit Polyfills 1.x, 2.x, 3.x and 4.x series side-by-side, like so composer require --dev yoast/phpunit-polyfills:"^1.0 || ^2.0 || ^3.0 || ^4.0".

[*]: Note: Releases from the PHPUnit Polyfills 4.x branch will support running tests on PHPUnit 7.5.0 - 9.x, 11.x and 12.x, but will not allow for running tests on PHPUnit 10 (for reasons explained in #200). In practical terms, the net effect of this is that tests on PHP 8.1 will run on PHPUnit 9 instead of PHPUnit 10. Other than that, there is no impact.

Changelog

Changed

  • Composer: allow for installation of PHPUnit 12.x. PR #247
  • Scalar parameter type declarations and return type declarations are now used where possible. PR #241
  • General housekeeping.

Removed

  • Support for PHP < 7.1. PR #238.
  • Support for PHPUnit < 7.5.0. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertIsType trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.
  • The Yoast\PHPUnitPolyfills\Polyfills\AssertEqualsSpecializations trait which is no longer needed now support for PHPUnit < 7.5 has been dropped. PR #239.

#238: Yoast/PHPUnit-Polyfills#238 #239: Yoast/PHPUnit-Polyfills#239 #241: Yoast/PHPUnit-Polyfills#241 #247: Yoast/PHPUnit-Polyfills#247

[3.1.2] - 2025-02-09

This is a maintenance release.

Changed

  • README: updated sub-section about PHPUnit version support with information about the PHPUnit Polyfills 4.x branch. PR #249
  • General housekeeping.

... (truncated)

Commits
  • 134921b Merge pull request #254 from Yoast/feature/4.x/changelog-v4.0.0
  • 467709f Changelog for the 4.0.0 release and other documentation updates
  • b7d234f Merge branch '3.x' into 4.x
  • 9cf2ccd Merge pull request #253 from Yoast/feature/3.x/changelog-v3.1.2
  • 59bd807 Changelog for the 3.1.2 release
  • 083d158 Merge branch '3.x' into 4.x
  • 615cd5b Merge branch '2.x' into 3.x
  • 93c9b2d Merge branch '3.x' into 4.x
  • 47d6fb2 Merge branch '2.x' into 3.x
  • d2874f0 Changelog for the 1.1.4 release
  • Additional commits viewable in compare view

@dependabot @github

dependabot Bot commented on behalf of github Apr 14, 2026

Copy link
Copy Markdown
Contributor Author

Labels

The following labels could not be found: composer, dependencies. Please create them before Dependabot can add them to a pull request.

Please fix the above issues or remove invalid values from dependabot.yml.

rpgmem commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Review manual (Sprint 0 follow-up).

Não mergeado agora. Esta atualização cruza um limite estratégico que precisa de decisão do maintainer.

Análise

O bump de yoast/phpunit-polyfills 2.0.5 → 4.0.0 tem um efeito colateral positivo: ao recomputar o lockfile, faz downgrade de doctrine/instantiator 2.1.0 → 1.5.0, o que corrige um bug pré-existente em main — a versão 2.1.0 exige PHP ^8.4 e estava quebrando composer install nos jobs de PHP 7.4/8.1/8.3. Por isso os jobs em 8.1/8.3/8.4 passam aqui.

Porém, PHP 7.4 segue vermelho, falhando durante a execução dos testes (não no install). Isso representa uma quebra efetiva do suporte à versão mínima declarada do plugin (Requires PHP: 7.4 em ffcertificate.php e readme.txt).

Caminhos

  1. Manter PHP 7.4: investigar a falha de testes, corrigir antes de mergear (pode ser polyfill v4 incompatível com algum teste específico, dep transitiva, ou PHPUnit 9.6 em 7.4).
  2. Subir mínimo para PHP 8.0 ou 8.1 (PHP 7.4 EOL desde nov/2022): atualizar Requires PHP: em ffcertificate.php, readme.txt, composer.json; remover 7.4 da matriz ci.yml; bump de versão major; mergear sem mais fixes.

Assim que houver decisão, retomo.


Generated by Claude Code

Bumps [yoast/phpunit-polyfills](https://github.com/Yoast/PHPUnit-Polyfills) from 2.0.5 to 4.0.0.
- [Release notes](https://github.com/Yoast/PHPUnit-Polyfills/releases)
- [Changelog](https://github.com/Yoast/PHPUnit-Polyfills/blob/4.x/CHANGELOG.md)
- [Commits](Yoast/PHPUnit-Polyfills@2.0.5...4.0.0)

---
updated-dependencies:
- dependency-name: yoast/phpunit-polyfills
  dependency-version: 4.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot
dependabot Bot force-pushed the dependabot/composer/yoast/phpunit-polyfills-4.0.0 branch from 708995f to 450f602 Compare April 15, 2026 01:55

rpgmem commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Closing as obsoleted by #15, which pinned composer config.platform.php = "8.1" and regenerated the lockfile. The root-cause lockfile reproducibility issue that made this downgrade attractive is resolved on main. The yoast/phpunit-polyfills 2→4 bump can be re-evaluated on its own merits in a fresh PR when Dependabot refreshes.


Generated by Claude Code

@rpgmem rpgmem closed this Apr 15, 2026
@dependabot @github

dependabot Bot commented on behalf of github Apr 15, 2026

Copy link
Copy Markdown
Contributor Author

OK, I won't notify you again about this release, but will get in touch when a new version is available. If you'd rather skip all updates until the next major or minor version, let me know by commenting @dependabot ignore this major version or @dependabot ignore this minor version. You can also ignore all major, minor, or patch releases for a dependency by adding an ignore condition with the desired update_types to your config file.

If you change your mind, just re-open this PR and I'll resolve any conflicts on it.

@dependabot
dependabot Bot deleted the dependabot/composer/yoast/phpunit-polyfills-4.0.0 branch April 15, 2026 01:56
rpgmem added a commit that referenced this pull request Jun 20, 2026
…ation alerts (#564)

CodeQL (security-extended, JavaScript) open alerts:

- #9 / #10 js/xss-through-dom — ffc-reregistration-admin.js audience transfer
  list built HTML by string-concatenating audience name/color/id read from a
  DOM data-attribute, then injected with .append() (both the selected and
  available branches). A malicious audience name/color stored by a delegated
  audience manager could execute in the reregistration admin screen. Rebuilt
  with jQuery $('<el>',{...}) + .text()/.attr() so all values are escaped; the
  color is applied only when it matches a hex pattern. DOM shape, data-id and
  hidden-input values are unchanged (tests green).

- #13 js/incomplete-sanitization — ffc-geofence-admin.js derived the field key
  with .replace(']','') (first-occurrence only). Input is a hardcoded allowlist
  so it was not exploitable, but replaced with a robust slice() to clear the
  alert for good.

Rebuilt the matching .min.js. ESLint clean; full Vitest green (1590); JS line
coverage 99.41% (floor 96).

Note: alerts #11/#12 (js/xss-through-dom in ffc-pdf-generator.js) are false
positives — $img.attr('src', …) restoring an <img> src for html2canvas is not
an HTML/script-execution sink — and should be dismissed as "False positive" in
the code-scanning UI rather than patched on the certificate-render path.


Claude-Session: https://claude.ai/code/session_014QgDNjPLKv2gQWMhdG7d6d

Co-authored-by: Claude <noreply@anthropic.com>
rpgmem added a commit that referenced this pull request Aug 5, 2026
#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>
rpgmem added a commit that referenced this pull request Aug 6, 2026
…port / Restore defaults (#865) (#877)

* feat(admin): certificate-template management actions — Duplicate / Export / Restore defaults (#865)

Completes the ffc_cert_template management screen's row/toolbar actions
(#865 decisions #11/#13):

- Duplicate (row action, every template): copies the HTML into a new
  editable user template via CertTemplateWriter::create() and opens it —
  the sanctioned way to customize a read-only shipped default.
- Export (row action, every template): streams the raw template HTML as a
  `.html` download; the terminal header/echo/exit is isolated in a
  protected emit_download() so it stays unit-testable.
- Restore defaults (toolbar button via restrict_manage_posts): re-runs the
  seeder non-destructively — CertTemplateSeeder::restore() re-adds any
  missing shipped default and refreshes the shipped body of existing ones,
  never touching user templates or a default's chosen visibility.
- New templates created via "Add New" (an auto-draft) are visible by
  default (#865 decision #10).

All gated by ffc_manage_forms; defaults stay delete/rename/edit-protected
server-side. Seeder refactor extracts insert_default() + existing_default_map()
shared by seed()/restore(). Tests cover the row actions, each handler
(cap/nonce, duplicate title, export filename/body, restore denial) and the
non-destructive restore; no new module-boundary edges (all within Admin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLreB3t94fZd6TsH6NSa3

* style(admin): satisfy WPCS on the template management actions

Simplify CertTemplateSeeder::insert_default() to (slug, title, html) all-string
params (fixes @PARAM type-alignment errors from the shaped-array type) and add
the blank line before each $actions[...] assignment so the short $dup_url /
$exp_url temporaries aren't force-aligned to the long array-key writes.

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>
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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant