From 0ccbc11cc2cce79c455847eefb5a60033fa503b6 Mon Sep 17 00:00:00 2001 From: TheRON Date: Sat, 6 Jun 2026 02:22:53 -0400 Subject: [PATCH] Updated --- hubzilla/addon/vs01/config.json.template | 21 +- hubzilla/addon/vs01/vs01.php | 352 +++++++++++++++++++++-- hubzilla/addon/vs01/vs01_renderer.php | 328 +++++++++++++++++++++ hubzilla/addon/vs01/vs01_spool.php | 233 +++++++++++++++ 4 files changed, 904 insertions(+), 30 deletions(-) create mode 100644 hubzilla/addon/vs01/vs01_renderer.php create mode 100644 hubzilla/addon/vs01/vs01_spool.php diff --git a/hubzilla/addon/vs01/config.json.template b/hubzilla/addon/vs01/config.json.template index 328897e..8fa68b5 100644 --- a/hubzilla/addon/vs01/config.json.template +++ b/hubzilla/addon/vs01/config.json.template @@ -1,6 +1,21 @@ { "_note": "Copy to config.json. Do not commit config.json — it contains secrets and installation-specific values.", - "corpus_builder_group_id": 0, - "listings_file": "REPLACE — absolute path to listings.json on the host", - "directory_default_tab": "core" + "receiver_url": "REPLACE — orchestrator receiver endpoint, e.g. https://orchestrator1.internal.diagnostics.kane-il.us/receive", + "node_token": "REPLACE — shared secret for node-to-orchestrator authentication", + "listings_file": "REPLACE — absolute path to listings.json on the host, e.g. /var/www/hubzilla/addon/vs01/listings.json", + "directory_default_tab": "core", + "associations": { + "REPLACE-SLUG": { + "_note": "Key is the URL slug — lowercase, hyphens only, matches argv(1) in the route", + "name": "REPLACE — association legal name", + "address": "REPLACE — association street address", + "channel_id": 0, + "channel_address": "REPLACE — channel@node address", + "groups": { + "public": 0, + "sase_participant": 0, + "corpus_builder": 0 + } + } + } } diff --git a/hubzilla/addon/vs01/vs01.php b/hubzilla/addon/vs01/vs01.php index 3635197..0d13347 100644 --- a/hubzilla/addon/vs01/vs01.php +++ b/hubzilla/addon/vs01/vs01.php @@ -3,13 +3,16 @@ /** * Name: VS-01 Vital Signs * Description: Public civic diagnostic — the ten structural preconditions of an HOA association. - * Version: 0.1.0 + * Version: 0.2.0 * MinVersion: 11.0 * MaxVersion: 12.0 */ use Zotlabs\Extend\Widget; +require_once 'addon/vs01/vs01_renderer.php'; +require_once 'addon/vs01/vs01_spool.php'; + function vs01_module() {} function vs01_load() { @@ -44,34 +47,71 @@ function vs01_h($value) { // ACCESS // ---------------------------------------------------------------------------- -function vs01_access_state() { +function vs01_access_state($association_slug = '') { if (!local_channel()) { return 'public'; } - $channel = App::get_channel(); + $config = vs01_load_config(); + // Operator check — site owner, not association channel owner + $channel = App::get_channel(); if (local_channel() === intval($channel['channel_id'])) { return 'operator'; } - $config = vs01_load_config(); - $gid = intval($config['corpus_builder_group_id'] ?? 0); + if (!$association_slug) { + return 'public'; + } - if ($gid && in_array(get_observer_hash(), group_get_members_xchan($gid))) { + $assoc = $config['associations'][$association_slug] ?? null; + if (!$assoc) return 'public'; + + $observer = get_observer_hash(); + $groups = $assoc['groups'] ?? []; + + // Corpus Builder — highest participant tier + $cb_gid = intval($groups['corpus_builder'] ?? 0); + if ($cb_gid && in_array($observer, group_get_members_xchan($cb_gid))) { return 'participant'; } - return 'denied'; + // SASE Participant + $sase_gid = intval($groups['sase_participant'] ?? 0); + if ($sase_gid && in_array($observer, group_get_members_xchan($sase_gid))) { + return 'participant'; + } + + return 'public'; } -function vs01_access_wall() { +function vs01_is_professional() { + if (!local_channel()) return false; + $observer = get_observer_hash(); + $listings = vs01_load_listings(); + foreach (['tier1', 'tier2'] as $tier) { + foreach ($listings[$tier] ?? [] as $entry) { + if (($entry['xchan'] ?? '') === $observer + && ($entry['status'] ?? '') === 'active') { + return true; + } + } + } + return false; +} + +function vs01_access_wall($association_slug = '') { + $config = vs01_load_config(); + $assoc = $association_slug + ? ($config['associations'][$association_slug] ?? null) + : null; + $name = $assoc ? vs01_h($assoc['name']) : 'this association'; return '
'; + return $out; + } + + // Form + $out .= '

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

'; + $out .= '
'; + $out .= vs01_csrf_token(); + $out .= ''; + $out .= ''; + + foreach ($perspective['fields'] ?? [] as $field) { + $field_html = vs01_render_field($field, []); + if (!empty($field['depends_on'])) { + $field_html = vs01_wrap_depends_on($field, $field_html); + } + $out .= $field_html; + } + + // THROWAWAY — bare minimum confirmation target; replace with full TMP workflow + $out .= '
'; + $out .= ''; + $out .= '
'; + $out .= '
'; + + // VS navigation — previous / next + $out .= vs01_render_vs_nav($association_slug, $vs_code); + + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// RENDER — VS NAVIGATION +// ---------------------------------------------------------------------------- + +function vs01_render_vs_nav($association_slug, $current_code) { + $schemas = vs01_load_schemas(); + $codes = array_keys($schemas); + $idx = array_search($current_code, $codes, true); + $base = z_root() . '/vs01/' . vs01_h($association_slug) . '/'; + + $out = ''; + return $out; +} + +// ---------------------------------------------------------------------------- +// RENDER — MANAGE (TMP REVIEW) +// ---------------------------------------------------------------------------- + +function vs01_render_manage($association_slug) { + $config = vs01_load_config(); + $assoc = $config['associations'][$association_slug]; + $name = vs01_h($assoc['name'] ?? $association_slug); + + $out = '
'; + $out .= '

Manage — ' . $name . '

'; + // TODO: load TMP submissions from orchestrator and render review interface + $out .= '

TMP review forthcoming.

'; + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// RENDER — NOT FOUND +// ---------------------------------------------------------------------------- + +function vs01_render_not_found() { + return '
Association not found.
'; +} + // ---------------------------------------------------------------------------- // CONFIG // ---------------------------------------------------------------------------- @@ -137,6 +417,24 @@ function vs01_load_config() { return (json_last_error() === JSON_ERROR_NONE) ? $data : []; } +// ---------------------------------------------------------------------------- +// LISTINGS +// ---------------------------------------------------------------------------- + +function vs01_load_listings() { + $config = vs01_load_config(); + $path = $config['listings_file'] ?? 'addon/vs01/listings.json'; + $raw = @file_get_contents($path); + if ($raw === false) { + return ['core' => [], 'tier1' => [], 'tier2' => [], 'other' => []]; + } + $data = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return ['core' => [], 'tier1' => [], 'tier2' => [], 'other' => []]; + } + return $data; +} + // ---------------------------------------------------------------------------- // CSRF // ---------------------------------------------------------------------------- diff --git a/hubzilla/addon/vs01/vs01_renderer.php b/hubzilla/addon/vs01/vs01_renderer.php new file mode 100644 index 0000000..fbed925 --- /dev/null +++ b/hubzilla/addon/vs01/vs01_renderer.php @@ -0,0 +1,328 @@ +'; + $out .= ''; + $out .= ''; + $out .= ''; + return $out; +} + +function vs01_render_field_number($field, $value = '') { + $id = vs01_h($field['id']); + $out = '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + return $out; +} + +function vs01_render_field_date($field, $value = '') { + $id = vs01_h($field['id']); + $out = '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + return $out; +} + +function vs01_render_field_boolean($field, $value = '') { + $id = vs01_h($field['id']); + $yes = ($value === 'true') ? 'checked' : ''; + $no = ($value === 'false') ? 'checked' : ''; + $req = !empty($field['required']) ? 'required' : ''; + $out = '
'; + $out .= '
'; + $out .= '' . vs01_h($field['label']) . ''; + $out .= '
+ + +
'; + $out .= '
+ + +
'; + $out .= '
'; + return $out; +} + +function vs01_render_field_select($field, $value = '') { + $id = vs01_h($field['id']); + $options = $field['options'] ?? []; + $out = '
'; + $out .= ''; + $out .= '
'; + return $out; +} + +function vs01_render_field_textarea($field, $value = '') { + $id = vs01_h($field['id']); + $out = '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + return $out; +} + +function vs01_render_field_multiselect($field, $value = '') { + $id = vs01_h($field['id']); + $options = $field['options'] ?? []; + $selected = $value ? json_decode($value, true) : []; + if (!is_array($selected)) $selected = []; + $out = '
'; + $out .= '
' . vs01_h($field['label']) . ''; + foreach ($options as $opt) { + $v = vs01_h($opt['value']); + $l = vs01_h($opt['label']); + $chk = in_array($opt['value'], $selected) ? 'checked' : ''; + $out .= '
+ + +
'; + } + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// FIELD DISPATCHER +// ---------------------------------------------------------------------------- + +function vs01_render_field($field, $submitted_values = []) { + $id = $field['id'] ?? ''; + $type = $field['type'] ?? 'text'; + $value = $submitted_values[$id] ?? ''; + + switch ($type) { + case 'text': $html = vs01_render_field_text($field, $value); break; + case 'number': $html = vs01_render_field_number($field, $value); break; + case 'date': $html = vs01_render_field_date($field, $value); break; + case 'boolean': $html = vs01_render_field_boolean($field, $value); break; + case 'select': $html = vs01_render_field_select($field, $value); break; + case 'textarea': $html = vs01_render_field_textarea($field, $value); break; + case 'multiselect': $html = vs01_render_field_multiselect($field, $value); break; + default: + $html = '
Unknown field type: ' . vs01_h($type) . '
'; + } + + $html .= vs01_render_diagnostic_flag($field, $value); + return $html; +} + +// ---------------------------------------------------------------------------- +// CONDITIONAL DISPLAY +// ---------------------------------------------------------------------------- + +function vs01_wrap_depends_on($field, $html) { + if (empty($field['depends_on'])) return $html; + $dep_field = vs01_h($field['depends_on']['field']); + $dep_value = $field['depends_on']['value']; + $dep_value = is_array($dep_value) + ? vs01_h(json_encode($dep_value)) + : vs01_h((string) $dep_value); + return ''; +} + +// ---------------------------------------------------------------------------- +// DIAGNOSTIC FLAGS +// ---------------------------------------------------------------------------- + +function vs01_render_diagnostic_flag($field, $value) { + if (empty($field['diagnostic_flag'])) return ''; + $condition = $field['flag_condition'] ?? null; + if ($condition === null) return ''; + + $triggered = false; + if (is_array($condition)) { + $triggered = in_array($value, $condition); + } elseif (is_bool($condition)) { + $triggered = ($value === ($condition ? 'true' : 'false')); + } else { + $triggered = ($value === (string) $condition); + } + + if (!$triggered) return ''; + + $severity = vs01_flag_severity($field); + $label = vs01_h($field['flag_label'] ?? 'Diagnostic condition'); + $note = isset($field['flag_note']) + ? '

' . vs01_h($field['flag_note']) . '

' + : ''; + + return ''; +} + +function vs01_flag_severity($field) { + $id = $field['id'] ?? ''; + if (in_array($id, ['pro_selfdealing_verified', 'pro_standing_verified', 'pro_incumbent_perpetuation_risk'])) { + return 'critical'; + } + if (str_starts_with($id, 'pro_')) return 'high'; + return 'standard'; +} + +// ---------------------------------------------------------------------------- +// COMPOUND CONDITION BANNERS +// ---------------------------------------------------------------------------- + +function vs01_render_compound_banners($association_slug) { + // TODO: query stored VS records for this association from orchestrator + // and evaluate the three compound conditions defined in VS-RENDERER-SPEC.md. + // Returns empty string until orchestrator query is implemented. + return ''; +} + +// ---------------------------------------------------------------------------- +// CROSS-VS REFERENCE NOTES +// ---------------------------------------------------------------------------- + +function vs01_render_cross_reference($vs_code, $label, $association_slug) { + $url = z_root() . '/vs01/' . vs01_h($association_slug) . '/' . vs01_h($vs_code); + return '
+ See also: ' . vs01_h($label) . ' +
'; +} + +// ---------------------------------------------------------------------------- +// VERIFICATION SOURCES PANEL +// ---------------------------------------------------------------------------- + +function vs01_render_verification_sources($meta) { + $sources = $meta['verification_sources'] ?? []; + if (empty($sources)) return ''; + $code = vs01_h($meta['code'] ?? 'vs'); + $out = '
'; + $out .= ''; + $out .= '
'; + foreach ($sources as $source) { + $label = vs01_h($source['label'] ?? ''); + $url = vs01_h($source['url'] ?? ''); + $instruction = vs01_h($source['instruction'] ?? ''); + $out .= '
'; + $out .= '' . $label . ''; + $out .= '

' . $instruction . '

'; + $out .= '
'; + } + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// PERSPECTIVE NOTE (VS-07 and VS-09 homeowner slot) +// ---------------------------------------------------------------------------- + +function vs01_render_perspective_note($schema, $perspective_key) { + if ($perspective_key !== 'homeowner') return ''; + $code = $schema['_meta']['code'] ?? ''; + if (!in_array($code, ['VS-07', 'VS-09'])) return ''; + $notes = $schema['_meta']['renderer_notes'] ?? ''; + if (!$notes) return ''; + return '
' + . vs01_h($notes) . '
'; +} + +// ---------------------------------------------------------------------------- +// IMMUTABLE PUBLIC RECORD +// ---------------------------------------------------------------------------- + +function vs01_public_record_exists($vs_code, $association_slug) { + // TODO: query orchestrator for existing promoted public record + return false; +} + +function vs01_render_public_record_readonly($perspective, $values) { + $out = '
'; + foreach ($perspective['fields'] ?? [] as $field) { + $id = vs01_h($field['id'] ?? ''); + $label = vs01_h($field['label'] ?? ''); + $value = vs01_h($values[$field['id'] ?? ''] ?? '—'); + $out .= '
'; + $out .= '' . $label . ' '; + $out .= '' . $value . ''; + $out .= '
'; + } + $out .= '
'; + return $out; +} diff --git a/hubzilla/addon/vs01/vs01_spool.php b/hubzilla/addon/vs01/vs01_spool.php new file mode 100644 index 0000000..877eb9d --- /dev/null +++ b/hubzilla/addon/vs01/vs01_spool.php @@ -0,0 +1,233 @@ +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:
'; + // Re-render form with submitted values and errors + $out .= vs01_render_vs_form_with_values($association_slug, $vs_code, $access, $fields); + return $out; + } + + // Build 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'] ?? '' + ); + + // POST to orchestrator + $result = vs01_post_to_orchestrator($envelope); + + if ($result === true) { + // THROWAWAY — replace with TMP review redirect or confirmation page + return '
+
+ Your record for ' . vs01_h($vs_code) . ' has been submitted. + + 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 +// ---------------------------------------------------------------------------- + +function vs01_build_spool_envelope($vs_code, $perspective, $fields, $association_slug, $channel_id) { + return [ + 'addon' => 'vs01', + 'contract_version' => '1.0', + 'vs_code' => $vs_code, + 'perspective' => $perspective, + 'association_slug' => $association_slug, + 'association_channel_id' => (string) $channel_id, + 'submitted_at' => date('c'), + 'standing' => vs01_access_state($association_slug), + 'fields' => $fields, + ]; +} + +// ---------------------------------------------------------------------------- +// ORCHESTRATOR POST +// ---------------------------------------------------------------------------- + +function vs01_post_to_orchestrator($envelope) { + $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); + + 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; +}