fix(instanda): hygiene pass + v1.1.0 (uninstall, notices, cron, resubmit, theme)
- F-15: uninstall now removes ALL espinstanda_* options and transients (incl. the encrypted password and its self-provisioned key). - F-16: register the "Gravity Forms required" admin notice unconditionally so it is reachable when GF is inactive (gform_loaded never fires then). - F-22: self-heal the daily retention cron on admin_init (matches the schema self-heal). - F-19: re-validate the stored payload via Field_Mapper::validate_payload() before a resubmit (DB-tamper trust boundary). - F-20: increment the retry count on resubmit and dispatch the previously-dead espinstanda_resubmit_success notification event. - F-23: emit the theme's Form-5 date-range JS only on pages that embed a Gravity Form and disconnect the MutationObserver (was observing the DOM subtree forever). - F-24: bump plugin to 1.1.0; add readme.txt changelog documenting the audit fixes and the required ops actions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: ESP INSTANDA Start Quote Integration
|
* Plugin Name: ESP INSTANDA Start Quote Integration
|
||||||
* Plugin URI: https://getfused.com
|
* Plugin URI: https://getfused.com
|
||||||
* Description: Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. Form-agnostic - attach the feed to any form.
|
* Description: Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. Form-agnostic - attach the feed to any form.
|
||||||
* Version: 1.0.7
|
* Version: 1.1.0
|
||||||
* Requires at least: 7.0
|
* Requires at least: 7.0
|
||||||
* Requires PHP: 8.5
|
* Requires PHP: 8.5
|
||||||
* Author: Getfused
|
* Author: Getfused
|
||||||
@@ -75,25 +75,36 @@ function platform_blockers(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
\add_action('admin_init', static function (): void {
|
\add_action('admin_init', static function (): void {
|
||||||
|
if (!empty(platform_blockers())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep the schema current if the plugin was updated without re-activation.
|
// Keep the schema current if the plugin was updated without re-activation.
|
||||||
if (empty(platform_blockers()) && Transaction_Store::needs_migration()) {
|
if (Transaction_Store::needs_migration()) {
|
||||||
Transaction_Store::install_table();
|
Transaction_Store::install_table();
|
||||||
}
|
}
|
||||||
|
// Self-heal the retention cron if its scheduled event was ever lost (the schema
|
||||||
|
// self-heals above; the cron must too, or retention silently stops forever).
|
||||||
|
if (!\wp_next_scheduled(CLEANUP_HOOK)) {
|
||||||
|
\wp_schedule_event(time(), 'daily', CLEANUP_HOOK);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requirements notice registered UNCONDITIONALLY: gform_loaded never fires when Gravity
|
||||||
|
// Forms is absent, so a notice registered only inside it would be unreachable in exactly
|
||||||
|
// the case (GF missing) an admin most needs to see it.
|
||||||
|
\add_action('admin_notices', static function (): void {
|
||||||
|
$errors = platform_blockers();
|
||||||
|
if ($errors) {
|
||||||
|
echo '<div class="notice notice-error"><p><strong>ESP INSTANDA Integration is inactive:</strong> '
|
||||||
|
. esc_html(implode(' ', $errors)) . '</p></div>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------ Gravity Forms Add-On registration */
|
/* ------------------------------------------------ Gravity Forms Add-On registration */
|
||||||
|
|
||||||
\add_action('gform_loaded', Guard::wrap(static function (): void {
|
\add_action('gform_loaded', Guard::wrap(static function (): void {
|
||||||
if (!empty(platform_blockers()) || !method_exists('GFForms', 'include_feed_addon_framework')) {
|
if (!empty(platform_blockers()) || !method_exists('GFForms', 'include_feed_addon_framework')) {
|
||||||
\add_action('admin_notices', static function (): void {
|
return; // the requirements notice is registered unconditionally above
|
||||||
$errors = platform_blockers();
|
|
||||||
if (!$errors) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
echo '<div class="notice notice-error"><p><strong>ESP INSTANDA Integration is inactive:</strong> '
|
|
||||||
. esc_html(implode(' ', $errors)) . '</p></div>';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
\GFForms::include_feed_addon_framework();
|
\GFForms::include_feed_addon_framework();
|
||||||
|
|||||||
@@ -121,6 +121,37 @@ final class Field_Mapper {
|
|||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanity-check a stored StartQuote wire body before re-sending it (resubmit trust
|
||||||
|
* boundary): the DB is treated as untrusted input. Returns error codes (empty = ok).
|
||||||
|
* Pure - uses a fixed UTC zone since only Y-m-d well-formedness is being checked.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $body A body previously built by to_payload().
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function validate_payload(array $body): array {
|
||||||
|
$errors = [];
|
||||||
|
foreach (['Role', 'ActivityType', 'PQ_LocationState_CHOICE'] as $key) {
|
||||||
|
if (trim((string) ($body[$key] ?? '')) === '') {
|
||||||
|
$errors[] = $key . '_missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$this->is_positive_integer($body['PQ_MaximumDailyAttendance_NUM'] ?? null)) {
|
||||||
|
$errors[] = 'attendance_invalid';
|
||||||
|
}
|
||||||
|
$email = trim((string) ($body['PQ_CustomerEmailAddress_TXT'] ?? ''));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[] = 'email_invalid';
|
||||||
|
}
|
||||||
|
$tz = new \DateTimeZone('UTC');
|
||||||
|
foreach (['PQ_EventStart_DATE', 'PQ_EventEnd_DATE'] as $key) {
|
||||||
|
if ($this->parse_date((string) ($body[$key] ?? ''), $tz) === null) {
|
||||||
|
$errors[] = $key . '_invalid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
|
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
|
||||||
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
|
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
|
||||||
return (int) $start->diff($end)->days + 1;
|
return (int) $start->diff($end)->days + 1;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace ESP\Instanda;
|
|||||||
|
|
||||||
final class Instanda_AddOn extends \GFFeedAddOn {
|
final class Instanda_AddOn extends \GFFeedAddOn {
|
||||||
|
|
||||||
protected $_version = '1.0.7';
|
protected $_version = '1.1.0';
|
||||||
protected $_min_gravityforms_version = '2.10.3';
|
protected $_min_gravityforms_version = '2.10.3';
|
||||||
protected $_slug = 'espinstanda';
|
protected $_slug = 'espinstanda';
|
||||||
protected $_path = 'esp-instanda-integration/esp-instanda-integration.php';
|
protected $_path = 'esp-instanda-integration/esp-instanda-integration.php';
|
||||||
|
|||||||
@@ -159,14 +159,24 @@ final class Resubmit_Handler {
|
|||||||
$store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error');
|
$store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Trust boundary: the stored body is untrusted input on the way back out to the
|
||||||
|
// insurer - re-validate it before re-sending (guards against DB tampering).
|
||||||
|
$payload_errors = (new Field_Mapper())->validate_payload($body);
|
||||||
|
if ($payload_errors) {
|
||||||
|
$store->append_event($tx_id, 'Resubmit aborted - stored payload failed re-validation: ' . implode(', ', $payload_errors), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->increment_retry($tx_id); // this attempt is a retry - keep the count truthful
|
||||||
|
|
||||||
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
|
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
|
||||||
if ($result->ok && $result->quote_ref !== null) {
|
if ($result->ok && $result->quote_ref !== null && $result->quote_ref !== '') {
|
||||||
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
||||||
Failure_Surface::bump();
|
Failure_Surface::bump();
|
||||||
|
|
||||||
if ($tx->entry_id) {
|
if ($tx->entry_id) {
|
||||||
Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment);
|
Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment);
|
||||||
|
self::notify_resubmit_success((int) $tx->entry_id);
|
||||||
} else {
|
} else {
|
||||||
// Stranded lead (no entry) - mint a fresh link and email the customer (option B).
|
// Stranded lead (no entry) - mint a fresh link and email the customer (option B).
|
||||||
$fresh = (string) $result->url_single_use;
|
$fresh = (string) $result->url_single_use;
|
||||||
@@ -182,4 +192,19 @@ final class Resubmit_Handler {
|
|||||||
Failure_Surface::bump();
|
Failure_Surface::bump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatch the registered espinstanda_resubmit_success notification event for an entry. */
|
||||||
|
private static function notify_resubmit_success(int $entry_id): void {
|
||||||
|
if (!class_exists('\GFAPI')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$entry = \GFAPI::get_entry($entry_id);
|
||||||
|
if (is_wp_error($entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$form = \GFAPI::get_form((int) $entry['form_id']);
|
||||||
|
if ($form) {
|
||||||
|
\GFAPI::send_notifications($form, $entry, 'espinstanda_resubmit_success');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
=== ESP INSTANDA Start Quote Integration ===
|
||||||
|
Contributors: getfused
|
||||||
|
Requires at least: 7.0
|
||||||
|
Requires PHP: 8.5
|
||||||
|
Stable tag: 1.1.0
|
||||||
|
|
||||||
|
Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with
|
||||||
|
write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock.
|
||||||
|
|
||||||
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.1.0 =
|
||||||
|
Remediation of the 2026-07 security/reliability/PII audit (see docs/audit-esp-instanda-2026-07.md).
|
||||||
|
|
||||||
|
No duplicate policies / no lost quotes:
|
||||||
|
* Dedupe key is now content-addressed (form id + mapped field values) instead of a
|
||||||
|
per-request token, so an identical re-POST or a retry-after-error reuses the existing
|
||||||
|
quote instead of creating a second one at INSTANDA (F-01). Adds a 30-minute
|
||||||
|
short-circuit window and a content-key race lock.
|
||||||
|
* Refuse to resubmit an already-delivered transaction (F-12); scope the resubmit
|
||||||
|
self-recovery guard to the content dedupe key so a customer's *different* event is not
|
||||||
|
skipped (F-18).
|
||||||
|
* A 2xx response with a missing/empty quoteRef is now recorded as ambiguous
|
||||||
|
(possible_duplicate = 1), not a hard failure (F-10, F-17).
|
||||||
|
* Aborts the StartQuote when the write-ahead insert fails, so no unaudited call is made (F-14).
|
||||||
|
|
||||||
|
Go-live safety:
|
||||||
|
* switch_to_live() now also sets the environment to Live; previously the go-live button
|
||||||
|
left real quotes hitting the Test endpoint (F-03).
|
||||||
|
* Go-live advisory flags when more than one active INSTANDA feed exists (F-02).
|
||||||
|
|
||||||
|
Security & PII:
|
||||||
|
* Corrected the at-rest-secret docblock: the "DB dump never exposes a secret" guarantee
|
||||||
|
only holds when SPG_INSTANDA_KEY is set as a constant (F-04).
|
||||||
|
* Retention now scrubs PII from stale failed/pending rows (previously kept forever) and
|
||||||
|
exempts stranded delivered leads from the purge (F-05, F-13); wired WordPress's Erase
|
||||||
|
Personal Data tool to delete a customer's transactions (F-05).
|
||||||
|
* Uninstall now removes ALL plugin options and transients, including the encrypted
|
||||||
|
password and its key (F-15).
|
||||||
|
* Added per-IP (20/h) and per-email (5/h) rate limiting on the public create (F-06).
|
||||||
|
|
||||||
|
Reliability & correctness:
|
||||||
|
* Timestamps are written in UTC to match the retention/dedupe cutoffs and displayed in
|
||||||
|
site-local time (F-07). NOTE: rows created before 1.1.0 were stored in site-local time;
|
||||||
|
clear the transaction table (test data) at deploy to avoid a mixed-timezone history.
|
||||||
|
* Fail closed on an unexpected error in the create path (no quote-less entry) and alert
|
||||||
|
admins, instead of silently passing (F-11); log when async recording cannot resolve a
|
||||||
|
transaction (F-09).
|
||||||
|
* The "Gravity Forms required" admin notice is now shown even when Gravity Forms is
|
||||||
|
inactive (F-16); the daily retention cron self-heals on admin_init (F-22).
|
||||||
|
* Resubmit re-validates the stored payload before re-sending (F-19); the Retries count is
|
||||||
|
now incremented and the resubmit-success notification event is dispatched (F-20).
|
||||||
|
|
||||||
|
Theme:
|
||||||
|
* The Form-5 date-range inline JS is emitted only on pages that embed a Gravity Form and
|
||||||
|
its MutationObserver now disconnects instead of watching the DOM forever (F-23).
|
||||||
|
|
||||||
|
Operations required alongside this release (see the audit report, Ops checklist):
|
||||||
|
* Set SPG_INSTANDA_KEY + SPG_INSTANDA_USERNAME_LIVE/_PASSWORD_LIVE + SPG_INSTANDA_ENV in
|
||||||
|
production wp-config.php (blocking) - OPS-1 / F-04.
|
||||||
|
* Deactivate the INSTANDA feed on the test Form 6 in production (blocking) - OPS-3 / F-02.
|
||||||
|
* Configure espinstanda_alert_emails and the quote-success notification (blocking) - OPS-4.
|
||||||
|
* Rotate the SendGrid API key out of committed wp-config.php - OPS-2 / F-08.
|
||||||
|
* Add reCAPTCHA/Akismet to the canonical quote form - OPS-5 / F-06.
|
||||||
|
|
||||||
|
= 1.0.7 =
|
||||||
|
* Initial audited release.
|
||||||
@@ -20,10 +20,19 @@ try {
|
|||||||
// Table name is a trusted internal constant, not user input.
|
// Table name is a trusted internal constant, not user input.
|
||||||
$wpdb->query("DROP TABLE IF EXISTS {$table}");
|
$wpdb->query("DROP TABLE IF EXISTS {$table}");
|
||||||
|
|
||||||
delete_option('espinstanda_db_version');
|
// Remove EVERY plugin option and transient - including the encrypted Test password
|
||||||
delete_option('espinstanda_operating_mode_audit');
|
// and its self-provisioned key - so no recoverable secret survives uninstall.
|
||||||
|
$opt = $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$tr = '_transient_' . $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$trt = '_transient_timeout_' . $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s",
|
||||||
|
$opt,
|
||||||
|
$tr,
|
||||||
|
$trt
|
||||||
|
));
|
||||||
|
|
||||||
// Clear any scheduled cleanup that may still be queued.
|
// Clear any scheduled events that may still be queued.
|
||||||
wp_clear_scheduled_hook('esp_instanda_cleanup');
|
wp_clear_scheduled_hook('esp_instanda_cleanup');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if (function_exists('error_log')) {
|
if (function_exists('error_log')) {
|
||||||
|
|||||||
@@ -189,7 +189,17 @@ function gf_validate_date_range( $validation_result ) {
|
|||||||
add_action( 'wp_footer', 'gf_date_range_scripts' );
|
add_action( 'wp_footer', 'gf_date_range_scripts' );
|
||||||
|
|
||||||
function gf_date_range_scripts() {
|
function gf_date_range_scripts() {
|
||||||
if ( ! class_exists( 'GFForms' ) ) {
|
if ( ! class_exists( 'GFForms' ) || is_admin() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only emit this inline script on requests whose content embeds a Gravity Form -
|
||||||
|
// keeps ~220 lines of JS off every other page. Server-side validation is unaffected.
|
||||||
|
// If the post cannot be inspected, fall back to emitting (safe default).
|
||||||
|
$post = get_post();
|
||||||
|
if ( $post instanceof WP_Post
|
||||||
|
&& ! has_shortcode( (string) $post->post_content, 'gravityform' )
|
||||||
|
&& ! has_block( 'gravityforms/form', $post )
|
||||||
|
&& false === strpos( (string) $post->post_content, 'gform' ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -410,10 +420,14 @@ function gf_date_range_scripts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( ! attachListeners() ) {
|
if ( ! attachListeners() ) {
|
||||||
|
var tries = 0;
|
||||||
var observer = new MutationObserver( function () {
|
var observer = new MutationObserver( function () {
|
||||||
if ( attachListeners() ) observer.disconnect();
|
// Stop once the fields are wired, or after a bounded number of tries, so we
|
||||||
|
// never observe the whole document subtree indefinitely on non-form pages.
|
||||||
|
if ( attachListeners() || ++tries > 20 ) observer.disconnect();
|
||||||
} );
|
} );
|
||||||
observer.observe( document.body, { childList: true, subtree: true } );
|
observer.observe( document.body, { childList: true, subtree: true } );
|
||||||
|
setTimeout( function () { observer.disconnect(); }, 10000 );
|
||||||
}
|
}
|
||||||
|
|
||||||
} )();
|
} )();
|
||||||
|
|||||||
Reference in New Issue
Block a user