From eed64733d96a527780b5e73bf5bbc8a1bba3cfe4 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 14:53:02 +0300 Subject: [PATCH 01/50] docs: rewrite README as a full framework guide (concepts, lifecycle, usage) --- README.md | 524 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 493 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 67b906c..db18f40 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,504 @@ -# php-service-platform +# AlfacodeTeam PhpServicePlatform -Documentation has been organized under [docs/README.md](docs/README.md). +> A modular **PHP 8.4+** service framework built on the **Gated Demand Architecture (GDA)**. +> Security runs *before* any module loads, and only the modules a request actually needs are +> ever wired in. The kernel is codenamed **Sentinel**. -Native global installation (Linux/macOS/Windows): +[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/) +[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) +![Runtime](https://img.shields.io/badge/runtime-FPM%20%7C%20OpenSwoole-orange) -1. Create a release tag (CI builds installers): - - `git tag v1.0.0 && git push origin v1.0.0` -2. Install from release artifacts: - - Linux: `psp-kernel__amd64.deb` - - Windows: `psp-kernel--windows-x86_64.zip` - - macOS: `psp-kernel--macos-universal.tar.gz` -3. Scaffold and run a project anywhere: - - `psp new /absolute/path/to/my-project --project=admin` - - `php /absolute/path/to/my-project/app/cli/run.php list` +It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so you install and +upgrade it like a Go/Rust binary — no Composer needed to get started. -Notes: +--- -- The native launcher reads kernel location from `PSP_KERNEL_HOME` or `PSP_CLI_PATH`. -- Optional override for generated project autoload: `PSP_GLOBAL_AUTOLOAD=/path/to/vendor/autoload.php`. -- Full packaging and install instructions: [packaging/README.md](packaging/README.md). +## Table of contents -Composer-based global install remains supported for development: +1. [Why GDA?](#why-gda) +2. [Install](#install) +3. [The `hkm` CLI](#the-hkm-cli) +4. [Your first project](#your-first-project) +5. [Core concepts](#core-concepts) +6. [The request lifecycle](#the-request-lifecycle) +7. [Building a feature — end to end](#building-a-feature--end-to-end) +8. [The five access rules](#the-five-access-rules) +9. [Batteries included (plugins)](#batteries-included-plugins) +10. [Development from source](#development-from-source) +11. [Security defaults](#security-defaults) +12. [License](#license) -- `composer global require alfacode-team/php-service-platform` +--- -Native system installers (no Composer required for end users): +## Why GDA? -- Debian/Kali apt package scaffolding: `packaging/apt/` -- Windows `.exe` bundle scaffolding: `packaging/windows/` -- macOS `.app` bundle scaffolding: `packaging/macos/` -- Zig launcher/config utility: `tools/psp-launcher-zig/` +Most frameworks boot everything, then decide what to do. GDA inverts that: -Key locations: +| Principle | What it means in practice | +|---|---| +| **Security before everything** | A `SecurityGateway` runs before any module loads. A denied request costs *zero* module wiring. | +| **Load only what is needed** | Only the modules required for *this* route are registered — resolved from a dependency graph per request. | +| **One module, one domain** | Every module owns exactly one bounded business domain. No exceptions. | +| **Isolation by default** | Modules cannot touch each other's internals — request-scoped containers enforce this at **runtime**. | +| **Infrastructure independence** | The kernel defines *port* interfaces; the project supplies implementations (MySQL, Redis, S3, …). | +| **Explicit over implicit** | Everything is declared in `module.json`. Nothing is auto-discovered at runtime. | -- Commands reports: [docs/reports/commands](docs/reports/commands) -- Database reports: [docs/reports/database](docs/reports/database) -- Enterprise reports: [docs/reports/enterprise](docs/reports/enterprise) -- Infrastructure reports: [docs/reports/infrastructure](docs/reports/infrastructure) -- Migrations reports: [docs/reports/migrations](docs/reports/migrations) -- Deployment guides: [docs/guides](docs/guides) -- AI context: [docs/ai-context](docs/ai-context) +The result: predictable performance (you pay only for what a route uses), strong domain +boundaries that hold at runtime, and infrastructure you can swap without touching business +code. + +> **This is not Laravel, Symfony, or Slim.** It borrows none of their conventions. If you're +> coming from those, unlearn the globals and facades — everything here is explicit and injected. + +### The three worlds + +```text +┌─────────────────────────────────────────────────┐ +│ PROJECT LAYER (wiring only — no business logic)│ +│ ┌─────────────────────────────────────────────┐ │ +│ │ MODULE / PLUGIN LAYER (bounded domains) │ │ +│ │ ┌───────────────────────────────────────┐ │ │ +│ │ │ KERNEL (Sentinel) │ │ │ +│ │ │ boot · security · loading · DI · │ │ │ +│ │ │ pipelines · events · ports │ │ │ +│ │ └───────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +- **Kernel** — knows nothing about your domains. Changes rarely. +- **Module/Plugin** — knows nothing about the project. Wires to the kernel through contracts. +- **Project** — knows everything, contains no business logic. Pure wiring. + +--- + +## Install + +Download the latest build from +[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest). + +**Linux (Debian / Ubuntu / Kali)** +```bash +sudo apt install ./hkm-kernel__amd64.deb +hkm doctor # verify PHP + extensions +``` + +**macOS** — extract `hkm-kernel--macos-universal.tar.gz`, then run +`HKM.app/Contents/Resources/opt/hkm-kernel/install.sh`. + +**Windows** — extract `hkm-kernel--windows-x86_64.zip`, run +`hkm-kernel\install.bat`, and add the folder to `PATH`. + +The launcher **self-locates** the kernel — no environment variables required on a standard +install. Runtime PHP dependencies are resolved with Composer on the target at install time, +so they match your exact PHP. + +### Requirements (verified by `hkm doctor`) + +- PHP **≥ 8.4.1** +- Extensions: `json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo` +- At least one PDO driver: `mysql` · `pgsql` · `sqlite` · `sqlsrv` +- Optional: `redis`, `swoole`/`openswoole`, `gd`, `intl` + +--- + +## The `hkm` CLI + +| Command | Purpose | +|---|---| +| `hkm new [--project=]` | Scaffold a new project (secure defaults: `.env` chmod 600, Apache **+** nginx configs) | +| `hkm run [path\|name]` | Run a project locally (PHP dev server) | +| `hkm cli [command]` | Run a project's console interactively | +| `hkm worker [args]` | Run a project's queue worker | +| `hkm list` | List registered projects | +| `hkm plugins [path\|name]` | Analyse a project's enabled plugins/modules | +| `hkm ui [sync\|list\|link\|clean]` | Federate enabled plugins' UIs into the frontend | +| `hkm doctor` | Diagnose PHP, extensions, and the resolved kernel path | +| `hkm-config` | Set up / repair the full environment (kernel + userdata) | +| `hkm upgrade [--check]` | Check for and install a newer release | +| `hkm version` / `--version` / `-v` | Show the Sentinel banner + version | +| `hkm --dev` | Run any command against the **development** kernel checkout | + +### Environment (all auto-detected — override only for non-standard layouts) + +| Variable | Meaning | +|---|---| +| `HKM_KERNEL_HOME` | Kernel root (`composer.json`, `vendor/`, `projects/`, `templates/`) | +| `HKM_DEV_HOME` | Development kernel checkout used by `--dev` (`hkm-config set-dev-home `) | +| `HKM_USERDATA_DIR` | Persistent registry (`projects.json` + `platform.json`) that **survives updates** | +| `HKM_PHP_BIN` | Override the `php` binary | +| `HKM_CLI_PATH` / `HKM_GLOBAL_AUTOLOAD` | Override the PHP CLI script / kernel autoload | + +Run `hkm-config` once and it pins `HKM_KERNEL_HOME` and provisions a persistent +`HKM_USERDATA_DIR` (migrating any existing registry) into `~/.config/hkm/config.env`. + +--- + +## Your first project + +```bash +# 1. Scaffold — creates a hardened project skeleton +hkm new ~/apps/shop --project=shop + +# 2. Verify the environment +hkm doctor + +# 3. Run it locally +hkm run shop +# → serving http://127.0.0.1:8000 (docroot pinned to app/public) + +# 4. Console + queue worker for the same project +hkm cli shop migrate # run migrations (LetMigrate) +hkm worker shop # drain the job queue +``` + +A project is **wiring only**. Its bootstrap composes the kernel from a shared base and +declares which plugins it activates: + +```php +// projects/shop/bootstrap/app.php +/** @var Kernel $builder */ +$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; + +return $builder + ->withProjectPath(dirname(__DIR__)) + ->withModules([ + Plugins\Auth\Provider::class, + Plugins\User\Provider::class, + Plugins\Task\Provider::class, + ]) + ->build(); +``` + +Incoming requests are mapped to a project by **host** (`app.example.com` → the `shop` +project) via `DomainResolver`, falling back to the `HKM_PROJECT` env var, then `admin`. + +--- + +## Core concepts + +### Modules & plugins + +A **plugin** (local business module) lives under `plugins//`, uses the `Plugins\\` +namespace, and follows a strict GDA folder layout: + +``` +plugins/Invoice/ +├── module.json ← single source of truth +├── API/Contracts/InvoiceServiceContract.php ← the ONLY thing other modules may import +├── Domain/ ← entities, value objects, domain events (zero external imports) +├── Application/Services/InvoiceService.php ← transaction + event orchestration +├── Infrastructure/ +│ ├── Persistence/InvoiceRepository.php ← DatabasePort only +│ ├── Gateways/StripeGateway.php ← vendor SDK only +│ └── Http/Controllers/InvoiceController.php ← ≤3-line actions +└── Provider.php ← implements ModuleContract +``` + +### `module.json` — the single source of truth + +Routes, config, dependencies, and emitted events are **declared**, never discovered: + +```json +{ + "name": "invoice", + "solves": "invoice.generation", + "type": "module", + "requires": ["database.query"], + "exposes": ["InvoiceServiceContract"], + "routes": [ + { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, + { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create", "filters": ["auth", "throttle:60,1"] }, + { "method": "GET", "path": "/api/invoices/{id}", "handler": "InvoiceController@show" } + ], + "emits": ["invoice.created", "invoice.paid"], + "config": ["INVOICE_CURRENCY", { "key": "INVOICE_TAX_RATE", "type": "float", "required": false }] +} +``` + +If a module reads an env var that isn't in `config[]`, **boot fails** — no silent misconfig. + +### Ports (infrastructure independence) + +The kernel defines interfaces; the project binds implementations once, at the app-lifetime +container: + +```php +->withPorts([ + DatabasePort::class => new MySQLAdapter(config('database')), + CachePort::class => new RedisAdapter(config('cache')), + QueuePort::class => new RedisQueueAdapter(config('jobs')), + MailPort::class => new SmtpMailAdapter(config('mail')), + StoragePort::class => new S3StorageAdapter(config('storage')), +]) +``` + +Swap MySQL for Postgres, or SMTP for SES, without touching a single line of domain code. + +--- + +## The request lifecycle + +```text +Request + │ + ▼ SecurityGateway (runs BEFORE any module loads) + │ Firewall → RateLimiter → CSRF → [your Auth layer] ── deny = zero module cost + ▼ +HTTP pipeline + 1. CorrelationIdStage propagate X-Correlation-ID + 2. SecurityStage run the gateway, attach Identity + 3. ResolveStage route-manifest lookup → service name + 4. LoadStage build dependency graph → wire ONLY those modules + ↳ RouteFilterStage run the route's declared filters[] (auth, throttle, …) + 5. ExecuteStage contract → DTO → controller → Response + 6. ErrorStage (wraps all) classify → notify (Slack/Mail/DB/File) → HTTP response +``` + +Every stage is `handle(Request $request, callable $next): Response`. Modules add +cross-cutting behaviour by registering hooks in `Provider::boot()`, or opt individual routes +into named **filters** via `module.json`. + +--- + +## Building a feature — end to end + +A complete vertical slice. Each layer has one job and may only talk to the layer below it. + +### 1. Domain — pure, zero external imports + +```php +// Domain/ValueObjects/Money.php +final readonly class Money +{ + private function __construct(private int $amount, private string $currency) { // integer cents — NEVER float + if ($this->amount < 0) throw new \DomainException('Money cannot be negative'); + } + public static function of(int|float $amount, string $currency): self { + return new self((int) round($amount * 100), strtoupper($currency)); + } + public function add(self $o): self { + if ($this->currency !== $o->currency) throw new \DomainException('Currency mismatch'); + return new self($this->amount + $o->amount, $this->currency); // operations return NEW instances + } + public function amount(): int { return $this->amount; } +} +``` + +### 2. Service — transaction + event orchestration (the mandatory shape) + +```php +final class InvoiceService implements InvoiceServiceContract +{ + public function __construct( + private readonly InvoiceRepository $repository, + private readonly TransactionManager $transaction, + private readonly DomainEventCollector $collector, + private readonly EventBus $eventBus, + private readonly Identity $identity, // from the SecurityGateway + ) {} + + public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO + { + // authorization first + if (!$this->identity->hasPermission('invoice:create')) { + throw new ServiceException('invoice.unauthorized', layer: 'service.invoice'); + } + + $this->collector->beginCollection(); + $this->transaction->begin(); + try { + $invoice = Invoice::create(ClientId::from($dto->clientId), Money::of($dto->amount, 'USD')); + foreach ($invoice->releaseEvents() as $e) $this->collector->collect($e); + $this->repository->save($invoice); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // ALWAYS — no phantom events on rollback + throw new ServiceException('invoice.create.failed', layer: 'service.invoice', previous: $e); + } + + // integration events dispatch ONLY after a successful commit — never inside try{} + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent($invoice->id()->value(), $dto->amount)); + + return InvoiceResponseDTO::from($invoice); + } +} +``` + +### 3. Repository — `DatabasePort` only, translate every `\PDOException` + +```php +final class InvoiceRepository +{ + public function __construct(private readonly DatabasePort $db, private readonly Identity $identity) {} + + public function find(string $id): Invoice + { + try { + $row = $this->db->queryOne( + 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t AND deleted_at IS NULL', + ['id' => $id, 't' => $this->identity->tenantId], // ALWAYS tenant-scoped + ); + } catch (\PDOException $e) { + throw new RepositoryException("find invoice [$id]", layer: 'repository.invoice', previous: $e); + } + return $row ? InvoiceHydrator::hydrate($row) : throw new RepositoryException("Invoice [$id] not found"); + } +} +``` + +### 4. Controller — ≤3 lines: DTO → service → Response + +```php +final class InvoiceController +{ + public function __construct(private readonly InvoiceServiceContract $service) {} // contract only + + public function create(Request $request): Response + { + $dto = CreateInvoiceDTO::fromRequest($request); // validation happens here + return Response::json($this->service->create($dto)->toArray(), 201); + } +} +``` + +### 5. Provider — wire it together + +```php +class Provider implements ModuleContract +{ + public function solves(): string { return 'invoice.generation'; } + public function requires(): array { return [DatabasePort::class]; } + public function exposes(): array { return [InvoiceServiceContract::class]; } + + public function register(ModuleContainer $c): void + { + $c->bindInternal(InvoiceRepository::class, fn($c) => + new InvoiceRepository($c->make(DatabasePort::class), $c->make(Identity::class))); + + $c->bind(InvoiceServiceContract::class, fn($c) => new InvoiceService( + $c->make(InvoiceRepository::class), $c->make(TransactionManager::class), + $c->make(DomainEventCollector::class), $c->make(EventBus::class), $c->make(Identity::class), + )); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // $events->subscribe('payment.succeeded', MarkInvoicePaidListener::class); + } +} +``` + +Add `Plugins\Invoice\Provider::class` to a project's `withModules([...])` and the routes, +config validation, and DI are live. + +### Testing — always fakes, never real infrastructure + +```php +$sut = new InvoiceService( + new InMemoryInvoiceRepository(), new FakeTransactionManager(), + new DomainEventCollector(), $bus = new FakeIntegrationEventBus(), Identity::asUser('u-1', 'tenant-a'), +); +$result = $sut->create($validDto); + +$bus->assertDispatched(InvoiceCreatedIntegrationEvent::class, times: 1); +$this->assertNotNull($result->invoiceId); +``` + +--- + +## The five access rules + +These are **enforced at runtime** by `ModuleContainer::bindInternal()` — a violation throws +`ScopeViolationException`, not a lint warning. + +```text +Controller → Service (published contract interface ONLY) +Service → Repository AND Gateway (the ONLY layer that may call both) +Repository → DatabasePort ONLY (no HTTP, no vendor SDK) +Gateway → Vendor SDK ONLY (no DB, no services) +Domain → NOTHING EXTERNAL (zero imports outside Domain/) +``` + +**Never** (a partial list the framework actively rejects): +- Routes defined in PHP — only in `module.json` / `proj.json`. +- `float` for money — use a `Money` value object with integer cents. +- Vendor exceptions (`\PDOException`, Stripe, …) escaping their layer — translate them. +- Integration events dispatched inside a `try{}` — only after commit. +- Another module's internal class imported — use its published contract. +- `getenv()` for a `.env` value — use the `env()` helper. +- Business logic in a controller — max 3 lines. + +--- + +## Batteries included (plugins) + +Drop-in modules under `plugins/`, activated per project: + +| Plugin | Domain | What you get | +|---|---|---| +| **Auth** | `auth.identity` | JWT / PAT / session issuance + verification, refresh-token rotation, guards | +| **OAuth2** | `oauth.server` | Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection) | +| **User** | `user.management` | Central identity store, email verification, transactional outbox, audit log | +| **Tenancy** | `tenancy.routing` | Multi-tenant DB routing, memberships, invitations, per-tenant isolation | +| **Validation** | `validation.rules` | Request validation engine + `AbstractDto` (`rules()`), ~45 built-in rules | +| **Mail** | `mail.delivery` | Native dependency-free mailer — SMTP/Sendmail/`mail()`, DKIM, attachments | +| **Storage** | `storage.local` | `StoragePort` over local disk **or** S3 (Flysystem), signed temp URLs | +| **Session / Cookie** | `session.management` / `http.cookies` | Encrypted sessions, flash, CSRF; queued encrypted cookies | +| **HttpClient** | `http.client` | `HttpClientPort` (cURL) with idempotent-safe retries + coroutine backoff | +| **View / ViteManifest / Pageflow** | frontend | PHP templating, Vite asset resolution, Inertia-style SPA bridge | +| **SecurityFilters** | `http.security_filters` | CORS + secure headers; route-filter aliases `auth`, `throttle`, `hmac`, `shield` | +| **I18n** | `i18n.translation` | File-based translator, pluralization, `Accept-Language` negotiation | + +Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), +[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md), +[OAuth2](plugins/OAuth2/README.md). + +--- + +## Development from source + +```bash +git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git +cd php-service-platform +composer install +vendor/bin/phpunit # run the test suite + +# Build the native launcher (needs Zig — see tools/.zig-version): +cd tools && zig build --release=small # → ../bin/hkm + ../bin/hkm-config +``` + +### Building release bundles + +```bash +VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip → dist/ +# MODULES=git ./tools/bundle.sh linux # fetch path-repo modules from pinned commits +``` + +Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds all three +OS bundles and publishes them automatically. + +For deep dives, see the layer guides in [`docs/ai-context/`](docs/) and the +[CHANGELOG](CHANGELOG.md). + +--- + +## Security defaults + +Scaffolded projects are hardened out of the box: + +- `.env` is `chmod 600`; debug output is force-disabled when `APP_ENV=production`. +- Every project ships web-server configs (`app/public/.htaccess`, + `app/apache.conf.example`, `app/nginx.conf.example`) pinning the docroot to `app/public`, + denying dotfiles, and adding baseline security headers. +- The `SecurityGateway` (firewall → rate limiter → CSRF → your auth layer) runs before any + module — denied requests never touch business code. +- Keep secrets (`APP_KEY`, JWT signing keys, DB credentials) out of the CLI config and, + in production, behind a `SECRETS_PROVIDER`. + +--- + +## License + +MIT — see [LICENSE](LICENSE). From 82bc557c3029d698f15c524ea7fdc10e3608e179 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 15:10:06 +0300 Subject: [PATCH 02/50] build(bundle): pin bundle dependencies to the PHP 8.4 series Use versioned php8.4-* Debian packages instead of php-cli (>= 8.4) so PHP 8.5+ can no longer satisfy the dependency; adjust docstring and Windows INSTALL.txt wording from "PHP >= 8.4" to "PHP 8.4". --- CHANGELOG.md | 10 ++++++++++ tools/bundle.sh | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 838fde3..849ef2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.12] - 2026-07-16 + +### Changed +- **Bundle dependencies pinned to the PHP 8.4 series (not `>= 8.4`).** The + Debian `.deb` `Depends`/`Recommends` now use the versioned `php8.4-*` + packages instead of the unversioned `php-cli (>= 8.4)` meta-package — which + would also let PHP 8.5+ satisfy the dependency. The docstring and Windows + `INSTALL.txt` wording changed from "PHP >= 8.4" to "PHP 8.4". The runtime is + now locked to the 8.4 line, not "8.4 or newer". + ## [1.0.11] - 2026-07-16 ### Added diff --git a/tools/bundle.sh b/tools/bundle.sh index 5f5417a..8cdadcb 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -12,7 +12,7 @@ # • the PHP CLI (bin/psp) installed AS bin/hkm so the launcher's default # passthrough path (/bin/hkm) resolves. # -# End users still need PHP >= 8.4 on PATH — `hkm doctor` verifies it. +# End users still need PHP 8.4 on PATH — `hkm doctor` verifies it. # --------------------------------------------------------------------------- set -euo pipefail @@ -112,7 +112,7 @@ if [[ "$want" == all || "$want" == linux ]]; then # composer is a hard dependency now: the package ships SOURCE, not vendor/, and # resolves dependencies on the target in postinst. Network access is required # at install time. In MODULES=git mode, git is also required to fetch modules. - DEPS="php-cli (>= 8.4), php-mbstring, php-curl, php-xml, php-zip, composer, ca-certificates" + DEPS="php8.4-cli, php8.4-mbstring, php8.4-curl, php8.4-xml, php8.4-zip, composer, ca-certificates" [ "$MODULES" = git ] && DEPS="$DEPS, git" cat > "$P/DEBIAN/control" < Depends: ${DEPS} -Recommends: php-mysql | php-pgsql | php-sqlite3, php-redis, php-intl +Recommends: php8.4-mysql | php8.4-pgsql | php8.4-sqlite3, php8.4-redis, php8.4-intl Description: PhpServicePlatform (HKM) kernel and native launcher Installs the kernel PHP source (src, plugins, projects, modules) under /opt/hkm-kernel and a native hkm launcher in /usr/bin. PHP dependencies are @@ -235,7 +235,7 @@ EOF HKM Kernel — Windows ==================== 1. Extract this folder to C:\hkm (or any path). -2. Install PHP >= 8.4 (winget install PHP.PHP) and Composer, open a new terminal. +2. Install PHP 8.4 (winget install PHP.PHP) and Composer, open a new terminal. 3. Resolve dependencies (vendor/ is NOT bundled): cd C:\hkm\hkm-kernel install.bat From 195b7e7ef18a4de4745cd8ace47b5bd339d8f35d Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 21:46:55 +0300 Subject: [PATCH 03/50] ci(release): auto-tag new CHANGELOG version on merge to main -> triggers Release build --- .github/workflows/auto-release.yml | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..80a756f --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,62 @@ +name: Auto Release + +# When work lands on main, read the top CHANGELOG version and, if it has no +# matching tag yet, create and push `vX.Y.Z`. Pushing that tag triggers the +# existing Release workflow (.github/workflows/release.yml), which runs the +# test gate, builds every OS bundle, and publishes the GitHub Release. +# +# IMPORTANT: the tag is pushed with a Personal Access Token (secret RELEASE_PAT) +# — a tag pushed with the default GITHUB_TOKEN does NOT trigger release.yml +# (GitHub blocks workflow-triggering-workflow recursion). + +"on": + push: + branches: + - main + +permissions: + contents: write + +concurrency: + group: auto-release + cancel-in-progress: false + +jobs: + tag: + name: Tag new CHANGELOG version + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # need all tags + token: ${{ secrets.RELEASE_PAT }} + + - name: Read top CHANGELOG version + id: ver + run: | + # First "## [x.y.z]" heading — skip "## [Unreleased]". + VERSION="$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md \ + | tr -d '## []')" + if [ -z "$VERSION" ]; then + echo "No versioned CHANGELOG entry found — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists — skipping." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "New version detected: v$VERSION" + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.ver.outputs.release == 'true' + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" From d86341b083b8d304131656b78f7a2b933dda4b81 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 21:49:38 +0300 Subject: [PATCH 04/50] ci(release): auto-release on merge to main via workflow_call (no PAT); tag from CHANGELOG version --- .github/workflows/auto-release.yml | 36 ++++++++++++++++++++---------- .github/workflows/release.yml | 33 ++++++++++++++++++++------- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 80a756f..f1190f9 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,18 +1,18 @@ name: Auto Release # When work lands on main, read the top CHANGELOG version and, if it has no -# matching tag yet, create and push `vX.Y.Z`. Pushing that tag triggers the -# existing Release workflow (.github/workflows/release.yml), which runs the -# test gate, builds every OS bundle, and publishes the GitHub Release. +# matching tag yet, create the tag `vX.Y.Z` and run the Release build for it. # -# IMPORTANT: the tag is pushed with a Personal Access Token (secret RELEASE_PAT) -# — a tag pushed with the default GITHUB_TOKEN does NOT trigger release.yml -# (GitHub blocks workflow-triggering-workflow recursion). +# No PAT required: instead of relying on the tag push to trigger release.yml +# (GitHub blocks workflow-triggering-workflow with the default token), this +# workflow CALLS release.yml directly via workflow_call, passing the version. "on": push: branches: - main + paths: + - CHANGELOG.md # only a version bump can start a release permissions: contents: write @@ -22,19 +22,20 @@ concurrency: cancel-in-progress: false jobs: - tag: - name: Tag new CHANGELOG version + detect: + name: Detect new version runs-on: ubuntu-22.04 + outputs: + release: ${{ steps.ver.outputs.release }} + version: ${{ steps.ver.outputs.version }} steps: - uses: actions/checkout@v5 - with: - fetch-depth: 0 # need all tags - token: ${{ secrets.RELEASE_PAT }} + with: { fetch-depth: 0 } # need all tags - name: Read top CHANGELOG version id: ver run: | - # First "## [x.y.z]" heading — skip "## [Unreleased]". + # First "## [x.y.z]" heading — skips "## [Unreleased]". VERSION="$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md \ | tr -d '## []')" if [ -z "$VERSION" ]; then @@ -60,3 +61,14 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git tag -a "v$VERSION" -m "Release v$VERSION" git push origin "v$VERSION" + + # Build + publish, reusing the single source of truth in release.yml. + release: + name: Build & publish + needs: detect + if: needs.detect.outputs.release == 'true' + uses: ./.github/workflows/release.yml + permissions: + contents: write + with: + version: ${{ needs.detect.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3aff70b..7b3856e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,30 @@ name: Release +# Two ways in: +# 1. push a `v*` tag → VERSION comes from the tag name. +# 2. workflow_call (version) → VERSION comes from the caller (auto-release.yml), +# so no PAT is needed to chain from a merge to main. "on": push: tags: - 'v*' + workflow_call: + inputs: + version: + description: "Release version without the leading v (e.g. 1.0.12)" + required: true + type: string permissions: contents: write # Single source of truth for bundling is tools/bundle.sh. Each job sets up the -# toolchain, then calls the script for its OS target. VERSION comes from the tag. +# toolchain, then calls the script for its OS target. VERSION comes from the tag +# (push) or the workflow_call input. env: ZIG_VERSION: "0.17.0-dev.657+2faf8debf" + # Resolved version for every job: the input when called, else the tag name. + RELEASE_VERSION: ${{ inputs.version || '' }} jobs: # ── Gate: run the PHPUnit suite. Nothing builds or publishes unless GREEN. ── @@ -49,7 +62,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (linux) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh linux + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh linux - uses: actions/upload-artifact@v5 with: { name: linux-deb, path: dist/*.deb } @@ -67,7 +80,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (windows) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh windows + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh windows - uses: actions/upload-artifact@v5 with: { name: windows-zip, path: dist/*.zip } @@ -87,7 +100,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (macos) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh macos + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh macos - uses: actions/upload-artifact@v5 with: { name: macos-app, path: dist/*.tar.gz } @@ -97,13 +110,14 @@ jobs: needs: [build-linux, build-windows, build-macos] runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 # need CHANGELOG.md at the tagged commit + - uses: actions/checkout@v5 # need CHANGELOG.md at the checked-out commit - uses: actions/download-artifact@v5 with: { path: artifacts/ } - - name: Extract CHANGELOG section for this version + - name: Resolve version + extract CHANGELOG section id: notes run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" # Pull the block between "## [VERSION]" and the next "## [" heading. awk -v v="$VERSION" ' $0 ~ "^## \\[" v "\\]" {grab=1; next} @@ -117,6 +131,9 @@ jobs: fi - uses: softprops/action-gh-release@v2 with: + # When called from auto-release the ref is a branch, so name the tag + # explicitly; on a tag push this matches GITHUB_REF_NAME anyway. + tag_name: v${{ steps.notes.outputs.version }} files: | artifacts/linux-deb/*.deb artifacts/windows-zip/*.zip @@ -126,4 +143,4 @@ jobs: body_path: ${{ steps.notes.outputs.has_notes == 'true' && 'release-body.md' || '' }} generate_release_notes: true draft: false - prerelease: ${{ contains(github.ref_name, '-') }} + prerelease: ${{ contains(steps.notes.outputs.version, '-') }} From 1218ab1c9fe64af809d25947f75d930cbb968566 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 21:52:49 +0300 Subject: [PATCH 05/50] docs(readme): document master->main branch model and automatic CHANGELOG-driven releases --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index db18f40..9693651 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,27 @@ VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds all three OS bundles and publishes them automatically. +### Releasing (branch model + automation) + +Development happens on the **`master`** dev branch; **`main`** is the stable release branch. +Releases are **CHANGELOG-driven and automatic** — you never tag by hand. + +1. Do your work on `master` and commit. +2. Add a new `## [x.y.z] - YYYY-MM-DD` section to [`CHANGELOG.md`](CHANGELOG.md) + (below `## [Unreleased]`), describing the changes. +3. Open a PR `master` → `main` and merge it. +4. On merge, the **Auto Release** workflow ([`.github/workflows/auto-release.yml`](.github/workflows/auto-release.yml)) + reads the top CHANGELOG version and, if no `vX.Y.Z` tag exists yet, creates the tag and + calls the **Release** workflow — which runs the test gate, builds all OS bundles, and + publishes the GitHub Release (notes pulled from that CHANGELOG section). + +Notes: +- A release only starts when the merge changes `CHANGELOG.md` **and** introduces a version + not already tagged — ordinary merges don't publish anything. +- No secret/PAT is required: Auto Release invokes the Release workflow directly via + `workflow_call`. +- You can still cut a release manually at any time by pushing a `v*` tag. + For deep dives, see the layer guides in [`docs/ai-context/`](docs/) and the [CHANGELOG](CHANGELOG.md). From a68ce886753f2b666255d35cc7f4b8fde4c67ff5 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 22:42:19 +0300 Subject: [PATCH 06/50] ci(security): CODEOWNERS + main branch protection script (required reviews, code owners, CI gates, linear history) --- .github/CODEOWNERS | 2 ++ tools/ci/protect-main.sh | 77 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100755 tools/ci/protect-main.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1946b65 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change requires review from a repo owner. +* @hakeemRash @Alshatri diff --git a/tools/ci/protect-main.sh b/tools/ci/protect-main.sh new file mode 100755 index 0000000..0559025 --- /dev/null +++ b/tools/ci/protect-main.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Secure the `main` branch: CODEOWNERS (you as sole reviewer) + hardened GitHub +# branch protection. Idempotent — safe to re-run. Requires the `gh` CLI, auth'd +# with a token that has admin rights on the repo. +# +# ./tools/ci/protect-main.sh +# +set -euo pipefail + +REPO="$(gh repo view --json nameWithOwner -q '.nameWithOwner')" +LOGIN="$(gh api user --jq '.login')" +BRANCH="main" + +# Optionally add a second collaborator (invitation). Set these env vars: +# COLLABORATOR= COLLAB_ROLE= +# e.g. COLLABORATOR=octocat COLLAB_ROLE=push ./tools/ci/protect-main.sh +COLLABORATOR="${COLLABORATOR:-}" +COLLAB_ROLE="${COLLAB_ROLE:-push}" + +if [ -n "$COLLABORATOR" ]; then + echo "Inviting @$COLLABORATOR as '$COLLAB_ROLE' collaborator..." + gh api -X PUT "repos/$REPO/collaborators/$COLLABORATOR" \ + -H "Accept: application/vnd.github+json" \ + -f permission="$COLLAB_ROLE" >/dev/null + echo "Invitation sent (they must accept it)." + echo +fi + +echo "Repo: $REPO" +echo "Owner: @$LOGIN (sole required reviewer)" +echo "Branch: $BRANCH" +echo + +# ── 1. CODEOWNERS — @you owns everything, so every PR needs your review ─────── +mkdir -p .github +if [ -n "$COLLABORATOR" ]; then + OWNERS="@$LOGIN @$COLLABORATOR" +else + OWNERS="@$LOGIN" +fi +printf '# Every change requires review from a repo owner.\n* %s\n' "$OWNERS" > .github/CODEOWNERS +echo "Wrote .github/CODEOWNERS ($OWNERS)" + +# ── 2. Hardened branch protection ───────────────────────────────────────────── +# - 1 approving review, from a CODE OWNER, stale approvals dismissed on new pushes +# - required CI status checks (must pass + be up to date with main) +# - enforced for admins too (strict, no bypass) +# - linear history (no merge commits), conversations resolved before merge +# - force-push + deletion blocked +gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" \ + -H "Accept: application/vnd.github+json" \ + --input - <<'JSON' +{ + "required_status_checks": { + "strict": true, + "contexts": ["PHPUnit (PHP 8.4)", "Zig build (all targets)"] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "require_code_owner_reviews": true, + "dismiss_stale_reviews": true + }, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true, + "block_creations": false, + "lock_branch": false, + "allow_fork_syncing": false +} +JSON + +echo +echo "Branch protection applied to $BRANCH." +echo "Commit + push .github/CODEOWNERS on master, then PR it into main so it takes effect." From a79620275f7718d12b27ce1c769d69b00addb86e Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 02:59:02 +0300 Subject: [PATCH 07/50] feat(edge): host-aware web-server config plugin (nginx SNI stream splitter / nginx-only / Apache), generates from platform domains + CLI apply --- composer.json | 3 +- .../API/Contracts/EdgeServiceContract.php | 31 ++++ plugins/Edge/Application/EdgeService.php | 107 +++++++++++ plugins/Edge/Domain/EdgePlan.php | 22 +++ plugins/Edge/Domain/ServerStack.php | 56 ++++++ plugins/Edge/Domain/Strategy.php | 35 ++++ .../Infrastructure/Cli/EdgeApplyCommand.php | 65 +++++++ .../Infrastructure/Cli/EdgeStatusCommand.php | 48 +++++ .../Edge/Infrastructure/ConfigRenderer.php | 168 ++++++++++++++++++ .../Edge/Infrastructure/DomainCollector.php | 54 ++++++ plugins/Edge/Infrastructure/SystemProbe.php | 86 +++++++++ plugins/Edge/Provider.php | 69 +++++++ plugins/Edge/README.md | 92 ++++++++++ plugins/Edge/Support/helpers.php | 48 +++++ plugins/Edge/config/edge.php | 60 +++++++ plugins/Edge/module.json | 32 ++++ tools/src/templates/app/bootstrap/app.php | 7 + 17 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 plugins/Edge/API/Contracts/EdgeServiceContract.php create mode 100644 plugins/Edge/Application/EdgeService.php create mode 100644 plugins/Edge/Domain/EdgePlan.php create mode 100644 plugins/Edge/Domain/ServerStack.php create mode 100644 plugins/Edge/Domain/Strategy.php create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php create mode 100644 plugins/Edge/Infrastructure/ConfigRenderer.php create mode 100644 plugins/Edge/Infrastructure/DomainCollector.php create mode 100644 plugins/Edge/Infrastructure/SystemProbe.php create mode 100644 plugins/Edge/Provider.php create mode 100644 plugins/Edge/README.md create mode 100644 plugins/Edge/Support/helpers.php create mode 100644 plugins/Edge/config/edge.php create mode 100644 plugins/Edge/module.json diff --git a/composer.json b/composer.json index 2713316..0513b94 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,8 @@ "plugins/Auth/Support/helpers.php", "plugins/Authorization/Engine/functions.php", "plugins/Cookie/Support/helpers.php", - "plugins/Pageflow/Support/helpers.php" + "plugins/Pageflow/Support/helpers.php", + "plugins/Edge/Support/helpers.php" ], "exclude-from-classmap": [ "**/database/seeders/**", diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php new file mode 100644 index 0000000..e262b9b --- /dev/null +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -0,0 +1,31 @@ +, message?: string + * } + */ + public function apply(bool $reload = true, bool $dryRun = false): array; +} diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php new file mode 100644 index 0000000..82771af --- /dev/null +++ b/plugins/Edge/Application/EdgeService.php @@ -0,0 +1,107 @@ +probe->detect(); + } + + public function plan(): EdgePlan + { + $stack = $this->probe->detect(); + $strategy = $stack->strategy(); + $domains = $this->domains->collect(); + [$path, $body] = $this->renderer->render($strategy, $domains); + + return new EdgePlan($stack, $strategy, $domains, $path, $body); + } + + public function apply(bool $reload = true, bool $dryRun = false): array + { + $plan = $this->plan(); + + if ($plan->strategy === Strategy::None) { + return [ + 'ok' => false, + 'strategy' => Strategy::None->value, + 'message' => 'No active web server detected — nothing to apply.', + ]; + } + + if ($dryRun) { + return [ + 'ok' => true, + 'dry_run' => true, + 'strategy' => $plan->strategy->value, + 'path' => $plan->targetPath, + 'domains' => \count($plan->domains), + 'contents' => $plan->contents, + ]; + } + + // Write the config atomically (temp file + rename) so a live include + // never sees a half-written file. + $dir = dirname($plan->targetPath); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'message' => "Cannot create directory {$dir}"]; + } + $tmp = $plan->targetPath . '.tmp'; + if (@file_put_contents($tmp, $plan->contents) === false || !@rename($tmp, $plan->targetPath)) { + @unlink($tmp); + return ['ok' => false, 'strategy' => $plan->strategy->value, 'message' => "Failed to write {$plan->targetPath}"]; + } + + $domainCount = \count($plan->domains); + $steps = ["wrote {$plan->targetPath} ({$domainCount} domains)"]; + + if ($reload) { + $isApache = $plan->strategy === Strategy::ApacheOnly; + $testCmd = (string) edge_config($isApache ? 'commands.apache_test' : 'commands.nginx_test'); + $reloadCmd = (string) edge_config($isApache ? 'commands.apache_reload' : 'commands.nginx_reload'); + + [$tc, $tout] = $this->probe->run($testCmd); + $steps[] = "test: {$testCmd} → " . ($tc === 0 ? 'ok' : 'FAILED'); + if ($tc !== 0) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'message' => trim($tout)]; + } + + [$rc, $rout] = $this->probe->run($reloadCmd); + $steps[] = "reload: {$reloadCmd} → " . ($rc === 0 ? 'ok' : 'FAILED'); + if ($rc !== 0) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'message' => trim($rout)]; + } + } + + return [ + 'ok' => true, + 'strategy' => $plan->strategy->value, + 'path' => $plan->targetPath, + 'domains' => \count($plan->domains), + 'steps' => $steps, + ]; + } +} diff --git a/plugins/Edge/Domain/EdgePlan.php b/plugins/Edge/Domain/EdgePlan.php new file mode 100644 index 0000000..af01a4d --- /dev/null +++ b/plugins/Edge/Domain/EdgePlan.php @@ -0,0 +1,22 @@ + $domains */ + public function __construct( + public ServerStack $stack, + public Strategy $strategy, + public array $domains, + public string $targetPath, + public string $contents, + ) {} +} diff --git a/plugins/Edge/Domain/ServerStack.php b/plugins/Edge/Domain/ServerStack.php new file mode 100644 index 0000000..56ee019 --- /dev/null +++ b/plugins/Edge/Domain/ServerStack.php @@ -0,0 +1,56 @@ +nginxActive && $this->apacheActive) { + return $this->nginxHasStream ? Strategy::NginxStream : Strategy::NginxOnly; + } + if ($this->nginxActive) { + return Strategy::NginxOnly; + } + if ($this->apacheActive) { + return Strategy::ApacheOnly; + } + return Strategy::None; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'nginx_installed' => $this->nginxInstalled, + 'nginx_active' => $this->nginxActive, + 'nginx_has_stream' => $this->nginxHasStream, + 'apache_installed' => $this->apacheInstalled, + 'apache_active' => $this->apacheActive, + 'strategy' => $this->strategy()->value, + ]; + } +} diff --git a/plugins/Edge/Domain/Strategy.php b/plugins/Edge/Domain/Strategy.php new file mode 100644 index 0000000..3c914a1 --- /dev/null +++ b/plugins/Edge/Domain/Strategy.php @@ -0,0 +1,35 @@ + 'nginx SNI stream splitter (nginx + Apache fallback)', + self::NginxOnly => 'nginx-only reverse proxy (no stream)', + self::ApacheOnly => 'Apache-only SSL VirtualHost', + self::None => 'no active web server', + }; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php new file mode 100644 index 0000000..c329756 --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php @@ -0,0 +1,65 @@ +name = 'edge:apply'; + $this->description = 'Generate the nginx/Apache edge config from platform domains, then reload the server'; + + $this->addOption('dry-run', '', 'Print the config that would be written; change nothing'); + $this->addOption('no-reload', '', 'Write the config file but do not validate or reload'); + } + + protected function handle(): int + { + $dryRun = $this->hasOption('dry-run'); + $reload = !$this->hasOption('no-reload'); + + $result = $this->edge->apply(reload: $reload, dryRun: $dryRun); + + if (($result['ok'] ?? false) !== true) { + $this->error('Edge apply failed [' . ($result['strategy'] ?? '?') . ']: ' . ($result['message'] ?? 'unknown error')); + foreach ((array) ($result['steps'] ?? []) as $step) { + $this->muted(' - ' . $step); + } + + return self::FAILURE; + } + + if ($dryRun) { + $this->info('strategy: ' . $result['strategy'] . ' → ' . $result['path'] . ' (' . $result['domains'] . ' domains)'); + $this->newLine(); + $this->muted($result['contents']); + + return self::SUCCESS; + } + + $this->success('Edge applied [' . $result['strategy'] . ']'); + foreach ((array) ($result['steps'] ?? []) as $step) { + $this->info(' - ' . $step); + } + + return self::SUCCESS; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php new file mode 100644 index 0000000..0f61647 --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -0,0 +1,48 @@ +name = 'edge:status'; + $this->description = 'Detect nginx/Apache and show the edge routing strategy that would be applied'; + } + + protected function handle(): int + { + $plan = $this->edge->plan(); + $stack = $plan->stack; + + $this->section('Edge — detected stack'); + $yn = static fn (bool $b): string => $b ? 'yes' : 'no'; + $this->info('nginx installed : ' . $yn($stack->nginxInstalled)); + $this->info('nginx active : ' . $yn($stack->nginxActive)); + $this->info('nginx stream : ' . $yn($stack->nginxHasStream)); + $this->info('apache installed: ' . $yn($stack->apacheInstalled)); + $this->info('apache active : ' . $yn($stack->apacheActive)); + $this->newLine(); + $this->success('strategy: ' . $plan->strategy->label()); + $this->info('domains : ' . count($plan->domains) . ($plan->domains === [] ? '' : ' (' . implode(', ', $plan->domains) . ')')); + $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); + + return self::SUCCESS; + } +} diff --git a/plugins/Edge/Infrastructure/ConfigRenderer.php b/plugins/Edge/Infrastructure/ConfigRenderer.php new file mode 100644 index 0000000..cf8be32 --- /dev/null +++ b/plugins/Edge/Infrastructure/ConfigRenderer.php @@ -0,0 +1,168 @@ + $domains + * @return array{0: string, 1: string} [targetPath, contents] ('' path for None) + */ + public function render(Strategy $strategy, array $domains): array + { + return match ($strategy) { + Strategy::NginxStream => [(string) edge_config('paths.stream'), $this->stream($domains)], + Strategy::NginxOnly => [(string) edge_config('paths.nginx'), $this->nginx($domains)], + Strategy::ApacheOnly => [(string) edge_config('paths.apache'), $this->apache($domains)], + Strategy::None => ['', ''], + }; + } + + /** nginx SNI (L4) stream splitter: listed domains → nginx, default → Apache. */ + private function stream(array $domains): string + { + $nginx = (string) edge_config('upstreams.nginx'); + $apache = (string) edge_config('upstreams.apache'); + $listen = (int) edge_config('listen', 443); + + $map = ''; + foreach ($domains as $d) { + $pad = str_repeat(' ', max(1, 42 - strlen($d))); + $map .= " {$d}{$pad}nginx_backend;\n"; + } + + $tpl = <<<'NGINX' +# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. +# +# SNI TLS router: the ClientHello's server name is read WITHOUT decrypting +# (ssl_preread), then the raw TLS stream is forwarded to the matching backend. +# Listed platform domains go to nginx (%NGINX%); everything else falls back to +# Apache (%APACHE%). TLS is terminated by the chosen backend, not here. +# +# This block MUST live at the nginx MAIN context (top level of nginx.conf), +# NOT inside http{}. Include it from nginx.conf: include %SELF%; +stream { + upstream nginx_backend { server %NGINX%; } + upstream apache_ssl { server %APACHE%; } + + map $ssl_preread_server_name $backend_name { +%MAP% default apache_ssl; + } + + server { + listen %LISTEN%; + proxy_pass $backend_name; + ssl_preread on; + } +} +NGINX; + + return $this->fill($tpl, [ + '%NGINX%' => $nginx, + '%APACHE%' => $apache, + '%LISTEN%' => (string) $listen, + '%MAP%' => $map, + '%SELF%' => (string) edge_config('paths.stream'), + ]); + } + + /** Plain nginx reverse-proxy vhost (no Apache present, no stream layer). */ + private function nginx(array $domains): string + { + $app = (string) edge_config('upstreams.app'); + $cert = (string) edge_config('ssl.cert'); + $key = (string) edge_config('ssl.key'); + $names = $domains === [] ? '_' : implode(' ', $domains); + + $tpl = <<<'NGINX' +# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. +# nginx-only: no Apache on this host. nginx terminates TLS and reverse-proxies +# every platform domain to the application backend (%APP%). +upstream hkm_app_backend { server %APP%; } + +server { + listen %LISTEN% ssl; + http2 on; + server_name %NAMES%; + + ssl_certificate %CERT%; + ssl_certificate_key %KEY%; + + location / { + proxy_pass http://hkm_app_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +NGINX; + + return $this->fill($tpl, [ + '%APP%' => $app, + '%LISTEN%' => (string) (int) edge_config('listen', 443), + '%NAMES%' => $names, + '%CERT%' => $cert, + '%KEY%' => $key, + ]); + } + + /** Apache SSL VirtualHost (Apache is the active server). */ + private function apache(array $domains): string + { + $app = (string) edge_config('upstreams.app'); + $cert = (string) edge_config('ssl.cert'); + $key = (string) edge_config('ssl.key'); + + $primary = $domains[0] ?? '_'; + $aliases = ''; + foreach (array_slice($domains, 1) as $d) { + $aliases .= " ServerAlias {$d}\n"; + } + + $tpl = <<<'APACHE' +# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. +# Apache-only: Apache terminates TLS and reverse-proxies every platform domain +# to the application backend (%APP%). + + ServerName %PRIMARY% +%ALIASES% + SSLEngine on + SSLCertificateFile %CERT% + SSLCertificateKeyFile %KEY% + + ProxyPreserveHost On + ProxyPass / http://%APP%/ + ProxyPassReverse / http://%APP%/ + RequestHeader set X-Forwarded-Proto "https" + +APACHE; + + return $this->fill($tpl, [ + '%APP%' => $app, + '%LISTEN%' => (string) (int) edge_config('listen', 443), + '%PRIMARY%' => $primary, + '%ALIASES%' => rtrim($aliases, "\n"), + '%CERT%' => $cert, + '%KEY%' => $key, + ]); + } + + /** @param array $vars */ + private function fill(string $template, array $vars): string + { + return rtrim(strtr($template, $vars), "\n") . "\n"; + } +} diff --git a/plugins/Edge/Infrastructure/DomainCollector.php b/plugins/Edge/Infrastructure/DomainCollector.php new file mode 100644 index 0000000..425aa92 --- /dev/null +++ b/plugins/Edge/Infrastructure/DomainCollector.php @@ -0,0 +1,54 @@ + sorted, unique, validated hostnames */ + public function collect(): array + { + $domains = []; + + $registry = (string) edge_config('projects_registry', ''); + if ($registry !== '' && is_file($registry)) { + $json = json_decode((string) file_get_contents($registry), true); + if (is_array($json)) { + foreach ($json as $project) { + foreach ((array) ($project['domains'] ?? []) as $domain) { + $domains[] = strtolower(trim((string) $domain)); + } + } + } + } + + foreach ((array) edge_config('extra_domains', []) as $domain) { + $domains[] = strtolower(trim((string) $domain)); + } + + $exclude = array_map('strtolower', (array) edge_config('exclude_domains', [])); + + $domains = array_filter( + array_unique($domains), + fn (string $d): bool => $d !== '' && $this->isValid($d) && !in_array($d, $exclude, true), + ); + + sort($domains); + + return array_values($domains); + } + + /** A conservative hostname whitelist — letters, digits, dot, hyphen only. */ + private function isValid(string $host): bool + { + return (bool) preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host); + } +} diff --git a/plugins/Edge/Infrastructure/SystemProbe.php b/plugins/Edge/Infrastructure/SystemProbe.php new file mode 100644 index 0000000..317c4c9 --- /dev/null +++ b/plugins/Edge/Infrastructure/SystemProbe.php @@ -0,0 +1,86 @@ +which('nginx'); + $apacheInstalled = $this->which('apache2') || $this->which('httpd') || $this->which('apachectl'); + + return new ServerStack( + nginxInstalled: $nginxInstalled, + nginxActive: $this->active('nginx'), + nginxHasStream: $nginxInstalled && $this->nginxHasStream(), + apacheInstalled: $apacheInstalled, + apacheActive: $this->active('apache2') || $this->active('httpd'), + ); + } + + /** Run an arbitrary command; returns [exitCode, combinedOutput]. */ + public function run(string $command): array + { + $output = []; + $code = 0; + @exec($command . ' 2>&1', $output, $code); + + return [$code, implode("\n", $output)]; + } + + private function which(string $binary): bool + { + [$code] = $this->run('command -v ' . escapeshellarg($binary)); + + return $code === 0; + } + + /** + * Is a service active? Prefer systemd; fall back to a process match so it + * still works on non-systemd hosts / inside containers. + */ + private function active(string $service): bool + { + [$code, $out] = $this->run('systemctl is-active ' . escapeshellarg($service)); + if ($code === 0 && trim($out) === 'active') { + return true; + } + + [$pcode] = $this->run('pgrep -x ' . escapeshellarg($service)); + + return $pcode === 0; + } + + /** Does the installed nginx support the stream (L4) module? */ + private function nginxHasStream(): bool + { + [, $banner] = $this->run('nginx -V'); + if (str_contains($banner, '--with-stream')) { + return true; + } + + // Dynamic module shipped separately (Debian/RHEL common paths). + foreach ([ + '/usr/lib/nginx/modules/ngx_stream_module.so', + '/usr/lib64/nginx/modules/ngx_stream_module.so', + '/etc/nginx/modules/ngx_stream_module.so', + ] as $path) { + if (is_file($path)) { + return true; + } + } + + return false; + } +} diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php new file mode 100644 index 0000000..388c06b --- /dev/null +++ b/plugins/Edge/Provider.php @@ -0,0 +1,69 @@ + */ + public function requires(): array + { + return []; + } + + /** @return list */ + public function exposes(): array + { + return [EdgeServiceContract::class]; + } + + public function register(ModuleContainer $container): void + { + $container->bind(EdgeServiceContract::class, static fn (): EdgeService => self::service()); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // CLI-only: defer so only CLI processes construct the commands. + $cli->defer(static function (CliPipeline $cli): void { + $service = self::service(); + $cli->command(new EdgeStatusCommand($service)); + $cli->command(new EdgeApplyCommand($service)); + }); + } + + private static function service(): EdgeService + { + return new EdgeService(new SystemProbe(), new DomainCollector(), new ConfigRenderer()); + } +} diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md new file mode 100644 index 0000000..d4a9eb9 --- /dev/null +++ b/plugins/Edge/README.md @@ -0,0 +1,92 @@ +# Edge plugin (`Plugins\Edge`, solves `edge.routing`) + +Generates the host's **web-server front config** from the platform's registered +domains, adapting to whatever is actually running on the machine. + +It probes the host, picks a strategy, renders the matching config, then +validates and reloads the server. + +## Strategy detection + +| Detected stack | Strategy | Rendered config | +|---|---|---| +| nginx **and** Apache active, nginx has the `stream` module | `nginx-stream` | nginx **SNI (L4) stream splitter** — listed domains → nginx (`:444`), everything else → Apache (`:8443`) | +| only nginx active (or Apache present but **inactive**, or nginx lacks `stream`) | `nginx-only` | plain nginx reverse-proxy vhost (no stream) | +| only Apache active | `apache-only` | Apache SSL `VirtualHost` | +| neither active | `none` | nothing — reports and stops | + +This is exactly the "check what's on the host and apply accordingly" rule: +nginx is the front; if it can stream and Apache is up, split by SNI; if Apache +is down, just nginx without stream; if only Apache, configure Apache. + +## The SNI stream splitter (the `nginx-stream` output) + +```nginx +stream { + upstream nginx_backend { server 127.0.0.1:444; } + upstream apache_ssl { server 127.0.0.1:8443; } + + map $ssl_preread_server_name $backend_name { + app.example.com nginx_backend; + ... + default apache_ssl; + } + + server { + listen 443; + proxy_pass $backend_name; + ssl_preread on; + } +} +``` + +`ssl_preread` reads the TLS ClientHello's SNI **without decrypting**, then the +raw TLS stream is forwarded to whichever backend the `map` picked. TLS is +terminated by that backend (nginx on `:444`, Apache on `:8443`) — the stream +layer never sees plaintext, so certificates live on the backends. + +> The `stream {}` block must live at the nginx **main context** (top level of +> `nginx.conf`), **not** inside `http {}`. Include it: `include ;` + +## Commands + +```bash +hkm edge:status # probe host; show stack, strategy, domains, target path +hkm edge:apply # render + write + `nginx -t` / `apachectl configtest` + reload +hkm edge:apply --dry-run # print the config that WOULD be written; change nothing +hkm edge:apply --no-reload # write the file only; skip validate + reload +``` + +## Domains + +Collected automatically from `projects/projects.json` (each project's +`domains[]`), plus `EDGE_EXTRA_DOMAINS`, minus `EDGE_EXCLUDE_DOMAINS`. Every +hostname is validated against a strict charset before it can reach a rendered +config, so a malformed registry entry can never inject directives. + +## Configuration (`config/edge.php`, all env-driven) + +| Env | Default | Purpose | +|---|---|---| +| `EDGE_LISTEN_PORT` | `443` | public TLS port | +| `EDGE_NGINX_BACKEND` | `127.0.0.1:444` | nginx TLS backend (stream) | +| `EDGE_APACHE_BACKEND` | `127.0.0.1:8443` | Apache fallback backend (stream) | +| `EDGE_APP_BACKEND` | `127.0.0.1:8080` | app upstream (nginx-only / Apache) | +| `EDGE_SSL_CERT` / `EDGE_SSL_KEY` | `/etc/ssl/...` | cert used by nginx-only / Apache templates | +| `EDGE_STREAM_PATH` / `EDGE_NGINX_PATH` / `EDGE_APACHE_PATH` | `var/edge/*.conf` | where each config is written (point at `/etc/nginx/...` in prod) | +| `EDGE_RELOAD` | `false` | reload after write by default (also controllable per-command) | +| `EDGE_*_TEST_CMD` / `EDGE_*_RELOAD_CMD` | `nginx -t`, `nginx -s reload`, `apachectl configtest`, `apachectl graceful` | validate/reload commands per distro | +| `EDGE_EXTRA_DOMAINS` / `EDGE_EXCLUDE_DOMAINS` | — | comma-separated add/drop | + +Defaults write to `var/edge/` so no root is needed to test; in production point +`EDGE_*_PATH` at the real nginx/Apache include dirs and run `hkm` with the +privileges needed to reload. + +## Notes + +- ON-DEMAND module; the value is the CLI. A route that needs the contract + declares `"requires": ["edge.routing"]`. +- Writes are atomic (temp file + rename), so a live `include` never sees a + half-written file. +- The service is DI-free (collaborators read `edge_config()`), so it constructs + without ports or a database. diff --git a/plugins/Edge/Support/helpers.php b/plugins/Edge/Support/helpers.php new file mode 100644 index 0000000..98c96d7 --- /dev/null +++ b/plugins/Edge/Support/helpers.php @@ -0,0 +1,48 @@ +/config/edge.php wins over the plugin + * default. + * + * edge_config(); // full array + * edge_config('listen'); // 443 + * edge_config('upstreams.nginx'); // dotted access + * edge_config('paths.stream', '…'); // value, or fallback if absent + * + * @return mixed the whole config array, or a single (dotted) key's value + */ + function edge_config(?string $key = null, mixed $default = null): mixed + { + /** @var array|null $config */ + static $config = null; + + if ($config === null) { + $projectFile = Paths::config('edge.php'); + $pluginFile = __DIR__ . '/../config/edge.php'; + + $file = is_file($projectFile) ? $projectFile : $pluginFile; + $loaded = require $file; + $config = is_array($loaded) ? $loaded : []; + } + + if ($key === null) { + return $config; + } + + $value = $config; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php new file mode 100644 index 0000000..078b017 --- /dev/null +++ b/plugins/Edge/config/edge.php @@ -0,0 +1,60 @@ +/config/edge.php overrides this default. + * Everything is env-driven; the defaults are safe for local development (the + * generated files land under var/edge/ so no root is needed to write them — + * point EDGE_*_PATH at /etc/nginx or /etc/apache2 in production). + */ +return [ + // The public TLS port the edge listens on. + 'listen' => (int) (env('EDGE_LISTEN_PORT') ?: 443), + + // Backends the traffic is routed to. + 'upstreams' => [ + // Where nginx terminates TLS for the platform's own domains. + 'nginx' => (string) (env('EDGE_NGINX_BACKEND') ?: '127.0.0.1:444'), + // Fallback web server (Apache) for everything not owned by the platform. + 'apache' => (string) (env('EDGE_APACHE_BACKEND') ?: '127.0.0.1:8443'), + // The application backend nginx/Apache reverse-proxy to (Swoole http or + // a plain listener). For PHP-FPM use fastcgi in your own vhost instead. + 'app' => (string) (env('EDGE_APP_BACKEND') ?: '127.0.0.1:8080'), + ], + + // TLS material used by the nginx-only and Apache-only templates. + 'ssl' => [ + 'cert' => (string) (env('EDGE_SSL_CERT') ?: '/etc/ssl/certs/hkm-edge.pem'), + 'key' => (string) (env('EDGE_SSL_KEY') ?: '/etc/ssl/private/hkm-edge.key'), + ], + + // Where each rendered config is written. Override to /etc/nginx/... in prod. + 'paths' => [ + 'stream' => (string) (env('EDGE_STREAM_PATH') ?: base_path('var/edge/hkm-edge-stream.conf')), + 'nginx' => (string) (env('EDGE_NGINX_PATH') ?: base_path('var/edge/hkm-edge-nginx.conf')), + 'apache' => (string) (env('EDGE_APACHE_PATH') ?: base_path('var/edge/hkm-edge-apache.conf')), + ], + + // Validation + reload commands (configurable per distro / init system). + 'commands' => [ + 'nginx_test' => (string) (env('EDGE_NGINX_TEST_CMD') ?: 'nginx -t'), + 'nginx_reload' => (string) (env('EDGE_NGINX_RELOAD_CMD') ?: 'nginx -s reload'), + 'apache_test' => (string) (env('EDGE_APACHE_TEST_CMD') ?: 'apachectl configtest'), + 'apache_reload' => (string) (env('EDGE_APACHE_RELOAD_CMD') ?: 'apachectl graceful'), + ], + + // Reload the web server after writing (edge:apply). Can also be forced/ skipped + // with CLI flags. Off by default so a bare `edge:apply` never touches a live + // server unless you opt in. + 'reload' => filter_var(env('EDGE_RELOAD', 'false'), FILTER_VALIDATE_BOOL), + + // Domain sources. The registries are read automatically; extra/exclude let + // you add or drop hostnames without editing the registry. + 'projects_registry' => base_path('projects/projects.json'), + 'platform_registry' => base_path('projects/platform.json'), + 'extra_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXTRA_DOMAINS', ''))))), + 'exclude_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXCLUDE_DOMAINS', ''))))), +]; diff --git a/plugins/Edge/module.json b/plugins/Edge/module.json new file mode 100644 index 0000000..f24466f --- /dev/null +++ b/plugins/Edge/module.json @@ -0,0 +1,32 @@ +{ + "name": "edge", + "version": "1.0.0", + "solves": "edge.routing", + "type": "module", + + "requires": [], + "exposes": ["Plugins\\Edge\\API\\Contracts\\EdgeServiceContract"], + + "routes": [], + "emits": [], + "listens": [], + + "config": [ + { "key": "EDGE_LISTEN_PORT", "type": "int", "required": false }, + { "key": "EDGE_NGINX_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_APACHE_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_APP_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_SSL_CERT", "type": "string", "required": false }, + { "key": "EDGE_SSL_KEY", "type": "string", "required": false }, + { "key": "EDGE_STREAM_PATH", "type": "string", "required": false }, + { "key": "EDGE_NGINX_PATH", "type": "string", "required": false }, + { "key": "EDGE_APACHE_PATH", "type": "string", "required": false }, + { "key": "EDGE_RELOAD", "type": "bool", "required": false }, + { "key": "EDGE_EXTRA_DOMAINS", "type": "string", "required": false }, + { "key": "EDGE_EXCLUDE_DOMAINS", "type": "string", "required": false }, + { "key": "EDGE_NGINX_TEST_CMD", "type": "string", "required": false }, + { "key": "EDGE_NGINX_RELOAD_CMD", "type": "string", "required": false }, + { "key": "EDGE_APACHE_TEST_CMD", "type": "string", "required": false }, + { "key": "EDGE_APACHE_RELOAD_CMD", "type": "string", "required": false } + ] +} diff --git a/tools/src/templates/app/bootstrap/app.php b/tools/src/templates/app/bootstrap/app.php index 559a30f..11ce79e 100644 --- a/tools/src/templates/app/bootstrap/app.php +++ b/tools/src/templates/app/bootstrap/app.php @@ -94,6 +94,7 @@ use Plugins\SiteSEO\Provider as SiteSeoModule; use Plugins\View\Provider as ViewModule; use Plugins\SecurityFilters\Provider as SecurityFiltersModule; +use Plugins\Edge\Provider as EdgeProvider; // Flat layout: this directory's grandparent is the project root. @@ -289,6 +290,12 @@ // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* // routes. Needs http.client (above) for its network actions. SiteSeoModule::class, + + // Edge (solves: edge.routing) — generates the host's web-server front + // config (nginx SNI stream splitter / nginx-only / Apache vhost) from the + // platform's registered domains. CLI-first: `hkm edge:status`, + // `hkm edge:apply`. Routes opt in via "requires": ["edge.routing"]. + EdgeProvider::class, ]) // ESSENTIAL modules: registered into EVERY request container regardless of From dc335b49b680d287de8a31733026d027b9b196a0 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 03:07:05 +0300 Subject: [PATCH 08/50] =?UTF-8?q?feat(edge):=20classify=20.local/.test=20a?= =?UTF-8?q?s=20local=20domains=20=E2=80=94=20exclude=20from=20server=20con?= =?UTF-8?q?fig,=20sync=20to=20/etc/hosts=20(edge:hosts,=20--no-hosts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/Contracts/EdgeServiceContract.php | 16 ++++- plugins/Edge/Application/EdgeService.php | 59 ++++++++++++---- plugins/Edge/Domain/EdgePlan.php | 6 +- .../Infrastructure/Cli/EdgeApplyCommand.php | 27 ++++++- .../Infrastructure/Cli/EdgeHostsCommand.php | 62 ++++++++++++++++ .../Infrastructure/Cli/EdgeStatusCommand.php | 5 +- .../Edge/Infrastructure/DomainCollector.php | 44 +++++++++++- .../Edge/Infrastructure/HostsFileWriter.php | 70 +++++++++++++++++++ plugins/Edge/Provider.php | 10 ++- plugins/Edge/README.md | 37 ++++++++-- plugins/Edge/config/edge.php | 16 +++++ plugins/Edge/module.json | 7 +- 12 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php create mode 100644 plugins/Edge/Infrastructure/HostsFileWriter.php diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php index e262b9b..56ac881 100644 --- a/plugins/Edge/API/Contracts/EdgeServiceContract.php +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -20,12 +20,22 @@ public function detect(): ServerStack; public function plan(): EdgePlan; /** - * Write the rendered config, then (optionally) validate + reload the server. + * Write the rendered config, sync local domains to /etc/hosts, then + * (optionally) validate + reload the server. * * @return array{ * ok: bool, strategy: string, path?: string, domains?: int, - * dry_run?: bool, contents?: string, steps?: list, message?: string + * dry_run?: bool, contents?: string, steps?: list, + * hosts?: array|null, message?: string * } */ - public function apply(bool $reload = true, bool $dryRun = false): array; + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null): array; + + /** + * Sync the platform's LOCAL domains (.local / .test / …) into /etc/hosts + * (pointing at the loopback), or remove the managed block with $remove. + * + * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, block?: string, message?: string} + */ + public function syncHosts(bool $remove = false, bool $dryRun = false): array; } diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php index 82771af..4fc65b3 100644 --- a/plugins/Edge/Application/EdgeService.php +++ b/plugins/Edge/Application/EdgeService.php @@ -10,6 +10,7 @@ use Plugins\Edge\Domain\Strategy; use Plugins\Edge\Infrastructure\ConfigRenderer; use Plugins\Edge\Infrastructure\DomainCollector; +use Plugins\Edge\Infrastructure\HostsFileWriter; use Plugins\Edge\Infrastructure\SystemProbe; /** @@ -23,6 +24,7 @@ public function __construct( private readonly SystemProbe $probe, private readonly DomainCollector $domains, private readonly ConfigRenderer $renderer, + private readonly HostsFileWriter $hosts, ) {} public function detect(): ServerStack @@ -32,23 +34,50 @@ public function detect(): ServerStack public function plan(): EdgePlan { - $stack = $this->probe->detect(); - $strategy = $stack->strategy(); - $domains = $this->domains->collect(); - [$path, $body] = $this->renderer->render($strategy, $domains); + $stack = $this->probe->detect(); + $strategy = $stack->strategy(); - return new EdgePlan($stack, $strategy, $domains, $path, $body); + // Local domains (.local / .test / …) are dev-only — they go to /etc/hosts, + // NOT the public server config, unless EDGE_LOCAL_IN_SERVER is set. + $split = $this->domains->split(); + $serverDomains = (bool) edge_config('include_local_in_server', false) + ? array_values(array_unique([...$split['public'], ...$split['local']])) + : $split['public']; + sort($serverDomains); + + [$path, $body] = $this->renderer->render($strategy, $serverDomains); + + return new EdgePlan($stack, $strategy, $serverDomains, $split['local'], $path, $body); } - public function apply(bool $reload = true, bool $dryRun = false): array + public function syncHosts(bool $remove = false, bool $dryRun = false): array + { + return $this->hosts->sync( + domains: $this->domains->split()['local'], + ip: (string) edge_config('hosts.ip', '127.0.0.1'), + path: (string) edge_config('hosts.path', '/etc/hosts'), + remove: $remove, + dryRun: $dryRun, + ); + } + + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null): array { $plan = $this->plan(); + // 1. Local domains → /etc/hosts (independent of any web server, so it + // still runs when the strategy is None). + $hosts = null; + if ($manageHosts ?? (bool) edge_config('manage_hosts', true)) { + $hosts = $this->syncHosts(dryRun: $dryRun); + } + if ($plan->strategy === Strategy::None) { return [ - 'ok' => false, + 'ok' => ($hosts['ok'] ?? true) === true, 'strategy' => Strategy::None->value, - 'message' => 'No active web server detected — nothing to apply.', + 'hosts' => $hosts, + 'message' => 'No active web server detected — only local hosts were synced.', ]; } @@ -60,19 +89,20 @@ public function apply(bool $reload = true, bool $dryRun = false): array 'path' => $plan->targetPath, 'domains' => \count($plan->domains), 'contents' => $plan->contents, + 'hosts' => $hosts, ]; } - // Write the config atomically (temp file + rename) so a live include - // never sees a half-written file. + // 2. Write the server config atomically (temp file + rename) so a live + // include never sees a half-written file. $dir = dirname($plan->targetPath); if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'message' => "Cannot create directory {$dir}"]; + return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Cannot create directory {$dir}"]; } $tmp = $plan->targetPath . '.tmp'; if (@file_put_contents($tmp, $plan->contents) === false || !@rename($tmp, $plan->targetPath)) { @unlink($tmp); - return ['ok' => false, 'strategy' => $plan->strategy->value, 'message' => "Failed to write {$plan->targetPath}"]; + return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Failed to write {$plan->targetPath}"]; } $domainCount = \count($plan->domains); @@ -86,13 +116,13 @@ public function apply(bool $reload = true, bool $dryRun = false): array [$tc, $tout] = $this->probe->run($testCmd); $steps[] = "test: {$testCmd} → " . ($tc === 0 ? 'ok' : 'FAILED'); if ($tc !== 0) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'message' => trim($tout)]; + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($tout)]; } [$rc, $rout] = $this->probe->run($reloadCmd); $steps[] = "reload: {$reloadCmd} → " . ($rc === 0 ? 'ok' : 'FAILED'); if ($rc !== 0) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'message' => trim($rout)]; + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($rout)]; } } @@ -102,6 +132,7 @@ public function apply(bool $reload = true, bool $dryRun = false): array 'path' => $plan->targetPath, 'domains' => \count($plan->domains), 'steps' => $steps, + 'hosts' => $hosts, ]; } } diff --git a/plugins/Edge/Domain/EdgePlan.php b/plugins/Edge/Domain/EdgePlan.php index af01a4d..bd40d07 100644 --- a/plugins/Edge/Domain/EdgePlan.php +++ b/plugins/Edge/Domain/EdgePlan.php @@ -11,11 +11,15 @@ */ final readonly class EdgePlan { - /** @param list $domains */ + /** + * @param list $domains public (server-facing) domains in the config + * @param list $localDomains dev-only domains (.local / .test / …) → /etc/hosts + */ public function __construct( public ServerStack $stack, public Strategy $strategy, public array $domains, + public array $localDomains, public string $targetPath, public string $contents, ) {} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php index c329756..350c030 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php @@ -29,14 +29,18 @@ protected function configure(): void $this->addOption('dry-run', '', 'Print the config that would be written; change nothing'); $this->addOption('no-reload', '', 'Write the config file but do not validate or reload'); + $this->addOption('no-hosts', '', 'Skip writing local (.local/.test) domains to /etc/hosts'); } protected function handle(): int { $dryRun = $this->hasOption('dry-run'); $reload = !$this->hasOption('no-reload'); + $hosts = $this->hasOption('no-hosts') ? false : null; // null = use config default - $result = $this->edge->apply(reload: $reload, dryRun: $dryRun); + $result = $this->edge->apply(reload: $reload, dryRun: $dryRun, manageHosts: $hosts); + + $this->reportHosts($result['hosts'] ?? null); if (($result['ok'] ?? false) !== true) { $this->error('Edge apply failed [' . ($result['strategy'] ?? '?') . ']: ' . ($result['message'] ?? 'unknown error')); @@ -62,4 +66,25 @@ protected function handle(): int return self::SUCCESS; } + + /** @param array|null $hosts */ + private function reportHosts(?array $hosts): void + { + if ($hosts === null) { + return; + } + $count = (int) ($hosts['count'] ?? 0); + $path = (string) ($hosts['path'] ?? '/etc/hosts'); + + if (($hosts['ok'] ?? false) !== true) { + $this->warning("hosts: {$count} local domain(s) NOT written — " . ($hosts['message'] ?? 'error')); + return; + } + if (($hosts['dry_run'] ?? false) === true) { + $this->info("hosts: would sync {$count} local domain(s) to {$path}"); + return; + } + $verb = ($hosts['changed'] ?? false) ? 'synced' : 'already current'; + $this->info("hosts: {$verb} {$count} local domain(s) in {$path}"); + } } diff --git a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php new file mode 100644 index 0000000..0046b49 --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php @@ -0,0 +1,62 @@ +name = 'edge:hosts'; + $this->description = 'Sync local (.local/.test) platform domains into /etc/hosts'; + + $this->addOption('dry-run', '', 'Show what would change; write nothing'); + $this->addOption('remove', '', 'Remove the HKM-managed block from the hosts file'); + } + + protected function handle(): int + { + $result = $this->edge->syncHosts( + remove: $this->hasOption('remove'), + dryRun: $this->hasOption('dry-run'), + ); + + $count = (int) ($result['count'] ?? 0); + $path = (string) ($result['path'] ?? '/etc/hosts'); + + if (($result['ok'] ?? false) !== true) { + $this->error('hosts sync failed: ' . ($result['message'] ?? 'unknown error')); + return self::FAILURE; + } + + if (($result['dry_run'] ?? false) === true) { + $this->info("Would write {$count} local domain(s) to {$path}:"); + $this->newLine(); + $this->muted(($result['block'] ?? '') === '' ? '(managed block would be removed)' : (string) $result['block']); + return self::SUCCESS; + } + + $verb = ($result['changed'] ?? false) ? 'Synced' : 'Already current —'; + $this->success("{$verb} {$count} local domain(s) in {$path}."); + + return self::SUCCESS; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php index 0f61647..00fcb5e 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -40,8 +40,9 @@ protected function handle(): int $this->info('apache active : ' . $yn($stack->apacheActive)); $this->newLine(); $this->success('strategy: ' . $plan->strategy->label()); - $this->info('domains : ' . count($plan->domains) . ($plan->domains === [] ? '' : ' (' . implode(', ', $plan->domains) . ')')); - $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); + $this->info('server domains : ' . count($plan->domains) . ($plan->domains === [] ? '' : ' (' . implode(', ', $plan->domains) . ')')); + $this->info('local domains : ' . count($plan->localDomains) . ($plan->localDomains === [] ? '' : ' → /etc/hosts (' . implode(', ', $plan->localDomains) . ')')); + $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); return self::SUCCESS; } diff --git a/plugins/Edge/Infrastructure/DomainCollector.php b/plugins/Edge/Infrastructure/DomainCollector.php index 425aa92..aaf3712 100644 --- a/plugins/Edge/Infrastructure/DomainCollector.php +++ b/plugins/Edge/Infrastructure/DomainCollector.php @@ -46,9 +46,51 @@ public function collect(): array return array_values($domains); } + /** + * Split collected domains into public (server-facing) and local (dev-only: + * .local / .test / … or single-label). Local domains are NOT served by the + * public edge config; they belong in /etc/hosts instead. + * + * @return array{public: list, local: list} + */ + public function split(): array + { + $public = []; + $local = []; + foreach ($this->collect() as $domain) { + if ($this->isLocal($domain)) { + $local[] = $domain; + } else { + $public[] = $domain; + } + } + + return ['public' => $public, 'local' => $local]; + } + /** A conservative hostname whitelist — letters, digits, dot, hyphen only. */ private function isValid(string $host): bool { - return (bool) preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host); + // Public FQDN (two+ labels, real TLD) … + if (preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host)) { + return true; + } + // … or a single-label local host (e.g. "myapp") — treated as local below. + return (bool) preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host); + } + + /** + * A domain is LOCAL when it has no dot (single label) or its TLD is in the + * configured local set (default: local, test, localhost, example, invalid). + */ + public function isLocal(string $host): bool + { + if (!str_contains($host, '.')) { + return true; + } + $tld = strtolower(substr((string) strrchr($host, '.'), 1)); + $tlds = array_map('strtolower', (array) edge_config('local_tlds', ['local', 'test', 'localhost', 'example', 'invalid'])); + + return in_array($tld, $tlds, true); } } diff --git a/plugins/Edge/Infrastructure/HostsFileWriter.php b/plugins/Edge/Infrastructure/HostsFileWriter.php new file mode 100644 index 0000000..4fbb1b0 --- /dev/null +++ b/plugins/Edge/Infrastructure/HostsFileWriter.php @@ -0,0 +1,70 @@ +>> HKM Edge (local domains) >>>'; + private const END = '# <<< HKM Edge (local domains) <<<'; + + /** + * @param list $domains local hostnames to point at $ip + * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, block?: string, message?: string} + */ + public function sync(array $domains, string $ip, string $path, bool $remove = false, bool $dryRun = false): array + { + if (!is_file($path)) { + return ['ok' => false, 'path' => $path, 'count' => 0, 'message' => "hosts file not found: {$path}"]; + } + + $current = (string) file_get_contents($path); + $stripped = $this->stripBlock($current); + + $block = ''; + if (!$remove && $domains !== []) { + $lines = [self::BEGIN]; + foreach ($domains as $d) { + $lines[] = sprintf('%s %s', $ip, $d); + } + $lines[] = self::END; + $block = implode("\n", $lines); + } + + $new = $block === '' + ? rtrim($stripped, "\n") . "\n" + : rtrim($stripped, "\n") . "\n\n" . $block . "\n"; + + $changed = $new !== $current; + + if ($dryRun) { + return ['ok' => true, 'dry_run' => true, 'changed' => $changed, 'path' => $path, 'count' => count($domains), 'block' => $block]; + } + if (!$changed) { + return ['ok' => true, 'changed' => false, 'path' => $path, 'count' => count($domains), 'message' => 'already up to date']; + } + + $tmp = $path . '.hkm.tmp'; + if (@file_put_contents($tmp, $new) === false || !@rename($tmp, $path)) { + @unlink($tmp); + return ['ok' => false, 'path' => $path, 'count' => count($domains), 'message' => "cannot write {$path} (run with the privileges to edit it, e.g. sudo)"]; + } + + return ['ok' => true, 'changed' => true, 'path' => $path, 'count' => count($domains)]; + } + + /** Remove any existing HKM-managed block (and the blank lines around it). */ + private function stripBlock(string $contents): string + { + $pattern = '/\n*' . preg_quote(self::BEGIN, '/') . '.*?' . preg_quote(self::END, '/') . '\n*/s'; + + return preg_replace($pattern, "\n", $contents) ?? $contents; + } +} diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php index 388c06b..d99da23 100644 --- a/plugins/Edge/Provider.php +++ b/plugins/Edge/Provider.php @@ -13,9 +13,11 @@ use Plugins\Edge\API\Contracts\EdgeServiceContract; use Plugins\Edge\Application\EdgeService; use Plugins\Edge\Infrastructure\Cli\EdgeApplyCommand; +use Plugins\Edge\Infrastructure\Cli\EdgeHostsCommand; use Plugins\Edge\Infrastructure\Cli\EdgeStatusCommand; use Plugins\Edge\Infrastructure\ConfigRenderer; use Plugins\Edge\Infrastructure\DomainCollector; +use Plugins\Edge\Infrastructure\HostsFileWriter; use Plugins\Edge\Infrastructure\SystemProbe; /** @@ -59,11 +61,17 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke $service = self::service(); $cli->command(new EdgeStatusCommand($service)); $cli->command(new EdgeApplyCommand($service)); + $cli->command(new EdgeHostsCommand($service)); }); } private static function service(): EdgeService { - return new EdgeService(new SystemProbe(), new DomainCollector(), new ConfigRenderer()); + return new EdgeService( + new SystemProbe(), + new DomainCollector(), + new ConfigRenderer(), + new HostsFileWriter(), + ); } } diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md index d4a9eb9..04bd70b 100644 --- a/plugins/Edge/README.md +++ b/plugins/Edge/README.md @@ -51,19 +51,44 @@ layer never sees plaintext, so certificates live on the backends. ## Commands ```bash -hkm edge:status # probe host; show stack, strategy, domains, target path -hkm edge:apply # render + write + `nginx -t` / `apachectl configtest` + reload -hkm edge:apply --dry-run # print the config that WOULD be written; change nothing +hkm edge:status # probe host; show stack, strategy, server + local domains +hkm edge:apply # render + write server config + sync /etc/hosts + reload +hkm edge:apply --dry-run # print what WOULD be written; change nothing hkm edge:apply --no-reload # write the file only; skip validate + reload +hkm edge:apply --no-hosts # skip the /etc/hosts sync +hkm edge:hosts # sync ONLY the local domains into /etc/hosts (needs sudo) +hkm edge:hosts --dry-run # show the hosts block that would be written +hkm edge:hosts --remove # remove the HKM-managed hosts block ``` -## Domains +## Domains — public vs local Collected automatically from `projects/projects.json` (each project's `domains[]`), plus `EDGE_EXTRA_DOMAINS`, minus `EDGE_EXCLUDE_DOMAINS`. Every hostname is validated against a strict charset before it can reach a rendered config, so a malformed registry entry can never inject directives. +Domains are then **split**: + +- **Public** (real FQDN, e.g. `app.example.com`) → go into the **server config** + (nginx stream / vhost / Apache). +- **Local** (`*.local`, `*.test`, `*.localhost`, `*.example`, `*.invalid`, or a + single-label host like `myapp`) → are **dev-only**: kept OUT of the public + server config and written to **`/etc/hosts`** pointing at the loopback, so they + resolve on this machine. The managed block is delimited by markers, so the rest + of your hosts file is never touched and re-runs are idempotent: + + ``` + # >>> HKM Edge (local domains) >>> + 127.0.0.1 api.hkm.local + 127.0.0.1 hkm.local + # <<< HKM Edge (local domains) <<< + ``` + +Tune the local TLD set with `EDGE_LOCAL_TLDS`. Set `EDGE_LOCAL_IN_SERVER=true` if +you also want nginx to serve `.local` sites locally (they then appear in BOTH the +server config and `/etc/hosts`). + ## Configuration (`config/edge.php`, all env-driven) | Env | Default | Purpose | @@ -77,6 +102,10 @@ config, so a malformed registry entry can never inject directives. | `EDGE_RELOAD` | `false` | reload after write by default (also controllable per-command) | | `EDGE_*_TEST_CMD` / `EDGE_*_RELOAD_CMD` | `nginx -t`, `nginx -s reload`, `apachectl configtest`, `apachectl graceful` | validate/reload commands per distro | | `EDGE_EXTRA_DOMAINS` / `EDGE_EXCLUDE_DOMAINS` | — | comma-separated add/drop | +| `EDGE_LOCAL_TLDS` | `local,test,localhost,example,invalid` | TLDs treated as local (→ /etc/hosts) | +| `EDGE_MANAGE_HOSTS` | `true` | write local domains to /etc/hosts on apply | +| `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | +| `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config | Defaults write to `var/edge/` so no root is needed to test; in production point `EDGE_*_PATH` at the real nginx/Apache include dirs and run `hkm` with the diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php index 078b017..01176c2 100644 --- a/plugins/Edge/config/edge.php +++ b/plugins/Edge/config/edge.php @@ -57,4 +57,20 @@ 'platform_registry' => base_path('projects/platform.json'), 'extra_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXTRA_DOMAINS', ''))))), 'exclude_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXCLUDE_DOMAINS', ''))))), + + // Local (dev-only) domains. A domain whose TLD is in this list — or that has + // no dot at all — is treated as LOCAL: it is kept OUT of the public server + // config and written to /etc/hosts instead (pointing at the loopback). + 'local_tlds' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_LOCAL_TLDS', 'local,test,localhost,example,invalid'))))), + + // Write local domains into /etc/hosts on apply (needs privileges to edit it). + 'manage_hosts' => filter_var(env('EDGE_MANAGE_HOSTS', 'true'), FILTER_VALIDATE_BOOL), + 'hosts' => [ + 'path' => (string) (env('EDGE_HOSTS_PATH') ?: '/etc/hosts'), + 'ip' => (string) (env('EDGE_HOSTS_IP') ?: '127.0.0.1'), + ], + + // Set true to ALSO include local domains in the generated server config + // (e.g. when nginx serves your .local sites in local development). + 'include_local_in_server' => filter_var(env('EDGE_LOCAL_IN_SERVER', 'false'), FILTER_VALIDATE_BOOL), ]; diff --git a/plugins/Edge/module.json b/plugins/Edge/module.json index f24466f..ea301b9 100644 --- a/plugins/Edge/module.json +++ b/plugins/Edge/module.json @@ -27,6 +27,11 @@ { "key": "EDGE_NGINX_TEST_CMD", "type": "string", "required": false }, { "key": "EDGE_NGINX_RELOAD_CMD", "type": "string", "required": false }, { "key": "EDGE_APACHE_TEST_CMD", "type": "string", "required": false }, - { "key": "EDGE_APACHE_RELOAD_CMD", "type": "string", "required": false } + { "key": "EDGE_APACHE_RELOAD_CMD", "type": "string", "required": false }, + { "key": "EDGE_LOCAL_TLDS", "type": "string", "required": false }, + { "key": "EDGE_MANAGE_HOSTS", "type": "bool", "required": false }, + { "key": "EDGE_HOSTS_PATH", "type": "string", "required": false }, + { "key": "EDGE_HOSTS_IP", "type": "string", "required": false }, + { "key": "EDGE_LOCAL_IN_SERVER", "type": "bool", "required": false } ] } From 9af3885a644c917171648176224815a406235d36 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 03:18:14 +0300 Subject: [PATCH 09/50] fix(edge): resolve project registry from global kernel home (not project base_path); document EDGE_* env in template --- plugins/Edge/config/edge.php | 24 ++++++++++++++++++++++-- tools/src/templates/env.example | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php index 01176c2..d9c368a 100644 --- a/plugins/Edge/config/edge.php +++ b/plugins/Edge/config/edge.php @@ -10,6 +10,26 @@ * generated files land under var/edge/ so no root is needed to write them — * point EDGE_*_PATH at /etc/nginx or /etc/apache2 in production). */ +$__edgeProjectsDir = (static function (): string { + // Edge is a HOST/control-plane tool: it must read the GLOBAL project registry + // (every project + its domains), which lives in the kernel home — NOT the + // per-project base_path. Resolution order: explicit override → PSP_PROJECTS_DIR + // → HKM_KERNEL_HOME/projects → base_path('projects'). + $explicit = (string) env('EDGE_PROJECTS_DIR', ''); + if ($explicit !== '') { + return rtrim($explicit, '/'); + } + $psp = (string) env('PSP_PROJECTS_DIR', ''); + if ($psp !== '') { + return rtrim($psp, '/'); + } + $home = (string) env('HKM_KERNEL_HOME', ''); + if ($home !== '') { + return rtrim($home, '/') . '/projects'; + } + return base_path('projects'); +})(); + return [ // The public TLS port the edge listens on. 'listen' => (int) (env('EDGE_LISTEN_PORT') ?: 443), @@ -53,8 +73,8 @@ // Domain sources. The registries are read automatically; extra/exclude let // you add or drop hostnames without editing the registry. - 'projects_registry' => base_path('projects/projects.json'), - 'platform_registry' => base_path('projects/platform.json'), + 'projects_registry' => $__edgeProjectsDir . '/projects.json', + 'platform_registry' => $__edgeProjectsDir . '/platform.json', 'extra_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXTRA_DOMAINS', ''))))), 'exclude_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXCLUDE_DOMAINS', ''))))), diff --git a/tools/src/templates/env.example b/tools/src/templates/env.example index 378eb2d..af868d4 100644 --- a/tools/src/templates/env.example +++ b/tools/src/templates/env.example @@ -170,6 +170,27 @@ CORS_ALLOW_CREDENTIALS=false HSTS_MAX_AGE=31536000 # CONTENT_SECURITY_POLICY= +# Edge routing (plugins/Edge) — nginx/Apache front config + /etc/hosts sync. +# ALL optional: Edge auto-detects the host stack and reads the global project +# registry (HKM_KERNEL_HOME/projects). Uncomment to override. +# EDGE_LISTEN_PORT=443 +# EDGE_NGINX_BACKEND=127.0.0.1:444 # nginx TLS backend (stream splitter) +# EDGE_APACHE_BACKEND=127.0.0.1:8443 # Apache fallback backend (stream) +# EDGE_APP_BACKEND=127.0.0.1:8080 # app upstream (nginx-only / Apache) +# EDGE_SSL_CERT=/etc/ssl/certs/hkm-edge.pem +# EDGE_SSL_KEY=/etc/ssl/private/hkm-edge.key +# EDGE_STREAM_PATH=/etc/nginx/streams-enabled/hkm-edge.conf # prod: real include dir +# EDGE_NGINX_PATH=/etc/nginx/conf.d/hkm-edge.conf +# EDGE_APACHE_PATH=/etc/apache2/sites-enabled/hkm-edge.conf +# EDGE_RELOAD=false # reload web server after edge:apply +# EDGE_LOCAL_TLDS=local,test,localhost,example,invalid # → /etc/hosts, not the server +# EDGE_MANAGE_HOSTS=true # write local domains to /etc/hosts +# EDGE_HOSTS_PATH=/etc/hosts +# EDGE_HOSTS_IP=127.0.0.1 +# EDGE_LOCAL_IN_SERVER=false # also serve .local domains via nginx locally +# EDGE_EXTRA_DOMAINS= # comma-separated extra hostnames +# EDGE_EXCLUDE_DOMAINS= + # Observability # Honeycomb, DataDog, etc. # HONEYCOMB_API_KEY= From 87ccd905836f89c2bd8546651299f4257585fd43 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 03:43:58 +0300 Subject: [PATCH 10/50] =?UTF-8?q?feat(edge):=20project-aware=20config=20?= =?UTF-8?q?=E2=80=94=20per-project=20vhosts=20(docroot=20app/public,=20fpm?= =?UTF-8?q?|swoole=20via=20proj.json)=20with=20injected=20run-env=20(APP?= =?UTF-8?q?=5FENV/HKM=5FUSERDATA=5FDIR/PSP=5FGLOBAL=5FAUTOLOAD/HKM=5FKERNE?= =?UTF-8?q?L=5FHOME)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/Contracts/EdgeServiceContract.php | 2 +- plugins/Edge/Application/EdgeService.php | 28 +- plugins/Edge/Domain/EdgePlan.php | 8 +- plugins/Edge/Domain/ServeModel.php | 23 ++ plugins/Edge/Domain/Site.php | 38 +++ .../Infrastructure/Cli/EdgeApplyCommand.php | 2 +- .../Infrastructure/Cli/EdgeStatusCommand.php | 11 +- .../Edge/Infrastructure/ConfigRenderer.php | 274 +++++++++++++----- .../Edge/Infrastructure/DomainCollector.php | 96 ------ plugins/Edge/Infrastructure/SiteCollector.php | 201 +++++++++++++ plugins/Edge/Provider.php | 4 +- plugins/Edge/README.md | 36 +++ plugins/Edge/config/edge.php | 21 ++ plugins/Edge/module.json | 8 +- tools/src/templates/env.example | 7 + 15 files changed, 570 insertions(+), 189 deletions(-) create mode 100644 plugins/Edge/Domain/ServeModel.php create mode 100644 plugins/Edge/Domain/Site.php delete mode 100644 plugins/Edge/Infrastructure/DomainCollector.php create mode 100644 plugins/Edge/Infrastructure/SiteCollector.php diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php index 56ac881..30a8c14 100644 --- a/plugins/Edge/API/Contracts/EdgeServiceContract.php +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -24,7 +24,7 @@ public function plan(): EdgePlan; * (optionally) validate + reload the server. * * @return array{ - * ok: bool, strategy: string, path?: string, domains?: int, + * ok: bool, strategy: string, path?: string, sites?: int, * dry_run?: bool, contents?: string, steps?: list, * hosts?: array|null, message?: string * } diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php index 4fc65b3..89e18cc 100644 --- a/plugins/Edge/Application/EdgeService.php +++ b/plugins/Edge/Application/EdgeService.php @@ -9,8 +9,8 @@ use Plugins\Edge\Domain\ServerStack; use Plugins\Edge\Domain\Strategy; use Plugins\Edge\Infrastructure\ConfigRenderer; -use Plugins\Edge\Infrastructure\DomainCollector; use Plugins\Edge\Infrastructure\HostsFileWriter; +use Plugins\Edge\Infrastructure\SiteCollector; use Plugins\Edge\Infrastructure\SystemProbe; /** @@ -22,7 +22,7 @@ final class EdgeService implements EdgeServiceContract { public function __construct( private readonly SystemProbe $probe, - private readonly DomainCollector $domains, + private readonly SiteCollector $sites, private readonly ConfigRenderer $renderer, private readonly HostsFileWriter $hosts, ) {} @@ -37,23 +37,19 @@ public function plan(): EdgePlan $stack = $this->probe->detect(); $strategy = $stack->strategy(); - // Local domains (.local / .test / …) are dev-only — they go to /etc/hosts, - // NOT the public server config, unless EDGE_LOCAL_IN_SERVER is set. - $split = $this->domains->split(); - $serverDomains = (bool) edge_config('include_local_in_server', false) - ? array_values(array_unique([...$split['public'], ...$split['local']])) - : $split['public']; - sort($serverDomains); + // Per-project sites (public domains → server config); local (.local/.test) + // domains ride along on each site but go to /etc/hosts, not the config. + $sites = $this->sites->sites(); - [$path, $body] = $this->renderer->render($strategy, $serverDomains); + [$path, $body] = $this->renderer->render($strategy, $sites); - return new EdgePlan($stack, $strategy, $serverDomains, $split['local'], $path, $body); + return new EdgePlan($stack, $strategy, $sites, $this->sites->localDomains(), $path, $body); } public function syncHosts(bool $remove = false, bool $dryRun = false): array { return $this->hosts->sync( - domains: $this->domains->split()['local'], + domains: $this->sites->localDomains(), ip: (string) edge_config('hosts.ip', '127.0.0.1'), path: (string) edge_config('hosts.path', '/etc/hosts'), remove: $remove, @@ -87,7 +83,7 @@ public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHo 'dry_run' => true, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, - 'domains' => \count($plan->domains), + 'sites' => \count($plan->sites), 'contents' => $plan->contents, 'hosts' => $hosts, ]; @@ -105,8 +101,8 @@ public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHo return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Failed to write {$plan->targetPath}"]; } - $domainCount = \count($plan->domains); - $steps = ["wrote {$plan->targetPath} ({$domainCount} domains)"]; + $siteCount = \count($plan->sites); + $steps = ["wrote {$plan->targetPath} ({$siteCount} project site(s))"]; if ($reload) { $isApache = $plan->strategy === Strategy::ApacheOnly; @@ -130,7 +126,7 @@ public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHo 'ok' => true, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, - 'domains' => \count($plan->domains), + 'sites' => \count($plan->sites), 'steps' => $steps, 'hosts' => $hosts, ]; diff --git a/plugins/Edge/Domain/EdgePlan.php b/plugins/Edge/Domain/EdgePlan.php index bd40d07..f8087d6 100644 --- a/plugins/Edge/Domain/EdgePlan.php +++ b/plugins/Edge/Domain/EdgePlan.php @@ -6,19 +6,19 @@ /** * The full result of planning an edge apply: the detected stack, the chosen - * strategy, the domains that fed the config, and the rendered file (path + - * contents) that will be written. + * strategy, the per-project Sites that fed the config, the dev-only local + * domains (→ /etc/hosts), and the rendered file (path + contents). */ final readonly class EdgePlan { /** - * @param list $domains public (server-facing) domains in the config + * @param list $sites per-project sites in the server config * @param list $localDomains dev-only domains (.local / .test / …) → /etc/hosts */ public function __construct( public ServerStack $stack, public Strategy $strategy, - public array $domains, + public array $sites, public array $localDomains, public string $targetPath, public string $contents, diff --git a/plugins/Edge/Domain/ServeModel.php b/plugins/Edge/Domain/ServeModel.php new file mode 100644 index 0000000..0dae072 --- /dev/null +++ b/plugins/Edge/Domain/ServeModel.php @@ -0,0 +1,23 @@ +/app/public` via PHP-FPM (fastcgi), + * passing the run env as fastcgi_param / SetEnv. + * - Swoole : the project runs its own OpenSwoole HTTP server; the edge just + * reverse-proxies to that upstream (env lives in the Swoole process). + */ +enum ServeModel: string +{ + case Fpm = 'fpm'; + case Swoole = 'swoole'; + + public static function from_(string $value, self $default = self::Fpm): self + { + return self::tryFrom(strtolower(trim($value))) ?? $default; + } +} diff --git a/plugins/Edge/Domain/Site.php b/plugins/Edge/Domain/Site.php new file mode 100644 index 0000000..5e112f9 --- /dev/null +++ b/plugins/Edge/Domain/Site.php @@ -0,0 +1,38 @@ +/app/public`), how it's served (FPM vs Swoole + the + * upstream), and the run-env that must be injected into its vhost so the project + * boots (APP_ENV, HKM_USERDATA_DIR, PSP_GLOBAL_AUTOLOAD, HKM_KERNEL_HOME, …). + * + * Local (.local/.test) domains ride along on the owning site but are NOT put in + * the server config — they go to /etc/hosts. + */ +final readonly class Site +{ + /** + * @param list $publicDomains server-facing hostnames + * @param list $localDomains dev-only hostnames (→ /etc/hosts) + * @param array $env run-env injected into the vhost + */ + public function __construct( + public string $name, + public string $docroot, // /app/public + public array $publicDomains, + public array $localDomains, + public ServeModel $model, + public string $upstream, // fpm: fastcgi socket/addr · swoole: host:port + public array $env, + ) {} + + /** Does this site have anything to put in the server config? */ + public function servesPublic(): bool + { + return $this->publicDomains !== [] && $this->docroot !== ''; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php index 350c030..468dfdf 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php @@ -52,7 +52,7 @@ protected function handle(): int } if ($dryRun) { - $this->info('strategy: ' . $result['strategy'] . ' → ' . $result['path'] . ' (' . $result['domains'] . ' domains)'); + $this->info('strategy: ' . $result['strategy'] . ' → ' . $result['path'] . ' (' . ($result['sites'] ?? 0) . ' site(s))'); $this->newLine(); $this->muted($result['contents']); diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php index 00fcb5e..95084d0 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -40,7 +40,16 @@ protected function handle(): int $this->info('apache active : ' . $yn($stack->apacheActive)); $this->newLine(); $this->success('strategy: ' . $plan->strategy->label()); - $this->info('server domains : ' . count($plan->domains) . ($plan->domains === [] ? '' : ' (' . implode(', ', $plan->domains) . ')')); + $this->info('project sites : ' . count($plan->sites)); + foreach ($plan->sites as $site) { + $this->info(sprintf( + ' • %s [%s → %s] %s', + $site->name, + $site->model->value, + $site->upstream, + $site->publicDomains === [] ? '(no public domains)' : implode(', ', $site->publicDomains), + )); + } $this->info('local domains : ' . count($plan->localDomains) . ($plan->localDomains === [] ? '' : ' → /etc/hosts (' . implode(', ', $plan->localDomains) . ')')); $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); diff --git a/plugins/Edge/Infrastructure/ConfigRenderer.php b/plugins/Edge/Infrastructure/ConfigRenderer.php index cf8be32..df09f6c 100644 --- a/plugins/Edge/Infrastructure/ConfigRenderer.php +++ b/plugins/Edge/Infrastructure/ConfigRenderer.php @@ -4,52 +4,58 @@ namespace Plugins\Edge\Infrastructure; +use Plugins\Edge\Domain\ServeModel; +use Plugins\Edge\Domain\Site; use Plugins\Edge\Domain\Strategy; /** - * Pure config renderer — no I/O, no globals beyond edge_config(). Turns a - * strategy + domain list into the text of an nginx stream router, an nginx - * reverse-proxy vhost, or an Apache SSL VirtualHost. + * Pure config renderer — no I/O. Turns a strategy + the list of Sites into the + * text of the host's web-server config: a per-project vhost (docroot + * /app/public, FPM fastcgi or Swoole proxy, with the run-env injected) + * modeled on templates/app/{nginx,apache}.conf.example, plus — for the stream + * strategy — the nginx SNI splitter that routes SNI → nginx (:444) / Apache. */ final class ConfigRenderer { /** - * @param list $domains + * @param list $sites * @return array{0: string, 1: string} [targetPath, contents] ('' path for None) */ - public function render(Strategy $strategy, array $domains): array + public function render(Strategy $strategy, array $sites): array { return match ($strategy) { - Strategy::NginxStream => [(string) edge_config('paths.stream'), $this->stream($domains)], - Strategy::NginxOnly => [(string) edge_config('paths.nginx'), $this->nginx($domains)], - Strategy::ApacheOnly => [(string) edge_config('paths.apache'), $this->apache($domains)], - Strategy::None => ['', ''], + Strategy::NginxStream => [ + (string) edge_config('paths.stream'), + $this->stream($sites) . "\n" . $this->nginxVhosts($sites, $this->nginxInternalPort()), + ], + Strategy::NginxOnly => [ + (string) edge_config('paths.nginx'), + $this->nginxVhosts($sites, (int) edge_config('listen', 443)), + ], + Strategy::ApacheOnly => [ + (string) edge_config('paths.apache'), + $this->apacheVhosts($sites, (int) edge_config('listen', 443)), + ], + Strategy::None => ['', ''], }; } - /** nginx SNI (L4) stream splitter: listed domains → nginx, default → Apache. */ - private function stream(array $domains): string - { - $nginx = (string) edge_config('upstreams.nginx'); - $apache = (string) edge_config('upstreams.apache'); - $listen = (int) edge_config('listen', 443); + // ── nginx SNI stream splitter (L4) ──────────────────────────────────────── + /** @param list $sites */ + private function stream(array $sites): string + { $map = ''; - foreach ($domains as $d) { + foreach ($this->publicDomains($sites) as $d) { $pad = str_repeat(' ', max(1, 42 - strlen($d))); $map .= " {$d}{$pad}nginx_backend;\n"; } $tpl = <<<'NGINX' # Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. -# -# SNI TLS router: the ClientHello's server name is read WITHOUT decrypting -# (ssl_preread), then the raw TLS stream is forwarded to the matching backend. -# Listed platform domains go to nginx (%NGINX%); everything else falls back to -# Apache (%APACHE%). TLS is terminated by the chosen backend, not here. -# -# This block MUST live at the nginx MAIN context (top level of nginx.conf), -# NOT inside http{}. Include it from nginx.conf: include %SELF%; +# SNI TLS router: server name is read WITHOUT decrypting (ssl_preread), then the +# raw TLS stream is forwarded. Platform domains → nginx (%NGINX%); everything +# else → Apache (%APACHE%). This block lives at the nginx MAIN context. stream { upstream nginx_backend { server %NGINX%; } upstream apache_ssl { server %APACHE%; } @@ -67,30 +73,91 @@ private function stream(array $domains): string NGINX; return $this->fill($tpl, [ - '%NGINX%' => $nginx, - '%APACHE%' => $apache, - '%LISTEN%' => (string) $listen, + '%NGINX%' => (string) edge_config('upstreams.nginx'), + '%APACHE%' => (string) edge_config('upstreams.apache'), + '%LISTEN%' => (string) (int) edge_config('listen', 443), '%MAP%' => $map, - '%SELF%' => (string) edge_config('paths.stream'), ]); } - /** Plain nginx reverse-proxy vhost (no Apache present, no stream layer). */ - private function nginx(array $domains): string + // ── per-project nginx vhosts ────────────────────────────────────────────── + + /** @param list $sites */ + private function nginxVhosts(array $sites, int $port): string + { + $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; + foreach ($sites as $site) { + if (!$site->servesPublic()) { + continue; + } + $out .= "\n" . ($site->model === ServeModel::Swoole + ? $this->nginxSwoole($site, $port) + : $this->nginxFpm($site, $port)); + } + + return rtrim($out, "\n") . "\n"; + } + + private function nginxFpm(Site $site, int $port): string { - $app = (string) edge_config('upstreams.app'); - $cert = (string) edge_config('ssl.cert'); - $key = (string) edge_config('ssl.key'); - $names = $domains === [] ? '_' : implode(' ', $domains); + $params = ''; + foreach ($site->env as $k => $v) { + $params .= sprintf(" fastcgi_param %s \"%s\";\n", $k, $this->escapeNginx($v)); + } $tpl = <<<'NGINX' -# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. -# nginx-only: no Apache on this host. nginx terminates TLS and reverse-proxies -# every platform domain to the application backend (%APP%). -upstream hkm_app_backend { server %APP%; } +# Project: %NAME% (PHP-FPM) +server { + listen %PORT% ssl; + http2 on; + server_name %NAMES%; + root %DOCROOT%; + index index.php; + + ssl_certificate %CERT%; + ssl_certificate_key %KEY%; + + location ~ /\. { deny all; return 404; } + + location ~ \.php$ { + location = /index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; +%PARAMS% fastcgi_pass %UPSTREAM%; + } + return 404; + } + + location / { try_files $uri /index.php$is_args$args; } + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + server_tokens off; + client_max_body_size 25m; +} +NGINX; + + return $this->fill($tpl, [ + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%NAMES%' => implode(' ', $site->publicDomains), + '%DOCROOT%' => $site->docroot, + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%PARAMS%' => $params, + '%UPSTREAM%' => $site->upstream, + ]); + } + + private function nginxSwoole(Site $site, int $port): string + { + $tpl = <<<'NGINX' +# Project: %NAME% (OpenSwoole) — env lives in the Swoole process: +# hkm run %NAME% --swoole (bind it to %UPSTREAM%) server { - listen %LISTEN% ssl; + listen %PORT% ssl; http2 on; server_name %NAMES%; @@ -98,7 +165,7 @@ private function nginx(array $domains): string ssl_certificate_key %KEY%; location / { - proxy_pass http://hkm_app_backend; + proxy_pass http://%UPSTREAM%; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -111,55 +178,128 @@ private function nginx(array $domains): string NGINX; return $this->fill($tpl, [ - '%APP%' => $app, - '%LISTEN%' => (string) (int) edge_config('listen', 443), - '%NAMES%' => $names, - '%CERT%' => $cert, - '%KEY%' => $key, + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%NAMES%' => implode(' ', $site->publicDomains), + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%UPSTREAM%' => $site->upstream, ]); } - /** Apache SSL VirtualHost (Apache is the active server). */ - private function apache(array $domains): string + // ── per-project Apache vhosts ───────────────────────────────────────────── + + /** @param list $sites */ + private function apacheVhosts(array $sites, int $port): string { - $app = (string) edge_config('upstreams.app'); - $cert = (string) edge_config('ssl.cert'); - $key = (string) edge_config('ssl.key'); + $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; + foreach ($sites as $site) { + if (!$site->servesPublic()) { + continue; + } + $out .= "\n" . $this->apacheSite($site, $port); + } - $primary = $domains[0] ?? '_'; + return rtrim($out, "\n") . "\n"; + } + + private function apacheSite(Site $site, int $port): string + { $aliases = ''; - foreach (array_slice($domains, 1) as $d) { + foreach (array_slice($site->publicDomains, 1) as $d) { $aliases .= " ServerAlias {$d}\n"; } + $setenv = ''; + foreach ($site->env as $k => $v) { + $setenv .= sprintf(" SetEnv %s \"%s\"\n", $k, $this->escapeApache($v)); + } + + // PHP handler: FPM via mod_proxy_fcgi, or reverse-proxy for Swoole. + if ($site->model === ServeModel::Swoole) { + $handler = " ProxyPreserveHost On\n ProxyPass / http://{$site->upstream}/\n ProxyPassReverse / http://{$site->upstream}/"; + } else { + $fcgi = str_starts_with($site->upstream, 'unix:') + ? 'proxy:' . $site->upstream . '|fcgi://localhost/' + : 'proxy:fcgi://' . $site->upstream; + $handler = " \n SetHandler \"{$fcgi}\"\n "; + } $tpl = <<<'APACHE' -# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. -# Apache-only: Apache terminates TLS and reverse-proxies every platform domain -# to the application backend (%APP%). - +# Project: %NAME% + ServerName %PRIMARY% -%ALIASES% +%ALIASES% DocumentRoot %DOCROOT% + + + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + Require all denied + + SSLEngine on SSLCertificateFile %CERT% SSLCertificateKeyFile %KEY% - ProxyPreserveHost On - ProxyPass / http://%APP%/ - ProxyPassReverse / http://%APP%/ - RequestHeader set X-Forwarded-Proto "https" +%SETENV%%HANDLER% + + ServerTokens Prod + ServerSignature Off + LimitRequestBody 26214400 APACHE; return $this->fill($tpl, [ - '%APP%' => $app, - '%LISTEN%' => (string) (int) edge_config('listen', 443), - '%PRIMARY%' => $primary, - '%ALIASES%' => rtrim($aliases, "\n"), - '%CERT%' => $cert, - '%KEY%' => $key, + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%PRIMARY%' => $site->publicDomains[0] ?? '_', + '%ALIASES%' => $aliases, + '%DOCROOT%' => $site->docroot, + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%SETENV%' => $setenv, + '%HANDLER%' => $handler, ]); } + // ── helpers ─────────────────────────────────────────────────────────────── + + /** @param list $sites @return list */ + private function publicDomains(array $sites): array + { + $domains = []; + foreach ($sites as $site) { + foreach ($site->publicDomains as $d) { + $domains[] = $d; + } + } + $domains = array_values(array_unique($domains)); + sort($domains); + + return $domains; + } + + /** The internal port nginx vhosts listen on when behind the stream splitter. */ + private function nginxInternalPort(): int + { + $backend = (string) edge_config('upstreams.nginx', '127.0.0.1:444'); + $port = (int) substr(strrchr($backend, ':') ?: ':444', 1); + + return $port > 0 ? $port : 444; + } + + private function escapeNginx(string $v): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $v); + } + + private function escapeApache(string $v): string + { + return str_replace('"', '\\"', $v); + } + /** @param array $vars */ private function fill(string $template, array $vars): string { diff --git a/plugins/Edge/Infrastructure/DomainCollector.php b/plugins/Edge/Infrastructure/DomainCollector.php deleted file mode 100644 index aaf3712..0000000 --- a/plugins/Edge/Infrastructure/DomainCollector.php +++ /dev/null @@ -1,96 +0,0 @@ - sorted, unique, validated hostnames */ - public function collect(): array - { - $domains = []; - - $registry = (string) edge_config('projects_registry', ''); - if ($registry !== '' && is_file($registry)) { - $json = json_decode((string) file_get_contents($registry), true); - if (is_array($json)) { - foreach ($json as $project) { - foreach ((array) ($project['domains'] ?? []) as $domain) { - $domains[] = strtolower(trim((string) $domain)); - } - } - } - } - - foreach ((array) edge_config('extra_domains', []) as $domain) { - $domains[] = strtolower(trim((string) $domain)); - } - - $exclude = array_map('strtolower', (array) edge_config('exclude_domains', [])); - - $domains = array_filter( - array_unique($domains), - fn (string $d): bool => $d !== '' && $this->isValid($d) && !in_array($d, $exclude, true), - ); - - sort($domains); - - return array_values($domains); - } - - /** - * Split collected domains into public (server-facing) and local (dev-only: - * .local / .test / … or single-label). Local domains are NOT served by the - * public edge config; they belong in /etc/hosts instead. - * - * @return array{public: list, local: list} - */ - public function split(): array - { - $public = []; - $local = []; - foreach ($this->collect() as $domain) { - if ($this->isLocal($domain)) { - $local[] = $domain; - } else { - $public[] = $domain; - } - } - - return ['public' => $public, 'local' => $local]; - } - - /** A conservative hostname whitelist — letters, digits, dot, hyphen only. */ - private function isValid(string $host): bool - { - // Public FQDN (two+ labels, real TLD) … - if (preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host)) { - return true; - } - // … or a single-label local host (e.g. "myapp") — treated as local below. - return (bool) preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host); - } - - /** - * A domain is LOCAL when it has no dot (single label) or its TLD is in the - * configured local set (default: local, test, localhost, example, invalid). - */ - public function isLocal(string $host): bool - { - if (!str_contains($host, '.')) { - return true; - } - $tld = strtolower(substr((string) strrchr($host, '.'), 1)); - $tlds = array_map('strtolower', (array) edge_config('local_tlds', ['local', 'test', 'localhost', 'example', 'invalid'])); - - return in_array($tld, $tlds, true); - } -} diff --git a/plugins/Edge/Infrastructure/SiteCollector.php b/plugins/Edge/Infrastructure/SiteCollector.php new file mode 100644 index 0000000..f7362d0 --- /dev/null +++ b/plugins/Edge/Infrastructure/SiteCollector.php @@ -0,0 +1,201 @@ + */ + public function sites(): array + { + $sites = []; + foreach ($this->projects() as $name => $project) { + $path = rtrim((string) ($project['path'] ?? ''), '/'); + $domains = $this->classify((array) ($project['domains'] ?? [])); + if ($path === '' || ($domains['public'] === [] && $domains['local'] === [])) { + continue; + } + + $proj = $this->projJson($path); + $edge = (array) ($proj['edge'] ?? []); + $model = ServeModel::from_((string) ($edge['serve'] ?? edge_config('serve.model', 'fpm'))); + + $sites[] = new Site( + name: (string) $name, + docroot: $path . '/app/public', + publicDomains: $domains['public'], + localDomains: $domains['local'], + model: $model, + upstream: $this->upstream($model, $edge), + env: $this->env($path, $edge), + ); + } + + return $sites; + } + + /** All local (dev-only) domains across every project + EDGE_EXTRA_DOMAINS. */ + public function localDomains(): array + { + $local = []; + foreach ($this->sites() as $site) { + foreach ($site->localDomains as $d) { + $local[] = $d; + } + } + foreach ($this->classify((array) edge_config('extra_domains', []))['local'] as $d) { + $local[] = $d; + } + $local = array_values(array_unique($local)); + sort($local); + + return $local; + } + + // ── registries ──────────────────────────────────────────────────────────── + + /** @return array> */ + private function projects(): array + { + $file = (string) edge_config('projects_registry', ''); + if ($file === '' || !is_file($file)) { + return []; + } + $json = json_decode((string) file_get_contents($file), true); + + return is_array($json) ? $json : []; + } + + /** @return array */ + private function projJson(string $path): array + { + $file = $path . '/proj.json'; + if (!is_file($file)) { + return []; + } + $json = json_decode((string) file_get_contents($file), true); + + return is_array($json) ? $json : []; + } + + // ── serving + env ─────────────────────────────────────────────────────── + + /** @param array $edge */ + private function upstream(ServeModel $model, array $edge): string + { + if ($model === ServeModel::Swoole) { + $host = (string) edge_config('serve.swoole_host', '127.0.0.1'); + $port = (int) ($edge['port'] ?? edge_config('serve.swoole_base_port', 9500)); + + return "{$host}:{$port}"; + } + + // FPM: an explicit per-project socket, else the global default. + return (string) ($edge['socket'] ?? edge_config('serve.fpm_socket', 'unix:/run/php/php-fpm.sock')); + } + + /** + * The run-env injected into a site's vhost. Base env (APP_ENV, userdata, + * kernel resolution) merged with per-project proj.json `edge.env` extras. + * + * @param array $edge + * @return array + */ + private function env(string $path, array $edge): array + { + $env = []; + + $appEnv = (string) edge_config('env.app_env', 'production'); + if ($appEnv !== '') { + $env['APP_ENV'] = $appEnv; + } + + $userdata = (string) edge_config('env.userdata_dir', ''); + if ($userdata !== '') { + $env['HKM_USERDATA_DIR'] = $userdata; + } + + if ((bool) edge_config('inject_kernel_env', true)) { + $home = (string) edge_config('env.kernel_home', ''); + $autoload = (string) edge_config('env.global_autoload', ''); + if ($autoload === '' && $home !== '') { + $autoload = $home . '/vendor/autoload.php'; + } + if ($home !== '') { + $env['HKM_KERNEL_HOME'] = $home; + } + if ($autoload !== '') { + $env['PSP_GLOBAL_AUTOLOAD'] = $autoload; + } + } + + // Per-project extras win. + foreach ((array) ($edge['env'] ?? []) as $k => $v) { + if (is_string($k)) { + $env[$k] = (string) $v; + } + } + + return $env; + } + + // ── domain classification (validated) ───────────────────────────────────── + + /** + * @param array $domains + * @return array{public: list, local: list} + */ + private function classify(array $domains): array + { + $exclude = array_map('strtolower', (array) edge_config('exclude_domains', [])); + $public = []; + $local = []; + foreach ($domains as $domain) { + $host = strtolower(trim((string) $domain)); + if ($host === '' || !$this->isValid($host) || in_array($host, $exclude, true)) { + continue; + } + if ($this->isLocal($host)) { + $local[] = $host; + } else { + $public[] = $host; + } + } + + return ['public' => array_values(array_unique($public)), 'local' => array_values(array_unique($local))]; + } + + private function isValid(string $host): bool + { + if (preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host)) { + return true; + } + + return (bool) preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host); + } + + private function isLocal(string $host): bool + { + if (!str_contains($host, '.')) { + return true; + } + $tld = strtolower(substr((string) strrchr($host, '.'), 1)); + $tlds = array_map('strtolower', (array) edge_config('local_tlds', ['local', 'test', 'localhost', 'example', 'invalid'])); + + return in_array($tld, $tlds, true); + } +} diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php index d99da23..52a354b 100644 --- a/plugins/Edge/Provider.php +++ b/plugins/Edge/Provider.php @@ -16,8 +16,8 @@ use Plugins\Edge\Infrastructure\Cli\EdgeHostsCommand; use Plugins\Edge\Infrastructure\Cli\EdgeStatusCommand; use Plugins\Edge\Infrastructure\ConfigRenderer; -use Plugins\Edge\Infrastructure\DomainCollector; use Plugins\Edge\Infrastructure\HostsFileWriter; +use Plugins\Edge\Infrastructure\SiteCollector; use Plugins\Edge\Infrastructure\SystemProbe; /** @@ -69,7 +69,7 @@ private static function service(): EdgeService { return new EdgeService( new SystemProbe(), - new DomainCollector(), + new SiteCollector(), new ConfigRenderer(), new HostsFileWriter(), ); diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md index 04bd70b..353c1f9 100644 --- a/plugins/Edge/README.md +++ b/plugins/Edge/README.md @@ -61,6 +61,37 @@ hkm edge:hosts --dry-run # show the hosts block that would be written hkm edge:hosts --remove # remove the HKM-managed hosts block ``` +## Per-project serving (the vhost model) + +Edge is **project-aware**: it reads the global registry (`projects.json` → +name/path/domains) and renders **one vhost per project**, with: + +- **docroot = `/app/public`** (never the project root — keeps + `.env`/config/src/vendor out of the web tree), modeled on + `templates/app/{nginx,apache}.conf.example`; +- the **run-env injected** so the served project boots: `APP_ENV`, + `HKM_USERDATA_DIR`, and (when `EDGE_INJECT_KERNEL_ENV=true`) `HKM_KERNEL_HOME` + + `PSP_GLOBAL_AUTOLOAD` — as `fastcgi_param` (nginx FPM) / `SetEnv` (Apache); +- a **serve model** per project: `fpm` (fastcgi to PHP-FPM) or `swoole` + (reverse-proxy to the project's OpenSwoole port). + +Each project may override the model + upstream + extra env in its **`proj.json`**: + +```jsonc +{ + "name": "shop", + "edge": { + "serve": "swoole", // or "fpm" + "port": 9601, // swoole upstream port + "socket": "unix:/run/php/php8.4-fpm.sock", // fpm socket (fpm model) + "env": { "APP_ENV": "production", "SHOP_FLAG": "1" } // per-project extras + } +} +``` + +Defaults come from `EDGE_SERVE_MODEL` / `EDGE_FPM_SOCKET` / +`EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT`. + ## Domains — public vs local Collected automatically from `projects/projects.json` (each project's @@ -106,6 +137,11 @@ server config and `/etc/hosts`). | `EDGE_MANAGE_HOSTS` | `true` | write local domains to /etc/hosts on apply | | `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | | `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config | +| `EDGE_SERVE_MODEL` | `fpm` | default serve model (`fpm` \| `swoole`); per-project override in `proj.json` | +| `EDGE_FPM_SOCKET` | `unix:/run/php/php-fpm.sock` | default PHP-FPM socket/addr | +| `EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT` | `127.0.0.1` / `9500` | Swoole upstream host + base port | +| `EDGE_INJECT_KERNEL_ENV` | `true` | inject `PSP_GLOBAL_AUTOLOAD` + `HKM_KERNEL_HOME` into each vhost | +| `EDGE_APP_ENV` | `APP_ENV` or `production` | `APP_ENV` written into each vhost | Defaults write to `var/edge/` so no root is needed to test; in production point `EDGE_*_PATH` at the real nginx/Apache include dirs and run `hkm` with the diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php index d9c368a..612b55b 100644 --- a/plugins/Edge/config/edge.php +++ b/plugins/Edge/config/edge.php @@ -93,4 +93,25 @@ // Set true to ALSO include local domains in the generated server config // (e.g. when nginx serves your .local sites in local development). 'include_local_in_server' => filter_var(env('EDGE_LOCAL_IN_SERVER', 'false'), FILTER_VALIDATE_BOOL), + + // How each project is served (per-project override via proj.json "edge"). + 'serve' => [ + 'model' => (string) (env('EDGE_SERVE_MODEL') ?: 'fpm'), // fpm | swoole + 'fpm_socket' => (string) (env('EDGE_FPM_SOCKET') ?: 'unix:/run/php/php-fpm.sock'), + 'swoole_host' => (string) (env('EDGE_SWOOLE_HOST') ?: '127.0.0.1'), + 'swoole_base_port' => (int) (env('EDGE_SWOOLE_BASE_PORT') ?: 9500), + ], + + // Inject the kernel-resolution env (PSP_GLOBAL_AUTOLOAD / HKM_KERNEL_HOME) + // into each vhost so FPM workers boot against the correct kernel. + 'inject_kernel_env' => filter_var(env('EDGE_INJECT_KERNEL_ENV', 'true'), FILTER_VALIDATE_BOOL), + + // Base run-env written into every generated vhost. Per-project proj.json + // "edge": { "env": { … } } extras override these. + 'env' => [ + 'app_env' => (string) (env('EDGE_APP_ENV') ?: env('APP_ENV') ?: 'production'), + 'userdata_dir' => (string) env('HKM_USERDATA_DIR', ''), + 'global_autoload' => (string) env('PSP_GLOBAL_AUTOLOAD', ''), + 'kernel_home' => (string) env('HKM_KERNEL_HOME', ''), + ], ]; diff --git a/plugins/Edge/module.json b/plugins/Edge/module.json index ea301b9..7da83c7 100644 --- a/plugins/Edge/module.json +++ b/plugins/Edge/module.json @@ -32,6 +32,12 @@ { "key": "EDGE_MANAGE_HOSTS", "type": "bool", "required": false }, { "key": "EDGE_HOSTS_PATH", "type": "string", "required": false }, { "key": "EDGE_HOSTS_IP", "type": "string", "required": false }, - { "key": "EDGE_LOCAL_IN_SERVER", "type": "bool", "required": false } + { "key": "EDGE_LOCAL_IN_SERVER", "type": "bool", "required": false }, + { "key": "EDGE_SERVE_MODEL", "type": "string", "required": false }, + { "key": "EDGE_FPM_SOCKET", "type": "string", "required": false }, + { "key": "EDGE_SWOOLE_HOST", "type": "string", "required": false }, + { "key": "EDGE_SWOOLE_BASE_PORT", "type": "int", "required": false }, + { "key": "EDGE_INJECT_KERNEL_ENV", "type": "bool", "required": false }, + { "key": "EDGE_APP_ENV", "type": "string", "required": false } ] } diff --git a/tools/src/templates/env.example b/tools/src/templates/env.example index af868d4..f094d1a 100644 --- a/tools/src/templates/env.example +++ b/tools/src/templates/env.example @@ -190,6 +190,13 @@ HSTS_MAX_AGE=31536000 # EDGE_LOCAL_IN_SERVER=false # also serve .local domains via nginx locally # EDGE_EXTRA_DOMAINS= # comma-separated extra hostnames # EDGE_EXCLUDE_DOMAINS= +# Per-project serving (override per project in proj.json "edge": {...}): +# EDGE_SERVE_MODEL=fpm # fpm | swoole +# EDGE_FPM_SOCKET=unix:/run/php/php-fpm.sock +# EDGE_SWOOLE_HOST=127.0.0.1 +# EDGE_SWOOLE_BASE_PORT=9500 +# EDGE_INJECT_KERNEL_ENV=true # inject PSP_GLOBAL_AUTOLOAD + HKM_KERNEL_HOME into vhosts +# EDGE_APP_ENV=production # APP_ENV written into each vhost # Observability # Honeycomb, DataDog, etc. From c84409c510d6a17fba29415556beaf29720d5c5e Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 04:25:45 +0300 Subject: [PATCH 11/50] feat(edge): default commands to the CURRENT project (base_path/proj.json); add --all for the whole registry --- .../API/Contracts/EdgeServiceContract.php | 16 ++-- plugins/Edge/Application/EdgeService.php | 20 ++--- .../Infrastructure/Cli/EdgeApplyCommand.php | 4 +- .../Infrastructure/Cli/EdgeHostsCommand.php | 2 + .../Infrastructure/Cli/EdgeStatusCommand.php | 4 +- plugins/Edge/Infrastructure/SiteCollector.php | 80 +++++++++++++------ plugins/Edge/README.md | 23 ++++-- 7 files changed, 98 insertions(+), 51 deletions(-) diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php index 30a8c14..0cbde3e 100644 --- a/plugins/Edge/API/Contracts/EdgeServiceContract.php +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -16,8 +16,11 @@ interface EdgeServiceContract /** Probe the host and return the detected web-server stack. */ public function detect(): ServerStack; - /** Detect + collect domains + render — WITHOUT touching the filesystem. */ - public function plan(): EdgePlan; + /** + * Detect + collect sites + render — WITHOUT touching the filesystem. + * $all=false (default) scopes to the CURRENT project; true = every project. + */ + public function plan(bool $all = false): EdgePlan; /** * Write the rendered config, sync local domains to /etc/hosts, then @@ -29,13 +32,14 @@ public function plan(): EdgePlan; * hosts?: array|null, message?: string * } */ - public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null): array; + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false): array; /** - * Sync the platform's LOCAL domains (.local / .test / …) into /etc/hosts - * (pointing at the loopback), or remove the managed block with $remove. + * Sync LOCAL domains (.local / .test / …) into /etc/hosts (pointing at the + * loopback), or remove the managed block with $remove. $all=false (default) + * scopes to the current project. * * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, block?: string, message?: string} */ - public function syncHosts(bool $remove = false, bool $dryRun = false): array; + public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false): array; } diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php index 89e18cc..85b717a 100644 --- a/plugins/Edge/Application/EdgeService.php +++ b/plugins/Edge/Application/EdgeService.php @@ -32,24 +32,24 @@ public function detect(): ServerStack return $this->probe->detect(); } - public function plan(): EdgePlan + public function plan(bool $all = false): EdgePlan { $stack = $this->probe->detect(); $strategy = $stack->strategy(); - // Per-project sites (public domains → server config); local (.local/.test) - // domains ride along on each site but go to /etc/hosts, not the config. - $sites = $this->sites->sites(); + // Default: ONLY the current project. --all renders every registered one. + // Public domains → server config; local (.local/.test) → /etc/hosts. + $sites = $this->sites->sites($all); [$path, $body] = $this->renderer->render($strategy, $sites); - return new EdgePlan($stack, $strategy, $sites, $this->sites->localDomains(), $path, $body); + return new EdgePlan($stack, $strategy, $sites, $this->sites->localDomains($all), $path, $body); } - public function syncHosts(bool $remove = false, bool $dryRun = false): array + public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false): array { return $this->hosts->sync( - domains: $this->sites->localDomains(), + domains: $this->sites->localDomains($all), ip: (string) edge_config('hosts.ip', '127.0.0.1'), path: (string) edge_config('hosts.path', '/etc/hosts'), remove: $remove, @@ -57,15 +57,15 @@ public function syncHosts(bool $remove = false, bool $dryRun = false): array ); } - public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null): array + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false): array { - $plan = $this->plan(); + $plan = $this->plan($all); // 1. Local domains → /etc/hosts (independent of any web server, so it // still runs when the strategy is None). $hosts = null; if ($manageHosts ?? (bool) edge_config('manage_hosts', true)) { - $hosts = $this->syncHosts(dryRun: $dryRun); + $hosts = $this->syncHosts(dryRun: $dryRun, all: $all); } if ($plan->strategy === Strategy::None) { diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php index 468dfdf..ad6b0c0 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php @@ -30,6 +30,7 @@ protected function configure(): void $this->addOption('dry-run', '', 'Print the config that would be written; change nothing'); $this->addOption('no-reload', '', 'Write the config file but do not validate or reload'); $this->addOption('no-hosts', '', 'Skip writing local (.local/.test) domains to /etc/hosts'); + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); } protected function handle(): int @@ -37,8 +38,9 @@ protected function handle(): int $dryRun = $this->hasOption('dry-run'); $reload = !$this->hasOption('no-reload'); $hosts = $this->hasOption('no-hosts') ? false : null; // null = use config default + $all = $this->hasOption('all'); - $result = $this->edge->apply(reload: $reload, dryRun: $dryRun, manageHosts: $hosts); + $result = $this->edge->apply(reload: $reload, dryRun: $dryRun, manageHosts: $hosts, all: $all); $this->reportHosts($result['hosts'] ?? null); diff --git a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php index 0046b49..9bf8e6f 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php @@ -30,6 +30,7 @@ protected function configure(): void $this->addOption('dry-run', '', 'Show what would change; write nothing'); $this->addOption('remove', '', 'Remove the HKM-managed block from the hosts file'); + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); } protected function handle(): int @@ -37,6 +38,7 @@ protected function handle(): int $result = $this->edge->syncHosts( remove: $this->hasOption('remove'), dryRun: $this->hasOption('dry-run'), + all: $this->hasOption('all'), ); $count = (int) ($result['count'] ?? 0); diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php index 95084d0..9fc9996 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -24,11 +24,13 @@ protected function configure(): void { $this->name = 'edge:status'; $this->description = 'Detect nginx/Apache and show the edge routing strategy that would be applied'; + + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); } protected function handle(): int { - $plan = $this->edge->plan(); + $plan = $this->edge->plan($this->hasOption('all')); $stack = $plan->stack; $this->section('Edge — detected stack'); diff --git a/plugins/Edge/Infrastructure/SiteCollector.php b/plugins/Edge/Infrastructure/SiteCollector.php index f7362d0..c8a6ed8 100644 --- a/plugins/Edge/Infrastructure/SiteCollector.php +++ b/plugins/Edge/Infrastructure/SiteCollector.php @@ -19,46 +19,43 @@ */ final class SiteCollector { - /** @return list */ - public function sites(): array + /** + * @param bool $all false (default) = ONLY the current project (read from + * base_path()/proj.json); true = every registered project. + * @return list + */ + public function sites(bool $all = false): array { + if (!$all) { + $site = $this->currentSite(); + + return $site !== null ? [$site] : []; + } + $sites = []; foreach ($this->projects() as $name => $project) { - $path = rtrim((string) ($project['path'] ?? ''), '/'); - $domains = $this->classify((array) ($project['domains'] ?? [])); - if ($path === '' || ($domains['public'] === [] && $domains['local'] === [])) { - continue; + $site = $this->buildSite((string) $name, (string) ($project['path'] ?? ''), (array) ($project['domains'] ?? [])); + if ($site !== null) { + $sites[] = $site; } - - $proj = $this->projJson($path); - $edge = (array) ($proj['edge'] ?? []); - $model = ServeModel::from_((string) ($edge['serve'] ?? edge_config('serve.model', 'fpm'))); - - $sites[] = new Site( - name: (string) $name, - docroot: $path . '/app/public', - publicDomains: $domains['public'], - localDomains: $domains['local'], - model: $model, - upstream: $this->upstream($model, $edge), - env: $this->env($path, $edge), - ); } return $sites; } - /** All local (dev-only) domains across every project + EDGE_EXTRA_DOMAINS. */ - public function localDomains(): array + /** Local (dev-only) domains for the current project (or all projects). */ + public function localDomains(bool $all = false): array { $local = []; - foreach ($this->sites() as $site) { + foreach ($this->sites($all) as $site) { foreach ($site->localDomains as $d) { $local[] = $d; } } - foreach ($this->classify((array) edge_config('extra_domains', []))['local'] as $d) { - $local[] = $d; + if ($all) { + foreach ($this->classify((array) edge_config('extra_domains', []))['local'] as $d) { + $local[] = $d; + } } $local = array_values(array_unique($local)); sort($local); @@ -66,6 +63,39 @@ public function localDomains(): array return $local; } + /** The project the command is running in — its own proj.json is the truth. */ + private function currentSite(): ?Site + { + $path = rtrim((string) base_path(), '/'); + $proj = $this->projJson($path); + $name = (string) ($proj['name'] ?? basename($path)); + + return $this->buildSite($name, $path, (array) ($proj['domains'] ?? [])); + } + + /** @param array $domains */ + private function buildSite(string $name, string $path, array $domains): ?Site + { + $path = rtrim($path, '/'); + $cls = $this->classify($domains); + if ($path === '' || ($cls['public'] === [] && $cls['local'] === [])) { + return null; + } + + $edge = (array) ($this->projJson($path)['edge'] ?? []); + $model = ServeModel::from_((string) ($edge['serve'] ?? edge_config('serve.model', 'fpm'))); + + return new Site( + name: $name, + docroot: $path . '/app/public', + publicDomains: $cls['public'], + localDomains: $cls['local'], + model: $model, + upstream: $this->upstream($model, $edge), + env: $this->env($path, $edge), + ); + } + // ── registries ──────────────────────────────────────────────────────────── /** @return array> */ diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md index 353c1f9..b18b570 100644 --- a/plugins/Edge/README.md +++ b/plugins/Edge/README.md @@ -50,17 +50,24 @@ layer never sees plaintext, so certificates live on the backends. ## Commands +By default every command scopes to the **current project** (read from +`base_path()/proj.json` — i.e. the project you run it in). Add **`--all`** to act +on every registered project in the global `projects.json`. + ```bash -hkm edge:status # probe host; show stack, strategy, server + local domains -hkm edge:apply # render + write server config + sync /etc/hosts + reload -hkm edge:apply --dry-run # print what WOULD be written; change nothing -hkm edge:apply --no-reload # write the file only; skip validate + reload -hkm edge:apply --no-hosts # skip the /etc/hosts sync -hkm edge:hosts # sync ONLY the local domains into /etc/hosts (needs sudo) -hkm edge:hosts --dry-run # show the hosts block that would be written -hkm edge:hosts --remove # remove the HKM-managed hosts block +hkm cli -p edge:status # probe host; show THIS project's plan +hkm cli -p edge:status --all # every registered project +hkm cli -p edge:apply # render + write config + sync /etc/hosts + reload +hkm cli -p edge:apply --dry-run # print what WOULD be written +hkm cli -p edge:apply --no-reload # write only; skip validate + reload +hkm cli -p edge:apply --no-hosts # skip the /etc/hosts sync +hkm cli -p edge:apply --all # render ALL projects into one file +hkm cli -p edge:hosts # sync THIS project's local domains → /etc/hosts (sudo) +hkm cli -p edge:hosts --remove # remove the HKM-managed hosts block ``` +(During development add `--dev` so `hkm` uses your dev kernel checkout.) + ## Per-project serving (the vhost model) Edge is **project-aware**: it reads the global registry (`projects.json` → From 0533c818ce1e9020c96d5d44a57a189605d52c25 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 04:38:38 +0300 Subject: [PATCH 12/50] feat(edge): auto-resolve PHP-FPM socket to match the CLI PHP version (multi-PHP hosts); show php/fpm binding in edge:status --- .../API/Contracts/EdgeServiceContract.php | 7 ++ plugins/Edge/Application/EdgeService.php | 9 +++ .../Infrastructure/Cli/EdgeStatusCommand.php | 7 ++ plugins/Edge/Infrastructure/SiteCollector.php | 9 ++- plugins/Edge/Infrastructure/SystemProbe.php | 67 +++++++++++++++++++ plugins/Edge/Provider.php | 6 +- plugins/Edge/README.md | 2 +- plugins/Edge/config/edge.php | 4 +- 8 files changed, 105 insertions(+), 6 deletions(-) diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php index 0cbde3e..63da28e 100644 --- a/plugins/Edge/API/Contracts/EdgeServiceContract.php +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -16,6 +16,13 @@ interface EdgeServiceContract /** Probe the host and return the detected web-server stack. */ public function detect(): ServerStack; + /** + * PHP-FPM binding info for the CLI PHP version running the command. + * + * @return array{version: string, socket: string, active: list} + */ + public function phpFpm(): array; + /** * Detect + collect sites + render — WITHOUT touching the filesystem. * $all=false (default) scopes to the CURRENT project; true = every project. diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php index 85b717a..4256818 100644 --- a/plugins/Edge/Application/EdgeService.php +++ b/plugins/Edge/Application/EdgeService.php @@ -32,6 +32,15 @@ public function detect(): ServerStack return $this->probe->detect(); } + public function phpFpm(): array + { + return [ + 'version' => $this->probe->phpCliVersion(), + 'socket' => $this->probe->phpFpmSocket(), + 'active' => $this->probe->phpFpmActive(), + ]; + } + public function plan(bool $all = false): EdgePlan { $stack = $this->probe->detect(); diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php index 9fc9996..6d6d6a9 100644 --- a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -40,6 +40,13 @@ protected function handle(): int $this->info('nginx stream : ' . $yn($stack->nginxHasStream)); $this->info('apache installed: ' . $yn($stack->apacheInstalled)); $this->info('apache active : ' . $yn($stack->apacheActive)); + + $php = $this->edge->phpFpm(); + $this->info('php (cli) : ' . $php['version']); + $this->info('php-fpm socket : ' . $php['socket']); + if ($php['active'] !== []) { + $this->info('php-fpm active : ' . implode(', ', $php['active'])); + } $this->newLine(); $this->success('strategy: ' . $plan->strategy->label()); $this->info('project sites : ' . count($plan->sites)); diff --git a/plugins/Edge/Infrastructure/SiteCollector.php b/plugins/Edge/Infrastructure/SiteCollector.php index c8a6ed8..24c66cf 100644 --- a/plugins/Edge/Infrastructure/SiteCollector.php +++ b/plugins/Edge/Infrastructure/SiteCollector.php @@ -19,6 +19,8 @@ */ final class SiteCollector { + public function __construct(private readonly SystemProbe $probe = new SystemProbe()) {} + /** * @param bool $all false (default) = ONLY the current project (read from * base_path()/proj.json); true = every registered project. @@ -134,8 +136,11 @@ private function upstream(ServeModel $model, array $edge): string return "{$host}:{$port}"; } - // FPM: an explicit per-project socket, else the global default. - return (string) ($edge['socket'] ?? edge_config('serve.fpm_socket', 'unix:/run/php/php-fpm.sock')); + // FPM: an explicit per-project socket, else an explicit EDGE_FPM_SOCKET, + // else auto-resolve the socket matching the CLI PHP version (multi-PHP hosts). + $explicit = (string) ($edge['socket'] ?? edge_config('serve.fpm_socket', '')); + + return $explicit !== '' ? $explicit : $this->probe->phpFpmSocket(); } /** diff --git a/plugins/Edge/Infrastructure/SystemProbe.php b/plugins/Edge/Infrastructure/SystemProbe.php index 317c4c9..6e69e24 100644 --- a/plugins/Edge/Infrastructure/SystemProbe.php +++ b/plugins/Edge/Infrastructure/SystemProbe.php @@ -29,6 +29,73 @@ public function detect(): ServerStack ); } + /** The PHP version running THIS command, e.g. "8.4". */ + public function phpCliVersion(): string + { + return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + } + + /** + * Resolve the PHP-FPM upstream that matches the CLI PHP version running the + * command, so a multi-PHP host binds the vhost to the RIGHT pool: + * 1. the versioned socket for the CLI version (Debian/Ubuntu naming), + * 2. any versioned socket present — the exact version, else the newest, + * 3. a generic/unversioned socket (RHEL, custom), + * 4. a TCP fallback (127.0.0.1:9000, common in containers). + */ + public function phpFpmSocket(): string + { + $ver = $this->phpCliVersion(); + + foreach (["/run/php/php{$ver}-fpm.sock", "/var/run/php/php{$ver}-fpm.sock"] as $sock) { + if (@file_exists($sock)) { + return "unix:{$sock}"; + } + } + + $socks = array_merge(glob('/run/php/php*-fpm.sock') ?: [], glob('/var/run/php/php*-fpm.sock') ?: []); + if ($socks !== []) { + // exact CLI version wins; otherwise the newest available pool. + usort($socks, fn (string $a, string $b): int => version_compare($this->sockVersion($b), $this->sockVersion($a))); + foreach ($socks as $s) { + if ($this->sockVersion($s) === $ver) { + return "unix:{$s}"; + } + } + return "unix:{$socks[0]}"; + } + + foreach (['/run/php-fpm/www.sock', '/var/run/php-fpm/www.sock', '/run/php/php-fpm.sock'] as $sock) { + if (@file_exists($sock)) { + return "unix:{$sock}"; + } + } + + return '127.0.0.1:9000'; + } + + /** Which php*-fpm services systemd reports as active (best-effort, for status). */ + public function phpFpmActive(): array + { + [$code, $out] = $this->run("systemctl list-units --type=service --state=active --no-legend 'php*-fpm*.service'"); + if ($code !== 0 || trim($out) === '') { + return []; + } + $names = []; + foreach (explode("\n", trim($out)) as $line) { + if (preg_match('/(php[0-9.]*-fpm[^\s]*)\.service/', $line, $m)) { + $names[] = $m[1]; + } + } + + return array_values(array_unique($names)); + } + + private function sockVersion(string $path): string + { + return preg_match('/php(\d+\.\d+)-fpm\.sock$/', $path, $m) ? $m[1] : '0'; + } + /** Run an arbitrary command; returns [exitCode, combinedOutput]. */ public function run(string $command): array { diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php index 52a354b..f60ffd3 100644 --- a/plugins/Edge/Provider.php +++ b/plugins/Edge/Provider.php @@ -67,9 +67,11 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke private static function service(): EdgeService { + $probe = new SystemProbe(); + return new EdgeService( - new SystemProbe(), - new SiteCollector(), + $probe, + new SiteCollector($probe), new ConfigRenderer(), new HostsFileWriter(), ); diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md index b18b570..5c76424 100644 --- a/plugins/Edge/README.md +++ b/plugins/Edge/README.md @@ -145,7 +145,7 @@ server config and `/etc/hosts`). | `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | | `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config | | `EDGE_SERVE_MODEL` | `fpm` | default serve model (`fpm` \| `swoole`); per-project override in `proj.json` | -| `EDGE_FPM_SOCKET` | `unix:/run/php/php-fpm.sock` | default PHP-FPM socket/addr | +| `EDGE_FPM_SOCKET` | *(auto)* | pin the FPM socket/addr; empty = auto-resolve the socket matching the CLI PHP version | | `EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT` | `127.0.0.1` / `9500` | Swoole upstream host + base port | | `EDGE_INJECT_KERNEL_ENV` | `true` | inject `PSP_GLOBAL_AUTOLOAD` + `HKM_KERNEL_HOME` into each vhost | | `EDGE_APP_ENV` | `APP_ENV` or `production` | `APP_ENV` written into each vhost | diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php index 612b55b..35df6a6 100644 --- a/plugins/Edge/config/edge.php +++ b/plugins/Edge/config/edge.php @@ -97,7 +97,9 @@ // How each project is served (per-project override via proj.json "edge"). 'serve' => [ 'model' => (string) (env('EDGE_SERVE_MODEL') ?: 'fpm'), // fpm | swoole - 'fpm_socket' => (string) (env('EDGE_FPM_SOCKET') ?: 'unix:/run/php/php-fpm.sock'), + // Empty = auto-resolve the FPM socket matching the CLI PHP version + // (multi-PHP hosts). Set explicitly to pin a socket/addr. + 'fpm_socket' => (string) env('EDGE_FPM_SOCKET', ''), 'swoole_host' => (string) (env('EDGE_SWOOLE_HOST') ?: '127.0.0.1'), 'swoole_base_port' => (int) (env('EDGE_SWOOLE_BASE_PORT') ?: 9500), ], From f2bef6ef4e01b6717e2f17641a390324c9a42300 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 17 Jul 2026 05:16:20 +0300 Subject: [PATCH 13/50] feat(userconfig): resolve config path for non-root sudo users --- tools/src/lib/userconfig.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig index a86b2e5..46174ce 100644 --- a/tools/src/lib/userconfig.zig +++ b/tools/src/lib/userconfig.zig @@ -11,7 +11,18 @@ const EnvMap = std.process.Environ.Map; const Dir = std.Io.Dir; /// Absolute path to the config file, honouring XDG_CONFIG_HOME then HOME. +/// +/// Under `sudo`, HOME is root's (/root) but the config was written by the +/// invoking user — so `sudo hkm --dev` would otherwise lose HKM_DEV_HOME and +/// everything else in config.env. When SUDO_USER is set we resolve the config in +/// that user's home instead, so a privileged run (e.g. editing /etc/hosts) still +/// sees the same configuration as a normal run. pub fn path(allocator: std.mem.Allocator, env: *EnvMap) !?[]const u8 { + if (env.get("SUDO_USER")) |user| { + if (user.len > 0 and !std.mem.eql(u8, user, "root")) { + return try std.fmt.allocPrint(allocator, "/home/{s}/.config/hkm/config.env", .{user}); + } + } if (env.get("XDG_CONFIG_HOME")) |x| { if (x.len > 0) return try std.fmt.allocPrint(allocator, "{s}/hkm/config.env", .{x}); } From 5514c5c2655718e8e5df4c4b28132a9a921d74a7 Mon Sep 17 00:00:00 2001 From: hakeem code Date: Fri, 17 Jul 2026 05:22:58 +0300 Subject: [PATCH 14/50] =?UTF-8?q?Adopt=20master=E2=86=92main=20branch=20mo?= =?UTF-8?q?del,=20automated=20releases,=20and=20main=20branch=20protection?= =?UTF-8?q?=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * remove module common-type-alias * remove module bind-it * add submodules for bind-it, php-io-cli, and module-template * feat(module): add commands for managing git submodules - Implemented `module:add` command to add a git submodule and configure it as a Composer path package. - Implemented `module:remove` command to fully remove a git submodule and clean up all traces from the repository. - Created a bash script `module.sh` for adding and removing modules with similar functionality. - Added scaffolding for module structure including `src/` directory and `composer.json`. - Updated root `composer.json` to include new modules as path repositories. - Added error handling and user confirmation prompts for destructive actions. * fix(common-type-alias): update subproject commit to indicate dirty state * d * remove module orchestrator * Add unit tests for I18n, Pageflow, Support, and Validation plugins; introduce Zig build system - Created `TranslatorTest` to validate translation functionality including key resolution and interpolation. - Implemented `PageflowResponderTest` to ensure correct rendering of pages and handling of requests. - Added `CollectionTest` to test collection operations and array helper functions. - Developed `ResourceTest` to verify resource transformation and serialization. - Established `ValidatorTest` to check validation rules and error handling. - Introduced Zig build configuration with `build.zig`, `config.zig`, and `main.zig` for project management. * fix(pulse-engine): update subproject commit to indicate dirty state * feat(routes): implement project-level route handling and manifest compilation * feat(cookie): introduce cookie management with configuration and helpers - Added cookie configuration file and helper functions for managing cookies. - Implemented CookieJar for queuing and reading cookies, including encryption support. - Created tests for cookie functionality, ensuring correct behavior for setting, reading, and deleting cookies. feat(http): enhance request handling with RequestAware interface - Introduced RequestAware interface for controllers to hold the active Request. - Updated ExecuteStage to set the Request on controllers implementing RequestAware. feat(http): implement route filters for declarative behavior - Added FilterRegistry to map route-filter aliases to pipeline stages. - Implemented RouteFilterStage to execute filters declared on routes. feat(view): compile view manifest for structured view resolution - Created CompileViewManifestStage to compile view paths with priority handling. - Ensured project views take precedence over plugin views. feat(api): create base controllers for JSON and HTML responses - Developed ApiController for JSON endpoints with standardized response methods. - Created ViewController for rendering HTML views with integrated cookie management. feat(task): add example plugin with JSON filter and view rendering - Implemented RequireJsonStage to enforce JSON response expectations. - Added a welcome view for the Task plugin demonstrating view rendering. * refactor(routes): enhance project route handling with additional validation and state management * feat(storage): add storage configuration and local storage adapter - Introduced a new storage configuration file to manage storage settings via environment variables. - Implemented a LocalStorageAdapter for handling file storage operations, including storing, retrieving, and deleting files. - Added tests for LocalStorageAdapter to ensure functionality and edge cases are covered. feat(session): implement session management traits and tests - Created HasRequest and InteractsWithSession traits for managing request and session data in controllers. - Developed unit tests for session management, ensuring proper functionality of session storage, retrieval, and lifecycle management. test(session): add comprehensive tests for session handling - Added tests for cookie-based session handling, including encryption, tampering, and session expiration scenarios. - Ensured that session management adheres to expected behaviors under various conditions. feat(s3): implement S3 storage adapter with configuration options - Added S3StorageAdapter to support AWS S3 and compatible storage services. - Implemented configuration options for region, credentials, and custom endpoints. - Created tests to validate S3 adapter functionality and credential resolution. * feat(seo): Implement SEO components including RouteCatalog, SeoHead, SitemapGenerator, and SitemapStreamWriter - Added RouteCatalog to manage public routes for sitemaps based on the compiled route manifest. - Introduced SeoHead for assembling comprehensive SEO elements for pages. - Created SitemapGenerator to facilitate sitemap creation from public routes and dynamic URLs. - Developed SitemapSource to combine static and dynamic URLs for sitemaps. - Implemented SitemapStreamWriter for efficient streaming of large sitemaps, supporting gzip compression. - Added SitemapUrlProvider interface for dynamic route pattern expansion into concrete URLs from data stores. * Refactor code structure for improved readability and maintainability * Add integration tests for OAuth2 functionality - Implement OAuth2HttpIntegrationTest to cover HTTP interactions with OAuth2 controllers, including token issuance, introspection, and discovery. - Implement OAuth2PersistenceIntegrationTest to validate the behavior of repositories and services against an in-memory SQLite database, focusing on authorization code flow, refresh token rotation, and client CRUD operations. * feat: Implement DataConverter for hydration between DB and Domain objects - Added DataConverter class to handle conversion between raw DB data and PHP Domain objects. - Introduced methods for data extraction and reconstruction with type casting support. feat: Create Resource and ResourceCollection for API response transformation - Added Resource class to map domain objects to API response shapes. - Introduced ResourceCollection to handle lists of resources and their transformations. feat: Add Str utility class for string manipulation - Implemented various string utility methods including studly, camel, snake, kebab, and slug. test: Add unit tests for RequireTenantStage and RouteFilterStage - Implemented tests to ensure tenant requirements are enforced in HTTP request handling. - Verified correct behavior of route filters in the pipeline. test: Add TenantAdminService tests for tenant management functionality - Created tests to validate tenant creation, provisioning, and authorization checks. test: Implement FeedbackService tests for user feedback submission and retrieval - Added tests to ensure proper handling of feedback submissions and access control. test: Add UserSettingsService tests for user preferences management - Implemented tests to verify user settings updates and validation rules. * Refactor code structure for improved readability and maintainability * feat: Add native launcher and install script for HKM kernel with dependency resolution * feat: Implement seeder command structure with run, fresh, and status functionalities * feat: Add versioning and upgrade command for kernel management * feat: Add upgrade command for kernel updates and implement versioning in binaries * feat: Add unit tests for exception handling, request, response, identity, and security verdict functionalities * Update PHP version requirement to 8.4 across various scripts - Updated the minimum PHP version requirement from 8.2 to 8.4 in bundle.sh, ensuring compatibility with the latest features. - Modified the doctor.zig command to check for PHP version 8.4, reflecting the updated requirement. - Adjusted install-kernel.sh to require PHP 8.4, providing clearer instructions for users. - Updated installation instructions for Debian/Ubuntu to install PHP 8.4 and its extensions. * feat: Add PHPUnit configuration file for unit testing * feat: Update CI workflows to use self-hosted Zig toolchain and improve macOS bundle process * docs: rewrite README as a full framework guide (concepts, lifecycle, usage) * ci(release): auto-tag new CHANGELOG version on merge to main -> triggers Release build * ci(release): auto-release on merge to main via workflow_call (no PAT); tag from CHANGELOG version * docs(readme): document master->main branch model and automatic CHANGELOG-driven releases * ci(security): CODEOWNERS + main branch protection script (required reviews, code owners, CI gates, linear history) * feat(edge): host-aware web-server config plugin (nginx SNI stream splitter / nginx-only / Apache), generates from platform domains + CLI apply * feat(edge): classify .local/.test as local domains — exclude from server config, sync to /etc/hosts (edge:hosts, --no-hosts) * fix(edge): resolve project registry from global kernel home (not project base_path); document EDGE_* env in template * feat(edge): project-aware config — per-project vhosts (docroot app/public, fpm|swoole via proj.json) with injected run-env (APP_ENV/HKM_USERDATA_DIR/PSP_GLOBAL_AUTOLOAD/HKM_KERNEL_HOME) * feat(edge): default commands to the CURRENT project (base_path/proj.json); add --all for the whole registry * feat(edge): auto-resolve PHP-FPM socket to match the CLI PHP version (multi-PHP hosts); show php/fpm binding in edge:status * feat(userconfig): resolve config path for non-root sudo users --------- Co-authored-by: Hakeem Shamavu --- .github/CODEOWNERS | 2 + .github/workflows/auto-release.yml | 74 ++++ .github/workflows/release.yml | 33 +- README.md | 21 + composer.json | 3 +- ...01_create_personal_access_tokens_table.php | 27 ++ ...nd_abilities_to_personal_access_tokens.php | 45 +++ ..._04_000002_create_refresh_tokens_table.php | 60 +++ ..._06_05_000001_create_casbin_rule_table.php | 29 ++ .../API/Contracts/EdgeServiceContract.php | 52 +++ plugins/Edge/Application/EdgeService.php | 143 +++++++ plugins/Edge/Domain/EdgePlan.php | 26 ++ plugins/Edge/Domain/ServeModel.php | 23 ++ plugins/Edge/Domain/ServerStack.php | 56 +++ plugins/Edge/Domain/Site.php | 38 ++ plugins/Edge/Domain/Strategy.php | 35 ++ .../Infrastructure/Cli/EdgeApplyCommand.php | 92 +++++ .../Infrastructure/Cli/EdgeHostsCommand.php | 64 +++ .../Infrastructure/Cli/EdgeStatusCommand.php | 67 ++++ .../Edge/Infrastructure/ConfigRenderer.php | 308 +++++++++++++++ .../Edge/Infrastructure/HostsFileWriter.php | 70 ++++ plugins/Edge/Infrastructure/SiteCollector.php | 236 +++++++++++ plugins/Edge/Infrastructure/SystemProbe.php | 153 ++++++++ plugins/Edge/Provider.php | 79 ++++ plugins/Edge/README.md | 164 ++++++++ plugins/Edge/Support/helpers.php | 48 +++ plugins/Edge/config/edge.php | 119 ++++++ plugins/Edge/module.json | 43 ++ plugins/Mail/Infrastructure/SmtpMailer.php | 105 +++++ plugins/Mail/Infrastructure/SmtpTransport.php | 129 ++++++ ...6_27_000010_create_oauth_clients_table.php | 29 ++ ...7_000011_create_oauth_auth_codes_table.php | 34 ++ ...0012_create_oauth_refresh_tokens_table.php | 32 ++ ...06_27_000013_create_oauth_scopes_table.php | 23 ++ ...000014_create_oauth_device_codes_table.php | 33 ++ ...7_04_000001_add_owner_to_oauth_clients.php | 37 ++ .../Tenancy/Application/Ports/AuditReader.php | 44 +++ .../Tenancy/Application/Ports/AuditSink.php | 23 ++ .../Tenancy/Application/Ports/AuditWriter.php | 27 ++ .../Application/Services/AuditService.php | 49 +++ .../Tenancy/Domain/Entities/AuditEntry.php | 57 +++ .../Persistence/AuditLogRepository.php | 123 ++++++ .../Infrastructure/Persistence/AuditTrail.php | 60 +++ ...26_06_22_000005_create_audit_log_table.php | 45 +++ plugins/User/API/DTOs/FeedbackPage.php | 40 ++ plugins/User/API/DTOs/ListFeedbackQuery.php | 46 +++ plugins/User/API/DTOs/SubmitFeedbackDTO.php | 60 +++ .../FeedbackSubmittedIntegrationEvent.php | 50 +++ .../User/Application/Ports/FeedbackStore.php | 30 ++ .../Application/Services/FeedbackService.php | 181 +++++++++ .../User/Domain/Entities/FeedbackEntry.php | 102 +++++ .../Domain/ValueObjects/FeedbackCategory.php | 38 ++ .../User/Domain/ValueObjects/FeedbackId.php | 43 ++ .../Domain/ValueObjects/FeedbackMessage.php | 43 ++ .../Domain/ValueObjects/FeedbackRating.php | 59 +++ .../Domain/ValueObjects/FeedbackStatus.php | 39 ++ .../User/Infrastructure/Audit/AuditLogger.php | 106 +++++ .../Http/Controllers/FeedbackController.php | 57 +++ .../Infrastructure/Outbox/OutboxRelay.php | 90 +++++ .../Infrastructure/Outbox/OutboxWriter.php | 72 ++++ .../Persistence/FeedbackRepository.php | 145 +++++++ ...6_29_000005_create_user_feedback_table.php | 55 +++ .../User/resources/views/account/feedback.php | 116 ++++++ projects/projects.json | 21 +- .../Unit/Plugins/User/FeedbackServiceTest.php | 149 +++++++ .../User/Support/FakeFeedbackStore.php | 51 +++ tools/ci/protect-main.sh | 77 ++++ tools/src/lib/userconfig.zig | 11 + tools/src/templates/README.md | 27 ++ tools/src/templates/app/bootstrap/app.php | 329 ++++++++++++++++ .../app/bootstrap/kernel-autoload.php | 222 +++++++++++ tools/src/templates/app/cli/run.php | 40 ++ tools/src/templates/app/public/index.php | 66 ++++ tools/src/templates/app/swoole/index.php | 228 +++++++++++ tools/src/templates/app/worker/run.php | 112 ++++++ tools/src/templates/composer.json | 21 + .../templates/config/environments/local.php | 35 ++ .../config/environments/production.php | 36 ++ .../templates/config/environments/staging.php | 35 ++ .../templates/config/environments/testing.php | 35 ++ tools/src/templates/config/let-migrate.php | 57 +++ tools/src/templates/config/storage.php | 77 ++++ tools/src/templates/env.example | 207 ++++++++++ tools/src/templates/frontend/.gitignore | 7 + tools/src/templates/frontend/README.md | 120 ++++++ tools/src/templates/frontend/components.json | 21 + .../templates/frontend/docs/HOW_IT_WORKS.md | 257 ++++++++++++ tools/src/templates/frontend/index.html | 14 + tools/src/templates/frontend/package.json | 82 ++++ .../frontend/src/shared/hooks/use-toast.ts | 153 ++++++++ .../frontend/src/shared/lib/utils.ts | 7 + .../frontend/src/shared/providers/theme.tsx | 22 ++ .../frontend/src/shared/styles/theme.css | 113 ++++++ .../frontend/src/shared/ui/accordion.tsx | 55 +++ .../frontend/src/shared/ui/alert-dialog.tsx | 139 +++++++ .../frontend/src/shared/ui/alert.tsx | 59 +++ .../frontend/src/shared/ui/aspect-ratio.tsx | 5 + .../frontend/src/shared/ui/avatar.tsx | 48 +++ .../frontend/src/shared/ui/badge.tsx | 36 ++ .../frontend/src/shared/ui/breadcrumb.tsx | 115 ++++++ .../frontend/src/shared/ui/button.tsx | 57 +++ .../frontend/src/shared/ui/calendar.tsx | 70 ++++ .../templates/frontend/src/shared/ui/card.tsx | 83 ++++ .../frontend/src/shared/ui/carousel.tsx | 260 +++++++++++++ .../frontend/src/shared/ui/chart.tsx | 368 ++++++++++++++++++ .../frontend/src/shared/ui/checkbox.tsx | 28 ++ .../frontend/src/shared/ui/collapsible.tsx | 9 + .../frontend/src/shared/ui/command.tsx | 153 ++++++++ .../frontend/src/shared/ui/context-menu.tsx | 202 ++++++++++ .../frontend/src/shared/ui/dialog.tsx | 120 ++++++ .../frontend/src/shared/ui/drawer.tsx | 116 ++++++ .../frontend/src/shared/ui/dropdown-menu.tsx | 203 ++++++++++ .../src/shared/ui/form-standalone.tsx | 57 +++ .../templates/frontend/src/shared/ui/form.tsx | 177 +++++++++ .../frontend/src/shared/ui/hover-card.tsx | 27 ++ .../frontend/src/shared/ui/input-otp.tsx | 69 ++++ .../frontend/src/shared/ui/input.tsx | 25 ++ .../frontend/src/shared/ui/label.tsx | 24 ++ .../frontend/src/shared/ui/menubar.tsx | 238 +++++++++++ .../src/shared/ui/navigation-menu.tsx | 128 ++++++ .../src/shared/ui/page-transition.tsx | 96 +++++ .../frontend/src/shared/ui/pagination.tsx | 121 ++++++ .../frontend/src/shared/ui/popover.tsx | 31 ++ .../frontend/src/shared/ui/progress.tsx | 26 ++ .../frontend/src/shared/ui/radio-group.tsx | 42 ++ .../frontend/src/shared/ui/resizable.tsx | 43 ++ .../frontend/src/shared/ui/scroll-area.tsx | 46 +++ .../frontend/src/shared/ui/select.tsx | 162 ++++++++ .../frontend/src/shared/ui/separator.tsx | 29 ++ .../frontend/src/shared/ui/sheet.tsx | 138 +++++++ .../frontend/src/shared/ui/skeleton.tsx | 15 + .../frontend/src/shared/ui/slider.tsx | 26 ++ .../frontend/src/shared/ui/sonner.tsx | 30 ++ .../frontend/src/shared/ui/switch.tsx | 27 ++ .../frontend/src/shared/ui/table.tsx | 120 ++++++ .../templates/frontend/src/shared/ui/tabs.tsx | 53 +++ .../frontend/src/shared/ui/textarea.tsx | 24 ++ .../frontend/src/shared/ui/toast.tsx | 127 ++++++ .../frontend/src/shared/ui/toaster.tsx | 33 ++ .../frontend/src/shared/ui/toggle-group.tsx | 59 +++ .../frontend/src/shared/ui/toggle.tsx | 43 ++ .../frontend/src/shared/ui/tooltip.tsx | 28 ++ .../src/surfaces/admin/Pages/Dashboard.tsx | 43 ++ .../src/surfaces/admin/Pages/Login.tsx | 47 +++ .../src/surfaces/admin/Pages/Users/Index.tsx | 136 +++++++ .../frontend/src/surfaces/admin/index.tsx | 62 +++ .../src/surfaces/admin/styles/index.css | 3 + .../frontend/src/surfaces/admin/surface.json | 7 + .../src/surfaces/project/Pages/About.tsx | 20 + .../src/surfaces/project/Pages/Home.tsx | 94 +++++ .../frontend/src/surfaces/project/index.tsx | 64 +++ .../src/surfaces/project/styles/index.css | 10 + .../src/surfaces/project/surface.json | 7 + tools/src/templates/frontend/tsconfig.json | 22 ++ tools/src/templates/frontend/vite.config.ts | 114 ++++++ tools/src/templates/frontend/vite/aliases.ts | 78 ++++ .../src/templates/frontend/vite/build-all.mjs | 60 +++ tools/src/templates/frontend/vite/plugins.ts | 194 +++++++++ tools/src/templates/frontend/vite/surfaces.ts | 82 ++++ tools/src/templates/gitignore | 12 + tools/src/templates/plugin/Provider.php | 48 +++ tools/src/templates/plugin/config.php | 17 + tools/src/templates/plugin/factory.php | 19 + tools/src/templates/plugin/migration.php | 31 ++ tools/src/templates/plugin/module.json | 17 + tools/src/templates/plugin/seeder.php | 29 ++ tools/src/templates/plugin/view.php | 22 ++ tools/src/templates/proj.json | 11 + tools/src/templates/resources/welcome.php | 8 + .../src/Application/GreetingService.php | 27 ++ tools/src/templates/src/Domain/Greeting.php | 28 ++ .../Infrastructure/Http/HomeController.php | 44 +++ tools/src/templates/src/README.md | 55 +++ 173 files changed, 12555 insertions(+), 10 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/auto-release.yml create mode 100644 plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php create mode 100644 plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php create mode 100644 plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php create mode 100644 plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php create mode 100644 plugins/Edge/API/Contracts/EdgeServiceContract.php create mode 100644 plugins/Edge/Application/EdgeService.php create mode 100644 plugins/Edge/Domain/EdgePlan.php create mode 100644 plugins/Edge/Domain/ServeModel.php create mode 100644 plugins/Edge/Domain/ServerStack.php create mode 100644 plugins/Edge/Domain/Site.php create mode 100644 plugins/Edge/Domain/Strategy.php create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php create mode 100644 plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php create mode 100644 plugins/Edge/Infrastructure/ConfigRenderer.php create mode 100644 plugins/Edge/Infrastructure/HostsFileWriter.php create mode 100644 plugins/Edge/Infrastructure/SiteCollector.php create mode 100644 plugins/Edge/Infrastructure/SystemProbe.php create mode 100644 plugins/Edge/Provider.php create mode 100644 plugins/Edge/README.md create mode 100644 plugins/Edge/Support/helpers.php create mode 100644 plugins/Edge/config/edge.php create mode 100644 plugins/Edge/module.json create mode 100644 plugins/Mail/Infrastructure/SmtpMailer.php create mode 100644 plugins/Mail/Infrastructure/SmtpTransport.php create mode 100644 plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php create mode 100644 plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php create mode 100644 plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php create mode 100644 plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php create mode 100644 plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php create mode 100644 plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php create mode 100644 plugins/Tenancy/Application/Ports/AuditReader.php create mode 100644 plugins/Tenancy/Application/Ports/AuditSink.php create mode 100644 plugins/Tenancy/Application/Ports/AuditWriter.php create mode 100644 plugins/Tenancy/Application/Services/AuditService.php create mode 100644 plugins/Tenancy/Domain/Entities/AuditEntry.php create mode 100644 plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php create mode 100644 plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php create mode 100644 plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php create mode 100644 plugins/User/API/DTOs/FeedbackPage.php create mode 100644 plugins/User/API/DTOs/ListFeedbackQuery.php create mode 100644 plugins/User/API/DTOs/SubmitFeedbackDTO.php create mode 100644 plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php create mode 100644 plugins/User/Application/Ports/FeedbackStore.php create mode 100644 plugins/User/Application/Services/FeedbackService.php create mode 100644 plugins/User/Domain/Entities/FeedbackEntry.php create mode 100644 plugins/User/Domain/ValueObjects/FeedbackCategory.php create mode 100644 plugins/User/Domain/ValueObjects/FeedbackId.php create mode 100644 plugins/User/Domain/ValueObjects/FeedbackMessage.php create mode 100644 plugins/User/Domain/ValueObjects/FeedbackRating.php create mode 100644 plugins/User/Domain/ValueObjects/FeedbackStatus.php create mode 100644 plugins/User/Infrastructure/Audit/AuditLogger.php create mode 100644 plugins/User/Infrastructure/Http/Controllers/FeedbackController.php create mode 100644 plugins/User/Infrastructure/Outbox/OutboxRelay.php create mode 100644 plugins/User/Infrastructure/Outbox/OutboxWriter.php create mode 100644 plugins/User/Infrastructure/Persistence/FeedbackRepository.php create mode 100644 plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php create mode 100644 plugins/User/resources/views/account/feedback.php create mode 100644 tests/Unit/Plugins/User/FeedbackServiceTest.php create mode 100644 tests/Unit/Plugins/User/Support/FakeFeedbackStore.php create mode 100755 tools/ci/protect-main.sh create mode 100644 tools/src/templates/README.md create mode 100644 tools/src/templates/app/bootstrap/app.php create mode 100644 tools/src/templates/app/bootstrap/kernel-autoload.php create mode 100644 tools/src/templates/app/cli/run.php create mode 100644 tools/src/templates/app/public/index.php create mode 100644 tools/src/templates/app/swoole/index.php create mode 100644 tools/src/templates/app/worker/run.php create mode 100644 tools/src/templates/composer.json create mode 100644 tools/src/templates/config/environments/local.php create mode 100644 tools/src/templates/config/environments/production.php create mode 100644 tools/src/templates/config/environments/staging.php create mode 100644 tools/src/templates/config/environments/testing.php create mode 100644 tools/src/templates/config/let-migrate.php create mode 100644 tools/src/templates/config/storage.php create mode 100644 tools/src/templates/env.example create mode 100644 tools/src/templates/frontend/.gitignore create mode 100644 tools/src/templates/frontend/README.md create mode 100644 tools/src/templates/frontend/components.json create mode 100644 tools/src/templates/frontend/docs/HOW_IT_WORKS.md create mode 100644 tools/src/templates/frontend/index.html create mode 100644 tools/src/templates/frontend/package.json create mode 100644 tools/src/templates/frontend/src/shared/hooks/use-toast.ts create mode 100644 tools/src/templates/frontend/src/shared/lib/utils.ts create mode 100644 tools/src/templates/frontend/src/shared/providers/theme.tsx create mode 100644 tools/src/templates/frontend/src/shared/styles/theme.css create mode 100644 tools/src/templates/frontend/src/shared/ui/accordion.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/alert.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/avatar.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/badge.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/button.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/calendar.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/card.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/carousel.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/chart.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/checkbox.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/collapsible.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/command.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/context-menu.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/dialog.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/drawer.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/dropdown-menu.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/form-standalone.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/form.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/hover-card.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/input-otp.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/input.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/label.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/menubar.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/navigation-menu.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/page-transition.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/pagination.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/popover.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/progress.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/radio-group.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/resizable.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/scroll-area.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/select.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/separator.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/sheet.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/skeleton.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/slider.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/sonner.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/switch.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/table.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/tabs.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/textarea.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/toast.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/toaster.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/toggle-group.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/toggle.tsx create mode 100644 tools/src/templates/frontend/src/shared/ui/tooltip.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/admin/Pages/Dashboard.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/admin/Pages/Login.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/admin/Pages/Users/Index.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/admin/index.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/admin/styles/index.css create mode 100644 tools/src/templates/frontend/src/surfaces/admin/surface.json create mode 100644 tools/src/templates/frontend/src/surfaces/project/Pages/About.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/project/Pages/Home.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/project/index.tsx create mode 100644 tools/src/templates/frontend/src/surfaces/project/styles/index.css create mode 100644 tools/src/templates/frontend/src/surfaces/project/surface.json create mode 100644 tools/src/templates/frontend/tsconfig.json create mode 100644 tools/src/templates/frontend/vite.config.ts create mode 100644 tools/src/templates/frontend/vite/aliases.ts create mode 100644 tools/src/templates/frontend/vite/build-all.mjs create mode 100644 tools/src/templates/frontend/vite/plugins.ts create mode 100644 tools/src/templates/frontend/vite/surfaces.ts create mode 100644 tools/src/templates/gitignore create mode 100644 tools/src/templates/plugin/Provider.php create mode 100644 tools/src/templates/plugin/config.php create mode 100644 tools/src/templates/plugin/factory.php create mode 100644 tools/src/templates/plugin/migration.php create mode 100644 tools/src/templates/plugin/module.json create mode 100644 tools/src/templates/plugin/seeder.php create mode 100644 tools/src/templates/plugin/view.php create mode 100644 tools/src/templates/proj.json create mode 100644 tools/src/templates/resources/welcome.php create mode 100644 tools/src/templates/src/Application/GreetingService.php create mode 100644 tools/src/templates/src/Domain/Greeting.php create mode 100644 tools/src/templates/src/Infrastructure/Http/HomeController.php create mode 100644 tools/src/templates/src/README.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1946b65 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change requires review from a repo owner. +* @hakeemRash @Alshatri diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..f1190f9 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,74 @@ +name: Auto Release + +# When work lands on main, read the top CHANGELOG version and, if it has no +# matching tag yet, create the tag `vX.Y.Z` and run the Release build for it. +# +# No PAT required: instead of relying on the tag push to trigger release.yml +# (GitHub blocks workflow-triggering-workflow with the default token), this +# workflow CALLS release.yml directly via workflow_call, passing the version. + +"on": + push: + branches: + - main + paths: + - CHANGELOG.md # only a version bump can start a release + +permissions: + contents: write + +concurrency: + group: auto-release + cancel-in-progress: false + +jobs: + detect: + name: Detect new version + runs-on: ubuntu-22.04 + outputs: + release: ${{ steps.ver.outputs.release }} + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: { fetch-depth: 0 } # need all tags + + - name: Read top CHANGELOG version + id: ver + run: | + # First "## [x.y.z]" heading — skips "## [Unreleased]". + VERSION="$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md \ + | tr -d '## []')" + if [ -z "$VERSION" ]; then + echo "No versioned CHANGELOG entry found — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists — skipping." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "New version detected: v$VERSION" + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.ver.outputs.release == 'true' + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + + # Build + publish, reusing the single source of truth in release.yml. + release: + name: Build & publish + needs: detect + if: needs.detect.outputs.release == 'true' + uses: ./.github/workflows/release.yml + permissions: + contents: write + with: + version: ${{ needs.detect.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3aff70b..7b3856e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,30 @@ name: Release +# Two ways in: +# 1. push a `v*` tag → VERSION comes from the tag name. +# 2. workflow_call (version) → VERSION comes from the caller (auto-release.yml), +# so no PAT is needed to chain from a merge to main. "on": push: tags: - 'v*' + workflow_call: + inputs: + version: + description: "Release version without the leading v (e.g. 1.0.12)" + required: true + type: string permissions: contents: write # Single source of truth for bundling is tools/bundle.sh. Each job sets up the -# toolchain, then calls the script for its OS target. VERSION comes from the tag. +# toolchain, then calls the script for its OS target. VERSION comes from the tag +# (push) or the workflow_call input. env: ZIG_VERSION: "0.17.0-dev.657+2faf8debf" + # Resolved version for every job: the input when called, else the tag name. + RELEASE_VERSION: ${{ inputs.version || '' }} jobs: # ── Gate: run the PHPUnit suite. Nothing builds or publishes unless GREEN. ── @@ -49,7 +62,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (linux) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh linux + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh linux - uses: actions/upload-artifact@v5 with: { name: linux-deb, path: dist/*.deb } @@ -67,7 +80,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (windows) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh windows + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh windows - uses: actions/upload-artifact@v5 with: { name: windows-zip, path: dist/*.zip } @@ -87,7 +100,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: { php-version: "8.4", tools: composer } - name: Bundle (macos) - run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh macos + run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh macos - uses: actions/upload-artifact@v5 with: { name: macos-app, path: dist/*.tar.gz } @@ -97,13 +110,14 @@ jobs: needs: [build-linux, build-windows, build-macos] runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 # need CHANGELOG.md at the tagged commit + - uses: actions/checkout@v5 # need CHANGELOG.md at the checked-out commit - uses: actions/download-artifact@v5 with: { path: artifacts/ } - - name: Extract CHANGELOG section for this version + - name: Resolve version + extract CHANGELOG section id: notes run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" # Pull the block between "## [VERSION]" and the next "## [" heading. awk -v v="$VERSION" ' $0 ~ "^## \\[" v "\\]" {grab=1; next} @@ -117,6 +131,9 @@ jobs: fi - uses: softprops/action-gh-release@v2 with: + # When called from auto-release the ref is a branch, so name the tag + # explicitly; on a tag push this matches GITHUB_REF_NAME anyway. + tag_name: v${{ steps.notes.outputs.version }} files: | artifacts/linux-deb/*.deb artifacts/windows-zip/*.zip @@ -126,4 +143,4 @@ jobs: body_path: ${{ steps.notes.outputs.has_notes == 'true' && 'release-body.md' || '' }} generate_release_notes: true draft: false - prerelease: ${{ contains(github.ref_name, '-') }} + prerelease: ${{ contains(steps.notes.outputs.version, '-') }} diff --git a/README.md b/README.md index db18f40..9693651 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,27 @@ VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds all three OS bundles and publishes them automatically. +### Releasing (branch model + automation) + +Development happens on the **`master`** dev branch; **`main`** is the stable release branch. +Releases are **CHANGELOG-driven and automatic** — you never tag by hand. + +1. Do your work on `master` and commit. +2. Add a new `## [x.y.z] - YYYY-MM-DD` section to [`CHANGELOG.md`](CHANGELOG.md) + (below `## [Unreleased]`), describing the changes. +3. Open a PR `master` → `main` and merge it. +4. On merge, the **Auto Release** workflow ([`.github/workflows/auto-release.yml`](.github/workflows/auto-release.yml)) + reads the top CHANGELOG version and, if no `vX.Y.Z` tag exists yet, creates the tag and + calls the **Release** workflow — which runs the test gate, builds all OS bundles, and + publishes the GitHub Release (notes pulled from that CHANGELOG section). + +Notes: +- A release only starts when the merge changes `CHANGELOG.md` **and** introduces a version + not already tagged — ordinary merges don't publish anything. +- No secret/PAT is required: Auto Release invokes the Release workflow directly via + `workflow_call`. +- You can still cut a release manually at any time by pushing a `v*` tag. + For deep dives, see the layer guides in [`docs/ai-context/`](docs/) and the [CHANGELOG](CHANGELOG.md). diff --git a/composer.json b/composer.json index 2713316..0513b94 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,8 @@ "plugins/Auth/Support/helpers.php", "plugins/Authorization/Engine/functions.php", "plugins/Cookie/Support/helpers.php", - "plugins/Pageflow/Support/helpers.php" + "plugins/Pageflow/Support/helpers.php", + "plugins/Edge/Support/helpers.php" ], "exclude-from-classmap": [ "**/database/seeders/**", diff --git a/plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php b/plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000..3f99612 --- /dev/null +++ b/plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php @@ -0,0 +1,27 @@ +create('personal_access_tokens', static function ($t) { + $t->string('id', 64)->primary(); + $t->string('user_id', 64); + $t->string('name', 255); + $t->string('token_hash', 64)->unique(); + $t->timestamp('last_used_at')->nullable(); + $t->timestamp('created_at')->nullable(); + + $t->index(['user_id']); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('personal_access_tokens'); + } +}; diff --git a/plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php b/plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php new file mode 100644 index 0000000..1ce9836 --- /dev/null +++ b/plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php @@ -0,0 +1,45 @@ +hasTable('personal_access_tokens')) { + return; + } + + $schema->table('personal_access_tokens', static function ($t) use ($schema) { + if (!$schema->hasColumn('personal_access_tokens', 'expires_at')) { + $t->timestamp('expires_at')->nullable(); + } + if (!$schema->hasColumn('personal_access_tokens', 'abilities')) { + $t->text('abilities')->nullable(); + } + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + if (!$schema->hasTable('personal_access_tokens')) { + return; + } + + $schema->table('personal_access_tokens', static function ($t) use ($schema) { + if ($schema->hasColumn('personal_access_tokens', 'expires_at')) { + $t->dropColumn('expires_at'); + } + if ($schema->hasColumn('personal_access_tokens', 'abilities')) { + $t->dropColumn('abilities'); + } + }); + } +}; diff --git a/plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php b/plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php new file mode 100644 index 0000000..43b2501 --- /dev/null +++ b/plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php @@ -0,0 +1,60 @@ +hasTable('refresh_tokens')) { + return; // pre-existing (e.g. migrated under the old Tenancy owner) + } + + $schema->create('refresh_tokens', static function ($t) { + $t->id(); + $t->char('token_id', 31); + $t->char('family_id', 31)->comment('rotation lineage for reuse detection'); + $t->char('user_id', 31); + $t->char('token_hash', 64)->comment('SHA-256 of the refresh token — never store raw'); + $t->char('tenant_id', 31)->nullable()->comment('scope hint for the tnt claim; not re-verified'); + $t->string('device', 191)->nullable()->comment('UA / device label'); + $t->string('ip', 45)->nullable(); + $t->timestamp('expires_at'); + $t->timestamp('revoked_at')->nullable(); + $t->timestamp('last_used_at')->nullable(); + $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); + + $t->unique(['token_id'], 'uniq_token_id'); + $t->unique(['token_hash'], 'uniq_token_hash'); + $t->index(['user_id', 'revoked_at'], 'idx_user_active'); + $t->index(['family_id'], 'idx_family'); + + $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); + + $t->engine('InnoDB'); + $t->charset('utf8mb4'); + $t->collation('utf8mb4_0900_ai_ci'); + $t->rowFormat('DYNAMIC'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('refresh_tokens'); + } +}; diff --git a/plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php b/plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php new file mode 100644 index 0000000..f261a79 --- /dev/null +++ b/plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php @@ -0,0 +1,29 @@ +create('casbin_rule', static function ($t) { + $t->id(); + $t->string('ptype', 32); + $t->string('v0', 255)->nullable(); + $t->string('v1', 255)->nullable(); + $t->string('v2', 255)->nullable(); + $t->string('v3', 255)->nullable(); + $t->string('v4', 255)->nullable(); + $t->string('v5', 255)->nullable(); + + $t->index(['ptype']); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('casbin_rule'); + } +}; diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php new file mode 100644 index 0000000..63da28e --- /dev/null +++ b/plugins/Edge/API/Contracts/EdgeServiceContract.php @@ -0,0 +1,52 @@ +} + */ + public function phpFpm(): array; + + /** + * Detect + collect sites + render — WITHOUT touching the filesystem. + * $all=false (default) scopes to the CURRENT project; true = every project. + */ + public function plan(bool $all = false): EdgePlan; + + /** + * Write the rendered config, sync local domains to /etc/hosts, then + * (optionally) validate + reload the server. + * + * @return array{ + * ok: bool, strategy: string, path?: string, sites?: int, + * dry_run?: bool, contents?: string, steps?: list, + * hosts?: array|null, message?: string + * } + */ + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false): array; + + /** + * Sync LOCAL domains (.local / .test / …) into /etc/hosts (pointing at the + * loopback), or remove the managed block with $remove. $all=false (default) + * scopes to the current project. + * + * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, block?: string, message?: string} + */ + public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false): array; +} diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php new file mode 100644 index 0000000..4256818 --- /dev/null +++ b/plugins/Edge/Application/EdgeService.php @@ -0,0 +1,143 @@ +probe->detect(); + } + + public function phpFpm(): array + { + return [ + 'version' => $this->probe->phpCliVersion(), + 'socket' => $this->probe->phpFpmSocket(), + 'active' => $this->probe->phpFpmActive(), + ]; + } + + public function plan(bool $all = false): EdgePlan + { + $stack = $this->probe->detect(); + $strategy = $stack->strategy(); + + // Default: ONLY the current project. --all renders every registered one. + // Public domains → server config; local (.local/.test) → /etc/hosts. + $sites = $this->sites->sites($all); + + [$path, $body] = $this->renderer->render($strategy, $sites); + + return new EdgePlan($stack, $strategy, $sites, $this->sites->localDomains($all), $path, $body); + } + + public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false): array + { + return $this->hosts->sync( + domains: $this->sites->localDomains($all), + ip: (string) edge_config('hosts.ip', '127.0.0.1'), + path: (string) edge_config('hosts.path', '/etc/hosts'), + remove: $remove, + dryRun: $dryRun, + ); + } + + public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false): array + { + $plan = $this->plan($all); + + // 1. Local domains → /etc/hosts (independent of any web server, so it + // still runs when the strategy is None). + $hosts = null; + if ($manageHosts ?? (bool) edge_config('manage_hosts', true)) { + $hosts = $this->syncHosts(dryRun: $dryRun, all: $all); + } + + if ($plan->strategy === Strategy::None) { + return [ + 'ok' => ($hosts['ok'] ?? true) === true, + 'strategy' => Strategy::None->value, + 'hosts' => $hosts, + 'message' => 'No active web server detected — only local hosts were synced.', + ]; + } + + if ($dryRun) { + return [ + 'ok' => true, + 'dry_run' => true, + 'strategy' => $plan->strategy->value, + 'path' => $plan->targetPath, + 'sites' => \count($plan->sites), + 'contents' => $plan->contents, + 'hosts' => $hosts, + ]; + } + + // 2. Write the server config atomically (temp file + rename) so a live + // include never sees a half-written file. + $dir = dirname($plan->targetPath); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Cannot create directory {$dir}"]; + } + $tmp = $plan->targetPath . '.tmp'; + if (@file_put_contents($tmp, $plan->contents) === false || !@rename($tmp, $plan->targetPath)) { + @unlink($tmp); + return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Failed to write {$plan->targetPath}"]; + } + + $siteCount = \count($plan->sites); + $steps = ["wrote {$plan->targetPath} ({$siteCount} project site(s))"]; + + if ($reload) { + $isApache = $plan->strategy === Strategy::ApacheOnly; + $testCmd = (string) edge_config($isApache ? 'commands.apache_test' : 'commands.nginx_test'); + $reloadCmd = (string) edge_config($isApache ? 'commands.apache_reload' : 'commands.nginx_reload'); + + [$tc, $tout] = $this->probe->run($testCmd); + $steps[] = "test: {$testCmd} → " . ($tc === 0 ? 'ok' : 'FAILED'); + if ($tc !== 0) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($tout)]; + } + + [$rc, $rout] = $this->probe->run($reloadCmd); + $steps[] = "reload: {$reloadCmd} → " . ($rc === 0 ? 'ok' : 'FAILED'); + if ($rc !== 0) { + return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($rout)]; + } + } + + return [ + 'ok' => true, + 'strategy' => $plan->strategy->value, + 'path' => $plan->targetPath, + 'sites' => \count($plan->sites), + 'steps' => $steps, + 'hosts' => $hosts, + ]; + } +} diff --git a/plugins/Edge/Domain/EdgePlan.php b/plugins/Edge/Domain/EdgePlan.php new file mode 100644 index 0000000..f8087d6 --- /dev/null +++ b/plugins/Edge/Domain/EdgePlan.php @@ -0,0 +1,26 @@ + $sites per-project sites in the server config + * @param list $localDomains dev-only domains (.local / .test / …) → /etc/hosts + */ + public function __construct( + public ServerStack $stack, + public Strategy $strategy, + public array $sites, + public array $localDomains, + public string $targetPath, + public string $contents, + ) {} +} diff --git a/plugins/Edge/Domain/ServeModel.php b/plugins/Edge/Domain/ServeModel.php new file mode 100644 index 0000000..0dae072 --- /dev/null +++ b/plugins/Edge/Domain/ServeModel.php @@ -0,0 +1,23 @@ +/app/public` via PHP-FPM (fastcgi), + * passing the run env as fastcgi_param / SetEnv. + * - Swoole : the project runs its own OpenSwoole HTTP server; the edge just + * reverse-proxies to that upstream (env lives in the Swoole process). + */ +enum ServeModel: string +{ + case Fpm = 'fpm'; + case Swoole = 'swoole'; + + public static function from_(string $value, self $default = self::Fpm): self + { + return self::tryFrom(strtolower(trim($value))) ?? $default; + } +} diff --git a/plugins/Edge/Domain/ServerStack.php b/plugins/Edge/Domain/ServerStack.php new file mode 100644 index 0000000..56ee019 --- /dev/null +++ b/plugins/Edge/Domain/ServerStack.php @@ -0,0 +1,56 @@ +nginxActive && $this->apacheActive) { + return $this->nginxHasStream ? Strategy::NginxStream : Strategy::NginxOnly; + } + if ($this->nginxActive) { + return Strategy::NginxOnly; + } + if ($this->apacheActive) { + return Strategy::ApacheOnly; + } + return Strategy::None; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'nginx_installed' => $this->nginxInstalled, + 'nginx_active' => $this->nginxActive, + 'nginx_has_stream' => $this->nginxHasStream, + 'apache_installed' => $this->apacheInstalled, + 'apache_active' => $this->apacheActive, + 'strategy' => $this->strategy()->value, + ]; + } +} diff --git a/plugins/Edge/Domain/Site.php b/plugins/Edge/Domain/Site.php new file mode 100644 index 0000000..5e112f9 --- /dev/null +++ b/plugins/Edge/Domain/Site.php @@ -0,0 +1,38 @@ +/app/public`), how it's served (FPM vs Swoole + the + * upstream), and the run-env that must be injected into its vhost so the project + * boots (APP_ENV, HKM_USERDATA_DIR, PSP_GLOBAL_AUTOLOAD, HKM_KERNEL_HOME, …). + * + * Local (.local/.test) domains ride along on the owning site but are NOT put in + * the server config — they go to /etc/hosts. + */ +final readonly class Site +{ + /** + * @param list $publicDomains server-facing hostnames + * @param list $localDomains dev-only hostnames (→ /etc/hosts) + * @param array $env run-env injected into the vhost + */ + public function __construct( + public string $name, + public string $docroot, // /app/public + public array $publicDomains, + public array $localDomains, + public ServeModel $model, + public string $upstream, // fpm: fastcgi socket/addr · swoole: host:port + public array $env, + ) {} + + /** Does this site have anything to put in the server config? */ + public function servesPublic(): bool + { + return $this->publicDomains !== [] && $this->docroot !== ''; + } +} diff --git a/plugins/Edge/Domain/Strategy.php b/plugins/Edge/Domain/Strategy.php new file mode 100644 index 0000000..3c914a1 --- /dev/null +++ b/plugins/Edge/Domain/Strategy.php @@ -0,0 +1,35 @@ + 'nginx SNI stream splitter (nginx + Apache fallback)', + self::NginxOnly => 'nginx-only reverse proxy (no stream)', + self::ApacheOnly => 'Apache-only SSL VirtualHost', + self::None => 'no active web server', + }; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php new file mode 100644 index 0000000..ad6b0c0 --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php @@ -0,0 +1,92 @@ +name = 'edge:apply'; + $this->description = 'Generate the nginx/Apache edge config from platform domains, then reload the server'; + + $this->addOption('dry-run', '', 'Print the config that would be written; change nothing'); + $this->addOption('no-reload', '', 'Write the config file but do not validate or reload'); + $this->addOption('no-hosts', '', 'Skip writing local (.local/.test) domains to /etc/hosts'); + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); + } + + protected function handle(): int + { + $dryRun = $this->hasOption('dry-run'); + $reload = !$this->hasOption('no-reload'); + $hosts = $this->hasOption('no-hosts') ? false : null; // null = use config default + $all = $this->hasOption('all'); + + $result = $this->edge->apply(reload: $reload, dryRun: $dryRun, manageHosts: $hosts, all: $all); + + $this->reportHosts($result['hosts'] ?? null); + + if (($result['ok'] ?? false) !== true) { + $this->error('Edge apply failed [' . ($result['strategy'] ?? '?') . ']: ' . ($result['message'] ?? 'unknown error')); + foreach ((array) ($result['steps'] ?? []) as $step) { + $this->muted(' - ' . $step); + } + + return self::FAILURE; + } + + if ($dryRun) { + $this->info('strategy: ' . $result['strategy'] . ' → ' . $result['path'] . ' (' . ($result['sites'] ?? 0) . ' site(s))'); + $this->newLine(); + $this->muted($result['contents']); + + return self::SUCCESS; + } + + $this->success('Edge applied [' . $result['strategy'] . ']'); + foreach ((array) ($result['steps'] ?? []) as $step) { + $this->info(' - ' . $step); + } + + return self::SUCCESS; + } + + /** @param array|null $hosts */ + private function reportHosts(?array $hosts): void + { + if ($hosts === null) { + return; + } + $count = (int) ($hosts['count'] ?? 0); + $path = (string) ($hosts['path'] ?? '/etc/hosts'); + + if (($hosts['ok'] ?? false) !== true) { + $this->warning("hosts: {$count} local domain(s) NOT written — " . ($hosts['message'] ?? 'error')); + return; + } + if (($hosts['dry_run'] ?? false) === true) { + $this->info("hosts: would sync {$count} local domain(s) to {$path}"); + return; + } + $verb = ($hosts['changed'] ?? false) ? 'synced' : 'already current'; + $this->info("hosts: {$verb} {$count} local domain(s) in {$path}"); + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php new file mode 100644 index 0000000..9bf8e6f --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php @@ -0,0 +1,64 @@ +name = 'edge:hosts'; + $this->description = 'Sync local (.local/.test) platform domains into /etc/hosts'; + + $this->addOption('dry-run', '', 'Show what would change; write nothing'); + $this->addOption('remove', '', 'Remove the HKM-managed block from the hosts file'); + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); + } + + protected function handle(): int + { + $result = $this->edge->syncHosts( + remove: $this->hasOption('remove'), + dryRun: $this->hasOption('dry-run'), + all: $this->hasOption('all'), + ); + + $count = (int) ($result['count'] ?? 0); + $path = (string) ($result['path'] ?? '/etc/hosts'); + + if (($result['ok'] ?? false) !== true) { + $this->error('hosts sync failed: ' . ($result['message'] ?? 'unknown error')); + return self::FAILURE; + } + + if (($result['dry_run'] ?? false) === true) { + $this->info("Would write {$count} local domain(s) to {$path}:"); + $this->newLine(); + $this->muted(($result['block'] ?? '') === '' ? '(managed block would be removed)' : (string) $result['block']); + return self::SUCCESS; + } + + $verb = ($result['changed'] ?? false) ? 'Synced' : 'Already current —'; + $this->success("{$verb} {$count} local domain(s) in {$path}."); + + return self::SUCCESS; + } +} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php new file mode 100644 index 0000000..6d6d6a9 --- /dev/null +++ b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php @@ -0,0 +1,67 @@ +name = 'edge:status'; + $this->description = 'Detect nginx/Apache and show the edge routing strategy that would be applied'; + + $this->addOption('all', '', 'Include every registered project (default: only the current one)'); + } + + protected function handle(): int + { + $plan = $this->edge->plan($this->hasOption('all')); + $stack = $plan->stack; + + $this->section('Edge — detected stack'); + $yn = static fn (bool $b): string => $b ? 'yes' : 'no'; + $this->info('nginx installed : ' . $yn($stack->nginxInstalled)); + $this->info('nginx active : ' . $yn($stack->nginxActive)); + $this->info('nginx stream : ' . $yn($stack->nginxHasStream)); + $this->info('apache installed: ' . $yn($stack->apacheInstalled)); + $this->info('apache active : ' . $yn($stack->apacheActive)); + + $php = $this->edge->phpFpm(); + $this->info('php (cli) : ' . $php['version']); + $this->info('php-fpm socket : ' . $php['socket']); + if ($php['active'] !== []) { + $this->info('php-fpm active : ' . implode(', ', $php['active'])); + } + $this->newLine(); + $this->success('strategy: ' . $plan->strategy->label()); + $this->info('project sites : ' . count($plan->sites)); + foreach ($plan->sites as $site) { + $this->info(sprintf( + ' • %s [%s → %s] %s', + $site->name, + $site->model->value, + $site->upstream, + $site->publicDomains === [] ? '(no public domains)' : implode(', ', $site->publicDomains), + )); + } + $this->info('local domains : ' . count($plan->localDomains) . ($plan->localDomains === [] ? '' : ' → /etc/hosts (' . implode(', ', $plan->localDomains) . ')')); + $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); + + return self::SUCCESS; + } +} diff --git a/plugins/Edge/Infrastructure/ConfigRenderer.php b/plugins/Edge/Infrastructure/ConfigRenderer.php new file mode 100644 index 0000000..df09f6c --- /dev/null +++ b/plugins/Edge/Infrastructure/ConfigRenderer.php @@ -0,0 +1,308 @@ +/app/public, FPM fastcgi or Swoole proxy, with the run-env injected) + * modeled on templates/app/{nginx,apache}.conf.example, plus — for the stream + * strategy — the nginx SNI splitter that routes SNI → nginx (:444) / Apache. + */ +final class ConfigRenderer +{ + /** + * @param list $sites + * @return array{0: string, 1: string} [targetPath, contents] ('' path for None) + */ + public function render(Strategy $strategy, array $sites): array + { + return match ($strategy) { + Strategy::NginxStream => [ + (string) edge_config('paths.stream'), + $this->stream($sites) . "\n" . $this->nginxVhosts($sites, $this->nginxInternalPort()), + ], + Strategy::NginxOnly => [ + (string) edge_config('paths.nginx'), + $this->nginxVhosts($sites, (int) edge_config('listen', 443)), + ], + Strategy::ApacheOnly => [ + (string) edge_config('paths.apache'), + $this->apacheVhosts($sites, (int) edge_config('listen', 443)), + ], + Strategy::None => ['', ''], + }; + } + + // ── nginx SNI stream splitter (L4) ──────────────────────────────────────── + + /** @param list $sites */ + private function stream(array $sites): string + { + $map = ''; + foreach ($this->publicDomains($sites) as $d) { + $pad = str_repeat(' ', max(1, 42 - strlen($d))); + $map .= " {$d}{$pad}nginx_backend;\n"; + } + + $tpl = <<<'NGINX' +# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. +# SNI TLS router: server name is read WITHOUT decrypting (ssl_preread), then the +# raw TLS stream is forwarded. Platform domains → nginx (%NGINX%); everything +# else → Apache (%APACHE%). This block lives at the nginx MAIN context. +stream { + upstream nginx_backend { server %NGINX%; } + upstream apache_ssl { server %APACHE%; } + + map $ssl_preread_server_name $backend_name { +%MAP% default apache_ssl; + } + + server { + listen %LISTEN%; + proxy_pass $backend_name; + ssl_preread on; + } +} +NGINX; + + return $this->fill($tpl, [ + '%NGINX%' => (string) edge_config('upstreams.nginx'), + '%APACHE%' => (string) edge_config('upstreams.apache'), + '%LISTEN%' => (string) (int) edge_config('listen', 443), + '%MAP%' => $map, + ]); + } + + // ── per-project nginx vhosts ────────────────────────────────────────────── + + /** @param list $sites */ + private function nginxVhosts(array $sites, int $port): string + { + $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; + foreach ($sites as $site) { + if (!$site->servesPublic()) { + continue; + } + $out .= "\n" . ($site->model === ServeModel::Swoole + ? $this->nginxSwoole($site, $port) + : $this->nginxFpm($site, $port)); + } + + return rtrim($out, "\n") . "\n"; + } + + private function nginxFpm(Site $site, int $port): string + { + $params = ''; + foreach ($site->env as $k => $v) { + $params .= sprintf(" fastcgi_param %s \"%s\";\n", $k, $this->escapeNginx($v)); + } + + $tpl = <<<'NGINX' +# Project: %NAME% (PHP-FPM) +server { + listen %PORT% ssl; + http2 on; + server_name %NAMES%; + + root %DOCROOT%; + index index.php; + + ssl_certificate %CERT%; + ssl_certificate_key %KEY%; + + location ~ /\. { deny all; return 404; } + + location ~ \.php$ { + location = /index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; +%PARAMS% fastcgi_pass %UPSTREAM%; + } + return 404; + } + + location / { try_files $uri /index.php$is_args$args; } + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + server_tokens off; + client_max_body_size 25m; +} +NGINX; + + return $this->fill($tpl, [ + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%NAMES%' => implode(' ', $site->publicDomains), + '%DOCROOT%' => $site->docroot, + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%PARAMS%' => $params, + '%UPSTREAM%' => $site->upstream, + ]); + } + + private function nginxSwoole(Site $site, int $port): string + { + $tpl = <<<'NGINX' +# Project: %NAME% (OpenSwoole) — env lives in the Swoole process: +# hkm run %NAME% --swoole (bind it to %UPSTREAM%) +server { + listen %PORT% ssl; + http2 on; + server_name %NAMES%; + + ssl_certificate %CERT%; + ssl_certificate_key %KEY%; + + location / { + proxy_pass http://%UPSTREAM%; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +NGINX; + + return $this->fill($tpl, [ + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%NAMES%' => implode(' ', $site->publicDomains), + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%UPSTREAM%' => $site->upstream, + ]); + } + + // ── per-project Apache vhosts ───────────────────────────────────────────── + + /** @param list $sites */ + private function apacheVhosts(array $sites, int $port): string + { + $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; + foreach ($sites as $site) { + if (!$site->servesPublic()) { + continue; + } + $out .= "\n" . $this->apacheSite($site, $port); + } + + return rtrim($out, "\n") . "\n"; + } + + private function apacheSite(Site $site, int $port): string + { + $aliases = ''; + foreach (array_slice($site->publicDomains, 1) as $d) { + $aliases .= " ServerAlias {$d}\n"; + } + $setenv = ''; + foreach ($site->env as $k => $v) { + $setenv .= sprintf(" SetEnv %s \"%s\"\n", $k, $this->escapeApache($v)); + } + + // PHP handler: FPM via mod_proxy_fcgi, or reverse-proxy for Swoole. + if ($site->model === ServeModel::Swoole) { + $handler = " ProxyPreserveHost On\n ProxyPass / http://{$site->upstream}/\n ProxyPassReverse / http://{$site->upstream}/"; + } else { + $fcgi = str_starts_with($site->upstream, 'unix:') + ? 'proxy:' . $site->upstream . '|fcgi://localhost/' + : 'proxy:fcgi://' . $site->upstream; + $handler = " \n SetHandler \"{$fcgi}\"\n "; + } + + $tpl = <<<'APACHE' +# Project: %NAME% + + ServerName %PRIMARY% +%ALIASES% DocumentRoot %DOCROOT% + + + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + Require all denied + + + SSLEngine on + SSLCertificateFile %CERT% + SSLCertificateKeyFile %KEY% + +%SETENV%%HANDLER% + + ServerTokens Prod + ServerSignature Off + LimitRequestBody 26214400 + +APACHE; + + return $this->fill($tpl, [ + '%NAME%' => $site->name, + '%PORT%' => (string) $port, + '%PRIMARY%' => $site->publicDomains[0] ?? '_', + '%ALIASES%' => $aliases, + '%DOCROOT%' => $site->docroot, + '%CERT%' => (string) edge_config('ssl.cert'), + '%KEY%' => (string) edge_config('ssl.key'), + '%SETENV%' => $setenv, + '%HANDLER%' => $handler, + ]); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /** @param list $sites @return list */ + private function publicDomains(array $sites): array + { + $domains = []; + foreach ($sites as $site) { + foreach ($site->publicDomains as $d) { + $domains[] = $d; + } + } + $domains = array_values(array_unique($domains)); + sort($domains); + + return $domains; + } + + /** The internal port nginx vhosts listen on when behind the stream splitter. */ + private function nginxInternalPort(): int + { + $backend = (string) edge_config('upstreams.nginx', '127.0.0.1:444'); + $port = (int) substr(strrchr($backend, ':') ?: ':444', 1); + + return $port > 0 ? $port : 444; + } + + private function escapeNginx(string $v): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $v); + } + + private function escapeApache(string $v): string + { + return str_replace('"', '\\"', $v); + } + + /** @param array $vars */ + private function fill(string $template, array $vars): string + { + return rtrim(strtr($template, $vars), "\n") . "\n"; + } +} diff --git a/plugins/Edge/Infrastructure/HostsFileWriter.php b/plugins/Edge/Infrastructure/HostsFileWriter.php new file mode 100644 index 0000000..4fbb1b0 --- /dev/null +++ b/plugins/Edge/Infrastructure/HostsFileWriter.php @@ -0,0 +1,70 @@ +>> HKM Edge (local domains) >>>'; + private const END = '# <<< HKM Edge (local domains) <<<'; + + /** + * @param list $domains local hostnames to point at $ip + * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, block?: string, message?: string} + */ + public function sync(array $domains, string $ip, string $path, bool $remove = false, bool $dryRun = false): array + { + if (!is_file($path)) { + return ['ok' => false, 'path' => $path, 'count' => 0, 'message' => "hosts file not found: {$path}"]; + } + + $current = (string) file_get_contents($path); + $stripped = $this->stripBlock($current); + + $block = ''; + if (!$remove && $domains !== []) { + $lines = [self::BEGIN]; + foreach ($domains as $d) { + $lines[] = sprintf('%s %s', $ip, $d); + } + $lines[] = self::END; + $block = implode("\n", $lines); + } + + $new = $block === '' + ? rtrim($stripped, "\n") . "\n" + : rtrim($stripped, "\n") . "\n\n" . $block . "\n"; + + $changed = $new !== $current; + + if ($dryRun) { + return ['ok' => true, 'dry_run' => true, 'changed' => $changed, 'path' => $path, 'count' => count($domains), 'block' => $block]; + } + if (!$changed) { + return ['ok' => true, 'changed' => false, 'path' => $path, 'count' => count($domains), 'message' => 'already up to date']; + } + + $tmp = $path . '.hkm.tmp'; + if (@file_put_contents($tmp, $new) === false || !@rename($tmp, $path)) { + @unlink($tmp); + return ['ok' => false, 'path' => $path, 'count' => count($domains), 'message' => "cannot write {$path} (run with the privileges to edit it, e.g. sudo)"]; + } + + return ['ok' => true, 'changed' => true, 'path' => $path, 'count' => count($domains)]; + } + + /** Remove any existing HKM-managed block (and the blank lines around it). */ + private function stripBlock(string $contents): string + { + $pattern = '/\n*' . preg_quote(self::BEGIN, '/') . '.*?' . preg_quote(self::END, '/') . '\n*/s'; + + return preg_replace($pattern, "\n", $contents) ?? $contents; + } +} diff --git a/plugins/Edge/Infrastructure/SiteCollector.php b/plugins/Edge/Infrastructure/SiteCollector.php new file mode 100644 index 0000000..24c66cf --- /dev/null +++ b/plugins/Edge/Infrastructure/SiteCollector.php @@ -0,0 +1,236 @@ + + */ + public function sites(bool $all = false): array + { + if (!$all) { + $site = $this->currentSite(); + + return $site !== null ? [$site] : []; + } + + $sites = []; + foreach ($this->projects() as $name => $project) { + $site = $this->buildSite((string) $name, (string) ($project['path'] ?? ''), (array) ($project['domains'] ?? [])); + if ($site !== null) { + $sites[] = $site; + } + } + + return $sites; + } + + /** Local (dev-only) domains for the current project (or all projects). */ + public function localDomains(bool $all = false): array + { + $local = []; + foreach ($this->sites($all) as $site) { + foreach ($site->localDomains as $d) { + $local[] = $d; + } + } + if ($all) { + foreach ($this->classify((array) edge_config('extra_domains', []))['local'] as $d) { + $local[] = $d; + } + } + $local = array_values(array_unique($local)); + sort($local); + + return $local; + } + + /** The project the command is running in — its own proj.json is the truth. */ + private function currentSite(): ?Site + { + $path = rtrim((string) base_path(), '/'); + $proj = $this->projJson($path); + $name = (string) ($proj['name'] ?? basename($path)); + + return $this->buildSite($name, $path, (array) ($proj['domains'] ?? [])); + } + + /** @param array $domains */ + private function buildSite(string $name, string $path, array $domains): ?Site + { + $path = rtrim($path, '/'); + $cls = $this->classify($domains); + if ($path === '' || ($cls['public'] === [] && $cls['local'] === [])) { + return null; + } + + $edge = (array) ($this->projJson($path)['edge'] ?? []); + $model = ServeModel::from_((string) ($edge['serve'] ?? edge_config('serve.model', 'fpm'))); + + return new Site( + name: $name, + docroot: $path . '/app/public', + publicDomains: $cls['public'], + localDomains: $cls['local'], + model: $model, + upstream: $this->upstream($model, $edge), + env: $this->env($path, $edge), + ); + } + + // ── registries ──────────────────────────────────────────────────────────── + + /** @return array> */ + private function projects(): array + { + $file = (string) edge_config('projects_registry', ''); + if ($file === '' || !is_file($file)) { + return []; + } + $json = json_decode((string) file_get_contents($file), true); + + return is_array($json) ? $json : []; + } + + /** @return array */ + private function projJson(string $path): array + { + $file = $path . '/proj.json'; + if (!is_file($file)) { + return []; + } + $json = json_decode((string) file_get_contents($file), true); + + return is_array($json) ? $json : []; + } + + // ── serving + env ─────────────────────────────────────────────────────── + + /** @param array $edge */ + private function upstream(ServeModel $model, array $edge): string + { + if ($model === ServeModel::Swoole) { + $host = (string) edge_config('serve.swoole_host', '127.0.0.1'); + $port = (int) ($edge['port'] ?? edge_config('serve.swoole_base_port', 9500)); + + return "{$host}:{$port}"; + } + + // FPM: an explicit per-project socket, else an explicit EDGE_FPM_SOCKET, + // else auto-resolve the socket matching the CLI PHP version (multi-PHP hosts). + $explicit = (string) ($edge['socket'] ?? edge_config('serve.fpm_socket', '')); + + return $explicit !== '' ? $explicit : $this->probe->phpFpmSocket(); + } + + /** + * The run-env injected into a site's vhost. Base env (APP_ENV, userdata, + * kernel resolution) merged with per-project proj.json `edge.env` extras. + * + * @param array $edge + * @return array + */ + private function env(string $path, array $edge): array + { + $env = []; + + $appEnv = (string) edge_config('env.app_env', 'production'); + if ($appEnv !== '') { + $env['APP_ENV'] = $appEnv; + } + + $userdata = (string) edge_config('env.userdata_dir', ''); + if ($userdata !== '') { + $env['HKM_USERDATA_DIR'] = $userdata; + } + + if ((bool) edge_config('inject_kernel_env', true)) { + $home = (string) edge_config('env.kernel_home', ''); + $autoload = (string) edge_config('env.global_autoload', ''); + if ($autoload === '' && $home !== '') { + $autoload = $home . '/vendor/autoload.php'; + } + if ($home !== '') { + $env['HKM_KERNEL_HOME'] = $home; + } + if ($autoload !== '') { + $env['PSP_GLOBAL_AUTOLOAD'] = $autoload; + } + } + + // Per-project extras win. + foreach ((array) ($edge['env'] ?? []) as $k => $v) { + if (is_string($k)) { + $env[$k] = (string) $v; + } + } + + return $env; + } + + // ── domain classification (validated) ───────────────────────────────────── + + /** + * @param array $domains + * @return array{public: list, local: list} + */ + private function classify(array $domains): array + { + $exclude = array_map('strtolower', (array) edge_config('exclude_domains', [])); + $public = []; + $local = []; + foreach ($domains as $domain) { + $host = strtolower(trim((string) $domain)); + if ($host === '' || !$this->isValid($host) || in_array($host, $exclude, true)) { + continue; + } + if ($this->isLocal($host)) { + $local[] = $host; + } else { + $public[] = $host; + } + } + + return ['public' => array_values(array_unique($public)), 'local' => array_values(array_unique($local))]; + } + + private function isValid(string $host): bool + { + if (preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host)) { + return true; + } + + return (bool) preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host); + } + + private function isLocal(string $host): bool + { + if (!str_contains($host, '.')) { + return true; + } + $tld = strtolower(substr((string) strrchr($host, '.'), 1)); + $tlds = array_map('strtolower', (array) edge_config('local_tlds', ['local', 'test', 'localhost', 'example', 'invalid'])); + + return in_array($tld, $tlds, true); + } +} diff --git a/plugins/Edge/Infrastructure/SystemProbe.php b/plugins/Edge/Infrastructure/SystemProbe.php new file mode 100644 index 0000000..6e69e24 --- /dev/null +++ b/plugins/Edge/Infrastructure/SystemProbe.php @@ -0,0 +1,153 @@ +which('nginx'); + $apacheInstalled = $this->which('apache2') || $this->which('httpd') || $this->which('apachectl'); + + return new ServerStack( + nginxInstalled: $nginxInstalled, + nginxActive: $this->active('nginx'), + nginxHasStream: $nginxInstalled && $this->nginxHasStream(), + apacheInstalled: $apacheInstalled, + apacheActive: $this->active('apache2') || $this->active('httpd'), + ); + } + + /** The PHP version running THIS command, e.g. "8.4". */ + public function phpCliVersion(): string + { + return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + } + + /** + * Resolve the PHP-FPM upstream that matches the CLI PHP version running the + * command, so a multi-PHP host binds the vhost to the RIGHT pool: + * 1. the versioned socket for the CLI version (Debian/Ubuntu naming), + * 2. any versioned socket present — the exact version, else the newest, + * 3. a generic/unversioned socket (RHEL, custom), + * 4. a TCP fallback (127.0.0.1:9000, common in containers). + */ + public function phpFpmSocket(): string + { + $ver = $this->phpCliVersion(); + + foreach (["/run/php/php{$ver}-fpm.sock", "/var/run/php/php{$ver}-fpm.sock"] as $sock) { + if (@file_exists($sock)) { + return "unix:{$sock}"; + } + } + + $socks = array_merge(glob('/run/php/php*-fpm.sock') ?: [], glob('/var/run/php/php*-fpm.sock') ?: []); + if ($socks !== []) { + // exact CLI version wins; otherwise the newest available pool. + usort($socks, fn (string $a, string $b): int => version_compare($this->sockVersion($b), $this->sockVersion($a))); + foreach ($socks as $s) { + if ($this->sockVersion($s) === $ver) { + return "unix:{$s}"; + } + } + return "unix:{$socks[0]}"; + } + + foreach (['/run/php-fpm/www.sock', '/var/run/php-fpm/www.sock', '/run/php/php-fpm.sock'] as $sock) { + if (@file_exists($sock)) { + return "unix:{$sock}"; + } + } + + return '127.0.0.1:9000'; + } + + /** Which php*-fpm services systemd reports as active (best-effort, for status). */ + public function phpFpmActive(): array + { + [$code, $out] = $this->run("systemctl list-units --type=service --state=active --no-legend 'php*-fpm*.service'"); + if ($code !== 0 || trim($out) === '') { + return []; + } + $names = []; + foreach (explode("\n", trim($out)) as $line) { + if (preg_match('/(php[0-9.]*-fpm[^\s]*)\.service/', $line, $m)) { + $names[] = $m[1]; + } + } + + return array_values(array_unique($names)); + } + + private function sockVersion(string $path): string + { + return preg_match('/php(\d+\.\d+)-fpm\.sock$/', $path, $m) ? $m[1] : '0'; + } + + /** Run an arbitrary command; returns [exitCode, combinedOutput]. */ + public function run(string $command): array + { + $output = []; + $code = 0; + @exec($command . ' 2>&1', $output, $code); + + return [$code, implode("\n", $output)]; + } + + private function which(string $binary): bool + { + [$code] = $this->run('command -v ' . escapeshellarg($binary)); + + return $code === 0; + } + + /** + * Is a service active? Prefer systemd; fall back to a process match so it + * still works on non-systemd hosts / inside containers. + */ + private function active(string $service): bool + { + [$code, $out] = $this->run('systemctl is-active ' . escapeshellarg($service)); + if ($code === 0 && trim($out) === 'active') { + return true; + } + + [$pcode] = $this->run('pgrep -x ' . escapeshellarg($service)); + + return $pcode === 0; + } + + /** Does the installed nginx support the stream (L4) module? */ + private function nginxHasStream(): bool + { + [, $banner] = $this->run('nginx -V'); + if (str_contains($banner, '--with-stream')) { + return true; + } + + // Dynamic module shipped separately (Debian/RHEL common paths). + foreach ([ + '/usr/lib/nginx/modules/ngx_stream_module.so', + '/usr/lib64/nginx/modules/ngx_stream_module.so', + '/etc/nginx/modules/ngx_stream_module.so', + ] as $path) { + if (is_file($path)) { + return true; + } + } + + return false; + } +} diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php new file mode 100644 index 0000000..f60ffd3 --- /dev/null +++ b/plugins/Edge/Provider.php @@ -0,0 +1,79 @@ + */ + public function requires(): array + { + return []; + } + + /** @return list */ + public function exposes(): array + { + return [EdgeServiceContract::class]; + } + + public function register(ModuleContainer $container): void + { + $container->bind(EdgeServiceContract::class, static fn (): EdgeService => self::service()); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // CLI-only: defer so only CLI processes construct the commands. + $cli->defer(static function (CliPipeline $cli): void { + $service = self::service(); + $cli->command(new EdgeStatusCommand($service)); + $cli->command(new EdgeApplyCommand($service)); + $cli->command(new EdgeHostsCommand($service)); + }); + } + + private static function service(): EdgeService + { + $probe = new SystemProbe(); + + return new EdgeService( + $probe, + new SiteCollector($probe), + new ConfigRenderer(), + new HostsFileWriter(), + ); + } +} diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md new file mode 100644 index 0000000..5c76424 --- /dev/null +++ b/plugins/Edge/README.md @@ -0,0 +1,164 @@ +# Edge plugin (`Plugins\Edge`, solves `edge.routing`) + +Generates the host's **web-server front config** from the platform's registered +domains, adapting to whatever is actually running on the machine. + +It probes the host, picks a strategy, renders the matching config, then +validates and reloads the server. + +## Strategy detection + +| Detected stack | Strategy | Rendered config | +|---|---|---| +| nginx **and** Apache active, nginx has the `stream` module | `nginx-stream` | nginx **SNI (L4) stream splitter** — listed domains → nginx (`:444`), everything else → Apache (`:8443`) | +| only nginx active (or Apache present but **inactive**, or nginx lacks `stream`) | `nginx-only` | plain nginx reverse-proxy vhost (no stream) | +| only Apache active | `apache-only` | Apache SSL `VirtualHost` | +| neither active | `none` | nothing — reports and stops | + +This is exactly the "check what's on the host and apply accordingly" rule: +nginx is the front; if it can stream and Apache is up, split by SNI; if Apache +is down, just nginx without stream; if only Apache, configure Apache. + +## The SNI stream splitter (the `nginx-stream` output) + +```nginx +stream { + upstream nginx_backend { server 127.0.0.1:444; } + upstream apache_ssl { server 127.0.0.1:8443; } + + map $ssl_preread_server_name $backend_name { + app.example.com nginx_backend; + ... + default apache_ssl; + } + + server { + listen 443; + proxy_pass $backend_name; + ssl_preread on; + } +} +``` + +`ssl_preread` reads the TLS ClientHello's SNI **without decrypting**, then the +raw TLS stream is forwarded to whichever backend the `map` picked. TLS is +terminated by that backend (nginx on `:444`, Apache on `:8443`) — the stream +layer never sees plaintext, so certificates live on the backends. + +> The `stream {}` block must live at the nginx **main context** (top level of +> `nginx.conf`), **not** inside `http {}`. Include it: `include ;` + +## Commands + +By default every command scopes to the **current project** (read from +`base_path()/proj.json` — i.e. the project you run it in). Add **`--all`** to act +on every registered project in the global `projects.json`. + +```bash +hkm cli -p edge:status # probe host; show THIS project's plan +hkm cli -p edge:status --all # every registered project +hkm cli -p edge:apply # render + write config + sync /etc/hosts + reload +hkm cli -p edge:apply --dry-run # print what WOULD be written +hkm cli -p edge:apply --no-reload # write only; skip validate + reload +hkm cli -p edge:apply --no-hosts # skip the /etc/hosts sync +hkm cli -p edge:apply --all # render ALL projects into one file +hkm cli -p edge:hosts # sync THIS project's local domains → /etc/hosts (sudo) +hkm cli -p edge:hosts --remove # remove the HKM-managed hosts block +``` + +(During development add `--dev` so `hkm` uses your dev kernel checkout.) + +## Per-project serving (the vhost model) + +Edge is **project-aware**: it reads the global registry (`projects.json` → +name/path/domains) and renders **one vhost per project**, with: + +- **docroot = `/app/public`** (never the project root — keeps + `.env`/config/src/vendor out of the web tree), modeled on + `templates/app/{nginx,apache}.conf.example`; +- the **run-env injected** so the served project boots: `APP_ENV`, + `HKM_USERDATA_DIR`, and (when `EDGE_INJECT_KERNEL_ENV=true`) `HKM_KERNEL_HOME` + + `PSP_GLOBAL_AUTOLOAD` — as `fastcgi_param` (nginx FPM) / `SetEnv` (Apache); +- a **serve model** per project: `fpm` (fastcgi to PHP-FPM) or `swoole` + (reverse-proxy to the project's OpenSwoole port). + +Each project may override the model + upstream + extra env in its **`proj.json`**: + +```jsonc +{ + "name": "shop", + "edge": { + "serve": "swoole", // or "fpm" + "port": 9601, // swoole upstream port + "socket": "unix:/run/php/php8.4-fpm.sock", // fpm socket (fpm model) + "env": { "APP_ENV": "production", "SHOP_FLAG": "1" } // per-project extras + } +} +``` + +Defaults come from `EDGE_SERVE_MODEL` / `EDGE_FPM_SOCKET` / +`EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT`. + +## Domains — public vs local + +Collected automatically from `projects/projects.json` (each project's +`domains[]`), plus `EDGE_EXTRA_DOMAINS`, minus `EDGE_EXCLUDE_DOMAINS`. Every +hostname is validated against a strict charset before it can reach a rendered +config, so a malformed registry entry can never inject directives. + +Domains are then **split**: + +- **Public** (real FQDN, e.g. `app.example.com`) → go into the **server config** + (nginx stream / vhost / Apache). +- **Local** (`*.local`, `*.test`, `*.localhost`, `*.example`, `*.invalid`, or a + single-label host like `myapp`) → are **dev-only**: kept OUT of the public + server config and written to **`/etc/hosts`** pointing at the loopback, so they + resolve on this machine. The managed block is delimited by markers, so the rest + of your hosts file is never touched and re-runs are idempotent: + + ``` + # >>> HKM Edge (local domains) >>> + 127.0.0.1 api.hkm.local + 127.0.0.1 hkm.local + # <<< HKM Edge (local domains) <<< + ``` + +Tune the local TLD set with `EDGE_LOCAL_TLDS`. Set `EDGE_LOCAL_IN_SERVER=true` if +you also want nginx to serve `.local` sites locally (they then appear in BOTH the +server config and `/etc/hosts`). + +## Configuration (`config/edge.php`, all env-driven) + +| Env | Default | Purpose | +|---|---|---| +| `EDGE_LISTEN_PORT` | `443` | public TLS port | +| `EDGE_NGINX_BACKEND` | `127.0.0.1:444` | nginx TLS backend (stream) | +| `EDGE_APACHE_BACKEND` | `127.0.0.1:8443` | Apache fallback backend (stream) | +| `EDGE_APP_BACKEND` | `127.0.0.1:8080` | app upstream (nginx-only / Apache) | +| `EDGE_SSL_CERT` / `EDGE_SSL_KEY` | `/etc/ssl/...` | cert used by nginx-only / Apache templates | +| `EDGE_STREAM_PATH` / `EDGE_NGINX_PATH` / `EDGE_APACHE_PATH` | `var/edge/*.conf` | where each config is written (point at `/etc/nginx/...` in prod) | +| `EDGE_RELOAD` | `false` | reload after write by default (also controllable per-command) | +| `EDGE_*_TEST_CMD` / `EDGE_*_RELOAD_CMD` | `nginx -t`, `nginx -s reload`, `apachectl configtest`, `apachectl graceful` | validate/reload commands per distro | +| `EDGE_EXTRA_DOMAINS` / `EDGE_EXCLUDE_DOMAINS` | — | comma-separated add/drop | +| `EDGE_LOCAL_TLDS` | `local,test,localhost,example,invalid` | TLDs treated as local (→ /etc/hosts) | +| `EDGE_MANAGE_HOSTS` | `true` | write local domains to /etc/hosts on apply | +| `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | +| `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config | +| `EDGE_SERVE_MODEL` | `fpm` | default serve model (`fpm` \| `swoole`); per-project override in `proj.json` | +| `EDGE_FPM_SOCKET` | *(auto)* | pin the FPM socket/addr; empty = auto-resolve the socket matching the CLI PHP version | +| `EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT` | `127.0.0.1` / `9500` | Swoole upstream host + base port | +| `EDGE_INJECT_KERNEL_ENV` | `true` | inject `PSP_GLOBAL_AUTOLOAD` + `HKM_KERNEL_HOME` into each vhost | +| `EDGE_APP_ENV` | `APP_ENV` or `production` | `APP_ENV` written into each vhost | + +Defaults write to `var/edge/` so no root is needed to test; in production point +`EDGE_*_PATH` at the real nginx/Apache include dirs and run `hkm` with the +privileges needed to reload. + +## Notes + +- ON-DEMAND module; the value is the CLI. A route that needs the contract + declares `"requires": ["edge.routing"]`. +- Writes are atomic (temp file + rename), so a live `include` never sees a + half-written file. +- The service is DI-free (collaborators read `edge_config()`), so it constructs + without ports or a database. diff --git a/plugins/Edge/Support/helpers.php b/plugins/Edge/Support/helpers.php new file mode 100644 index 0000000..98c96d7 --- /dev/null +++ b/plugins/Edge/Support/helpers.php @@ -0,0 +1,48 @@ +/config/edge.php wins over the plugin + * default. + * + * edge_config(); // full array + * edge_config('listen'); // 443 + * edge_config('upstreams.nginx'); // dotted access + * edge_config('paths.stream', '…'); // value, or fallback if absent + * + * @return mixed the whole config array, or a single (dotted) key's value + */ + function edge_config(?string $key = null, mixed $default = null): mixed + { + /** @var array|null $config */ + static $config = null; + + if ($config === null) { + $projectFile = Paths::config('edge.php'); + $pluginFile = __DIR__ . '/../config/edge.php'; + + $file = is_file($projectFile) ? $projectFile : $pluginFile; + $loaded = require $file; + $config = is_array($loaded) ? $loaded : []; + } + + if ($key === null) { + return $config; + } + + $value = $config; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php new file mode 100644 index 0000000..35df6a6 --- /dev/null +++ b/plugins/Edge/config/edge.php @@ -0,0 +1,119 @@ +/config/edge.php overrides this default. + * Everything is env-driven; the defaults are safe for local development (the + * generated files land under var/edge/ so no root is needed to write them — + * point EDGE_*_PATH at /etc/nginx or /etc/apache2 in production). + */ +$__edgeProjectsDir = (static function (): string { + // Edge is a HOST/control-plane tool: it must read the GLOBAL project registry + // (every project + its domains), which lives in the kernel home — NOT the + // per-project base_path. Resolution order: explicit override → PSP_PROJECTS_DIR + // → HKM_KERNEL_HOME/projects → base_path('projects'). + $explicit = (string) env('EDGE_PROJECTS_DIR', ''); + if ($explicit !== '') { + return rtrim($explicit, '/'); + } + $psp = (string) env('PSP_PROJECTS_DIR', ''); + if ($psp !== '') { + return rtrim($psp, '/'); + } + $home = (string) env('HKM_KERNEL_HOME', ''); + if ($home !== '') { + return rtrim($home, '/') . '/projects'; + } + return base_path('projects'); +})(); + +return [ + // The public TLS port the edge listens on. + 'listen' => (int) (env('EDGE_LISTEN_PORT') ?: 443), + + // Backends the traffic is routed to. + 'upstreams' => [ + // Where nginx terminates TLS for the platform's own domains. + 'nginx' => (string) (env('EDGE_NGINX_BACKEND') ?: '127.0.0.1:444'), + // Fallback web server (Apache) for everything not owned by the platform. + 'apache' => (string) (env('EDGE_APACHE_BACKEND') ?: '127.0.0.1:8443'), + // The application backend nginx/Apache reverse-proxy to (Swoole http or + // a plain listener). For PHP-FPM use fastcgi in your own vhost instead. + 'app' => (string) (env('EDGE_APP_BACKEND') ?: '127.0.0.1:8080'), + ], + + // TLS material used by the nginx-only and Apache-only templates. + 'ssl' => [ + 'cert' => (string) (env('EDGE_SSL_CERT') ?: '/etc/ssl/certs/hkm-edge.pem'), + 'key' => (string) (env('EDGE_SSL_KEY') ?: '/etc/ssl/private/hkm-edge.key'), + ], + + // Where each rendered config is written. Override to /etc/nginx/... in prod. + 'paths' => [ + 'stream' => (string) (env('EDGE_STREAM_PATH') ?: base_path('var/edge/hkm-edge-stream.conf')), + 'nginx' => (string) (env('EDGE_NGINX_PATH') ?: base_path('var/edge/hkm-edge-nginx.conf')), + 'apache' => (string) (env('EDGE_APACHE_PATH') ?: base_path('var/edge/hkm-edge-apache.conf')), + ], + + // Validation + reload commands (configurable per distro / init system). + 'commands' => [ + 'nginx_test' => (string) (env('EDGE_NGINX_TEST_CMD') ?: 'nginx -t'), + 'nginx_reload' => (string) (env('EDGE_NGINX_RELOAD_CMD') ?: 'nginx -s reload'), + 'apache_test' => (string) (env('EDGE_APACHE_TEST_CMD') ?: 'apachectl configtest'), + 'apache_reload' => (string) (env('EDGE_APACHE_RELOAD_CMD') ?: 'apachectl graceful'), + ], + + // Reload the web server after writing (edge:apply). Can also be forced/ skipped + // with CLI flags. Off by default so a bare `edge:apply` never touches a live + // server unless you opt in. + 'reload' => filter_var(env('EDGE_RELOAD', 'false'), FILTER_VALIDATE_BOOL), + + // Domain sources. The registries are read automatically; extra/exclude let + // you add or drop hostnames without editing the registry. + 'projects_registry' => $__edgeProjectsDir . '/projects.json', + 'platform_registry' => $__edgeProjectsDir . '/platform.json', + 'extra_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXTRA_DOMAINS', ''))))), + 'exclude_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXCLUDE_DOMAINS', ''))))), + + // Local (dev-only) domains. A domain whose TLD is in this list — or that has + // no dot at all — is treated as LOCAL: it is kept OUT of the public server + // config and written to /etc/hosts instead (pointing at the loopback). + 'local_tlds' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_LOCAL_TLDS', 'local,test,localhost,example,invalid'))))), + + // Write local domains into /etc/hosts on apply (needs privileges to edit it). + 'manage_hosts' => filter_var(env('EDGE_MANAGE_HOSTS', 'true'), FILTER_VALIDATE_BOOL), + 'hosts' => [ + 'path' => (string) (env('EDGE_HOSTS_PATH') ?: '/etc/hosts'), + 'ip' => (string) (env('EDGE_HOSTS_IP') ?: '127.0.0.1'), + ], + + // Set true to ALSO include local domains in the generated server config + // (e.g. when nginx serves your .local sites in local development). + 'include_local_in_server' => filter_var(env('EDGE_LOCAL_IN_SERVER', 'false'), FILTER_VALIDATE_BOOL), + + // How each project is served (per-project override via proj.json "edge"). + 'serve' => [ + 'model' => (string) (env('EDGE_SERVE_MODEL') ?: 'fpm'), // fpm | swoole + // Empty = auto-resolve the FPM socket matching the CLI PHP version + // (multi-PHP hosts). Set explicitly to pin a socket/addr. + 'fpm_socket' => (string) env('EDGE_FPM_SOCKET', ''), + 'swoole_host' => (string) (env('EDGE_SWOOLE_HOST') ?: '127.0.0.1'), + 'swoole_base_port' => (int) (env('EDGE_SWOOLE_BASE_PORT') ?: 9500), + ], + + // Inject the kernel-resolution env (PSP_GLOBAL_AUTOLOAD / HKM_KERNEL_HOME) + // into each vhost so FPM workers boot against the correct kernel. + 'inject_kernel_env' => filter_var(env('EDGE_INJECT_KERNEL_ENV', 'true'), FILTER_VALIDATE_BOOL), + + // Base run-env written into every generated vhost. Per-project proj.json + // "edge": { "env": { … } } extras override these. + 'env' => [ + 'app_env' => (string) (env('EDGE_APP_ENV') ?: env('APP_ENV') ?: 'production'), + 'userdata_dir' => (string) env('HKM_USERDATA_DIR', ''), + 'global_autoload' => (string) env('PSP_GLOBAL_AUTOLOAD', ''), + 'kernel_home' => (string) env('HKM_KERNEL_HOME', ''), + ], +]; diff --git a/plugins/Edge/module.json b/plugins/Edge/module.json new file mode 100644 index 0000000..7da83c7 --- /dev/null +++ b/plugins/Edge/module.json @@ -0,0 +1,43 @@ +{ + "name": "edge", + "version": "1.0.0", + "solves": "edge.routing", + "type": "module", + + "requires": [], + "exposes": ["Plugins\\Edge\\API\\Contracts\\EdgeServiceContract"], + + "routes": [], + "emits": [], + "listens": [], + + "config": [ + { "key": "EDGE_LISTEN_PORT", "type": "int", "required": false }, + { "key": "EDGE_NGINX_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_APACHE_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_APP_BACKEND", "type": "string", "required": false }, + { "key": "EDGE_SSL_CERT", "type": "string", "required": false }, + { "key": "EDGE_SSL_KEY", "type": "string", "required": false }, + { "key": "EDGE_STREAM_PATH", "type": "string", "required": false }, + { "key": "EDGE_NGINX_PATH", "type": "string", "required": false }, + { "key": "EDGE_APACHE_PATH", "type": "string", "required": false }, + { "key": "EDGE_RELOAD", "type": "bool", "required": false }, + { "key": "EDGE_EXTRA_DOMAINS", "type": "string", "required": false }, + { "key": "EDGE_EXCLUDE_DOMAINS", "type": "string", "required": false }, + { "key": "EDGE_NGINX_TEST_CMD", "type": "string", "required": false }, + { "key": "EDGE_NGINX_RELOAD_CMD", "type": "string", "required": false }, + { "key": "EDGE_APACHE_TEST_CMD", "type": "string", "required": false }, + { "key": "EDGE_APACHE_RELOAD_CMD", "type": "string", "required": false }, + { "key": "EDGE_LOCAL_TLDS", "type": "string", "required": false }, + { "key": "EDGE_MANAGE_HOSTS", "type": "bool", "required": false }, + { "key": "EDGE_HOSTS_PATH", "type": "string", "required": false }, + { "key": "EDGE_HOSTS_IP", "type": "string", "required": false }, + { "key": "EDGE_LOCAL_IN_SERVER", "type": "bool", "required": false }, + { "key": "EDGE_SERVE_MODEL", "type": "string", "required": false }, + { "key": "EDGE_FPM_SOCKET", "type": "string", "required": false }, + { "key": "EDGE_SWOOLE_HOST", "type": "string", "required": false }, + { "key": "EDGE_SWOOLE_BASE_PORT", "type": "int", "required": false }, + { "key": "EDGE_INJECT_KERNEL_ENV", "type": "bool", "required": false }, + { "key": "EDGE_APP_ENV", "type": "string", "required": false } + ] +} diff --git a/plugins/Mail/Infrastructure/SmtpMailer.php b/plugins/Mail/Infrastructure/SmtpMailer.php new file mode 100644 index 0000000..f2fb9ee --- /dev/null +++ b/plugins/Mail/Infrastructure/SmtpMailer.php @@ -0,0 +1,105 @@ +render($view, $data); + $message = $this->buildMime($recipients, $subject, $html); + + $this->transport->send([$this->fromEmail, $this->fromName], $recipients, $message); + } + + public function queue(string|array $to, string $subject, string $view, array $data = []): string + { + $this->send($to, $subject, $view, $data); + return 'sync-' . bin2hex(random_bytes(8)); + } + + /** + * Render a PHP template to HTML. If $view contains a newline or '<' it is + * treated as an inline HTML body instead of a template name. + * + * @param array $data + */ + private function render(string $view, array $data): string + { + if (str_contains($view, "\n") || str_contains($view, '<')) { + return $view; // inline HTML + } + + $file = rtrim($this->viewsPath, '/') . '/' . str_replace('.', '/', $view) . '.php'; + if ($this->viewsPath === '' || !is_file($file)) { + throw new GatewayException( + "Mail view [{$view}] not found.", + layer: 'gateway.smtp', + context: ['file' => $file], + ); + } + + return (static function () use ($file, $data): string { + extract($data, EXTR_SKIP); + ob_start(); + include $file; + return (string) ob_get_clean(); + })(); + } + + /** @param list $recipients */ + private function buildMime(array $recipients, string $subject, string $html): string + { + $from = $this->fromName !== '' + ? sprintf('%s <%s>', $this->mimeEncode($this->fromName), $this->fromEmail) + : $this->fromEmail; + + $headers = [ + 'From: ' . $from, + 'To: ' . implode(', ', $recipients), + 'Subject: ' . $this->mimeEncode($subject), + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + 'Date: ' . date('r'), + 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . (gethostname() ?: 'localhost') . '>', + ]; + + return implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeNewlines($html); + } + + private function mimeEncode(string $value): string + { + return preg_match('/[^\x20-\x7E]/', $value) === 1 + ? '=?UTF-8?B?' . base64_encode($value) . '?=' + : $value; + } + + private function normalizeNewlines(string $body): string + { + return preg_replace('/\r\n|\r|\n/', "\r\n", $body) ?? $body; + } +} diff --git a/plugins/Mail/Infrastructure/SmtpTransport.php b/plugins/Mail/Infrastructure/SmtpTransport.php new file mode 100644 index 0000000..99d505f --- /dev/null +++ b/plugins/Mail/Infrastructure/SmtpTransport.php @@ -0,0 +1,129 @@ + $recipients + */ + public function send(array $from, array $recipients, string $rawMessage): void + { + $this->connect(); + try { + $this->ehlo(); + + if ($this->encryption === 'tls') { + $this->command('STARTTLS', 220); + if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new GatewayException('STARTTLS negotiation failed.', layer: 'gateway.smtp'); + } + $this->ehlo(); // re-EHLO after upgrading + } + + if ($this->username !== null) { + $this->command('AUTH LOGIN', 334); + $this->command(base64_encode($this->username), 334); + $this->command(base64_encode((string) $this->password), 235); + } + + $this->command('MAIL FROM:<' . $from[0] . '>', 250); + foreach ($recipients as $rcpt) { + $this->command('RCPT TO:<' . $rcpt . '>', 250); + } + $this->command('DATA', 354); + // Dot-stuffing + terminating "." + $body = preg_replace('/^\./m', '..', $rawMessage) ?? $rawMessage; + $this->command($body . "\r\n.", 250); + $this->command('QUIT', 221); + } finally { + $this->close(); + } + } + + private function connect(): void + { + $prefix = $this->encryption === 'ssl' ? 'ssl://' : ''; + $socket = @stream_socket_client( + $prefix . $this->host . ':' . $this->port, + $errno, + $errstr, + $this->timeout, + STREAM_CLIENT_CONNECT, + ); + if ($socket === false) { + throw new GatewayException( + "Could not connect to SMTP host {$this->host}:{$this->port} ({$errstr}).", + layer: 'gateway.smtp', + context: ['errno' => $errno], + ); + } + $this->socket = $socket; + stream_set_timeout($this->socket, $this->timeout); + $this->expect(220); + } + + private function ehlo(): void + { + $host = gethostname() ?: 'localhost'; + $this->command('EHLO ' . $host, 250); + } + + private function command(string $line, int $expected): void + { + fwrite($this->socket, $line . "\r\n"); + $this->expect($expected); + } + + private function expect(int $code): void + { + $response = ''; + while (($line = fgets($this->socket, 515)) !== false) { + $response .= $line; + // Multi-line replies use "250-"; the final line uses "250 ". + if (isset($line[3]) && $line[3] === ' ') { + break; + } + } + + $actual = (int) substr($response, 0, 3); + if ($actual !== $code) { + throw new GatewayException( + "Unexpected SMTP reply: expected {$code}, got " . trim($response), + layer: 'gateway.smtp', + ); + } + } + + private function close(): void + { + if (is_resource($this->socket)) { + @fclose($this->socket); + } + $this->socket = null; + } +} diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php b/plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php new file mode 100644 index 0000000..0a7c523 --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php @@ -0,0 +1,29 @@ +create('oauth_clients', static function ($t) { + $t->string('id', 64)->primary(); + $t->string('name', 150); + $t->string('secret_hash', 255)->nullable(); // null = public client + $t->text('redirect_uris'); // JSON list + $t->text('grant_types'); // JSON list + $t->text('scopes')->nullable(); // JSON list (empty = any) + $t->boolean('confidential')->default(true); + $t->boolean('revoked')->default(false); + $t->timestamp('created_at')->nullable(); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('oauth_clients'); + } +}; diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php b/plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php new file mode 100644 index 0000000..5357488 --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php @@ -0,0 +1,34 @@ +create('oauth_auth_codes', static function ($t) { + $t->string('id', 64)->primary(); + $t->char('code_hash', 64)->unique(); + $t->string('client_id', 64); + $t->string('user_id', 64); + $t->text('redirect_uri'); + $t->text('scopes')->nullable(); + $t->string('code_challenge', 128)->nullable(); + $t->string('code_challenge_method', 10)->nullable(); + $t->string('nonce', 255)->nullable(); + $t->boolean('consumed')->default(false); + $t->timestamp('expires_at'); + $t->timestamp('created_at')->nullable(); + + $t->index(['client_id']); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('oauth_auth_codes'); + } +}; diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php b/plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php new file mode 100644 index 0000000..dcf02f7 --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php @@ -0,0 +1,32 @@ +create('oauth_refresh_tokens', static function ($t) { + $t->string('id', 64)->primary(); + $t->string('family_id', 64); + $t->char('token_hash', 64)->unique(); + $t->string('client_id', 64); + $t->string('user_id', 64); + $t->text('scopes')->nullable(); + $t->boolean('revoked')->default(false); + $t->timestamp('expires_at'); + $t->timestamp('created_at')->nullable(); + + $t->index(['family_id']); + $t->index(['client_id']); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('oauth_refresh_tokens'); + } +}; diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php b/plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php new file mode 100644 index 0000000..02896a7 --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php @@ -0,0 +1,23 @@ +create('oauth_scopes', static function ($t) { + $t->string('id', 150)->primary(); // the scope identifier, e.g. "profile" + $t->string('description', 255)->nullable(); + $t->timestamp('created_at')->nullable(); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('oauth_scopes'); + } +}; diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php b/plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php new file mode 100644 index 0000000..badb11c --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php @@ -0,0 +1,33 @@ +create('oauth_device_codes', static function ($t) { + $t->string('id', 64)->primary(); + $t->char('device_code_hash', 64)->unique(); + $t->string('user_code', 20)->unique(); + $t->string('client_id', 64); + $t->text('scopes')->nullable(); + $t->string('status', 16)->default('pending'); + $t->string('user_id', 64)->nullable(); + $t->integer('interval_seconds')->default(5); + $t->timestamp('last_polled_at')->nullable(); + $t->timestamp('expires_at'); + $t->timestamp('created_at')->nullable(); + + $t->index(['client_id']); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('oauth_device_codes'); + } +}; diff --git a/plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php b/plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php new file mode 100644 index 0000000..52bb477 --- /dev/null +++ b/plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php @@ -0,0 +1,37 @@ +hasTable('oauth_clients') || $schema->hasColumn('oauth_clients', 'owner_id')) { + return; + } + + $schema->table('oauth_clients', static function ($t) { + $t->string('owner_id', 64)->nullable()->comment('user_id of the registering user; null = first-party'); + $t->index(['owner_id'], 'idx_oauth_clients_owner'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + if (!$schema->hasTable('oauth_clients') || !$schema->hasColumn('oauth_clients', 'owner_id')) { + return; + } + + $schema->table('oauth_clients', static function ($t) { + $t->dropIndex('idx_oauth_clients_owner'); + $t->dropColumn('owner_id'); + }); + } +}; diff --git a/plugins/Tenancy/Application/Ports/AuditReader.php b/plugins/Tenancy/Application/Ports/AuditReader.php new file mode 100644 index 0000000..2fb15ea --- /dev/null +++ b/plugins/Tenancy/Application/Ports/AuditReader.php @@ -0,0 +1,44 @@ + Newest first across the whole trail. */ + public function recent(int $limit = 50, ?int $beforeId = null): array; + + /** @return list Newest first for one tenant. */ + public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array; + + /** @return list Newest first for one user. */ + public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array; + + /** @return list Newest first for one action (e.g. 'tenant.switch'). */ + public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array; + + /** A single entry by its public event id, or null. */ + public function find(string $eventId): ?AuditEntry; + + /** How many entries a tenant has accrued. */ + public function countForTenant(string $tenantId): int; + + /** + * Delete entries strictly older than the cutoff (retention / GDPR purge). + * + * @return int rows removed + */ + public function purgeOlderThan(\DateTimeImmutable $cutoff): int; +} diff --git a/plugins/Tenancy/Application/Ports/AuditSink.php b/plugins/Tenancy/Application/Ports/AuditSink.php new file mode 100644 index 0000000..48017e7 --- /dev/null +++ b/plugins/Tenancy/Application/Ports/AuditSink.php @@ -0,0 +1,23 @@ + $meta + */ + public function record( + string $action, + ?string $userId = null, + ?string $tenantId = null, + array $meta = [], + ?string $ip = null, + ): void; +} diff --git a/plugins/Tenancy/Application/Ports/AuditWriter.php b/plugins/Tenancy/Application/Ports/AuditWriter.php new file mode 100644 index 0000000..0dc8cfa --- /dev/null +++ b/plugins/Tenancy/Application/Ports/AuditWriter.php @@ -0,0 +1,27 @@ + $meta + */ + public function write( + string $action, + ?string $userId = null, + ?string $tenantId = null, + array $meta = [], + ?string $ip = null, + ): void; +} diff --git a/plugins/Tenancy/Application/Services/AuditService.php b/plugins/Tenancy/Application/Services/AuditService.php new file mode 100644 index 0000000..0c05397 --- /dev/null +++ b/plugins/Tenancy/Application/Services/AuditService.php @@ -0,0 +1,49 @@ +writer->write($action, $userId, $tenantId, $meta, $ip); + } catch (\Throwable $e) { + // Best-effort: never let an audit write fail the action it records — + // but surface the failure to the log instead of discarding it. + $this->logger->error('Audit trail write failed', [ + 'action' => $action, + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/plugins/Tenancy/Domain/Entities/AuditEntry.php b/plugins/Tenancy/Domain/Entities/AuditEntry.php new file mode 100644 index 0000000..e22ad22 --- /dev/null +++ b/plugins/Tenancy/Domain/Entities/AuditEntry.php @@ -0,0 +1,57 @@ + $row */ + public static function fromRow(array $row): self + { + $metaRaw = $row['meta'] ?? null; + $meta = is_string($metaRaw) && $metaRaw !== '' + ? (json_decode($metaRaw, true) ?: []) + : []; + + $e = (new self())->forceFill([ + 'id' => (int) $row['id'], + 'eventId' => (string) $row['event_id'], + 'userId' => isset($row['user_id']) ? (string) $row['user_id'] : null, + 'tenantId' => isset($row['tenant_id']) ? (string) $row['tenant_id'] : null, + 'action' => (string) $row['action'], + 'ip' => isset($row['ip']) ? (string) $row['ip'] : null, + 'meta' => is_array($meta) ? $meta : [], + 'occurredAt' => (string) $row['occurred_at'], + ]); + $e->syncOriginal(); + + return $e; + } + + /** @return array */ + public function toArray(bool $onlyChanged = false): array + { + return [ + 'id' => $this->id, + 'event_id' => $this->eventId, + 'user_id' => $this->userId, + 'tenant_id' => $this->tenantId, + 'action' => $this->action, + 'ip' => $this->ip, + 'meta' => $this->meta, + 'occurred_at' => $this->occurredAt, + ]; + } +} diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php b/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php new file mode 100644 index 0000000..18ee772 --- /dev/null +++ b/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php @@ -0,0 +1,123 @@ +page('', [], $limit, $beforeId); + } + + public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array + { + return $this->page('tenant_id = :tenant_id', ['tenant_id' => $tenantId], $limit, $beforeId); + } + + public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array + { + return $this->page('user_id = :user_id', ['user_id' => $userId], $limit, $beforeId); + } + + public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array + { + return $this->page('action = :action', ['action' => $action], $limit, $beforeId); + } + + public function find(string $eventId): ?AuditEntry + { + try { + $row = $this->central->queryOne( + self::SELECT . ' WHERE event_id = :event_id LIMIT 1', + ['event_id' => $eventId], + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to load audit entry.', layer: 'repository.tenancy', previous: $e); + } + + return $row === null ? null : AuditEntry::fromRow($row); + } + + public function countForTenant(string $tenantId): int + { + try { + $row = $this->central->queryOne( + 'SELECT COUNT(*) AS c FROM audit_log WHERE tenant_id = :tenant_id', + ['tenant_id' => $tenantId], + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to count audit entries.', layer: 'repository.tenancy', previous: $e); + } + + return (int) ($row['c'] ?? 0); + } + + public function purgeOlderThan(\DateTimeImmutable $cutoff): int + { + try { + return $this->central->execute( + 'DELETE FROM audit_log WHERE occurred_at < :cutoff', + ['cutoff' => $cutoff->format('Y-m-d H:i:s')], + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to purge audit entries.', layer: 'repository.tenancy', previous: $e); + } + } + + /** + * Run a keyset-paginated listing with an optional WHERE filter. + * + * @param array $params + * @return list + */ + private function page(string $where, array $params, int $limit, ?int $beforeId): array + { + $limit = max(1, min(self::MAX_LIMIT, $limit)); + $clauses = $where !== '' ? [$where] : []; + + if ($beforeId !== null) { + $clauses[] = 'id < ' . (int) $beforeId; // validated int — keyset cursor + } + + $sql = self::SELECT + . ($clauses !== [] ? ' WHERE ' . implode(' AND ', $clauses) : '') + . ' ORDER BY id DESC LIMIT ' . $limit; + + try { + $rows = $this->central->query($sql, $params); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to list audit entries.', layer: 'repository.tenancy', previous: $e); + } + + return array_map(static fn (array $r): AuditEntry => AuditEntry::fromRow($r), $rows); + } +} diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php b/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php new file mode 100644 index 0000000..70b4645 --- /dev/null +++ b/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php @@ -0,0 +1,60 @@ +central->execute( + 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) + VALUES (:eid, :uid, :tid, :action, :ip, :meta, :ts)', + [ + 'eid' => Token::ulid(), + 'uid' => $userId, + 'tid' => $tenantId, + 'action' => $action, + 'ip' => $ip, + 'meta' => $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES), + 'ts' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ], + ); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to write audit entry.', + layer: 'repository.tenancy', + context: ['action' => $action], + previous: $e, + ); + } + } +} diff --git a/plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php b/plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php new file mode 100644 index 0000000..c7889fb --- /dev/null +++ b/plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php @@ -0,0 +1,45 @@ +create('audit_log', static function ($t) { + $t->id(); + $t->char('event_id', 31); + $t->char('user_id', 31)->nullable(); + $t->char('tenant_id', 31)->nullable(); + $t->string('action', 64)->comment('login|tenant.switch|tenant.create|member.invite|...'); + $t->string('ip', 45)->nullable(); + $t->json('meta')->nullable(); + $t->timestamp('occurred_at')->default('CURRENT_TIMESTAMP'); + + $t->unique(['event_id'], 'uniq_event_id'); + $t->index(['tenant_id', 'occurred_at'], 'idx_tenant_time'); + $t->index(['user_id', 'occurred_at'], 'idx_user_time'); + $t->index(['action', 'occurred_at'], 'idx_action_time'); + + $t->engine('InnoDB'); + $t->charset('utf8mb4'); + $t->collation('utf8mb4_0900_ai_ci'); + $t->rowFormat('DYNAMIC'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('audit_log'); + } +}; diff --git a/plugins/User/API/DTOs/FeedbackPage.php b/plugins/User/API/DTOs/FeedbackPage.php new file mode 100644 index 0000000..f539bb2 --- /dev/null +++ b/plugins/User/API/DTOs/FeedbackPage.php @@ -0,0 +1,40 @@ + $items */ + public function __construct( + public array $items, + public bool $hasMore, + public int $limit, + ) {} + + /** The cursor to pass as ?after= for the next page (null on the last page). */ + public function nextCursor(): ?string + { + if (!$this->hasMore || $this->items === []) { + return null; + } + return $this->items[array_key_last($this->items)]->id()->value(); + } + + /** @return array */ + public function meta(): array + { + return [ + 'count' => count($this->items), + 'limit' => $this->limit, + 'has_more' => $this->hasMore, + 'next_cursor' => $this->nextCursor(), + ]; + } +} diff --git a/plugins/User/API/DTOs/ListFeedbackQuery.php b/plugins/User/API/DTOs/ListFeedbackQuery.php new file mode 100644 index 0000000..3d7ef73 --- /dev/null +++ b/plugins/User/API/DTOs/ListFeedbackQuery.php @@ -0,0 +1,46 @@ + + * + * `after` is the opaque public feedback_id of the last row from the previous + * page; the repository resolves it to the internal sort key. `status` is an + * optional triage filter, validated against the closed enum. + */ +final readonly class ListFeedbackQuery +{ + public const DEFAULT_LIMIT = 25; + public const MAX_LIMIT = 100; + + public function __construct( + public int $limit, + public ?string $after, + public ?FeedbackStatus $status, + ) {} + + public static function fromRequest(Request $request): self + { + $limit = (int) $request->input('limit', self::DEFAULT_LIMIT); + $limit = max(1, min($limit, self::MAX_LIMIT)); + + $after = trim((string) $request->input('after', '')); + // Cursor must look like a UUID; otherwise ignore it (start from the top). + if ($after === '' || !preg_match('/^[0-9a-fA-F-]{36}$/', $after)) { + $after = null; + } + + // Unknown status → ignore the filter rather than 422 a read-only list. + $status = FeedbackStatus::tryFrom(trim((string) $request->input('status', ''))); + + return new self(limit: $limit, after: $after, status: $status); + } +} diff --git a/plugins/User/API/DTOs/SubmitFeedbackDTO.php b/plugins/User/API/DTOs/SubmitFeedbackDTO.php new file mode 100644 index 0000000..f801d2b --- /dev/null +++ b/plugins/User/API/DTOs/SubmitFeedbackDTO.php @@ -0,0 +1,60 @@ +input('category')); + } catch (\DomainException $e) { + $errors['category'] = $e->getMessage(); + } + + $rating = null; + try { + $rating = FeedbackRating::fromNullable($request->input('rating')); + } catch (\DomainException $e) { + $errors['rating'] = $e->getMessage(); + } + + $message = null; + try { + $message = FeedbackMessage::fromString((string) $request->input('message', '')); + } catch (\DomainException $e) { + $errors['message'] = $e->getMessage(); + } + + if ($errors !== []) { + throw new ValidationException($errors); + } + + /** @var FeedbackMessage $message */ + return new self(category: $category, rating: $rating, message: $message); + } +} diff --git a/plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php b/plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php new file mode 100644 index 0000000..c7940bd --- /dev/null +++ b/plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php @@ -0,0 +1,50 @@ +version = '1.0'; + } + + public function name(): string + { + return 'feedback.submitted'; + } + + public function version(): string + { + return $this->version; + } + + /** @return array */ + public function payload(): array + { + return [ + 'feedbackId' => $this->feedbackId, + 'userId' => $this->userId, + 'category' => $this->category, + 'rating' => $this->rating, + 'occurredAt' => $this->occurredAt, + 'version' => $this->version, + ]; + } +} diff --git a/plugins/User/Application/Ports/FeedbackStore.php b/plugins/User/Application/Ports/FeedbackStore.php new file mode 100644 index 0000000..1543028 --- /dev/null +++ b/plugins/User/Application/Ports/FeedbackStore.php @@ -0,0 +1,30 @@ +, 1: bool} [entries, hasMore] + */ + public function paginate(ListFeedbackQuery $query): array; + + /** Persist a status transition. Returns false if the row no longer exists. */ + public function updateStatus(string $feedbackId, string $status): bool; +} diff --git a/plugins/User/Application/Services/FeedbackService.php b/plugins/User/Application/Services/FeedbackService.php new file mode 100644 index 0000000..373679a --- /dev/null +++ b/plugins/User/Application/Services/FeedbackService.php @@ -0,0 +1,181 @@ +identity->isGuest()) { + throw new SecurityException( + 'feedback.submit.unauthenticated', + layer: 'service.feedback', + ); + } + + $entry = FeedbackEntry::submit( + userId: $this->identity->userId, + category: $dto->category, + rating: $dto->rating, + message: $dto->message, + ); + + // A single tenant-scoped INSERT is atomic on its own. We deliberately do + // NOT use the kernel TransactionManager here: it is constructed against + // the CENTRAL DatabasePort, whereas this repository writes to the + // request's TENANT connection — wrapping it would open an idle central + // transaction that never covers the tenant write. + try { + $this->repository->insert($entry); + } catch (\Throwable $e) { + throw $this->wrap($e, 'feedback.submit.failed'); + } + + // Integration event AFTER the write succeeds. + $this->eventBus->dispatch(new FeedbackSubmittedIntegrationEvent( + feedbackId: $entry->id()->value(), + userId: $entry->userId(), + category: $entry->category()?->value, + rating: $entry->rating()?->value(), + occurredAt: $entry->createdAt()->format(\DateTimeInterface::RFC3339), + )); + + $this->audit->record('feedback.submitted', ['feedbackId' => $entry->id()->value()]); + + return $entry; + } + + public function find(string $feedbackId): ?FeedbackEntry + { + $entry = $this->repository->find($feedbackId); + if ($entry === null) { + return null; + } + + // Self-or-admin: a user may read only their own feedback. + if (!$entry->isOwnedBy($this->identity->userId) + && !$this->identity->hasPermission(self::PERMISSION_MANAGE)) { + throw new SecurityException( + 'feedback.read.forbidden', + layer: 'service.feedback', + context: ['feedbackId' => $feedbackId], + ); + } + + return $entry; + } + + public function list(ListFeedbackQuery $query): FeedbackPage + { + $this->requireManage(); + + [$entries, $hasMore] = $this->repository->paginate($query); + + return new FeedbackPage( + items: $entries, + hasMore: $hasMore, + limit: $query->limit, + ); + } + + public function updateStatus(string $feedbackId, string $status): ?FeedbackEntry + { + $this->requireManage(); + + $entry = $this->repository->find($feedbackId); + if ($entry === null) { + return null; + } + + // Validate + apply the transition on the entity (forward-only) before + // touching the database — an illegal jump throws a 422. + try { + $entry->transitionTo(FeedbackStatus::fromString($status)); + } catch (\DomainException $e) { + throw new ValidationException(['status' => $e->getMessage()]); + } + + try { + $updated = $this->repository->updateStatus($feedbackId, $entry->status()->value); + } catch (\Throwable $e) { + throw $this->wrap($e, 'feedback.update_status.failed', ['feedbackId' => $feedbackId]); + } + + // The row vanished between read and write (concurrent delete). + if (!$updated) { + return null; + } + + $this->audit->record('feedback.status_changed', [ + 'feedbackId' => $feedbackId, + 'status' => $entry->status()->value, + ]); + + return $entry; + } + + private function requireManage(): void + { + if (!$this->identity->hasPermission(self::PERMISSION_MANAGE)) { + throw new SecurityException( + 'feedback.manage.forbidden', + layer: 'service.feedback', + ); + } + } + + private function wrap(\Throwable $e, string $code, array $context = []): \Throwable + { + // Preserve typed faults so the kernel maps them to the right HTTP status. + if ($e instanceof ServiceException + || $e instanceof ValidationException + || $e instanceof SecurityException + || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\DomainException + ) { + return $e; + } + + return new ServiceException($code, layer: 'service.feedback', context: $context, previous: $e); + } +} diff --git a/plugins/User/Domain/Entities/FeedbackEntry.php b/plugins/User/Domain/Entities/FeedbackEntry.php new file mode 100644 index 0000000..fd84b21 --- /dev/null +++ b/plugins/User/Domain/Entities/FeedbackEntry.php @@ -0,0 +1,102 @@ + */ + protected array $casts = [ + 'rating' => '?int', + 'created_at' => 'datetime', + ]; + + /** + * Submit brand-new feedback. The cross-module announcement is the + * FeedbackSubmittedIntegrationEvent dispatched by the service after the + * write; this aggregate records no in-process domain events. + */ + public static function submit( + string $userId, + ?FeedbackCategory $category, + ?FeedbackRating $rating, + FeedbackMessage $message, + ): self { + if ($userId === '' || mb_strlen($userId) > 31) { + throw new \DomainException('FeedbackEntry requires a valid user id.'); + } + + $e = (new self())->forceFill([ + 'feedback_id' => FeedbackId::generate()->value(), + 'user_id' => $userId, + 'category' => $category?->value, + 'rating' => $rating?->value(), + 'message' => $message->value(), + 'status' => FeedbackStatus::Received->value, + 'created_at' => new \DateTimeImmutable(), + ]); + $e->syncOriginal(); + + return $e; + } + + /** Advance triage state (forward-only). */ + public function transitionTo(FeedbackStatus $next): void + { + $current = $this->status(); + if ($next === $current) { + return; + } + if (!$current->canTransitionTo($next)) { + throw new \DomainException( + "Cannot move feedback from {$current->value} to {$next->value}." + ); + } + $this->setAttribute('status', $next->value); + } + + public function isOwnedBy(string $userId): bool + { + return hash_equals($this->userId(), $userId); + } + + public function id(): FeedbackId { return FeedbackId::fromString($this->getString('feedback_id')); } + public function userId(): string { return $this->getString('user_id'); } + public function category(): ?FeedbackCategory { $v = $this->getRawAttribute('category'); return $v === null ? null : FeedbackCategory::from((string) $v); } + public function rating(): ?FeedbackRating { $v = $this->getRawAttribute('rating'); return $v === null ? null : FeedbackRating::of((int) $v); } + public function message(): FeedbackMessage { return FeedbackMessage::fromString($this->getString('message')); } + public function status(): FeedbackStatus { return FeedbackStatus::from($this->getString('status')); } + public function createdAt(): \DateTimeImmutable { return $this->getDate('created_at') ?? new \DateTimeImmutable(); } + + /** @return array Camel-cased API shape (not the DB shape). */ + public function toArray(bool $onlyChanged = false): array + { + return [ + 'feedbackId' => $this->id()->value(), + 'userId' => $this->userId(), + 'category' => $this->category()?->value, + 'rating' => $this->rating()?->value(), + 'message' => $this->message()->value(), + 'status' => $this->status()->value, + 'createdAt' => $this->createdAt()->format(\DateTimeInterface::RFC3339), + ]; + } +} diff --git a/plugins/User/Domain/ValueObjects/FeedbackCategory.php b/plugins/User/Domain/ValueObjects/FeedbackCategory.php new file mode 100644 index 0000000..105cfbf --- /dev/null +++ b/plugins/User/Domain/ValueObjects/FeedbackCategory.php @@ -0,0 +1,38 @@ +value; + } +} diff --git a/plugins/User/Domain/ValueObjects/FeedbackMessage.php b/plugins/User/Domain/ValueObjects/FeedbackMessage.php new file mode 100644 index 0000000..ec18e65 --- /dev/null +++ b/plugins/User/Domain/ValueObjects/FeedbackMessage.php @@ -0,0 +1,43 @@ +value); + if ($len < self::MIN) { + throw new \DomainException('Feedback message cannot be empty.'); + } + if ($len > self::MAX) { + throw new \DomainException('Feedback message cannot exceed ' . self::MAX . ' characters.'); + } + } + + public static function fromString(string $value): self + { + // Strip control chars except tab (\x09) and newline (\x0A). + $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', trim($value)) ?? ''; + + return new self($clean); + } + + public function value(): string + { + return $this->value; + } +} diff --git a/plugins/User/Domain/ValueObjects/FeedbackRating.php b/plugins/User/Domain/ValueObjects/FeedbackRating.php new file mode 100644 index 0000000..58d9003 --- /dev/null +++ b/plugins/User/Domain/ValueObjects/FeedbackRating.php @@ -0,0 +1,59 @@ + self::MAX) { + throw new \DomainException('Rating must be between 1 and 5.'); + } + } + + public static function of(int $value): self + { + return new self($value); + } + + /** + * null/'' → no rating. Accepts an int, an integer-valued float (4.0) or a + * digit string; rejects fractional floats, arrays and non-numeric strings. + * Typed `mixed` because it receives raw request input (a JSON number may + * decode as float) — a narrow union would TypeError under strict_types + * instead of yielding a clean validation error. + */ + public static function fromNullable(mixed $value): ?self + { + if ($value === null || $value === '') { + return null; + } + if (is_int($value)) { + return new self($value); + } + if (is_float($value) && floor($value) === $value) { + return new self((int) $value); + } + if (is_string($value) && ctype_digit($value)) { + return new self((int) $value); + } + + throw new \DomainException('Rating must be a whole number 1–5.'); + } + + public function value(): int + { + return $this->value; + } +} diff --git a/plugins/User/Domain/ValueObjects/FeedbackStatus.php b/plugins/User/Domain/ValueObjects/FeedbackStatus.php new file mode 100644 index 0000000..7c19d13 --- /dev/null +++ b/plugins/User/Domain/ValueObjects/FeedbackStatus.php @@ -0,0 +1,39 @@ +rank() > $this->rank(); + } + + private function rank(): int + { + return match ($this) { + self::Received => 0, + self::Acknowledged => 1, + self::Resolved => 2, + }; + } +} diff --git a/plugins/User/Infrastructure/Audit/AuditLogger.php b/plugins/User/Infrastructure/Audit/AuditLogger.php new file mode 100644 index 0000000..971ddd4 --- /dev/null +++ b/plugins/User/Infrastructure/Audit/AuditLogger.php @@ -0,0 +1,106 @@ +sink = $sink ?? static fn(string $line) => error_log($line); + } + + /** @param array $context */ + public function record(string $action, array $context = []): void + { + $occurredAt = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339); + + $entry = json_encode([ + 'source' => 'user_audit', + 'action' => $action, + 'actor' => $this->actorId, + 'context' => $context, + 'timestamp' => $occurredAt, + ], JSON_UNESCAPED_SLASHES); + + if ($entry !== false) { + ($this->sink)($entry); + } + + $this->persist($action, $context, $occurredAt); + } + + /** + * Persist to the shared `audit_log` table. Best-effort: any failure is + * swallowed (already captured in the log line) so auditing never aborts the + * audited action. `userId` in context maps to the user_id column; everything + * else is kept in the JSON `meta` column. + * + * @param array $context + */ + private function persist(string $action, array $context, string $occurredAt): void + { + if ($this->db === null) { + return; + } + + $userId = isset($context['userId']) ? (string) $context['userId'] : ($this->actorId ?: null); + $ip = isset($context['ip']) ? (string) $context['ip'] : null; + + $meta = $context; + unset($meta['userId'], $meta['ip']); + $metaJson = $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES); + + try { + $this->db->execute( + 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) + VALUES (:event_id, :user_id, :tenant_id, :action, :ip, :meta, :occurred_at)', + [ + 'event_id' => Ulid::generate(), + 'user_id' => $userId, + 'tenant_id' => ($this->tenantId ?? '') !== '' ? $this->tenantId : null, + 'action' => $action, + 'ip' => $ip, + 'meta' => $metaJson === false ? null : $metaJson, + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ], + ); + } catch (\Throwable) { + // Best-effort — the log line above is the durable fallback. + } + } +} diff --git a/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php b/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php new file mode 100644 index 0000000..0e1b6ce --- /dev/null +++ b/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php @@ -0,0 +1,57 @@ +resolveRequest(). + */ +final class FeedbackController extends ApiController +{ + public function __construct( + private readonly FeedbackService $feedback, + ) {} + + public function submit(): Response + { + $dto = SubmitFeedbackDTO::fromRequest($this->resolveRequest()); + return $this->created($this->feedback->submit($dto)->toArray()); + } + + public function show(string $id): Response + { + return $this->okOrNotFound( + $this->feedback->find($id)?->toArray(), + "Feedback [{$id}] not found.", + ); + } + + public function index(): Response + { + $page = $this->feedback->list(ListFeedbackQuery::fromRequest($this->resolveRequest())); + + return Response::json([ + 'data' => array_map(static fn($f) => $f->toArray(), $page->items), + 'meta' => $page->meta(), + ]); + } + + public function updateStatus(string $id): Response + { + $status = (string) $this->resolveRequest()->input('status', ''); + $entry = $this->feedback->updateStatus($id, $status); + + return $this->okOrNotFound($entry?->toArray(), "Feedback [{$id}] not found."); + } +} diff --git a/plugins/User/Infrastructure/Outbox/OutboxRelay.php b/plugins/User/Infrastructure/Outbox/OutboxRelay.php new file mode 100644 index 0000000..032d055 --- /dev/null +++ b/plugins/User/Infrastructure/Outbox/OutboxRelay.php @@ -0,0 +1,90 @@ +pending($limit); + $dispatched = 0; + + foreach ($rows as $row) { + try { + $payload = json_decode((string) $row['payload'], true, 512, JSON_THROW_ON_ERROR); + + $this->eventBus->dispatch(new GenericIntegrationEvent( + name: (string) $row['event_name'], + version: (string) $row['event_version'], + payload: is_array($payload) ? $payload : [], + )); + + $this->markDispatched((int) $row['id']); + $dispatched++; + } catch (\Throwable $e) { + $this->markFailed((int) $row['id'], (int) $row['attempts'] + 1, $e->getMessage()); + } + } + + return $dispatched; + } + + /** @return list> */ + private function pending(int $limit): array + { + try { + return $this->db->query( + 'SELECT id, event_name, event_version, payload, attempts + FROM user_outbox + WHERE status = 0 + ORDER BY occurred_at ASC, id ASC + LIMIT :limit', + ['limit' => max(1, $limit)], + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to read outbox.', layer: 'repository.user.outbox', previous: $e); + } + } + + private function markDispatched(int $id): void + { + $this->db->execute( + 'UPDATE user_outbox SET status = 1, dispatched_at = :now, attempts = attempts + 1 + WHERE id = :id AND status = 0', + ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'id' => $id], + ); + } + + private function markFailed(int $id, int $attempts, string $error): void + { + $status = $attempts >= self::MAX_ATTEMPTS ? 2 : 0; // park as failed, else retry next run + $this->db->execute( + 'UPDATE user_outbox SET status = :status, attempts = :attempts, last_error = :err + WHERE id = :id', + ['status' => $status, 'attempts' => $attempts, 'err' => mb_substr($error, 0, 1000), 'id' => $id], + ); + } +} diff --git a/plugins/User/Infrastructure/Outbox/OutboxWriter.php b/plugins/User/Infrastructure/Outbox/OutboxWriter.php new file mode 100644 index 0000000..9c7ab31 --- /dev/null +++ b/plugins/User/Infrastructure/Outbox/OutboxWriter.php @@ -0,0 +1,72 @@ +db->execute( + 'INSERT INTO user_outbox + (event_id, event_name, event_version, payload, + status, attempts, occurred_at, created_at) + VALUES + (:event_id, :event_name, :event_version, :payload, + 0, 0, :occurred_at, :created_at)', + [ + 'event_id' => self::uuid(), + 'event_name' => $event->name(), + 'event_version' => $event->version(), + 'payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR), + 'occurred_at' => self::now(), + 'created_at' => self::now(), + ], + ); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to enqueue outbox event.', + layer: 'repository.user.outbox', + context: ['event' => $event->name()], + previous: $e, + ); + } + } + + private static function now(): string + { + return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + } + + private static function uuid(): string + { + $b = random_bytes(16); + $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); + $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); + } +} diff --git a/plugins/User/Infrastructure/Persistence/FeedbackRepository.php b/plugins/User/Infrastructure/Persistence/FeedbackRepository.php new file mode 100644 index 0000000..3e39531 --- /dev/null +++ b/plugins/User/Infrastructure/Persistence/FeedbackRepository.php @@ -0,0 +1,145 @@ +db->execute( + 'INSERT INTO ' . self::TABLE . ' + (user_id, feedback_id, category, rating, message, status, created_at) + VALUES + (:user_id, :feedback_id, :category, :rating, :message, :status, :created_at)', + [ + 'user_id' => $entry->userId(), + 'feedback_id' => $entry->id()->value(), + 'category' => $entry->category()?->value, + 'rating' => $entry->rating()?->value(), + 'message' => $entry->message()->value(), + 'status' => $entry->status()->value, + 'created_at' => $entry->createdAt()->format('Y-m-d H:i:s'), + ], + ); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to insert feedback.', + layer: 'repository.feedback', + context: ['feedbackId' => $entry->id()->value()], + previous: $e, + ); + } + } + + public function find(string $feedbackId): ?FeedbackEntry + { + try { + $row = $this->db->queryOne( + 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' + WHERE feedback_id = :id LIMIT 1', + ['id' => $feedbackId], + ); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to load feedback.', + layer: 'repository.feedback', + previous: $e, + ); + } + + return $row === null ? null : self::hydrate($row); + } + + public function paginate(ListFeedbackQuery $query): array + { + $params = ['limit' => $query->limit + 1]; + $where = []; + + if ($query->status !== null) { + $where[] = 'status = :status'; + $params['status'] = $query->status->value; + } + + if ($query->after !== null) { + // Keyset on the internal id resolved from the opaque public cursor. + $where[] = 'id < (SELECT id FROM ' . self::TABLE . ' WHERE feedback_id = :after)'; + $params['after'] = $query->after; + } + + $clause = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); + + try { + $rows = $this->db->query( + 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . $clause . ' + ORDER BY id DESC + LIMIT :limit', + $params, + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to list feedback.', layer: 'repository.feedback', previous: $e); + } + + $hasMore = count($rows) > $query->limit; + if ($hasMore) { + array_pop($rows); + } + + return [array_map(static fn(array $r): FeedbackEntry => self::hydrate($r), $rows), $hasMore]; + } + + public function updateStatus(string $feedbackId, string $status): bool + { + try { + $affected = $this->db->execute( + 'UPDATE ' . self::TABLE . ' SET status = :status WHERE feedback_id = :id', + ['status' => $status, 'id' => $feedbackId], + ); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to update feedback status.', + layer: 'repository.feedback', + context: ['feedbackId' => $feedbackId], + previous: $e, + ); + } + + return $affected > 0; + } + + /** @param array $row */ + private static function hydrate(array $row): FeedbackEntry + { + return FeedbackEntry::reconstitute($row); + } +} diff --git a/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php b/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php new file mode 100644 index 0000000..73e3b9f --- /dev/null +++ b/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php @@ -0,0 +1,55 @@ +create('user_feedback', static function ($t) { + $t->id(); + + $t->char('user_id', 31) + ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); + + $t->char('feedback_id', 36) + ->comment('Public opaque ID (UUID) returned to the client'); + $t->string('category', 60)->nullable() + ->comment('search_browsing|messaging|payments|hosting|app_performance|feature_request|other'); + $t->unsignedTinyInteger('rating')->nullable() + ->comment('1-5 star rating'); + $t->text('message'); + $t->string('status', 20)->default('received') + ->comment('received|acknowledged|resolved'); + + $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); + + // Public id is globally unique + the client-facing lookup key. + $t->unique(['feedback_id'], 'uniq_feedback_id'); + // List a user's submissions; triage by status. + $t->index(['user_id'], 'idx_feedback_user'); + $t->index(['status'], 'idx_feedback_status'); + + $t->engine('InnoDB'); + $t->charset('utf8mb4'); + $t->collation('utf8mb4_0900_ai_ci'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('user_feedback'); + } +}; diff --git a/plugins/User/resources/views/account/feedback.php b/plugins/User/resources/views/account/feedback.php new file mode 100644 index 0000000..61d328e --- /dev/null +++ b/plugins/User/resources/views/account/feedback.php @@ -0,0 +1,116 @@ + +
+

Submit feedback

+

POST /ajx/feedback

+
+ + + + +
+
+
+
+ +
+
+

Triage (admin)

+ +
+

GET /ajx/feedback · PATCH /ajx/feedback/{id}

+ + + +
IDUserCategoryRatingStatus
Loading…
+
+ + diff --git a/projects/projects.json b/projects/projects.json index 0967ef4..2cf9004 100644 --- a/projects/projects.json +++ b/projects/projects.json @@ -1 +1,20 @@ -{} +{ + "shop": { + "name": "shop", + "version": "1.0.0", + "path": "/home/home/Documents/PROJECTS/psp-shop", + "domains": [ + "shop.com" + ] + }, + "hkmcode": { + "name": "hkmcode", + "version": "1.0.0", + "path": "/home/home/Documents/PROJECTS/hkmcode", + "domains": [ + "hkm.local", + "api.hkm.local", + "app.hkm.local" + ] + } +} diff --git a/tests/Unit/Plugins/User/FeedbackServiceTest.php b/tests/Unit/Plugins/User/FeedbackServiceTest.php new file mode 100644 index 0000000..2bc26ed --- /dev/null +++ b/tests/Unit/Plugins/User/FeedbackServiceTest.php @@ -0,0 +1,149 @@ +store = new FakeFeedbackStore(); + } + + private function service(Identity $identity): FeedbackService + { + return new FeedbackService( + repository: $this->store, + eventBus: new EventBus($this->emptyContainer()), + identity: $identity, + audit: new AuditLogger('actor', static fn(string $l) => null), + ); + } + + private function emptyContainer(): ContainerInterface + { + return new class implements ContainerInterface { + public function get(string $id): mixed { throw new \RuntimeException('no bindings'); } + public function has(string $id): bool { return false; } + }; + } + + private function submitDto(array $data): SubmitFeedbackDTO + { + return SubmitFeedbackDTO::fromRequest(FakeRequest::with($data)); + } + + private function user(string $id, array $permissions = []): Identity + { + return new Identity($id, 'tenant-1', [], $permissions, 'jwt'); + } + + // ── submit ──────────────────────────────────────────────────────────────── + + public function test_guest_cannot_submit(): void + { + $this->expectException(SecurityException::class); + $this->service(Identity::guest())->submit($this->submitDto(['message' => 'Hi'])); + } + + public function test_authenticated_user_submits_and_is_attributed_to_identity(): void + { + $svc = $this->service($this->user('user-A')); + + $dto = $this->submitDto(['category' => 'payments', 'rating' => '4', 'message' => 'Great']); + $result = $svc->submit($dto); + + $this->assertSame('user-A', $result->userId()); + $this->assertSame('payments', $result->category()?->value); + $this->assertSame(4, $result->rating()?->value()); + $this->assertSame('received', $result->status()->value); + $this->assertNotNull($this->store->find($result->id()->value())); + } + + public function test_invalid_category_is_rejected(): void + { + $this->expectException(ValidationException::class); + $this->submitDto(['category' => 'not_a_category', 'message' => 'Hi']); + } + + public function test_empty_message_is_rejected(): void + { + $this->expectException(ValidationException::class); + $this->submitDto(['message' => ' ']); + } + + public function test_fractional_rating_is_a_validation_error_not_a_type_error(): void + { + // A JSON float rating must surface as 422, never an uncaught TypeError. + $this->expectException(ValidationException::class); + $this->submitDto(['rating' => 4.5, 'message' => 'Hi']); + } + + public function test_integer_valued_float_rating_is_accepted(): void + { + $result = $this->service($this->user('user-A')) + ->submit($this->submitDto(['rating' => 4.0, 'message' => 'Hi'])); + + $this->assertSame(4, $result->rating()?->value()); + } + + // ── read (self-or-admin) ──────────────────────────────────────────────────── + + public function test_owner_can_read_own_feedback(): void + { + $owner = $this->user('user-A'); + $id = $this->service($owner)->submit($this->submitDto(['message' => 'Mine']))->id()->value(); + + $this->assertSame('user-A', $this->service($owner)->find($id)?->userId()); + } + + public function test_other_user_cannot_read_foreign_feedback(): void + { + $id = $this->service($this->user('user-A'))->submit($this->submitDto(['message' => 'Mine']))->id()->value(); + + $this->expectException(SecurityException::class); + $this->service($this->user('user-B'))->find($id); + } + + public function test_manager_can_read_any_feedback(): void + { + $id = $this->service($this->user('user-A'))->submit($this->submitDto(['message' => 'Mine']))->id()->value(); + + $manager = $this->user('admin', ['feedback:manage']); + $this->assertSame('user-A', $this->service($manager)->find($id)?->userId()); + } + + // ── list / triage (manager only) ──────────────────────────────────────────── + + public function test_non_manager_cannot_list(): void + { + $this->expectException(SecurityException::class); + $this->service($this->user('user-A'))->list(ListFeedbackQuery::fromRequest(FakeRequest::with([], 'GET'))); + } + + public function test_status_can_only_move_forward(): void + { + $id = $this->service($this->user('user-A'))->submit($this->submitDto(['message' => 'Mine']))->id()->value(); + $manager = $this->user('admin', ['feedback:manage']); + + // received → resolved is allowed (forward). + $this->assertSame('resolved', $this->service($manager)->updateStatus($id, 'resolved')?->status()->value); + } +} diff --git a/tests/Unit/Plugins/User/Support/FakeFeedbackStore.php b/tests/Unit/Plugins/User/Support/FakeFeedbackStore.php new file mode 100644 index 0000000..8354598 --- /dev/null +++ b/tests/Unit/Plugins/User/Support/FakeFeedbackStore.php @@ -0,0 +1,51 @@ + */ + private array $rows = []; + + public function insert(FeedbackEntry $entry): void + { + $this->rows[$entry->id()->value()] = $entry; + } + + public function find(string $feedbackId): ?FeedbackEntry + { + return $this->rows[$feedbackId] ?? null; + } + + public function paginate(ListFeedbackQuery $query): array + { + $all = array_reverse(array_values($this->rows)); + + if ($query->status !== null) { + $all = array_values(array_filter( + $all, + static fn(FeedbackEntry $e): bool => $e->status() === $query->status, + )); + } + + $page = array_slice($all, 0, $query->limit); + $hasMore = count($all) > $query->limit; + + return [$page, $hasMore]; + } + + public function updateStatus(string $feedbackId, string $status): bool + { + return isset($this->rows[$feedbackId]); + } +} diff --git a/tools/ci/protect-main.sh b/tools/ci/protect-main.sh new file mode 100755 index 0000000..0559025 --- /dev/null +++ b/tools/ci/protect-main.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Secure the `main` branch: CODEOWNERS (you as sole reviewer) + hardened GitHub +# branch protection. Idempotent — safe to re-run. Requires the `gh` CLI, auth'd +# with a token that has admin rights on the repo. +# +# ./tools/ci/protect-main.sh +# +set -euo pipefail + +REPO="$(gh repo view --json nameWithOwner -q '.nameWithOwner')" +LOGIN="$(gh api user --jq '.login')" +BRANCH="main" + +# Optionally add a second collaborator (invitation). Set these env vars: +# COLLABORATOR= COLLAB_ROLE= +# e.g. COLLABORATOR=octocat COLLAB_ROLE=push ./tools/ci/protect-main.sh +COLLABORATOR="${COLLABORATOR:-}" +COLLAB_ROLE="${COLLAB_ROLE:-push}" + +if [ -n "$COLLABORATOR" ]; then + echo "Inviting @$COLLABORATOR as '$COLLAB_ROLE' collaborator..." + gh api -X PUT "repos/$REPO/collaborators/$COLLABORATOR" \ + -H "Accept: application/vnd.github+json" \ + -f permission="$COLLAB_ROLE" >/dev/null + echo "Invitation sent (they must accept it)." + echo +fi + +echo "Repo: $REPO" +echo "Owner: @$LOGIN (sole required reviewer)" +echo "Branch: $BRANCH" +echo + +# ── 1. CODEOWNERS — @you owns everything, so every PR needs your review ─────── +mkdir -p .github +if [ -n "$COLLABORATOR" ]; then + OWNERS="@$LOGIN @$COLLABORATOR" +else + OWNERS="@$LOGIN" +fi +printf '# Every change requires review from a repo owner.\n* %s\n' "$OWNERS" > .github/CODEOWNERS +echo "Wrote .github/CODEOWNERS ($OWNERS)" + +# ── 2. Hardened branch protection ───────────────────────────────────────────── +# - 1 approving review, from a CODE OWNER, stale approvals dismissed on new pushes +# - required CI status checks (must pass + be up to date with main) +# - enforced for admins too (strict, no bypass) +# - linear history (no merge commits), conversations resolved before merge +# - force-push + deletion blocked +gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" \ + -H "Accept: application/vnd.github+json" \ + --input - <<'JSON' +{ + "required_status_checks": { + "strict": true, + "contexts": ["PHPUnit (PHP 8.4)", "Zig build (all targets)"] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "require_code_owner_reviews": true, + "dismiss_stale_reviews": true + }, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true, + "block_creations": false, + "lock_branch": false, + "allow_fork_syncing": false +} +JSON + +echo +echo "Branch protection applied to $BRANCH." +echo "Commit + push .github/CODEOWNERS on master, then PR it into main so it takes effect." diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig index a86b2e5..46174ce 100644 --- a/tools/src/lib/userconfig.zig +++ b/tools/src/lib/userconfig.zig @@ -11,7 +11,18 @@ const EnvMap = std.process.Environ.Map; const Dir = std.Io.Dir; /// Absolute path to the config file, honouring XDG_CONFIG_HOME then HOME. +/// +/// Under `sudo`, HOME is root's (/root) but the config was written by the +/// invoking user — so `sudo hkm --dev` would otherwise lose HKM_DEV_HOME and +/// everything else in config.env. When SUDO_USER is set we resolve the config in +/// that user's home instead, so a privileged run (e.g. editing /etc/hosts) still +/// sees the same configuration as a normal run. pub fn path(allocator: std.mem.Allocator, env: *EnvMap) !?[]const u8 { + if (env.get("SUDO_USER")) |user| { + if (user.len > 0 and !std.mem.eql(u8, user, "root")) { + return try std.fmt.allocPrint(allocator, "/home/{s}/.config/hkm/config.env", .{user}); + } + } if (env.get("XDG_CONFIG_HOME")) |x| { if (x.len > 0) return try std.fmt.allocPrint(allocator, "{s}/hkm/config.env", .{x}); } diff --git a/tools/src/templates/README.md b/tools/src/templates/README.md new file mode 100644 index 0000000..7e161da --- /dev/null +++ b/tools/src/templates/README.md @@ -0,0 +1,27 @@ +# {{PROJECT_NAME}} + +A standalone [PhpServicePlatform](https://github.com/alfacode-team) project, +scaffolded with `hkm new`. Hybrid global-kernel model: the framework kernel is +installed globally; this project owns its plugins + `src/` (namespace `{{STUDLY}}\`). + +## Getting started + +```bash +composer install +# generate an APP_KEY and put it in .env: +php -r "echo base64_encode(random_bytes(32)).PHP_EOL;" +php -S localhost:8000 -t app/public +``` + +## Layout + +| Path | Role | +|-----------------|--------------------------------------------------| +| `app/bootstrap` | Kernel autoload + project bootstrap | +| `app/public` | HTTP entry (`index.php`) | +| `app/cli` | CLI entry (`run.php`) | +| `src/` | Project-only code (namespace `{{STUDLY}}\`) | +| `config/` | Project configuration | +| `database/` | LetMigrate migrations / seeders / factories | +| `resources/` | Views | +| `proj.json` | Project manifest (routes, domains, views) | diff --git a/tools/src/templates/app/bootstrap/app.php b/tools/src/templates/app/bootstrap/app.php new file mode 100644 index 0000000..11ce79e --- /dev/null +++ b/tools/src/templates/app/bootstrap/app.php @@ -0,0 +1,329 @@ +http()->handle(...)` for web, + * `$kernel->cli()->run(...)` for the terminal. Keeping wiring here and execution + * in the entry points means all surfaces share one identical configuration. + * + * ----------------------------------------------------------------------------- + * FLAT LAYOUT + * ----------------------------------------------------------------------------- + * This project uses the flat layout: the scaffolded directory IS the project. + * There is no nested projects// folder, so the base path and the project + * path are the SAME directory — the project root. `dirname(__DIR__, 2)` walks up + * two levels (bootstrap → app → root) to find it. + * + * ----------------------------------------------------------------------------- + * BOOT ORDER (top to bottom in this file — order matters) + * ----------------------------------------------------------------------------- + * 1. Autoloaders — make the kernel + plugins + your code loadable. + * 2. Domain — map the incoming Host header to a project "face". + * 3. Environment — load the .env cascade BEFORE any config is read. + * 4. Error net — install the pre-kernel safety net (catches early fatals). + * 5. APP_KEY guard — refuse to boot outside local/testing without a real key. + * 6. Ports — declare lazy infrastructure factories (DB, cache, ...). + * 7. Kernel build — configure modules + security and compile manifests. + * + * `build()` is compile-only: it validates config and compiles manifests but does + * NOT construct pipelines or wire modules. That happens lazily on the first + * http()/cli() call in the entry point — so a CLI process never pays for HTTP + * wiring and vice-versa. + * ============================================================================= + */ + +// ----------------------------------------------------------------------------- +// STEP 1 — AUTOLOADERS +// Load the kernel autoload helper (defined in kernel-autoload.php), then run it +// to register the framework kernel, the Plugins\ namespace, and this project's +// PSR-4 roots. The guard keeps this safe even when an entry point already loaded +// the helper. +// ----------------------------------------------------------------------------- +if (!function_exists('psp_require_kernel_autoload') || !function_exists('psp_kernel_home')) { + require_once __DIR__ . '/kernel-autoload.php'; +} +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\EncryptionPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +use Project\Bootstrap\EntryHelpers; +use Project\Bootstrap\Environment\ErrorGuard; +use Project\Bootstrap\Environment\LoadEnvironment; +use Project\Infrastructure\FileQueue; +use Project\Infrastructure\InMemoryCache; +use Project\Infrastructure\PdoDatabase; + +// Plugins — port adapters / infrastructure. +use Plugins\Crypto\Infrastructure\AesEncrypter; +use Plugins\Crypto\Infrastructure\PasswordHasher; +use Plugins\Database\Infrastructure\Drivers\DatabaseConfigurationFactory; +use Plugins\Database\Infrastructure\Persistence\MultiDriverDatabaseAdapter; +use Plugins\Database\Infrastructure\Pool\ConnectionPool; +use Plugins\Database\Infrastructure\Pool\PoolConfiguration; + + + +// Plugins — module providers (registered into the kernel below). +use Plugins\Crypto\Provider as CryptoProvider; +use Plugins\I18n\Provider as I18nProvider; +use Plugins\Database\Provider as DatabaseProvider; +use Plugins\Commands\Provider as CommandsProvider; +use Plugins\Storage\Provider as StorageProvider; +use Plugins\HttpClient\Provider as HttpClientProvider; +use Plugins\Session\Provider as SessionProvider; +use Plugins\Cookie\Provider as CookieProvider; +use Plugins\RedisCache\Provider as RedisCacheProvider; +use Plugins\SiteSEO\Application\Listeners\EnqueueIndexNowListener; +use Plugins\SiteSEO\Provider as SiteSeoModule; +use Plugins\View\Provider as ViewModule; +use Plugins\SecurityFilters\Provider as SecurityFiltersModule; +use Plugins\Edge\Provider as EdgeProvider; + + +// Flat layout: this directory's grandparent is the project root. +$projectRoot = dirname(__DIR__, 2); + +// ----------------------------------------------------------------------------- +// STEP 2 — DOMAIN RESOLUTION +// Translate the request's Host header into a DomainContext (project face: +// admin / api / project / public + any features). This stays in the project +// layer so the kernel never needs to know about hosts. It is null under CLI and +// workers (no Host header) — that is expected and handled downstream. +// ----------------------------------------------------------------------------- +$domain = EntryHelpers::resolveDomain($projectRoot, $_SERVER['HTTP_HOST'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 3 — ENVIRONMENT +// Load the .env cascade BEFORE anything reads configuration. Values already in +// the real process environment always win, so true OS/server config is never +// clobbered. (Note: .env values are injected into $_ENV/$_SERVER, NOT putenv() — +// always read config via the env() helper below, never getenv().) +// ----------------------------------------------------------------------------- +// $_SERVER['argv'] is present under the CLI SAPI (null on web), so passing it +// here lets `--env=...` / `--domain=...` flags select the .env tier for console +// commands — without the CLI entry needing to load the environment itself. +LoadEnvironment::load($projectRoot, $domain, $_SERVER['argv'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 4 — PRE-KERNEL ERROR NET +// The outer safety net. It catches throws and PHP fatals that happen BEFORE the +// kernel's own error pipeline is live (e.g. the APP_KEY guard just below, parse +// errors, out-of-memory). It writes to the same log the kernel's FileNotifier +// uses, so all errors land in one place: var/logs/errors.log. +// ----------------------------------------------------------------------------- +ErrorGuard::install($projectRoot . '/var/logs/errors.log'); + +// Canonical config reader. $_ENV is the source of truth (LoadEnvironment does +// not call putenv(), so getenv() will NOT see .env values). This wraps the +// global env() helper and normalises the result to a nullable string. +$env = static fn(string $key, ?string $default = null): ?string => + (($v = env($key)) !== null ? (string) $v : $default); + +// ----------------------------------------------------------------------------- +// STEP 5 — ENCRYPTION KEY (FAIL-FAST) +// Collect the active key plus an optional previous key (kept during rotation so +// data encrypted with the old key still decrypts). Order matters: the first key +// encrypts; all keys are tried on decrypt. +// ----------------------------------------------------------------------------- +$appKeys = array_values(array_filter([ + $env('APP_KEY', '') ?? '', + $env('APP_KEY_PREVIOUS', '') ?? '', +], static fn(string $k): bool => $k !== '')); + +// Refuse to boot in any non-local environment without a real key. Without one +// the encryption layer would silently fall back to an all-zero key, leaving +// encrypted cookies/sessions effectively unprotected. Better to fail loudly. +$appEnv = strtolower($env('APP_ENV', 'production') ?? 'production'); +if ($appKeys === [] && !in_array($appEnv, ['local', 'testing'], true)) { + throw new \RuntimeException( + "APP_KEY is not set. Refusing to boot in '{$appEnv}' with an insecure " + . 'fallback encryption key. Generate one with ' + . 'php -r "echo base64_encode(random_bytes(32)).PHP_EOL;".' + ); +} + +// ----------------------------------------------------------------------------- +// STEP 6 — PORT FACTORIES (LAZY) +// Ports are the kernel's seams to infrastructure (database, cache, mail, ...). +// Bind each as a CLOSURE so the implementation — and any connection it opens — +// is constructed only the first time a loaded module actually resolves it. A +// request that never touches the database pays nothing for it. +// +// Add more ports here as your project grows, e.g.: +// CachePort::class => static fn() => new RedisCache(...), +// MailPort::class => static fn() => new SmtpMailer(...), +// ----------------------------------------------------------------------------- +$ports = [ + CachePort::class => static fn(): InMemoryCache => new InMemoryCache(), + + DatabasePort::class => static fn(): PdoDatabase => new PdoDatabase( + dsn: $env('DB_DSN', 'sqlite::memory:') ?? 'sqlite::memory:', + username: $env('DB_USERNAME'), + password: $env('DB_PASSWORD'), + ), + HashingPort::class => static fn(): PasswordHasher => new PasswordHasher( + cost: (int) ($env('HASH_BCRYPT_COST', '12') ?? '12'), + ), + + EncryptionPort::class => static fn(): AesEncrypter => new AesEncrypter( + $appKeys === [] ? str_repeat('0', 32) : $appKeys, + ), + + // File-backed queue (cross-process, no Redis). RedisCache overrides this + // when REDIS_HOST is set. Lets `php app/worker/run.php` drain real jobs. + QueuePort::class => static fn(): FileQueue => new FileQueue($projectRoot . '/var/queue'), + + // The SEO module subscribes EnqueueIndexNowListener to seo.url_published, but + // the EventBus resolves listeners from the CoreContainer — so bind it here + // with the QueuePort. (The factory receives the container.) + EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( + $c->make(QueuePort::class), + ), +]; + +if (filter_var($env('DB_POOL_ENABLED', 'false'), FILTER_VALIDATE_BOOL)) { + $dbConfig = (new DatabaseConfigurationFactory())->fromEnvironment(); + $logQueries = filter_var($env('DB_ENABLE_QUERY_LOG', 'false'), FILTER_VALIDATE_BOOL); + + $pool = new ConnectionPool( + factory: static fn(): MultiDriverDatabaseAdapter => + new MultiDriverDatabaseAdapter($dbConfig, null, $logQueries), + config: PoolConfiguration::fromEnvironment(), + driver: $dbConfig->driver(), + ); + $pool->warmup(); + + $ports[ConnectionPool::class] = $pool; +} + + +// ----------------------------------------------------------------------------- +// STEP 7 — CONFIGURE, WIRE & BUILD THE KERNEL +// The fluent builder assembles everything. build() is compile-only (validates +// config + compiles route/view/job manifests); pipelines and modules are not +// materialized until the first http()/cli() call in the entry point. +// ----------------------------------------------------------------------------- +return Kernel::configure() + // Filesystem roots. Flat layout → base == project == root, so var/, config/, + // database/ and userdata/ all resolve under this directory. + ->withBasePath($projectRoot) + ->withProjectPath($projectRoot) + + // Lazy infrastructure factories from STEP 6. + ->withPorts($ports) + + // Project-layer routes declared in proj.json ("routes"). These map a + // method+path to one of YOUR controllers (full class path) and resolve under + // the synthetic '__project__' scope — no module register() runs for them. + // Keep these controllers thin; real domain logic lives in plugins. + ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + + // Security layers run BEFORE any module loads — a denied request costs zero + // module work. CsrfTokenLayer here is a stateless, HMAC-signed token + // (WordPress-nonce style): the token is signed with APP_KEY and bound to the + // opaque `csrf_bind` cookie, so no cookie VALUE is ever trusted as the token. + // /api is exempt because APIs authenticate per request, not via a browser + // CSRF token. Add a FirewallLayer / RateLimiterLayer here as needed. + ->withSecurity([ + new CsrfTokenLayer( + bindCookie: 'csrf_bind', + exemptPaths: ['/api'], + ), + ]) + + // ON-DEMAND modules: their boot() hooks register at build, but their + // register() bindings only run when a route's dependency graph pulls them + // in. Use for capabilities only SOME routes need (views, outbound HTTP, + // storage). A route opts in via its "requires" in proj.json / module.json. + ->withModules([ + // Crypto (solves: crypto) — provides the concrete AesEncrypter and + // PasswordHasher classes behind the Encryption/Hashing port factories, + // plus crypto helpers other modules consume. + CryptoProvider::class, + + // I18n (solves: i18n) — translation/localisation: message catalogues, + // locale negotiation, and the translator used by modules and views. + I18nProvider::class, + + // Database (solves: database.query) — the multi-driver database stack: + // the DatabasePort adapter, the pooled adapter that borrows from the + // ConnectionPool, and connection/schema management. + DatabaseProvider::class, + + // Commands (solves: commands) — registers this project's console + // commands into the CLI pipeline (run via `php app/cli/run.php`). + CommandsProvider::class, + + // Storage (solves: storage.local) — the StoragePort: file storage on the + // local disk or S3/S3-compatible backends (atomic writes, streaming + // up/download, signed temporary URLs). Routes opt in via + // "requires": ["storage.local"]. + StorageProvider::class, + + // HttpClient (solves: http.client) — the HttpClientPort for OUTBOUND + // HTTP (calling third-party APIs from gateways). Required by SiteSEO. + HttpClientProvider::class, + + // View (solves: view.rendering) — server-side PHP templating: layouts, + // sections, the project-first view cascade and `namespace::view` + // resolution. Routes opt in via "requires": ["view.rendering"]. + ViewModule::class, + + // SiteSEO (solves: seo.management) — SEO toolkit: sitemaps, Open Graph, + // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* + // routes. Needs http.client (above) for its network actions. + SiteSeoModule::class, + + // Edge (solves: edge.routing) — generates the host's web-server front + // config (nginx SNI stream splitter / nginx-only / Apache vhost) from the + // platform's registered domains. CLI-first: `hkm edge:status`, + // `hkm edge:apply`. Routes opt in via "requires": ["edge.routing"]. + EdgeProvider::class, + ]) + + // ESSENTIAL modules: registered into EVERY request container regardless of + // the route graph. Use sparingly for cross-cutting, REQUEST-SCOPED + // infrastructure that can't be a stateless app-lifetime port — sessions, + // cookies, a cache override. Keep their adapters self-guarding (no work + // until first use) so idle requests stay cheap. + ->withEssentialModules([ + // Session (solves: session.management) — the SessionPort: per-request + // session state, flash data, and the CSRF token source. + SessionProvider::class, + + // Cookie (solves: http.cookies) — the CookieJar plus the stage that + // flushes queued cookies onto the Response (encrypting via EncryptionPort). + CookieProvider::class, + + // RedisCache (solves: cache.redis) — when REDIS_HOST is set, OVERRIDES + // the CachePort and QueuePort with Redis-backed adapters (shared across + // workers). Falls back to the in-memory/file defaults when unset. + RedisCacheProvider::class, + + // SecurityFilters (solves: http.security_filters) — global CORS + + // SecureHeaders hooks, and the route filter aliases (auth, throttle, + // hmac, shield). HMAC no longer runs globally — it fires ONLY on routes + // that declare "filters": ["hmac"], so this is safe to enable app-wide. + SecurityFiltersModule::class, + ]) + + // Compile-only. Returns the Kernel to the entry point, which materializes it + // on the first http()/cli() call. + ->build(); diff --git a/tools/src/templates/app/bootstrap/kernel-autoload.php b/tools/src/templates/app/bootstrap/kernel-autoload.php new file mode 100644 index 0000000..3f10307 --- /dev/null +++ b/tools/src/templates/app/bootstrap/kernel-autoload.php @@ -0,0 +1,222 @@ +\ (this project's src/) + * + * ----------------------------------------------------------------------------- + * WHY A CUSTOM AUTOLOAD FILE (the "hybrid global-kernel model") + * ----------------------------------------------------------------------------- + * In this model the framework kernel is installed ONCE, GLOBALLY (via + * `composer global require alfacode-team/php-service-platform`) and shared by + * every project on the machine. Each project keeps ONLY its own plugins and + * `src/` in a LOCAL `vendor/`. The benefit: many projects, one kernel copy, + * upgraded in one place. + * + * That means a single `require 'vendor/autoload.php'` is NOT enough — the kernel + * may not live in this project's vendor at all. This file resolves both halves: + * + * LOCAL vendor → registers Plugins\ + your project PSR-4 (App\, \) + * GLOBAL vendor → registers the kernel namespace, only if not already loaded + * + * ----------------------------------------------------------------------------- + * RESOLUTION ORDER (first hit that provides the kernel class wins) + * ----------------------------------------------------------------------------- + * 1. ./vendor/autoload.php — this project's local vendor. + * Always loaded first so plugins + + * your code register. If you also + * `composer require` the kernel + * locally, this alone is enough and + * the steps below are skipped. + * 2. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point + * it at any vendor/autoload.php + * (e.g. the monorepo's) to reuse a + * specific kernel + its plugins. + * 3. $COMPOSER_HOME/vendor/autoload.php — Composer's configured home. + * 4. ~/.config/composer/vendor/autoload.php — Linux/macOS default global home. + * 5. ~/.composer/vendor/autoload.php — older Composer default home. + * + * If NONE provide the kernel class, the function prints an actionable message + * (CLI → STDERR, web → HTTP 500 + plain text) and hard-exits with code 1. We + * `exit()` rather than throw because at this point the kernel's error pipeline + * does not exist yet — there is nothing to catch a throw. + * + * ----------------------------------------------------------------------------- + * HOW TO CUSTOMISE + * ----------------------------------------------------------------------------- + * - Kernel in a non-standard location? set PSP_GLOBAL_AUTOLOAD=/abs/.../vendor/autoload.php + * - Want a fully self-contained project? `composer require alfacode-team/php-service-platform` + * locally; step 1 then satisfies everything. + * - The function is guarded by function_exists() + the class_exists() early + * returns, so requiring this file more than once (every entry script does) + * is safe and cheap — no autoloader is registered twice. + * + * `dirname(__DIR__, 2)` walks up two levels (bootstrap → app → project root) + * because this file lives at /app/bootstrap/kernel-autoload.php. + * ============================================================================= + */ + +if (!function_exists('psp_require_kernel_autoload')) { + /** + * Locate and require the autoloader(s) that provide the framework kernel. + * + * Idempotent: safe to call repeatedly. Returns as soon as the kernel class + * is resolvable; hard-exits with code 1 if it can never be found. + */ + function psp_require_kernel_autoload(): void + { + // The canonical "is the framework available?" probe. As soon as this + // class exists, every kernel namespace is autoloadable and we are done. + $kernelClass = \AlfacodeTeam\PhpServicePlatform\Kernel\Kernel::class; + + // --- STEP 1: local vendor ------------------------------------------- + // Load this project's own vendor first. This registers Plugins\ and the + // project's PSR-4 roots (App\, \). It MIGHT also already contain + // the kernel (if you required it locally) — checked right after. + $localVendor = dirname(__DIR__, 2) . '/vendor/autoload.php'; + if (is_file($localVendor)) { + require_once $localVendor; + } + // Local vendor already provided the kernel → nothing more to do. + if (class_exists($kernelClass)) { + return; + } + + // --- STEP 2-5: build the ordered list of GLOBAL autoload candidates -- + // Each entry is a possible vendor/autoload.php that may hold the kernel. + // They are tried in priority order until one resolves the kernel class. + $candidates = []; + + // (2) Explicit override — highest priority after local vendor. Lets an + // operator or a test harness pin an exact kernel install. + $explicit = getenv('PSP_GLOBAL_AUTOLOAD'); + if (is_string($explicit) && $explicit !== '') { + $candidates[] = $explicit; + } + + // (3) Composer's configured home directory, if COMPOSER_HOME is set. + $composerHome = getenv('COMPOSER_HOME'); + if (is_string($composerHome) && $composerHome !== '') { + $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; + } + + // (4)+(5) Default global Composer homes on Linux/macOS. + $home = getenv('HOME'); + if (is_string($home) && $home !== '') { + $home = rtrim($home, '/\\'); + $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default + $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default + } + + // Try each candidate; the first one that makes the kernel class + // resolvable wins and we return immediately. + foreach ($candidates as $autoload) { + if (is_string($autoload) && is_file($autoload)) { + require_once $autoload; + if (class_exists($kernelClass)) { + return; + } + } + } + + // --- FAILURE: kernel not found anywhere ----------------------------- + // We cannot continue without the framework, and the kernel's own error + // handling is not installed this early, so report clearly and exit(1). + $msg = "[PSP] Could not load the global kernel autoload.\n" + . "Install globally: composer global require alfacode-team/php-service-platform\n" + . "Or set PSP_GLOBAL_AUTOLOAD=/absolute/path/to/vendor/autoload.php\n"; + + // STDERR only exists under the CLI SAPI; the web SAPI has no such + // constant, so branch on PHP_SAPI to emit the error correctly. + if (\PHP_SAPI === 'cli' || \PHP_SAPI === 'phpdbg') { + fwrite(\defined('STDERR') ? STDERR : fopen('php://stderr', 'w'), $msg); + } else { + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + } + echo $msg; + } + exit(1); + } +} + +if (!function_exists('psp_kernel_home')) { + /** + * Resolve the framework KERNEL HOME — the directory that owns the shared + * `plugins/` tree — used by the `require`s that the `hkm plugins` tooling + * wires into this bootstrap (e.g. a kernel plugin's Support/helpers.php). + * + * Resolution order: + * 1. HKM_KERNEL_HOME env var (set by `hkm run`, the launcher, or the operator) + * 2. the $fallback captured when the require was wired (dev-machine path) + * 3. derived from the loaded kernel — walk up from the Kernel class file to + * the first ancestor directory that owns a plugins/ tree + * + * If NONE resolve, the framework is not installed correctly: report clearly + * and hard-exit, rather than let a later require_once fatal cryptically with + * "failed to open stream". + */ + function psp_kernel_home(?string $fallback = null): string + { + static $home = null; + if ($home !== null) { + return $home; + } + + // 1. Explicit environment variable — the canonical, relocatable source. + $env = getenv('HKM_KERNEL_HOME'); + if (is_string($env) && $env !== '' && is_dir($env)) { + return $home = rtrim($env, "/\\"); + } + + // 2. Wire-time fallback (the kernel path known when the require was added). + if (is_string($fallback) && $fallback !== '' && is_dir($fallback)) { + return $home = rtrim($fallback, "/\\"); + } + + // 3. Derive from the already-loaded kernel package. + $kernelClass = \AlfacodeTeam\PhpServicePlatform\Kernel\Kernel::class; + if (class_exists($kernelClass)) { + $file = (new \ReflectionClass($kernelClass))->getFileName(); + if (is_string($file) && $file !== '') { + $dir = dirname($file); + for ($i = 0; $i < 8 && $dir !== dirname($dir); $i++, $dir = dirname($dir)) { + if (is_dir($dir . '/plugins')) { + return $home = $dir; + } + } + } + } + + // Nothing resolved — the framework is not installed correctly. + $msg = "[PSP] HKM_KERNEL_HOME is not set and the framework kernel could not be\n" + . "located — the framework is not installed correctly.\n" + . "Set it to the kernel root, e.g.:\n" + . " export HKM_KERNEL_HOME=/path/to/php-service-platform\n" + . (is_string($fallback) && $fallback !== '' ? "(tried wire-time path: {$fallback})\n" : ''); + + if (\PHP_SAPI === 'cli' || \PHP_SAPI === 'phpdbg') { + fwrite(\defined('STDERR') ? STDERR : fopen('php://stderr', 'w'), $msg); + } else { + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + } + echo $msg; + } + + exit(1); + } +} diff --git a/tools/src/templates/app/cli/run.php b/tools/src/templates/app/cli/run.php new file mode 100644 index 0000000..1555ccb --- /dev/null +++ b/tools/src/templates/app/cli/run.php @@ -0,0 +1,40 @@ + [args...] # run a command + * php app/cli/run.php migrate # (example) run migrations + * + * There is no Host header on the CLI, so domain resolution is skipped. The + * bootstrap loads the .env cascade from $_SERVER['argv'], so flags like + * `--env=production` / `--domain=...` still select the correct .env tier. + * + * Flow: + * 1. Load the autoloaders (kernel + plugins + your code). + * 2. Require the project bootstrap → fully-built Kernel. The bootstrap itself + * loads .env (argv-aware) and installs the pre-kernel error net, so this + * entry point does NOT duplicate that work. + * 3. Run the CLI pipeline and exit with the command's status code. + * ============================================================================= + */ + +// 1. Autoloaders. +require_once __DIR__ . '/../bootstrap/kernel-autoload.php'; +psp_require_kernel_autoload(); + +// 2. Build the application. app/bootstrap/app.php loads the .env cascade +// (reading $_SERVER['argv'] for --env / --domain) and installs ErrorGuard. +/** @var \AlfacodeTeam\PhpServicePlatform\Kernel\Kernel $kernel */ +$kernel = require __DIR__ . '/../bootstrap/app.php'; + +// 3. Dispatch the command and propagate its exit code to the shell (0 = success, +// non-zero = failure) so scripts and CI can react to it. +exit($kernel->cli()->run($argv)); diff --git a/tools/src/templates/app/public/index.php b/tools/src/templates/app/public/index.php new file mode 100644 index 0000000..39225c8 --- /dev/null +++ b/tools/src/templates/app/public/index.php @@ -0,0 +1,66 @@ +/app/public, rewrite all to index.php + * + * Flow: + * 1. Load the autoloaders (kernel + plugins + your code). + * 2. Require the project bootstrap, which returns a fully-built Kernel. + * (Bootstrap also set $domain — the resolved DomainContext — into scope.) + * 3. Capture the real HTTP request, attach the domain context, hand it to the + * kernel's HTTP pipeline, and send the Response back to the browser. + * 4. Any Throwable that escapes the pipeline becomes a safe JSON 500. + * + * Keep this file thin: it only adapts PHP's SAPI globals to the kernel and back. + * All wiring lives in app/bootstrap/app.php; all logic lives in controllers and + * plugins. + * ============================================================================= + */ + +// 1. Autoloaders. The bootstrap also requires these, but the front controller +// loads them first so the use-statements below resolve. +require_once __DIR__ . '/../bootstrap/kernel-autoload.php'; +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; +use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; + +// 2. Build the application. $domain (the resolved DomainContext, possibly null) +// is defined inside the bootstrap and is in scope after this require. +/** @var \AlfacodeTeam\PhpServicePlatform\Kernel\Kernel $kernel */ +$kernel = require __DIR__ . '/../bootstrap/app.php'; + +try { + // 3. Build an immutable Request from the SAPI globals ($_SERVER, $_GET, + // $_POST, php://input, ...) and ride the resolved domain on it as an + // attribute (never via a global — coroutine/Swoole safe). + $request = Request::capture(); + if (isset($domain) && $domain !== null) { + $request = $request->withAttribute('domain', $domain); + } + + // Run the HTTP pipeline (security → resolve → load → execute) and emit the + // Response (status line, headers, body) to the client. + $kernel->http()->handle($request)->send(); +} catch (\Throwable $e) { + // 4. Last-resort net for anything the kernel's own ErrorStage could not + // handle. In debug we surface the message; in production we never leak + // internals — just a generic 500. + $debug = filter_var($_ENV['APP_DEBUG'] ?? getenv('APP_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL); + Response::json([ + 'error' => [ + 'code' => 'kernel.unhandled', + 'message' => $debug ? $e->getMessage() : 'Internal Server Error', + ], + ], 500)->send(); +} diff --git a/tools/src/templates/app/swoole/index.php b/tools/src/templates/app/swoole/index.php new file mode 100644 index 0000000..dfc7b3f --- /dev/null +++ b/tools/src/templates/app/swoole/index.php @@ -0,0 +1,228 @@ +http()->handle($request) + * → Response → Swoole response → $kernel->requestTeardown() + * + * OpenSwoole memory-safety contract + * --------------------------------- + * - The kernel is built per worker in `workerStart` and stored on the worker. + * CoreContainer is frozen after build() (writes throw), so cross-request + * mutation is impossible. + * - A fresh ModuleContainer is created per request inside the pipeline's + * LoadStage and discarded when the request finishes. + * - PHP superglobals are NEVER read — every input comes from the Swoole + * request object. + * - `enable_coroutine` defaults to FALSE: requests run sequentially per worker + * and concurrency comes from multiple workers. Set SWOOLE_COROUTINE=true only + * after verifying the whole request path is coroutine-safe. + * + * Usage + * ----- + * php app/swoole/index.php + * # config comes from .env; real env vars still override (they win in env()): + * SWOOLE_PORT=9502 HKM_WORKERS=8 php app/swoole/index.php + * + * Configuration (read from .env via the env() helper; OS env overrides .env) + * -------------------------------------------------------------------------- + * SWOOLE_HOST 127.0.0.1 Listen host (keep internal; front with a gateway) + * SWOOLE_PORT 9502 Listen port + * HKM_WORKERS cpu_count Worker process count + * HKM_ENV production production | development + * SWOOLE_COROUTINE false Enable coroutine concurrency (advanced) + * SWOOLE_MAX_REQUEST 0 Recycle worker after N requests (0 = never) + * SWOOLE_DAEMONIZE false Run the server in the background + */ + +use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; +use AlfacodeTeam\PhpServicePlatform\Kernel\Http\UploadedFile; +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use Project\Bootstrap\EntryHelpers; +use Project\Bootstrap\Environment\LoadEnvironment; +use OpenSwoole\Http\Request as SwooleRequest; +use OpenSwoole\Http\Response as SwooleResponse; +use OpenSwoole\Http\Server as SwooleServer; + + +// ── 0. OpenSwoole presence guard ───────────────────────────────────────────── +if (!class_exists(SwooleServer::class)) { + fwrite(STDERR, "[{{PROJECT_NAME}}] OpenSwoole extension not loaded. Add `extension=openswoole` to php.ini.\n"); + exit(1); +} + +// ── 1. Server configuration (from .env) ────────────────────────────────────── +// Load the base .env in the MASTER process so the server's own settings can come +// from .env. Read via the env() helper (the canonical reader: $_ENV/$_SERVER +// first, then real OS env) — never getenv(), which does not see .env values. +// Workers reload .env in workerStart for their per-project cascade + kernel. +$rootPath = dirname(__DIR__, 2); +LoadEnvironment::load($rootPath); + +$host = (string) (env('SWOOLE_HOST') ?: '127.0.0.1'); +$port = (int) (env('SWOOLE_PORT') ?: 9502); +$workers = (int) (env('HKM_WORKERS') ?: (function_exists('swoole_cpu_num') ? swoole_cpu_num() : 4)); +$env = (string) (env('HKM_ENV') ?: 'production'); +$coroutine = filter_var(env('SWOOLE_COROUTINE') ?: 'false', FILTER_VALIDATE_BOOLEAN); + +$server = new SwooleServer($host, $port); +$server->set([ + 'worker_num' => $workers, + 'enable_coroutine' => $coroutine, + 'max_request' => (int) (env('SWOOLE_MAX_REQUEST') ?: 0), + 'reload_async' => true, + 'max_wait_time' => 60, + 'open_tcp_nodelay' => true, + 'socket_buffer_size' => 8 * 1024 * 1024, + 'buffer_output_size' => 32 * 1024 * 1024, + 'daemonize' => filter_var(env('SWOOLE_DAEMONIZE') ?: 'false', FILTER_VALIDATE_BOOLEAN), +]); + +// ── 2. WorkerStart — build the kernel ONCE per worker ──────────────────────── +// NOTE: We boot the kernel of the *env-selected* project here. Per-request domain +// resolution attaches a DomainContext attribute to the Request so modules can react +// to the actual host the client hit (admin vs api vs project face) without needing +// a separate kernel per host. If you need a hard per-host kernel split, run a +// dedicated worker pool per project (e.g. one server.php instance per HKM_PROJECT). +// +// Per-worker kernel, captured by reference across the worker/request closures. +// OpenSwoole forks each worker before these closures run, so every worker owns +// its own copy — no cross-worker sharing. Avoids a dynamic property on the +// Server object (deprecated in PHP 8.4, fatal in PHP 9). +/** @var Kernel|null $kernel */ +$kernel = null; + +$server->on('workerStart', static function (SwooleServer $server, int $workerId) use ($rootPath, &$kernel): void { + + $kernel = require $rootPath . '/app/bootstrap/app.php'; + error_log("[{{PROJECT_NAME}}] Worker #{$workerId} ready (project={{PROJECT_NAME}})"); +}); + +// ── 3. WorkerStop — release the worker's kernel ────────────────────────────── +$server->on('workerStop', static function (SwooleServer $server, int $workerId) use (&$kernel): void { + $kernel = null; + error_log("[{{PROJECT_NAME}}] Worker #{$workerId} stopped"); +}); + +// ── 4. Request handler — route through the kernel HttpPipeline ──────────────── +$server->on('request', static function (SwooleRequest $req, SwooleResponse $res) use ($env, $rootPath, &$kernel): void { + + try { + $request = buildRequest($req); + + // Per-request domain resolution. Uses a worker-level cache keyed by basePath, + // so this is a cheap array lookup after the first request in each worker. + $hostHeader = $req->header['host'] ?? null; + $domain = EntryHelpers::resolveDomain($rootPath, is_string($hostHeader) ? $hostHeader : null); + if ($domain !== null) { + $request = $request->withAttribute('domain', $domain); + } + + $response = $kernel->http()->handle($request); + + $res->status($response->status()); + foreach ($response->headers() as $name => $value) { + $res->header($name, $value); + } + // Cookies are tracked separately from headers (see Response::cookies()); + // emit each as a Set-Cookie line so they survive under OpenSwoole too. + foreach ($response->cookies() as $cookie) { + $res->header('Set-Cookie', $cookie); + } + + if ($response->isFile() && $response->filePath() !== null && is_file($response->filePath())) { + // Zero-copy file transfer; sendfile() finalises the response itself. + $res->sendfile($response->filePath()); + } elseif ($response->isStreamed()) { + // Pipe chunks straight to the socket without buffering the whole body. + $response->streamTo(static fn(string $chunk): bool => $res->write($chunk)); + $res->end(); + } else { + $res->end($response->body()); + } + } catch (\Throwable $e) { + // The pipeline's ErrorStage normally handles this; this is the last resort. + $res->status(500); + $res->header('Content-Type', 'application/json'); + $res->end(json_encode([ + 'error' => [ + 'code' => 'kernel.unhandled', + 'message' => $env === 'development' ? $e->getMessage() : 'Internal Server Error', + ], + ])); + } finally { + $kernel?->requestTeardown(); + } +}); + +$server->on('shutdown', static function (): void { + echo "[{{PROJECT_NAME}}] Server shutting down\n"; +}); + +/** + * Translate an OpenSwoole request into a kernel Request value object. + * No PHP superglobals are touched. + */ +function buildRequest(SwooleRequest $req): Request +{ + $method = strtoupper((string) ($req->server['request_method'] ?? 'GET')); + $path = (string) ($req->server['request_uri'] ?? '/'); + $headers = $req->header ?? []; + $query = $req->get ?? []; + $rawBody = (string) $req->rawContent(); + $body = $req->post ?? []; + + $contentType = $headers['content-type'] ?? ''; + if ($rawBody !== '' && str_contains($contentType, 'application/json')) { + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $body = $decoded; + } + } + + // Map Swoole's $_FILES-shaped uploads into kernel UploadedFile objects built + // in test mode (their temp files were not created by PHP's multipart handler, + // so is_uploaded_file() would reject a real-upload wrapper). + $files = []; + foreach ($req->files ?? [] as $field => $file) { + $files[$field] = is_array($file) && isset($file['tmp_name']) && !is_array($file['tmp_name']) + ? UploadedFile::fromSwoole($file) + : $file; + } + + return Request::build( + method: $method, + path: $path, + headers: $headers, + query: $query, + body: $body, + rawBody: $rawBody, + cookies: $req->cookie ?? [], + files: $files, + ); +} + +echo "[{{PROJECT_NAME}}] OpenSwoole HTTP server http://{$host}:{$port} env={$env} workers={$workers}" + . " coroutine=" . ($coroutine ? 'ON' : 'OFF') . "\n"; + +$server->start(); diff --git a/tools/src/templates/app/worker/run.php b/tools/src/templates/app/worker/run.php new file mode 100644 index 0000000..400ccb0 --- /dev/null +++ b/tools/src/templates/app/worker/run.php @@ -0,0 +1,112 @@ +pop() shape +// below is backend-specific; a Redis adapter would BLPOP instead.) +$queueAdapter = $kernel->container()->make(QueuePort::class); + +// 5. The $puller: the loop calls this to fetch the next job. Returning null means +// "nothing to do right now" and the loop idles/backs off. Here we pop one raw +// record off the file queue and rehydrate it into a typed JobPayload. +$puller = static function () use ($queue, $queueAdapter): ?JobPayload { + if (!$queueAdapter instanceof FileQueue) { + return null; // unknown backend — stay idle rather than guess its API + } + + $record = $queueAdapter->pop($queue); + if ($record === null) { + return null; // queue empty + } + + return new JobPayload( + jobId: (string) $record['jobId'], + jobClass: (string) $record['jobClass'], + data: (array) $record['data'], + queue: (string) $record['queue'], + attempts: (int) $record['attempts'], + maxAttempts: (int) $record['maxAttempts'], + enqueuedAt: new \DateTimeImmutable((string) $record['enqueuedAt']), + signature: '', + ); +}; + +// 6. The kernel's worker loop — materialises the Worker pipeline on first call. +$loop = $kernel->workerLoop(); + +// 7. Graceful shutdown. With pcntl available, trap SIGTERM/SIGINT and ask the +// loop to stop AFTER the in-flight job completes (no partial processing). +if (function_exists('pcntl_signal')) { + pcntl_async_signals(true); + $stop = static function () use ($loop): void { + echo "[{{PROJECT_NAME}}] Worker stopping...\n"; + $loop->stop(); + }; + pcntl_signal(SIGTERM, $stop); + pcntl_signal(SIGINT, $stop); +} + +echo "[{{PROJECT_NAME}}] Worker loop started queue={$queue}" + . ($maxIterations > 0 ? " maxIterations={$maxIterations}" : ' (forever)') . "\n"; + +// 8. Run until stopped (signal) or until maxIterations jobs have been processed. +$loop->run($puller, $maxIterations); + +echo "[{{PROJECT_NAME}}] Worker finished. Remaining in '{$queue}': " . $queueAdapter->size($queue) . "\n"; diff --git a/tools/src/templates/composer.json b/tools/src/templates/composer.json new file mode 100644 index 0000000..02370ed --- /dev/null +++ b/tools/src/templates/composer.json @@ -0,0 +1,21 @@ +{ + "name": "psp/{{PROJECT_NAME}}", + "description": "A PhpServicePlatform project.", + "type": "project", + "require": { + "php": ">=8.2" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "{{STUDLY}}\\": "src/", + "Plugins\\": "plugins/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/tools/src/templates/config/environments/local.php b/tools/src/templates/config/environments/local.php new file mode 100644 index 0000000..650df2a --- /dev/null +++ b/tools/src/templates/config/environments/local.php @@ -0,0 +1,35 @@ + 'default', + 'connections' => [ + 'default' => [ + 'driver' => env('DB_DRIVER', 'sqlite'), + 'database' => env('DB_NAME', $root . '/database/app.sqlite'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 0), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + ], + ], + 'paths' => [ + $root . '/database/migrations', + ], + 'seeders_path' => $root . '/database/seeders', + 'factories_path' => $root . '/database/factories', + 'tracking_table' => 'let_migrations', + 'pretend' => false, + 'transactional' => false, +]; \ No newline at end of file diff --git a/tools/src/templates/config/environments/production.php b/tools/src/templates/config/environments/production.php new file mode 100644 index 0000000..40fa429 --- /dev/null +++ b/tools/src/templates/config/environments/production.php @@ -0,0 +1,36 @@ + 'default', + 'connections' => [ + 'default' => [ + 'driver' => env('DB_DRIVER', 'mysql'), + 'database' => env('DB_NAME', $root . '/database/app.sqlite'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 0), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + ], + ], + 'paths' => [ + $root . '/database/migrations', + ], + 'seeders_path' => $root . '/database/seeders', + 'factories_path' => $root . '/database/factories', + 'tracking_table' => 'let_migrations', + 'pretend' => false, + 'transactional' => true, +]; \ No newline at end of file diff --git a/tools/src/templates/config/environments/staging.php b/tools/src/templates/config/environments/staging.php new file mode 100644 index 0000000..e4c6146 --- /dev/null +++ b/tools/src/templates/config/environments/staging.php @@ -0,0 +1,35 @@ + 'default', + 'connections' => [ + 'default' => [ + 'driver' => env('DB_DRIVER', 'mysql'), + 'database' => env('DB_NAME', $root . '/database/app.sqlite'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 0), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + ], + ], + 'paths' => [ + $root . '/database/migrations', + ], + 'seeders_path' => $root . '/database/seeders', + 'factories_path' => $root . '/database/factories', + 'tracking_table' => 'let_migrations', + 'pretend' => false, + 'transactional' => false, +]; \ No newline at end of file diff --git a/tools/src/templates/config/environments/testing.php b/tools/src/templates/config/environments/testing.php new file mode 100644 index 0000000..edc9600 --- /dev/null +++ b/tools/src/templates/config/environments/testing.php @@ -0,0 +1,35 @@ + 'default', + 'connections' => [ + 'default' => [ + 'driver' => env('DB_DRIVER', 'sqlite'), + 'database' => env('DB_NAME', ':memory:'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 0), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + ], + ], + 'paths' => [ + $root . '/database/migrations', + ], + 'seeders_path' => $root . '/database/seeders', + 'factories_path' => $root . '/database/factories', + 'tracking_table' => 'let_migrations', + 'pretend' => false, + 'transactional' => false, +]; \ No newline at end of file diff --git a/tools/src/templates/config/let-migrate.php b/tools/src/templates/config/let-migrate.php new file mode 100644 index 0000000..fa764f6 --- /dev/null +++ b/tools/src/templates/config/let-migrate.php @@ -0,0 +1,57 @@ +.php overrides it per environment (e.g. + * a real MySQL/Postgres connection in production). + * + * Keys: + * default which entry in `connections` to use. + * connections named DB connections. driver: sqlite | mysql | pgsql | sqlsrv. + * The fluent Blueprint API compiles one migration to the right + * dialect per driver — write once, run on any database. + * paths directories scanned for migration classes. + * seeders_path directory holding seeder classes (db:seed). + * factories_path directory holding model/data factories. + * tracking_table table that records which migrations have run (batched). + * pretend true = print the SQL instead of executing it (CI previews + * only — never in production). + * transactional wrap each migration run in a transaction (auto-rollback on + * failure). Off for SQLite-in-dev where DDL is non-transactional. + * + * `$root` is the project root (this file lives at /config/let-migrate.php), + * so the SQLite database and the migration/seeder/factory folders resolve under + * the project by default. + * ============================================================================= + */ + +$root = dirname(__DIR__, 1); + +return [ + 'default' => 'default', + 'connections' => [ + 'default' => [ + 'driver' => env('DB_DRIVER', 'sqlite'), + 'database' => env('DB_NAME', $root . '/database/app.sqlite'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', 0), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + ], + ], + 'paths' => [ + $root . '/database/migrations', + ], + 'seeders_path' => $root . '/database/seeders', + 'factories_path' => $root . '/database/factories', + 'tracking_table' => 'let_migrations', + 'pretend' => false, + 'transactional' => true, +]; \ No newline at end of file diff --git a/tools/src/templates/config/storage.php b/tools/src/templates/config/storage.php new file mode 100644 index 0000000..eb4e71e --- /dev/null +++ b/tools/src/templates/config/storage.php @@ -0,0 +1,77 @@ +/config/storage.php (project override — copy this file there) + * 2. plugins/Storage/config/storage.php (this file — framework default) + * + * Read it anywhere with the storage_config() helper: + * storage_config('driver'); // 'local' | 's3' + * storage_config('local.root'); // dotted access into a section + * storage_config('s3.bucket'); + */ +return [ + + /* + |-------------------------------------------------------------------------- + | Default Driver + |-------------------------------------------------------------------------- + | Which StoragePort adapter the plugin binds: 'local' (disk) or 's3' + | (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO). A project that + | wires StoragePort itself in withPorts() overrides this entirely. + */ + 'driver' => strtolower((string) (env('STORAGE_DRIVER') ?: 'local')), + + /* + |-------------------------------------------------------------------------- + | Local Disk Driver + |-------------------------------------------------------------------------- + | root directory blobs are written under (required to enable). A RELATIVE + | STORAGE_ROOT (e.g. "userdata/storage") resolves against the active + | project root via Paths::project(); an absolute path is used as-is + | url_base public base URL prefix for temporaryUrl() (CDN/host) + | url_secret HMAC secret used to sign + verify expiring temporary URLs + */ + 'local' => [ + 'root' => (static function (): string { + $root = (string) (env('STORAGE_ROOT') ?: ''); + if ($root === '') { + return ''; + } + // Absolute paths (Unix "/…" or Windows "C:\…") are used verbatim; + // a relative path is resolved under the active project root so + // STORAGE_ROOT=userdata/storage Just Works per project. + $isAbsolute = $root[0] === '/' || (bool) preg_match('#^[A-Za-z]:[\\\\/]#', $root); + return $isAbsolute ? $root : Paths::project($root); + })(), + 'url_base' => (string) (env('STORAGE_URL_BASE') ?: ''), + 'url_secret' => (string) (env('STORAGE_URL_SECRET') ?: ''), + ], + + /* + |-------------------------------------------------------------------------- + | S3 / S3-Compatible Driver + |-------------------------------------------------------------------------- + | bucket / region the target bucket and its region (required to enable) + | key / secret static credentials — LEAVE EMPTY on EC2/ECS/EKS so the + | AWS default provider chain (IAM roles) is used instead + | endpoint custom endpoint for non-AWS providers (Spaces/R2/MinIO) + | use_path_style true for MinIO / path-style endpoints + */ + 's3' => [ + 'bucket' => (string) (env('STORAGE_S3_BUCKET') ?: ''), + 'region' => (string) (env('STORAGE_S3_REGION') ?: 'us-east-1'), + 'key' => (string) (env('STORAGE_S3_KEY') ?: ''), + 'secret' => (string) (env('STORAGE_S3_SECRET') ?: ''), + 'endpoint' => env('STORAGE_S3_ENDPOINT') ?: null, + 'use_path_style' => filter_var(env('STORAGE_S3_PATH_STYLE') ?: 'false', FILTER_VALIDATE_BOOL), + ], + +]; diff --git a/tools/src/templates/env.example b/tools/src/templates/env.example new file mode 100644 index 0000000..f094d1a --- /dev/null +++ b/tools/src/templates/env.example @@ -0,0 +1,207 @@ +# Application Environment +APP_ENV=local +APP_DEBUG=true +APP_NAME="{{PROJECT_NAME}}" + +# View rendering (plugins/View — on-demand; a module opts in with +# requires: ["view.rendering"]). When VIEW_PATHS is unset, views resolve under +# /resources/views by default. +# colon/comma-separated absolute view directories +VIEW_PATHS=resources +# comma-separated recognised extensions +VIEW_EXTENSIONS=php +# persist set data across render() calls +VIEW_SAVE_DATA=false + +# Database Configuration +DB_CONNECTION=default +DB_DRIVER=sqlite +DB_HOST=127.0.0.1 +DB_PORT=3306 +# For local dev this scaffold uses a file-based SQLite at +# /database/app.sqlite (see config/let-migrate.php). Set an ABSOLUTE +# path or ':memory:' to override. A relative path here resolves against the +# process CWD — avoid it. For MySQL/Postgres set DB_DRIVER + the fields below. +# DB_NAME= +DB_USERNAME=app +DB_PASSWORD= + +# Secondary Database (optional) +DB_SECONDARY_DRIVER=mysql +DB_SECONDARY_HOST=secondary-db.example.com +DB_SECONDARY_PORT=3306 +DB_SECONDARY_NAME=secondary_db +DB_SECONDARY_USERNAME=app_secondary +DB_SECONDARY_PASSWORD= + +# Analytics Database (optional) +DB_ANALYTICS_DRIVER=mysql +DB_ANALYTICS_HOST=analytics-db.example.com +DB_ANALYTICS_PORT=3306 +DB_ANALYTICS_NAME=analytics_db +DB_ANALYTICS_USERNAME=app_analytics +DB_ANALYTICS_PASSWORD= + +# Let-Migrate Configuration +LET_MIGRATE_PRETEND=false +LET_MIGRATE_TRANSACTIONAL=true + +# Secrets Provider +# Options: env, aws, vault +# For production, use AWS Secrets Manager or Vault instead of env vars +SECRETS_PROVIDER=env + +# AWS Secrets Manager (if using aws provider) +# AWS_REGION=us-east-1 +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= + +# HashiCorp Vault (if using vault provider) +# VAULT_ADDR=http://127.0.0.1:8200 +# VAULT_TOKEN= + +# Logging +LOG_LEVEL=info +LOG_CHANNEL=stack + +# Cache +CACHE_DRIVER=array + +# App encryption key (REQUIRED for encrypted cookies/sessions; 32+ random bytes, +# base64 or raw). Generate your OWN — never reuse another project's key: +# php -r "echo base64_encode(random_bytes(32)).PHP_EOL;" +APP_KEY= +# APP_KEY_PREVIOUS= # set during key rotation so old ciphertext still decrypts + +# Sessions (plugins/Session — essential module, active app-wide) +# file | array | cookie +SESSION_DRIVER=cookie +SESSION_COOKIE={{PROJECT_NAME}}_session +# SESSION_PATH defaults to /var/sessions (file driver only) +# SESSION_PATH= +SESSION_LIFETIME=7200 +SESSION_SAMESITE=Lax +# json (safe) | php +SESSION_SERIALIZATION=json +# Secure flag: auto (follow request scheme) | true (force) | false +SESSION_SECURE=auto +# Cookie scope (defaults: path=/ , no domain) +# SESSION_COOKIE_PATH=/ +# SESSION_COOKIE_DOMAIN= + +# --- Cookie session driver (only used when SESSION_DRIVER=cookie) ------------- +# State is stored IN the cookie: encrypted when APP_KEY/Crypto is present, +# else HMAC-signed (readable but tamper-evident). Keep it small (~4 KB max). +# +# HMAC signing key — falls back to APP_KEY when unset. +# SESSION_SIGNING_KEY= +# Idle timeout in seconds (0 = disabled); slides on each request. +SESSION_IDLE_TIMEOUT=0 +# Deflate data above this many bytes (0 = never compress). +SESSION_COOKIE_COMPRESS=1024 +# Drop the cookie if the protected payload exceeds this many bytes. +SESSION_COOKIE_MAX_BYTES=3800 +# Fingerprint binding: off | ua | ip | ua,ip (all/strict) +# ua = survives IP changes (safe for mobile) ip = strict network binding +SESSION_COOKIE_FINGERPRINT=ua,ip +# Refuse to boot unless authenticated (encrypter OR signing key). +SESSION_COOKIE_REQUIRE_AUTH=true +# Refuse to boot unless ENCRYPTED (blocks signed-but-readable cookies). +SESSION_COOKIE_REQUIRE_ENCRYPTION=false + +# Cookies (plugins/Cookie — essential module) +# Default cookie life in MINUTES (0 = session cookie) +COOKIE_LIFETIME=120 +# URL path the cookie is valid for +COOKIE_PATH=/ +# Cookie domain — blank = bind to issuing host (correct for localhost/IP); or .myshop.com +COOKIE_DOMAIN= +# HTTPS-only; set false for local http:// dev +COOKIE_SECURE=false +# Hide from JS (document.cookie) — XSS defence +COOKIE_HTTP_ONLY=true +# SameSite: Lax | Strict | None (None requires secure=true) +COOKIE_SAME_SITE=Lax +# Comma-separated cookie names whose values are NOT encrypted: +COOKIE_ENCRYPT_EXEMPT= + +# Redis cache + queue (plugins/RedisCache — only active when REDIS_HOST is set; +# otherwise the in-memory CachePort is used). Requires ext-redis. +# REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +# REDIS_PASSWORD= +REDIS_DB=0 +REDIS_PREFIX={{PROJECT_NAME}}: +# let Redis replace the in-memory CachePort +REDIS_OVERRIDE=true +# pconnect reuse across requests (FPM only; keep false on Swoole) +REDIS_PERSISTENT=false + +# Outbound HTTP client (plugins/HttpClient — on-demand; native cURL) +HTTP_CLIENT_TIMEOUT=30 +HTTP_CLIENT_CONNECT_TIMEOUT=10 +HTTP_CLIENT_RETRY=0 + +# File storage (plugins/Storage — on-demand). STORAGE_DRIVER: local | s3 +STORAGE_DRIVER=local +# --- local driver (set STORAGE_ROOT to enable) --- +# Relative path → resolved under the project root (/userdata/storage); +# use an absolute path to override. +STORAGE_ROOT=userdata/storage +STORAGE_URL_BASE=http://localhost:8000/files +# Generate your OWN: php -r "echo bin2hex(random_bytes(32)).PHP_EOL;" +STORAGE_URL_SECRET= +# --- s3 driver (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO) --- +# STORAGE_S3_BUCKET= +# STORAGE_S3_REGION=us-east-1 +# STORAGE_S3_KEY= +# STORAGE_S3_SECRET= +# STORAGE_S3_ENDPOINT= # non-AWS S3-compatible endpoint (Spaces/R2/MinIO) +# STORAGE_S3_PATH_STYLE=false # true for MinIO / path-style endpoints + +# CORS + security headers (plugins/SecurityFilters) +# use an explicit allowlist in production +CORS_ALLOWED_ORIGINS=* +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With +CORS_EXPOSED_HEADERS= +CORS_MAX_AGE=0 +CORS_ALLOW_CREDENTIALS=false +HSTS_MAX_AGE=31536000 +# CONTENT_SECURITY_POLICY= + +# Edge routing (plugins/Edge) — nginx/Apache front config + /etc/hosts sync. +# ALL optional: Edge auto-detects the host stack and reads the global project +# registry (HKM_KERNEL_HOME/projects). Uncomment to override. +# EDGE_LISTEN_PORT=443 +# EDGE_NGINX_BACKEND=127.0.0.1:444 # nginx TLS backend (stream splitter) +# EDGE_APACHE_BACKEND=127.0.0.1:8443 # Apache fallback backend (stream) +# EDGE_APP_BACKEND=127.0.0.1:8080 # app upstream (nginx-only / Apache) +# EDGE_SSL_CERT=/etc/ssl/certs/hkm-edge.pem +# EDGE_SSL_KEY=/etc/ssl/private/hkm-edge.key +# EDGE_STREAM_PATH=/etc/nginx/streams-enabled/hkm-edge.conf # prod: real include dir +# EDGE_NGINX_PATH=/etc/nginx/conf.d/hkm-edge.conf +# EDGE_APACHE_PATH=/etc/apache2/sites-enabled/hkm-edge.conf +# EDGE_RELOAD=false # reload web server after edge:apply +# EDGE_LOCAL_TLDS=local,test,localhost,example,invalid # → /etc/hosts, not the server +# EDGE_MANAGE_HOSTS=true # write local domains to /etc/hosts +# EDGE_HOSTS_PATH=/etc/hosts +# EDGE_HOSTS_IP=127.0.0.1 +# EDGE_LOCAL_IN_SERVER=false # also serve .local domains via nginx locally +# EDGE_EXTRA_DOMAINS= # comma-separated extra hostnames +# EDGE_EXCLUDE_DOMAINS= +# Per-project serving (override per project in proj.json "edge": {...}): +# EDGE_SERVE_MODEL=fpm # fpm | swoole +# EDGE_FPM_SOCKET=unix:/run/php/php-fpm.sock +# EDGE_SWOOLE_HOST=127.0.0.1 +# EDGE_SWOOLE_BASE_PORT=9500 +# EDGE_INJECT_KERNEL_ENV=true # inject PSP_GLOBAL_AUTOLOAD + HKM_KERNEL_HOME into vhosts +# EDGE_APP_ENV=production # APP_ENV written into each vhost + +# Observability +# Honeycomb, DataDog, etc. +# HONEYCOMB_API_KEY= +# DATADOG_API_KEY= + +# NOTE: NEVER commit actual values for these to version control! +# Use environment variables or a secrets manager instead. diff --git a/tools/src/templates/frontend/.gitignore b/tools/src/templates/frontend/.gitignore new file mode 100644 index 0000000..a0452d2 --- /dev/null +++ b/tools/src/templates/frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +../public_html/build/ +*-hot + +# Generated by `hkm ui sync` — regenerate, do not commit as source of truth. +plugins/ +tsconfig.plugins.json diff --git a/tools/src/templates/frontend/README.md b/tools/src/templates/frontend/README.md new file mode 100644 index 0000000..109fc88 --- /dev/null +++ b/tools/src/templates/frontend/README.md @@ -0,0 +1,120 @@ +# Project frontend + +A **per-project** frontend. Each project owns this folder; there is no global +build coordinator. Shared design-system code lives in `src/shared/`, and plugin +UIs (Pageflow, etc.) are **federated** in by `hkm ui` — never vendored. + +## Why this replaced the old monolith + +The previous frontend was one giant coordinator that built *every* app (admin + +all projects + all modules + all services) from a single `package.json` and a +hardcoded `MODES` registry in `vite.config.ts`. That meant: + +| Old pain | New model | +|---|---| +| One 150-dep `package.json` for every app | Per-project `package.json` — only what this project needs | +| Hardcoded `MODES` map + a `dev:*`/`build:*` script per app | **Surfaces**: drop a folder under `src/surfaces/*`, vite auto-discovers it | +| Pageflow copied (46 stale files) into `src/pageflow/` | `@pageflow/*` **federated** from `plugins/Pageflow/ui/` via `hkm ui` | +| Aliases duplicated in tsconfig + vite, hand-synced | Plugin aliases read once from generated `tsconfig.plugins.json` | +| Business pages mixed into the shared `components/` kit | Business code lives in its **surface**; `src/shared/` is reusable-only | + +## Layout + +``` +frontend/ +├─ vite.config.ts # ONE config — no app registry +├─ vite/ +│ ├─ surfaces.ts # discovers src/surfaces/*/surface.json +│ ├─ aliases.ts # shared + plugin (tsconfig.plugins.json) + React dedupe +│ ├─ plugins.ts # hkmPlugin (hot-file + dev origin + full-reload) + gzip +│ └─ build-all.mjs # build every discovered surface +├─ src/ +│ ├─ shared/ # reusable ONLY — the design system + cross-app code +│ │ ├─ ui/ # shadcn primitives → import { Button } from "@ui/button" +│ │ ├─ lib/ # utils → "@lib/utils" +│ │ ├─ hooks/ providers/ # "@hooks/*" "@providers/*" +│ └─ surfaces/ # buildable apps (one = one `vite build --mode `) +│ └─ admin/ +│ ├─ surface.json # { name, entry, style, base } +│ ├─ index.tsx # Pageflow bootstrap + Pages glob +│ ├─ Pages/ # Pageflow pages (server sends { component, props }) +│ └─ styles/index.css +├─ plugins/ # GENERATED by `hkm ui` — federated plugin UIs (git-ignored) +└─ tsconfig.plugins.json # GENERATED by `hkm ui` — plugin-UI path aliases +``` + +> **Full guide:** [`docs/HOW_IT_WORKS.md`](docs/HOW_IT_WORKS.md) — command usage +> and how to add a page on the admin and project surfaces. + +## Example surfaces + +The scaffold ships two surfaces so you can see the model: + +| Surface | Entry | Example pages | +|---|---|---| +| `admin` | `src/surfaces/admin/index.tsx` | `Dashboard`, `Login`, `Users/Index` (search + partial reload + `useForm` delete + pagination) | +| `project` | `src/surfaces/project/index.tsx` | `Home` (public landing: `` SEO + newsletter `useForm`), `About` (in-app `` nav). Hydrates SSR markup when present. | + +They share the `@ui` design system and the federated `@pageflow` client, but +build to `/build/admin/` and `/build/project/` independently. + +## Surfaces (adding an app) + +A **surface** is one buildable app. To add one (e.g. a storefront): + +```bash +cp -r src/surfaces/admin src/surfaces/storefront +# edit src/surfaces/storefront/surface.json → "name": "storefront" +``` + +That's it — no `vite.config.ts` edit, no new npm script. Build/run it by name: + +```bash +npm run dev -- --mode storefront # dev the storefront surface +npm run build -- --mode storefront # → ../public_html/build/storefront/ + manifest-storefront.json +npm run build:all # every surface +npm run surfaces # list discovered surfaces +``` + +The PHP asset contract is unchanged: built files land in +`../public_html/build//[name].[hash].js` with +`manifest-.json`, and dev writes `../public_html/-hot`. + +## Plugin UIs (federation) + +Enabled plugins that ship a `ui/` (e.g. **Pageflow**) are pulled in with: + +```bash +hkm ui sync # mirrors plugins//ui → frontend/plugins/ + # + regenerates tsconfig.plugins.json path aliases +hkm ui link pageflow # symlink for live plugin co-development +``` + +Then import through the stable alias — `import { usePage } from "@pageflow/react"`. +Never copy a plugin's UI by hand; re-run `hkm ui sync` after enabling/disabling. + +## Dev integration (`hkmPlugin`) + +`vite/plugins.ts` exports `hkmPlugin` — the old `hkmPlugin` upgraded with the +richer dev wiring that used to live in `src/index.ts`: + +- **Hot file** — dev writes `../public_html/-hot` = `` + (APP_URL host + HTTPS aware). A PHP `vite()`/Pageflow helper reads it to point + ` + + +
+ + diff --git a/tools/src/templates/frontend/package.json b/tools/src/templates/frontend/package.json new file mode 100644 index 0000000..09e5c66 --- /dev/null +++ b/tools/src/templates/frontend/package.json @@ -0,0 +1,82 @@ +{ + "name": "@project/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Per-project frontend. Each buildable app is a self-describing surface under src/surfaces/*; plugin UIs are federated in via `hkm ui` (frontend/plugins/ + tsconfig.plugins.json). Shared shadcn/ui design system lives in src/shared/ui.", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "build:types": "tsc --noEmit && vite build", + "build:all": "node vite/build-all.mjs", + "preview": "vite preview", + "surfaces": "node vite/build-all.mjs --list", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "axios": "^1.7.0", + "es-toolkit": "^1.30.0", + "qs": "^6.13.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.0.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.500.0", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.7.3", + "recharts": "^3.0.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.0.0", + "vaul": "^1.1.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.0.0", + "@types/qs": "^6.9.18", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.0", + "picocolors": "^1.1.1", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.2.0", + "typescript": "^5.7.0", + "vite": "^8.0.0" + } +} diff --git a/tools/src/templates/frontend/src/shared/hooks/use-toast.ts b/tools/src/templates/frontend/src/shared/hooks/use-toast.ts new file mode 100644 index 0000000..ec8c4dd --- /dev/null +++ b/tools/src/templates/frontend/src/shared/hooks/use-toast.ts @@ -0,0 +1,153 @@ +// Adapted from shadcn/ui use-toast +// https://ui.shadcn.com/docs/components/toast +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1_000_000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { type: ActionType["ADD_TOAST"]; toast: ToasterToast } + | { type: ActionType["UPDATE_TOAST"]; toast: Partial } + | { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] } + | { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) return; + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ type: "REMOVE_TOAST", toastId }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => addToRemoveQueue(toast.id)); + } + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { ...t, open: false } + : t + ), + }; + } + + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { ...state, toasts: [] }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => listener(memoryState)); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } }); + + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { id, dismiss, update }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) listeners.splice(index, 1); + }; + }, []); + + return { + ...state, + toast, + dismiss: (toastId?: string) => + dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/tools/src/templates/frontend/src/shared/lib/utils.ts b/tools/src/templates/frontend/src/shared/lib/utils.ts new file mode 100644 index 0000000..fca5f91 --- /dev/null +++ b/tools/src/templates/frontend/src/shared/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** Merge Tailwind class names, de-duplicating conflicting utilities. */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/tools/src/templates/frontend/src/shared/providers/theme.tsx b/tools/src/templates/frontend/src/shared/providers/theme.tsx new file mode 100644 index 0000000..e30a09b --- /dev/null +++ b/tools/src/templates/frontend/src/shared/providers/theme.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +type Theme = "light" | "dark"; +const ThemeContext = React.createContext<{ theme: Theme; toggle: () => void }>({ + theme: "light", + toggle: () => {}, +}); + +/** Minimal light/dark provider — swap for your real one as the app grows. */ +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = React.useState( + () => (localStorage.getItem("theme") as Theme) || "light", + ); + React.useEffect(() => { + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem("theme", theme); + }, [theme]); + const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light")); + return {children}; +} + +export const useTheme = () => React.useContext(ThemeContext); diff --git a/tools/src/templates/frontend/src/shared/styles/theme.css b/tools/src/templates/frontend/src/shared/styles/theme.css new file mode 100644 index 0000000..5fcf149 --- /dev/null +++ b/tools/src/templates/frontend/src/shared/styles/theme.css @@ -0,0 +1,113 @@ +/* + * Shared shadcn/ui theme for Tailwind v4. + * + * Import this ONCE per surface (see src/surfaces//styles/index.css). It + * provides the shadcn design tokens (light + dark) AND the `@theme inline` + * mapping that turns them into Tailwind utilities — so every ported component in + * @ui/* can use `bg-background`, `text-muted-foreground`, `border-border`, + * `ring-ring`, `rounded-lg`, etc. `tw-animate-css` supplies the `animate-in` / + * `data-[state=open]` transitions shadcn components rely on. + */ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.5rem; + + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; +} + +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; +} + +/* Map the HSL tokens onto Tailwind's color/radius scales (v4 `@theme inline`). */ +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} diff --git a/tools/src/templates/frontend/src/shared/ui/accordion.tsx b/tools/src/templates/frontend/src/shared/ui/accordion.tsx new file mode 100644 index 0000000..617707f --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; + +import { cn } from '@lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx b/tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx new file mode 100644 index 0000000..466481f --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@lib/utils'; +import { buttonVariants } from '@ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/tools/src/templates/frontend/src/shared/ui/alert.tsx b/tools/src/templates/frontend/src/shared/ui/alert.tsx new file mode 100644 index 0000000..f8c27a7 --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx b/tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx new file mode 100644 index 0000000..5dfdf1e --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/tools/src/templates/frontend/src/shared/ui/avatar.tsx b/tools/src/templates/frontend/src/shared/ui/avatar.tsx new file mode 100644 index 0000000..077780e --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/tools/src/templates/frontend/src/shared/ui/badge.tsx b/tools/src/templates/frontend/src/shared/ui/badge.tsx new file mode 100644 index 0000000..c7ea5e3 --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx b/tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx new file mode 100644 index 0000000..23214f5 --- /dev/null +++ b/tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { Slot } from '@radix-ui/react-slot'; + +import { cn } from '@lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>