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:
1 parent
c2747a5762
commit
d3e8cf8fae
39 files changed
+5291
-38
No files matched your search
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use DI\ContainerBuilder;
|
||||
use MeshVault\Config\AppConfig;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\App;
|
||||
use Slim\Exception\HttpMethodNotAllowedException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Psr7\Response;
|
||||
use Throwable;
|
||||
|
||||
final class AppFactory
|
||||
{
|
||||
public static function create(AppConfig $config): App
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
|
||||
$builder->addDefinitions($definitions($config));
|
||||
$container = $builder->build();
|
||||
$app = \Slim\Factory\AppFactory::createFromContainer($container);
|
||||
$routes = require dirname(__DIR__, 2) . '/config/routes.php';
|
||||
$routes($app);
|
||||
$app->addRoutingMiddleware();
|
||||
$errors = $app->addErrorMiddleware($config->environment === 'development', true, false);
|
||||
$errors->setDefaultErrorHandler(function (
|
||||
ServerRequestInterface $request,
|
||||
Throwable $exception,
|
||||
bool $displayErrorDetails
|
||||
) use ($container): Response {
|
||||
$status = match (true) {
|
||||
$exception instanceof HttpNotFoundException => 404,
|
||||
$exception instanceof HttpMethodNotAllowedException => 405,
|
||||
default => 500,
|
||||
};
|
||||
$title = match ($status) {
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
default => 'Internal Server Error',
|
||||
};
|
||||
if ($status === 500) {
|
||||
// No exception messages, arguments, request data or config values in production logs.
|
||||
$container->get(LoggerInterface::class)->error('HTTP request failed', [
|
||||
'exception_type' => $exception::class,
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
$response = new Response($status);
|
||||
if ($exception instanceof HttpMethodNotAllowedException) {
|
||||
$response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods()));
|
||||
}
|
||||
$response->getBody()->write($container->get(View::class)->render('error', [
|
||||
'title' => $title,
|
||||
'detail' => $displayErrorDetails ? $exception::class . ': ' . $exception->getMessage() : '',
|
||||
]));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
});
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final readonly class HomeHandler
|
||||
{
|
||||
public function __construct(private View $view)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write($this->view->render('home', ['title' => 'MeshVault']));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
final readonly class View
|
||||
{
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
public function render(string $template, array $data): string
|
||||
{
|
||||
if (!in_array($template, ['home', 'error'], true)) {
|
||||
throw new \InvalidArgumentException('Unknown view.');
|
||||
}
|
||||
$escape = static fn (string $value): string => htmlspecialchars(
|
||||
$value,
|
||||
ENT_QUOTES | ENT_SUBSTITUTE,
|
||||
'UTF-8'
|
||||
);
|
||||
ob_start();
|
||||
try {
|
||||
require $this->directory . '/' . $template . '.php';
|
||||
return (string) ob_get_contents();
|
||||
} finally {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Logging;
|
||||
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Stringable;
|
||||
|
||||
/** Immutable job scope; no mutable processor shared with another job. */
|
||||
final class JobLogger extends AbstractLogger
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LoggerInterface $logger,
|
||||
private readonly string $jobId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$context['job_id'] = $this->jobId;
|
||||
$this->logger->log($level, $message, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Logging;
|
||||
|
||||
use MeshVault\Config\AppConfig;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class LoggerFactory
|
||||
{
|
||||
public static function create(AppConfig $config): LoggerInterface
|
||||
{
|
||||
$handler = new StreamHandler('php://stdout', $config->logLevel);
|
||||
$handler->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true));
|
||||
return new Logger('meshvault', [$handler]);
|
||||
}
|
||||
|
||||
public static function forJob(LoggerInterface $logger, string $jobId): LoggerInterface
|
||||
{
|
||||
// Re-scoping an existing job replaces its ID without mutating either logger.
|
||||
if ($logger instanceof JobLogger) {
|
||||
$logger = $logger->logger;
|
||||
}
|
||||
return new JobLogger($logger, $jobId);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user