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

+65
View File
@@ -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;
}
}
+21
View File
@@ -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');
}
}
+31
View File
@@ -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();
}
}
}