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.');
}
}
+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();
}
}
}
+25
View File
@@ -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);
}
}
+30
View File
@@ -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);
}
}