build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers

Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
Keith Solomon committed 2026-10-11 16:45:45 -05:00
1 parent c2747a5762
commit d3e8cf8fae
39 files changed
+5291 -38

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace MeshVault\Config;
use InvalidArgumentException;
use JsonSerializable;
use SensitiveParameter;
final readonly class AppConfig implements JsonSerializable
{
private function __construct(
public string $environment,
public string $libraryPath,
public string $dbHost,
public int $dbPort,
public string $dbName,
public string $dbUser,
#[SensitiveParameter] public string $dbPassword,
public string $logLevel,
) {
}
/** Values are explicit strings, as returned by getenv(); no implicit .env loading. */
public static function fromEnvironment(#[SensitiveParameter] array $environment): self
{
$required = static function (string $key) use ($environment): string {
$value = $environment[$key] ?? null;
if (!is_string($value) || trim($value) === '' || str_contains($value, "\0")) {
throw new InvalidArgumentException($key . ' must be a non-empty string without NUL bytes.');
}
return $value;
};
$choice = static function (string $key, string $default, array $choices) use ($environment): string {
$value = array_key_exists($key, $environment) ? $environment[$key] : $default;
if (!is_string($value) || !in_array($value, $choices, true)) {
throw new InvalidArgumentException($key . ' must be one of: ' . implode(', ', $choices) . '.');
}
return $value;
};
$appEnvironment = $choice('MESHVAULT_ENV', 'production', ['development', 'test', 'production']);
$level = $choice('MESHVAULT_LOG_LEVEL', 'info', [
'debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency',
]);
$port = array_key_exists('MESHVAULT_DB_PORT', $environment) ? $environment['MESHVAULT_DB_PORT'] : '5432';
if (!is_string($port) || !preg_match('/^[0-9]{1,5}$/D', $port) || (int) $port < 1 || (int) $port > 65535) {
throw new InvalidArgumentException('MESHVAULT_DB_PORT must be decimal digits in the range 1..65535.');
}
$path = $required('MESHVAULT_LIBRARY_DIR');
$absolute = str_starts_with($path, '/')
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^(?:[A-Za-z]:[\\\\/]|\\\\\\\\)~', $path));
if (!$absolute || preg_match('/[\x00-\x1f\x7f]/', $path)) {
throw new InvalidArgumentException('MESHVAULT_LIBRARY_DIR must be an absolute local directory path.');
}
clearstatcache(true, $path);
if (!is_dir($path) || !is_writable($path)) {
throw new InvalidArgumentException(
'MESHVAULT_LIBRARY_DIR must exist as a directory writable by the runtime user.'
);
}
$database = [];
foreach (['MESHVAULT_DB_HOST', 'MESHVAULT_DB_NAME', 'MESHVAULT_DB_USER'] as $key) {
$value = $required($key);
if (trim($value) !== $value || preg_match('/[\x00-\x1f\x7f]/', $value)) {
throw new InvalidArgumentException($key . ' must not contain controls or surrounding whitespace.');
}
$database[$key] = $value;
}
return new self(
$appEnvironment,
$path,
$database['MESHVAULT_DB_HOST'],
(int) $port,
$database['MESHVAULT_DB_NAME'],
$database['MESHVAULT_DB_USER'],
$required('MESHVAULT_DB_PASSWORD'),
$level,
);
}
/** Diagnostic representations intentionally contain no configuration values. */
public function jsonSerialize(): array
{
return ['configuration' => '[redacted]'];
}
public function __debugInfo(): array
{
return $this->jsonSerialize();
}
public function __serialize(): array
{
return $this->jsonSerialize();
}
public function __unserialize(array $data): void
{
throw new \LogicException('AppConfig must be created from explicit environment input.');
}
}