- F-03: switch_to_live() now sets espinstanda_env=live so environment() actually resolves to Live after go-live (was leaving real quotes on the Test endpoint). - F-04: correct the class-config docblock overstating the at-rest guarantee; it only holds when SPG_INSTANDA_KEY is a constant (else the key is self-provisioned into the same DB as the ciphertext). - F-02: add advisory A5 "exactly one active INSTANDA feed" to catch a stray test-form feed that also creates quotes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
10 KiB
PHP
238 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* Go_Live - the test-mode gate. Computes the blocking (B1-B5) and advisory checklist
|
|
* items, stores acknowledgements, and performs the capability-gated, audited switch
|
|
* from "locked to Test" to "live-enabled". The switch is only possible when every
|
|
* blocking item passes; rolling back to Test is always available.
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
final class Go_Live {
|
|
|
|
public const ROLES = [
|
|
'event_host' => 'Event Host or Organizer',
|
|
'vendor' => 'Vendor, Exhibitor or Subcontractor',
|
|
'performer' => 'Performer, Entertainer or DJ',
|
|
];
|
|
|
|
private const FRESHNESS = 1800; // a Test probe counts as "passing" for 30 minutes
|
|
|
|
/** @return list<array{key:string,label:string,detail:string,pass:bool}> */
|
|
public function blocking_items(): array {
|
|
$pricing = $this->pricing_confirmed();
|
|
$items = [
|
|
[
|
|
'key' => 'B1',
|
|
'label' => 'Live credentials present as wp-config.php constants',
|
|
'detail' => Config::live_credentials_are_constants()
|
|
? 'Detected'
|
|
: 'Not configured - add SPG_INSTANDA_USERNAME_LIVE (or SPG_INSTANDA_USER_LIVE) + SPG_INSTANDA_PASSWORD_LIVE',
|
|
'pass' => Config::live_credentials_are_constants(),
|
|
],
|
|
[
|
|
'key' => 'B2',
|
|
'label' => 'Test connection passing',
|
|
'detail' => $this->probe_detail('espinstanda_test_probe_ok'),
|
|
'pass' => $this->probe_fresh('espinstanda_test_probe_ok'),
|
|
],
|
|
[
|
|
'key' => 'B3',
|
|
'label' => 'Live credentials authenticate',
|
|
'detail' => $this->probe_detail('espinstanda_live_probe_ok'),
|
|
'pass' => $this->probe_fresh('espinstanda_live_probe_ok'),
|
|
],
|
|
[
|
|
'key' => 'B4',
|
|
'label' => 'Dropdown workbook synced (byte-exact)',
|
|
'detail' => $this->ack_detail('espinstanda_workbook_ack'),
|
|
'pass' => (bool) get_option('espinstanda_workbook_ack'),
|
|
],
|
|
[
|
|
'key' => 'B5',
|
|
'label' => 'Pricing confirmed per role path (' . count($pricing) . ' of 3)',
|
|
'detail' => $pricing ? 'Confirmed: ' . implode(', ', $pricing) : 'No role paths confirmed yet',
|
|
'pass' => count($pricing) === 3,
|
|
],
|
|
[
|
|
'key' => 'B6',
|
|
'label' => 'siteDomainName set to a valid host',
|
|
'detail' => $this->site_domain_detail(),
|
|
'pass' => $this->site_domain_valid(),
|
|
],
|
|
];
|
|
return $items;
|
|
}
|
|
|
|
/** INSTANDA's two documented consumer/agent journey hosts (a custom domain is also allowed). */
|
|
private const ACCEPTED_DOMAINS = ['consumer.instanda.us', 'agent.instanda.us'];
|
|
|
|
/** siteDomainName is a required StartQuote/ContinueQuote query param: must be a real host. */
|
|
private function site_domain_valid(): bool {
|
|
$domain = Config::site_domain();
|
|
if ($domain === '') {
|
|
return false;
|
|
}
|
|
// Accept the two documented hosts, or any plausible bare hostname (INSTANDA may
|
|
// configure a custom consumer domain). Reject schemes, paths, and empties.
|
|
return in_array($domain, self::ACCEPTED_DOMAINS, true)
|
|
|| (bool) preg_match('/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/', $domain);
|
|
}
|
|
|
|
private function site_domain_detail(): string {
|
|
$domain = Config::site_domain();
|
|
if ($domain === '') {
|
|
return 'Not set - required query param (recommended: consumer.instanda.us)';
|
|
}
|
|
if (in_array($domain, self::ACCEPTED_DOMAINS, true)) {
|
|
return $domain;
|
|
}
|
|
return $this->site_domain_valid() ? $domain . ' (custom host - confirm with INSTANDA)' : 'Invalid host: ' . $domain;
|
|
}
|
|
|
|
/** @return list<array{key:string,label:string,detail:string,pass:bool}> */
|
|
public function advisory_items(): array {
|
|
$retention = (int) get_option('espinstanda_retention_days', 7);
|
|
$storage = get_option('espinstanda_storage_enabled', '1') === '1';
|
|
$emails = trim((string) get_option('espinstanda_alert_emails', ''));
|
|
$unresolved = (new Transaction_Store())->unresolved_count();
|
|
|
|
return [
|
|
['key' => 'A1', 'label' => 'Storage on + retention set', 'detail' => $storage ? "On, {$retention} day(s)" : 'Off', 'pass' => $storage && $retention > 0],
|
|
['key' => 'A2', 'label' => 'Alert email recipient(s)', 'detail' => $emails !== '' ? $emails : 'None set', 'pass' => $emails !== ''],
|
|
['key' => 'A3', 'label' => 'Front-end form submit + redirect verified in a browser', 'detail' => $this->ack_detail('espinstanda_frontend_ack'), 'pass' => (bool) get_option('espinstanda_frontend_ack')],
|
|
['key' => 'A4', 'label' => 'No unresolved failures', 'detail' => $unresolved === 0 ? 'Clean' : "{$unresolved} unresolved", 'pass' => $unresolved === 0],
|
|
['key' => 'A5', 'label' => 'Exactly one active INSTANDA feed', 'detail' => $this->feed_summary(), 'pass' => $this->active_feed_count() === 1],
|
|
];
|
|
}
|
|
|
|
/** Count active INSTANDA feeds across all forms (should be exactly one at go-live). */
|
|
private function active_feed_count(): int {
|
|
if (!class_exists(__NAMESPACE__ . '\\Instanda_AddOn')) {
|
|
return 0;
|
|
}
|
|
$feeds = Instanda_AddOn::get_instance()->get_feeds();
|
|
return count(array_filter((array) $feeds, static fn ($f) => !empty($f['is_active'])));
|
|
}
|
|
|
|
private function feed_summary(): string {
|
|
$n = $this->active_feed_count();
|
|
if ($n === 1) {
|
|
return '1 active feed';
|
|
}
|
|
return $n === 0
|
|
? 'No active feed - the quote form will not push to INSTANDA'
|
|
: "{$n} active feeds - deactivate all but the canonical quote form (a stray test-form feed also creates quotes)";
|
|
}
|
|
|
|
/** @return array{complete:int,total:int,ready:bool} */
|
|
public function readiness(): array {
|
|
$blocking = $this->blocking_items();
|
|
$complete = count(array_filter($blocking, static fn ($i) => $i['pass']));
|
|
return [
|
|
'complete' => $complete,
|
|
'total' => count($blocking),
|
|
'ready' => $complete === count($blocking),
|
|
];
|
|
}
|
|
|
|
public function can_switch_to_live(): bool {
|
|
return $this->readiness()['ready'] && !Config::operating_mode_pinned();
|
|
}
|
|
|
|
public function switch_to_live(): bool {
|
|
if (!\GFCommon::current_user_can_any('espinstanda_settings') || !$this->can_switch_to_live()) {
|
|
return false;
|
|
}
|
|
update_option('espinstanda_operating_mode', 'live');
|
|
// Switching the mode must also select the Live environment, mirroring
|
|
// return_to_test(); otherwise environment() stays clamped to Test and real
|
|
// customer quotes would keep hitting the Test endpoint after go-live.
|
|
update_option('espinstanda_env', 'live');
|
|
update_option('espinstanda_operating_mode_audit', [
|
|
'actor' => get_current_user_id(),
|
|
'ts' => current_time('mysql'),
|
|
'snapshot' => $this->readiness(),
|
|
]);
|
|
Logger::debug('Operating mode switched to LIVE by user ' . get_current_user_id());
|
|
return true;
|
|
}
|
|
|
|
public function return_to_test(): bool {
|
|
if (!\GFCommon::current_user_can_any('espinstanda_settings') || Config::operating_mode_pinned()) {
|
|
return false;
|
|
}
|
|
update_option('espinstanda_operating_mode', 'test');
|
|
update_option('espinstanda_env', 'test');
|
|
Logger::debug('Operating mode returned to TEST by user ' . get_current_user_id());
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------------------- acknowledgements */
|
|
|
|
public function acknowledge_workbook(): void {
|
|
update_option('espinstanda_workbook_ack', [
|
|
'actor' => get_current_user_id(),
|
|
'ts' => current_time('mysql'),
|
|
]);
|
|
}
|
|
|
|
public function acknowledge_frontend(): void {
|
|
update_option('espinstanda_frontend_ack', [
|
|
'actor' => get_current_user_id(),
|
|
'ts' => current_time('mysql'),
|
|
]);
|
|
}
|
|
|
|
public function confirm_role_pricing(string $role_key): void {
|
|
if (!isset(self::ROLES[$role_key])) {
|
|
return;
|
|
}
|
|
$confirmed = (array) get_option('espinstanda_pricing_ack', []);
|
|
$confirmed[$role_key] = ['ts' => current_time('mysql'), 'actor' => get_current_user_id()];
|
|
update_option('espinstanda_pricing_ack', $confirmed);
|
|
}
|
|
|
|
public function record_probe(string $option_key, bool $ok): void {
|
|
update_option($option_key, ['ok' => $ok, 'ts' => time()]);
|
|
}
|
|
|
|
/** @return list<string> human labels of confirmed roles */
|
|
private function pricing_confirmed(): array {
|
|
$confirmed = (array) get_option('espinstanda_pricing_ack', []);
|
|
$labels = [];
|
|
foreach (self::ROLES as $key => $label) {
|
|
if (isset($confirmed[$key])) {
|
|
$labels[] = $label;
|
|
}
|
|
}
|
|
return $labels;
|
|
}
|
|
|
|
private function probe_fresh(string $option_key): bool {
|
|
$probe = get_option($option_key);
|
|
return is_array($probe) && !empty($probe['ok']) && (time() - (int) ($probe['ts'] ?? 0)) < self::FRESHNESS;
|
|
}
|
|
|
|
private function probe_detail(string $option_key): string {
|
|
$probe = get_option($option_key);
|
|
if (!is_array($probe) || empty($probe['ts'])) {
|
|
return 'Not run yet';
|
|
}
|
|
$ago = human_time_diff((int) $probe['ts']);
|
|
return (!empty($probe['ok']) ? '200 OK' : 'Failed') . ", {$ago} ago";
|
|
}
|
|
|
|
private function ack_detail(string $option_key): string {
|
|
$ack = get_option($option_key);
|
|
if (!is_array($ack) || empty($ack['ts'])) {
|
|
return 'Not acknowledged';
|
|
}
|
|
return 'Acknowledged ' . $ack['ts'];
|
|
}
|
|
}
|