agent foundation

This commit is contained in:
2026-03-06 00:44:52 +01:00
parent 9819cba733
commit 9a08f96c11
199 changed files with 8522 additions and 1880 deletions

View File

@@ -2,6 +2,8 @@
namespace MintyPHP\Support;
// AES-256-GCM encryption/decryption for sensitive stored values (e.g. SSO secrets).
// Requires APP_CRYPTO_KEY — see EnvValidator for accepted key formats.
class Crypto
{
private const CIPHER = 'aes-256-gcm';
@@ -33,6 +35,8 @@ class Crypto
throw new \RuntimeException('Encryption failed');
}
// Outer base64 wraps a JSON object with v (version), iv, auth tag, and ciphertext —
// all inner binary values are base64url-encoded to stay JSON-safe.
$payload = json_encode([
'v' => 1,
'iv' => self::base64UrlEncode($iv),
@@ -94,6 +98,7 @@ class Crypto
return null;
}
// Accepts 64-char hex (32 bytes) or base64-encoded 32 bytes — both yield a 256-bit key.
$hex = preg_match('/^[0-9a-f]{64}$/i', $raw) ? hex2bin($raw) : false;
if (is_string($hex) && strlen($hex) === 32) {
return $hex;
@@ -107,6 +112,7 @@ class Crypto
return null;
}
// URL-safe base64: replaces +/ with -_ and strips = padding.
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');

View File

@@ -4,6 +4,7 @@ namespace MintyPHP\Support;
use MintyPHP\Session;
// Session-based flash message store. Messages are written before a redirect and consumed on the next page.
class Flash
{
private const SESSION_KEY = 'flash_messages';
@@ -19,6 +20,7 @@ class Flash
}
}
// If a key is given, any existing message with the same key+scope is replaced — prevents duplicate notices.
public static function add(string $type, string $message, ?string $scope = null, ?string $key = null): string
{
self::ensureSession();
@@ -93,6 +95,7 @@ class Flash
}
}
// Prevents messages from being cleared on the next render — useful when a redirect chain crosses multiple pages.
public static function keep(int $times = 1)
{
self::ensureSession();

View File

@@ -9,8 +9,11 @@ use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Tenant\TenantService;
// Session-based web auth guard. Enforces login and RBAC for page actions.
// For API token authentication, see ApiAuth instead.
class Guard
{
// Re-check tenant assignment at most once per minute to pick up permission changes without re-login.
private const TENANT_REFRESH_INTERVAL = 60;
/** @var (callable(): AuthService)|null */
private static $authServiceResolver = null;
@@ -56,7 +59,7 @@ class Guard
return;
}
// Current tenant was deleted, refresh session data
// Current tenant was deleted reload session so the user is switched to another active tenant or logged out.
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0) {
self::authService()->loadTenantDataIntoSession($userId);
@@ -106,6 +109,7 @@ class Guard
...$context,
]);
if (!$decision->isAllowed()) {
// JSON requests get a 403/404 response body; browser requests get a redirect to the forbidden page.
if (Request::wantsJson()) {
http_response_code($decision->status());
header('Content-Type: application/json; charset=utf-8');

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\Support\Search;
class SearchQueryNormalizer
{
// Prepares a user query for SQL LIKE. Escapes \ and _ (LIKE special chars),
// converts * to %, and wraps with % wildcards if not already present.
public static function normalizeLikeQuery(string $query): string
{
$query = trim($query);
@@ -25,6 +27,7 @@ class SearchQueryNormalizer
return $query;
}
// Strips wildcard chars for relevance scoring — score queries need a clean term, not a LIKE pattern.
public static function normalizeScoreQuery(string $query): string
{
$query = trim($query);

View File

@@ -28,6 +28,8 @@ class SearchSpecialResourceProvider
return $rows;
}
// Two-tier match: first checks against the hotkey service's keyword list (e.g. "shortcut", "keyboard"),
// then falls back to substring matching against individual entry labels/key combos.
private static function matchesHotkeyQueryWith(string $query, array $entries): bool
{
$query = trim(mb_strtolower($query));
@@ -222,6 +224,7 @@ class SearchSpecialResourceProvider
$descriptionParts[] = $snippet;
}
// Map raw doc scores (01000+) to coarser search_score buckets for result ranking.
$rawScore = (int) $match['score'];
$searchScore = match (true) {
$rawScore >= 1000 => 400,

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\Support\Search;
use MintyPHP\Service\Access\PermissionService;
// Defines all SQL-backed search resources. Each resource declares count/preview/result queries
// with a {{tenantFilter}} placeholder that is substituted at query time based on the user's tenant scope.
class SearchSqlResourceProvider
{
public static function tenantScopeFilters(): array
@@ -25,6 +27,7 @@ class SearchSqlResourceProvider
'key' => 'address-book',
'label' => t('Address book'),
'permission' => PermissionService::ADDRESS_BOOK_VIEW,
// escape '\\\\' — four backslashes: PHP reduces to two, SQL reduces to one literal backslash escape char.
'countSql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}",
'countParams' => [$like, $like, $like],
'previewSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?",

View File

@@ -42,7 +42,7 @@ function setAppContainer(\MintyPHP\App\AppContainer $container): void
\MintyPHP\Http\ApiBootstrap::configure(
static fn (): \MintyPHP\Service\Audit\ApiAuditService => $container->get(\MintyPHP\Service\Audit\ApiAuditService::class),
static fn (): \MintyPHP\Service\Settings\SettingGateway => $container->get(\MintyPHP\Service\Settings\SettingGateway::class),
static fn (): \MintyPHP\Service\Settings\SettingsApiPolicyGateway => $container->get(\MintyPHP\Service\Settings\SettingsApiPolicyGateway::class),
static fn (): \MintyPHP\Service\Security\RateLimiterService => $container->get(\MintyPHP\Service\Security\RateLimiterService::class),
static fn (): \MintyPHP\Http\ApiSystemAuditReporter => $container->get(\MintyPHP\Http\ApiSystemAuditReporter::class)
);
@@ -290,6 +290,66 @@ function allowRegistration(): bool
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
/**
* Feature flag: allow frontend telemetry events.
*/
function frontendTelemetryEnabled(): bool
{
$value = appSetting('frontend_telemetry_enabled');
if ($value === null || $value === '') {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
/**
* Frontend telemetry sample rate normalized to 0..1.
*/
function frontendTelemetrySampleRate(): float
{
$value = appSetting('frontend_telemetry_sample_rate');
if ($value === null || trim($value) === '') {
return 0.2;
}
$rate = is_numeric($value) ? (float) $value : NAN;
if (!is_finite($rate) || $rate < 0 || $rate > 1) {
return 0.2;
}
return $rate;
}
/**
* Allowed frontend telemetry events as canonical short keys.
*
* @return list<string>
*/
function frontendTelemetryAllowedEvents(): array
{
$allowed = ['warn_once', 'ajax_error'];
$value = appSetting('frontend_telemetry_allowed_events');
if ($value === null || trim($value) === '') {
return $allowed;
}
$items = preg_split('/[\s,]+/', strtolower(trim($value))) ?: [];
$normalized = array_values(array_unique(array_filter(
array_map(
static fn ($entry): string => trim((string) $entry),
$items
),
static fn ($entry): bool => in_array($entry, $allowed, true)
)));
if ($normalized === []) {
return $allowed;
}
sort($normalized, SORT_STRING);
return $normalized;
}
/**
* Resolve active primary color (tenant override -> app setting).
*/