status = $status; } } // Failure carries machine-readable protocol codes and an HTTP status; callers serialize them through JSON/WP_Error, never as HTML. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This throws a protocol exception; it does not emit output. function demand($condition, $code = 'invalid_value', $status = 422) { if (!$condition) throw new Failure($code, $status); } /** Strict JSON, preserving objects/arrays and rejecting ambiguous integer tokens. */ final class JsonParser { private $raw; private $i = 0; private $n; public function __construct($raw) { $this->raw = $raw; $this->n = strlen($raw); } private function space() { while ($this->i < $this->n && strpos(" \t\r\n", $this->raw[$this->i]) !== false) $this->i++; } private function stringValue() { $start = $this->i++; while ($this->i < $this->n) { $c = $this->raw[$this->i++]; if ($c === '\\') { $this->i++; continue; } if ($c === '"') { try { return json_decode(substr($this->raw, $start, $this->i - $start), false, 32, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { throw new Failure('invalid_json', 400); } } } throw new Failure('invalid_json', 400); } public function value($depth = 0) { demand($depth <= 32, 'json_depth', 400); $this->space(); $c = $this->raw[$this->i] ?? ''; if ($c === '"') return $this->stringValue(); if ($c === '{' || $c === '[') { $object = $c === '{'; $end = $object ? '}' : ']'; $this->i++; $value = $object ? new \stdClass() : []; $keys = []; $this->space(); if (($this->raw[$this->i] ?? '') === $end) { $this->i++; return $value; } while (true) { $this->space(); if ($object) { demand(($this->raw[$this->i] ?? '') === '"', 'invalid_json', 400); $key = $this->stringValue(); demand(!in_array($key, $keys, true), 'duplicate_json_key', 400); $keys[] = $key; $this->space(); demand(($this->raw[$this->i++] ?? '') === ':', 'invalid_json', 400); $value->{$key} = $this->value($depth + 1); } else $value[] = $this->value($depth + 1); $this->space(); $sep = $this->raw[$this->i++] ?? ''; if ($sep === $end) return $value; demand($sep === ',', 'invalid_json', 400); } } foreach (['true' => true, 'false' => false, 'null' => null] as $token => $value) { if (substr($this->raw, $this->i, strlen($token)) === $token) { $this->i += strlen($token); return $value; } } demand(preg_match('/\G(?:0|[1-9][0-9]*)/A', $this->raw, $m, 0, $this->i) === 1, 'invalid_json', 400); demand(strlen($m[0]) < 16 || (strlen($m[0]) === 16 && strcmp($m[0], '9007199254740991') <= 0), 'unsafe_integer', 400); $this->i += strlen($m[0]); return (int) $m[0]; } public function parse() { $v = $this->value(); $this->space(); demand($this->i === $this->n, 'invalid_json', 400); return $v; } } function parse_json($raw) { demand(is_string($raw) && strlen($raw) <= 12582912, 'body_limit', 413); demand(preg_match('//u', $raw) === 1, 'invalid_json', 400); $v = (new JsonParser($raw))->parse(); demand($v instanceof \stdClass, 'object_required', 400); return $v; } function canonical($v) { if ($v instanceof \stdClass) { $keys = array_keys(get_object_vars($v)); usort($keys, function ($a, $b) { return strcmp(iconv('UTF-8', 'UTF-16BE', (string) $a), iconv('UTF-8', 'UTF-16BE', (string) $b)); }); $parts = []; foreach ($keys as $key) $parts[] = canonical((string) $key) . ':' . canonical($v->{$key}); return '{' . implode(',', $parts) . '}'; } if (is_array($v)) { demand(array_is_list($v), 'object_required'); return '[' . implode(',', array_map(__NAMESPACE__ . '\\canonical', $v)) . ']'; } demand(is_string($v) || is_bool($v) || $v === null || (is_int($v) && $v >= 0 && $v <= 9007199254740991)); try { return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR); } catch (\Throwable $e) { throw new Failure('invalid_json', 400); } } function obj($values) { return (object) $values; } function pick($v, $keys) { $out = new \stdClass(); foreach ($keys as $key) { demand(property_exists($v, $key)); $out->{$key} = $v->{$key}; } return $out; } function semantic_hash($v) { return hash('sha256', canonical($v)); } function utc_seconds($text) { demand(is_string($text), 'invalid_time'); $d = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s.v\Z', $text, new \DateTimeZone('UTC')); demand($d && $d->format('Y-m-d\TH:i:s.v\Z') === $text && substr($text, 0, 4) !== '0000', 'invalid_time'); return (float) $d->format('U.v'); } function utc_now() { return gmdate('Y-m-d\TH:i:s') . '.000Z'; } function schema($definition, $value) { static $root; if (!$root) $root = json_decode(file_get_contents(__DIR__ . '/connector-v1.json'), true, 64, JSON_THROW_ON_ERROR); demand(isset($root['$defs'][$definition]), 'schema_unavailable', 503); validate_schema($root['$defs'][$definition], $value, $root); return $value; } function validate_schema($s, $v, $root) { if (isset($s['$ref'])) { validate_schema($root['$defs'][substr($s['$ref'], 8)], $v, $root); return; } foreach (['anyOf', 'oneOf'] as $union) if (isset($s[$union])) { $count = 0; foreach ($s[$union] as $branch) { try { validate_schema($branch, $v, $root); $count++; } catch (Failure $e) {} } demand($union === 'oneOf' ? $count === 1 : $count >= 1); } foreach ($s['allOf'] ?? [] as $branch) validate_schema($branch, $v, $root); if (isset($s['if'])) { try { validate_schema($s['if'], $v, $root); $yes = true; } catch (Failure $e) { $yes = false; } if (isset($s[$yes ? 'then' : 'else'])) validate_schema($s[$yes ? 'then' : 'else'], $v, $root); } if (array_key_exists('const', $s)) demand($v === $s['const']); if (isset($s['enum'])) demand(in_array($v, $s['enum'], true)); if (isset($s['type'])) { $valid = ['object' => $v instanceof \stdClass, 'array' => is_array($v), 'string' => is_string($v), 'integer' => is_int($v), 'null' => $v === null, 'boolean' => is_bool($v)]; demand($valid[$s['type']] ?? false); } if ($v instanceof \stdClass) { foreach ($s['required'] ?? [] as $key) demand(property_exists($v, $key)); foreach (get_object_vars($v) as $key => $item) { if (($s['additionalProperties'] ?? null) === false) demand(array_key_exists($key, $s['properties'] ?? [])); if (isset($s['properties'][$key])) validate_schema($s['properties'][$key], $item, $root); } } if (is_array($v)) { demand(count($v) >= ($s['minItems'] ?? 0) && count($v) <= ($s['maxItems'] ?? PHP_INT_MAX)); if ($s['uniqueItems'] ?? false) demand(count(array_unique(array_map(__NAMESPACE__ . '\\canonical', $v))) === count($v)); if (isset($s['items'])) foreach ($v as $item) validate_schema($s['items'], $item, $root); } if (is_string($v)) { $len = mb_strlen($v, 'UTF-8'); demand($len >= ($s['minLength'] ?? 0) && $len <= ($s['maxLength'] ?? PHP_INT_MAX)); if (isset($s['pattern'])) { if ($s['pattern'] === '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$') { $prefix = strspn($v, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'); $tail = substr($v, $prefix); demand(strlen($v) % 4 === 0 && in_array($tail, ['', '=', '=='], true)); } else demand(preg_match('~' . str_replace('~', '\\~', $s['pattern']) . '~uD', $v) === 1); } if (($s['format'] ?? '') === 'publisher-utc') utc_seconds($v); } if (is_int($v)) demand($v >= ($s['minimum'] ?? 0) && $v <= ($s['maximum'] ?? 9007199254740991)); } function effect_hashes($body) { $payload = $body->operation === 'media.upload' ? $body->media : $body->publication; $subject = semantic_hash(obj(['operation' => $body->operation, 'payload' => $payload])); $target = semantic_hash(pick($body, ['tenant_id', 'site_id', 'installation_id', 'protocol_version', 'policy_hash', 'operation', 'subject_hash'])); $key = semantic_hash(pick($body, ['tenant_id', 'site_id', 'installation_id', 'effect_id', 'operation', 'subject_hash', 'target_fingerprint', 'policy_hash'])); $exec = pick($body, ['protocol_version', 'tenant_id', 'site_id', 'installation_id', 'effect_id', 'operation', 'policy_hash', 'subject_hash', 'target_fingerprint']); $exec->payload = $payload; return ['subject_hash' => $subject, 'target_fingerprint' => $target, 'idempotency_key' => $key, 'execution_fingerprint' => semantic_hash($exec)]; } function signing_input($binding, $method, $route, $time, $nonce, $type, $hash, $key, $domain = 'publisher-request-v1', $base = null) { return implode("\n", [$domain, $method, $route, $base ?? $binding->api_base, $binding->tenant_id, $binding->site_id, $binding->installation_id, $binding->connection_id, $binding->key_id, (string) $time, $nonce, $type, $hash, $key]); } function sign_headers($binding, $secret, $method, $route, $raw, $key, $domain = 'publisher-request-v1', $base = null) { $headers = []; foreach (['Tenant', 'Site', 'Installation', 'Connection', 'Key'] as $id) $headers['X-Publisher-' . $id] = $binding->{strtolower($id) . '_id'}; $time = (string) time(); $nonce = bin2hex(random_bytes(16)); $hash = hash('sha256', $raw); $type = $method === 'POST' ? 'application/json' : '-'; $headers += ['X-Publisher-Time' => $time, 'X-Publisher-Nonce' => $nonce, 'X-Publisher-Body-SHA256' => $hash, 'X-Publisher-Idempotency' => $key, 'X-Publisher-Signature' => hash_hmac('sha256', signing_input($binding, $method, $route, $time, $nonce, $type, $hash, $key, $domain, $base), $secret)]; if ($method === 'POST') $headers['Content-Type'] = 'application/json'; return $headers; }