Initial push

This commit is contained in:
2026-06-08 02:08:51 -04:00
parent e540094df2
commit c6fdd38612
4 changed files with 770 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
<?php
/**
* Name: CRY01 Value Layer
* Description: Ğ1 balance display, capacity signal registration, and Ğ1 web of trust bridge.
* Version: 0.1.0
* MinVersion: 11.0
* MaxVersion: 12.0
*/
use Zotlabs\Extend\Widget;
require_once 'addon/cry01/cry01_chain.php';
require_once 'addon/cry01/cry01_renderer.php';
require_once 'addon/cry01/cry01_spool.php';
function cry01_module() {}
function cry01_load() {
register_hook('load_pdl', 'addon/cry01/cry01.php', 'cry01_load_pdl');
Widget::register('addon/cry01/Widget/Cry01.php', 'cry01');
}
function cry01_unload() {
unregister_hook('load_pdl', 'addon/cry01/cry01.php', 'cry01_load_pdl');
Widget::unregister('addon/cry01/Widget/Cry01.php', 'cry01');
}
function cry01_load_pdl(&$b) {
// Loads the cry01 PDL layout for cry01 module pages.
if (!is_array($b) || empty($b['module']) || $b['module'] !== 'cry01') {
return;
}
$layout = @file_get_contents('addon/cry01/mod_cry01.pdl');
if ($layout !== false) {
$b['layout'] = $layout;
}
}
// ---------------------------------------------------------------------------
// HELPERS
// ---------------------------------------------------------------------------
function cry01_h($value) {
// HTML-escapes a value for safe output.
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
// ---------------------------------------------------------------------------
// ACCESS
// ---------------------------------------------------------------------------
function cry01_access_state($association_slug = '') {
// Returns operator, participant, or public. Does not call local_channel() for group checks.
if (local_channel()) {
$channel = App::get_channel();
if (local_channel() === intval($channel['channel_id'])) {
return 'operator';
}
}
if (!$association_slug) return 'public';
$raw = @file_get_contents('addon/vs01/config.json');
if ($raw === false) return 'public';
$cfg = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) return 'public';
$assoc = $cfg['associations'][$association_slug] ?? null;
if (!$assoc) return 'public';
$observer = get_observer_hash();
if (!$observer) return 'public';
$groups = $assoc['groups'] ?? [];
// Direct pgrp_member query — works for guest tokens.
foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) {
$gid = intval($groups[$group_key] ?? 0);
if ($gid) {
$r = q("SELECT xchan FROM pgrp_member WHERE gid = %d AND xchan = '%s' LIMIT 1",
intval($gid),
dbesc($observer)
);
if ($r) return 'participant';
}
}
return 'public';
}
function cry01_access_wall($association_slug = '') {
// Renders the access wall for non-participants attempting to submit a signal.
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$assoc = $association_slug ? ($cfg['associations'][$association_slug] ?? null) : null;
$name = $assoc ? cry01_h($assoc['name']) : 'this association';
return '
<div class="cry01-content">
<div class="alert alert-info" role="alert">
<strong>HOA_MEMBER standing required to register a signal for ' . $name . '.</strong>
The signal board is readable by verified participants only.
To participate, you must complete the SASE process.
Visit <a href="https://directory.diagnostics.kane-il.us/channel/theron">
directory.diagnostics.kane-il.us
</a> to begin.
</div>
</div>
';
}
// ---------------------------------------------------------------------------
// CONTENT ROUTER
// ---------------------------------------------------------------------------
function cry01_content() {
if (function_exists('head_add_css')) {
head_add_css('/addon/cry01/view/css/cry01.css');
}
if (function_exists('head_add_js')) {
head_add_js('/addon/cry01/view/js/cry01.js');
}
$association_slug = argv(1) ?? '';
$sub_route = strtolower(argv(2) ?? '');
if (!$association_slug) {
return cry01_render_index();
}
$raw = @file_get_contents('addon/vs01/config.json');
if ($raw === false) return cry01_render_error('Configuration unavailable. Contact the operator.');
$cfg = json_decode($raw, true);
if (!isset($cfg['associations'][$association_slug])) {
return cry01_render_not_found();
}
$access = cry01_access_state($association_slug);
switch ($sub_route) {
case 'signal':
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($access === 'public') return cry01_access_wall($association_slug);
if (!cry01_verify_csrf()) {
return cry01_render_error('Invalid form token. Please reload and try again.');
}
return cry01_handle_signal_post($association_slug, $access);
}
if ($access === 'public') return cry01_access_wall($association_slug);
return cry01_render_signal_form($association_slug, $access);
case 'g1':
if ($access !== 'operator') return cry01_access_wall($association_slug);
return cry01_render_g1_candidates($association_slug);
case 'manage':
if ($access !== 'operator') return cry01_access_wall($association_slug);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
return cry01_handle_manage_post($association_slug);
}
return cry01_render_manage($association_slug);
default:
return cry01_render_landing($association_slug, $access);
}
}
// ---------------------------------------------------------------------------
// RENDER — INDEX
// ---------------------------------------------------------------------------
function cry01_render_index() {
// Lists all registered associations with links to their value layer pages.
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$list = $cfg['associations'] ?? [];
if (empty($list)) {
return '<div class="cry01-content"><p class="text-muted">No associations registered.</p></div>';
}
$out = '<div class="cry01-content">';
$out .= '<h2>Value Layer</h2>';
$out .= '<ul class="list-group">';
foreach ($list as $slug => $assoc) {
$name = cry01_h($assoc['name'] ?? $slug);
$url = z_root() . '/cry01/' . cry01_h($slug);
$out .= '<li class="list-group-item"><a href="' . $url . '">' . $name . '</a></li>';
}
$out .= '</ul></div>';
return $out;
}
// ---------------------------------------------------------------------------
// RENDER — NOT FOUND / ERROR
// ---------------------------------------------------------------------------
function cry01_render_not_found() {
return '<div class="cry01-content"><div class="alert alert-warning">Association not found.</div></div>';
}
function cry01_render_error($message) {
// Shows a plain-language error message. Never shows a blank page or stack trace.
return '<div class="cry01-content"><div class="alert alert-danger">' . cry01_h($message) . '</div></div>';
}
// ---------------------------------------------------------------------------
// CSRF
// ---------------------------------------------------------------------------
function cry01_csrf_token() {
// Generates and stores a CSRF token for the current session.
if (empty($_SESSION['cry01_csrf'])) {
$_SESSION['cry01_csrf'] = bin2hex(random_bytes(16));
}
return '<input type="hidden" name="cry01_csrf" value="'
. cry01_h($_SESSION['cry01_csrf']) . '">';
}
function cry01_verify_csrf() {
// Returns true if the CSRF token in POST matches the session token.
return isset($_POST['cry01_csrf'], $_SESSION['cry01_csrf'])
&& hash_equals($_SESSION['cry01_csrf'], $_POST['cry01_csrf']);
}

View File

@@ -0,0 +1,156 @@
<?php
/**
* cry01_chain.php — On-chain read layer.
* Reads Ğ1 balances from the local Duniter node RPC over Wireguard.
* Reads and writes the local balance cache.
* This is the only file in the project that makes outbound network calls.
* If the node infrastructure changes, only this file changes.
*/
// ---------------------------------------------------------------------------
// BALANCE READ
// ---------------------------------------------------------------------------
function cry01_get_balance($g1_pubkey) {
// Returns the current Ğ1 balance for the given public key.
// Queries the local Duniter node RPC. Returns null on failure.
$config = cry01_load_config();
$rpc = $config['g1_rpc_endpoint'] ?? '';
if (!$rpc || !$g1_pubkey) return null;
$payload = json_encode([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'state_getStorage',
'params' => [cry01_balance_storage_key($g1_pubkey)],
]);
$result = cry01_rpc_post($rpc, $payload);
if ($result === null) return null;
return cry01_decode_balance($result);
}
function cry01_balance_storage_key($g1_pubkey) {
// Builds the Substrate storage key for the account balance of the given Ğ1 public key.
// The key format is defined by the Duniter v2 runtime (pallet_balances storage map).
// Placeholder — requires Substrate storage key encoding (xxHash + Blake2).
// TODO: implement full storage key derivation or use duniter-specific RPC method.
return '0x' . bin2hex($g1_pubkey);
}
function cry01_decode_balance($rpc_result) {
// Decodes the raw RPC result into a human-readable Ğ1 balance string.
// Duniter v2 balances are stored as u64 encoded in SCALE codec.
// Returns formatted balance string or null if decoding fails.
$hex = $rpc_result['result'] ?? null;
if (!$hex || $hex === '0x') return '0';
// Remove 0x prefix and decode little-endian u64.
$hex = ltrim($hex, '0x');
$bytes = array_reverse(str_split(str_pad($hex, 16, '0', STR_PAD_LEFT), 2));
$val = 0;
foreach ($bytes as $b) {
$val = ($val << 8) | hexdec($b);
}
// Duniter uses centimes (hundredths of Ğ1). Divide by 100.
return number_format($val / 100, 2) . ' Ğ1';
}
// ---------------------------------------------------------------------------
// RPC TRANSPORT
// ---------------------------------------------------------------------------
function cry01_rpc_post($endpoint, $payload) {
// POSTs a JSON-RPC request to the Duniter node. Returns decoded response or null.
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $payload,
'timeout' => 5,
],
]);
$raw = @file_get_contents($endpoint, false, $context);
if ($raw === false) {
logger('cry01_chain: RPC call failed to ' . $endpoint);
return null;
}
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
logger('cry01_chain: RPC response is not valid JSON');
return null;
}
return $data;
}
// ---------------------------------------------------------------------------
// CACHE
// ---------------------------------------------------------------------------
function cry01_read_cache() {
// Returns the balance cache array. Returns empty array if cache does not exist or is unreadable.
$config = cry01_load_config();
$path = $config['cache_file'] ?? '';
if (!$path) return [];
$raw = @file_get_contents($path);
if ($raw === false) return [];
$data = json_decode($raw, true);
return (json_last_error() === JSON_ERROR_NONE) ? $data : [];
}
function cry01_write_cache($data) {
// Writes the balance cache to disk. Returns true on success, false on failure.
$config = cry01_load_config();
$path = $config['cache_file'] ?? '';
if (!$path) return false;
$tmp = $path . '.tmp';
$ok = @file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT));
if ($ok === false) {
logger('cry01_chain: could not write cache to ' . $tmp);
return false;
}
return @rename($tmp, $path);
}
function cry01_cache_is_stale() {
// Returns true if the cache is older than cache_max_age_seconds or does not exist.
$config = cry01_load_config();
$path = $config['cache_file'] ?? '';
$max_age = intval($config['cache_max_age_seconds'] ?? 3600);
if (!$path || !file_exists($path)) return true;
return (time() - filemtime($path)) > $max_age;
}
function cry01_refresh_balance_cache() {
// Refreshes the balance cache by querying the Duniter node.
// Writes updated cache to disk. Called by the manage route.
$config = cry01_load_config();
$pubkey = $config['operator_g1_pubkey'] ?? '';
if (!$pubkey) {
logger('cry01_chain: operator_g1_pubkey not set in config');
return false;
}
$balance = cry01_get_balance($pubkey);
if ($balance === null) return false;
$cache = cry01_read_cache();
$cache['operator_balance'] = $balance;
$cache['operator_g1_pubkey'] = $pubkey;
$cache['refreshed_at'] = date('c');
return cry01_write_cache($cache);
}
// ---------------------------------------------------------------------------
// CONFIG LOADER
// ---------------------------------------------------------------------------
function cry01_load_config() {
// Loads cry01 config.json from the addon directory. Returns empty array on failure.
static $cfg = null;
if ($cfg !== null) return $cfg;
$raw = @file_get_contents('addon/cry01/config.json');
if ($raw === false) return [];
$cfg = json_decode($raw, true);
return (json_last_error() === JSON_ERROR_NONE) ? $cfg : [];
}

View File

@@ -0,0 +1,245 @@
<?php
/**
* cry01_renderer.php — All HTML rendering for cry01.
* Knows nothing about network calls or POST handling.
* Reads config and cache via cry01_chain.php functions.
*/
// ---------------------------------------------------------------------------
// ASSOCIATION LANDING
// ---------------------------------------------------------------------------
function cry01_render_landing($association_slug, $access) {
// Renders the main cry01 page: balance display + signal board.
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$assoc = $cfg['associations'][$association_slug] ?? [];
$name = cry01_h($assoc['name'] ?? $association_slug);
$out = '<div class="cry01-content">';
$out .= '<div class="cry01-header mb-3">';
$out .= '<h2>' . $name . '</h2>';
$out .= '<p class="text-muted">Value Layer — Ğ1 balance and capacity signals.</p>';
$out .= '</div>';
$out .= cry01_render_balance_display();
$out .= cry01_render_signal_board($association_slug, $access);
if ($access === 'participant') {
$signal_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal';
$out .= '<div class="mt-3">';
$out .= '<a href="' . $signal_url . '" class="btn btn-outline-primary btn-sm">Register a Signal</a>';
$out .= '</div>';
}
if ($access === 'operator') {
$manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage';
$g1_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/g1';
$out .= '<div class="mt-3 d-flex gap-2">';
$out .= '<a href="' . $manage_url . '" class="btn btn-sm btn-outline-secondary">Manage</a>';
$out .= '<a href="' . $g1_url . '" class="btn btn-sm btn-outline-secondary">Ğ1 Candidates</a>';
$out .= '</div>';
}
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// BALANCE DISPLAY
// ---------------------------------------------------------------------------
function cry01_render_balance_display() {
// Renders the operator Ğ1 balance from cache. Shows staleness if cache is old.
$cache = cry01_read_cache();
$balance = $cache['operator_balance'] ?? null;
$pubkey = $cache['operator_g1_pubkey'] ?? '';
$refreshed = $cache['refreshed_at'] ?? null;
$out = '<div class="cry01-balance-display mb-4">';
$out .= '<h5 class="cry01-section-label">Operator Ğ1 Balance</h5>';
if ($balance === null) {
$out .= '<p class="text-muted fst-italic">Balance not yet loaded. Operator: use Manage to refresh.</p>';
} else {
$out .= '<p class="cry01-balance-amount">' . cry01_h($balance) . '</p>';
if ($pubkey) {
$out .= '<p class="text-muted small">' . cry01_h(substr($pubkey, 0, 16) . '...') . '</p>';
}
if ($refreshed) {
$stale = cry01_cache_is_stale() ? ' <span class="badge bg-warning text-dark">stale</span>' : '';
$out .= '<p class="text-muted small">Last refreshed: ' . cry01_h($refreshed) . $stale . '</p>';
}
}
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// SIGNAL BOARD
// ---------------------------------------------------------------------------
function cry01_render_signal_board($association_slug, $access) {
// Renders the signal board. Visible to participants and operator only.
if ($access === 'public') {
return '<div class="alert alert-info">Signal board is visible to verified participants only.</div>';
}
// TODO: load signals from orchestrator spool for this association.
// Placeholder until orchestrator query is implemented.
$out = '<div class="cry01-signal-board mb-4">';
$out .= '<h5 class="cry01-section-label">Capacity Signals</h5>';
$out .= '<p class="text-muted fst-italic">No signals posted yet.</p>';
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// SIGNAL FORM
// ---------------------------------------------------------------------------
function cry01_render_signal_form($association_slug, $access) {
// Renders the capacity signal registration form.
$config = cry01_load_config();
$categories = $config['signal_categories'] ?? [];
$form_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal';
$out = '<div class="cry01-content">';
$out .= '<h3>Register a Capacity Signal</h3>';
$out .= '<p class="text-muted">Describe what you are offering or seeking, denominated in Ğ1.</p>';
$out .= '<form method="post" action="' . $form_url . '" class="cry01-form" novalidate>';
$out .= cry01_csrf_token();
// Ğ1 public key
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="g1_pubkey">Your Ğ1 Public Key <span class="text-danger">*</span></label>';
$out .= '<input type="text" class="form-control" id="g1_pubkey" name="g1_pubkey" required
placeholder="G1..." maxlength="64">';
$out .= '<div class="form-text">Your Ğ1 address. This is your payment address for this signal.</div>';
$out .= '</div>';
// Direction
$out .= '<div class="mb-3">';
$out .= '<fieldset><legend class="form-label">Direction <span class="text-danger">*</span></legend>';
$out .= '<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" id="dir_offer" name="direction" value="offer" required>
<label class="form-check-label" for="dir_offer">Offering capacity</label>
</div>';
$out .= '<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" id="dir_seek" name="direction" value="seek">
<label class="form-check-label" for="dir_seek">Seeking capacity</label>
</div>';
$out .= '</fieldset></div>';
// Category
if ($categories) {
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="category_id">Category <span class="text-danger">*</span></label>';
$out .= '<select class="form-select" id="category_id" name="category_id" required>';
$out .= '<option value="">— select —</option>';
foreach ($categories as $cat) {
$v = cry01_h($cat['id'] ?? '');
$l = cry01_h($cat['label'] ?? $v);
$out .= '<option value="' . $v . '">' . $l . '</option>';
}
$out .= '</select></div>';
}
// Description
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="capacity_description">Description <span class="text-danger">*</span></label>';
$out .= '<textarea class="form-control" id="capacity_description" name="capacity_description"
rows="4" required
placeholder="Describe what you are offering or seeking in plain language."></textarea>';
$out .= '</div>';
// Denomination
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="g1_denomination">Ğ1 Amount <span class="text-danger">*</span></label>';
$out .= '<input type="number" class="form-control" id="g1_denomination" name="g1_denomination"
required min="1" placeholder="e.g. 100">';
$out .= '<div class="form-text">The amount in Ğ1 you consider fair exchange.</div>';
$out .= '</div>';
// Duration
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="duration_days">Duration (days) <span class="text-danger">*</span></label>';
$out .= '<input type="number" class="form-control" id="duration_days" name="duration_days"
required min="1" max="365" placeholder="e.g. 14">';
$out .= '</div>';
$out .= '<div class="mt-3">';
$out .= '<button type="submit" class="btn btn-primary">Register Signal</button>';
$out .= '</div>';
$out .= '</form>';
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// Ğ1 CERTIFICATION CANDIDATE LIST
// ---------------------------------------------------------------------------
function cry01_render_g1_candidates($association_slug) {
// Renders the operator-only list of SASE participants with registered Ğ1 keys.
// TODO: load candidate list from orchestrator spool.
$out = '<div class="cry01-content">';
$out .= '<h3>Ğ1 Certification Candidates</h3>';
$out .= '<p class="text-muted">SASE participants who have registered a Ğ1 public key and are eligible for web of trust certification.</p>';
$out .= '<p class="text-muted fst-italic">No candidates registered yet.</p>';
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// MANAGE
// ---------------------------------------------------------------------------
function cry01_render_manage($association_slug) {
// Renders the operator manage page: cache refresh and config status.
$cache = cry01_read_cache();
$refreshed = $cache['refreshed_at'] ?? null;
$config = cry01_load_config();
$has_rpc = !empty($config['g1_rpc_endpoint']);
$has_key = !empty($config['operator_g1_pubkey']);
$manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage';
$out = '<div class="cry01-content">';
$out .= '<h3>Value Layer — Manage</h3>';
// Config status
$out .= '<div class="mb-3">';
$out .= '<h5>Configuration</h5>';
$out .= '<p>' . ($has_rpc ? '✓' : '✗') . ' Duniter RPC endpoint: ';
$out .= $has_rpc ? '<span class="text-success">configured</span>' : '<span class="text-danger">missing</span>';
$out .= '</p>';
$out .= '<p>' . ($has_key ? '✓' : '✗') . ' Operator Ğ1 public key: ';
$out .= $has_key ? '<span class="text-success">configured</span>' : '<span class="text-danger">missing</span>';
$out .= '</p>';
$out .= '</div>';
// Cache status
$out .= '<div class="mb-3">';
$out .= '<h5>Balance Cache</h5>';
if ($refreshed) {
$stale = cry01_cache_is_stale() ? ' (stale)' : ' (current)';
$out .= '<p>Last refreshed: ' . cry01_h($refreshed) . $stale . '</p>';
} else {
$out .= '<p class="text-muted">Cache has not been populated yet.</p>';
}
$out .= '</div>';
// Refresh form
$out .= '<form method="post" action="' . $manage_url . '">';
$out .= cry01_csrf_token();
$out .= '<input type="hidden" name="action" value="refresh_cache">';
$out .= '<button type="submit" class="btn btn-sm btn-outline-primary">Refresh Balance Cache</button>';
$out .= '</form>';
$out .= '</div>';
return $out;
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* cry01_spool.php — POST handlers.
* Validates input, builds spool envelope, POSTs to orchestrator.
* Does not render HTML — returns rendered output from cry01_renderer.php.
*/
// ---------------------------------------------------------------------------
// SIGNAL POST HANDLER
// ---------------------------------------------------------------------------
function cry01_handle_signal_post($association_slug, $access) {
// Validates and submits a capacity signal to the orchestrator spool receiver.
$g1_pubkey = trim($_POST['g1_pubkey'] ?? '');
$direction = trim($_POST['direction'] ?? '');
$category_id = trim($_POST['category_id'] ?? '');
$capacity_description = trim($_POST['capacity_description'] ?? '');
$g1_denomination = trim($_POST['g1_denomination'] ?? '');
$duration_days = intval($_POST['duration_days'] ?? 0);
// Validate required fields.
$errors = [];
if (!$g1_pubkey) $errors[] = 'Ğ1 public key is required.';
if (!in_array($direction, ['offer', 'seek'])) $errors[] = 'Direction must be offer or seek.';
if (!$category_id) $errors[] = 'Category is required.';
if (!$capacity_description) $errors[] = 'Description is required.';
if (!$g1_denomination || !is_numeric($g1_denomination) || floatval($g1_denomination) <= 0) {
$errors[] = 'Ğ1 amount must be a positive number.';
}
if ($duration_days < 1 || $duration_days > 365) {
$errors[] = 'Duration must be between 1 and 365 days.';
}
if ($errors) {
return '<div class="cry01-content"><div class="alert alert-danger"><ul class="mb-0">'
. implode('', array_map(fn($e) => '<li>' . cry01_h($e) . '</li>', $errors))
. '</ul></div></div>'
. cry01_render_signal_form($association_slug, $access);
}
$config = cry01_load_config();
$token = $config['node_token'] ?? '';
// Build the spool envelope per contracts/signal-v1.json.
$envelope = [
'_header' => [
'addon' => 'cry01',
'association_slug' => $association_slug,
'participant_xchan' => get_observer_hash(),
'submitted_at' => date('c'),
'node_token_hash' => hash('sha256', $token),
],
'_payload' => [
'g1_pubkey' => $g1_pubkey,
'capacity_description' => $capacity_description,
'g1_denomination' => $g1_denomination,
'duration_days' => $duration_days,
'category_id' => $category_id,
'direction' => $direction,
],
];
$result = cry01_post_to_orchestrator('/cry01/signal', $envelope);
if ($result === true) {
return '<div class="cry01-content">'
. '<div class="alert alert-success">Signal registered successfully. '
. 'A Verifiable Credential will be issued and timestamped to Bitcoin.</div>'
. '<p><a href="' . z_root() . '/cry01/' . cry01_h($association_slug) . '">&larr; Back to Value Layer</a></p>'
. '</div>';
}
return '<div class="cry01-content">'
. '<div class="alert alert-danger">Signal could not be registered. '
. 'The orchestrator may be temporarily unavailable. Please try again.</div>'
. '</div>'
. cry01_render_signal_form($association_slug, $access);
}
// ---------------------------------------------------------------------------
// MANAGE POST HANDLER
// ---------------------------------------------------------------------------
function cry01_handle_manage_post($association_slug) {
// Handles operator manage actions: currently only cache refresh.
if (!cry01_verify_csrf()) {
return cry01_render_error('Invalid form token. Please reload and try again.');
}
$action = $_POST['action'] ?? '';
if ($action === 'refresh_cache') {
$ok = cry01_refresh_balance_cache();
$msg = $ok
? '<div class="alert alert-success">Balance cache refreshed successfully.</div>'
: '<div class="alert alert-warning">Cache refresh failed. Check that the Duniter node is reachable and the operator Ğ1 key is configured.</div>';
return '<div class="cry01-content">' . $msg . '</div>' . cry01_render_manage($association_slug);
}
return cry01_render_error('Unknown action.');
}
// ---------------------------------------------------------------------------
// ORCHESTRATOR TRANSPORT
// ---------------------------------------------------------------------------
function cry01_post_to_orchestrator($path, $payload) {
// POSTs a JSON envelope to the orchestrator spool receiver.
// Returns true on success (HTTP 201), false on any failure.
$config = cry01_load_config();
$base = rtrim($config['receiver_url'] ?? '', '/');
if (!$base) {
logger('cry01_spool: receiver_url not configured');
return false;
}
$url = $base . $path;
$body = json_encode($payload);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n",
'content' => $body,
'timeout' => 10,
'ignore_errors' => true,
],
]);
$raw = @file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];
$status = 0;
foreach ($headers as $h) {
if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $h, $m)) {
$status = intval($m[1]);
}
}
if ($status === 201) return true;
logger('cry01_spool: orchestrator returned status ' . $status . ' for ' . $path);
return false;
}