Unknown VS code: ' . vs01_h($vs_code) . ''; } $schema = $schemas[$vs_code]; // Determine perspective $perspective_map = [ 'participant' => 'homeowner', 'professional' => 'professional', 'operator' => 'public_record', ]; $perspective_key = $perspective_map[$access] ?? null; if (!$perspective_key) { return '
Invalid access state for submission.
'; } $perspective = $schema['perspectives'][$perspective_key] ?? null; if (!$perspective) { return '
No perspective defined for this access state.
'; } // Collect and sanitize submitted fields $raw_fields = $_POST['fields'] ?? []; $fields = []; foreach ($perspective['fields'] ?? [] as $field_def) { $id = $field_def['id'] ?? ''; if (!$id) continue; $type = $field_def['type'] ?? 'text'; if ($type === 'multiselect') { $val = isset($raw_fields[$id]) && is_array($raw_fields[$id]) ? array_map('strval', $raw_fields[$id]) : []; $fields[$id] = json_encode($val); } else { $fields[$id] = isset($raw_fields[$id]) ? substr(strip_tags((string) $raw_fields[$id]), 0, 8192) : ''; } } // Validate required fields $errors = vs01_validate_required($perspective, $fields); if ($errors) { $out = '
Please complete the required fields:
'; $out .= vs01_render_vs_form_with_values($association_slug, $vs_code, $access, $fields); return $out; } // Build and POST spool envelope $config = vs01_load_config(); $assoc = $config['associations'][$association_slug] ?? []; $envelope = vs01_build_spool_envelope( $vs_code, $perspective_key, $fields, $association_slug, $assoc['channel_id'] ?? '', $access, $config['node_token'] ?? '' ); $result = vs01_post_to_orchestrator($envelope, $config); if ($result === true) { // Store a local snapshot so scn01 can read VS context for Scenario prerequisite check. set_pconfig(local_channel(), 'vs01_snapshot', $association_slug, json_encode($fields)); return '
Your record for ' . vs01_h($vs_code) . ' has been saved. Return to ' . vs01_h($assoc['name'] ?? $association_slug) . '
'; } return '
Submission failed. Please try again or contact the operator.
' . vs01_h($result) . '
'; } // --------------------------------------------------------------------------- // VALIDATION // --------------------------------------------------------------------------- function vs01_validate_required($perspective, $fields) { $errors = []; foreach ($perspective['fields'] ?? [] as $field_def) { if (empty($field_def['required'])) continue; $id = $field_def['id'] ?? ''; $value = trim($fields[$id] ?? ''); if ($value === '' || $value === '[]') { $errors[] = $field_def['label'] ?? $id; } } return $errors; } // --------------------------------------------------------------------------- // SPOOL ENVELOPE // Matches contracts/vs01/record-v1.json — _header / _payload shape. // --------------------------------------------------------------------------- function vs01_build_spool_envelope($vs_code, $perspective, $fields, $association_slug, $channel_id, $standing, $node_token) { return [ '_header' => [ 'addon' => 'vs01', 'contract_version' => '1.0', 'association_slug' => $association_slug, 'association_channel_id' => (string) $channel_id, 'participant_id' => vs01_get_participant_id($association_slug), 'submitted_at' => date('c'), 'standing' => $standing, 'node_token_hash' => hash('sha256', $node_token), ], '_payload' => [ 'vs_code' => $vs_code, 'perspective' => $perspective, 'fields' => $fields, ], ]; } // --------------------------------------------------------------------------- // PARTICIPANT ID — Placekey derived from SASE channel address // --------------------------------------------------------------------------- function vs01_get_participant_id($association_slug) { // The participant_id is the Placekey — the SASE-verified channel address // for this participant, which encodes their postal address. // It is stored in the channel's xchan_addr field. // Format: SASE1-{encoded-address} $observer = get_observer_hash(); if (!$observer) return ''; $r = q("SELECT xchan_addr FROM xchan WHERE xchan_hash = '%s' LIMIT 1", dbesc($observer) ); if (!$r) return ''; // xchan_addr is in the form "channelname@hostname" // The channel name IS the Placekey for SASE participants. $addr = $r[0]['xchan_addr'] ?? ''; $parts = explode('@', $addr); // Strip guest: prefix — Placekey is the channel name without the guest: qualifier. return preg_replace('/^guest:/', '', $parts[0] ?? ''); } // --------------------------------------------------------------------------- // ORCHESTRATOR POST // --------------------------------------------------------------------------- function vs01_post_to_orchestrator($envelope, $config = null) { if ($config === null) { $config = vs01_load_config(); } $receiver_url = $config['receiver_url'] ?? ''; $node_token = $config['node_token'] ?? ''; if (!$receiver_url) { logger('vs01_spool: receiver_url not configured'); return 'Orchestrator receiver URL not configured.'; } $payload = json_encode($envelope); if ($payload === false) { logger('vs01_spool: failed to encode envelope'); return 'Failed to encode submission.'; } $ch = curl_init($receiver_url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Node-Token: ' . $node_token, ], ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curl_error = curl_error($ch); curl_close($ch); // --- DEBUG LOGGING --- // Writes every orchestrator POST attempt (request + response) to a // dedicated debug log, independent of timing. Read with: // tail -50 /var/www/hubzilla/addon/vs01/debug.log // Remove this block once the 400 root cause is found and fixed. @file_put_contents( __DIR__ . '/debug.log', '[' . date('c') . "] vs01 POST to orchestrator\n" . " url: $receiver_url\n" . " http_code: $http_code\n" . " curl_error: " . ($curl_error ?: '(none)') . "\n" . " request_payload: $payload\n" . " response_body: " . ($response === false ? '(curl failed)' : $response) . "\n" . "----\n", FILE_APPEND ); // --- END DEBUG LOGGING --- if ($curl_error) { logger('vs01_spool: curl error: ' . $curl_error); return 'Network error: ' . $curl_error; } if ($http_code === 200 || $http_code === 201) { return true; } logger('vs01_spool: orchestrator returned HTTP ' . $http_code . ': ' . $response); return 'Orchestrator error (HTTP ' . $http_code . ').'; } // --------------------------------------------------------------------------- // RE-RENDER FORM WITH SUBMITTED VALUES // --------------------------------------------------------------------------- function vs01_render_vs_form_with_values($association_slug, $vs_code, $access, $submitted_values) { $schemas = vs01_load_schemas(); if (!isset($schemas[$vs_code])) return ''; $schema = $schemas[$vs_code]; $config = vs01_load_config(); $assoc = $config['associations'][$association_slug]; $perspective_map = [ 'participant' => 'homeowner', 'professional' => 'professional', 'operator' => 'public_record', ]; $perspective_key = $perspective_map[$access] ?? 'homeowner'; $perspective = $schema['perspectives'][$perspective_key] ?? null; if (!$perspective) return ''; $form_url = z_root() . '/vs01/' . vs01_h($association_slug) . '/' . vs01_h($vs_code); $meta = $schema['_meta']; $out = '
'; $out .= '

' . vs01_h($vs_code) . ' — ' . vs01_h($meta['title'] ?? '') . '

'; $out .= vs01_render_verification_sources($meta); $out .= '

' . vs01_h($perspective['instruction'] ?? '') . '

'; $out .= '
'; $out .= vs01_csrf_token(); $out .= ''; $out .= ''; foreach ($perspective['fields'] ?? [] as $field) { $field_html = vs01_render_field($field, $submitted_values); if (!empty($field['depends_on'])) { $field_html = vs01_wrap_depends_on($field, $field_html); } $out .= $field_html; } $out .= '
'; $out .= '
'; $out .= vs01_render_vs_nav($association_slug, $vs_code); $out .= '
'; return $out; }