diff --git a/.gitignore b/.gitignore index 33c5751..ce849ad 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,10 @@ tools/zig-out/ /.claude/ /CLAUDE.md /.github/copilot-instructions.md -/docs/ +# Ignore each entry under docs/ (not the dir itself, so the negation below works): +/docs/* +# ...but publish the curated, reader-facing guides (docs/ai-context stays ignored): +!/docs/guides/ # ── COMPOSER ────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cbd29..bcc9f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.21] - 2026-07-22 + +### Changed +- **Rebranded to HKM Kernel.** The CLI banner (`hkm version`) now renders the HKM + block-letter art and reads "HKM Kernel · Gated Demand Architecture"; the debug/error + page and CLI exception header are branded **HKM** (was "Sentinel"); the global-kernel + autoload error prefix is now `[HKM]`. +- **README rewritten as a guided document** — leads with Purpose, project goals, and an + honest "done vs. cooking" status map, followed by install and usage. Adds the HKM hero + banner and points at the new public guides. + +### Added +- **Public architecture guides under `docs/guides/`** — a curated, reader-facing set of + layer-by-layer guides (kernel, modules, plugins, security, data access, and more), with + an index. The internal AI-context source stays private. + +### Fixed +- **Security-layer docs corrected to match the code.** The guides no longer describe a + kernel `FirewallLayer` / `RateLimiterLayer` (which do not exist) — the kernel ships only + `CsrfTokenLayer`; authentication comes from the Auth plugin (`JwtAuthLayer` / + `PersonalAccessTokenLayer`), and rate-limiting / IP-filtering are SecurityFilters route + filters (`throttle` / `shield`). + +### Merged +- Integrates edge features, CLI commands, and security updates from #36. + ## [1.0.20] - 2026-07-22 ### Added diff --git a/README.md b/README.md index 3883d6d..43ebd8c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ PHP License Runtime + Status

It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so you install and @@ -22,18 +23,115 @@ upgrade it like a Go/Rust binary — no Composer needed to get started. ## Table of contents -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) +1. [Purpose](#purpose) +2. [What we're building toward](#what-were-building-toward) +3. [Project status — done vs. cooking](#project-status--done-vs-cooking) +4. [Why GDA?](#why-gda) +5. [Install](#install) +6. [The `hkm` CLI](#the-hkm-cli) +7. [Your first project](#your-first-project) +8. [Core concepts](#core-concepts) +9. [The request lifecycle](#the-request-lifecycle) +10. [Building a feature — end to end](#building-a-feature--end-to-end) +11. [The five access rules](#the-five-access-rules) +12. [Batteries included (plugins)](#batteries-included-plugins) +13. [Development from source](#development-from-source) +14. [Security defaults](#security-defaults) +15. [License](#license) + +--- + +## Purpose + +**HKM Kernel exists to make secure, cost-predictable PHP services the default — not the +reward for discipline.** + +Most PHP frameworks boot the whole application, wire every service, and *then* decide what +the request needs. That is convenient, but it means an unauthenticated request that should +cost nothing still pays to construct half your app, and domain boundaries live only in your +head (and your code reviews). + +HKM inverts that with the **Gated Demand Architecture**: + +- **Security is the gate, not a middleware afterthought.** A `SecurityGateway` runs *before* + any module is wired. A denied request costs *zero* module construction. +- **You pay only for what a route uses.** Modules are resolved from a per-request dependency + graph and wired on demand. Nothing you didn't ask for is loaded. +- **Boundaries are enforced by the runtime, not by convention.** Cross-layer and + cross-module access rules throw real exceptions, not lint warnings. + +The goal is a framework where the *fast, secure, well-bounded* way to build something is also +the *easy* way — and where you can drop in a first-party plugin (auth, tenancy, mail, OAuth2) +without inheriting a monolith. + +> **This is not Laravel, Symfony, or Slim.** It borrows none of their conventions — no +> globals, no facades, no runtime auto-discovery. Everything is explicit and injected. + +--- + +## What we're building toward + +The north-star goals that guide every decision in this repo: + +| Goal | What it means | +|---|---| +| **Zero-cost denial** | A blocked request never constructs a module. Security is measured in the gateway, not the controller. | +| **Per-request minimalism** | The kernel wires exactly the modules a route needs — and their transitive dependencies — and nothing else. | +| **Runtime-enforced isolation** | One module, one domain. Modules cannot reach into each other's internals; the container throws if they try. | +| **A dependency-free kernel** | The core should carry no vendor coupling. Request/Response/uploads become pure value objects (see *cooking*, below). | +| **Infrastructure independence** | The kernel defines *ports*; the project supplies adapters (MySQL, Redis, S3, SMTP…). Swap them without touching domain code. | +| **Install like a binary** | `hkm` is a native cross-platform launcher — no Composer required to get started, upgradeable in place. | +| **Batteries, not a monolith** | Auth, Users, Tenancy, OAuth2, Mail, and more ship as opt-in first-party plugins, each owning exactly one domain. | + +--- + +## Project status — done vs. cooking + +> **Where things stand today.** HKM is under **active development**. The architecture and the +> core plugins are in daily use, and releases are cut regularly — but some subsystems are +> still stabilizing. This section is the honest map. + +### ✅ Done & stable + +- **The GDA kernel** — boot pipeline (10 fail-fast stages), security gateway, on-demand + module loader, request-scoped DI with runtime scope enforcement, HTTP / CLI / Worker + pipelines, domain + integration event system, and the port interfaces. +- **The five access rules**, enforced at runtime via `ModuleContainer::bindInternal()`. +- **Native distribution** — `hkm` launcher + `hkm-config` built with Zig; `.deb`, macOS + `.app`, and Windows `.zip` bundles published automatically from `CHANGELOG.md`. +- **First-party plugins** (see [the full table](#batteries-included-plugins)) — Auth, + User, Tenancy, OAuth2, Validation, Mail, Storage, Session/Cookie, HttpClient, + SecurityFilters, I18n, View/ViteManifest/Pageflow. +- **Multi-project, multi-tenant hosting** — host-based `DomainResolver`, strict per-tenant + DB routing, and a project-over-plugin resource resolution model. +- **Database & migrations** — the multi-driver `DatabasePort` (MySQL / PostgreSQL / SQLite / + SQL Server) and the standalone **LetMigrate** engine (fluent schema, seeders, CLI). +- **Frontend federation** — per-project surfaces + `hkm ui` to mirror plugin UIs, with a + Pageflow (Inertia-style) SPA bridge. + +### 🍳 Still cooking + +- **Dependency-free HTTP core.** The kernel's `Request`/`Response`/`UploadedFile` are + *currently* built on `symfony/http-foundation` as a deliberate, temporary choice. They are + being reimplemented as pure value objects so the kernel carries no vendor coupling. The + kernel's own method surface (`$request->input()`, `Response::json()`, …) is the stable API — + build against it and the switch will be non-breaking. +- **API surface hardening.** We're at the `1.0.x` line; some plugin contracts and config keys + are still settling. Pin your version and read the [CHANGELOG](CHANGELOG.md) before upgrading. +- **Docs & guides.** The layer deep-dives in [`docs/guides/`](docs/guides/) are being expanded + and turned into a proper documentation site. +- **Test & tooling coverage.** PHPStan (level 5) and the PHPUnit suite are wired as CI gates; + coverage and static-analysis depth are still growing. + +### 🗺️ On the roadmap + +- Finish the dependency-free kernel and drop the transitional Symfony dependency. +- A public documentation site generated from the layer guides. +- More first-party adapters (queue backends, storage drivers, mail transports). +- Performance benchmarks published per release. + +Found a gap or want to help? [Open an issue](https://github.com/AlfaCode-Team/hkm-kernel/issues) +or a discussion. --- @@ -54,9 +152,6 @@ The result: predictable performance (you pay only for what a route uses), strong 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 @@ -65,7 +160,7 @@ code. │ ┌─────────────────────────────────────────────┐ │ │ │ MODULE / PLUGIN LAYER (bounded domains) │ │ │ │ ┌───────────────────────────────────────┐ │ │ -│ │ │ KERNEL (Sentinel) │ │ │ +│ │ │ KERNEL │ │ │ │ │ │ boot · security · loading · DI · │ │ │ │ │ │ pipelines · events · ports │ │ │ │ │ └───────────────────────────────────────┘ │ │ @@ -82,7 +177,7 @@ code. ## Install Download the latest build from -[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest). +[Releases](https://github.com/AlfaCode-Team/hkm-kernel/releases/latest). **Linux (Debian / Ubuntu / Kali)** ```bash @@ -119,11 +214,12 @@ so they match your exact PHP. | `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 module` | Inspect / update the first-party kernel packages | | `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 version` / `--version` / `-v` | Show the banner + version | | `hkm --dev` | Run any command against the **development** kernel checkout | ### Environment (all auto-detected — override only for non-standard layouts) @@ -467,8 +563,8 @@ Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), ## Development from source ```bash -git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git -cd php-service-platform +git clone --recurse-submodules git@github.com:AlfaCode-Team/hkm-kernel.git +cd hkm-kernel composer install # also wires the git hooks (core.hooksPath=.githooks) vendor/bin/phpunit # run the test suite @@ -511,7 +607,7 @@ Notes: `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 +For deep dives, see the layer guides in [`docs/guides/`](docs/guides/) and the [CHANGELOG](CHANGELOG.md). --- diff --git a/docs/guides/00_SENTINEL_OVERVIEW.md b/docs/guides/00_SENTINEL_OVERVIEW.md new file mode 100644 index 0000000..7bde2de --- /dev/null +++ b/docs/guides/00_SENTINEL_OVERVIEW.md @@ -0,0 +1,204 @@ +# HKM Kernel — Architecture Overview + +> **Start here.** This is the root guide that every layer guide builds on. Every rule stated +> here is absolute and enforced by the framework at runtime. + +--- + +## What HKM Kernel Is + +HKM Kernel is a PHP 8.2+ framework built on the **Gated Demand Architecture (GDA)** pattern. + +| Principle | Meaning | +|---|---| +| **Security before bootstrap** | The `SecurityGateway` runs before any module loads. Denied requests cost microseconds. | +| **Load only what is needed** | Only the modules required for the current request are loaded into memory. | +| **One module, one domain** | Every module declares exactly one business domain it solves. | +| **Isolation by default** | Modules cannot access each other's internals. Scoped containers enforce this at runtime. | +| **Infrastructure independence** | The kernel defines port interfaces. The project provides implementations. | +| **Explicit over implicit** | Everything is declared in `module.json`. Nothing is auto-discovered at runtime. | + +--- + +## The Three Worlds + +``` +┌─────────────────────────────────────────────────┐ +│ PROJECT LAYER (wiring only — no business logic) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ MODULE LAYER (business domains) │ │ +│ │ ┌───────────────────────────────────────┐ │ │ +│ │ │ KERNEL (boot, pipeline, security, │ │ │ +│ │ │ loading, events, DI) │ │ │ +│ │ └───────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +- **Kernel** — knows nothing about modules or business logic. Never changes. +- **Module** — knows nothing about the project. Wires to the kernel through contracts. +- **Project** — knows about everything but contains no business logic. Wires only. + +--- + +## The Five Access Rules (ABSOLUTE — NEVER VIOLATE) + +These are enforced by `ModuleContainer` scope checking at runtime. + +``` +Controller → Service (via published contract only) +Service → Repository AND Gateway +Repository → DatabasePort only +Gateway → Vendor SDK only +Domain → Nothing external +``` + +**In plain English:** +1. A Controller may ONLY call a Service. Never a Repository, Gateway, or Domain class directly. +2. A Service is the ONLY layer that may call both Repository and Gateway. +3. A Repository may ONLY use `DatabasePort`. No HTTP calls, no third-party SDKs. +4. A Gateway may ONLY wrap a vendor SDK. No database, no other services. +5. Domain classes (Entities, Value Objects) have ZERO imports from outside `Domain/`. + +--- + +## The HTTP Request Lifecycle + +``` +Request arrives + │ + ▼ +CorrelationIdStage ← generates/propagates X-Correlation-ID + │ + ▼ +SecurityGateway ← PRE-BOOTSTRAP: runs before any module loads + CsrfTokenLayer ← kernel: stateless HMAC-signed CSRF verification + [Auth plugin] layers ← JwtAuthLayer / PersonalAccessTokenLayer → sets Identity + │ DENIED → 401/403 (zero module cost) + │ (rate limiting + IP shield are SecurityFilters route filters, later in-pipeline) + │ CLEARED ↓ +after.security hooks ← module-registered stages run here + │ + ▼ +ResolveStage ← route-manifest.php lookup → service name + (static: O(1) "METHOD /path" hash; parameterized: + regex scan over ONLY the requested method's bucket) + │ + ▼ +LoadStage ← dep graph calc → OnDemandLoader + (only modules needed for THIS route) +after.load hooks ← module-registered stages run here +RouteFilterStage ← runs the matched route's declared filters[] (auth, throttle, …) + │ + ▼ +ExecuteStage ← resolve service contract → run → Response +after.execute hooks + │ + ▼ +Response returned + │ +ErrorStage wraps everything ← catches all Throwables, routes to ErrorPipeline +``` + +--- + +## Module Directory Structure + +``` +projects/{name}/ +├── module.json ← SINGLE SOURCE OF TRUTH +├── API/ +│ ├── Contracts/{Name}ServiceContract.php +│ └── IntegrationEvents/{Name}CreatedEvent.php +├── Domain/ +│ ├── Entities/{Name}.php +│ ├── ValueObjects/{Field}.php +│ ├── Rules/{Rule}.php +│ └── Events/{Name}CreatedDomainEvent.php +├── Application/ +│ └── Services/{Name}Service.php +├── Infrastructure/ +│ ├── Persistence/{Name}Repository.php +│ ├── Gateways/{Vendor}Gateway.php +│ └── Http/Controllers/{Name}Controller.php +└── Provider.php +``` + +Note: `modules/` in this repository holds internal framework packages +(`bind-it`, `php-io-cli`, etc.), while business domain modules are currently +loaded from `projects/{name}/`. + +--- + +## module.json Schema (All Fields) + +```json +{ + "name": "invoice", + "version": "1.0.0", + "solves": "invoice.generation", + "type": "module", + "requires": ["database.query", "pdf.generation"], + "exposes": ["InvoiceServiceContract"], + "routes": [ + { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, + { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create" } + ], + "emits": ["invoice.created", "invoice.paid"], + "listens": ["payment.succeeded"], + "config": ["INVOICE_CURRENCY", { "key": "TAX_RATE", "type": "float", "required": false }] +} +``` + +--- + +## Exception Hierarchy + +``` +FrameworkException (base) +├── SecurityException → severity: warning → HTTP 401/403 +├── DomainException → severity: info → HTTP 422 +├── ServiceException → severity: warning → HTTP 422/500 +├── RepositoryException → severity: critical → HTTP 500 +├── GatewayException → severity: critical → HTTP 502 +└── KernelException → severity: critical → HTTP 500 +``` + +Always throw the exception type matching the layer. Never let a `\PDOException` or `\Stripe\Exception` escape its layer. + +--- + +## Key Contracts Reference + +| Interface | Location | Used By | +|---|---|---| +| `DatabasePort` | Kernel | Repository layer | +| `CachePort` | Kernel | Rate limiter, cache module | +| `QueuePort` | Kernel | Service layer (job dispatch) | +| `MailPort` | Kernel | Service layer (email) | +| `SmsPort` | Kernel | Service layer (SMS) | +| `StoragePort` | Kernel | Service layer (files) | +| `ModuleContract` | Kernel | Every module's Provider | +| `SecurityLayerContract` | Kernel | SecurityGateway layers | +| `JobContract` | Kernel | Worker pipeline job handlers | +| `CommandContract` | Kernel | CLI pipeline command handlers | + +--- + +## Guide index + +| File | Covers | +|---|---| +| `00_SENTINEL_OVERVIEW.md` | This file — architecture rules and structure | +| `01_KERNEL.md` | Kernel components, pipelines, boot sequence | +| `02_MODULE.md` | module.json, ModuleContract, Provider wiring | +| `03_DOMAIN.md` | Entities, Value Objects, Domain Events, Rules | +| `04_SERVICE.md` | Application Services, transactions, event dispatch | +| `05_REPOSITORY.md` | Repository layer, DatabasePort, hydration | +| `06_GATEWAY.md` | Gateway layer, SDK wrapping, exception translation | +| `07_CONTROLLER.md` | HTTP Controllers, DTOs, response format | +| `08_EVENTS.md` | Domain vs Integration events, EventBus, outbox | +| `09_SECURITY.md` | SecurityGateway, layers, Identity, tokens | +| `10_TESTING.md` | Test patterns, fakes, port doubles, strategies | +| `11_PROJECT.md` | Bootstrap, port adapters, configuration wiring | +| `12_WORKER.md` | Worker pipeline, jobs, retry, dead-letter queue | diff --git a/docs/guides/01_KERNEL.md b/docs/guides/01_KERNEL.md new file mode 100644 index 0000000..5e3d4f4 --- /dev/null +++ b/docs/guides/01_KERNEL.md @@ -0,0 +1,346 @@ +# HKM Kernel — Kernel Layer + +> The kernel is the **smallest, most stable** component. It owns exactly eight responsibilities +> and knows nothing about any module or business domain. When you see code in `vendor/sentinel/kernel/`, +> these rules describe it. + +--- + +## What the Kernel Owns + +| Responsibility | Component | Pattern | +|---|---|---| +| Boot orchestration | `BootPipeline` + stages | Pipeline / Fail-Fast | +| HTTP request processing | `HttpPipeline` + stages | Chain of Responsibility | +| Pre-bootstrap security | `SecurityGateway` | Chain of Responsibility | +| Module loading | `OnDemandLoader` + `DependencyGraphCalculator` | Strategy + Template Method | +| Error handling | `ErrorPipeline` + `ErrorConsumer` | Observer + Chain of Responsibility | +| Cross-module messaging | `EventBus` + `DomainEventCollector` | Observer / Mediator | +| Dependency injection | `CoreContainer` + `ModuleContainer` | IoC Container | +| Infrastructure abstraction | Port interfaces | Ports and Adapters | + +## What the Kernel Does NOT Own + +The kernel has **zero knowledge** of: +- Which modules exist +- What any module does +- Any business domain (invoices, payments, users, etc.) +- Any infrastructure implementation (MySQL, Redis, S3, etc.) +- Any HTTP framework (Laravel, Symfony, Slim, etc.) + +--- + +## BootPipeline — Runs Once at Startup + +```php +// Stages run in this exact order. Any failure = immediate shutdown. +ValidateConfigStage::class, // 1. All env vars present and correct type +DetectConflictsStage::class, // 2. No two modules share the same solves() domain +DetectCyclesStage::class, // 3. No circular dependency chains in requires[] +CompileRouteManifestStage::class, // 4. routes[] → projects//var/cache/manifests/route-manifest.php +CompileJobManifestStage::class, // 5. type:job → projects//var/cache/manifests/job-manifest.php +CompileCommandManifestStage::class, // 6. type:command → projects//var/cache/manifests/command-manifest.php +RegisterPortsStage::class, // 7. Port interface → Adapter bindings +BindSecurityStage::class, // 8. SecurityGateway + layers initialized +``` + +**Rule:** Boot fails loudly with a descriptive `BootException` listing exactly what is wrong. +It never starts with missing config, conflicting modules, or circular dependencies. + +**Single manifest read:** every manifest-reading stage shares ONE `ManifestReader` +instance (constructed in `BootPipeline` and injected via each stage's `reader:` param). +The reader caches each `module.json` by class, so a module's manifest is read from disk +and JSON-decoded exactly once per boot — not once per stage. + +--- + +## Kernel Lifecycle — build() vs materialize() + +Startup is **two phases**. `build()` is compile-only; the heavy work is deferred to the +first entry-point call so a process only pays for the surface it actually uses. + +| Phase | Trigger | Work done | +|---|---|---| +| `build()` | explicit, once | Set paths, bind ports into `CoreContainer`, run `BootPipeline` (validate config + compile manifests). **No pipelines, no module wiring, no freeze.** | +| `materialize(RuntimeMode)` | first `http()` / `cli()` / `workerLoop()` / `container()` call, once | Construct pipelines, wire every module ONCE (`Provider::boot`), bind kernel services, **freeze the core container**. | + +```php +$kernel->http() // → materialize(RuntimeMode::Http), then HttpPipeline +$kernel->cli() // → materialize(RuntimeMode::Cli), then CliPipeline +$kernel->workerLoop() // → materialize(RuntimeMode::Worker), then WorkerLoop +$kernel->container() // → materialize(RuntimeMode::Cli), then frozen CoreContainer +$kernel->mode() // → ?RuntimeMode the kernel materialized for (null before first call) +``` + +**Why all three pipelines still get constructed at materialize:** `ModuleContract::boot()` +registers HTTP hooks, CLI commands, worker hooks **and** event subscriptions in a single +call, so all three pipeline instances must exist when modules wire. They are cheap shells — +`HttpPipeline` and `WorkerLoop` defer their manifest disk I/O until their OWN first run +(`handle()` / first job). Net effect: an HTTP-only process never reads the job manifest, and +a CLI process never reads the route manifest. + +**Rule:** Treat `materialize()` as private — never call it directly. Reach an entry point and +the kernel materializes itself for that surface. + +--- + +## SecurityGateway — Permanent Resident + +```php +interface SecurityLayerContract +{ + public function check(Request $request): SecurityVerdict; +} + +final class SecurityVerdict +{ + public static function allow(Request $request): self; // Identity attached + public static function deny(int $code, string $reason): self; // 401/403/429 + public function isDenied(): bool; + public function identity(): ?Identity; +} +``` + +**Critical rules:** +- Runs **before** any module loads. Denied requests never touch module code. +- Each layer returns `allow` or `deny`. The first `deny` short-circuits all remaining layers. +- Order matters: Firewall (cheapest) → RateLimiter → TokenValidator (most expensive). +- `Identity` is immutable. Once set by the gateway it cannot be modified downstream. + +--- + +## Identity — Immutable Value Object + +```php +final readonly class Identity +{ + public function __construct( + public readonly string $userId, + public readonly string $tenantId, + public readonly array $roles, + public readonly array $permissions, + public readonly string $tokenType, // 'jwt' | 'api_key' | 'session' + ) {} + + public function hasRole(string $role): bool; + public function hasPermission(string $perm): bool; + public function isGuest(): bool; +} +``` + +**Rule:** `Identity` flows through the entire request from SecurityGateway to Service. +Services receive it via constructor injection from the scoped container. + +--- + +## OnDemandLoader — Per-Request Module Loading + +``` +Request cleared by SecurityGateway + │ + ▼ +RouteMatcher::match(method, path) → service name (e.g. 'invoice.generation') + (static routes: O(1) "METHOD /path" hash lookup; parameterized routes: + regex scan over ONLY the requested method's bucket — never the whole table) + │ + ▼ +DependencyGraphCalculator::resolve(service) + → [database.query, pdf.generation, invoice.generation] (ordered, minimal) + │ + ▼ +OnDemandLoader::load(graph, request) + → Instantiate each module and call register() ONLY (per-request DI bindings) + → boot() is NOT called here — hooks + event subscriptions are wired once at + materialize(); per-request work stays minimal + → Return request-scoped ModuleContainer + │ + ▼ +Request executes → Container discarded at end of request +``` + +**Rule:** The dep graph is **pre-compiled** into `service-manifest.php` at deploy time. +The calculator reads a PHP array — zero I/O, microsecond lookup. + +--- + +## Container Architecture — bind-it Engine + +Both `CoreContainer` and `ModuleContainer` extend `PHPShots\Common\Container` from the **bind-it** +package (`phpshots/bind-it`, lives in `modules/bind-it/`). This provides reflection-based +autowiring, PSR-11 compliance, contextual bindings, extenders, `resolving()` / `rebinding()` +callbacks. GDA scope rules are layered on top inside the kernel wrappers. + +--- + +## CoreContainer — App-Lifetime + +One instance per worker process. Created by `Kernel::configure()`, frozen during +`materialize()` — the lazy step that runs on the first entry-point call, NOT in `build()`. + +```php +$core->instance(DatabasePort::class, $adapter); // register port implementation +$core->singleton(TransactionManager::class, ...); // kernel services +$core->freeze(); // called AUTOMATICALLY during materialize() — no writes after this +$core->isFrozen(): bool // false after build(), true after materialize() + +// DISABLED — throw LogicException (no global singleton in Swoole workers): +$core->getInstance(); // ← LogicException +$core->setInstance(); // ← LogicException +``` + +**Rule:** Never call `bind()`, `singleton()`, or `extend()` on `CoreContainer` after the +kernel materializes (the first `http()`/`cli()`/`workerLoop()`/`container()` call). The +container is frozen and will throw `LogicException`. + +--- + +## ModuleContainer — Request-Scoped + +New instance per request, discarded at end of request. Scope isolation enforced at runtime. + +```php +// Internal binding — ScopeViolationException if resolved from outside this module +$container->bindInternal(InvoiceRepository::class, fn($c) => + new InvoiceRepository($c->make(DatabasePort::class)) +); + +// Public binding — resolvable by modules that declare this in requires[] +$container->bind(InvoiceServiceContract::class, fn($c) => + new InvoiceService($c->make(InvoiceRepository::class), ...) +); + +// Resolve with explicit caller scope — used by ExecuteStage for controllers +$container->makeInScope(InvoiceController::class, 'invoice.generation'); + +// Full lifecycle teardown — MUST be called at end of every Swoole request +$container->reset(); + +// DISABLED — throw LogicException: +$container->getInstance(); // ← LogicException +$container->setInstance(); // ← LogicException +``` + +**Rule:** In Swoole workers, call `$container->reset()` at end of each request. +`OnDemandLoader` creates a fresh `ModuleContainer` per request — reset is automatic in HTTP. +For Swoole coroutines, wire `Kernel::requestTeardown()` into your coroutine cleanup hook. + +--- + +## CoreContainer vs ModuleContainer + +| Feature | CoreContainer | ModuleContainer | +|---|---|---| +| Lifetime | Application lifetime (one per process) | Request lifetime (new per request) | +| Scope | All modules + kernel services | One module only | +| Contains | Port implementations, kernel services | Module services, repositories, gateways | +| Internal access | Any code | Only the owning module | +| Isolation | None | Full — ScopeViolationException on cross-access | +| Write lock | Frozen during `materialize()` (first entry-point call) | No lock — discarded after request | +| Teardown | None (process lifetime) | `reset()` — wipes all state | +| Global singleton | Disabled (`getInstance()` throws) | Disabled (`getInstance()` throws) | + +--- + +## ModuleContainer — Scope Isolation + +```php +// ENFORCED AT RUNTIME — not just a convention +$container->bindInternal(InvoiceRepository::class, ...); +// ↑ Bindings marked internal throw ScopeViolationException if resolved +// from any scope other than the module that owns them. + +// Cross-module access: only through published contracts +$container->bind(InvoiceServiceContract::class, ...); +// ↑ Resolvable from any scope — this is the module's public API +``` + +**Rule:** `ScopeViolationException` is thrown if Module A resolves Module B's internal binding. +Fix: use Module B's published contract from `API/Contracts/`. + +--- + +## ErrorPipeline + +``` +Any Throwable + │ + ▼ +ErrorInterceptor ← catches, wraps in ErrorContext + │ + ▼ +ErrorNormalizer ← adds request metadata, correlation ID, severity + │ + ▼ +ErrorClassifier ← assigns: critical | warning | info + │ + ▼ +ErrorDispatcher ← routes to notifier chain + ├── SlackNotifier (critical) + ├── MailNotifier (critical) + ├── DatabaseLogger (warning+) + └── FileNotifier (always — fallback) +``` + +**Rule:** `FileNotifier` always runs. It is the guaranteed fallback even if all others fail. +Notifier failures are silently ignored so they never mask the original error. + +--- + +## Port Interfaces (Kernel-Defined) + +The kernel defines these interfaces. The project provides implementations. +**No module imports an implementation — only the interface.** + +```php +interface DatabasePort { + public function query(string $sql, array $params = []): array; + public function queryOne(string $sql, array $params = []): ?array; + public function execute(string $sql, array $params = []): int; + // Portable, atomic upsert (MySQL ON DUPLICATE KEY / PostgreSQL+SQLite ON CONFLICT). + public function upsert(string $table, array $values, array $conflictColumns, ?array $updateColumns = null): int; + public function lastInsertId(?string $sequence = null): string; + public function beginTransaction(): void; + public function commit(): void; + public function rollback(): void; + public function inTransaction(): bool; +} + +interface CachePort { + public function get(string $key): mixed; + public function set(string $key, mixed $value, ?int $ttl = null): bool; + public function delete(string $key): bool; + public function remember(string $key, int $ttl, callable $callback): mixed; +} + +interface QueuePort { + public function push(string $jobClass, array $payload, string $queue = 'default', int $delay = 0): string; + public function later(int $seconds, string $jobClass, array $payload, string $queue = 'default'): string; +} + +interface MailPort { + public function send(string|array $to, string $subject, string $view, array $data = []): void; + public function queue(string|array $to, string $subject, string $view, array $data = []): string; +} + +interface StoragePort { + public function store(string $contents, string $filename, string $path = '', string $visibility = 'private'): string; + public function get(string $path): string; + public function temporaryUrl(string $path, int $expiresInSeconds = 3600): string; + public function delete(string $path): bool; +} +``` + +--- + +## Rules for Kernel Code + +When writing or reviewing kernel code: + +- **DO** keep kernel classes free of any business domain knowledge +- **DO** use port interfaces — never concrete adapters +- **DO** throw `KernelException` for kernel-level failures +- **DO** ensure BootPipeline stages fail fast with descriptive messages +- **DON'T** add module-specific logic to kernel classes +- **DON'T** let `\PDOException`, `\RedisException`, or any vendor exception escape the kernel +- **DON'T** add new port methods without backward compatibility consideration +- **DON'T** modify `Identity` after it is set by the SecurityGateway diff --git a/docs/guides/02_MODULE.md b/docs/guides/02_MODULE.md new file mode 100644 index 0000000..fa90ca2 --- /dev/null +++ b/docs/guides/02_MODULE.md @@ -0,0 +1,260 @@ +# HKM Kernel — Module Layer + +> A module is a **self-describing, self-contained bounded context**. It declares everything +> the kernel needs to know in `module.json`. The kernel reads only the manifest — never +> the module's PHP code directly. + +--- + +## Module Identity Rules + +| Rule | Detail | +|---|---| +| One module = one domain | `solves` field declares a single domain string. No exceptions. | +| Name is unique | Two modules with the same `solves` value causes boot failure. | +| All dependencies declared | Every contract the module needs must be in `requires[]`. | +| All exports declared | Every contract the module provides must be in `exposes[]`. | +| All config declared | Every env var the module reads must be in `config[]`. | +| All routes declared | Routes live in `module.json`, never in PHP route files. | + +--- + +## module.json — Complete Annotated Schema + +```json +{ + "name": "invoice", // kebab-case, unique across all installed modules + "version": "1.0.0", // semver — used for conflict detection + "solves": "invoice.generation", // dot-notation domain string — UNIQUE in the system + + "type": "module", // "module" | "job" | "command" + + "requires": [ // Contracts this module needs from other modules + "database.query", // → another module's solves() value + "pdf.generation" + ], + + "exposes": [ // Contracts this module makes available to others + "InvoiceServiceContract" // → fully qualified or short class name + ], + + "routes": [ // HTTP routes — compiled into route-manifest.php + // Optional "filters": [...] declares route filters by alias (run by + // RouteFilterStage). String or list; "alias:arg1,arg2" passes args. + { "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" }, + { "method": "PUT", "path": "/api/invoices/{id}", "handler": "InvoiceController@update" }, + { "method": "DELETE", "path": "/api/invoices/{id}", "handler": "InvoiceController@destroy" } + ], + + "emits": [ // Integration events this module dispatches + "invoice.created", + "invoice.paid" + ], + + "listens": [ // Integration events this module subscribes to + "payment.succeeded" + ], + + "config": [ // Environment variables this module reads + "INVOICE_CURRENCY", // required string + { "key": "INVOICE_TAX_RATE", "type": "float", "required": false } // optional float + ] +} +``` + +--- + +## ModuleContract — Every Module Implements This + +```php +interface ModuleContract +{ + // The single domain this module owns. Must match module.json "solves" field. + public function solves(): string; + + // Contracts this module requires from other modules. Must match module.json "requires". + public function requires(): array; + + // Contracts this module exposes to other modules. Must match module.json "exposes". + public function exposes(): array; + + // Register DI bindings in the module's scoped container. + // Called once when the module is loaded for a request. + public function register(ModuleContainer $container): void; + + // Register pipeline hooks and event subscriptions. + // Called after all required modules are registered. + public function boot( + HttpPipeline $http, + CliPipeline $cli, + WorkerPipeline $worker, + EventBus $events, + ): void; +} +``` + +--- + +## Provider.php — Canonical Implementation + +```php +bindInternal(InvoiceRepository::class, fn($c) => + new InvoiceRepository($c->make(DatabasePort::class)) + ); + + // PUBLIC binding — resolvable by any module that declares this in requires[] + $container->bind(InvoiceServiceContract::class, fn($c) => + new InvoiceService( + repository: $c->make(InvoiceRepository::class), + transaction: $c->make(TransactionManager::class), + collector: $c->make(DomainEventCollector::class), + eventBus: $c->make(IntegrationEventBus::class), + identity: $c->make(Identity::class), + ) + ); + } + + public function boot( + HttpPipeline $http, CliPipeline $cli, + WorkerPipeline $worker, EventBus $events, + ): void { + // Register pipeline hooks (optional) + // $http->hook('after.security', SomeStage::class, priority: 50); + + // Subscribe to integration events (optional) + // $events->subscribe('payment.succeeded', PaymentSucceededListener::class); + } +} +``` + +--- + +## Pipeline Hook Slots and Priorities + +``` +HTTP Pipeline Hooks: + after.security ← module stages run after SecurityGateway clears the request + after.load ← module stages run after OnDemandLoader instantiates modules + after.execute ← module stages run after ExecuteStage returns a response + +Priority conventions: + 1–9 = System-level (maintenance mode, CORS preflight) + 10–19 = Security-adjacent (rate limiter, IP validation) + 20–39 = Auth-adjacent (session refresh, token rotation) + 40–59 = Feature middleware (locale, feature flags) + 60–79 = Business-specific (tenant context) + 80–99 = Observability (metrics, tracing) + 100+ = Cleanup (response formatting, header injection) +``` + +--- + +## Cross-Module Communication + +### Option 1 — Synchronous (API Contract) + +```php +// Module B declares: "requires": ["invoice.generation"] +// Module B injects the contract — never the implementation + +use InvoiceModule\API\Contracts\InvoiceServiceContract; + +class PaymentService +{ + public function __construct( + private readonly InvoiceServiceContract $invoices, // ← interface, not class + ) {} + + public function process(ProcessPaymentDTO $dto): PaymentResponseDTO + { + $invoice = $this->invoices->find($dto->invoiceId); // valid cross-module call + } +} +``` + +### Option 2 — Asynchronous (Integration Event) + +```php +// Module B declares: "listens": ["invoice.created"] +// Module B's Provider registers the listener in boot() +$events->subscribe('invoice.created', InvoiceCreatedListener::class); +``` + +--- + +## Module Type Variants + +### Standard Module (`"type": "module"`) +Has routes, services, domain. Standard module as described above. + +### Job Module (`"type": "job"`) +```json +{ + "type": "job", + "queue": "emails", + "retry": { "max": 3, "strategy": "exponential", "jitter": true }, + "timeout": 30, + "requires": ["mail.port", "invoice.generation"] +} +``` + +### Command Module (`"type": "command"`) +```json +{ + "type": "command", + "signature": "invoice:generate {clientId} {--currency=USD}", + "requires": ["database.query", "invoice.generation"] +} +``` + +--- + +## Rules for Module Code + +When writing or reviewing module code: + +- **DO** ensure `module.json` lists every env var in `config[]` — boot will fail otherwise +- **DO** mark internal bindings with `bindInternal()` — not `bind()` +- **DO** match `solves()` return value exactly to `module.json` `"solves"` field +- **DO** match `requires()` and `exposes()` arrays to `module.json` fields +- **DON'T** register routes in PHP code — they belong in `module.json` only +- **DON'T** import another module's concrete class — use its published contract +- **DON'T** put business logic in `Provider.php` — it is wiring only +- **DON'T** create a module that solves two domains — split into two modules diff --git a/docs/guides/03_DOMAIN.md b/docs/guides/03_DOMAIN.md new file mode 100644 index 0000000..4ec7f7a --- /dev/null +++ b/docs/guides/03_DOMAIN.md @@ -0,0 +1,337 @@ +# HKM Kernel — Domain Layer + +> The Domain layer is the **core of every module**. It contains pure business logic and has +> **zero external dependencies** — no framework classes, no port interfaces, no vendor SDKs. +> If a file in `Domain/` has an `import` from outside its own namespace, it is wrong. + +--- + +## Domain Layer Rules (ABSOLUTE) + +| Rule | Why | +|---|---| +| No imports from outside `Domain/` | Domain must be testable with zero infrastructure | +| No `new DateTimeImmutable()` from outside — pass it in | Makes tests deterministic | +| State changes only through entity methods | Prevents invalid state | +| All validation in constructors | Invalid objects cannot exist | +| All invariants checked before state mutation | Business rules enforced at the source | +| Domain events recorded inside entities | Entity is responsible for its own events | +| `releaseEvents()` returns AND clears the buffer | Events are consumed once | + +--- + +## Entity Pattern + +```php +status = InvoiceStatus::DRAFT; + } + + // ── Named constructor (factory method) ───────────────────────────────── + public static function create( + InvoiceNumber $number, + ClientId $clientId, + DateTimeImmutable $dueDate, + ): self { + $invoice = new self( + id: InvoiceId::generate(), + number: $number, + clientId: $clientId, + subtotal: Money::zero('USD'), + tax: Money::zero('USD'), + dueDate: $dueDate, + createdAt: new DateTimeImmutable(), + ); + $invoice->record(new InvoiceCreatedDomainEvent($invoice)); + return $invoice; + } + + // ── Reconstitution (for hydration from DB) ────────────────────────────── + public static function reconstitute(/* all fields */): self + { + $invoice = new self(/* ... */); + // NOTE: do NOT record domain events on reconstitution + return $invoice; + } + + // ── State transitions ─────────────────────────────────────────────────── + public function addLineItem(LineItem $item): void + { + $this->ensureStatus(InvoiceStatus::DRAFT, 'add line items'); + $this->lineItems[] = $item; + $this->subtotal = $this->subtotal->add($item->total()); + $this->tax = $this->tax->add($item->taxAmount()); + } + + public function issue(): void + { + $this->ensureStatus(InvoiceStatus::DRAFT, 'issue'); + InvoiceMustHaveLineItems::check($this->lineItems); + $this->status = InvoiceStatus::ISSUED; + $this->record(new InvoiceIssuedDomainEvent($this)); + } + + // ── Domain event management ───────────────────────────────────────────── + public function releaseEvents(): array + { + $events = $this->domainEvents; + $this->domainEvents = []; + return $events; + } + + private function record(DomainEventContract $event): void + { + $this->domainEvents[] = $event; + } + + // ── Guard helpers ─────────────────────────────────────────────────────── + private function ensureStatus(InvoiceStatus $required, string $action): void + { + if ($this->status !== $required) { + throw new \DomainException( + "Cannot {$action}: invoice status is [{$this->status->value}]" + ); + } + } + + // ── Getters (read-only access) ────────────────────────────────────────── + public function id(): InvoiceId { return $this->id; } + public function number(): InvoiceNumber { return $this->number; } + public function clientId(): ClientId { return $this->clientId; } + public function status(): InvoiceStatus { return $this->status; } + public function subtotal(): Money { return $this->subtotal; } + public function tax(): Money { return $this->tax; } + public function dueDate(): DateTimeImmutable { return $this->dueDate; } + public function lineItems(): array { return $this->lineItems; } + public function version(): int { return $this->version; } +} +``` + +--- + +## Value Object Pattern + +```php +amount < 0) { + throw new \DomainException('Money amount cannot be negative'); + } + if (strlen($this->currency) !== 3) { + throw new \DomainException("Invalid currency code: [{$this->currency}]"); + } + } + + // ── Named constructors ────────────────────────────────────────────────── + public static function of(int|float $amount, string $currency): self + { + return new self((int) round($amount * 100), strtoupper($currency)); + } + + public static function fromCents(int $cents, string $currency): self + { + return new self($cents, strtoupper($currency)); + } + + public static function zero(string $currency): self + { + return new self(0, strtoupper($currency)); + } + + // ── Operations return NEW instances — immutable ───────────────────────── + public function add(self $other): self + { + $this->assertSameCurrency($other); + return new self($this->amount + $other->amount, $this->currency); + } + + public function subtract(self $other): self + { + $this->assertSameCurrency($other); + return new self($this->amount - $other->amount, $this->currency); + } + + public function multiply(int|float $factor): self + { + return new self((int) round($this->amount * $factor), $this->currency); + } + + // ── Comparison ────────────────────────────────────────────────────────── + public function equals(self $other): bool + { + return $this->amount === $other->amount && $this->currency === $other->currency; + } + + public function isGreaterThan(self $other): bool + { + $this->assertSameCurrency($other); + return $this->amount > $other->amount; + } + + // ── Accessors ─────────────────────────────────────────────────────────── + public function value(): float { return $this->amount / 100; } + public function amount(): int { return $this->amount; } + public function currency(): string { return $this->currency; } + + private function assertSameCurrency(self $other): void + { + if ($this->currency !== $other->currency) { + throw new \DomainException( + "Cannot operate on {$this->currency} and {$other->currency}" + ); + } + } +} +``` + +--- + +## Domain Event Pattern + +```php + [self::ISSUED, self::CANCELLED], + self::ISSUED => [self::PAID, self::OVERDUE, self::CANCELLED], + self::OVERDUE => [self::PAID, self::CANCELLED], + self::PAID => [], + self::CANCELLED => [], + }, true); + } +} +``` + +--- + +## Domain Layer File Checklist + +Every file in `Domain/` must satisfy: + +- [ ] `declare(strict_types=1)` at the top +- [ ] No imports from outside `Domain/` namespace +- [ ] No `use` of any framework class, port interface, or vendor SDK +- [ ] All constructors validate their inputs and throw `\DomainException` on violation +- [ ] Entities use `private` constructors and `public static` factory methods +- [ ] Value objects are `final readonly` classes +- [ ] Domain events are `final readonly` classes named in past tense +- [ ] `releaseEvents()` clears the event buffer after returning it + +--- + +## Rules for Domain Code + +When writing or reviewing domain code: + +- **DO** make all Value Objects `final readonly` — immutability is mandatory +- **DO** throw `\DomainException` (not `\RuntimeException`) for business rule violations +- **DO** store money as integer cents — never `float` +- **DO** use `declare(strict_types=1)` on every domain file +- **DO** use private constructors + static factory methods for Entities +- **DO** use named constructors that describe intent: `Invoice::create()`, not `new Invoice()` +- **DON'T** inject any service, port, or infrastructure class into domain objects +- **DON'T** put persistence logic in entities (no Eloquent, no Active Record) +- **DON'T** call `new DateTimeImmutable()` inside domain methods — pass time as a parameter +- **DON'T** dispatch events from domain objects — record them, release from the Service layer diff --git a/docs/guides/04_SERVICE.md b/docs/guides/04_SERVICE.md new file mode 100644 index 0000000..5640312 --- /dev/null +++ b/docs/guides/04_SERVICE.md @@ -0,0 +1,291 @@ +# HKM Kernel — Application Service Layer + +> The Service layer is the **only layer** that may call both Repository (persistence) and +> Gateway (third-party APIs). It orchestrates workflows, owns transactions, collects domain +> events, and dispatches integration events. + +--- + +## Service Layer Rules (ABSOLUTE) + +| Rule | Detail | +|---|---| +| ONLY layer calling Repository AND Gateway | No other layer touches both | +| Owns transaction boundaries | `begin → save → commit / rollback` | +| Collects domain events during transaction | Via `DomainEventCollector` | +| Dispatches integration events AFTER commit | Never inside the transaction | +| Discards domain events on rollback | `collector->discard()` in catch block | +| Identity injected via constructor | From the scoped container — set by SecurityGateway | +| NEVER instantiates HTTP Request/Response objects | Those belong in controllers | +| NEVER calls another Service directly | Use Integration Events for cross-module async | +| NEVER dispatches to QueuePort directly | Use Integration Events or dedicated dispatch service | + +--- + +## Canonical Service Implementation + +```php +clientId !== $this->identity->userId + && !$this->identity->hasPermission('invoice:create-for-others')) { + throw new ServiceException( + 'invoice.creation.unauthorized', + layer: 'service.invoice', + context: ['clientId' => $dto->clientId, 'userId' => $this->identity->userId], + ); + } + + // ── Transaction + domain event collection ──────────────────────── + $this->collector->beginCollection(); + $this->transaction->begin(); + try { + $invoice = Invoice::create( + InvoiceNumber::generate(), + ClientId::from($dto->clientId), + new \DateTimeImmutable($dto->dueDate), + ); + + foreach ($dto->lineItems as $item) { + $invoice->addLineItem(LineItem::from($item)); + } + + $invoice->issue(); + + // Collect domain events from entity into the transaction buffer + foreach ($invoice->releaseEvents() as $event) { + $this->collector->collect($event); + } + + $this->repository->save($invoice); + $this->transaction->commit(); + + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // No phantom events on failure + throw new ServiceException( + 'invoice.create.failed', + layer: 'service.invoice', + context: ['clientId' => $dto->clientId], + previous: $e, + ); + } + + // ── Integration event dispatch — AFTER successful commit ───────── + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent( + invoiceId: $invoice->id()->value(), + clientId: $dto->clientId, + amount: $invoice->total()->value(), + currency: $invoice->total()->currency(), + occurredAt: new \DateTimeImmutable(), + version: '1.0', + )); + + return InvoiceResponseDTO::from($invoice); + } +} +``` + +--- + +## Transaction Pattern — Always This Shape + +```php +$this->collector->beginCollection(); +$this->transaction->begin(); +try { + // 1. Domain operations + // 2. Collect domain events + // 3. Persist + $this->transaction->commit(); + +} catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // ← ALWAYS discard events on rollback + throw new ServiceException('...', previous: $e); +} + +// 4. Dispatch integration events ONLY here — after successful commit +$this->eventBus->dispatch(new SomethingHappenedIntegrationEvent(...)); +``` + +**Never dispatch an integration event inside a try block.** If the commit fails after the event +was dispatched, you have a phantom event for data that was never persisted. + +--- + +## DTO Pattern — Input and Output + +```php +// Input DTO — validated before reaching the service +final readonly class CreateInvoiceDTO +{ + public function __construct( + public readonly string $clientId, + public readonly string $dueDate, // 'Y-m-d' or relative like '+30 days' + public readonly array $lineItems, + public readonly string $currency = 'USD', + ) {} + + // DTOs may have a factory from Request — validation happens here + public static function fromRequest(Request $request): self + { + $data = $request->body(); + $errors = []; + + if (empty($data['clientId'])) { + $errors['clientId'] = 'Client ID is required'; + } + // ... more validation ... + + if (!empty($errors)) { + throw new ValidationException($errors); + } + + return new self( + clientId: $data['clientId'], + dueDate: $data['dueDate'], + lineItems: $data['lineItems'] ?? [], + ); + } +} + +// Output DTO — public shape of the domain entity +final readonly class InvoiceResponseDTO +{ + public function __construct( + public readonly string $invoiceId, + public readonly string $number, + public readonly string $status, + public readonly string $clientId, + public readonly float $subtotal, + public readonly float $tax, + public readonly float $total, + public readonly string $currency, + public readonly string $dueDate, + public readonly ?string $issuedAt, + public readonly ?string $paidAt, + public readonly array $lineItems, + ) {} + + public static function from(Invoice $invoice): self + { + return new self( + invoiceId: $invoice->id()->value(), + number: $invoice->number()->value(), + status: $invoice->status()->value, + clientId: $invoice->clientId()->value(), + subtotal: $invoice->subtotal()->value(), + tax: $invoice->tax()->value(), + total: $invoice->total()->value(), + currency: $invoice->total()->currency(), + dueDate: $invoice->dueDate()->format('Y-m-d'), + issuedAt: $invoice->issuedAt()?->format(\DateTimeInterface::RFC3339), + paidAt: $invoice->paidAt()?->format(\DateTimeInterface::RFC3339), + lineItems: array_map(fn($i) => LineItemDTO::from($i)->toArray(), $invoice->lineItems()), + ); + } + + public function toArray(): array { return get_object_vars($this); } +} +``` + +--- + +## Service Contract (API/Contracts/) + +```php +// This is the PUBLIC API of the module — other modules import only this interface. +interface InvoiceServiceContract +{ + /** + * @throws ServiceException code='invoice.creation.unauthorized' + * @throws ServiceException code='invoice.create.failed' + * @throws ValidationException if DTO is invalid + */ + public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO; + + /** + * @throws ServiceException code='invoice.not_found' + */ + public function find(string $invoiceId): InvoiceResponseDTO; + + public function list(ListInvoicesDTO $dto): PaginatedResult; + + /** + * @throws ServiceException code='invoice.not_found' + * @throws DomainException if invoice is not in ISSUED or OVERDUE status + */ + public function markPaid(MarkInvoicePaidDTO $dto): void; +} +``` + +--- + +## Service Security Pattern + +```php +// Pattern: explicit authorization at the start of every mutating service method +public function delete(string $invoiceId): void +{ + $invoice = $this->repository->find($invoiceId); + + // Ownership check (ABAC) + role check (RBAC) combined + if ($invoice->clientId()->value() !== $this->identity->userId + && !$this->identity->hasPermission('invoice:delete-any')) { + throw new ServiceException( + 'invoice.delete.unauthorized', + layer: 'service.invoice', + context: ['invoiceId' => $invoiceId, 'userId' => $this->identity->userId], + ); + } + + // ... proceed with deletion +} +``` + +--- + +## Rules for Service Code + +When writing or reviewing service code: + +- **DO** inject `Identity` via constructor — never resolve it inside a method +- **DO** call `collector->beginCollection()` before `transaction->begin()` +- **DO** call `collector->discard()` in every catch block — no exceptions +- **DO** dispatch integration events ONLY after the transaction commits (outside try/catch) +- **DO** throw `ServiceException` with a dot-notation code: `'invoice.create.failed'` +- **DO** check authorization at the start of every mutating method +- **DON'T** catch `ServiceException` inside the service — let it propagate +- **DON'T** use `QueuePort` directly from service — use `eventBus->dispatch()` with async events +- **DON'T** pass `Request` or `Response` objects into a service method +- **DON'T** call another module's Service class — call its published contract interface +- **DON'T** put business logic in DTOs — DTOs validate shape, not business rules diff --git a/docs/guides/05_REPOSITORY.md b/docs/guides/05_REPOSITORY.md new file mode 100644 index 0000000..1c79c5e --- /dev/null +++ b/docs/guides/05_REPOSITORY.md @@ -0,0 +1,296 @@ +# HKM Kernel — Repository Layer + +> The Repository is the **only layer that touches DatabasePort**. It translates between +> domain entities and raw database rows. It contains SQL but zero business logic. + +--- + +## Repository Rules (ABSOLUTE) + +| Rule | Detail | +|---|---| +| ONLY layer using `DatabasePort` | No controller, service, or gateway touches the DB directly | +| Zero business logic | No domain rules, no authorization, no event dispatch | +| One repository per aggregate root | `InvoiceRepository` for Invoice, not for LineItem | +| Returns domain objects, not arrays | `find()` returns `Invoice`, not `['id' => ...]` | +| All SQL in the repository | No SQL strings in services, domain, or controllers | +| Soft-delete by default | Filter `deleted_at IS NULL` on all queries unless specified | +| Always include `tenant_id` in WHERE | Tenant isolation is mandatory — never omit it | +| Translate `\PDOException` to `RepositoryException` | DB errors never leak to higher layers | + +--- + +## Canonical Repository Implementation + +```php +db->queryOne( + 'SELECT * FROM invoices + WHERE id = :id + AND tenant_id = :tenant -- ALWAYS scope by tenant + AND deleted_at IS NULL', + ['id' => $id, 'tenant' => $this->identity->tenantId] + ); + } catch (\PDOException $e) { + throw new RepositoryException( + "Failed to find invoice [{$id}]", + layer: 'repository.invoice', + context: ['invoiceId' => $id], + previous: $e, + ); + } + + if ($row === null) { + throw new RepositoryException( + "Invoice [{$id}] not found", + layer: 'repository.invoice', + context: ['invoiceId' => $id], + ); + } + + $lineRows = $this->db->query( + 'SELECT * FROM invoice_line_items WHERE invoice_id = :id ORDER BY sort_order ASC', + ['id' => $id] + ); + + return InvoiceHydrator::hydrate($row, $lineRows); + } + + // ── Persist (insert or update) ────────────────────────────────────────── + public function save(Invoice $invoice): void + { + try { + $data = InvoiceHydrator::dehydrate($invoice); + $affected = $this->db->execute( + 'INSERT INTO invoices + (id, number, tenant_id, client_id, status, + subtotal_cents, tax_cents, currency, due_date, + issued_at, paid_at, cancelled_at, version) + VALUES + (:id, :number, :tenant_id, :client_id, :status, + :subtotal_cents, :tax_cents, :currency, :due_date, + :issued_at, :paid_at, :cancelled_at, 1) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + subtotal_cents = VALUES(subtotal_cents), + tax_cents = VALUES(tax_cents), + issued_at = VALUES(issued_at), + paid_at = VALUES(paid_at), + cancelled_at = VALUES(cancelled_at), + version = version + 1', + $data + ); + } catch (\PDOException $e) { + throw new RepositoryException( + 'Failed to persist invoice', + layer: 'repository.invoice', + context: ['invoiceId' => $invoice->id()->value()], + previous: $e, + ); + } + + // Save line items + $this->saveLineItems($invoice); + } + + // ── Soft delete ───────────────────────────────────────────────────────── + public function softDelete(string $id): void + { + $this->db->execute( + 'UPDATE invoices + SET deleted_at = NOW() + WHERE id = :id + AND tenant_id = :tenant + AND deleted_at IS NULL', + ['id' => $id, 'tenant' => $this->identity->tenantId] + ); + } + + // ── Criteria-based list ───────────────────────────────────────────────── + public function findByCriteria(InvoiceCriteria $c): PaginatedResult + { + [$where, $params] = $this->buildWhere($c); + + $total = (int) $this->db->queryOne( + "SELECT COUNT(*) AS n FROM invoices WHERE {$where}", $params + )['n']; + + $rows = $this->db->query( + "SELECT * FROM invoices + WHERE {$where} + ORDER BY {$this->sanitizeOrder($c->sortBy, $c->sortDir)} + LIMIT :limit + OFFSET :offset", + array_merge($params, [ + 'limit' => $c->perPage, + 'offset' => ($c->page - 1) * $c->perPage, + ]) + ); + + return new PaginatedResult( + data: array_map(fn($r) => InvoiceHydrator::hydrateRow($r), $rows), + total: $total, + page: $c->page, + perPage: $c->perPage, + lastPage: (int) ceil($total / $c->perPage), + ); + } + + // ── Private helpers ───────────────────────────────────────────────────── + private function buildWhere(InvoiceCriteria $c): array + { + $clauses = [ + 'tenant_id = :tenant_id', + 'deleted_at IS NULL', + ]; + $params = ['tenant_id' => $this->identity->tenantId]; + + if ($c->clientId) { + $clauses[] = 'client_id = :client_id'; + $params['client_id'] = $c->clientId; + } + if ($c->status) { + $clauses[] = 'status = :status'; + $params['status'] = $c->status->value; + } + + return [implode(' AND ', $clauses), $params]; + } + + private function sanitizeOrder(string $sortBy, string $sortDir): string + { + $allowed = ['created_at', 'due_date', 'total_cents', 'number', 'status']; + $col = in_array($sortBy, $allowed, true) ? $sortBy : 'created_at'; + $dir = strtoupper($sortDir) === 'ASC' ? 'ASC' : 'DESC'; + return "{$col} {$dir}"; + } +} +``` + +--- + +## Hydrator Pattern + +```php +// Hydrator: translates DB rows ↔ Domain objects. Never in the entity itself. +final class InvoiceHydrator +{ + public static function hydrate(array $row, array $lineRows): Invoice + { + return Invoice::reconstitute( + id: InvoiceId::from($row['id']), + number: InvoiceNumber::of($row['number']), + clientId: ClientId::from($row['client_id']), + status: InvoiceStatus::from($row['status']), + subtotal: Money::fromCents((int) $row['subtotal_cents'], $row['currency']), + tax: Money::fromCents((int) $row['tax_cents'], $row['currency']), + dueDate: new \DateTimeImmutable($row['due_date']), + lineItems: array_map(fn($r) => LineItemHydrator::hydrate($r), $lineRows), + issuedAt: $row['issued_at'] ? new \DateTimeImmutable($row['issued_at']) : null, + paidAt: $row['paid_at'] ? new \DateTimeImmutable($row['paid_at']) : null, + createdAt: new \DateTimeImmutable($row['created_at']), + version: (int) ($row['version'] ?? 1), + ); + } + + public static function dehydrate(Invoice $invoice): array + { + return [ + 'id' => $invoice->id()->value(), + 'number' => $invoice->number()->value(), + 'tenant_id' => $invoice->tenantId(), + 'client_id' => $invoice->clientId()->value(), + 'status' => $invoice->status()->value, + 'subtotal_cents' => $invoice->subtotal()->amount(), + 'tax_cents' => $invoice->tax()->amount(), + 'currency' => $invoice->subtotal()->currency(), + 'due_date' => $invoice->dueDate()->format('Y-m-d'), + 'issued_at' => $invoice->issuedAt()?->format('Y-m-d H:i:s'), + 'paid_at' => $invoice->paidAt()?->format('Y-m-d H:i:s'), + 'cancelled_at' => $invoice->cancelledAt()?->format('Y-m-d H:i:s'), + ]; + } +} +``` + +--- + +## Migration Pattern + +```php +// Migrations implement MigrationContract — run via `php cli.php migrate` +final class CreateInvoicesTable implements MigrationContract +{ + public function up(DatabasePort $db): void + { + $db->execute(" + CREATE TABLE invoices ( + id CHAR(26) NOT NULL PRIMARY KEY, -- ULID + number VARCHAR(32) NOT NULL UNIQUE, + tenant_id CHAR(26) NOT NULL, + client_id CHAR(26) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + subtotal_cents BIGINT NOT NULL DEFAULT 0, + tax_cents BIGINT NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'USD', + due_date DATE NOT NULL, + issued_at DATETIME NULL, + paid_at DATETIME NULL, + cancelled_at DATETIME NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + INDEX idx_tenant_status (tenant_id, status), + INDEX idx_tenant_client (tenant_id, client_id), + INDEX idx_tenant_due (tenant_id, due_date), + INDEX idx_deleted_at (deleted_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + } + + public function down(DatabasePort $db): void + { + $db->execute('DROP TABLE IF EXISTS invoices'); + } +} +``` + +--- + +## Rules for Repository Code + +When writing or reviewing repository code: + +- **DO** include `tenant_id = :tenant` in EVERY query — never omit tenant scoping +- **DO** include `deleted_at IS NULL` in EVERY query unless explicitly fetching deleted records +- **DO** translate `\PDOException` to `RepositoryException` in every try/catch +- **DO** use parameterized queries — never string concatenation in SQL +- **DO** sanitize ORDER BY columns against an allowlist — never pass user input directly to SQL +- **DO** store money as integer cents (BIGINT) — never DECIMAL or FLOAT for money +- **DON'T** put business logic in repositories — no authorization, no domain rules +- **DON'T** return raw arrays from `find()` — return domain objects +- **DON'T** let `\PDOException` propagate to the service layer +- **DON'T** create a repository for child entities — access them through the aggregate root +- **DON'T** use any ORM, Eloquent, or Active Record in the repository layer diff --git a/docs/guides/06_GATEWAY.md b/docs/guides/06_GATEWAY.md new file mode 100644 index 0000000..9359a47 --- /dev/null +++ b/docs/guides/06_GATEWAY.md @@ -0,0 +1,248 @@ +# HKM Kernel — Gateway Layer + +> The Gateway layer wraps **third-party vendor SDKs**. It translates between HKM Kernel's +> domain types and vendor-specific APIs. Vendor exceptions never escape this layer. + +--- + +## Gateway Rules (ABSOLUTE) + +| Rule | Detail | +|---|---| +| ONLY layer using vendor SDKs | No service, repository, or controller imports a vendor SDK | +| Catches ALL vendor exceptions | Translates to `GatewayException` before they escape | +| Zero business logic | No authorization, no domain rules, no event dispatch | +| Accepts domain types as input | `ChargeDTO` with `Money`, not raw `float $amount` | +| Returns domain-friendly result types | `ChargeResult`, not a raw Stripe object | +| Never calls DatabasePort | Gateways are for external APIs, not databases | +| Implements a declared contract | Each gateway implements an interface from `API/Contracts/` | + +--- + +## Canonical Gateway Implementation + +```php +stripe->paymentIntents->create([ + 'amount' => $dto->amount()->amount(), // cents + 'currency' => $this->currency, + 'payment_method' => $dto->paymentMethodId(), + 'confirm' => true, + 'metadata' => ['invoice_id' => $dto->invoiceId()], + ]); + + return match($intent->status) { + 'succeeded' => ChargeResult::success($intent->id), + 'requires_action' => ChargeResult::requiresAction($intent->client_secret), + default => ChargeResult::failed($intent->status), + }; + + } catch (\Stripe\Exception\CardException $e) { + // Translate — never let Stripe exception escape + throw new GatewayException( + 'Card declined: ' . $e->getError()->message, + layer: 'gateway.stripe.charge', + context: [ + 'decline_code' => $e->getError()->decline_code, + 'charge_code' => $e->getError()->code, + ], + previous: $e, + ); + + } catch (\Stripe\Exception\InvalidRequestException $e) { + throw new GatewayException( + 'Invalid payment request: ' . $e->getMessage(), + layer: 'gateway.stripe.charge', + context: ['stripe_message' => $e->getMessage()], + previous: $e, + ); + + } catch (\Stripe\Exception\ApiConnectionException $e) { + throw new GatewayException( + 'Stripe API unreachable — payment could not be processed', + layer: 'gateway.stripe.connection', + context: ['endpoint' => 'payment_intents'], + previous: $e, + ); + + } catch (\Stripe\Exception\RateLimitException $e) { + throw new GatewayException( + 'Stripe rate limit exceeded — retry after delay', + layer: 'gateway.stripe.rate_limit', + context: [], + previous: $e, + ); + } + } + + public function refund(string $transactionId, Money $amount): RefundResult + { + try { + $refund = $this->stripe->refunds->create([ + 'payment_intent' => $transactionId, + 'amount' => $amount->amount(), + ]); + return RefundResult::success($refund->id); + + } catch (\Stripe\Exception\InvalidRequestException $e) { + throw new GatewayException( + 'Refund failed: ' . $e->getMessage(), + layer: 'gateway.stripe.refund', + context: ['transactionId' => $transactionId], + previous: $e, + ); + } + } +} +``` + +--- + +## Gateway Contract Pattern + +```php +// Always define a contract in API/Contracts/ — Service injects the interface, not the class +interface PaymentGatewayContract +{ + public function charge(ChargeDTO $dto): ChargeResult; + public function refund(string $transactionId, Money $amount): RefundResult; +} + +// Result types — domain-friendly, no vendor types +final readonly class ChargeResult +{ + private function __construct( + private bool $success, + private ?string $transactionId, + private ?string $clientSecret, // for 3D Secure redirect + private ?string $failureReason, + ) {} + + public static function success(string $transactionId): self + { + return new self(true, $transactionId, null, null); + } + + public static function requiresAction(string $clientSecret): self + { + return new self(false, null, $clientSecret, null); + } + + public static function failed(string $reason): self + { + return new self(false, null, null, $reason); + } + + public function isSuccess(): bool { return $this->success; } + public function transactionId(): ?string { return $this->transactionId; } + public function clientSecret(): ?string { return $this->clientSecret; } + public function failureReason(): ?string { return $this->failureReason; } + public function requiresAction(): bool { return $this->clientSecret !== null; } +} +``` + +--- + +## Circuit Breaker Integration + +```php +// Wrap a gateway with a circuit breaker to prevent cascade failures +final class ResilientPdfGateway implements PdfGatewayContract +{ + public function __construct( + private readonly PdfGatewayContract $inner, + private readonly CircuitBreaker $breaker, + ) {} + + public function generate(Invoice $invoice): string + { + return $this->breaker->call( + fn() => $this->inner->generate($invoice) + ); + // CircuitBreaker throws CircuitOpenException if circuit is OPEN + // Service layer catches this and returns a degraded response + } +} +``` + +--- + +## Gateway Naming Conventions + +``` +Infrastructure/Gateways/ +├── StripePaymentGateway.php → implements PaymentGatewayContract +├── SendGridMailGateway.php → implements MailGatewayContract +├── TwilioSmsGateway.php → implements SmsGatewayContract +├── CloudinaryStorageGateway.php → implements StorageGatewayContract +├── WkhtmltopdfGateway.php → implements PdfGatewayContract +└── GoogleMapsGateway.php → implements GeocoderContract +``` + +Pattern: `{VendorName}{Domain}Gateway` — vendor first, domain second. + +--- + +## Fake Gateway for Testing + +```php +// In tests/Fixtures/ — implements the same contract as the real gateway +final class FakePdfGateway implements PdfGatewayContract +{ + private array $generated = []; + private bool $shouldFail = false; + + public function generate(Invoice $invoice): string + { + if ($this->shouldFail) { + throw new GatewayException('PDF generation failed (fake)', layer: 'gateway.pdf.fake'); + } + $path = 'fake-pdfs/' . $invoice->id()->value() . '.pdf'; + $this->generated[$invoice->id()->value()] = $path; + return $path; + } + + // Test helpers + public function failOnNextCall(): void { $this->shouldFail = true; } + public function wasCalledFor(string $invoiceId): bool { return isset($this->generated[$invoiceId]); } + public function generatedPaths(): array { return $this->generated; } +} +``` + +--- + +## Rules for Gateway Code + +When writing or reviewing gateway code: + +- **DO** catch every possible vendor exception type explicitly +- **DO** translate ALL vendor exceptions to `GatewayException` before they escape +- **DO** accept domain types as parameters (`Money`, `InvoiceId`) — not raw primitives +- **DO** return domain-friendly result types — never raw vendor response objects +- **DO** implement a contract interface from `API/Contracts/` +- **DO** include `layer:` context like `'gateway.stripe.charge'` in every `GatewayException` +- **DON'T** import the vendor SDK anywhere except in the Gateway class +- **DON'T** put business logic in gateways — no authorization, no domain rules +- **DON'T** call `DatabasePort` from a gateway +- **DON'T** let `\Exception`, `\RuntimeException`, or any vendor exception propagate out +- **DON'T** return `null` on failure — throw `GatewayException` or return a failure result type diff --git a/docs/guides/07_CONTROLLER.md b/docs/guides/07_CONTROLLER.md new file mode 100644 index 0000000..48ba1dd --- /dev/null +++ b/docs/guides/07_CONTROLLER.md @@ -0,0 +1,319 @@ +# HKM Kernel — HTTP Controller Layer + +> Controllers are **thin wrappers**. They translate HTTP requests into DTOs, +> call a service, and translate the result into an HTTP response. +> Three lines is the ideal controller method. Five lines is acceptable. More is a design smell. + +--- + +## Controller Rules (ABSOLUTE) + +| Rule | Detail | +|---|---| +| Calls Service ONLY via published contract | Never Repository, Gateway, or Domain directly | +| Input → DTO conversion here | `fromRequest()` validates and shapes the input | +| Returns `Response` objects | Never echoes, exits, or dies | +| Zero business logic | If it isn't HTTP translation, it belongs in the Service | +| Zero authorization decisions | Authorization belongs in the Service layer | +| Receives Identity via constructor | Injected by scoped container — set by SecurityGateway | + +--- + +## Canonical Controller + +```php +service->list($dto); + return Response::json($result->toArray()); + } + + // POST /api/invoices + public function create(Request $request): Response + { + $dto = CreateInvoiceDTO::fromRequest($request); // validation here + $result = $this->service->create($dto); // business logic in service + return Response::json($result->toArray(), 201); + } + + // GET /api/invoices/{id} + public function show(Request $request, string $id): Response + { + $result = $this->service->find($id); + return Response::json($result->toArray()); + } + + // PUT /api/invoices/{id} + public function update(Request $request, string $id): Response + { + $dto = UpdateInvoiceDTO::fromRequest($request, $id); + $result = $this->service->update($dto); + return Response::json($result->toArray()); + } + + // DELETE /api/invoices/{id} + public function destroy(Request $request, string $id): Response + { + $this->service->delete($id); + return Response::empty(204); + } +} +``` + +--- + +## Response Factory Methods + +```php +Response::json($data, 200) // 200 OK — data as JSON body +Response::json($data, 201) // 201 Created +Response::empty(204) // 204 No Content — no body +Response::notFound() // 404 {"error":{"code":"not_found",...}} +Response::unauthorized() // 401 +Response::forbidden() // 403 +Response::unprocessable($errors) // 422 {"error":{"code":"validation_failed","fields":{...}}} +Response::serverError() // 500 +Response::redirect($url, 302) // 302 Redirect +Response::created($data, $location) // 201 + Location header +Response::download($path, $name) // file download (sendfile on Swoole) +Response::stream($callback) // chunked streaming (both transports) +``` + +--- + +## Standard Response Shapes + +### Success (200/201) +```json +{ + "invoiceId": "inv_01H9X...", + "number": "INV-2025-000001", + "status": "issued", + "total": 300.00, + "currency": "USD" +} +``` + +### Paginated List (200) +```json +{ + "data": [ { ... }, { ... } ], + "meta": { "total": 142, "page": 2, "per_page": 20, "last_page": 8 }, + "links": { "prev": "...", "next": "...", "first": "...", "last": "..." } +} +``` + +### Validation Error (422) +```json +{ + "error": { + "code": "validation.failed", + "message": "The request data is invalid.", + "requestId": "20250615-a3f8b2c1", + "fields": { + "dueDate": ["The due date must be a future date."], + "lineItems": ["At least one line item is required."], + "lineItems.0.unitPrice": ["Unit price must be greater than zero."] + } + } +} +``` + +### Service / Auth Error (4xx/5xx) +```json +{ + "error": { + "code": "invoice.not_found", + "message": "Invoice [inv_123] was not found.", + "requestId": "20250615-b4c9d3e2" + } +} +``` + +--- + +## Request (most-used surface) + +`Request` is a final, IMMUTABLE value object (Symfony HttpFoundation under the hood — see +the HTTP layer usage in the [project README](../../README.md) for the full method list). The +methods controllers/DTOs reach for: + +```php +$request->method(); // 'POST' (upper-case) +$request->path(); // '/api/invoices' (leading slash, no query) +$request->body(); // parsed BODY only (JSON/form), excludes query +$request->all(); // body + query merged +$request->input($key, $default); // single value (body or query) +$request->only([...]); $request->except([...]); +$request->boolean($k); $request->integer($k); $request->float($k); $request->string($k); +$request->query($key, $default); // query-string value +$request->header($name); // ?string, case-insensitive +$request->cookie($name); // ?string +$request->bearerToken(); // Authorization: Bearer … +$request->file($key); // ?UploadedFile (FPM + Swoole safe) +$request->expectsJson(); // negotiate JSON vs HTML +$request->identity(); // ?Identity (from SecurityGateway) +$request->attribute('domain'); // ?DomainContext (from the pipeline) + +// URL / negotiation helpers: +$request->uri(); // immutable PSR-7 Uri → withPath()/withQuery() +$request->site()->to('auth/callback'); // absolute URL, host from the request +$request->negotiate()->language(['en','fr']); +``` + +Immutable mutators return a NEW request — `$request = $request->withAttribute('k', $v)`, +`->merge([...])`, `->withHeader(...)`. NEVER mutate in place (Swoole-safety). + +--- + +## DTO Validation in `fromRequest()` + +```php +final readonly class CreateInvoiceDTO +{ + public static function fromRequest(Request $request): self + { + $data = $request->body(); + $errors = []; + + // Validate each field — collect ALL errors before throwing + if (empty($data['clientId'])) { + $errors['clientId'] = 'Client ID is required'; + } + + if (empty($data['dueDate'])) { + $errors['dueDate'] = 'Due date is required'; + } else { + try { + $due = new \DateTimeImmutable($data['dueDate']); + if ($due <= new \DateTimeImmutable()) { + $errors['dueDate'] = 'Due date must be in the future'; + } + } catch (\Exception) { + $errors['dueDate'] = 'Due date must be a valid date (YYYY-MM-DD)'; + } + } + + if (empty($data['lineItems'])) { + $errors['lineItems'] = 'At least one line item is required'; + } + + if (!empty($errors)) { + throw new ValidationException($errors); // → 422 response + } + + return new self( + clientId: $data['clientId'], + dueDate: $data['dueDate'], + lineItems: $data['lineItems'], + ); + } +} +``` + +--- + +## File Upload in Controller + +```php +public function upload(Request $request): Response +{ + $file = $request->file('document'); + + if (!$file || !$file->isValid()) { + return Response::unprocessable(['document' => 'Invalid or missing file']); + } + + // Pass the file to the service — service validates type/size + $result = $this->service->store(new StoreDocumentDTO( + file: $file, + uploadedBy: $request->identity()->userId, + )); + + return Response::json(['documentId' => $result->id, 'path' => $result->path], 201); +} +``` + +--- + +## Base Controllers (project layer — `Project\Http\Controllers\`) + +Two optional base classes live in `projects/Http/Controllers/` (namespace +`Project\`). They are project-layer, NOT kernel, because view rendering and +cookies are plugin concerns — the kernel stays renderer-agnostic. + +| Base | Use for | Coupling | +|---|---|---| +| `ApiController` | JSON endpoints | Pure kernel types (no plugin) | +| `ViewController` | HTML/view endpoints | Injects `ViewRendererContract` (View plugin) | + +`ApiController` helpers: `ok()`, `created()`, `accepted()`, `noContent()`, +`paginated()`, `okOrNotFound()`, `notFound()`, `forbidden()`, `unprocessable()`, +`identity()`. `ViewController` helpers: `view()`, `viewNotFound()`, `redirect()`, +`back()`. + +Both `use InteractsWithCookies` (trait wrapping every public `CookieJar` method: +`cookie()`, `queueCookie()`, `rememberCookie()`, `forgetCookie()`, +`hasQueuedCookie()`, `decryptCookie()`, `cookieJar()`). + +### RequestAware — actions take route params ONLY (no `$request`) + +Both bases implement the kernel contract +`AlfacodeTeam\…\Kernel\Http\Contracts\RequestAware` (`setRequest(Request): static`). +`ExecuteStage` detects it and: + +- calls `setRequest($request)` with the container-bearing request BEFORE the action, then +- invokes the action as `$method(...$routeParams)` — WITHOUT `$request`. + +Plain controllers (not `RequestAware`) keep the conventional +`$method($request, ...$params)` signature — fully backward compatible. + +```php +use Project\Http\Controllers\ApiController; + +final class CartController extends ApiController // RequestAware +{ + public function show(string $id): Response // route param only — no $request + { + $this->queueCookie('last_viewed', $id); // request injected by the kernel + return $this->okOrNotFound($this->cart->find($id)?->toArray()); + } +} +``` + +The raw request is still available inside the action as `$this->request`; any +cookie helper also accepts an explicit `?Request` override. + +--- + +## Rules for Controller Code + +When writing or reviewing controller code: + +- **DO** keep every controller method to 3–5 lines: DTO → service → response +- **DO** put all validation logic in `DTO::fromRequest()` — not in the controller method +- **DO** use `Response::json($data, 201)` for create operations +- **DO** use `Response::empty(204)` for delete and void operations +- **DON'T** inject `InvoiceRepository` or any repository into a controller +- **DON'T** put authorization logic in controllers — it belongs in the service +- **DON'T** put business logic in controllers — even one if-statement is too much +- **DON'T** catch exceptions in controllers — let the `ErrorStage` handle them +- **DON'T** use `echo`, `print`, `exit`, `die`, or `header()` in controllers +- **DON'T** instantiate domain entities in controllers diff --git a/docs/guides/08_EVENTS.md b/docs/guides/08_EVENTS.md new file mode 100644 index 0000000..b1a5c78 --- /dev/null +++ b/docs/guides/08_EVENTS.md @@ -0,0 +1,264 @@ +# HKM Kernel — Event System + +> HKM Kernel uses **two distinct event types** with different semantics. +> Conflating them causes phantom events for transactions that later roll back. + +--- + +## Two Event Types — Critical Distinction + +| Aspect | Domain Event | Integration Event | +|---|---|---| +| **Scope** | Internal to the module | Cross-module or cross-service | +| **Timing** | Collected DURING transaction | Dispatched AFTER commit only | +| **On rollback** | DISCARDED — no phantom events | N/A — never dispatched | +| **Schema** | Module-internal DTO | Versioned public contract | +| **How to create** | `entity->record(new SomeEvent())` | `new SomeIntegrationEvent(...)` | +| **How to dispatch** | `collector->collect($event)` | `eventBus->dispatch($event)` | +| **Subscribers** | Projections inside same module | Any module that declares `listens[]` | +| **Transport** | In-memory only | Sync or async queue | + +--- + +## Domain Event Pattern + +```php +// Location: Domain/Events/InvoiceCreatedDomainEvent.php +// Rules: +// 1. Named in PAST TENSE +// 2. final readonly class — immutable +// 3. No external dependencies +// 4. Carries MINIMUM data for in-module listeners + +final readonly class InvoiceCreatedDomainEvent implements DomainEventContract +{ + public function __construct( + public readonly InvoiceId $invoiceId, + public readonly ClientId $clientId, + public readonly Money $total, + public readonly DateTimeImmutable $occurredAt, + ) {} +} +``` + +## Domain Event Flow + +``` +Entity.issue() + │ + ├── $this->record(new InvoiceIssuedDomainEvent($this)) + │ ↓ + │ Stored in $this->domainEvents array + │ +Service.create() + │ + ├── foreach ($invoice->releaseEvents() as $event) + │ $this->collector->collect($event) ← buffered + │ + ├── $this->repository->save($invoice) + │ + ├── $this->transaction->commit() ← if success + │ ↓ + │ foreach ($collector->release() as $event) + │ $this->projection->on($event) ← applied in-transaction + │ + └── on failure: $this->collector->discard() ← NO phantom events +``` + +--- + +## Integration Event Pattern + +```php +// Location: API/IntegrationEvents/InvoiceCreatedIntegrationEvent.php +// Rules: +// 1. Versioned — version field is mandatory +// 2. Stable public schema — other modules depend on this +// 3. Contains all data subscribers need (no further DB queries required) +// 4. Dispatched ONLY after successful transaction commit + +final readonly class InvoiceCreatedIntegrationEvent implements IntegrationEventContract +{ + public string $version = '1.0'; + + public function __construct( + // Use primitive types (string, int, float) — not domain objects + // Other modules may not have your domain value objects + public readonly string $invoiceId, + public readonly string $clientId, + public readonly string $tenantId, + public readonly float $amount, + public readonly string $currency, + public readonly string $dueDate, // 'Y-m-d' + public readonly string $occurredAt, // RFC3339 + ) {} + + public function name(): string { return 'invoice.created'; } + public function version(): string { return $this->version; } + public function payload(): array { return get_object_vars($this); } +} +``` + +## Integration Event Dispatch (Service Layer) + +```php +// CORRECT: dispatched AFTER the transaction commits — outside try/catch +$this->transaction->begin(); +try { + $invoice = Invoice::create(/* ... */); + $this->repository->save($invoice); + $this->transaction->commit(); +} catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); + throw $e; +} + +// ← Only reach here on successful commit +$this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent( + invoiceId: $invoice->id()->value(), + clientId: $dto->clientId, + tenantId: $this->identity->tenantId, + amount: $invoice->total()->value(), + currency: $invoice->total()->currency(), + dueDate: $invoice->dueDate()->format('Y-m-d'), + occurredAt: (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339), +)); +``` + +--- + +## Subscribing to Integration Events + +```json +// module.json — declare which events you listen to +{ + "listens": ["invoice.created", "invoice.paid"] +} +``` + +```php +// Provider.php boot() — register the listener +public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void +{ + $events->subscribe('invoice.created', InvoiceCreatedListener::class); + $events->subscribe('invoice.paid', InvoicePaidListener::class); +} +``` + +```php +// The listener class +final class InvoiceCreatedListener implements EventListenerContract +{ + public function __construct( + private readonly NotificationService $notifications, + ) {} + + public function handle(IntegrationEventContract $event): void + { + // Always check version — schema may evolve + if ($event->version() !== '1.0') { + // handle or skip unknown versions gracefully + return; + } + + $payload = InvoiceCreatedPayload::from($event->payload()); + + $this->notifications->sendInvoiceConfirmation( + userId: $payload->clientId, + invoiceId: $payload->invoiceId, + amount: $payload->amount, + ); + } +} +``` + +--- + +## Event Versioning — Adding Fields Without Breaking + +```php +// v1.0 — original +final readonly class InvoiceCreatedIntegrationEvent +{ + public string $version = '1.0'; + public function __construct( + public readonly string $invoiceId, + public readonly string $clientId, + public readonly float $amount, + ) {} +} + +// v2.0 — adds lineItems without removing existing fields +final readonly class InvoiceCreatedIntegrationEvent +{ + public string $version = '2.0'; + public function __construct( + public readonly string $invoiceId, + public readonly string $clientId, + public readonly float $amount, + public readonly array $lineItems, // NEW in 2.0 + ) {} +} + +// Subscriber handles both versions +public function handle(IntegrationEventContract $event): void +{ + match ($event->version()) { + '1.0' => $this->handleV1($event->payload()), + '2.0' => $this->handleV2($event->payload()), + default => null, // ignore unknown versions — never throw + }; +} +``` + +--- + +## Projection Pattern (In-Module Domain Event Listener) + +```php +// Projections update read models from domain events — inside the same transaction +final class InvoiceSummaryProjection +{ + public function __construct( + private readonly DatabasePort $db, + ) {} + + public function on(DomainEventContract $event): void + { + match (true) { + $event instanceof InvoiceCreated => $this->onCreated($event), + $event instanceof InvoiceIssued => $this->onIssued($event), + $event instanceof InvoicePaid => $this->onPaid($event), + default => null, + }; + } + + private function onCreated(InvoiceCreated $e): void + { + $this->db->execute( + 'INSERT INTO invoice_summaries (invoice_id, client_id, total_cents, status) + VALUES (:id, :cid, :total, :status)', + ['id' => $e->invoiceId->value(), 'cid' => $e->clientId->value(), + 'total' => $e->total->amount(), 'status' => 'draft'] + ); + } +} +``` + +--- + +## Rules for Event Code + +When writing or reviewing event code: + +- **DO** dispatch integration events AFTER the transaction commits — never inside +- **DO** use `collector->discard()` in every rollback path +- **DO** include a `version` field on every integration event +- **DO** use primitive types in integration event constructors (string, int, float) +- **DO** check version in listeners before reading payload fields +- **DON'T** dispatch a domain event as an integration event — they are different classes +- **DON'T** dispatch integration events inside a try/catch block +- **DON'T** dispatch events from repositories or gateways +- **DON'T** use domain objects (entities, value objects) in integration event constructors +- **DON'T** catch exceptions thrown by event listeners — isolation is handled by the EventBus diff --git a/docs/guides/09_SECURITY.md b/docs/guides/09_SECURITY.md new file mode 100644 index 0000000..527293a --- /dev/null +++ b/docs/guides/09_SECURITY.md @@ -0,0 +1,354 @@ +# HKM Kernel — Security Layer + +> The SecurityGateway runs **before any module loads**. A denied request never touches +> module code, costs microseconds, and returns immediately. + +--- + +## Security Architecture + +The kernel's `SecurityGateway` runs an ordered list of `SecurityLayerContract` layers +(configured via `Kernel::withSecurity([...])`). Layers run in declaration order and the +**first `deny()` short-circuits** the rest — nothing further runs. + +**What the kernel ships:** exactly one layer — `CsrfTokenLayer` (stateless HMAC-signed CSRF). +Everything else is contributed by plugins: + +- **Authentication** (JWT / personal-access-token verification) is added by the **Auth + plugin** — `JwtAuthLayer` and `PersonalAccessTokenLayer`, which you place in + `withSecurity([...])`. The kernel intentionally ships no token validator. +- **Rate limiting** and **IP filtering** are NOT gateway layers — they are **SecurityFilters + plugin** route filters (`throttle` → `ApiRateLimitStage`, `shield` → `ShieldStage`) that run + inside the HTTP pipeline once a route opts in. + +``` +Request arrives + │ + ▼ +SecurityGateway (always resident — runs your withSecurity([...]) layers in order) + │ + ├── CsrfTokenLayer.check() ← kernel: stateless HMAC CSRF (state-changing verbs) + │ DENY → 403 immediately ──────────────────────────────────────► + │ + └── [Auth plugin] JwtAuthLayer / PersonalAccessTokenLayer.check() ← token verify + DENY → 401 immediately ──────────────────────────────────────► + │ + CLEARED → Identity attached to Request + │ + ▼ + after.security hooks ← plugin-registered stages + │ + ▼ (later, after.load) route filters: throttle / shield / auth + Rest of pipeline +``` + +**Order matters:** layers run in the order you list them in `withSecurity([...])`, and the +first deny wins — so put the cheapest / most common denials first. + +--- + +## SecurityLayerContract + +```php +interface SecurityLayerContract +{ + /** + * Check the request. Return allow or deny. + * On allow: optionally attach or augment Identity on the request. + * On deny: return immediately — no further layers run. + */ + public function check(Request $request): SecurityVerdict; +} +``` + +--- + +## SecurityVerdict + +```php +final class SecurityVerdict +{ + // Allow — request proceeds to the next security layer or pipeline + public static function allow(Request $request): self; + + // Deny — pipeline stops here, HTTP error returned immediately + public static function deny(int $statusCode, string $reason): self; + + public function isDenied(): bool; + public function isAllowed(): bool; + public function identity(): ?Identity; + public function statusCode(): int; // 401 | 403 | 429 + public function reason(): string; +} +``` + +--- + +## Identity — The Security Passport + +```php +final readonly class Identity +{ + public function __construct( + public readonly string $userId, + public readonly string $tenantId, + public readonly array $roles, // ['admin', 'user'] + public readonly array $permissions, // ['invoice:create', 'invoice:view-all'] + public readonly string $tokenType, // 'jwt' | 'api_key' | 'session' + ) {} + + public function hasRole(string $role): bool + { + return in_array($role, $this->roles, true); + } + + public function hasPermission(string $perm): bool + { + return in_array($perm, $this->permissions, true); + } + + public function isGuest(): bool + { + return empty($this->userId); + } +} +``` + +**Identity is set once by the SecurityGateway and never modified downstream.** +All service-level authorization uses `$this->identity->hasPermission()` or `->hasRole()`. + +--- + +## Security capabilities — where each lives + +| Capability | Where it lives | How it runs | +|---|---|---| +| **CSRF** | Kernel — `CsrfTokenLayer` | Gateway layer (below) | +| **Authentication** (JWT / PAT / session) | **Auth plugin** — `JwtAuthLayer`, `PersonalAccessTokenLayer`, `SessionAuthStage` | Gateway layers you add in `withSecurity([...])`; session via `after.load` stage | +| **Rate limiting** | **SecurityFilters plugin** — `ApiRateLimitStage` | `throttle:MAX,MINUTES` route filter (sliding window via `CachePort`) | +| **IP allow/deny + shielding** | **SecurityFilters plugin** — `ShieldStage` | `shield` route filter | +| **CORS + secure headers** | **SecurityFilters plugin** — `CorsStage`, `SecurityHeadersStage` | `after.security` global hooks | + +> The kernel deliberately ships **only** `CsrfTokenLayer`. There is no kernel `FirewallLayer` +> or `RateLimiterLayer` — rate limiting and IP filtering are opt-in route filters from the +> SecurityFilters plugin, and token authentication comes from the Auth plugin. + +### CsrfTokenLayer (the one kernel layer) + +```php +// Stateless HMAC-signed CSRF token (the WordPress-nonce model) — NOT plain +// double-submit. No token is stored and NO cookie value is trusted as the +// token; a valid token cannot be forged without APP_KEY, so cookie injection +// (sibling sub-domain / MITM) cannot bypass it. +// +// token = tick . "." . hmac(APP_KEY, tick|binding|action) +// +// Safe methods (GET/HEAD/OPTIONS) + exemptPaths bypass; an empty APP_KEY +// fail-closes (denies). lifetime is in SECONDS (default 43200 = 12h). +// Mint with CsrfTokenLayer::make(), verify out-of-band with ::valid(). +// +// Full guide + framework-level usage: docs/guides/21_CSRF.md +new CsrfTokenLayer( + headerName: 'X-CSRF-Token', + formField: '_csrf_token', + bindCookie: 'hkm_session', // pin to the HttpOnly session cookie ('' = unbound) + lifetime: 43200, // seconds + exemptPaths: ['/api'], // machine-to-machine endpoints with their own auth +); +``` + +### Auth plugin layers — `JwtAuthLayer` / `PersonalAccessTokenLayer` + +```php +// Provided by Plugins\Auth (the kernel ships NO JWT code). You add them to +// withSecurity([...]) alongside CsrfTokenLayer. +// JwtAuthLayer — verifies a Bearer JWT (iss/aud/exp, jti deny-list), +// builds Identity from claims (incl. the `tnt` tenant claim). +// PersonalAccessTokenLayer — verifies long-lived personal access tokens. +// Session-based auth is a separate after.load stage (SessionAuthStage), not a gateway layer. +// All signature/token comparisons are timing-safe (hash_equals()). +``` + +### Tenant context on the Identity (`tnt` claim — multi-tenant control plane) + +`Identity.tenantId` carries the authenticated tenant for database-per-tenant +routing. `Plugins\Auth\Security\JwtAuthLayer` reads it from the signed **`tnt`** +claim (legacy `tenant` accepted for BC) and defaults it to **`''` (empty)**: + +```php +$tenant = (string) ($claims['tnt'] ?? $claims['tenant'] ?? ''); +$identity = new Identity(userId: $claims['sub'], tenantId: $tenant, /* … */); +``` + +- **Empty tenant claim ≠ central access.** `AuthService::issueJwt()` mints NO + tenant at login — but `TenantContextStage` routes STRICTLY: with no tenant + claim, the remembered cookie hint and then the Host identifier must still + resolve one, or the request 404s (no unscoped passthrough). Login/picker/public + pages therefore live on a host that is itself assigned to a tenant; + control-plane reads pin the central connection explicitly. +- **Non-empty tenant** is routed to its isolated database by + `Plugins\Tenancy`'s `TenantContextStage` (hooked `after.load`), which rebinds + `DatabasePort` in the request container. Mint a tenant-scoped token ONLY after + the user selects a tenant and membership is verified against the central + `user_tenants` table; re-check membership each request so a revoked seat loses + access before the token expires. +- **Control-plane plugins pin to central.** `Plugins\User` (the global `users` + identity table) and `Plugins\Auth` (`personal_access_tokens`) resolve the + `DatabaseConnectionManagerContract` **default** connection, NOT the per-request + (tenant-rebound) `DatabasePort` — so identity I/O never lands in a tenant DB. + Because the `tnt` claim is signed it cannot be forged, but it is still a hint, + not authority: authorization keys on `(userId, tenantId, role/permission)`. + +--- + +## Writing a Custom Security Layer + +```php +final class RequireVerifiedEmailLayer implements SecurityLayerContract +{ + public function __construct( + private readonly CachePort $cache, + ) {} + + public function check(Request $request): SecurityVerdict + { + $identity = $request->identity(); + + // If no identity yet (guest or public route), allow through + if (!$identity || $identity->isGuest()) { + return SecurityVerdict::allow($request); + } + + // Check email verification from cache (fast path) + $verified = $this->cache->get("email_verified:{$identity->userId}"); + + if ($verified === null) { + // Cache miss — check is done at service layer for first request + return SecurityVerdict::allow($request); + } + + if (!$verified) { + return SecurityVerdict::deny(403, 'Email address is not verified'); + } + + return SecurityVerdict::allow($request); + } +} +``` + +Register layers in the bootstrap (order = run order; first deny wins): +```php +$kernel->withSecurity([ + new CsrfTokenLayer(headerName: 'X-CSRF-Token', /* … */), // kernel — CSRF + new JwtAuthLayer(/* … */), // Auth plugin — token verify + new PersonalAccessTokenLayer(/* … */), // Auth plugin — PATs + new RequireVerifiedEmailLayer($cache), // ← your custom layer last +]); +``` + +Rate limiting and IP filtering are not added here — a route opts into them with the +SecurityFilters `throttle` / `shield` filters (see `20_FIRST_PARTY_PLUGINS.md`). + +--- + +## JWT Token Lifecycle + +``` +Login + │ + ▼ +AuthService.login(LoginDTO) + ├── Verify password (bcrypt.verify — constant time) + ├── Issue access token (JWT, 1 hour TTL, signed with HS256) + ├── Issue refresh token (JWT, 7 days TTL, stored in Redis) + └── Return {access_token, refresh_token, expires_in} + +Subsequent requests + │ + ▼ +JwtAuthLayer.check() (Auth plugin) + ├── Extract Bearer token from Authorization header + ├── Decode header + payload (base64url) + ├── Verify signature with hash_equals() — TIMING SAFE + ├── Check exp claim + └── Build Identity from claims → attach to Request + +Refresh + │ + ▼ +AuthService.refresh(RefreshTokenDTO) + ├── Look up refresh token in Redis + ├── Check if already rotated (reuse detection) + ├── Mark current token as rotated + ├── Issue new token pair + └── Return new {access_token, refresh_token} +``` + +--- + +## Rate Limit Configuration + +```php +// config/security.php +return [ + 'limits' => [ + 'global_ip' => ['max' => 1000, 'window' => 60, 'strategy' => 'sliding_window'], + 'per_user' => ['max' => 300, 'window' => 60, 'strategy' => 'sliding_window'], + 'routes' => [ + 'POST /api/auth/login' => ['max' => 5, 'window' => 60], + 'POST /api/auth/forgot' => ['max' => 3, 'window' => 3600], + 'POST /api/payments' => ['max' => 30, 'window' => 60], + ], + ], + 'public_routes' => [ + 'POST /api/auth/login', + 'POST /api/auth/register', + 'GET /api/health', + ], +]; +``` + +--- + +## Service-Level Authorization Pattern + +```php +// After SecurityGateway clears the request, authorization happens in the Service layer. +// SecurityGateway: WHO is this? (authentication) +// Service layer: WHAT can they do? (authorization) + +public function delete(string $invoiceId): void +{ + $invoice = $this->repository->find($invoiceId); + + // RBAC: does the user have the permission at all? + if (!$this->identity->hasPermission('invoice:delete')) { + throw new ServiceException('invoice.delete.unauthorized'); + } + + // ABAC: does the user own this specific resource? + if ($invoice->clientId()->value() !== $this->identity->userId + && !$this->identity->hasPermission('invoice:delete-any')) { + throw new ServiceException('invoice.delete.unauthorized'); + } + + $this->repository->softDelete($invoiceId); +} +``` + +--- + +## Security code rules + +When writing or reviewing security code: + +- **DO** place custom SecurityLayer implementations in the Project layer or a plugin (e.g. Auth) +- **DO** use `hash_equals()` for all token and signature comparisons — never `===` or `==` +- **DO** order `withSecurity([...])` cheapest-deny-first; the first `deny()` short-circuits the rest +- **DO** attach `Identity` to the request in the authenticating layer (Auth's `JwtAuthLayer`), not elsewhere +- **DO** check authorization in the Service layer, not in the SecurityGateway layers +- **DON'T** access `DatabasePort` from a SecurityLayer — use `CachePort` only +- **DON'T** put authorization (what they can do) in the SecurityGateway — that's authentication (who they are) +- **DON'T** throw exceptions from SecurityLayer — return `SecurityVerdict::deny()` +- **DON'T** use `===` for timing-sensitive comparisons (HMAC, token comparison) +- **DON'T** log passwords, tokens, or secrets anywhere in the security pipeline diff --git a/docs/guides/10_TESTING.md b/docs/guides/10_TESTING.md new file mode 100644 index 0000000..2541f03 --- /dev/null +++ b/docs/guides/10_TESTING.md @@ -0,0 +1,349 @@ +# HKM Kernel — Testing Layer + +> HKM Kernel's architecture is **designed for testability**. The domain has zero external +> dependencies. Port interfaces allow fake implementations. Scoped containers let you +> test modules in complete isolation. + +--- + +## Test Layer Organization + +``` +modules/{name}/tests/ +├── Unit/ +│ ├── Domain/ ← Pure PHP — no fakes needed — runs in < 1ms +│ │ ├── {Entity}Test.php +│ │ └── {ValueObject}Test.php +│ └── Application/ +│ └── {Name}ServiceTest.php ← Uses port fakes — no real DB/network +├── Integration/ +│ ├── Persistence/ +│ │ └── {Name}RepositoryTest.php ← Real MySQL in CI +│ └── Http/ +│ └── {Name}ControllerTest.php ← TestKernel + real module +└── Fixtures/ + ├── InMemory{Name}Repository.php + ├── Fake{Name}Service.php + ├── FakeIntegrationEventBus.php + └── FakeTransactionManager.php +``` + +--- + +## Test Double Taxonomy + +| Type | Has logic | Asserts calls | When to use | +|---|---|---|---| +| **Stub** | Minimal (returns canned value) | No | Predictable return scenarios | +| **Fake** | Yes (working simplified impl) | No | Service layer tests — replace all ports | +| **Spy** | Passes through (records calls) | Yes | Verify events/calls were made | +| **Mock** | No (pre-programmed) | Yes | Strict call verification (rare) | +| **Dummy** | None | No | Satisfying a constructor that won't be called | + +--- + +## Domain Unit Tests — No Fakes Needed + +```php +assertEquals(InvoiceStatus::DRAFT, $this->makeInvoice()->status()); + } + + public function test_add_line_item_updates_subtotal(): void + { + $invoice = $this->makeInvoice(); + $invoice->addLineItem(LineItem::make('Widget', 2, Money::of(50, 'USD'))); + $invoice->addLineItem(LineItem::make('Service', 1, Money::of(200, 'USD'))); + $this->assertEquals(300.00, $invoice->subtotal()->value()); + } + + public function test_cannot_issue_without_line_items(): void + { + $this->expectException(\DomainException::class); + $this->makeInvoice()->issue(); + } + + public function test_issue_records_domain_event(): void + { + $invoice = $this->makeInvoice(); + $invoice->addLineItem(LineItem::make('Widget', 1, Money::of(100, 'USD'))); + $invoice->releaseEvents(); // clear creation event + $invoice->issue(); + + $events = $invoice->releaseEvents(); + $this->assertCount(1, $events); + $this->assertInstanceOf(InvoiceIssuedDomainEvent::class, $events[0]); + } + + public function test_release_events_clears_buffer(): void + { + $invoice = $this->makeInvoice(); + $invoice->releaseEvents(); + $this->assertEmpty($invoice->releaseEvents()); + } +} +``` + +--- + +## Service Integration Tests — With Port Fakes + +```php +repo = new InMemoryInvoiceRepository(); + $this->txn = new FakeTransactionManager(); + $this->bus = new FakeIntegrationEventBus(); + $this->collector = new DomainEventCollector(); + + $this->sut = new InvoiceService( + repository: $this->repo, + transaction: $this->txn, + collector: $this->collector, + eventBus: $this->bus, + identity: Identity::asUser('user-1', 'tenant-abc'), + ); + } + + public function test_creates_and_stores_invoice(): void + { + $result = $this->sut->create($this->validDto()); + + $saved = $this->repo->find($result->invoiceId); + $this->assertEquals(InvoiceStatus::ISSUED, $saved->status()); + } + + public function test_commits_transaction(): void + { + $this->sut->create($this->validDto()); + $this->assertTrue($this->txn->wasCommitted()); + $this->assertFalse($this->txn->wasRolledBack()); + } + + public function test_dispatches_integration_event_after_commit(): void + { + $this->sut->create($this->validDto()); + + $events = $this->bus->dispatched(InvoiceCreatedIntegrationEvent::class); + $this->assertCount(1, $events); + $this->assertEquals('user-1', $events[0]->clientId); + } + + public function test_rolls_back_and_discards_events_on_failure(): void + { + $this->repo->failOnNextSave(); + + try { + $this->sut->create($this->validDto()); + } catch (ServiceException) {} + + $this->assertTrue($this->txn->wasRolledBack()); + $this->assertEmpty($this->bus->all()); // no phantom events + } + + public function test_unauthorized_user_cannot_create_for_another(): void + { + $dto = new CreateInvoiceDTO(clientId: 'different-user', /* ... */); + $this->expectException(ServiceException::class); + $this->sut->create($dto); + } + + private function validDto(): CreateInvoiceDTO + { + return new CreateInvoiceDTO( + clientId: 'user-1', + dueDate: '+30 days', + lineItems: [['description' => 'Test', 'quantity' => 1, 'unitPrice' => 100.00]], + ); + } +} +``` + +--- + +## Port Fakes + +### InMemoryInvoiceRepository + +```php +final class InMemoryInvoiceRepository implements InvoiceRepositoryContract +{ + private array $store = []; + private bool $failOnSave = false; + + public function find(string $id): Invoice + { + if (!isset($this->store[$id])) { + throw new RepositoryException("Invoice [{$id}] not found"); + } + return $this->store[$id]; + } + + public function save(Invoice $invoice): void + { + if ($this->failOnSave) { + $this->failOnSave = false; + throw new RepositoryException('Simulated save failure'); + } + $this->store[$invoice->id()->value()] = $invoice; + } + + public function failOnNextSave(): void { $this->failOnSave = true; } + public function count(): int { return count($this->store); } + public function all(): array { return $this->store; } +} +``` + +### FakeIntegrationEventBus + +```php +final class FakeIntegrationEventBus implements IntegrationEventBusContract +{ + private array $dispatched = []; + + public function dispatch(IntegrationEventContract $event): void + { + $this->dispatched[] = $event; + } + + public function dispatched(string $class): array + { + return array_values(array_filter( + $this->dispatched, + fn($e) => $e instanceof $class + )); + } + + public function all(): array { return $this->dispatched; } + + public function assertDispatched(string $class, int $times = 1): void + { + $count = count($this->dispatched($class)); + if ($count !== $times) { + throw new \PHPUnit\Framework\AssertionFailedError( + "Expected {$times} dispatch(es) of {$class}, got {$count}" + ); + } + } + + public function assertNotDispatched(string $class): void + { + $this->assertDispatched($class, 0); + } +} +``` + +### FakeTransactionManager + +```php +final class FakeTransactionManager implements TransactionManagerContract +{ + private bool $committed = false; + private bool $rolledBack = false; + + public function begin(): void {} + public function commit(): void { $this->committed = true; } + public function rollback(): void { $this->rolledBack = true; } + + public function wasCommitted(): bool { return $this->committed; } + public function wasRolledBack(): bool { return $this->rolledBack; } + + public function wrap(callable $callback): mixed + { + $this->begin(); + try { + $result = $callback(); + $this->commit(); + return $result; + } catch (\Throwable $e) { + $this->rollback(); + throw $e; + } + } +} +``` + +--- + +## Repository Integration Tests + +```php +// Real MySQL — use transactional rollback to isolate each test +abstract class IntegrationTestCase extends \PHPUnit\Framework\TestCase +{ + protected static \PDO $pdo; + + protected function setUp(): void + { + // Wrap each test in a transaction — rolled back in tearDown() + self::$pdo->beginTransaction(); + } + + protected function tearDown(): void + { + self::$pdo->rollBack(); // database clean for next test + } + + protected function db(): DatabasePort + { + return new MySQLAdapter(self::$pdo); + } +} + +class InvoiceRepositoryTest extends IntegrationTestCase +{ + public function test_save_and_find_roundtrip(): void + { + $repo = new InvoiceRepository($this->db(), Identity::admin()); + $invoice = Invoice::create(/* ... */); + + $repo->save($invoice); + $found = $repo->find($invoice->id()->value()); + + $this->assertTrue($invoice->id()->equals($found->id())); + } +} +``` + +--- + +## Rules for Test Code + +When writing or reviewing test code: + +- **DO** use `InMemory*Repository` fakes — never real DB in service tests +- **DO** use `FakeIntegrationEventBus` — assert events after the service call +- **DO** assert both that the transaction committed AND that events were dispatched +- **DO** test the rollback path: `failOnNextSave()` → assert `wasRolledBack()` → assert no events +- **DO** wrap repository integration tests in transactions — roll back in `tearDown()` +- **DON'T** mock domain entities — instantiate them with real constructors +- **DON'T** use `@runInSeparateProcess` — it means your test has a global state problem +- **DON'T** test private methods — test behavior through public methods only +- **DON'T** share database state between tests — each test must be independent +- **DON'T** assert on exact SQL strings — assert on repository behavior (what was persisted) diff --git a/docs/guides/11_PROJECT.md b/docs/guides/11_PROJECT.md new file mode 100644 index 0000000..436a53c --- /dev/null +++ b/docs/guides/11_PROJECT.md @@ -0,0 +1,266 @@ +# HKM Kernel — Project Layer + +> The Project layer contains no business logic. It wires kernel contracts to infrastructure adapters and chooses which business modules are active per project. + +--- + +## Current Project Bootstrap Architecture + +The repository now uses inheritance-safe project bootstrapping: + +- Shared base builder: `app/bootstrap/base.php` (returns an unbuilt `Kernel` builder) +- Per-project bootstrap: `projects/{project}/bootstrap/app.php` (extends base and calls `->build()`) +- Backward-compatible shim: `bootstrap/app.php` delegates to `projects/admin/bootstrap/app.php` +- Runtime selection: entry points resolve `HKM_PROJECT` (default: `admin`) and load `projects/{HKM_PROJECT}/bootstrap/app.php`, falling back to `bootstrap/app.php` + +--- + +## Why This Shape + +The kernel freezes `CoreContainer` when it materializes (the first entry-point call), not in `build()`. Inherited projects must still share the builder, not a built kernel instance — each project finalizes its own ports/modules with its own `->build()`. + +This allows: + +- one shared admin base in `app/` +- many child projects with their own module sets +- identical entry points reused across projects + +--- + +## Builder Semantics (Inheritance-Safe) + +`Kernel` builder methods are additive so child projects can extend base config safely: + +- `withPorts([...])`: merges with existing bindings (later keys override earlier ones) +- `withSecurity([...])`: appends layers (base first, project additions later) +- `withModules([...])`: appends and de-duplicates module class names preserving order + +--- + +## File Layout (As Implemented) + +```text +app/ +├── Infrastructure/ +│ ├── InMemoryCache.php +│ └── PdoDatabase.php +├── bootstrap/ +│ └── base.php +├── api/server.php +├── cli/run.php +├── worker/run.php +└── public_html/index.php + +projects/ +└── admin/ + └── bootstrap/app.php + +bootstrap/ +└── app.php # legacy shim +``` + +--- + +## Base Builder Pattern + +```php +// app/bootstrap/base.php (shared defaults, NO ->build()) +return Kernel::configure() + ->withBasePath(dirname(__DIR__, 2)) + ->withPorts([ + DatabasePort::class => new PdoDatabase(...), + CachePort::class => new InMemoryCache(), + ]) + ->withSecurity([ + new CsrfTokenLayer(exemptPaths: ['/api']), + ]); +``` + +--- + +## Project Bootstrap Pattern + +```php +// projects/admin/bootstrap/app.php +/** @var Kernel $builder */ +$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; + +return $builder + ->withModules([ + TaskModule::class, + ]) + ->build(); +``` + +--- + +## Entry Point Resolution Pattern + +All entry points in `app/` follow this runtime bootstrap selection logic. Note the +fixed order: resolve the project, **load the environment, install the error net, THEN +require the kernel bootstrap** (so a pre-kernel failure is caught and cannot leak): + +```php +$rootPath = dirname(__DIR__, 2); +$domain = EntryHelpers::resolveDomain($rootPath, $host); // HTTP only; null in CLI/worker +$project = (string) (getenv('HKM_PROJECT') ?: 'admin'); // ← legitimate pre-env getenv + +LoadEnvironment::load($rootPath, $domain, $argv); // 1. .env cascade → $_ENV +ErrorGuard::install($rootPath . '/projects/' . $project . '/var/logs/errors.log'); // 2. error net + +$kernel = require EntryHelpers::bootstrapPathFor($rootPath, $project); // 3. kernel +``` + +`HKM_PROJECT` is read with `getenv()` on purpose — it selects which project to boot and +is a genuine OS/server variable evaluated *before* `LoadEnvironment` runs. Everything the +kernel and modules read afterwards must use the `env()` helper, not `getenv()` (see +`app/Bootstrap/Environment/`). + +Applied to: + +- `app/api/server.php` (env + guard installed once per worker in `workerStart`; guard is ini-only) +- `app/cli/run.php` +- `app/worker/run.php` +- `app/public_html/index.php` + +--- + +## Project Routes & Views (Project-Over-Plugin Priority) + +A project can declare its OWN routes and view paths — they take precedence over +plugin resources by default (deterministic, compiled at boot). + +```jsonc +// projects//proj.json (or the flat project-root proj.json) +{ + "name": "shop", + "views": "resources", // project view root (priority 0) + "routes": [ + { "method": "GET", "path": "/", "handler": "Shop\\Http\\HomeController@index" }, + { "method": "GET", "path": "/ping", "handler": "Shop\\Http\\HomeController@ping" } + ] +} +``` + +- Routes: `EntryHelpers::projectRoutes($projectPath)` reads `proj.json` + `routes[]`; the project bootstrap passes them to `Kernel::withRoutes(...)`. + They compile AFTER all plugin routes and OVERRIDE a plugin route with the same + `METHOD path`. They resolve under the synthetic `__project__` scope (no module + graph); the full-class-path controller autowires from the request container. + Keep project controllers thin — orchestrate published plugin contracts. +- Views: project view paths sort to priority `0` (highest). `render('welcome')` + resolves the project copy before any plugin's; `render('plugin::view')` can be + overridden by dropping `{project-views}/plugin/view.php`. + +### Per-route `requires` — project routes opting into plugins + +The `__project__` scope has an EMPTY dependency graph, so a project route loads +NO plugins by default: on-demand modules' `register()` never runs, their published +contracts are unbound, and a `ViewController` (which constructor-injects +`ViewRendererContract`) cannot even be built. To pull a plugin into ONE project +route without making it essential, declare a route-level `requires[]`: + +```jsonc +// proj.json +{ "method": "GET", "path": "/dashboard", + "handler": "Shop\\Http\\DashboardController@index", + "requires": ["view.rendering"] } +``` + +- `CompileRouteManifestStage` validates each `requires[]` entry at BOOT against + the set of domains some module `solves()` — an unknown/typo'd domain fails the + build with a descriptive message (never a request-time 500). +- `LoadStage` reads the matched route's `requires[]` and seeds those domains + (plus their transitive `requires`) into THAT request's graph only, via + `DependencyGraphCalculator::resolve($service, $additional)`. Routes without + `requires[]` stay lean. +- Scope isolation is unchanged: the required plugin's PUBLIC contract resolves in + the project controller, but its `bindInternal` bindings still throw + `ScopeViolationException` cross-scope. + +| Need | Mechanism | +| --- | --- | +| Some project routes need a plugin | route-level `requires[]` in `proj.json` | +| Every request needs a plugin | `withEssentialModules([...])` | +| The endpoint IS the plugin's domain | declare the route in the plugin's `module.json` | + +Project routes also pass `filters[]` through to the compiler; plugin routes MAY +carry `requires[]` too (they normally get deps via their module's `solves` graph). + +### Route policy — DISABLE plugin routes (the third verb) + +A plugin OWNS and declares its routes, but the deploying project is the FINAL +authority: it can veto plugin routes it will not expose — without forking the +plugin. Declared in `proj.json` and wired by the bootstrap via +`Kernel::withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot))`: + +```jsonc +// proj.json +"routePolicy": { + "disable": [ + "GET /register", // one plugin route (method + path) + "oauth.server" // a module DOMAIN — every route that module solves() + ] +} +``` + +- Two spec forms: `"METHOD /path"` (one exact plugin route) or a bare module + domain (all of that module's routes — the whole-plugin off switch). +- `CompileRouteManifestStage` applies the policy to plugin routes AFTER they + compile and BEFORE project routes — so a project can disable a plugin route + and re-declare its OWN on the freed `METHOD path` with no duplicate-route + boot failure. +- A spec matching NOTHING fails the build with a descriptive message (same + anti-typo guard as unknown `requires[]` domains). Never a silent no-op. +- Project routes (`withRoutes`) are the project's own and are unaffected. + +| Route verb | Mechanism | Result | +| --- | --- | --- | +| add | project `routes[]` | new project route | +| override | project route on a plugin's `METHOD path` | project controller wins | +| disable | `routePolicy.disable[]` | plugin route dropped (404) | + +See the project-over-plugin resource-resolution model in [16_PLUGINS.md](16_PLUGINS.md). + +--- + +### Global (essential) modules — proj.json `"essentials"` + +Which plugins register on EVERY request is a per-project deployment decision, +declared in `proj.json` — not a bootstrap code edit: + +```jsonc +// proj.json — module DOMAINS (a plugin's solves value) +"essentials": ["tenancy.routing", "auth.identity", "user.management"] +``` + +Wired by the bootstrap via +`Kernel::withEssentialModules(EntryHelpers::projectEssentials($projectRoot))`. +Semantics: + +- `withEssentialModules()` accepts provider class-strings AND module domains; a + domain must name a module already in `withModules()` and resolves to its + provider at `build()` — an unknown domain FAILS the boot (never a silent + no-op essential). +- Essential domains are seeded into every request's dependency graph, so an + essential's transitive `requires[]` load with it; each module still registers + exactly once per request. +- Keep the list SHORT — every essential (and its requires graph) is + per-request `register()` cost. +- Session-cookie apps declare `auth.identity` + `user.management` so Auth's + `SessionAuthStage` resolves the logged-in user on every page; JWT/PAT-only + APIs need neither (token layers run before any module loads). +- Multi-tenant projects declare `tenancy.routing`; single-tenant projects leave + Tenancy out of `withModules` entirely. + +--- + +## Rules For Future Project Work + +- Keep business logic out of `app/`, `bootstrap/`, and project bootstrap files +- Project routes go in `proj.json` routes[] (or `Kernel::withRoutes()`), never in PHP +- Unwanted plugin routes go in `proj.json` routePolicy.disable[] — never fork a plugin to hide an endpoint +- Put only port/adapters/security/module lists in bootstrap wiring +- Add new projects under `projects/{name}/bootstrap/app.php` +- Ensure module classes listed in `withModules()` have valid `module.json` +- Prefer extending `app/bootstrap/base.php` over copy-pasting full kernel wiring diff --git a/docs/guides/12_WORKER.md b/docs/guides/12_WORKER.md new file mode 100644 index 0000000..bc7f0a3 --- /dev/null +++ b/docs/guides/12_WORKER.md @@ -0,0 +1,374 @@ +# HKM Kernel — Worker Pipeline + +> The Worker pipeline handles **background job processing**. It shares the same kernel, +> module system, and port abstractions as the HTTP pipeline. Business logic in a Job behaves +> identically whether triggered from HTTP or from a queue. + +--- + +## Worker Pipeline Stages + +``` +Queue (Redis / Beanstalkd / SQS) + │ + ▼ +DequeueStage + │ Attaches JobId as CorrelationId for tracing + ▼ +ValidateSignatureStage + │ Verifies HMAC payload signature — rejects tampered jobs + ▼ +ValidatePayloadStage + │ Parses and validates job payload as typed DTO + ▼ +OnDemandLoaderStage + │ Resolves dep graph from JobManifest → loads only needed modules + ▼ +ExecuteJobStage + │ Calls job->handle(JobPayload) inside a transaction boundary + ▼ +AcknowledgeStage + │ Removes job from queue on success + │ + ├── On failure → + │ RetryStage: exponential backoff (attempt 1: 30s, 2: 900s, 3: 3600s) + │ + └── After max retries → + DeadLetterStage: move to DLQ + notify ErrorPipeline +``` + +--- + +## JobContract — Every Job Implements This + +```php +interface JobContract +{ + /** + * Execute the job. Return JobResult on success. + * Throw any Throwable on failure — the pipeline handles retry. + */ + public function handle(JobPayload $payload): JobResult; + + /** + * Called after max retries are exhausted — before moving to dead-letter. + * Use to mark the operation as permanently failed in the database. + */ + public function failed(JobPayload $payload, \Throwable $e): void; +} +``` + +--- + +## JobPayload Contract + +```php +interface JobPayloadContract +{ + public function jobId(): string; // CorrelationId for this job + public function jobClass(): string; // e.g. SendInvoiceEmailJob + public function data(): array; // typed payload data + public function queue(): string; // which queue it came from + public function attempts(): int; // how many times tried so far + public function maxAttempts(): int; // from module.json retry.max + public function enqueuedAt(): \DateTimeImmutable; // when originally queued + public function signature(): string; // HMAC for integrity check + public function isSignatureValid(string $secret): bool; +} +``` + +--- + +## Canonical Job Implementation + +```php +data()); + + // Business logic — same patterns as in HTTP services + $invoice = $this->invoices->find($dto->invoiceId); + + if ($invoice->status() !== 'issued') { + // Skip — invoice is no longer in the right state. Do NOT retry. + return JobResult::success([ + 'skipped' => true, + 'reason' => "Invoice status is [{$invoice->status()}], not 'issued'", + ]); + } + + $this->mail->send( + to: $invoice->clientEmail(), + subject: "Invoice #{$invoice->number()} is ready", + view: 'invoice-email', + data: ['invoice' => $invoice], + ); + + return JobResult::success(['invoiceId' => $dto->invoiceId, 'sent' => true]); + } + + public function failed(JobPayload $payload, \Throwable $e): void + { + // Called after max retries — mark invoice delivery as permanently failed + $dto = SendInvoiceEmailPayload::from($payload->data()); + // Could update a delivery_status column, alert support, etc. + } +} +``` + +--- + +## Job module.json + +```json +{ + "name": "job-send-invoice-email", + "version": "1.0.0", + "solves": "job.send-invoice-email", + "type": "job", + + "queue": "emails", + "retry": { + "max": 3, + "strategy": "exponential", + "jitter": true + }, + "timeout": 30, + + "requires": [ + "mail.port", + "invoice.generation" + ], + + "config": [ + "INVOICE_FROM_EMAIL" + ] +} +``` + +--- + +## Job Dispatch — From Service Layer + +```php +// In a Service — dispatch a job after committing the transaction +// (same rule as integration events: only after commit) + +$this->transaction->begin(); +try { + $invoice = $this->createInvoice($dto); + $this->repository->save($invoice); + $this->transaction->commit(); +} catch (\Throwable $e) { + $this->transaction->rollback(); + throw $e; +} + +// After commit — dispatch job +$this->queue->push( + jobClass: SendInvoiceEmailJob::class, + payload: ['invoiceId' => $invoice->id()->value()], + queue: 'emails', +); +``` + +--- + +## Retry Strategies + +```php +// Exponential backoff with jitter — prevents thundering herd +// base=30, max_delay=3600, jitter=±25% + +// Attempt 1: ~30s (30 ± 7s) +// Attempt 2: ~900s (900 ± 225s) +// Attempt 3: ~3600s (capped at 3600 ± 900s) + +class ExponentialRetryStrategy implements RetryStrategyContract +{ + public function delay(int $attempt, array $config): int + { + $base = $config['base_delay'] ?? 30; + $max = $config['max_delay'] ?? 3600; + $delay = min((int) ($base ** $attempt), $max); + + if ($config['jitter'] ?? true) { + $range = (int) ($delay * 0.25); + $delay += random_int(-$range, $range); + } + + return max(1, $delay); + } +} +``` + +--- + +## Bulk / Long-Running Jobs + +```php +final class GenerateMonthlyReportsJob implements JobContract +{ + private const BATCH_SIZE = 50; + + public function handle(JobPayload $payload): JobResult + { + $dto = GenerateReportsPayload::from($payload->data()); + $tenantIds = $this->db->query('SELECT id FROM tenants WHERE plan = :plan', + ['plan' => $dto->plan]); + + $processed = $failed = 0; + + foreach (array_chunk($tenantIds, self::BATCH_SIZE) as $batch) { + foreach ($batch as $tenant) { + try { + $this->reportService->generateMonthly($tenant['id'], $dto->month); + $processed++; + } catch (\Throwable $e) { + $failed++; + // Log individually, continue processing other tenants + $this->errorConsumer->log($e, 'warning'); + } + } + + // Update progress after each batch + $this->operations->updateProgress( + id: $payload->jobId(), + done: $processed + $failed, + total: count($tenantIds), + ); + + // Check for graceful shutdown signal + if ($this->loop->shouldStop()) { + return JobResult::success(['processed' => $processed, 'stopped_early' => true]); + } + } + + return JobResult::success(['processed' => $processed, 'failed' => $failed]); + } + + public function failed(JobPayload $payload, \Throwable $e): void {} +} +``` + +--- + +## JobResult + +```php +final class JobResult +{ + private function __construct( + private readonly bool $success, + private readonly array $data, + ) {} + + public static function success(array $data = []): self + { + return new self(true, $data); + } + + // Use when the job should be considered done but nothing was processed + public static function skipped(string $reason): self + { + return new self(true, ['skipped' => true, 'reason' => $reason]); + } + + public function isSuccess(): bool { return $this->success; } + public function data(): array { return $this->data; } +} +``` + +--- + +## Queue Configuration + +```php +// config/jobs.php +return [ + 'connection' => env('QUEUE_DRIVER', 'redis'), + 'queues' => ['critical', 'high', 'default', 'emails', 'reports'], + 'concurrency' => (int) env('WORKER_CONCURRENCY', 4), + 'memory_limit'=> (int) env('WORKER_MEMORY_LIMIT', 128), // MB + 'poll_interval'=> (int) env('WORKER_SLEEP', 1), // seconds + 'max_jobs' => (int) env('WORKER_MAX_JOBS', 1000), // restart after N jobs + 'timeout' => (int) env('JOB_TIMEOUT', 60), // default seconds +]; +``` + +--- + +## Dead-Letter Queue Management + +```bash +# View dead-letter jobs +php cli.php queue:dead-letter + +# Retry a specific failed job +php cli.php queue:retry {job-id} + +# Retry all failed jobs (dangerous — check why they failed first) +php cli.php queue:retry --all + +# Flush the dead-letter queue (destructive — requires --force) +php cli.php queue:flush --queue=dead-letter --force +``` + +--- + +## Supervisor Configuration + +```ini +[program:sentinel-worker-critical] +command=php /var/www/worker.php --queue=critical --concurrency=2 --max-jobs=500 +numprocs=1 +autostart=true +autorestart=true +stdout_logfile=/var/log/sentinel/worker-critical.log +stderr_logfile=/var/log/sentinel/worker-critical-error.log +stopwaitsecs=30 + +[program:sentinel-worker-default] +command=php /var/www/worker.php --queue=default,emails --concurrency=4 --max-jobs=1000 +numprocs=2 +autostart=true +autorestart=true +stdout_logfile=/var/log/sentinel/worker-default.log +stderr_logfile=/var/log/sentinel/worker-default-error.log +stopwaitsecs=60 +``` + +--- + +## Rules for Worker / Job Code + +When writing or reviewing job code: + +- **DO** return `JobResult::skipped($reason)` when the job should not retry (e.g. wrong state) +- **DO** implement `failed()` to record permanent failure in the database +- **DO** process bulk jobs in batches — check `$loop->shouldStop()` between batches +- **DO** dispatch jobs AFTER the transaction commits — same rule as integration events +- **DON'T** throw an exception to skip a job — that triggers retry; return `JobResult::skipped()` +- **DON'T** use infinite loops in jobs — use batch processing with progress tracking +- **DON'T** directly call service methods that dispatch further jobs in a chain longer than 3 hops +- **DON'T** put business logic in the retry/failed handlers — delegate to a service +- **DON'T** access `$_SERVER`, `$_GET`, or any HTTP globals in a job — it runs in a worker process diff --git a/docs/guides/13_ANTIPATTERNS.md b/docs/guides/13_ANTIPATTERNS.md new file mode 100644 index 0000000..7e88672 --- /dev/null +++ b/docs/guides/13_ANTIPATTERNS.md @@ -0,0 +1,493 @@ +# HKM Kernel — Anti-Patterns and What NOT to Do + +> This guide is as important as any other. Many patterns that work in Laravel/Symfony +> **violate HKM's Gated Demand Architecture** — the ones below are actively rejected by the +> framework at boot or runtime. + +--- + +## ANTI-PATTERN 1 — Cross-Module Repository Access + +**Wrong — importing another module's Repository:** +```php +// In PaymentModule's service — NEVER DO THIS +use InvoiceModule\Infrastructure\Persistence\InvoiceRepository; // FORBIDDEN + +class PaymentService +{ + public function __construct( + private InvoiceRepository $invoiceRepo, // ScopeViolationException at runtime + ) {} +} +``` + +**Correct — inject the published contract:** +```php +use InvoiceModule\API\Contracts\InvoiceServiceContract; // Correct + +class PaymentService +{ + public function __construct( + private InvoiceServiceContract $invoices, // published interface — OK + ) {} +} +``` + +**Why:** `InvoiceRepository` is internal to InvoiceModule. The scoped container throws +`ScopeViolationException` if any code outside InvoiceModule resolves it. + +--- + +## ANTI-PATTERN 2 — Business Logic in Controller + +**Wrong:** +```php +class InvoiceController +{ + public function create(Request $request): Response + { + $data = $request->body(); + + // Business logic in controller — WRONG + if ($data['amount'] <= 0) { + return Response::unprocessable(['amount' => 'Must be positive']); + } + if (count($data['lineItems']) === 0) { + return Response::unprocessable(['lineItems' => 'Required']); + } + + $row = $this->db->execute('INSERT INTO invoices ...'); + return Response::json($row, 201); + } +} +``` + +**Correct:** +```php +class InvoiceController +{ + public function create(Request $request): Response + { + $dto = CreateInvoiceDTO::fromRequest($request); // validation here + $result = $this->service->create($dto); // business logic here + return Response::json($result->toArray(), 201); // just translation + } +} +``` + +--- + +## ANTI-PATTERN 3 — Event Dispatch Before Commit + +**Wrong — phantom events if commit fails:** +```php +public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO +{ + $this->transaction->begin(); + $invoice = Invoice::create(...); + $this->repository->save($invoice); + + // WRONG: dispatched before commit — if commit fails, phantom event was sent + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent(...)); + + $this->transaction->commit(); +} +``` + +**Correct — dispatch ONLY after commit:** +```php +public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO +{ + $this->transaction->begin(); + try { + $invoice = Invoice::create(...); + $this->repository->save($invoice); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // no phantom events + throw $e; + } + + // Only reached on successful commit + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent(...)); +} +``` + +--- + +## ANTI-PATTERN 4 — Domain with External Dependencies + +**Wrong — Eloquent / ORM in domain entity:** +```php +// In Domain/Entities/Invoice.php — NEVER +class Invoice extends \Illuminate\Database\Eloquent\Model // FORBIDDEN +{ + // Eloquent is infrastructure — domain cannot depend on it +} +``` + +**Wrong — importing any framework or vendor class in Domain:** +```php +namespace InvoiceModule\Domain\Entities; + +use Illuminate\Support\Carbon; // FORBIDDEN in Domain +use Symfony\Component\Uid\Ulid; // FORBIDDEN in Domain +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; // FORBIDDEN in Domain +``` + +**Correct — pure PHP in Domain:** +```php +namespace InvoiceModule\Domain\Entities; + +// Only PHP built-ins and own Domain types +use InvoiceModule\Domain\ValueObjects\InvoiceId; +use InvoiceModule\Domain\ValueObjects\Money; +use InvoiceModule\Domain\Events\InvoiceCreatedDomainEvent; + +final class Invoice +{ + // Pure PHP — no external dependencies +} +``` + +--- + +## ANTI-PATTERN 5 — Shared Database Tables Between Modules + +**Wrong:** +```php +// PaymentModule queries InvoiceModule's table directly +class PaymentRepository +{ + public function findWithInvoice(string $paymentId): array + { + return $this->db->query( + // FORBIDDEN — payments module must not know invoice table schema + 'SELECT p.*, i.number, i.total_cents + FROM payments p + JOIN invoices i ON p.invoice_id = i.id ← cross-module table join + WHERE p.id = :id', + ['id' => $paymentId] + ); + } +} +``` + +**Correct — each module owns its tables, reads cross-module data via contract:** +```php +class PaymentService +{ + public function findWithInvoice(string $paymentId): PaymentWithInvoiceDTO + { + $payment = $this->paymentRepository->find($paymentId); + $invoice = $this->invoiceService->find($payment->invoiceId()); // via contract + return new PaymentWithInvoiceDTO($payment, $invoice); + } +} +``` + +--- + +## ANTI-PATTERN 6 — Routes in PHP Files + +**Wrong:** +```php +// In Provider.php or anywhere — DO NOT define routes in PHP +$router->get('/api/invoices', [InvoiceController::class, 'index']); +$router->post('/api/invoices', [InvoiceController::class, 'create']); +``` + +**Correct — routes in module.json:** +```json +{ + "routes": [ + { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, + { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create" } + ] +} +``` + +--- + +## ANTI-PATTERN 7 — Skipping a Job by Throwing + +**Wrong — throwing causes retry:** +```php +public function handle(JobPayload $payload): JobResult +{ + $dto = GenerateReportPayload::from($payload->data()); + $invoice = $this->invoices->find($dto->invoiceId); + + if ($invoice->isPaid()) { + throw new \RuntimeException('Invoice already paid — skip'); // causes retry! + } +} +``` + +**Correct — return skipped:** +```php +public function handle(JobPayload $payload): JobResult +{ + $dto = GenerateReportPayload::from($payload->data()); + $invoice = $this->invoices->find($dto->invoiceId); + + if ($invoice->isPaid()) { + return JobResult::skipped('Invoice already paid — no report needed'); + } + + // ... proceed +} +``` + +--- + +## ANTI-PATTERN 8 — Using Static Properties for State + +**Wrong — static state leaks between requests in FPM:** +```php +class InvoiceService +{ + private static array $cache = []; // FORBIDDEN — leaks between requests! + + public function find(string $id): InvoiceResponseDTO + { + if (isset(self::$cache[$id])) { + return self::$cache[$id]; // returns stale data from previous request + } + // ... + } +} +``` + +**Correct — use CachePort:** +```php +class InvoiceQueryService +{ + public function find(string $id): InvoiceResponseDTO + { + return $this->cache->remember( + key: "invoice:v1:{$id}", + ttl: 300, + callback: fn() => InvoiceResponseDTO::from($this->repository->find($id)), + ); + } +} +``` + +--- + +## ANTI-PATTERN 9 — Authorization in the SecurityGateway + +**Wrong — business authorization in a SecurityLayer:** +```php +class InvoiceOwnershipLayer implements SecurityLayerContract +{ + public function check(Request $request): SecurityVerdict + { + $invoiceId = $request->routeParam('id'); + $invoice = $this->invoiceRepo->find($invoiceId); // WRONG — repo in gateway + + if ($invoice->clientId() !== $request->identity()->userId) { + return SecurityVerdict::deny(403, 'Not your invoice'); + } + return SecurityVerdict::allow($request); + } +} +``` + +**Correct — ownership check in the Service layer:** +```php +class InvoiceService +{ + public function find(string $id): InvoiceResponseDTO + { + $invoice = $this->repository->find($id); + + // Ownership check belongs here — Service has Identity + if ($invoice->clientId()->value() !== $this->identity->userId + && !$this->identity->hasPermission('invoice:view-all')) { + throw new ServiceException('invoice.access.denied'); + } + + return InvoiceResponseDTO::from($invoice); + } +} +``` + +--- + +## ANTI-PATTERN 10 — Missing `declare(strict_types=1)` + +**Wrong — PHP will silently coerce wrong types:** +```php +multiply(0.1); // 99 cents (rounded correctly) +$total = $price->add($tax); // 1098 cents = $10.98 +``` + +--- + +## ANTI-PATTERN 12 — Declaring Config in Code, Not module.json + +**Wrong:** +```php +class Provider implements ModuleContract +{ + public function register(ModuleContainer $container): void + { + $container->bind(InvoiceService::class, fn($c) => + new InvoiceService( + currency: env('INVOICE_CURRENCY'), // config used but NOT declared in module.json! + ) + ); + } +} +``` + +**Correct — declare ALL config in module.json:** +```json +{ + "config": ["INVOICE_CURRENCY"] +} +``` +```php +// Now the kernel validates INVOICE_CURRENCY exists at boot time +// If missing: boot fails with a descriptive error listing the variable and which module needs it +``` + +--- + +## Common Mistakes to Avoid + +| If you are about to... | Stop. Do this instead. | +|---|---| +| Extend `Model` in Domain/ | Use a plain PHP class with static factory methods | +| Import another module's Repository | Import its contract from `API/Contracts/` | +| Put SQL in a Service | Move it to the Repository | +| Put authorization in a Controller | Move it to the Service | +| Dispatch an event inside a `try` block | Move dispatch after the `try/catch` | +| Use `static` properties for caching | Use `CachePort` | +| Define routes in PHP | Define them in `module.json` | +| Use `float` for money | Use `Money::of()` with integer cents | +| Throw in a job to skip processing | Return `JobResult::skipped($reason)` | +| Use `===` for token comparison | Use `hash_equals()` | +| Extend `AbstractCommand` with `CommandContract` | Extend `AbstractCommand` from php-io-cli | +| Register a command as `$cli->command('name', Cmd::class)` | Use `$cli->command(Cmd::class)` (class-string only) | +| Call `CoreContainer::getInstance()` | Inject via DI — `getInstance()` throws `LogicException` | +| Bind services after the kernel materializes | All bindings happen in `register()` before the core is frozen | +| Forget to call `$container->reset()` in Swoole workers | Call `reset()` at end of every request | + +--- + +## ANTI-PATTERN 13 — Using Global Container Singletons + +**Wrong — calling `getInstance()` anywhere:** +```php +// FORBIDDEN — both containers have disabled this method +$core = CoreContainer::getInstance(); // ← LogicException +$module = ModuleContainer::getInstance(); // ← LogicException +``` + +**Why it is disabled:** In Swoole workers, multiple coroutines share the same process memory. +A globally shared container instance would cause race conditions and data leaks between requests. + +**Correct — always receive the container via dependency injection or constructor:** +```php +// CoreContainer is injected into each pipeline when the kernel materializes +// ModuleContainer is created by OnDemandLoader per request — receive it as a method parameter + +public function handle(Request $request, callable $next): Response +{ + $container = $request->getAttribute('container'); // injected by LoadStage + $service = $container->make(InvoiceServiceContract::class); + // ... +} +``` + +--- + +## ANTI-PATTERN 14 — Binding Services After Kernel::build() + +**Wrong — writing to CoreContainer after it is frozen:** +```php +// The kernel calls $core->freeze() when it materializes — on the first +// http()/cli()/workerLoop()/container() call, after all modules boot. +// Any write after this throws LogicException. + +$kernel = $app->build(); // compile-only — NOT yet frozen +$kernel->http(); // materializes + freezes the container + +// WRONG — attempting to add a binding after the kernel has materialized: +$kernel->container()->singleton(SomeService::class, fn() => new SomeService()); +// ↑ LogicException: Cannot bind to a frozen CoreContainer +``` + +**Correct — all bindings registered inside `Provider::register()`:** +```php +class Provider implements ModuleContract +{ + public function register(ModuleContainer $container): void + { + // All bindings go HERE — before the kernel materializes and freezes the CoreContainer + $container->bind(InvoiceServiceContract::class, fn($c) => new InvoiceService(...)); + } +} +``` + +--- + +## ANTI-PATTERN 15 — Resolving Internal Bindings from the Wrong Scope + +**Wrong — resolving another module's internal binding directly:** +```php +// In ExecuteStage or any code outside InvoiceModule: +$repo = $container->make(InvoiceRepository::class); +// ↑ ScopeViolationException — InvoiceRepository is internal to InvoiceModule +``` + +**Correct — use `makeInScope()` with the owning module's scope, or use published contract:** +```php +// Option 1: use makeInScope when ExecuteStage resolves a controller +$controller = $container->makeInScope(InvoiceController::class, 'invoice.generation'); + +// Option 2: resolve via published contract from any scope +$service = $container->make(InvoiceServiceContract::class); // public — always OK +``` diff --git a/docs/guides/14_CLI.md b/docs/guides/14_CLI.md new file mode 100644 index 0000000..62f7e30 --- /dev/null +++ b/docs/guides/14_CLI.md @@ -0,0 +1,303 @@ +# HKM Kernel — CLI Command Pipeline + +> CLI commands share the same module system, port abstractions, and business logic as +> HTTP requests. A command is a module — it declares dependencies in `module.json` +> and loads only what it needs. +> +> **Engine:** `CliPipeline` wraps `AlfacodeTeam\PhpIoCli\CLIApplication` from the +> **php-io-cli** package (`modules/php-io-cli/`). Module commands extend `AbstractCommand` +> — a **standalone** class with zero Symfony dependency. +> +> For the complete php-io-cli reference (components, I/O layer, Shell, Colors) see +> `docs/guides/17_PHP_IO_CLI.md`. + +--- + +## CLI Pipeline Engine + +```php +// CliPipeline wraps CLIApplication from php-io-cli. +// Module commands extend AbstractCommand — NOT CommandContract (deprecated). +// Registration: class-string — CliPipeline instantiates via CoreContainer (DI) or directly. + +// In Provider::boot(): +public function boot(HttpPipeline $http, CliPipeline $cli, ...): void +{ + $cli->command(GenerateMonthlyInvoicesCommand::class); + $cli->command(InvoiceDatabaseSeeder::class); +} + +// Run the CLI (in bin/cli.php entry point): +exit($kernel->cli()->run($argv)); +``` + +--- + +## CLI Pipeline Stages + +```text +$ php cli.php invoice:generate-monthly --dry-run + │ + ▼ +CorrelationIdStage ← generate CommandId for log tracing + │ + ▼ +AuthenticateCommandStage (optional) + │ Validates operator credentials for protected commands + ▼ +ResolveCommandStage + │ Matches argv[1] to a registered command name + ▼ +OnDemandLoaderStage + │ Dep graph → loads only needed modules + ▼ +ValidateArgsStage + │ Validates arguments against declared addArgument() defs + ▼ +ExecuteCommandStage + │ Calls command->execute(tokens, $io) via AbstractCommand + │ Returns exit code (0 = success, 1 = failure, 2 = invalid) + ▼ +ErrorStage (wraps all) ← routes uncaught errors to ErrorPipeline +``` + +--- + +## AbstractCommand — Base Class for All Commands + +`AbstractCommand` is **standalone** — it does NOT extend or wrap any Symfony class. +Its `handle()` method receives no parameters; input is read via `$this->argument()` / +`$this->option()`, and output is written via `$this->info()` / `$this->success()` etc. + +```php +use AlfacodeTeam\PhpIoCli\AbstractCommand; +use InvoiceModule\API\Contracts\InvoiceServiceContract; +use InvoiceModule\Application\DTO\GenerateMonthlyInvoicesDTO; + +final class GenerateMonthlyInvoicesCommand extends AbstractCommand +{ + public function __construct( + private readonly InvoiceServiceContract $invoices, + ) {} + + protected function configure(): void + { + $this->name = 'invoice:generate-monthly'; + $this->description = 'Generate monthly invoices for all active clients'; + + $this->addArgument('month', 'Target month (Y-m)', required: false); + $this->addOption('dry-run', 'd', 'Simulate — no invoices created'); + $this->addOption('tenant', 't', 'Restrict to a single tenant', acceptsValue: true); + } + + protected function handle(): int + { + $month = $this->argument('month', date('Y-m')); + $dryRun = $this->hasOption('dry-run'); + $tenant = $this->option('tenant'); + + $this->section('Invoice Generation'); + $this->info("Month: {$month}" . ($dryRun ? ' [dry-run]' : '')); + + if (!$this->confirm('Proceed?')) { + $this->muted('Aborted.'); + return self::SUCCESS; + } + + try { + $result = $this->invoices->generateMonthly( + new GenerateMonthlyInvoicesDTO(month: $month, dryRun: $dryRun, tenantId: $tenant) + ); + } catch (\Throwable $e) { + $this->alertError('Generation failed', [$e->getMessage()]); + return self::FAILURE; + } + + $this->alertSuccess( + "Generated {$result->created} invoices", + ["Skipped: {$result->skipped}"], + ); + return self::SUCCESS; + } +} +``` + +--- + +## Argument and Option Declaration + +Declared in `configure()` — **not** in `module.json`. The command name IS declared in +`module.json` under `"type": "command"` for the BootPipeline manifest, but signatures +live in PHP. + +```php +// Positional argument +$this->addArgument( + name: 'environment', + description: 'Target environment (prod, staging, dev)', + required: true, + default: null, +); + +// Boolean flag: --force / -f +$this->addOption('force', 'f', 'Skip confirmation prompts'); + +// Value-accepting option: --tag=v1.0 or --tag v1.0 +$this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true, default: 'latest'); +``` + +Reading inside `handle()`: + +```php +$env = $this->argument('environment'); // string|null +$force = $this->hasOption('force'); // bool +$tag = $this->option('tag', 'latest'); // mixed with fallback default +``` + +--- + +## Output Methods + +All available inside `handle()`. These are NOT Symfony Console formatting tags. + +```php +$this->info('Connecting to database…'); // cyan text +$this->success('Migration complete.'); // ✔ green +$this->warning('Disk usage above 80%.'); // ! yellow (stderr) +$this->error('Connection refused.'); // ✘ red (stderr) +$this->muted('Skipped — already exists.'); // dim gray + +$this->section('Build Pipeline'); // bold cyan heading + underline rule +$this->newLine(2); // blank lines + +// Alert boxes +$this->alertSuccess('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']); +$this->alertError('Build failed', ['See /var/log/build.log']); +$this->alertWarning('Rate limit at 80%'); +$this->alertInfo('New version available: 3.0.0'); +``` + +--- + +## Interactive Component Factory Shortcuts + +```php +$name = $this->ask('Project name'); +$env = $this->select('Target', ['prod', 'staging', 'dev']); +$ok = $this->confirm('Continue?'); +$bar = $this->progressBar('Installing', total: 10); // total=0 → indeterminate +$spin = $this->spinner('Compiling'); +$table = $this->table(); +``` + +--- + +## Exit Codes + +| Constant | Value | Meaning | +|---|---|---| +| `self::SUCCESS` | `0` | Completed normally | +| `self::FAILURE` | `1` | Command failed | +| `self::INVALID` | `2` | Bad input / missing required argument | + +Always `return` an exit code from `handle()`. Never call `exit()`. + +--- + +## Command module.json + +```json +{ + "name": "command-generate-invoices", + "version": "1.0.0", + "solves": "command.invoice.generate-monthly", + "type": "command", + "requires": ["database.query", "invoice.generation"], + "config": ["INVOICE_CURRENCY"] +} +``` + +The `"type": "command"` tells `CompileCommandManifestStage` to include this in the CLI manifest. +The command's human-readable name (`invoice:generate-monthly`) comes from `$this->name` in `configure()`. + +--- + +## Seeder Command Pattern + +Seeders are CLI commands — they inject a service contract and call it in a loop. + +```php +final class InvoiceDatabaseSeeder extends AbstractCommand +{ + public function __construct( + private readonly InvoiceServiceContract $invoices, + ) {} + + protected function configure(): void + { + $this->name = 'db:seed:invoices'; + $this->description = 'Seed fake invoices into the database'; + $this->addOption('count', 'c', 'Number of invoices to create', acceptsValue: true, default: '50'); + } + + protected function handle(): int + { + $count = (int) $this->option('count', 50); + $this->info("Seeding {$count} invoices…"); + + $bar = $this->progressBar('Seeding', $count); + $bar->start(); + + for ($i = 1; $i <= $count; $i++) { + $this->invoices->create(CreateInvoiceDTO::fake()); + $bar->advance(); + } + + $bar->finish('Done'); + $this->success("Seeded {$count} invoices."); + return self::SUCCESS; + } +} +``` + +--- + +## Protected Commands (Require Authentication) + +```php +// For commands requiring operator credentials: +// AuthenticateCommandStage checks against CLI_OPERATOR_TOKEN env var. +// List protected command names in config: + +return [ + 'protected_commands' => [ + 'db:seed:invoices', + 'migrate:reset', + 'tenant:delete', + ], + 'operator_token_env' => 'CLI_OPERATOR_TOKEN', +]; + +// Usage: +// CLI_OPERATOR_TOKEN=xxx php cli.php db:seed:invoices +``` + +--- + +## Rules for CLI Command Code + +- **DO** extend `AbstractCommand` (from php-io-cli) +- **DO** implement `configure()` to set `$this->name`, `$this->description`, arguments, options +- **DO** implement `handle()` with no parameters — read input via `$this->argument()` / `$this->option()` / `$this->hasOption()` +- **DO** write output via `$this->info()`, `$this->success()`, `$this->warning()`, `$this->error()`, `$this->muted()` +- **DO** return one of the three exit code constants from `handle()` +- **DO** inject services via published contracts — same rules as HTTP controllers +- **DO** register with `$cli->command(ClassName::class)` in `Provider::boot()` +- **DO** use `$this->progressBar()` or `$this->spinner()` for long operations +- **DON'T** use `InputInterface` / `OutputInterface` — those are Symfony Console types, not used here +- **DON'T** use `$output->writeln('...')` — use `$this->info()` etc. +- **DON'T** call `exit()` — return an integer +- **DON'T** use `CommandContract`, `Arguments`, or `Output` from `Cli/` — all @deprecated +- **DON'T** put business logic in the command — delegate to a Service +- **DON'T** declare the command signature in module.json — it belongs in `configure()` +- **DON'T** access `$_SERVER` or `$argv` directly diff --git a/docs/guides/15_ERROR_HANDLING.md b/docs/guides/15_ERROR_HANDLING.md new file mode 100644 index 0000000..ab99dde --- /dev/null +++ b/docs/guides/15_ERROR_HANDLING.md @@ -0,0 +1,315 @@ +# HKM Kernel — Error Handling + +> Every exception has a layer, a severity, and a chain of notifiers. +> Errors flow through the ErrorPipeline automatically — module code only throws, +> never handles infrastructure-level error reporting. + +--- + +## Exception Hierarchy and Mapping + +``` +FrameworkException (base — always use a subclass) +├── SecurityException → HTTP 401 / 403 / 429 → severity: warning +├── DomainException → HTTP 422 → severity: info +├── ServiceException → HTTP 422 / 500 → severity: warning +├── RepositoryException → HTTP 500 → severity: critical +├── GatewayException → HTTP 502 → severity: critical +└── KernelException → HTTP 500 → severity: critical +``` + +**Rule: Throw the exception type matching the layer where the error originates.** + +--- + +## Throwing the Right Exception + +```php +// ── Domain layer ───────────────────────────────────────────────────────── +// Use built-in \DomainException (not a HKM Kernel type) +throw new \DomainException('Invoice must have at least one line item before issuing'); + +// ── Service layer ───────────────────────────────────────────────────────── +throw new ServiceException( + message: 'invoice.create.failed', // dot-notation error code + layer: 'service.invoice', + context: ['clientId' => $dto->clientId, 'userId' => $this->identity->userId], + previous: $e, // always chain the original exception +); + +// ── Repository layer ────────────────────────────────────────────────────── +throw new RepositoryException( + message: "Invoice [{$id}] not found", + layer: 'repository.invoice', + context: ['invoiceId' => $id], + previous: $e, +); + +// ── Gateway layer ───────────────────────────────────────────────────────── +throw new GatewayException( + message: 'Stripe card declined: ' . $e->getError()->message, + layer: 'gateway.stripe.charge', + context: ['decline_code' => $e->getError()->decline_code], + previous: $e, +); + +// ── Security layer ──────────────────────────────────────────────────────── +// DON'T throw — return SecurityVerdict::deny() +return SecurityVerdict::deny(401, 'Invalid JWT signature'); +``` + +--- + +## FrameworkException Constructor + +```php +abstract class FrameworkException extends \RuntimeException +{ + public function __construct( + string $message, + public readonly string $layer = '', // 'service.invoice', 'gateway.stripe' + public readonly array $context = [], // additional typed context + int $code = 0, + ?\Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } +} +``` + +--- + +## Error Context — What Gets Captured Automatically + +The `ErrorPipeline` captures all of this on every error — no code needed in modules: + +| Field | Source | Example | +|---|---|---| +| `id` | Generated | `err_01H9X2K3M4` | +| `severity` | `ErrorClassifier` | `critical` | +| `layer` | Exception `.layer` | `gateway.stripe.charge` | +| `message` | Exception message | `Stripe card declined` | +| `trace` | PHP stack trace | Full trace | +| `context` | Exception `.context` | `{decline_code: 'insufficient_funds'}` | +| `requestId` | `CorrelationIdStage` | `20250615-a3f8b2c1` | +| `requestPath` | HTTP Request | `/api/payments` | +| `requestMethod` | HTTP Request | `POST` | +| `userId` | Identity | `usr_01H9X1A2B3` | +| `tenantId` | Identity | `ten_01H9X1A2B4` | +| `previous` | Exception chain | Previous exception message | +| `occurredAt` | Timestamp | `2025-06-15T14:23:01.234Z` | +| `environment` | `APP_ENV` | `production` | + +--- + +## Error Severity Rules + +```php +// config/errors.php +return [ + 'severity_rules' => [ + 'critical' => ['slack', 'mail', 'database', 'file'], + 'warning' => ['database', 'file'], + 'info' => ['file'], + ], +]; + +// Exception type → default severity mapping: +// SecurityException → warning (but configurable per-route) +// DomainException → info (business rule violation — expected) +// ServiceException → warning (service-level failure) +// RepositoryException → critical (database problem — ops team alerted) +// GatewayException → critical (third-party down — ops team alerted) +// Unknown Throwable → critical (unexpected — highest priority) +``` + +--- + +## HTTP Error Response Format + +```json +// 4xx / 5xx response body — always this shape +{ + "error": { + "code": "invoice.not_found", + "message": "Invoice [inv_123] was not found.", + "requestId": "20250615-a3f8b2c1", + "fields": {} // present only on 422 validation errors + } +} +``` + +```json +// 422 Validation error — fields map +{ + "error": { + "code": "validation.failed", + "message": "The request data is invalid.", + "requestId": "20250615-b4c9d3e2", + "fields": { + "dueDate": ["Due date must be in the future."], + "lineItems": ["At least one line item is required."], + "lineItems.0.unitPrice": ["Unit price must be greater than zero."] + } + } +} +``` + +--- + +## Two Error Layers (nested nets) + +The `ErrorPipeline` only handles errors *inside* a built kernel. A second, outer +layer catches what the kernel cannot — failures before it exists, and PHP fatals. + +``` +ErrorGuard (App\Bootstrap\Environment, SAPI-level) ── outer net + catches: pre-kernel throws (e.g. base.php APP_KEY guard), parse errors, fatals, OOM + └── ErrorStage / ErrorPipeline (kernel) ── inner net + catches: Throwables inside a running HTTP/CLI/worker pipeline +``` + +| Aspect | ErrorGuard | ErrorStage / ErrorPipeline | +|---|---|---| +| Layer | Project bootstrap (`app/Bootstrap/Environment/ErrorGuard.php`) | Kernel | +| Alive from | first line of the entry point — before the kernel | only after `materialize()` | +| Catches | pre-kernel throws, fatals/parse/OOM (uncatchable by try/catch) | Throwables in a running pipeline | +| Capability | generic 500 + log; debug page in debug | classify → notifiers (Slack/Mail/DB/File) | + +**Shared log sink:** both write to `{project}/var/logs/errors.log` — the kernel via +`FileNotifier`, ErrorGuard by appending a compatible JSON line tagged +`source=error_guard`. ErrorGuard never calls the ErrorPipeline (no global singletons); +the connection is the shared file only. + +**Install order (every entry point):** + +```php +LoadEnvironment::load($rootPath, $domain, $argv); +ErrorGuard::install($logRoot . '/var/logs/errors.log'); // ini-only on OpenSwoole +$kernel = require ...bootstrap; +``` + +ErrorGuard forces `display_errors=off` in production, so a pre-kernel crash can never +paint a stack trace to the browser. + +--- + +## Developer Debug Page (debug mode only) + +`src/Kernel/Error/DebugPageRenderer` is a dependency-free renderer (rich HTML page with +source preview + expandable trace, plus an ANSI CLI trace). It lives in the kernel so both +error layers can reuse it (kernel may not depend on the project layer, but the project layer +may depend on the kernel). + +- **Gated behind `APP_DEBUG=true`** — it exposes source code and stack traces, so it NEVER + renders in production. +- **Browser only.** API / AJAX / JSON callers always get the JSON error body. The decision: + - kernel ErrorStage: `Request::expectsJson()` (Accept `*/json`, `X-Requested-With`, JSON + body) OR a `/api` path prefix → JSON. + - ErrorGuard (pre-kernel, no Request): the same signals read from `$_SERVER` + `/api` prefix. +- Branded "HKM" (the debug page and CLI exception header). + +--- + +## Standard Error Codes (Dot-Notation) + +``` +Format: {module}.{resource}.{condition} + +Auth errors: + auth.token.missing → 401 No Authorization header + auth.token.invalid → 401 Bad signature or format + auth.token.expired → 401 Past exp claim + auth.credentials.invalid → 401 Wrong email or password (same message for both) + auth.refresh.reuse_detected→ 401 Refresh token reuse — all sessions invalidated + +Authorization errors: + authz.permission.denied → 403 Missing RBAC permission + authz.ownership.denied → 403 Not the owner of this resource + +Resource errors: + invoice.not_found → 404 + invoice.already_paid → 409 Duplicate payment attempt + invoice.status.invalid → 422 Invalid state machine transition + invoice.create.unauthorized→ 403 + +Validation errors: + validation.failed → 422 General validation failure (fields populated) + validation.required → 422 Required field missing + validation.invalid_format → 422 Field format incorrect + +System errors: + gateway.timeout → 504 Third-party did not respond + gateway.unavailable → 502 Third-party is down + system.maintenance → 503 Maintenance mode active +``` + +--- + +## Exception Translation Chain + +``` +\PDOException (thrown by PDO) + │ + ▼ caught by Repository +RepositoryException (layer: 'repository.invoice') + │ + ▼ propagates to Service (not caught — let it bubble) +ServiceException wrapper (optional — add context if needed) + │ + ▼ propagates to ErrorStage +ErrorPipeline.normalize() → adds requestId, userId, etc. +ErrorPipeline.classify() → assigns severity +ErrorPipeline.dispatch() → notifies Slack/Mail/DB/File + │ + ▼ +HTTP Response: 500 {"error": {"code": "repository.error", "requestId": "..."}} +``` + +--- + +## What Module Code Must NOT Do With Errors + +```php +// WRONG: catching errors to silently ignore them +try { + $this->repository->save($invoice); +} catch (RepositoryException $e) { + // logging manually and swallowing — NEVER do this + error_log($e->getMessage()); + // the error goes unreported to the ErrorPipeline +} + +// WRONG: catching generic Throwable in the service +try { + $result = $this->gateway->charge($dto); +} catch (\Throwable $e) { + return ChargeResult::failed('unknown error'); // hides the real problem +} + +// CORRECT: let exceptions propagate to the ErrorStage +// Only catch what you can meaningfully handle and rethrow wrapped +try { + $this->gateway->charge($dto); +} catch (GatewayException $e) { + // Rethrow as ServiceException to add service-level context + throw new ServiceException('payment.charge.failed', layer: 'service.payment', previous: $e); +} +``` + +--- + +## Rules for Error Handling Code + +When writing or reviewing error handling code: + +- **DO** throw the exception type matching the layer (`RepositoryException` in repos, etc.) +- **DO** include `layer:` in format `'layer.sublayer'` — e.g. `'repository.invoice'` +- **DO** include `context:` with relevant IDs and values for debugging +- **DO** chain the original exception with `previous: $e` — never lose the original +- **DO** use dot-notation error codes — `'invoice.create.failed'` not `'error'` +- **DON'T** catch exceptions in the service to silently swallow them +- **DON'T** use `error_log()`, `var_dump()`, or `print_r()` — the ErrorPipeline handles logging +- **DON'T** throw generic `\Exception` or `\RuntimeException` — always a HKM Kernel subclass +- **DON'T** put notification logic in modules — it belongs in the ErrorPipeline notifiers +- **DON'T** expose internal details (SQL, stack traces, vendor messages) in HTTP responses diff --git a/docs/guides/16_PLUGINS.md b/docs/guides/16_PLUGINS.md new file mode 100644 index 0000000..c062e69 --- /dev/null +++ b/docs/guides/16_PLUGINS.md @@ -0,0 +1,177 @@ +# HKM Kernel — Plugins Layer + +> The `plugins/` folder is the home for **locally developed business modules** that belong to +> this specific application but are not published as standalone packages. +> Every module here follows identical GDA rules — only the folder and namespace differ. + +--- + +## Why `plugins/` Exists + +| Folder | Purpose | +|---|---| +| `modules/` | First-party framework packages (`bind-it`, `php-io-cli`, etc.) loaded as Composer path repositories. These are git submodules and may be published to Packagist. | +| `projects/` | Project-layer wiring only — bootstrap files, domain resolution, `platform.json`, `projects.json`. No business logic lives here. | +| `plugins/` | Local business modules unique to this application. Full GDA structure. Autoloaded via `Plugins\\` PSR-4 prefix. Never git submodules. | + +--- + +## Namespace and Autoload + +```json +// composer.json autoload.psr-4 +"Plugins\\": "plugins/" +``` + +Every plugin's root namespace is `Plugins\{ModuleName}\`. + +--- + +## Plugin Directory Structure + +Identical to the standard GDA module layout: + +``` +plugins/{Name}/ +├── module.json ← single source of truth +├── Provider.php ← implements ModuleContract +├── API/ +│ ├── Contracts/{Name}ServiceContract.php +│ ├── Dto/ +│ └── IntegrationEvents/ +├── Domain/ +│ ├── Entities/ +│ ├── ValueObjects/ +│ ├── Events/ +│ └── Rules/ +├── Application/ +│ └── Services/{Name}Service.php +└── Infrastructure/ + ├── Http/Controllers/{Name}Controller.php + ├── Persistence/{Name}Repository.php + └── Gateways/ +``` + +--- + +## module.json Handler Paths + +Because handlers are in the `Plugins\` namespace, use the fully-qualified class string: + +```json +{ + "routes": [ + { + "method": "GET", + "path": "/api/things", + "handler": "Plugins\\MyModule\\Infrastructure\\Http\\MyController@index" + } + ], + "exposes": ["Plugins\\MyModule\\Api\\Contracts\\MyServiceContract"] +} +``` + +A route entry may also carry `filters[]` (auth, throttle, …) and an optional +`requires[]` of extra module domains. A plugin route normally gets its deps via +its own `solves` graph, so `requires[]` is rarely needed here — it is the primary +mechanism for PROJECT routes (whose `__project__` scope has no graph); see +[11_PROJECT.md](11_PROJECT.md) "Per-route `requires`". Either way, every +`requires[]` domain is validated at BOOT — an unknown domain fails the build. + +--- + +## Registering a Plugin + +Add the `Provider` class to the appropriate project bootstrap: + +```php +// projects/admin/bootstrap/app.php +use Plugins\Task\Provider as TaskModule; +use Plugins\MyOtherModule\Provider as MyOtherModule; + +return $builder + ->withModules([ + TaskModule::class, + MyOtherModule::class, + ]) + ->build(); +``` + +--- + +## Registered Plugins + +| Plugin | Namespace | Solves | Routes | +|---|---|---|---| +| Task | `Plugins\Task\` | `task.management` | `GET/POST /api/tasks`, `GET/POST/DELETE /api/tasks/{id}` | + +Infrastructure plugins (port adapters / pipeline stages, no routes) — see +[20_FIRST_PARTY_PLUGINS.md](20_FIRST_PARTY_PLUGINS.md) for the full list and the +module-activation notes (on-demand vs essential): + +| Plugin | Solves | Provides | Activation | +|---|---|---|---| +| Storage | `storage.local` | `StoragePort` (local + S3) | on-demand | +| HttpClient | `http.client` | `HttpClientPort` (cURL) | on-demand | +| Session | `session.management` | `SessionPort` (file/array/cookie drivers) | essential | +| Cookie | `http.cookies` | `CookieJar` + flush stage | essential | +| RedisCache | `cache.redis` | `CachePort` + `QueuePort` | essential | +| SecurityFilters | `http.security_filters` | global hooks: CORS, SecureHeaders. Route-filter aliases: `auth`, `throttle`, `hmac`, `shield` | hooked + filters | +| Tenancy | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` (database-per-tenant routing + selection/invitation flows; STRICT: every request must resolve a tenant or 404 — no unscoped passthrough; refresh tokens in `Plugins\Auth`; `requires: ["database.management"]` — route-level `requires[]` carry auth/user/audit for its own endpoints) | essential (declare `"essentials": ["tenancy.routing"]` in proj.json) | + +--- + +## Plugin Views — Project-First Cascade + Namespacing + +A plugin may ship its own templates and register them via a `views` key in +`module.json`. `CompileViewManifestStage` folds every plugin's `views` plus the +project's `proj.json` `views` into `view-manifest.php`, which the View plugin's +renderer consumes. + +```jsonc +// plugins/Task/module.json +"views": "resources/views" // namespace defaults to "task" +"views": { "path": "resources/views", "namespace": "task", + "priority": 100, "global": true } // explicit form +``` + +Resolution is DETERMINISTIC — lower `priority` wins: + +- PROJECT view paths default to priority `0` (highest) → a project view + overrides a plugin view of the same name BY DEFAULT. +- PLUGIN view paths default to priority `100` → fallbacks. +- `render('welcome')` walks the global cascade (project first). +- `render('task::welcome')` targets the `task` namespace, but the project can + override it by placing `{project-views}/task/welcome.php` (checked first). +- A plugin may preempt the project ONLY with an explicit lower priority + (e.g. `"priority": -1`). `"global": false` exposes a source under its + namespace only (collision-proof). + +The resource-resolution model (project-over-plugin, deterministic at boot) is described above. + +--- + +## Rules + +``` +✓ plugins/{Name}/ → Plugins\{Name}\ (PascalCase folder = PascalCase namespace) +✓ Project resources (routes/views) override plugin resources by default — deterministic +✓ A project may DISABLE plugin routes via proj.json routePolicy.disable[] — never fork a plugin to hide an endpoint +✓ Use namespace::view to target a plugin view and to avoid cross-plugin name collisions +✓ module.json handlers use fully-qualified Plugins\... class strings +✓ Provider registered in projects/{project}/bootstrap/app.php +✗ Do NOT place plugin files under projects/ — that folder is for wiring only +✗ Do NOT add plugins as Composer path repositories — Plugins\ PSR-4 covers autoloading +✗ All GDA five-layer access rules apply exactly as for any other module +``` + +--- + +## Adding a New Plugin (Checklist) + +1. `mkdir -p plugins/{Name}/{API/Contracts,API/Dto,API/IntegrationEvents,Application/Services,Domain/Entities,Domain/ValueObjects,Domain/Events,Infrastructure/Http,Infrastructure/Persistence}` +2. Write `plugins/{Name}/module.json` — set `"type": "module"`, `"solves"`, routes with `Plugins\\{Name}\\...` handlers +3. Implement all layers under `namespace Plugins\{Name}\...` +4. Write `plugins/{Name}/Provider.php` — `namespace Plugins\{Name};` implements `ModuleContract` +5. Add `Plugins\{Name}\Provider::class` to the relevant `projects/*/bootstrap/app.php` +6. Run `composer dump-autoload` if the new namespace isn't picked up automatically diff --git a/docs/guides/17_PHP_IO_CLI.md b/docs/guides/17_PHP_IO_CLI.md new file mode 100644 index 0000000..a34cd96 --- /dev/null +++ b/docs/guides/17_PHP_IO_CLI.md @@ -0,0 +1,748 @@ +# php-io-cli — Standalone CLI Runtime Reference + +> **Package:** `alfacode-team/php-io-cli` +> **Namespace:** `AlfacodeTeam\PhpIoCli\` +> **Location:** `modules/php-io-cli/` +> **PHP:** 8.2+ **Runtime deps:** `psr/log: ^3.0` only +> +> `php-io-cli` is a **completely standalone CLI framework**. Symfony Console is an optional +> dev dependency used only as a non-TTY fallback inside `ConsoleIO`/`BufferIO` — +> `AbstractCommand`, `CLIApplication`, and all interactive components have zero Symfony dependency. + +--- + +## Architecture + +``` +CLIApplication + └── AbstractCommand ← extend this for every command + ├── IOInterface ← unified I/O contract (extends PSR-3 LoggerInterface) + │ ├── ConsoleIO ← real TTY; delegates to reactive components on TTY, + │ │ falls back to Symfony QuestionHelper on pipes/CI + │ ├── BufferIO ← in-memory capture for testing + │ └── NullIO ← silent, returns defaults (daemons / CI) + └── Components + ├── Interactive: TextInput · Password · NumberInput · Confirm + │ Select · MultiSelect · Autocomplete · DatePicker + │ RadioGroup · SliderInput + └── Display: Table · Alert · ProgressBar · SpinnerComponent + └── AbstractPrompt → ILifecycle (mount / render / update / destroy) + ├── State — reactive key-value store with watchers + ├── Input — key binding dispatcher + ├── Renderer — scroll windowing, cursor management + └── Terminal — raw mode, escape sequences, cross-platform +``` + +--- + +## AbstractCommand — Base Class + +Every module command extends `AbstractCommand`. It is standalone — it does NOT wrap any +Symfony class. + +```php +use AlfacodeTeam\PhpIoCli\AbstractCommand; + +final class GenerateMonthlyInvoicesCommand extends AbstractCommand +{ + // ── Constructor: inject GDA services (if bound in CoreContainer) ── + public function __construct( + private readonly InvoiceServiceContract $invoices, + ) {} + + // ── configure(): declare metadata, arguments, options ──────────── + protected function configure(): void + { + $this->name = 'invoice:generate-monthly'; + $this->description = 'Generate monthly invoices for all active clients'; + + $this->addArgument('month', 'Target month (Y-m)', required: false, default: null); + $this->addOption('dry-run', 'd', 'Simulate — no invoices created'); + $this->addOption('tenant', 't', 'Restrict to a single tenant', acceptsValue: true); + } + + // ── handle(): business logic, returns POSIX exit code ──────────── + protected function handle(): int + { + $month = $this->argument('month', date('Y-m')); + $dryRun = $this->hasOption('dry-run'); + $tenant = $this->option('tenant'); + + $this->section('Invoice Generation'); + $this->info("Month: {$month}" . ($dryRun ? ' [dry-run]' : '')); + + if (!$this->confirm('Proceed?')) { + $this->muted('Aborted.'); + return self::SUCCESS; + } + + $bar = $this->progressBar('Generating', 0); // indeterminate + $bar->start(); + + try { + $result = $this->invoices->generateMonthly( + new GenerateMonthlyInvoicesDTO(month: $month, dryRun: $dryRun, tenantId: $tenant) + ); + } catch (\Throwable $e) { + $bar->finish('Failed'); + $this->alertError('Generation failed', [$e->getMessage()]); + return self::FAILURE; + } + + $bar->finish('Done'); + $this->alertSuccess("Generated {$result->created} invoices", ["Skipped: {$result->skipped}"]); + return self::SUCCESS; + } +} +``` + +### Exit codes + +| Constant | Value | Meaning | +|---|---|---| +| `self::SUCCESS` | `0` | Completed normally | +| `self::FAILURE` | `1` | Command failed | +| `self::INVALID` | `2` | Bad input / missing required argument | + +### configure() — argument and option registration + +```php +// Positional argument +$this->addArgument( + name: 'environment', + description: 'Target environment', + required: true, + default: null, +); + +// Boolean flag: --force / -f +$this->addOption('force', 'f', 'Skip confirmation prompts'); + +// Value-accepting option: --tag=v1.0 or --tag v1.0 +$this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true, default: 'latest'); +``` + +### handle() — reading input + +```php +$env = $this->argument('environment'); // string|null +$force = $this->hasOption('force'); // bool +$tag = $this->option('tag', 'latest'); // mixed (with fallback default) +``` + +### handle() — output helpers + +```php +$this->info('Connecting…'); // cyan +$this->success('Done.'); // ✔ green +$this->warning('Disk at 80%.'); // ! yellow (stderr) +$this->error('Connection refused.'); // ✘ red (stderr) +$this->muted('Skipped — already exists.'); // dim gray + +$this->section('Build Pipeline'); // bold cyan heading + underline rule +$this->newLine(2); + +// Alert boxes +$this->alertSuccess('Deployed!', ['v2.4.1', 'eu-west-1']); +$this->alertError('Build failed', ['See /var/log/build.log']); +$this->alertWarning('Rate limit at 80%'); +$this->alertInfo('New version: 3.0.0'); +``` + +### handle() — component factory shortcuts + +```php +$name = $this->ask('Project name'); +$env = $this->select('Target', ['prod', 'staging', 'dev']); +$ok = $this->confirm('Continue?'); +$bar = $this->progressBar('Installing', total: 10); // total=0 → indeterminate +$spin = $this->spinner('Compiling'); +$table = $this->table(); +``` + +### $hidden flag + +```php +protected bool $hidden = true; // hides from `list` output (still executable) +``` + +--- + +## CLIApplication — Entry Point + +```php +#!/usr/bin/env php +discoverCommands(__DIR__ . '/composer.json') // reads extra.php-io-cli.commands + ->add(new SomeExtraCommand()) // explicit registration + ->run(); +``` + +### Built-in commands + +| Command | Behaviour | +|---|---| +| `list` | All registered commands grouped by namespace (segment before `:`) | +| `help ` | Detailed usage for a specific command | +| `version` | Application name and version | + +### Global flags + +| Flag | Effect | +|---|---| +| `--no-ansi` | Disable all ANSI color | +| `--debug` / `-d` | Debug verbosity + `[MiB/s]` timing prefix | + +### Not-found handling + +Levenshtein distance ≤ 3 against all registered names. On a real TTY with multiple +matches an interactive `Select` picker is shown. Otherwise up to 3 suggestions are +printed to stderr. + +### Testing / custom IO + +```php +$app->withIO(new BufferIO()); // swap before run() +$app->catchExceptions(false); // rethrow instead of swallowing (tests) +``` + +### Composer command discovery + +Add to your **project's** `composer.json` (not the library's): + +```json +{ + "extra": { + "php-io-cli": { + "commands": [ + "App\\Commands\\MigrateCommand", + "Plugins\\Task\\Infrastructure\\Commands\\TaskListCommand" + ] + } + } +} +``` + +Classes that are absent, abstract, or not an `AbstractCommand` subclass are silently +skipped (logged under `--debug`). + +--- + +## Interactive Components + +All implement `IPromptComponent::run(): mixed`. Call `->run()` to start the reactive +loop and block until the user submits. Raw terminal mode is enabled and restored +automatically, including on `Ctrl+C`. + +### TextInput + +```php +use AlfacodeTeam\PhpIoCli\Components\TextInput; + +$host = (new TextInput('Database host')) + ->placeholder('localhost') + ->default('127.0.0.1') + ->validate(fn(string $v): ?string => + filter_var($v, FILTER_VALIDATE_IP) || $v === 'localhost' ? null : 'Invalid hostname' + ) + ->run(); // string +``` + +Keys: printable = insert; `←/→` = move cursor; `HOME/END` = jump; `Backspace/Delete` = delete; `Enter` = submit. + +### Password + +```php +use AlfacodeTeam\PhpIoCli\Components\Password; + +$secret = (new Password('Encryption key'))->showStrength()->run(); // string +``` + +Keys: printable = append; `Backspace` = delete last; `TAB` = toggle plaintext/masked; `Enter` = submit. + +Strength: length ≥ 8, ≥ 12, uppercase, digit, special — one point each → `Very weak` to `Strong`. + +### NumberInput + +```php +use AlfacodeTeam\PhpIoCli\Components\NumberInput; + +$port = (new NumberInput('Port'))->min(1)->max(65535)->default(8080)->step(1)->integer()->run(); // int +``` + +Keys: digits/`-`/`.` = append; `Backspace` = delete; `↑/↓` = step; `Enter` = submit (validates range). + +### Confirm + +```php +use AlfacodeTeam\PhpIoCli\Components\Confirm; + +$ok = (new Confirm('Overwrite?', default: false))->run(); // bool +``` + +Keys: `y/Y` = yes; `n/N` = no; `←/→` = toggle; `Enter` = confirm. + +### Select + +Fuzzy-filter single-selection with scroll window (8 items visible). + +```php +use AlfacodeTeam\PhpIoCli\Components\Select; + +$region = (new Select('Deploy region', ['eu-west-1', 'us-east-1', 'ap-southeast-1']))->run(); // string +``` + +Keys: printable = fuzzy filter; `↑/↓` = navigate; `Backspace` = delete filter char; `Enter` = confirm. + +### MultiSelect + +```php +use AlfacodeTeam\PhpIoCli\Components\MultiSelect; + +$features = (new MultiSelect('Enable features', ['Auth', 'API Gateway', 'Queue', 'Scheduler']))->run(); // string[] +``` + +Keys: `↑/↓` = navigate; `Space` = toggle; `Enter` = confirm. + +### Autocomplete + +```php +use AlfacodeTeam\PhpIoCli\Components\Autocomplete; + +$pkg = (new Autocomplete('Package', $allPackages))->maxSuggestions(8)->run(); // string +``` + +Keys: printable = type/filter; `↑/↓` = navigate dropdown; `TAB` = fill suggestion; `Enter` = confirm. + +### DatePicker + +```php +use AlfacodeTeam\PhpIoCli\Components\DatePicker; + +$date = (new DatePicker('Release date'))->run(); // DateTimeImmutable +``` + +Keys: `←/→` = prev/next day; `↑/↓` = prev/next week; `[/]` = prev/next month; `t` = today; `Enter` = confirm. + +### RadioGroup *(not in module README — fully implemented)* + +Best for ≤ 5 mutually exclusive choices. Renders all options at once (no scroll). + +```php +use AlfacodeTeam\PhpIoCli\Components\RadioGroup; + +$size = (new RadioGroup('T-shirt size', ['S', 'M', 'L', 'XL', 'XXL'])) + ->default('M') + ->columns(3) // render side-by-side + ->run(); // string +``` + +Keys: `↑/↓/←/→` = move focus; `1-9` = jump to position; `Enter` = confirm. + +### SliderInput *(not in module README — fully implemented)* + +Horizontal ASCII progress-bar slider for numeric ranges. + +```php +use AlfacodeTeam\PhpIoCli\Components\SliderInput; + +$volume = (new SliderInput('Volume', min: 0, max: 100)) + ->step(5)->default(50)->integer()->run(); // int + +$rate = (new SliderInput('Tax rate', min: 0.0, max: 1.0)) + ->step(0.01)->default(0.2)->run(); // float +``` + +Keys: `←/→` = one step; `[/]` = jump 10% of range; `HOME/END` = jump to extremes; `Enter` = submit (snaps to step). + +--- + +## Display Components + +### Table + +```php +use AlfacodeTeam\PhpIoCli\Components\Table; +use AlfacodeTeam\PhpIoCli\Depends\Colors; + +Table::make() + ->headers(['Service', 'Status', 'Latency']) + ->rows([ + ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms'], + ['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms'], + ]) + ->style('box') // 'box' | 'bold' | 'compact' | 'minimal' + ->align([2 => 'right']) + ->striped() + ->render(); +``` + +Column widths are measured on the **stripped** (ANSI-free) string — color codes never corrupt alignment. + +### Alert + +```php +use AlfacodeTeam\PhpIoCli\Components\Alert; + +Alert::success('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']); +Alert::error('Migration failed', ['Error: duplicate key on users.email']); +Alert::warning('Quota at 80%', ['Resets in 4 h']); +Alert::info('Maintenance 02:00–04:00 UTC'); +Alert::block('FATAL', 'Emergency shutdown required'); // solid background +``` + +### ProgressBar + +```php +use AlfacodeTeam\PhpIoCli\Components\ProgressBar; + +// Determinate — shows ETA + throughput +$bar = new ProgressBar('Processing', total: 1000); +$bar->start(); +foreach ($records as $r) { process($r); $bar->advance(); } +$bar->finish('All done'); + +// Indeterminate — bounce animation (total = 0) +$bar = new ProgressBar('Waiting for lock'); +$bar->start(); +// ... work ... +$bar->finish('Lock acquired'); + +// Fluent config +$bar->width(60)->fill('▓')->empty('░'); + +// advance(0) = redraw only (use in Shell::run() tick for animated steps) +$bar->advance(0); +``` + +### SpinnerComponent + +```php +use AlfacodeTeam\PhpIoCli\Components\SpinnerComponent; + +$spin = new SpinnerComponent('Connecting', style: 'dots'); // dots|line|bars|pulse|arc|bounce +$spin->start(); +$result = doSlowWork(); +$result->ok() ? $spin->stop('Connected') : $spin->fail('Refused'); +``` + +--- + +## Shell Execution + +`Shell::run()` uses `proc_open` + `stream_select()` (≤50 ms poll) to drain stdout and stderr +simultaneously — no pipe deadlock possible. A `$tick` callback fires on every poll cycle, +making it composable with progress animations. + +```php +use AlfacodeTeam\PhpIoCli\Depends\Shell; + +$result = Shell::run( + command: 'composer install --no-interaction', + tick: function (string $lastLine, bool $isStderr): void { + // called every ≤50 ms + }, + env: ['COMPOSER_NO_INTERACTION' => '1'], + cwd: '/var/www/app', +); + +if ($result->failed()) { + echo $result->errors(); // all stderr joined + exit($result->exitCode); +} +echo $result->output(); // all stdout joined +``` + +`Shell::capture()` — convenience for quick value reads: + +```php +$branch = Shell::capture('git rev-parse --abbrev-ref HEAD', cwd: $root); // string|null +``` + +**ShellResult API:** + +```php +$result->ok() // exitCode === 0 +$result->failed() // exitCode !== 0 +$result->exitCode // int +$result->output() // stdout joined with PHP_EOL +$result->errors() // stderr joined with PHP_EOL +$result->meaningfulErrors() // non-empty stderr lines as string[] +$result->stdout // string[] raw lines +$result->stderr // string[] raw lines +``` + +### Shell + ProgressBar pattern (canonical for animated steps) + +```php +protected function handle(): int +{ + $bar = $this->progressBar('Deploying', total: 4); + $bar->start(); + + $result = Shell::run( + 'composer install --no-dev', + tick: fn() => $bar->advance(0), // redraw without incrementing + cwd: $this->projectRoot(), + ); + + if ($result->failed()) { + $bar->finish('Aborted'); + $this->alertError('composer install failed', $result->meaningfulErrors()); + return self::FAILURE; + } + $bar->advance(); // step 1 done — bar moves forward + + $this->generateConfig(); + $bar->advance(); // step 2 done + + $bar->finish('Deployment complete'); + return self::SUCCESS; +} +``` + +> Never instantiate two `ProgressBar` instances simultaneously — their `moveCursorUp()` +> calls interfere and produce interleaved frames. Pass a single instance by reference into helpers. + +--- + +## I/O Layer + +### IOInterface + +`IOInterface` extends PSR-3 `LoggerInterface` — severity levels map to ANSI-themed output. + +```php +// Verbosity levels +$io->write('Always', verbosity: IOInterface::NORMAL); +$io->write('With --verbose', verbosity: IOInterface::VERBOSE); +$io->write('With -vv', verbosity: IOInterface::VERY_VERBOSE); +$io->write('With --debug', verbosity: IOInterface::DEBUG); + +// Interactive +$io->ask('Name', default: 'world'); +$io->askConfirmation('Sure?', default: true); +$io->askAndValidate('Email', fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL) ? $v : throw new \RuntimeException('Invalid')); +$io->askAndHideAnswer('Password'); +$io->select('Env', ['prod', 'staging'], default: 'staging'); +$io->select('Features', ['Auth', 'API'], default: 0, multiselect: true); +``` + +### ConsoleIO + +Real-terminal implementation. On a TTY (`posix_isatty(STDIN) === true`) every interactive +method delegates to the reactive component. On non-TTY (CI, pipes) it falls back to Symfony +`QuestionHelper`. + +```php +use AlfacodeTeam\PhpIoCli\ConsoleIO; +use Symfony\Component\Console\Helper\{HelperSet, QuestionHelper}; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\ConsoleOutput; + +$io = new ConsoleIO(new ArgvInput(), new ConsoleOutput(), new HelperSet([new QuestionHelper()])); +$io->enableDebugging(microtime(true)); // prepends [MiB/s] to every line +``` + +### BufferIO + +In-memory for testing. `getOutput()` strips all ANSI sequences + backspace chars. + +```php +use AlfacodeTeam\PhpIoCli\BufferIO; + +$io = new BufferIO(); +$io->setUserInputs(['n']); // simulated keystrokes — one string per prompt +$command->execute(['my-module'], $io); +assertStringContainsString('Aborted', $io->getOutput()); +``` + +### NullIO + +Silent — every write is a no-op, every interactive method returns its `$default`. + +```php +$io = new NullIO(); +$io->ask('Name', 'fallback'); // 'fallback' +$io->askConfirmation('Sure?', true); // true +$io->write('ignored'); // no-op +``` + +--- + +## Colors Utility + +```php +use AlfacodeTeam\PhpIoCli\Depends\Colors; + +Colors::wrap('text', Colors::BOLD); +Colors::wrap('text', [Colors::BOLD, Colors::CYAN]); +Colors::success('Done'); // ✔ green bold +Colors::error('Failed'); // ✘ red bold +Colors::warning('Caution'); // ! yellow bold +Colors::info('Note'); // cyan +Colors::muted('Skipped'); // dim gray +Colors::hex('#e94560', 'Alert!'); // true-color +Colors::line('text', [Colors::GREEN, Colors::BOLD]); // print line directly +Colors::strip($ansiString); // returns plain string (for width measurement / testing) +Colors::enable(); // force on +Colors::disable(); // force off (also triggered by --no-ansi) +``` + +Auto-detects environment: `NO_COLOR`, `FORCE_COLOR`, Windows VT100, Unix TTY. + +--- + +## Internals — Reactive Lifecycle + +Every interactive component extends `Component → AbstractPrompt`: + +``` +run() + ├── Terminal::enableRaw() + ├── mount() → setup() ← wire State + Input bindings + └── loop: + ├── render() ← draw current frame to terminal + ├── readKey() ← block until keypress + └── update() ← dispatch key → Input bindings → mutate State + → State watchers fire → context.markDirty() → re-render next cycle +``` + +On `CTRL+C`: `handleCancel()` prints cancellation and exits cleanly. +On exception: `handleError()` prints error then rethrows. +`destroy()` + `Terminal::disableRaw()` run in a `finally` — terminal always restored. + +### State + +```php +use AlfacodeTeam\PhpIoCli\Depends\State; + +$state = new State(['count' => 0]); +$state->count = 5; // magic set +echo $state->count; // magic get +$state->batch(['index' => 0, 'done' => false]); // single-notification batch +$state->increment('index', max: 9); // clamps at max +$state->decrement('index'); // clamps at 0 +$state->toggle('selected', 'Auth'); // add if absent, remove if present +$state->watch('index', fn($new, $old, $s) => ...); +``` + +### Input Bindings + +```php +use AlfacodeTeam\PhpIoCli\Depends\Input; + +$input = new Input(); +$input->bind('ENTER', fn($s) => $this->stop()); +$input->bind(['y', 'Y'], fn($s) => $s->confirmed = true); +$input->fallback(function ($s, $key): void { + if (Key::isPrintable($key)) { $s->value .= $key; } +}); +$input->unbind('ESC'); +``` + +Normalized key names: `UP`, `DOWN`, `LEFT`, `RIGHT`, `HOME`, `END`, `ENTER`, `TAB`, +`ESC`, `BACKSPACE`, `DELETE`, `CTRL_C`, `CTRL_D`, printable chars as-is. + +### Hooks (Event Bus) + +```php +use AlfacodeTeam\PhpIoCli\Hooks; + +$hooks = new Hooks(); +$hooks->on('submit', function (mixed $value, string $event, Hooks $hooks): void { ... }); +$hooks->once('mount', fn() => $this->loadDefaults()); // auto-unsubscribes after first fire +$hooks->dispatch('submit', $resolvedValue); +$handled = $hooks->dispatchUntil('validate', $inputValue); // Chain of Responsibility +$hooks->off('render', $handler); // unsubscribe specific handler +$hooks->off('render'); // remove all handlers for event +``` + +Standard lifecycle events: `mount`, `render`, `update`, `submit`, `destroy`. + +--- + +## Testing Commands + +```php +use AlfacodeTeam\PhpIoCli\BufferIO; +use PHPUnit\Framework\TestCase; + +class GenerateInvoicesCommandTest extends TestCase +{ + public function test_generates_successfully(): void + { + $io = new BufferIO(); + $io->setUserInputs(['y']); // answer the confirm() prompt + + $cmd = new GenerateMonthlyInvoicesCommand(new FakeInvoiceService()); + $exit = $cmd->execute(['2025-01'], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Generated', $io->getOutput()); + } + + public function test_aborts_on_decline(): void + { + $io = new BufferIO(); + $io->setUserInputs(['n']); + + $exit = (new GenerateMonthlyInvoicesCommand(new FakeInvoiceService())) + ->execute([], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('Aborted', $io->getOutput()); + } +} +``` + +`getOutput()` returns ANSI-stripped, backspace-cleaned plain text — safe for +`assertStringContainsString()` regardless of terminal formatting. + +--- + +## Component Inventory + +| Component | Namespace | Type | Returns | +|---|---|---|---| +| `TextInput` | `Components\` | Interactive | `string` | +| `Password` | `Components\` | Interactive | `string` | +| `NumberInput` | `Components\` | Interactive | `int\|float` | +| `Confirm` | `Components\` | Interactive | `bool` | +| `Select` | `Components\` | Interactive | `string` | +| `MultiSelect` | `Components\` | Interactive | `string[]` | +| `Autocomplete` | `Components\` | Interactive | `string` | +| `DatePicker` | `Components\` | Interactive | `DateTimeImmutable` | +| `RadioGroup` | `Components\` | Interactive | `string` | +| `SliderInput` | `Components\` | Interactive | `int\|float` | +| `Table` | `Components\` | Display | void | +| `Alert` | `Components\` | Display | void | +| `ProgressBar` | `Components\` | Display | void | +| `SpinnerComponent` | `Components\` | Display | void | +| `Colors` | `Depends\` | Utility | varies | +| `Shell` / `ShellResult` | `Depends\` | Utility | `ShellResult` | +| `State` | `Depends\` | Reactive | — | +| `Input` | `Depends\` | Reactive | — | +| `Terminal` | `Depends\` | Driver | — | +| `Hooks` | root | Event bus | — | + +> `RadioGroup` and `SliderInput` are fully implemented but not documented in the +> module's own README. Use them freely — they follow the same lifecycle contract. + +--- + +## What MUST NOT Happen With php-io-cli + +``` +✗ Extending Symfony Console Command — extend AbstractCommand only +✗ Using InputInterface / OutputInterface inside handle() — use $this->argument(), $this->option(), $this->info() etc. +✗ Using $output->writeln('...') — use $this->info(), $this->success(), $this->warning(), $this->error() +✗ Using CommandContract, Arguments, or Output from Cli/ — all @deprecated +✗ Instantiating two ProgressBar instances simultaneously — cursor interference +✗ Calling $bar->advance() inside Shell::run() tick — use advance(0) to redraw only +✗ Binding DomainContext into CLIApplication or AbstractCommand — it rides on Request only +✗ Direct instantiation of AbstractCommand in module Provider — use $cli->command(ClassName::class) +``` diff --git a/docs/guides/18_MIGRATIONS.md b/docs/guides/18_MIGRATIONS.md new file mode 100644 index 0000000..faa8f8a --- /dev/null +++ b/docs/guides/18_MIGRATIONS.md @@ -0,0 +1,917 @@ +# Database Migrations — LetMigrate Engine + +> **Module:** `alfacode-team/let-migrate` (`modules/let-migrate/`) +> **Namespace:** `AlfaCode\LetMigrate\` +> **Supports:** MySQL, PostgreSQL, SQLite, SQL Server +> **Requires:** PHP 8.2+, PSR-3 logger, PDO extension +> **Framework:** Standalone — **zero framework dependencies** + +--- + +## WHAT IS LETMIGRATE? + +LetMigrate is an **enterprise-grade**, multi-database migration engine that: + +- ✅ Writes migrations once, compiles to correct DDL per database +- ✅ Manages per-driver migration folders (mysql/, postgresql/, sqlite/, sqlserver/) +- ✅ Provides fluent `Blueprint` API for schema changes +- ✅ Supports batched runs, rollbacks, and full transaction safety +- ✅ Includes seeder engine with dependency resolution +- ✅ Ships with complete CLI commands (migrate:run, migrate:rollback, make:migration, db:seed, etc.) +- ✅ Extensible — register custom drivers and grammars via `DriverRegistry` +- ✅ Framework-agnostic — works in any PHP 8.2+ project + +**Do NOT use Laravel migrations, Doctrine migrations, or Symfony migrations.** +Every project in this framework uses **LetMigrate exclusively**. + +--- + +## BOOTSTRAP & CONFIGURATION + +### Minimal Setup + +```php +use AlfaCode\LetMigrate\LetMigrate; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('migrations'); +$logger->pushHandler(new StreamHandler('php://stdout')); + +$engine = LetMigrate::configure([ + 'driver' => 'mysql', // or pgsql, sqlite, sqlserver + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'my_app', + 'username' => 'root', + 'password' => env('DB_PASSWORD'), + 'paths' => [__DIR__ . '/migrations/mysql'], +], $logger); + +// Run pending migrations +$result = $engine->run(); +echo $result->summary(); // "3 migration(s) applied in batch 1." +``` + +### Multi-Database Setup + +For projects targeting multiple databases, use separate folders per driver: + +```php +$engine = LetMigrate::configure([ + 'driver' => env('DB_DRIVER', 'mysql'), // env-driven + 'host' => env('DB_HOST'), + 'database' => env('DB_NAME'), + 'username' => env('DB_USER'), + 'password' => env('DB_PASS'), + 'paths' => [ + __DIR__ . '/migrations/' . env('DB_DRIVER'), // driver-specific + __DIR__ . '/migrations/shared', // driver-agnostic seeds + ], +], $logger); +``` + +### Config Options + +```php +LetMigrate::configure([ + // Connection (required) + 'driver' => 'mysql|pgsql|sqlite|sqlserver', + 'host' => '127.0.0.1', // required for non-SQLite + 'port' => 3306, + 'database' => 'my_app', // file path for SQLite + 'username' => 'root', // optional for SQLite + 'password' => 'secret', // optional for SQLite + + // Paths (required) + 'paths' => [__DIR__ . '/migrations'], + + // Options (all optional) + 'pretend' => false, // log SQL without executing + 'table' => 'let_migrations', // tracking table name + 'batch' => 1, // initial batch number +], $logger); +``` + +--- + +## WRITING MIGRATIONS + +### File Naming Convention + +``` +YYYY_MM_DD_NNNNNN_description.php +2024_01_15_000001_create_users_table.php +2024_01_15_000002_create_posts_table.php +2024_03_22_000001_add_avatar_to_users.php +``` + +Files are sorted lexicographically — the timestamp prefix guarantees correct order. + +### Basic Migration Template + +Every migration file must return a class implementing `MigrationInterface`: + +```php +create('users', static function (Blueprint $t): void { + $t->id(); + $t->string('email', 191)->unique()->notNull(); + $t->string('password'); + $t->timestamps(); + $t->softDeletes(); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('users'); + } +}; +``` + +### Use `make:migration` to Scaffold + +Instead of writing from scratch, use the `make:migration` command. It writes a +blank, timestamped migration stub into the configured migrations directory +(the first entry of `paths[]`); pass `--path` to override the destination: + +```bash +# New migration (snake_case name) +php app/cli/run.php make:migration create_invoices_table + +# Override the output directory +php app/cli/run.php make:migration add_status_to_posts --path=/abs/dir +``` + +The scaffolder: +- Auto-generates filename with correct timestamp + sequence number +- Converts snake_case names to YYYY_MM_DD_NNNNNN format +- Guards against overwriting existing files +- Prepares stub with placeholder methods + +--- + +## BLUEPRINT API — COLUMN DEFINITIONS + +### Numeric Columns + +```php +$t->id(); // BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT +$t->tinyInteger('x'); // TINYINT +$t->smallInteger('x'); // SMALLINT +$t->mediumInteger('x'); // MEDIUMINT (→ INT on non-MySQL) +$t->integer('x'); // INT +$t->bigInteger('x'); // BIGINT +$t->decimal('price', 8, 2); // DECIMAL(8,2) +$t->float('rate'); // FLOAT +$t->double('score'); // DOUBLE + +// Unsigned shorthands (equivalent to ->unsigned() on the base type) +$t->unsignedTinyInteger('x'); // TINYINT UNSIGNED +$t->unsignedSmallInteger('x'); // SMALLINT UNSIGNED +$t->unsignedMediumInteger('x'); // MEDIUMINT UNSIGNED +$t->unsignedInteger('x'); // INT UNSIGNED +$t->unsignedBigInteger('x'); // BIGINT UNSIGNED +``` + +### String Columns + +```php +$t->char('code', 36); // CHAR(36) +$t->string('name', 255); // VARCHAR(255) — default length 255 +$t->text('body'); // TEXT +$t->longText('content'); // LONGTEXT +``` + +### Date / Time Columns + +```php +$t->date('born_on'); // DATE +$t->dateTime('happened_at'); // DATETIME (MySQL) / TIMESTAMP (PG) +$t->timestamp('created_at'); // TIMESTAMP +$t->timestamps(); // created_at + updated_at (auto-managed) +$t->softDeletes(); // deleted_at DATETIME NULL +``` + +### Special Columns + +```php +$t->boolean('is_active'); // TINYINT(1) (MySQL) / BOOLEAN (others) +$t->json('metadata'); // JSON +$t->enum('status', ['a', 'b']); // ENUM('a','b') +$t->uuid('id'); // CHAR(36) +$t->binary('data'); // BLOB +``` + +### Column Modifiers (chainable) + +```php +$t->string('email') + ->nullable() // allow NULL + ->notNull() // NOT NULL (default for most types) + ->unsigned() // UNSIGNED (numeric columns only) + ->default('hello') // DEFAULT value + ->default('CURRENT_TIMESTAMP') // raw expression (special cases only) + ->comment('User email address') // COMMENT + ->after('id') // column position (MySQL only) + ->first() // place first (MySQL only) + ->unique() // inline UNIQUE constraint + ->primary() // mark as PRIMARY KEY (see note) + ->autoIncrement(); // AUTO_INCREMENT (numeric columns) + ->onUpdateCurrentTimestamp(); // on-update trigger (see below) +``` + +> **`->primary()` vs `$t->primary([...])`.** The column modifier `->primary()` +> marks a single column as the table's primary key. The grammar emits it as a +> standalone `PRIMARY KEY (col)` clause (NOT inline next to the column) so it +> never collides with the auto-increment path — an auto-increment `id()` stays +> inline (`INTEGER PRIMARY KEY AUTOINCREMENT` on SQLite). For a **composite** +> primary key use the Blueprint method `$t->primary(['a', 'b'])`. Do not use +> both on the same table. +> +> **There is no fluent `->index()` or `->useCurrent()` column modifier.** +> Declare indexes with the Blueprint method `$t->index(['col'])` (see below), +> and a current-timestamp default with `->default('CURRENT_TIMESTAMP')`. + +### Timestamps Behavior + +```php +$t->timestamps(); +// Expands to: +// - created_at: DATETIME DEFAULT CURRENT_TIMESTAMP +// - updated_at: DATETIME DEFAULT CURRENT_TIMESTAMP +// + MySQL: ON UPDATE CURRENT_TIMESTAMP (inline) +// + PostgreSQL: BEFORE UPDATE trigger (auto-created) +// + SQLite: created by SQLite timestamp triggers +// + SQL Server: DATETIME2 with computed columns +``` + +For custom on-update behavior, use `onUpdateCurrentTimestamp()` modifier: + +```php +$t->dateTime('synced_at') + ->nullable() + ->default('CURRENT_TIMESTAMP') + ->onUpdateCurrentTimestamp(); // triggers DB-level auto-update +``` + +--- + +## BLUEPRINT API — INDEXES & CONSTRAINTS + +### Indexes + +```php +$t->index(['email']); // single-column index +$t->index(['tenant_id', 'status']); // composite index +$t->unique(['email']); // UNIQUE constraint +$t->unique(['code'], 'uq_code'); // named UNIQUE +$t->primary(['user_id', 'role_id']); // composite PRIMARY KEY +``` + +### Foreign Keys + +```php +$t->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('CASCADE') // or cascadeOnDelete() + ->onUpdate('RESTRICT') // or restrictOnDelete() + ->name('fk_user_id'); // optional custom name +``` + +When referencing a composite primary key: + +```php +$t->foreign(['user_id', 'role_id']) + ->references(['user_id', 'role_id']) + ->on('user_roles'); +``` + +--- + +## BLUEPRINT API — TABLE OPERATIONS + +### Create Table + +```php +$schema->create('posts', static function (Blueprint $t): void { + $t->id(); + $t->string('title'); + $t->text('body'); + $t->bigInteger('author_id'); + $t->foreign('author_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + $t->timestamps(); +}); +``` + +### Table Options (MySQL) + +Chainable table-level options on the Blueprint. These are emitted **only** by the +MySQL grammar — PostgreSQL, SQLite, and SQL Server ignore them (their grammars +override `compileTableOptions()` to return an empty string), so the same migration +stays portable. + +```php +$schema->create('users', static function (Blueprint $t): void { + $t->id(); + $t->string('email'); + + $t->engine('InnoDB'); // ENGINE=InnoDB (default) + $t->charset('utf8mb4'); // DEFAULT CHARSET=utf8mb4 (default) + $t->collation('utf8mb4_0900_ai_ci'); // COLLATE=… (default utf8mb4_unicode_ci) + $t->rowFormat('DYNAMIC'); // ROW_FORMAT=DYNAMIC + $t->comment('Core project registry'); // COMMENT='…' (table-level) +}); +``` + +`rowFormat()` accepts `DYNAMIC` | `COMPACT` | `COMPRESSED` | `REDUNDANT` | `FIXED` +(case-insensitive — normalised to upper-case). Omit it to let MySQL choose the +engine default. `comment()` sets the MySQL table-level `COMMENT='…'`. + +### CHECK Constraints (portable) + +`check()` adds a table-level CHECK constraint, emitted inline in the CREATE TABLE +body. Unlike table options, this is **portable across every driver** — MySQL +8.0.16+/MariaDB 10.2+, PostgreSQL, SQLite, and SQL Server all support inline +CHECK. Pass a raw boolean SQL expression using **unquoted** column names so it +stays dialect-neutral, plus an optional constraint name. + +```php +$schema->create('projects', static function (Blueprint $t): void { + $t->id('project_id'); + $t->tinyInteger('status')->unsigned()->default(1); + $t->tinyInteger('visibility')->unsigned()->default(4); + + $t->check('status between 1 and 3', 'chk_status'); // CONSTRAINT chk_status CHECK (...) + $t->check('visibility between 4 and 19', 'chk_visibility'); + $t->check('type between 0 and 5'); // anonymous CHECK (name auto-assigned by DB) +}); +``` + +### Alter Existing Table + +```php +$schema->table('users', static function (Blueprint $t): void { + $t->string('avatar_url', 500)->nullable(); +}); +``` + +### Modify Column (alter existing) + +```php +$schema->table('users', static function (Blueprint $t): void { + $t->modifyColumn('email', fn($c) => + $c->string(320)->notNull()->unique() + ); +}); +``` + +### Rename Column + +```php +$schema->table('users', static function (Blueprint $t): void { + $t->renameColumn('full_name', 'name'); +}); +``` + +### Drop Table + +```php +$schema->drop('users'); // fails if table doesn't exist +$schema->dropIfExists('users'); // safe +``` + +### Check Table / Column Existence + +```php +if ($schema->hasTable('users')) { + echo "Users table exists"; +} + +if ($schema->hasColumn('users', 'email')) { + echo "Email column exists"; +} +``` + +--- + +## SCHEMA INTROSPECTION + +LetMigrate can inspect existing database schemas to make intelligent decisions: + +```php +$inspector = $schema->getInspector(); +if ($inspector === null) { + throw new \LogicException('Inspector not available for this driver'); +} + +// Get all tables +$tables = $inspector->getTables(); // string[] + +// Get columns of a table +$columns = $inspector->getColumns('users'); // ColumnMeta[] +foreach ($columns as $col) { + echo $col->name; // string + echo $col->type; // 'string', 'integer', 'boolean', etc. + echo $col->nullable; // bool + echo $col->default; // ?string + echo $col->primaryKey; // bool + echo $col->autoIncrement; // bool +} + +// Get indexes +$indexes = $inspector->getIndexes('users'); // IndexMeta[] + +// Get foreign keys +$fks = $inspector->getForeignKeys('posts'); // ForeignKeyMeta[] +``` + +--- + +## RUNNER OPERATIONS + +### Run Pending Migrations + +```php +$result = $engine->run(); +echo $result->summary(); // "3 migration(s) applied in batch 1." +echo $result->count(); // 3 +echo $result->batch(); // 1 +``` + +### Rollback + +```php +// Roll back the last batch +$result = $engine->rollback(); + +// Roll back the last N batches +$result = $engine->rollback(steps: 3); + +// Roll back ALL migrations (dev only) +$result = $engine->reset(); + +// Reset + re-run everything +$result = $engine->refresh(); +``` + +### Status & Inspection + +```php +// Status of all migrations +$status = $engine->status(); +// [ +// '2024_01_01_000001_create_users' => [ +// 'status' => 'applied|pending', +// 'batch' => 1, // null if pending +// ], +// ... +// ] + +// List only pending migrations +$pending = $engine->pending(); // string[] +``` + +### Result Object + +```php +$result = $engine->run(); + +$result->count(); // int — migrations applied/rolled back +$result->batch(); // int — batch number +$result->direction(); // 'up' | 'down' +$result->summary(); // string — formatted message +$result->wasSuccessful(); // bool +``` + +--- + +## EVENTS & LIFECYCLE HOOKS + +```php +use AlfaCode\LetMigrate\Event\MigrationStarted; +use AlfaCode\LetMigrate\Event\MigrationFinished; +use AlfaCode\LetMigrate\Event\MigrationFailed; +use AlfaCode\LetMigrate\Event\MigrationsCompleted; + +// Migration about to run +$engine->events()->on(MigrationStarted::class, function (MigrationStarted $e): void { + echo "⏳ Starting: {$e->migration} ({$e->direction})\n"; + echo "Batch: {$e->batch}\n"; +}); + +// Migration finished successfully +$engine->events()->on(MigrationFinished::class, function (MigrationFinished $e): void { + echo "✔ Done: {$e->migration}\n"; + echo "Duration: {$e->durationMs}ms\n"; +}); + +// Migration failed +$engine->events()->on(MigrationFailed::class, function (MigrationFailed $e): void { + echo "✗ Failed: {$e->migration}\n"; + // Report to error tracking + Sentry::captureException($e->exception); +}); + +// All migrations completed +$engine->events()->on(MigrationsCompleted::class, function (MigrationsCompleted $e): void { + echo $e->result->summary(); +}); + +// Run them +$result = $engine->run(); +``` + +--- + +## SEEDER ENGINE + +### Writing Seeders + +Seeders populate the database with test/reference data. The `SeederRunner` +resolver accepts **two file styles** — pick either: + +**1. Named class (the `make:seeder` scaffold).** The class name MUST match the +file name (`UsersSeeder.php` → `class UsersSeeder`): + +```php +insert('users', ['name' => 'Alice', 'email' => 'a@example.test']); + } + + public function getDependencies(): array + { + return []; // or ['SomeOtherSeeder'] for dependency ordering + } +} +``` + +**2. Returned instance (anonymous class), like a migration file:** + +```php +execute('INSERT INTO statuses (name) VALUES (?)', ['active']); + $db->execute('INSERT INTO statuses (name) VALUES (?)', ['inactive']); + } + + public function getDependencies(): array + { + return []; + } +}; +``` + +Scaffold one with: `php app/cli/run.php make:seeder UsersSeeder`. + +### Seeder Commands + +Seeding runs through the `db:seed` command (there is no `seed:*` group): + +```bash +# Run all pending seeders (tracked in let_seeders — idempotent) +php app/cli/run.php db:seed + +# Run only one seeder by name (file basename / class name) +php app/cli/run.php db:seed --class UsersSeeder +``` + +### SeederRunner (Programmatic) + +```php +use AlfaCode\LetMigrate\Seeder\SeederRunner; +use AlfaCode\LetMigrate\Seeder\SeederRepository; + +$repository = new SeederRepository($driver, $grammar); // tracking table: let_seeders +$runner = new SeederRunner( + driver: $driver, + repository: $repository, + paths: [__DIR__ . '/seeders'], + // logger: $logger, // optional PSR-3 logger +); + +// Run pending seeders; pass a name to run just one. +$applied = $runner->run(); // string[] of seeder names +$applied = $runner->run(only: 'UsersSeeder'); // single seeder +$applied = $runner->run(force: true); // re-run even if recorded + +// Re-run all (ignores the tracking table) +$applied = $runner->fresh(); + +// Get status +$status = $runner->status(); +// [ +// 'SomeSeeder' => ['status' => 'applied|pending'], +// ... +// ] +``` + +### Seeder Dependency Ordering + +Seeders with dependencies are run **after** their dependencies (topological sort): + +```php +// DatabaseSeeder.php — runs first (no dependencies) +return new class implements SeederInterface { + public function getDependencies(): array { return []; } + public function run(DatabaseDriverInterface $db): void { + // Create base reference data + } +}; + +// PermissionSeeder.php — depends on DatabaseSeeder +return new class implements SeederInterface { + public function getDependencies(): array { + return ['DatabaseSeeder']; // class name string + } + public function run(DatabaseDriverInterface $db): void { + // Uses data from DatabaseSeeder + } +}; + +// Circular dependencies throw LetMigrateException at runtime +``` + +--- + +## PRETEND MODE (CI PREVIEWS) + +Test what SQL would be executed without touching the database: + +```php +$engine = LetMigrate::configure([ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'database' => 'staging_db', + 'paths' => [__DIR__ . '/migrations/mysql'], + 'pretend' => true, // ← log SQL, execute nothing +], $logger); + +$engine->run(); // logs all SQL statements without executing +``` + +Used in CI/CD pipelines to verify migrations compile correctly before applying to production. + +--- + +## CUSTOM DRIVERS & GRAMMARS + +Extend LetMigrate to support additional databases: + +```php +use AlfaCode\LetMigrate\Registry\DriverRegistry; +use AlfaCode\LetMigrate\Contract\DatabaseDriverInterface; +use AlfaCode\LetMigrate\Schema\GrammarInterface; + +// 1. Implement custom driver (extends AbstractPdoDriver) +class CockroachDBDriver extends AbstractPdoDriver { + // ... PDO connection + execution logic +} + +// 2. Implement custom grammar (extends AbstractGrammar) +class CockroachDBGrammar extends AbstractGrammar { + // ... DDL compilation rules +} + +// 3. Register via DriverRegistry +DriverRegistry::extendDriver('cockroach', fn($cfg) => new CockroachDBDriver($cfg)); +DriverRegistry::extendGrammar('cockroach', fn($cfg) => new CockroachDBGrammar()); + +// 4. Now use it normally +$engine = LetMigrate::configure([ + 'driver' => 'cockroach', + 'host' => '127.0.0.1', + 'database' => 'mydb', + 'paths' => [__DIR__ . '/migrations/cockroach'], +]); +``` + +--- + +## DATABASE-SPECIFIC NOTES + +### MySQL / MariaDB + +- InnoDB engine, utf8mb4 charset by default +- Backtick identifier quoting: `` `table` `` +- `AUTO_INCREMENT`, `FOREIGN KEY … ON DELETE CASCADE` +- `ON UPDATE CURRENT_TIMESTAMP` supported inline +- Column positioning: `->after('col')`, `->first()` +- Table options: `->engine()`, `->charset()`, `->collation()`, `->rowFormat('DYNAMIC')`, `->comment('…')` (MySQL-only) +- CHECK constraints via `->check('expr', 'name')` are portable (inline in CREATE TABLE) on all four drivers + +### PostgreSQL + +- Double-quote identifier quoting: `"table"` +- `BIGSERIAL` / `SERIAL` for auto-increment +- `TIMESTAMP` instead of `DATETIME`; `JSONB` instead of `JSON` +- **On-update triggers:** `timestamps()` and `onUpdateCurrentTimestamp()` create `BEFORE UPDATE` triggers automatically +- FK checks via `SET session_replication_role` +- No column positioning (Postgres doesn't support it) + +### SQLite + +- All types mapped to SQLite affinity groups (INTEGER, TEXT, REAL, BLOB) +- `INTEGER PRIMARY KEY AUTOINCREMENT` for auto-increment +- FK checks via `PRAGMA foreign_keys` +- DDL is transactional — migrations are fully rolled back on failure +- Limited ALTER TABLE support (only rename + add column) + +### SQL Server + +- Square-bracket `[identifier]` quoting: `[table]` +- `IDENTITY(1,1)` for auto-increment +- `NVARCHAR(MAX)` for Unicode text +- `DATETIME2` for timestamps (higher precision than DATETIME) +- No native on-update triggers — handled via computed columns +- FK checks via `sp_MSforeachtable` (no standard constraint syntax) + +--- + +## ERROR HANDLING + +LetMigrate throws domain-specific exceptions: + +```php +use AlfaCode\LetMigrate\Exception\LetMigrateException; +use AlfaCode\LetMigrate\Exception\ConnectionException; +use AlfaCode\LetMigrate\Exception\MigrationException; +use AlfaCode\LetMigrate\Exception\QueryException; + +try { + $engine->run(); +} catch (ConnectionException $e) { + echo "Database connection failed: {$e->getMessage()}"; +} catch (MigrationException $e) { + echo "Migration logic error: {$e->getMessage()}"; +} catch (QueryException $e) { + echo "SQL execution failed: {$e->getMessage()}"; +} catch (LetMigrateException $e) { + echo "Migration engine error: {$e->getMessage()}"; +} +``` + +--- + +## COMPLETE WORKFLOW EXAMPLE + +```php +pushHandler(new StreamHandler('php://stdout')); + +// 2. Configure engine +$engine = LetMigrate::configure([ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'database' => 'myapp', + 'username' => 'postgres', + 'password' => env('DB_PASSWORD'), + 'paths' => [ + __DIR__ . '/migrations/postgresql', + __DIR__ . '/migrations/shared', + ], +], $logger); + +// 3. (Optional) Hook events +$engine->events()->on( + \AlfaCode\LetMigrate\Event\MigrationFailed::class, + function ($e) { + Sentry::captureException($e->exception); + } +); + +// 4. Run +$result = $engine->run(); + +// 5. Check result +if (!$result->wasSuccessful()) { + exit(1); +} + +echo $result->summary(); +``` + +--- + +## What you must never do + +``` +✗ Use Laravel or Doctrine migrations in this project — only LetMigrate +✗ Import Eloquent, Doctrine, or Symfony migration classes +✗ Define routes in migrations or write any business logic +✗ Use float for money — use decimal() with precision + scale +✗ Write migrations without matching down() rollback +✗ Forget to run data migrations inside transactions (or explicit transaction handling) +✗ Use --seed in refresh without wiring SeederRunner to MigrateRefreshCommand +✗ Use a fluent `->index()` or `->useCurrent()` column modifier — they do NOT exist; use `$t->index(['col'])` and `->default('CURRENT_TIMESTAMP')` +✗ Combine a column `->primary()` with a Blueprint `$t->primary([...])` on the same table — pick one (double PRIMARY KEY error) +✗ Reference `seed:run`/`seed:fresh`/`seed:status` or `migrate:make` — the real commands are `db:seed` and `make:migration` +✗ Hardcode database table names — use string literals, never interpolation +✗ Call onUpdateCurrentTimestamp() on non-timestamp columns +✗ Use ON UPDATE CURRENT_TIMESTAMP on PostgreSQL (use trigger instead — LetMigrate handles this) +✗ Forget to add new env vars to config — migrations must declare all DB config they use +✗ Use pretend mode in production tests — only for CI previews +✗ Mutate migration files after they've been applied (create a new migration instead) +``` + +--- + +## CLI COMMANDS REFERENCE + +All commands are wired into `php-io-cli` and available via: + +```bash +php app/cli/run.php COMMAND [options] +``` + +### Migrate Commands + +| Command | Description | +|---|---| +| `migrate:run` | Apply all pending migrations | +| `migrate:rollback [--steps=3]` | Roll back last batch (or N batches) | +| `migrate:reset` | Roll back ALL migrations | +| `migrate:refresh [--seed]` | Reset + re-run all (optionally seed) | +| `migrate:status` | Show all migrations with run/pending status | +| `make:migration NAME` | Scaffold a new migration | +| `migrate:fresh` | Drop ALL tables and re-run every migration | +| `migrate:check` | CI drift guard — non-zero exit if live schema differs from target | +| `migrate:diff [--stdout] [--force]` | Emit a delta migration reconciling live DB → target | + +### Seed Commands + +| Command | Description | +|---|---| +| `db:seed` | Run all pending seeders (tracked in `let_seeders`) | +| `db:seed --class NAME` | Run only the named seeder | +| `make:seeder NAME` | Scaffold a new seeder class | + +--- + +## TESTING MIGRATIONS + +### Unit Tests + +```bash +composer test:unit # no DB needed +``` + +### Integration Tests + +```bash +composer test:int # requires pdo_sqlite +``` + +SQLite in-memory database used for integration tests — no MySQL/PostgreSQL installation required. + +### Full Test Suite + +```bash +composer check # cs-check + phpstan + all tests +``` + +--- + +## See Also + +- [CLI — php-io-cli Components](17_PHP_IO_CLI.md) +- [Commands & CLI Integration](14_CLI.md) +- [Error Handling](15_ERROR_HANDLING.md) diff --git a/docs/guides/19_DATABASE.md b/docs/guides/19_DATABASE.md new file mode 100644 index 0000000..b67d1f1 --- /dev/null +++ b/docs/guides/19_DATABASE.md @@ -0,0 +1,355 @@ +# 19 — DATABASE MODULE (Multi-Driver Persistence) + +> Enterprise multi-driver implementation of the kernel `DatabasePort`. +> Lives in `plugins/Database/` under the `Plugins\Database\` namespace. +> Solves the `database.management` domain. + +--- + +## WHAT THIS MODULE IS + +The Database module is the **single concrete implementation** of the kernel +`DatabasePort` interface. The kernel defines the port; this module provides a +production-grade adapter that speaks to four database engines through PDO: + +| Engine | Driver key | DSN prefix | +|---|---|---| +| MySQL / MariaDB | `mysql` | `mysql:` | +| PostgreSQL | `pgsql` | `pgsql:` | +| SQLite (file or `:memory:`) | `sqlite` | `sqlite:` | +| SQL Server | `sqlsrv` | `sqlsrv:` | + +Repositories depend on `DatabasePort` only. They never import a driver class or +the adapter — driver selection is an infrastructure concern resolved at boot from +`DB_*` environment variables. + +``` +Repository ──> DatabasePort (kernel interface) + ▲ + │ bound by Plugins\Database\Provider + │ + MultiDriverDatabaseAdapter ──> PDO ──> {MySQL|PostgreSQL|SQLite|SQL Server} +``` + +--- + +## FOLDER STRUCTURE + +``` +plugins/Database/ +├── module.json ← solves database.management, declares DB_* config +├── Provider.php ← wiring only: factory → adapter → DatabasePort +├── API/ +│ └── Contracts/ +│ ├── DatabaseConfigurationContract.php ← driver(), dsn(), pdoOptions(), initStatements() +│ └── DatabaseConnectionManagerContract.php ← named multi-connection registry +├── Infrastructure/ +│ ├── Drivers/ +│ │ ├── DatabaseConfigurationFactory.php ← alias resolution + per-driver defaults +│ │ ├── MySQLConfiguration.php +│ │ ├── PostgreSQLConfiguration.php +│ │ ├── SQLiteConfiguration.php +│ │ └── SqlServerConfiguration.php +│ ├── Persistence/ +│ │ ├── MultiDriverDatabaseAdapter.php ← DatabasePort implementation (direct) +│ │ ├── PooledDatabaseAdapter.php ← DatabasePort implementation (pool-backed, request-scoped) +│ │ ├── ConnectionManager.php ← DatabaseConnectionManagerContract implementation +│ │ └── SavepointGrammar.php ← driver-correct nested-transaction SQL +│ └── Pool/ +│ ├── ConnectionPool.php ← per-worker pool: warmup, validate, evict, stats +│ ├── PoolConfiguration.php ← min/max/timeouts/validate (DB_POOL_*) +│ └── PooledConnection.php ← slot wrapper (lifetime + idle bookkeeping) +└── Exceptions/ + └── ConnectionException.php ← the only exception that escapes the module +``` + +--- + +## THE FIVE ENTERPRISE BEHAVIOURS + +### 1. Lazy connection +The adapter does **not** open a socket in its constructor. PDO is created on the +first query (or explicit `pdo()` / `ping()` call). Booting a module that never +touches the database costs nothing — consistent with GDA "load only what is needed". + +```php +$db = new MultiDriverDatabaseAdapter($config); +$db->isConnected(); // false — no socket yet +$db->query('SELECT 1'); +$db->isConnected(); // true +``` + +### 2. Nested transactions via savepoints +`beginTransaction()` / `commit()` / `rollback()` **nest**. Only the outermost +level drives the real transaction; inner levels use `SAVEPOINT` so a partial +rollback does not abandon the whole unit of work. `SavepointGrammar` emits the +correct dialect (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO` for standard SQL; +`SAVE TRANSACTION` / `ROLLBACK TRANSACTION` for SQL Server). + +```php +$db->transaction(function (MultiDriverDatabaseAdapter $db) { + $db->execute('INSERT ...'); // outer + $db->transaction(fn ($db) => // inner — savepoint + $db->execute('INSERT ...')); +}); // single real COMMIT +``` + +`transaction(callable)` commits on success and rolls back on **any** throwable, +re-throwing the original exception. This is the preferred entry point for service +code that already wraps work in `TransactionManager`. + +### 3. Auto-reconnect +Long-running Swoole workers keep connections for hours. When a statement fails +with a "server has gone away" class error **and no transaction is active**, the +adapter transparently reconnects and retries the statement once. Inside a +transaction it does not retry (the transaction is already invalid) — it surfaces +the error so the caller rolls back. + +### 4. Post-connect init statements +Each driver returns `initStatements()` run immediately after connecting: + +| Driver | Statements | Why | +|---|---|---| +| SQLite | `PRAGMA foreign_keys = ON`, `busy_timeout = 5000`, `journal_mode = WAL`* | FK enforcement is **off by default** in SQLite | +| MySQL | `SET SESSION sql_mode = 'STRICT_ALL_TABLES,…'` | fail on truncation/coercion instead of silent corruption | +| SQL Server | `SET XACT_ABORT ON` | whole-transaction rollback on any runtime error | +| PostgreSQL | — | strict + FK-enforcing by default | + +\* WAL is skipped for `:memory:`. + +### 5. Query observability +Inject an optional PSR-3 `LoggerInterface`. Every statement is timed: +- `logQueries = true` → each query logged at **debug**. +- Any query slower than `slowQueryThresholdMs` (default 200ms) → logged at + **warning**, regardless of the debug flag. + +Set `DB_ENABLE_QUERY_LOG=true` to turn on debug logging through the Provider. + +--- + +## CONFIGURATION (ENV-DRIVEN) + +`DatabaseConfigurationFactory::fromEnvironment()` reads: + +| Variable | Applies to | Default | +|---|---|---| +| `DB_DRIVER` | all (aliases: `mariadb`, `postgres`, `mssql`, `sqlserver`, …) | `sqlite` | +| `DB_HOST` | mysql, pgsql, sqlsrv | driver default | +| `DB_PORT` | mysql, pgsql, sqlsrv | 3306 / 5432 / 1433 | +| `DB_DATABASE` | all (SQLite: file path or `:memory:`) | `:memory:` | +| `DB_USERNAME` / `DB_PASSWORD` | mysql, pgsql, sqlsrv | driver default | +| `DB_CHARSET` | mysql | `utf8mb4` | +| `DB_SSL_MODE` | pgsql (`disable`…`verify-full`) | `prefer` | +| `DB_SSL_VERIFY` / `DB_SSL_CA` | mysql | off | +| `DB_UNIX_SOCKET` | mysql, pgsql | — | +| `DB_ENCRYPT` / `DB_TRUST_SERVER_CERT` | sqlsrv | off | +| `DB_ENABLE_QUERY_LOG` | observability | off | + +Every variable is declared in `module.json` `config[]` — an undeclared variable +read by the module fails boot (GDA rule). + +--- + +## WIRING + +`Provider::register()` performs wiring only — no business logic: + +```php +$container->singleton(DatabaseConfigurationContract::class, fn () => + (new DatabaseConfigurationFactory())->fromEnvironment()); + +$container->bind(DatabasePort::class, fn ($c) => + new MultiDriverDatabaseAdapter( + config: $c->make(DatabaseConfigurationContract::class), + logger: /* optional PSR-3 */, + logQueries: env('DB_ENABLE_QUERY_LOG') === 'true', // env() — never getenv() for .env values + )); + +$container->singleton(DatabaseConnectionManagerContract::class, /* registry */); +``` + +The module is registered in `app/bootstrap/base.php`: + +```php +->withModules([ + Plugins\Database\Provider::class, + Plugins\Commands\Provider::class, +]); +``` + +--- + +## CONNECTION POOLING (OPT-IN, PER WORKER) + +Under OpenSwoole the kernel boots **once per worker** and handles many requests +on that long-lived process. Reconnecting to the database on every request wastes +the TCP/TLS handshake. The pool keeps a bounded set of warm connections and lends +one per request. + +### Topology + +``` +Worker process (app-lifetime) +└── ConnectionPool ← ONE per worker, bound via withPorts (CoreContainer) + ├── idle: [conn, conn, …] ← warm, ready to lend + └── borrowed:{conn, …} ← currently checked out + +Request (request-scoped) +└── PooledDatabaseAdapter (DatabasePort) + └── pins ONE borrowed connection for the whole request, + returns it to the pool on teardown +``` + +`PooledDatabaseAdapter` pins a single connection per request so `lastInsertId()` +and multi-statement transactions stay correct, then `release()`s it on teardown +(`__destruct` is the safety net). Because each request gets its own adapter and +(by default) requests run sequentially per worker, no per-coroutine keying is +needed; when `SWOOLE_COROUTINE=true`, `acquire()` yields the scheduler while +waiting for a free slot. + +### Enabling it + +Set `DB_POOL_ENABLED=true`. The bootstrap (`app/bootstrap/base.php`) builds one +`ConnectionPool` per worker and registers it app-lifetime via `withPorts`; the +module's `Provider` then binds `DatabasePort` to a request-scoped +`PooledDatabaseAdapter`. If no app-lifetime pool is present the Provider falls +back to a container-singleton pool, so the pooled path also works in tests/CLI. + +### Tuning (`DB_POOL_*`) + +| Variable | Default | Meaning | +|---|---|---| +| `DB_POOL_ENABLED` | `false` | Master switch for the pooled DatabasePort | +| `DB_POOL_MIN` | `0` | Connections opened at warm-up and kept hot | +| `DB_POOL_MAX` (alias `DB_POOL_SIZE`) | `10` | Hard ceiling on connections per worker | +| `DB_POOL_ACQUIRE_TIMEOUT_MS` | `3000` | Wait before `poolExhausted` when saturated | +| `DB_POOL_IDLE_TIMEOUT` | `60` | Evict a connection idle longer than this (s) | +| `DB_POOL_MAX_LIFETIME` | `3600` | Recycle a connection older than this (s) | +| `DB_POOL_VALIDATE` | `true` | `ping()` a reused connection before lending | + +A connection that is stale (past idle/lifetime) or fails validation is closed +deterministically (`MultiDriverDatabaseAdapter::close()`) and replaced. A +connection returned mid-transaction is rolled back before re-entering the pool. + +### Observability + +`ConnectionPool::stats()` returns `idle`, `active`, `total`, `max`, `min`, +`waiters`, `closed` — wire it into a health endpoint to watch saturation. + +Sizing rule of thumb: `DB_POOL_MAX × worker_count` must stay under the database +server's `max_connections`. + +--- + +## MULTI-DATABASE (READ REPLICAS / WAREHOUSE) + +`ConnectionManager` implements `DatabaseConnectionManagerContract` for setups +needing more than one connection. Connections are built lazily and cached: + +```php +$manager->register('primary', $primaryConfig); +$manager->register('replica', $replicaConfig); + +$manager->connection('replica')->query('SELECT ...'); // reads +$manager->default()->execute('INSERT ...'); // writes +$manager->close('replica'); // drop one +``` + +--- + +## ERROR HANDLING + +Every `\PDOException` is translated to `Plugins\Database\Exceptions\ConnectionException` +— no vendor exception escapes the module (GDA gateway/repository rule). It carries +structured context for the kernel `ErrorPipeline`: + +```php +try { + $db->query($sql); +} catch (ConnectionException $e) { + $e->driver; // 'mysql' | 'pgsql' | 'sqlite' | 'sqlsrv' + $e->operation; // 'connect' | 'query' | 'execute' | 'transaction.commit' | … + $e->getPrevious(); // original \PDOException +} +``` + +Repositories should catch `ConnectionException` and re-throw a `RepositoryException` +(per [05_REPOSITORY.md](05_REPOSITORY.md)). + +--- + +## TESTING + +The module ships a full unit suite under `tests/Unit/Database/` (85 tests). It uses +**SQLite `:memory:`** as a real connection — no mocking of PDO, so transaction and +savepoint behaviour is genuinely exercised: + +```bash +vendor/bin/phpunit tests/Unit/Database +``` + +Test coverage: +- `Drivers/*ConfigurationTest` — DSN, PDO options, init statements, password redaction +- `Drivers/DatabaseConfigurationFactoryTest` — alias resolution, env parsing, unknown driver +- `Persistence/MultiDriverDatabaseAdapterTest` — CRUD, nested tx/savepoints, `transaction()`, error translation, lazy connect +- `Persistence/ConnectionManagerTest` — named connection registry lifecycle +- `Persistence/QueryLoggingTest` — debug + slow-query logging +- `Exceptions/ConnectionExceptionTest` — structured context + +For repository/service tests, prefer the in-memory adapter or a `DatabasePort` +fake (see [10_TESTING.md](10_TESTING.md)). + +--- + +## CROSS-DRIVER PORTABILITY (UNIFORM API) + +PDO's API and the `:named` placeholder scheme are identical across MySQL, +PostgreSQL and SQLite — but the SQL *text* is not. `DatabasePort` absorbs the +constructs that genuinely differ so repositories never branch on the driver: + +| Need | Use | Never hand-write | +|---|---|---| +| Insert-or-update | `$db->upsert($table, $values, $conflictColumns, $updateColumns)` | `ON DUPLICATE KEY UPDATE` / `ON CONFLICT …` | +| Last insert id | `$db->lastInsertId($sequence = null)` — pass the sequence name on PostgreSQL | `lastInsertId()` assuming MySQL semantics | + +`upsert()` compiles to `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)` on +MySQL and `INSERT … ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` on +PostgreSQL/SQLite, quoting identifiers per driver. `$conflictColumns` must have a +matching unique/PK constraint. `$updateColumns`: `null` = all non-conflict +columns, `[]` = do nothing on conflict (insert-if-absent), a subset = only those +(e.g. refresh `role`/`updated_at` but preserve the original `joined_at`). It is +atomic — no UPDATE-then-INSERT race. + +Constructs the port does NOT abstract (keep to the portable subset, or branch on +`$db->driver()` in the rare case you must): string concatenation (`CONCAT` vs +`||`), `SUBSTRING`/`substr`, `bytea`/BLOB streams, and vendor functions. Prefer +computing such values in PHP and binding the result. Full guidance: +[22_DATA_ACCESS_ORM_BLUEPRINT.md](22_DATA_ACCESS_ORM_BLUEPRINT.md). + +## RULES — WHAT NOT TO DO + +``` +✗ Hand-writing ON DUPLICATE KEY / ON CONFLICT — use $db->upsert() (driver-portable) +✗ Importing a driver/adapter class in a repository — depend on DatabasePort only +✗ Reading DB_* env vars anywhere but DatabaseConfigurationFactory +✗ Letting a \PDOException escape the module — always ConnectionException +✗ Putting business logic in Provider — wiring only +✗ float for money columns — integer cents (see Domain/ValueObjects rules) +✗ Opening the connection eagerly in a constructor — it is lazy by design +✗ Catching ConnectionException and swallowing it — translate to RepositoryException +✗ Adding a 5th driver without an initStatements() review and a config test +✗ Making PooledDatabaseAdapter app-lifetime — it MUST be request-scoped (per-request pin) +✗ Making ConnectionPool request-scoped — it MUST be app-lifetime (one per worker) +✗ Holding a borrowed connection across requests without release() — starves the pool +✗ Setting DB_POOL_MAX × workers above the server's max_connections +``` + +--- + +## RELATED CONTEXT + +- [05_REPOSITORY.md](05_REPOSITORY.md) — repository layer rules (DatabasePort only) +- [18_MIGRATIONS.md](18_MIGRATIONS.md) — LetMigrate uses the same `DB_*` variables +- [16_PLUGINS.md](16_PLUGINS.md) — plugins folder convention +- [10_TESTING.md](10_TESTING.md) — port fakes and service tests +``` diff --git a/docs/guides/20_FIRST_PARTY_PLUGINS.md b/docs/guides/20_FIRST_PARTY_PLUGINS.md new file mode 100644 index 0000000..f4ff1fa --- /dev/null +++ b/docs/guides/20_FIRST_PARTY_PLUGINS.md @@ -0,0 +1,586 @@ +# First-Party Plugins — Ported / Built Capabilities + +These plugins live under `plugins/` (namespace `Plugins\`) and were added to give +the GDA kernel capabilities it intentionally did not ship with. Each follows the +plugin convention in `16_PLUGINS.md`: a `module.json`, a `Provider`, and the GDA +layer layout. Register a plugin by adding `Plugins\{Name}\Provider::class` to a +project bootstrap (most are already in `app/bootstrap/base.php` or +`projects/admin/bootstrap/app.php`). + +| Plugin | solves | Exposes / provides | +|---|---|---| +| `Authorization` | `authorization.policy` | `AuthorizationServiceContract` (Casbin RBAC/ABAC) | +| `Auth` | `auth.identity` | `AuthServiceContract` + JWT/PAT/session SecurityLayers (asymmetric signing, `jti` revocation, `SessionAuthStage`, `/auth/login\|logout\|me`). **Deep dive: [25_AUTH.md](25_AUTH.md)** | +| `OAuth2` | `oauth.server` | Native OAuth 2.1 + OIDC authorization server (auth-code/PKCE, client-credentials, refresh, password, device; JWKS, introspection/revocation, discovery). Access tokens are platform JWTs. **Deep dive: [26_OAUTH2.md](26_OAUTH2.md)** | +| `SocialAuth` | `auth.social` | `SocialAuthServiceContract` (OAuth1/OAuth2) | +| `SecurityFilters` | `http.security_filters` | global hooks (CORS, SecureHeaders) + route-filter aliases (`auth`, `throttle`, `hmac`, `shield`) | +| `Crypto` | `crypto.services` | `EncryptionPort` + `HashingPort` adapters | +| `Validation` | — (library) | `Validator` rules engine | +| `I18n` | `i18n.translation` | `Translator` — file-based `{APP_LANG_PATH}/{locale}/{group}.php`, dotted `group.key`; `:name`/`:Name`/`:NAME` placeholders (longest-first `strtr`); `choice()` pluralization (`singular\|plural` or ranges `{0}`/`[1,19]`/`[20,*]`); never throws (miss → fallback locale → key). `LocaleStage` (after.load p45) negotiates `Accept-Language` vs `APP_LOCALES` + binds global helpers `__()`/`trans()`/`trans_choice()`/`lang_has()` | +| `Support` | — (library) | `Collection`, `Arr`, `Str`, `Resource`, `collect()` | +| `Mail` | `mail.smtp` | `MailPort` SMTP adapter | +| `Pageflow` | `http.pageflow` | `PageflowResponder` + `PageflowChannel` (Inertia v2 SPA bridge: CSRF, validation/precognition, reactive props, auth, offline) | +| `DevTools` | `dev.tooling` | `make:*`, `module:list/info`, `routes:list`, `project:list` | +| `Storage` | `storage.local` | `StoragePort` — local disk + S3 driver (Flysystem), signed URLs | +| `View` | `view.rendering` | `ViewRendererContract` — PHP template engine (layouts, sections, decorators) | +| `HttpClient` | `http.client` | `HttpClientPort` — cURL client, fluent builder, multipart | +| `Session` | `session.management` | `SessionPort` — file/array/cookie handlers, flash, CSRF, lazy persist | +| `Cookie` | `http.cookies` | `CookieJar` — queued cookies, encrypt/decrypt via `EncryptionPort` | +| `RedisCache` | `cache.redis` | `CachePort` + `QueuePort` — ext-redis, in-memory fallback | +| `Tenancy` | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` + `TenantHostServiceContract` — database-per-tenant routing + selection/invitation/custom-host flows. (Refresh tokens moved to `Plugins\Auth`.) **Deep dive: [23_TENANCY.md](23_TENANCY.md)** | +| `User` | `user.management` | `UserServiceContract` — GLOBAL central identity (CRUD, credential/email verification, transactional outbox, audit_log). **Deep dive: [24_USER.md](24_USER.md)** | + +Activation: `Storage`, `View`, and `HttpClient` are **on-demand** (a consumer +declares `requires: ["storage.local"]` / `["view.rendering"]` / `["http.client"]`). +`Session`, `Cookie`, and +`RedisCache` are **essential** (registered every request via +`withEssentialModules` in `app/bootstrap/base.php`). `SecurityFilters` runs +`CorsStage` + `SecureHeadersStage` as global hooks and registers the `auth` / +`throttle` / `hmac` / `shield` route-filter aliases (opt in per route via +`"filters": [...]`). See `16_PLUGINS.md` and the SecurityFilters section below for the hook-vs-filter +and module-activation models. + +--- + +## Storage (local + S3) + +`StoragePort` adapter. `STORAGE_DRIVER=local` (default) uses atomic, fsync'd file +writes under `STORAGE_ROOT` (short-write detection guards against silent +disk-full corruption) with HMAC-signed `temporaryUrl()`; `STORAGE_DRIVER=s3` uses +`league/flysystem-aws-s3-v3` (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO) +with native pre-signed URLs. On-demand: a consuming module declares +`{ "requires": ["storage.local"] }`. + +**S3 credentials:** leave `STORAGE_S3_KEY` empty on EC2/ECS/EKS — `fromConfig()` +then omits static credentials so the AWS default provider chain (IAM +instance/task roles, env, SSO) resolves them. Only set the key/secret for +non-AWS providers or local dev. The adapter is bound as a request-scoped +**singleton**, so the `S3Client` is built once per request, not per resolution. + +**Configuration** is env-driven through `config/storage.php`, read via the +`storage_config()` helper (dotted access; a project copy at +`projects//config/storage.php` overrides the plugin default): + +```php +storage_config('driver'); // 'local' | 's3' +storage_config('local.root'); // STORAGE_ROOT +storage_config('s3.bucket'); // STORAGE_S3_BUCKET +``` + +**Streaming** (large blobs, no full in-memory buffer): + +```php +$path = $storage->store($bytes, 'invoice.pdf', 'invoices/2026', 'private'); +$url = $storage->temporaryUrl($path, 600); + +$storage->storeStream($readable, 'export.csv', 'exports'); // stream → storage +$handle = $storage->readStream('exports/export.csv'); // storage → stream (caller closes) +``` + +Env keys: `STORAGE_DRIVER`, `STORAGE_ROOT`, `STORAGE_URL_BASE`, +`STORAGE_URL_SECRET`, `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_KEY`, +`STORAGE_S3_SECRET`, `STORAGE_S3_ENDPOINT`, `STORAGE_S3_PATH_STYLE`. + +## View (PHP templates) + +`ViewRendererContract` — a PHP template engine ported from CodeIgniter 4 and +rebuilt to GDA rules: **no globals** (view paths, extensions, decorators and the +HTML escaper are all constructor-injected; the engine reads no `config()`/`kernel()`), +**request-scoped** (bound per request, so its mutable template data never leaks +across requests under OpenSwoole), and no file-locator dependency (views resolve +against the injected paths). Supports data binding with optional escaping, +layouts (`$options['layout']` or `extend()`/`section()`), section rendering, +includes and output decorators. On-demand: `{ "requires": ["view.rendering"] }`. + +`Plugins\View\Infrastructure\SidebarManager` ships alongside as a navigation-HTML +builder (instance-scoped icon cache — never `static`). + +Config (env; `VIEW_PATHS` unset → defaults to `/resources/views`): +`VIEW_PATHS` (colon/comma-separated dirs), `VIEW_EXTENSIONS` (default `php`), +`VIEW_SAVE_DATA` (persist data across `render()` calls). + +```php +// Controller injects ViewRendererContract (its module requires "view.rendering"): +return Response::html( + $this->view->setVar('name', $user->name) // pass raw… + ->render('welcome', ['layout' => 'layouts/app']) +); +// Escape ONCE: either pre-escape via setVar(..., 'html') AND echo raw in the +// template, OR pass raw and escape in the template — never both (double-escapes). +``` + +## HttpClient (outbound cURL) + +`HttpClientPort` adapter for Gateways. Dependency-free cURL with an immutable +fluent builder, safe retry/backoff, and manual multipart uploads. Vendor/transport +errors are translated to `GatewayException`. On-demand: `{ "requires": ["http.client"] }`. + +The fluent builder is reachable **through the port** — `HttpClientPort::pending(): +PendingRequestContract` — so a Gateway typed against the kernel contract (never the +concrete adapter) can still use `baseUrl()`, `withToken()`, `asForm()`, `attach()`, +etc. Both `pending()` and the returned `PendingRequestContract` live in the kernel +`Ports` namespace. + +```php +$res = $client->pending()->acceptJson()->withToken($t)->post($url, $payload); +if ($res->ok()) { $data = $res->json(); } +$client->pending()->asMultipart()->attach('file', $bytes, 'a.png')->post($url); +``` + +Hardening / behaviour to rely on: + +- **Retries are idempotent-only by default.** `retry(n)` retries transport failures + AND transient responses (5xx / 429), but ONLY for `GET/HEAD/PUT/DELETE/OPTIONS/TRACE` + — a POST/PATCH is never silently re-executed. Widen deliberately (e.g. an + idempotency-key POST) with `->retryMethods([...])` (builder) or the `retry_methods` + request option. Backoff is coroutine-aware (OpenSwoole/Swoole `Coroutine::usleep`, + else `usleep`) so it never blocks the worker. +- **Header injection is rejected** — CR/LF in any header name/value throws; multipart + field/file names are stripped of CR/LF and `"`. +- **JSON bodies use `JSON_THROW_ON_ERROR`** — an un-encodable payload throws a + `GatewayException`, never ships a silent `{}`. +- **OOM guard** — responses are capped at `HTTP_CLIENT_MAX_RESPONSE_BYTES` + (default 32 MiB) via an aborting cURL progress callback. +- TLS verification on by default; gzip/deflate negotiated transparently; `NOSIGNAL` + set for threaded/Swoole SAPIs. + +Config env: `HTTP_CLIENT_TIMEOUT` (30), `HTTP_CLIENT_CONNECT_TIMEOUT` (10), +`HTTP_CLIENT_RETRY` (0), `HTTP_CLIENT_MAX_RESPONSE_BYTES` (33554432). + +Live demo: `HttpClientController` in `psp-shop` (`/http/get`, `/http/fluent`, +`/http/post`, `/http/error`). + +## Session (essential) + +`SessionPort` adapter with native `\SessionHandlerInterface` handlers +(`SESSION_DRIVER=file|array|cookie`), flash data, CSRF `token()`, `regenerate()`/ +`invalidate()` for fixation defence, and **lazy persistence** — a fresh visitor +who never writes the session gets no file and no cookie (stateless API/bot traffic +stays clean). `StartSessionStage` (hooked `after.load`) opens it before modules +and persists + sets the cookie after, only when `shouldPersist()`. + +> Apps must call `$session->regenerate()` after login (fixation defence). The +> kernel's CSRF layer is double-submit-cookie based and independent of `token()`. + +### Drivers + +| `SESSION_DRIVER` | Storage | Notes | +|---|---|---| +| `file` (default) | one file per session under `var/sessions/` | server-side; `SESSION_PATH` overrides the dir | +| `array` | in-memory (per process) | tests / CLI / stateless contexts | +| `cookie` | **in the session cookie itself** | stateless & horizontally-scalable — no server store | + +### Cookie driver — stateless, encrypted/signed sessions + +`CookieSessionHandler` carries the whole serialized attribute bag inside the +session cookie, so nothing is stored server-side (ideal for multi-node deploys). +Defence in depth, all env-driven: + +- **Protection** — encrypted via `EncryptionPort` when `APP_KEY`/Crypto is present + (confidential + authenticated); otherwise **HMAC-SHA256 signed** with + `SESSION_SIGNING_KEY` (falls back to `APP_KEY`) — readable but tamper-evident, + verified with `hash_equals()`. +- **Timeouts** — `SESSION_LIFETIME` (absolute, never extended by re-saving) and + `SESSION_IDLE_TIMEOUT` (sliding), both enforced server-side on read. +- **Fingerprint binding** — `SESSION_COOKIE_FINGERPRINT=off|ua|ip|ua,ip` ties the + session to a hashed client fingerprint. `ua` survives IP changes (safe for + mobile); `ip`/`ua,ip` are stricter anti-theft. +- **Compression** — `SESSION_COOKIE_COMPRESS` deflates data above N bytes to fit + more under the ~4 KB cookie limit; `SESSION_COOKIE_MAX_BYTES` drops an oversized + cookie (and expires any stale one) rather than emit an invalid `Set-Cookie`. +- **Hard guards** — `SESSION_COOKIE_REQUIRE_AUTH` (default on) fails boot unless + signed or encrypted; `SESSION_COOKIE_REQUIRE_ENCRYPTION` fails boot unless + *encrypted* (blocks the signed-but-readable mode for confidential data). +- **Cookie attributes** — `SESSION_SECURE=auto|true|false`, plus + `SESSION_COOKIE_PATH` / `SESSION_COOKIE_DOMAIN`. +- Binary-safe regardless of `SESSION_SERIALIZATION` (`json` default | `php`). + +> Keep cookie sessions small (ids/flags/CSRF) — they ride on every request and are +> capped at ~4 KB. Use `file` (or a Redis driver) for large session state. + +## Cookie (essential) + +`CookieJar` queues outgoing cookies flushed by `QueuedCookiesStage`; values are +encrypted via `EncryptionPort` (except an exempt list). Read incoming cookies with +`$jar->read($request, $name)` (auto-decrypts; exempt cookies returned raw). +Encryption is only meaningful with `APP_KEY` set — the kernel hard-fails at boot +outside `local`/`testing` when it is missing. + +**Config — `plugins/Cookie/config/cookie.php` (env-driven; project override wins).** +A project may copy it to `projects//config/cookie.php`; `cookie_config()` +resolves the project file first (via `Paths::config()`), else the plugin default. +Every value reads from `.env`: + +| Env | Key | Default | +|---|---|---| +| `COOKIE_LIFETIME` (minutes) | `lifetime` | `120` | +| `COOKIE_PATH` | `path` | `/` | +| `COOKIE_DOMAIN` | `domain` | `null` (bind to issuing host) | +| `COOKIE_SECURE` | `secure` | `true` (set `false` for local http://) | +| `COOKIE_HTTP_ONLY` | `http_only` | `true` | +| `COOKIE_SAME_SITE` | `same_site` | `Lax` | +| `COOKIE_ENCRYPT_EXEMPT` (comma-separated) | `encrypt_exempt` | `[]` | + +`CookieJar::queue()` attributes are nullable — omitted ones fall back to these +defaults, so callers usually pass only name + value. + +**Encryption exemptions (`encrypt_exempt`).** Names listed here are written AND +read as plaintext — `CookieJar` skips both `encryptString()` on flush and +`decryptString()` on `read()` for them. The final list is a base array declared +in `config/cookie.php` MERGED with the comma-separated `COOKIE_ENCRYPT_EXEMPT` +env var (de-duplicated), so deployments can add names without editing code. +Exempt a cookie when its raw value must stay stable and readable as-is: + +- a JS-readable flag (theme, locale) the front-end reads directly; or +- an opaque session/binding cookie a **pre-load security layer** reads raw — e.g. + `CsrfTokenLayer`'s `bindCookie`. Encryption rotates the ciphertext on every + response (random IV), which would break that binding; exempting it keeps the + value byte-stable across requests. See [CSRF guide](21_CSRF.md). + +**Helpers (`plugins/Cookie/Support/helpers.php`, autoloaded):** + +```php +cookie_config(); // full config array (cached per process) +cookie_config('same_site'); // single key +$jar->queue(...cookie('cart', $id, minutes: 30)); // spread into queue() +Response::json($d)->withCookie(...cookie('seen', '1')); // or into withCookie() +``` + +`cookie()` returns a spread-ready attribute array (keys match both +`CookieJar::queue()` and `Response::withCookie()`); `maxAge` is in seconds. + +> `.env` gotcha: an empty value followed by an inline comment (`COOKIE_DOMAIN= # note`) +> resolves to empty — `LoadEnvironment` treats a comment-only value as `''`. Put +> comments on their OWN line to avoid surprises with non-empty values. + +## RedisCache (essential) + +`CachePort` + `QueuePort` on ext-redis (one shared lazy connection). Numbers are +stored raw so `increment()`/`set()`/`get()` interoperate (the rate limiter relies +on this); everything else is serialized. `deletePattern()` uses non-blocking SCAN. +Only binds when `REDIS_HOST` is set (else the in-memory `CachePort` stays). +`REDIS_PERSISTENT=true` enables `pconnect` reuse (FPM only — keep off on Swoole). + +--- + +## Authorization (Casbin) + +Casbin policy engine wrapped for GDA. Policy storage goes through `DatabasePort` +via `DatabasePolicyAdapter` (table `casbin_rule`); the `Enforcer` is an internal +binding and only `AuthorizationServiceContract` is exposed. + +```php +$authz->allows($userId, 'invoice:42', 'edit'); // bool +$authz->assignRole($userId, 'admin', $tenantId); +$authz->grant('admin', 'invoice', 'edit'); +``` + +Model config: `plugins/Authorization/config/rbac_model.conf` (override with +`AUTHZ_MODEL_PATH`). Run the bundled migration to create `casbin_rule`. + +## Auth (JWT + Personal Access Tokens) + +Credential **issuance** is `AuthServiceContract` (`issueJwt`, `createPersonalAccessToken`, +`hashPassword`/`verifyPassword` via `HashingPort`). Credential **verification** is +done by SecurityLayers wired into the kernel `withSecurity([...])` chain: + +- `JwtAuthLayer(secret, algo)` — validates `Authorization: Bearer `. +- `PersonalAccessTokenLayer(databasePort)` — validates DB-backed `` tokens. + +No header → anonymous (public routes still work). Invalid token → `deny(401)`. +PATs are looked up by deterministic `sha256` (passwords use bcrypt via `HashingPort`). + +## SocialAuth (OAuth) + +Ported OAuth providers (GitHub, Google, Facebook, GitLab, Bitbucket, LinkedIn, +Slack, X). A small compat layer (`Socialite/Http`, `Socialite/Support`) lets the +stateful OAuth flow run inside the stateless kernel. OAuth2 drivers work out of +the box; the Twitter OAuth1 driver also needs `league/oauth1-client` + `phpseclib`. + +```php +$social->redirectUrl('github'); // start +$social->userFromCallback('github', $request); // resolve user +``` + +## SecurityFilters (HTTP stages) + +The 0.3 filters rebuilt as `HttpStageContract` stages. CORS + SecureHeaders run as +GLOBAL pipeline hooks (every request); HMAC, auth, Shield and the rate limiter are +exposed as DECLARATIVE route-filter aliases that a route opts into by name. A stage +runs through exactly ONE mechanism — never both (double-registering double-runs it). + +**Global hooks** (registered in `Provider::boot()`, run on every request): + +| Stage | Slot | Config | +|---|---|---| +| `CorsStage` | after.security | `CORS_ALLOWED_ORIGINS/METHODS/HEADERS`, `CORS_ALLOW_CREDENTIALS`, `CORS_MAX_AGE` | +| `SecureHeadersStage` | after.execute | `CONTENT_SECURITY_POLICY`, `HSTS_MAX_AGE` | + +**Route-filter aliases** (registered via `$http->filter(...)`; a route opts in with +`"filters": [...]` in module.json / proj.json): + +| Alias | Stage | Config | +|---|---|---| +| `hmac` | `HmacSignedStage` | `HMAC_PROTECTED_PREFIX`, `REQUEST_SIGNING_SECRET`, `HMAC_MAX_SKEW` | +| `auth` | `RequireAuthStage` | also honours `AUTH_PROTECTED_PATHS` (exact / `prefix/*` / `*` segment) | +| `shield` | `ShieldStage` | `SHIELD_RULES` (`/path=role:admin;/x=perm:y`) | +| `throttle` | `ApiRateLimitStage` | `RATE_LIMIT_PREFIX/MAX/WINDOW` (uses `CachePort`); `"throttle:max,window"` args | + +```jsonc +// require auth + throttle on one route, declaratively +{ "method": "POST", "path": "/api/tasks", "handler": "...@create", + "filters": ["auth", "throttle:60,1"] } +``` + +`RequireAuthStage` enforces when EITHER the route declared the `auth` filter OR the +path is in `AUTH_PROTECTED_PATHS` — the auth layer attaches Identity globally, this +stage decides which routes demand it. See [16_PLUGINS.md](16_PLUGINS.md) for the hook-vs-filter model and `RouteFilterStage` / `FilterRegistry` internals. + +## Crypto (kernel ports) + +Adds two **kernel ports** the framework was missing, with adapters: + +- `EncryptionPort` → `AesEncrypter` — authenticated AES-256-GCM with key rotation. +- `HashingPort` → `PasswordHasher` — bcrypt/argon2 over `password_*`. + +Wired in `app/bootstrap/base.php` from `APP_KEY` / `APP_KEY_PREVIOUS` / +`HASH_BCRYPT_COST`. **Set a real 32-byte `APP_KEY` in production.** + +## Validation + +Dependency-free rules engine that throws the kernel `ValidationException` +(field → messages, the standard 422 shape). Optional `Translator` for localized +messages. + +```php +Validator::make($request->all(), [ + 'email' => 'required|email', + 'age' => 'required|integer|min:18', + 'password' => 'required|min:8|confirmed', +])->validate(); // returns validated data or throws +``` + +Rules: `required, nullable, string, integer, numeric, boolean, array, email, url, +min, max, between, in, regex, same, different, confirmed`. + +## I18n + +File-based `Translator`: `lang/{locale}/{group}.php`, dotted keys, `:placeholder` +substitution, locale→fallback→key resolution, path-traversal guarded. +Config: `APP_LOCALE`, `APP_FALLBACK_LOCALE`, `APP_LANG_PATH`. + +## Support + +`Collection` (fluent, immutable-friendly), `Arr`, `Str`, and `Resource` / +`ResourceCollection` (API transformers). `collect()` helper autoloaded. + +```php +collect($rows)->map(...)->where('active', true)->pluck('id')->all(); +UserResource::collection($users)->toArray(); +``` + +## Mail (SMTP) + +`SmtpMailer` implements `MailPort` over a dependency-free `SmtpTransport` +(STARTTLS/SSL, AUTH LOGIN). Bound only when `SMTP_HOST` is set, so unconfigured +projects are unaffected. Renders PHP-template views or inline HTML. + +## Pageflow (SPA bridge — `http.pageflow`) + +A fork of **Inertia.js v2**, rebranded and wired into the kernel, with +platform-native capabilities Inertia lacks. Server side + the React client both +live in `plugins/Pageflow/` (PHP) and `plugins/Pageflow/ui/` (client). Full usage +guide: `plugins/Pageflow/ui/PAGEFLOW_GUIDE.pdf`. + +### Core protocol + +`PageflowResponder::render($request, $component, $surface, $props = [], +$viteEntry = null, $loadPage = true, $cacheable = false)` returns a JSON page +object for `X-Pageflow` XHR navigations +or an HTML shell on first load (the client boots from the root element's +**`data-page`** attribute — `PageflowPage::mount($appId)` — NOT +`window.initialPage`). Honours partial reloads (`X-Pageflow-Partial-*`). +`PageflowVersionStage` returns `409 + X-Pageflow-Location` on stale assets. +Shared props via `pageflow_share('key', fn($request) => …)`. + +### CSRF + +The responder renders `` into the HTML head (minted from +`APP_KEY` + the session-cookie binding via `CsrfTokenLayer::make`). The client +reads it and sends `X-CSRF-Token` on mutations — **same-origin only** (never +leaked cross-origin). `GET /pageflow/csrf` (throttled) refreshes an expired token +for long-lived tabs; the client's axios interceptor auto-refreshes on a CSRF 403. +The token is intentionally NOT shared as a prop (kept out of JSON / SW cache). + +### Native validation & precognition + +`PageflowValidationStage` turns a kernel `ValidationException` into either a +`422 {errors}` (precognition) or a session-flashed **303 redirect-back** (normal +submit) — controllers just throw via their DTOs; the `errors` shared prop +surfaces them and `useForm` shows them (`preserveState` keeps the form). The +303 `Location` is reduced to a same-origin path (no open redirect). Precognition +(`Precognition: true`) runs validation only; a controller short-circuits with +`pageflow_precognition($request)` → `PageflowResponder::precognitionSuccess()`. +`PageflowPrecognitionStage` flags the request (`precognition` attribute) so a +repo/service can refuse writes. + +### Reactive props (secure server push) + +`PageflowChannel` (CachePort-backed): a Service calls +`$channel->touch("t:{$tenantId}:dashboard", ['orders'])` after commit; the +tenant-scoped `GET /pageflow/stream` SSE endpoint (auth-gated) pushes **stale key +names only — never data**. The client (`useReactiveProps`) reacts with a normal +authorized partial reload. Reconnect-safe via SSE `id:`/`Last-Event-ID`; bounded +lifetime (`PAGEFLOW_STREAM_MAX_SECONDS`). Requires OpenSwoole for real push. + +### Auth projection + +`pageflow_auth` shared prop (via `PageflowAuth`, override with +`pageflow_auth_projection()`) exposes userId/tenant/roles/permissions — +**never tokens**. Client `useAuth()`/`` gate UI (UX only; server stays the +authority). `useFlushOnIdentityChange()` purges prefetch + SW cache on +login/logout/tenant-switch. + +### Offline (opt-in) + +`registerPageflowSW()` + `pageflow-sw.js`: static assets cache-first; page objects +cached **only** when the server opts in (`render(..., cacheable: true)` → +`X-Pageflow-Cache: 1`, or `Cache-Control: public`) — authenticated pages are never +cached by default. `no-store`/`private` always win. + +### Client API (`@pageflow/react`) + +``, `useForm` (+ `resetOnSuccess`/`resetOnError`), `usePage`, ``, +`
`, `usePrecognition`, `useReactiveProps`, `useAuth`, +``, `useDirtyGuard`, `usePoll`, `usePrefetch`, `useRemember`, `Deferred`, +`WhenVisible`, `installCsrfAutoRefresh`, `registerPageflowSW`. Batched deferred +props (N groups → 1 request). CLI `pageflow:types` generates end-to-end `.d.ts`. + +### Endpoints & env + +Routes: `GET /pageflow/csrf` (throttle), `GET /pageflow/stream` (auth + +throttle). Env: `PAGEFLOW_VERSION`, `PAGEFLOW_ROOT_VIEW`, `PAGEFLOW_APP_ID`, +`PAGEFLOW_CSRF_COOKIE`, `PAGEFLOW_CSRF_LIFETIME`, `PAGEFLOW_STREAM_INTERVAL`, +`PAGEFLOW_STREAM_MAX_SECONDS`, `PAGEFLOW_PRECOGNITION_ROLLBACK`. + +## SiteSEO (`seo.management`, on-demand) + +Full SEO toolkit + Project-layer support. `requires: ["http.client"]`. Published +`SeoServiceContract`: `openGraph()`, `schema()`, `sitemap()`, `robots()`, +`pingSitemap()`, `indexNow(host,key,keyLocation,urls,endpoints,dryRun)` +(auto-batches 10k, lazy iterable), `indexNowChunks()`. All outbound HTTP goes +through `Infrastructure/Gateways/SearchEngineGateway` (`HttpClientPort`) — never +raw cURL. The toolkit value classes (`OpenGraph`, `Schema`, `Sitemap*`, +`RobotsTxtEditor`) autoload directly, so building sitemaps / OG / JSON-LD needs +NO module load; only ping + IndexNow do (they hit the network). + +Project-layer helpers (`Project\Support\Seo\`, reusable & DI-free): + +- `RouteCatalog` — public static GET pages from the route manifest (drops + `{param}`, auth-gated, `/api`, SEO endpoints). +- `SitemapGenerator` — small/route-derived `` (≤30k); `toXml()`/`save()`. +- `SitemapStreamWriter` — **enterprise**: streams an `iterable` to split child + files + index at **O(1) memory** (no DOM), 50k split, optional gzip. For + millions of URLs (verified flat memory to 1M+). +- `SitemapUrlProvider` + `SitemapSource` — expand dynamic routes (`/blog/{slug}`) + from the DB with a keyset-cursor generator; `uncoveredDynamicRoutes()` guards + silent omissions. +- `RichGraph` — Schema.org JSON-LD `@graph` for Google rich results (org → + website[SearchAction] → webPage → breadcrumb → content node, linked by `@id`). + Content nodes: article/newsArticle/blogPosting, product (offer+rating+review), + book, course (syllabus), realEstate (lease), pageantEdition/awardEdition/ + contestant (Event+Person), faq. +- `SeoHead` — full ``: title, description, **canonical**, **robots**, + **hreflang**/x-default, plus attached OG + JSON-LD. +- `IndexNowKey` — key/keyLocation value object. + +Controller traits (`Project\Http\Controllers\Concerns\`): `InteractsWithSeo` +(siteBaseUrl, sitemap, openGraph, ogImage, richGraph, robots) and +`InteractsWithGraphSeo` (adds `graph()` + `seoHead()`). + +Background indexing: job `seo.indexnow` (`IndexNowJob`, queue `indexing`, +declared in `module.json` `jobs[]`, bound in `Provider::register()`) submits one +≤10k batch; dispatch by chunking a URL stream and `QueuePort::push()` per batch +(`FileQueue` in `Project\Infrastructure\` is the no-Redis fallback). Index-on- +publish: emit `UrlPublishedIntegrationEvent` after commit → SEO module subscribes +`EnqueueIndexNowListener` (`Provider::boot()`) which enqueues. The EventBus +resolves listeners from the CoreContainer (`has()` bound-only), so the **project +binds the listener with its `QueuePort`** in `bootstrap/app.php`. Env: +`INDEXNOW_KEY` (listener no-ops without it), `INDEXNOW_LIVE`. + +`NOTE` the toolkit had two real bugs fixed during integration: `Schema` now emits +a proper multi-node `@graph` (was serializing only `things[0]`), and the Twitter +card no longer leaks `og:image:*` keys when a structured image is attached. + +## Tenancy (multi-tenant control plane) + +`solves: tenancy.routing`, `requires: ["database.management"]`, **essential**. +Database-per-tenant isolation layered on `plugins/Database`'s `ConnectionManager`. + +Two planes: a **central (control) DB** holds `users`, `tenants`, `user_tenants` +(+ optional invitations/refresh-tokens/audit); each **tenant has its own DB** +containing only business domain (no auth, no `tenant_id` column — the database is +the boundary). User references inside a tenant DB store the central +`users.user_id` ULID as an opaque value (no cross-DB FK). + +Flow: the Auth layer mints a tenant-scoped `Identity` (JWT `tnt` claim → +`Identity.tenantId`) after the user selects a tenant, re-checking `user_tenants` +each request so a revoked membership drops access before the token expires. +`TenantContextStage` (hooked at `after.load`) reads `Identity.tenantId`, asks +`TenantConnectionResolver` for that tenant's `DatabasePort`, and **rebinds +`DatabasePort` in the request container** — every repository then transparently +talks to the tenant DB. + +- **`TenantRegistry`** — cached reads of central `tenants` (DatabasePort-only, + reads the `ConnectionManager` default = central connection). +- **`TenantConnectionResolver`** — `tenant_id → DatabasePort`; registers a named + `tenant:` connection (password decrypted via `EncryptionPort` at connect + time only). **Fail-closed**: unknown/suspended/deleted/unreachable → throw, + never falls back to another tenant or central. Per-tenant **circuit breaker** + (`TENANCY_BREAKER_THRESHOLD`/`TENANCY_BREAKER_COOLDOWN`) isolates one dead + tenant DB from the fleet. +- **Swoole-safe**: tenant `DatabasePort` is bound into the per-request + `ModuleContainer` (discarded on `reset()`); tenant id rides on the immutable + `Request`/`Identity`, never a static or `CoreContainer`. For cross-request + pooling, bind `ConnectionManager` + resolver into the `CoreContainer` in + bootstrap (see the plugin README) and LRU-evict idle tenant connections. +- **CLI**: `tenants:create` (registry row → CREATE DATABASE → template migrate → + activate, with compensating `provisioning` status) and `tenants:migrate` + (resumable, failure-isolated fleet migrator; each tenant DB keeps its own + `let_migrations` table; central `tenants.schema_version` mirrors drift). +- **Tenant template** migrations live in `plugins/Tenancy/database/tenant-template/` + (override via `TENANCY_TEMPLATE_PATH`). Use expand→migrate→contract for + destructive changes and canary waves across the fleet. +- **Tenant-selection flow** (`MembershipServiceContract`, requires `auth.identity`): + `GET /api/me/tenants` lists active seats; `POST /api/tenants/{tenantId}/select` + re-verifies the membership against central `user_tenants` (never trusts a + client-supplied id), mints a tenant-scoped token via the Auth module (`tnt` + claim), and audits `tenant.switch`. `TENANCY_TOKEN_TTL` sets the scoped-token + lifetime. A revoked seat fails selection (`403`, audited `tenant.switch_denied`) + and loses access on an already-issued token via the per-request re-check. +- **Control-plane tables** (central migrations): `tenants`, `user_tenants`, + `tenant_invitations` (email onboarding, hashed token), `audit_log` (append-only). +- **Invitations** (`InvitationServiceContract`): `invite()` returns a one-time + token (hash stored); `accept()` requires the user's verified email to match, + creates/activates the seat (idempotent), audits `member.join`; `revoke()`. +- **Refresh tokens** moved to `Plugins\Auth` (`RefreshTokenServiceContract`, `POST /auth/refresh`) — tenant-agnostic; the tenant seat check stays at tenant-SELECT here. + +Env: `TENANCY_MODE` (`claim` = JWT `tnt` claim, default · `domain` = Host +sub-domain), `TENANCY_BASE_DOMAINS`, `TENANCY_REGISTRY_TTL`, +`TENANCY_BREAKER_THRESHOLD`, `TENANCY_BREAKER_COOLDOWN`, `TENANCY_TEMPLATE_PATH`, +`TENANCY_TOKEN_TTL` / `TENANCY_REFRESH_TTL` / `TENANCY_ACCESS_TTL`. +**Full AI reference: [23_TENANCY.md](23_TENANCY.md)** · human guide: +`plugins/Tenancy/README.md`. + +## DevTools (CLI) + +`make:plugin`, `make:service` (GDA scaffolding), plus introspection that reads +`module.json` as the source of truth: `module:list`, `module:info `, +`routes:list` (with collision detection), `project:list`. + +--- + +## Tests + +Unit tests for the new plugins live under `tests/Unit/Plugins/` (Crypto, +Validation, I18n, Support, Pageflow). Run `vendor/bin/phpunit`. diff --git a/docs/guides/21_CSRF.md b/docs/guides/21_CSRF.md new file mode 100644 index 0000000..4f13e84 --- /dev/null +++ b/docs/guides/21_CSRF.md @@ -0,0 +1,249 @@ +# HKM Kernel — CSRF Protection (CsrfTokenLayer) + +> `CsrfTokenLayer` is a **kernel** security layer. It runs inside the +> SecurityGateway **before any module loads**, so a forged request is denied at +> microsecond cost without touching domain code. +> +> Location: `src/Kernel/Security/Layers/CsrfTokenLayer.php` +> Contract: `SecurityLayerContract` (see [09_SECURITY.md](09_SECURITY.md)) + +--- + +## What it is — HMAC token, NOT plain double-submit + +`CsrfTokenLayer` uses the **WordPress-nonce model**: a stateless, HMAC-signed +token. Nothing is stored server-side, and — crucially — **no cookie value is +ever trusted as the token**. + +``` +token = tick . "." . hex( HMAC_SHA256( SECRET, tick . "|" . binding . "|" . action ) ) [ . action ] +``` + +| Part | Meaning | +|---|---| +| `SECRET` | A server-only key (`APP_KEY`). The attacker never has it. | +| `tick` | A coarse time window → the token expires (default lifetime 12h, with a 1-tick grace, exactly like WordPress). | +| `binding` | An opaque per-client value read from a cookie the attacker **cannot read** (e.g. the HttpOnly session cookie). Optional. | +| `action` | Optional scope, e.g. `"delete-post:42"`; `''` for a global token. Signed, so it cannot be tampered. | + +### Why this is stronger than double-submit + +Plain double-submit only checks *"submitted value == cookie value"*. An attacker +who can **write** a cookie — a compromised sibling sub-domain (`evil.example.com` +setting a cookie on `.example.com`), or a MITM on plaintext HTTP — can plant a +matching cookie/token pair and pass the check. Here there is **no cookie to +trust**: a valid token cannot be produced without `SECRET`, so cookie injection +buys the attacker nothing. + +| | Plain double-submit | CsrfTokenLayer (HMAC) | +|---|---|---| +| Server stores a token? | No | No | +| Token delivered in a cookie? | **Yes (trusted)** | No (header/form only) | +| Bound to the client? | No | Yes (via `bindCookie`) | +| Survives cookie injection? | **No** ❌ | Yes ✅ | +| Needs a server secret? | No | Yes (`APP_KEY`) | +| Expires automatically? | No | Yes (`tick`) | + +--- + +## The lifetime is in SECONDS + +`$lifetime` is **seconds** (default `43200` = 12h). The tick is a half-life +window with a one-tick grace, so a token is valid for **6h–12h** with the +default — overlapping windows, identical to WP nonces. + +```php +tick = ceil( time() / max(1, intdiv($lifetime, 2)) ); // time() is Unix seconds +// verify accepts the current tick OR the immediately previous one (grace) +``` + +--- + +## Constructor — framework-level wiring + +Wire it in `withSecurity([...])`. `CsrfTokenLayer` is the one CSRF layer the kernel ships; +add the Auth plugin's token layers alongside it (rate limiting / IP shield are separate +SecurityFilters route filters, not gateway layers): + +```php +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +->withSecurity([ + new CsrfTokenLayer( // kernel — stateless HMAC + // secret: null, // defaults to env('APP_KEY'); fail-closed if empty + headerName: 'X-CSRF-Token', // request header carrying the echoed token + formField: '_csrf_token', // fallback body/query field + bindCookie: 'hkm_session', // pin the token to this (HttpOnly) cookie's value; '' = unbound + lifetime: 43200, // SECONDS (12h) + exemptPaths: ['/api'], // path prefixes that bypass (machine-to-machine, own auth) + exemptMethods: [], // extra safe methods (GET/HEAD/OPTIONS are always safe) + ), + // Token/JWT auth is a separate concern → provide it via your AuthModule. +]) +``` + +| Param | Default | Notes | +|---|---|---| +| `secret` | `env('APP_KEY')` | HMAC key. **Empty ⇒ fail-closed** (every unsafe request denied — never silently open). | +| `headerName` | `X-CSRF-Token` | Where JS/fetch sends the token. | +| `formField` | `_csrf_token` | Where an HTML `` sends it. | +| `bindCookie` | `''` | Cookie whose **raw** value pins the token to one client. Use the HttpOnly session cookie. `''` = secret-only (still unforgeable, just not per-client). | +| `lifetime` | `43200` | Seconds. | +| `exemptPaths` | `[]` | Prefix match. APIs that authenticate per-request belong here. | +| `exemptMethods` | `[]` | Case-insensitive; on top of the always-safe GET/HEAD/OPTIONS. | + +**Prerequisite:** `APP_KEY` must be set in `.env`. The Session plugin already +uses it, so a project with sessions has it. Read it with the `env()` helper — +never `getenv()`. + +--- + +## How verification flows through the gateway + +``` +unsafe request (POST/PUT/PATCH/DELETE) + │ + ▼ +CsrfTokenLayer::check(Request) ← SecurityGateway, layer 3 + 1. GET/HEAD/OPTIONS or exemptMethods? → allow + 2. path under an exemptPath? → allow + 3. APP_KEY empty? → DENY 403 (fail-closed) + 4. token from header ?? formField → missing → DENY 403 + 5. read bindCookie value from raw Cookie header + 6. recompute HMAC for current & previous tick, hash_equals() + mismatch / expired → DENY 403 + match → allow → controller runs +``` + +A denied token returns **403 before the controller is ever constructed** — the +controller only runs on a valid token, so controllers never re-check CSRF. + +--- + +## Minting & verifying tokens — the static API + +A controller/view does **not** hold the layer instance. Use the public statics +(same algorithm the layer verifies with): + +```php +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +// Mint a token to embed in a tag / hidden form field: +$token = CsrfTokenLayer::make( + secret: (string) env('APP_KEY'), + binding: $bindingValue, // the SAME value that will be in bindCookie on the next request + lifetime: 43200, +); + +// Out-of-band verification (e.g. a custom AJAX check that must NOT deny): +$ok = CsrfTokenLayer::valid((string) env('APP_KEY'), $token, $bindingValue, 43200); +``` + +There is also an instance method when you *do* have the layer: +`$layer->issue($request, $action = '')` — mints for the current request's binding. + +> ⚠ The `lifetime` passed to `make()`/`valid()` MUST equal the layer's configured +> `lifetime`, or the ticks won't line up and valid tokens read as expired. + +--- + +## The binding gotcha (read this before binding to a cookie) + +The layer reads `bindCookie`'s value **verbatim from the raw `Cookie` header**. +Two consequences: + +1. **Mint with the value the client will send back.** On a first visit the cookie + may not exist yet (it's set in the *response*). Generate the binding, set it, + and mint with the value you are setting — so it matches on the next request. + +2. **The bound cookie must NOT be encrypted by `CookieJar`.** `CookieJar` encrypts + queued cookies by default, AND it re-encrypts with a fresh random IV on every + flush — so the ciphertext in the header changes between requests. The layer + runs at `SecurityStage` (before `after.load`), reads the raw header, and would + see that rotating ciphertext → intermittent `403 CSRF token invalid`. Avoid it + one of three ways: + - **Add the binding cookie to `encrypt_exempt`** (`COOKIE_ENCRYPT_EXEMPT` env or + the base list in `plugins/Cookie/config/cookie.php`). It is then stored AND + read as plaintext, so its raw value is byte-stable — the cleanest option for + pinning to the session cookie. See [First-party plugins → Cookie](20_FIRST_PARTY_PLUGINS.md). + - Queue a dedicated binding cookie with `raw: true` and read it back with + `$request->cookie(...)` (NOT `$this->cookie(...)`, which tries to decrypt). + - Bind to a cookie that is not re-written every response (so its value never + rotates), or use `bindCookie: ''` for a secret-only (unbound) token. + +--- + +## End-to-end controller example (project layer) + +```php +final class CsrfController extends ViewController // has ViewRendererContract injected +{ + private const LIFETIME = 43200; // MUST match the layer config + private ?string $bind = null; + + /** GET — render the form with a freshly minted token. */ + public function form(): Response + { + return $this->view('csrf/form', [ + 'title' => 'CSRF demo', + 'csrfToken' => CsrfTokenLayer::make((string) env('APP_KEY'), $this->binding(), self::LIFETIME), + ], layout: 'layouts/app'); // layout puts it in + } + + /** POST — reaching here PROVES the token was valid (gateway denied otherwise). */ + public function submit(): Response + { + return $this->view('csrf/result', ['message' => $this->request->input('message', '')], layout: 'layouts/app'); + } + + /** The per-client binding, read raw and (re)issued unencrypted. */ + private function binding(): string + { + if ($this->bind !== null) return $this->bind; + $bind = $this->request->cookie('csrf_bind'); // raw — NOT $this->cookie() + if ($bind === null || $bind === '') { + $bind = bin2hex(random_bytes(16)); + $this->cookieJar()?->queue('csrf_bind', $bind, raw: true); // raw:true → unencrypted + } + return $this->bind = $bind; + } +} +``` + +Layout (`` for JS) — `layouts/app.php`: +```php + + + +``` + +Form (hidden field) + JS fetch reading the meta tag: +```html + + + + + + +``` + +--- + +## AI / contributor rules for CSRF code + +``` +✓ CsrfTokenLayer is a KERNEL layer — wire it in withSecurity(), keep it 3rd (after firewall + rate limiter). +✓ SECRET defaults to env('APP_KEY'); an empty key fail-closes (denies) — never make it silently allow. +✓ lifetime is in SECONDS; make()/valid() lifetime MUST equal the layer's. +✓ Mint with CsrfTokenLayer::make(); verify out-of-band with CsrfTokenLayer::valid(); both use hash_equals internally. +✓ Bind to an HttpOnly cookie's RAW value (session cookie, or a raw:true binding cookie). +✓ Deliver the token in a tag (JS) and/or a hidden _csrf_token field (HTML) — NEVER as the trusted token cookie. +✗ Do NOT re-implement plain double-submit (trusting cookie == submitted value) — it's bypassable by cookie injection. +✗ Do NOT read the bound cookie via CookieJar/$this->cookie() (it decrypts) — read raw via $request->cookie(). +✗ Do NOT encrypt the binding cookie (queue it raw:true) — the layer reads the raw header value. +✗ Do NOT re-check CSRF in the controller — the gateway already denied invalid tokens upstream. +✗ Do NOT exempt a state-changing browser route; only exempt machine-to-machine paths with their own auth (e.g. /api). +``` diff --git a/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md new file mode 100644 index 0000000..a08229e --- /dev/null +++ b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md @@ -0,0 +1,282 @@ +# Data Access & "ORM" Blueprint — GDA-Compliant Persistence + +> This is the blueprint for HOW data access is built in the AlfacodeTeam +> PhpServicePlatform framework. It is **not** a third-party ORM adoption guide — +> Eloquent, Doctrine, and Propel are explicitly forbidden (see [13_ANTIPATTERNS.md](13_ANTIPATTERNS.md)). This +> document defines the *layered, hand-rolled object-relational mapping* that the +> framework uses instead, and the boundaries every persistence type must respect. + +--- + +## TL;DR — what to use, where + +| Concern | Use | Never use | +|---|---|---| +| Schema / DDL | **LetMigrate** `Blueprint` migrations | Eloquent/Doctrine migrations | +| Query execution | **`DatabasePort`** (`query`/`queryOne`/`execute`) | a raw `PDO`/vendor handle in a repository | +| Row → object | a **Hydrator** (static, pure) | Active Record / lazy proxies | +| Object → row | the **Repository** (explicit column mapping) | `$model->save()` magic | +| Dynamic SELECTs | a small **QueryBuilder VO** (optional, see below) | string concatenation of user input | +| Identity / tenancy scoping | `Identity.tenantId` injected into the repository | global scopes / framework middleware magic | +| Transactions + events | `TransactionManager` + `DomainEventCollector` | implicit per-model transactions | + +The "ORM" here is the **Repository + Hydrator + Domain Entity** triad. There is +no unit-of-work, no identity map, no lazy loading, and no Active Record. Mapping +is explicit and one-directional at each boundary. + +--- + +## The four layers of the mapping + +``` + ┌──────────────────────────────────────────────┐ + │ Domain Entity (Domain/Entities/*) │ ← behaviour + invariants + │ private ctor · named ctors · releaseEvents() │ + └──────────────▲───────────────────┬────────────┘ + │ reconstitute(...) │ toRow-ish (read by repo) + ┌──────────────┴───────────────────▼────────────┐ + │ Hydrator (Infrastructure/Persistence/*Hydrator)│ ← row[] ⇄ entity, PURE + └──────────────▲───────────────────┬────────────┘ + │ array rows │ + ┌──────────────┴───────────────────▼────────────┐ + │ Repository (Infrastructure/Persistence/*) │ ← SQL + DatabasePort ONLY + └──────────────▲───────────────────┬────────────┘ + │ query/execute │ + ┌──────────────┴───────────────────▼────────────┐ + │ DatabasePort (Kernel\Ports\DatabasePort) │ ← the only DB seam + │ adapter: plugins/Database MultiDriverAdapter │ + └────────────────────────────────────────────────┘ +``` + +Rules (these are the GDA Five Access Rules applied to persistence): + +- A **Repository** depends on `DatabasePort` ONLY — never an HTTP client, vendor + SDK, or another module's repository. It translates every `\PDOException` + (already wrapped as `ConnectionException` by the Database plugin) into a + `RepositoryException` so no vendor type escapes the layer. +- A **Hydrator** is a `final` class of `static` pure functions. It imports only + Domain types. No DB handle, no container, no I/O. +- A **Domain Entity** has a `private` constructor, a `reconstitute()` named + constructor used ONLY by the hydrator (records NO events), and `create()`-style + named constructors that DO record domain events. +- Persistence NEVER lives in the Domain layer. Entities don't know they're stored. + +--- + +## 1. Migration (LetMigrate) — the schema is the source of truth + +```php +return new class implements MigrationInterface { + public function up(SchemaBuilderInterface $schema): void + { + $schema->create('invoices', static function ($t) { + $t->char('invoice_id', 31); + $t->char('tenant_id', 31); + $t->integer('amount_cents'); // money is integer cents — never float/decimal-as-float + $t->char('currency', 3); + $t->string('status', 20); + $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); + $t->timestamp('deleted_at')->nullable(); + + $t->unique(['invoice_id'], 'uniq_invoice_id'); + $t->index(['tenant_id', 'status'], 'idx_tenant_status'); // every tenant-scoped query keys on tenant_id + }); + } + public function down(SchemaBuilderInterface $schema): void { $schema->dropIfExists('invoices'); } +}; +``` + +Write the migration ONCE with the fluent `Blueprint`; LetMigrate compiles +correct DDL per driver (MySQL/PostgreSQL/SQLite/SQL Server). Always pair `up()` +with a real `down()`. Index every column a repository filters on — especially +`tenant_id`. + +--- + +## 2. Hydrator — row ⇄ entity, pure and explicit + +```php +final class InvoiceHydrator +{ + /** @param array $row */ + public static function hydrate(array $row): Invoice + { + return Invoice::reconstitute( + id: InvoiceId::from((string) $row['invoice_id']), + tenant: (string) $row['tenant_id'], + total: Money::ofCents((int) $row['amount_cents'], (string) $row['currency']), + status: InvoiceStatus::from((string) $row['status']), + ); + } + + /** Object → the column map the repository binds. Keeps SQL params in one place. */ + public static function toColumns(Invoice $i): array + { + return [ + 'invoice_id' => $i->id()->value(), + 'tenant_id' => $i->tenantId(), + 'amount_cents'=> $i->total()->amount(), // integer cents + 'currency' => $i->total()->currency(), + 'status' => $i->status()->value, + ]; + } +} +``` + +The hydrator is the single place that knows column names ⇄ value objects. It is +trivially unit-testable with a literal array. + +--- + +## 3. Repository — DatabasePort only, tenant-scoped, exception-translating + +```php +final class InvoiceRepository +{ + public function __construct( + private readonly DatabasePort $db, // ONLY external dependency + private readonly Identity $identity, // tenant scope comes from the verified Identity + ) {} + + public function find(string $id): Invoice + { + try { + $row = $this->db->queryOne( + 'SELECT * FROM invoices + WHERE invoice_id = :id AND tenant_id = :tenant AND deleted_at IS NULL', + ['id' => $id, 'tenant' => $this->identity->tenantId], // ALWAYS scope by tenant + ); + } catch (\Throwable $e) { + throw new RepositoryException("Failed to load invoice [{$id}].", + layer: 'repository.invoice', context: ['id' => $id], previous: $e); + } + + return $row !== null + ? InvoiceHydrator::hydrate($row) + : throw new RepositoryException("Invoice [{$id}] not found.", layer: 'repository.invoice'); + } + + public function save(Invoice $invoice): void + { + $cols = InvoiceHydrator::toColumns($invoice) + + ['created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')]; + try { + // Portable upsert — DatabasePort compiles ON DUPLICATE KEY (MySQL) or + // ON CONFLICT … DO UPDATE (PostgreSQL/SQLite). NEVER hand-write either. + $this->db->upsert( + 'invoices', + $cols, + conflictColumns: ['invoice_id'], + updateColumns: ['amount_cents', 'status'], // created_at left untouched on update + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to save invoice.', layer: 'repository.invoice', previous: $e); + } + } +} +``` + +Always: bound parameters (never interpolation), `tenant_id` in every clause, +soft-delete with `deleted_at IS NULL`, `\Throwable` → `RepositoryException`, and +the **portable `DatabasePort` API** for anything dialect-specific (see next). + +> **Multi-tenant note:** under the Tenancy plugin the `DatabasePort` injected here +> is *already* the per-request tenant connection (rebound by `TenantContextStage`), +> so tenant data isolation is enforced at TWO layers — the physical connection AND +> the `tenant_id` predicate. Control-plane repositories (users, memberships) pin to +> the `ConnectionManager` **default** (central) connection instead. + +--- + +## 3b. Portable DatabasePort API — never hand-write dialect SQL + +PDO's *API* and placeholder scheme (`:named`) are identical across MySQL, +PostgreSQL, and SQLite — but the *SQL text* is not (upserts, identifier quoting, +`lastInsertId`, string concat). The `DatabasePort` absorbs the constructs that +actually differ so repository code stays driver-agnostic: + +| Need | Use | Don't write | +|---|---|---| +| Insert-or-update | `$db->upsert($table, $values, $conflictColumns, $updateColumns)` | `ON DUPLICATE KEY UPDATE` / `ON CONFLICT …` by hand | +| Last insert id | `$db->lastInsertId($sequence = null)` — pass the sequence name on Postgres | `lastInsertId()` assuming MySQL semantics | +| Plain reads/writes | `query` / `queryOne` / `execute` with `:named` params | positional `?` (works, but keep one scheme) | + +`upsert()` semantics: +- `$conflictColumns` — the unique/PK columns that define a collision (must have a + matching unique constraint in the migration). +- `$updateColumns = null` → overwrite every non-conflict column. `[]` → do + nothing on conflict (insert-if-absent). A subset → overwrite only those (e.g. + refresh `role`/`updated_at` but preserve the original `joined_at`). +- Compiles to `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)` on MySQL and + `INSERT … ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` on + PostgreSQL/SQLite. One call, atomic, no UPDATE-then-INSERT race. + +Constructs the port does NOT abstract (keep to the portable subset, or branch on +`$db->driver()` in the rare case you must): string concatenation (`CONCAT` vs +`||`), `bytea`/BLOB stream handling, and vendor-specific functions. + +## 4. Optional QueryBuilder — only for genuinely dynamic SELECTs + +Most repositories are fine with literal SQL strings. Reach for a builder ONLY +when filters/sorts are composed at runtime (search, list endpoints). Keep it a +small, immutable value object that emits `[sql, params]` — never a fluent +Active-Record-style chain that executes itself. + +```php +$qb = QueryBuilder::select('invoices') + ->whereEquals('tenant_id', $this->identity->tenantId) // forced scope + ->whereNull('deleted_at') + ->when($status !== null, fn($q) => $q->whereEquals('status', $status)) + ->orderBy('created_at', 'desc') + ->limit($perPage)->offset($offset); + +[$sql, $params] = $qb->compile(); +$rows = $this->db->query($sql, $params); +return array_map(InvoiceHydrator::hydrate(...), $rows); +``` + +Builder rules: +- It produces SQL + bound params; it does **not** hold a `DatabasePort` and does + **not** execute. The repository executes. +- Column / table / direction identifiers come from a **whitelist**, never from + raw request input — only *values* are bound. +- It is immutable (each method returns a new instance) for Swoole safety. +- It is NOT a public contract. It lives in `Infrastructure/Persistence/` and is + an implementation detail of repositories. + +--- + +## What this blueprint deliberately rejects + +``` +✗ Active Record ($invoice->save()) — entities never touch the DB +✗ Lazy loading / proxies / N+1 — repositories fetch explicitly +✗ Identity map / unit of work — TransactionManager owns the boundary instead +✗ Global query scopes resolved from a container/middleware — scope is an explicit param +✗ Annotations/attributes mapping classes to tables — the Hydrator is the map +✗ A fluent builder that executes itself — builder emits SQL, repo runs it +✗ Vendor exceptions escaping the repository — translate to RepositoryException +✗ float for money — integer cents in column + Money VO +✗ Skipping tenant_id in a tenant-scoped query — isolation is mandatory +``` + +--- + +## Migration path if a real ORM is ever needed + +If the project later needs richer mapping, the seam to extend is the **Hydrator ++ Repository pair**, behind the existing published `*ServiceContract`. Because +services depend on contracts (never on a concrete repository or a `PDO` handle), +a future mapper can be swapped in without touching the Domain or Application +layers. Do not introduce a vendor ORM that bypasses `DatabasePort` — it would +break tenant connection rebinding, the exception-translation contract, and +Swoole request isolation. + +--- + +## Related context + +- `docs/guides/05_REPOSITORY.md` — repository layer rules in detail +- `docs/guides/18_MIGRATIONS.md` — LetMigrate engine + patterns +- `docs/guides/19_DATABASE.md` — multi-driver Database module + DatabasePort adapter +- `docs/guides/03_DOMAIN.md` — entity / value object / reconstitute() patterns diff --git a/docs/guides/23_TENANCY.md b/docs/guides/23_TENANCY.md new file mode 100644 index 0000000..a961a40 --- /dev/null +++ b/docs/guides/23_TENANCY.md @@ -0,0 +1,250 @@ +# Tenancy Plugin — Multi-Tenant Control Plane + +> AI reference for `Plugins\Tenancy\` (solves `tenancy.routing`, **essential**). +> Database-per-tenant isolation + central control plane on top of +> `plugins/Database`'s `ConnectionManager`. Pairs with [09_SECURITY](09_SECURITY.md), +> [19_DATABASE](19_DATABASE.md), [24_USER](24_USER.md). + +--- + +## WHAT IT DOES + +Maps an incoming request to **one tenant**, then rebinds `DatabasePort` to that +tenant's **isolated database** for the request, so every repository downstream +transparently talks to the right DB. The control-plane tables (tenant registry, +memberships, invitations, hosts, audit) live in the **central** +database and are NEVER tenant-routed. + +``` +Request → identify tenant → resolve isolated DatabasePort → rebind for this request + (claim / host / cookie) (registry + breaker, fail-closed) +``` + +`requires: ["database.management"]` — module-level requires cover ONLY the +always-on `TenantContextStage` path. Everything the selection / admin / +invitation / host ROUTES need (`auth.identity`, `user.management`, +`audit.trail`, `http.pageflow`) is declared per route in `module.json` +`routes[].requires`, so a Tenancy-essential project does not register those +modules on every request. + +--- + +## TENANT IDENTIFICATION — `TENANCY_MODE` + +The pluggable `TenantIdentifier` seam decides WHICH tenant a request belongs to. +`identify(Request): string` returns the tenant id, or `''` when none was +identified — which the stage FAILS CLOSED on (404). It may also throw +`UnknownTenantException` to refuse a host explicitly (same 404). + +| Mode (`TENANCY_MODE`) | Identifier | Tenant source | +|---|---|---| +| `claim` (default, SaaS) | `ClaimTenantIdentifier` | `Identity.tenantId` (the signed JWT `tnt` claim) | +| `domain` (storefront) | `DomainTenantIdentifier` | Host sub-domain under `TENANCY_BASE_DOMAINS` | +| `host` (custom domains) | `HostTenantIdentifier` | FULL hostname via the central `tenant_hosts` registry | + +**STRICT routing — no unscoped passthrough.** Every request must resolve to a +tenant: cookie hint first, then the identifier; both empty ⇒ **404** (`Tenant +not found`). Every host the app serves must therefore be assigned to a tenant +(`tenant:host:add` in host mode; a resolvable label in domain mode). Central +control-plane code never depends on the stage skipping the rebind — it pins the +central connection explicitly via the `ConnectionManager` default. + +**Activation — must be ESSENTIAL, declared by the PROJECT.** `TenantContextStage` +is an always-on `after.load` hook that resolves `TenantIdentifier` + the +connection resolver from the **request container**; those bindings only exist +when `Tenancy::register()` ran, and the stage now FAILS LOUDLY when they are +absent. A multi-tenant project declares `"essentials": ["tenancy.routing"]` in +its `proj.json` (read by `EntryHelpers::projectEssentials()` → +`Kernel::withEssentialModules()`, which also accepts domains and fails the boot +on an unknown one). A single-tenant project leaves Tenancy OUT of `withModules` +entirely — merely dropping it from essentials would make the always-on stage +throw on every request. Essentials resolve through the dependency graph, so +Tenancy's `database.management` requirement loads with it automatically. + +**`domain` mode + session login — cross-subdomain cookie.** Control-plane routes +(`/auth/login`, `/ajx/me/tenants`, `/ajx/tenants/{id}/select`) run on the +apex/central host (`shop.localhost` → `''` → central); tenant-scoped routes run on +`.shop.localhost` (→ that tenant's DB). For the apex login's session to +carry to the tenant sub-domains, set the session cookie's domain to the shared +base: `SESSION_COOKIE_DOMAIN=.shop.localhost` (host-only otherwise = 401 on the +sub-domain). Reserved sub-domains (`TENANCY_RESERVED_SUBDOMAINS`: www, api, admin, +…) resolve to central, never a tenant. + +--- + +## REQUEST ROUTING — `TenantContextStage` (after.load, priority 5) + +`Infrastructure/Http/Stages/TenantContextStage.php`. Runs after the request +container exists, before route filters / `ExecuteStage`. + +1. Resolve the active tenant: **encrypted cookie hint first** (principal-bound), + then the `TenantIdentifier` (see cookie section). Both empty → **404 fail + closed** — there is NO unscoped passthrough to the central `DatabasePort`. +2. `resolver->for($tenantId)` → isolated `DatabasePort` (registry lookup + + per-tenant circuit breaker; **fail-closed**, no silent fallback). +3. `$container->instance(DatabasePort::class, $db)` — rebind for THIS request only. +4. `$request->withAttribute('tenant', $tenantId)` — expose to controllers. +5. `$container->bind('tenant.current', fn() => $tenantId)` — a **plain string + container key** so request-scoped services that never see the `Request` (e.g. + the User `AuditLogger`) can read the active tenant with no Tenancy import. + Use `bind()` (closure), NOT `instance()`: the kernel `ModuleContainer::instance()` + requires an `object`, so binding the bare tenant-id string there throws a + `TypeError` on every host/domain-routed request. +6. On `UnknownTenantException` → 404 (and forget a stale cookie hint); on + `TenantUnavailableException` → 403/410/503; connectivity faults feed the breaker. + +``` +✗ Binding tenant context into CoreContainer — it rides the request + request container only (Swoole-safe) +✗ Reading $_SERVER for the host inside a module — use $request->attribute('tenant') +✗ Silent fallback to central or another tenant on resolution failure — fail closed +``` + +### Tenant cookie (encrypted hint — never authority) + +`TenantContextStage` writes an **encrypted, user-bound** cookie remembering the +active tenant so a returning user keeps their selection without re-running the +picker. Properties: + +- **Encrypted** via the Cookie plugin's `EncryptionPort` (tamper → `read()` returns null). +- **Principal-bound**: stores `{t: tenantId, u: userId}`; honoured only by the + exact principal that minted it — a user's hint never replays onto another user + (or a post-logout guest), while a guest-minted hint (`u` = `''`) keeps working + for guests so public pages retain their selection. Log-in flips the principal + and re-mints. +- **Cookie first**: the remembered selection is consulted BEFORE the identifier; + the identifier only runs when there is no valid hint. Every hint is still + fully re-validated below, so a stale/hostile value can never route to an + unknown tenant. +- **Still revalidated** every request through `resolver->for()` — a hint, exactly + like the `tnt` claim. A stale hint at a deleted tenant is auto-forgotten. + +--- + +## PUBLISHED CONTRACTS (`exposes`) + +| Contract | Role | +|---|---| +| `TenantRegistryContract` | tenant_id → connection coordinates (CachePort-cached) | +| `TenantConnectionResolverContract` | `for($tenantId): DatabasePort` (+ breaker) | +| `MembershipServiceContract` | `myTenants`, `isActiveMember`, `selectTenant` | +| `InvitationServiceContract` | email invite → seat (`invite`, `accept`) | +| `TenantHostRegistryContract` | hostname → tenant_id resolution | +| `TenantHostServiceContract` | `add`/`verify`/`makePrimary`/`remove` custom hosts | + +Internal ports (`Application/Ports/`): `MembershipReader`/`MembershipWriter`, +`InvitationStore`, `TenantHostStore`, `AuditSink` (write), +`AuditReader` (read), `DnsResolver`. + +--- + +## CENTRAL TABLES (control plane — never in a tenant DB) + +| Table | Repository | Notes | +|---|---|---| +| `tenants` | `TenantRegistry` | registry; `db_password_enc` encrypted via `EncryptionPort` | +| `user_tenants` | `MembershipRepository` | M:N user↔tenant + role/status; FK → central `users`/`tenants` | +| `tenant_invitations` | `InvitationRepository` | email onboarding, hashed token | +| `tenant_hosts` | `TenantHostRepository` | PK is **`host_id`** (not `id`); custom domains + DNS verify | +| `audit_log` | write `AuditTrail` / read `AuditLogRepository` | append-only; keyset-paginated reads | + +Migrations: `plugins/Tenancy/database/migrations/`. Tenant template schema (run +per new tenant DB): `plugins/Tenancy/database/tenant-template/` (or +`TENANCY_TEMPLATE_PATH`). See [18_MIGRATIONS](18_MIGRATIONS.md). + +--- + +## AUDIT TRAIL (`audit_log`) + +Shared central table written by BOTH Tenancy and the User plugin. + +- **Write**: `AuditSink::record(action, userId?, tenantId?, meta[], ip?)` → + `AuditTrail` (best-effort — an audit write NEVER breaks the audited action). +- **Read**: `AuditReader` → `AuditLogRepository` — `recent`, `forTenant`, + `forUser`, `byAction` (keyset-paginated by descending id), `find(eventId)`, + `countForTenant`, `purgeOlderThan(cutoff)` (retention/GDPR). LIMIT is clamped + + **inlined as an int** (cannot be bound with emulated prepares off); filter + values stay parameter-bound. + +--- + +## MEMBERSHIP & SELF-SIGNUP ASSIGNMENT + +A new user is assigned to their originating tenant via the **`user.registered`** +integration event (User's transactional outbox, relayed by `user:outbox:relay`): + +``` +self-signup on tenant host → RegisterUserDTO reads request 'tenant' attribute + → UserRegisteredIntegrationEvent carries tenantId (persisted in the outbox) + → Tenancy's AssignTenantMembershipOnUserRegistered listener (subscribed in boot()) + → MembershipWriter::upsertActive(userId, tenantId, 'member') [idempotent] +``` + +- The listener resolves from the **CoreContainer** (no request context) — so the + tenant MUST ride on the event payload, never re-derived at relay time. +- The project binds the listener in the CoreContainer with a central-connection + `MembershipWriter` (the EventBus resolves listeners there). See [08_EVENTS](08_EVENTS.md). +- Assignment is **eventually consistent** (lands when the relay runs) and + **idempotent** (`upsertActive` upserts on `(user_id, tenant_id)`). + +--- + +## CLI COMMANDS (claim mode only — registered in `Provider::boot()`) + +Registered via a deferred closure that builds a scoped `ModuleContainer` +(Database + Crypto + Tenancy) so commands with module-scoped deps resolve. Hidden +in `domain` mode (tenants are provisioned by the project's own tooling there). + +| Command | Purpose | +|---|---| +| `tenant:create` | Provision: registry row → CREATE DATABASE → DB user + grant → template migrations → activate. Interactive wizard (RadioGroup driver picker, masked Password, NumberInput port) when flags are missing. **Compensating rollback** on any failure (DDL isn't transactional on MySQL). | +| `tenant:delete` | Drop the tenant DB user (all hosts), optionally the database (`--drop-database`), and the registry row. Requires confirmation / `--yes`. | +| `tenant:host:add` | Register a hostname (via `TenantHostService`); `--verified` seeds it past DNS, `--primary` makes it canonical. Prompts (tenant Select, host, IP Select) for anything omitted in a terminal. | +| `tenant:migrate` | Run tenant template migrations across the fleet (per-tenant transactional, failure-isolated, resumable). | + +### Tenant DB user provisioning (driver-aware, `ManagesTenantDatabase` trait) + +- **Privileges are scoped to the tenant's database only** — `GRANT ALL ON \`db\`.*` + (MySQL) / database `OWNER` (pgsql) / `db_owner` (sqlsrv). Never global. +- **MySQL accounts are loopback-only by default** — created at `localhost`, + `127.0.0.1`, `::1` (works over socket AND TCP); a non-loopback host pins to that + exact host. **The `'%'` wildcard is never used.** +- Supported: `mysql`/`mariadb`, `pgsql`, `sqlsrv`. `sqlite` is rejected (no + users/CREATE DATABASE — provision file-per-tenant instead). + +--- + +## TENANT SELECTION & TOKENS (HTTP, `/ajx/...`) + +- `GET /ajx/me/tenants` → list my tenants. `POST /ajx/tenants/{id}/select` → + re-verifies membership, mints a `tnt`-scoped access JWT. +- DECOMPOSED (tenancy ≠ authentication): `MembershipService` is control plane + ONLY — `selectTenant()` verifies the seat + audits and returns the verified + `TenantSummary`; it has NO Auth dependency. `TenantController` is the + composition point: it mints the token via `AuthServiceContract` (with + `roles` and the `name` claim read through User's published + `TenantProfileReaderContract`) and builds the `TenantSelection` response. + This also keeps the container graph acyclic (AuthService → UserService → + MembershipService — no cycle back into Auth). +- `POST /ajx/invitations/accept` → join a tenant from an emailed invite. +- Refresh-token rotation is NOT here — it moved to `Plugins\Auth` (`POST /auth/refresh`). Tenancy re-checks the tenant seat only at tenant-SELECT. +- Custom hosts: `GET/POST /ajx/tenant/hosts`, `…/{hostId}/verify|primary`, DELETE. + +The signed `tnt` claim is a **hint, not authority** — authorization still keys on +`(userId, tenantId, role/permission)` and membership is re-checked each request so +a revoked seat loses access before token expiry. + +--- + +## ABSOLUTE RULES + +``` +✓ Control-plane tables (tenants, user_tenants, invitations, hosts, audit_log) are CENTRAL — pin to ConnectionManager default. (refresh_tokens now belongs to Plugins\Auth.) +✓ TenantContextStage rebinds DatabasePort per request ONLY; never into CoreContainer. +✓ Tenant DB users: privileges scoped to their own database; MySQL accounts loopback/host-pinned, never '%'. +✓ Membership assignment travels on the user.registered event payload (outbox), idempotent upsert. +✓ Mint a tenant-scoped token ONLY after verifying membership; re-check every request. +✗ Reading $_SERVER / re-identifying the tenant inside a module — use $request->attribute('tenant'). +✗ Trusting the tnt claim or tenant cookie as authority — both are revalidated hints. +✗ Hand-writing CREATE USER with '@%' or cross-DB privileges in provisioning. +✗ Binding the membership/audit listener WITHOUT the project supplying its central writer in CoreContainer. +``` diff --git a/docs/guides/24_USER.md b/docs/guides/24_USER.md new file mode 100644 index 0000000..3c949f1 --- /dev/null +++ b/docs/guides/24_USER.md @@ -0,0 +1,191 @@ +# User Plugin — Central Identity + +> AI reference for `Plugins\User\` (solves `user.management`). +> The GLOBAL central identity store: CRUD, credential verification, email +> verification, transactional outbox. Pairs with [09_SECURITY](09_SECURITY.md) +> (Auth issues tokens over this identity), [23_TENANCY](23_TENANCY.md) (memberships +> link users to tenants), [08_EVENTS](08_EVENTS.md). + +--- + +## WHAT IT DOES + +Owns the **global, central `users` table** — identity is centralized, username +and email are globally unique. Repositories + the outbox are pinned to the +**central** connection (the `ConnectionManager` default), so identity I/O is +NEVER redirected to a tenant DB even when `TenantContextStage` rebinds +`DatabasePort` for the request. + +`requires: ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client"]` +`exposes: ["Plugins\User\API\Contracts\UserServiceContract"]` (the ONLY cross-module +contract — feedback + settings are internal to the plugin) + +**No `status` column.** The login gate is a verified email: `verifyCredentials` +checks `User::canLogin()` (= `email_verified_at` is set). "Disable" = soft delete. +The old `status` / `auth_provider` / `provider_subject` / `is_platform_admin` / +`last_login_at` columns were removed. + +--- + +## PUBLISHED CONTRACT — `UserServiceContract` + +`Application/Services/UserService.php`. All methods take/return DTOs (`API/DTOs/`) +— never entities or raw arrays across the boundary. + +| Method | Notes | +|---|---| +| `register(RegisterUserDTO): UserDTO` | tx + outbox; emits `user.registered` | +| `list(ListUsersQuery): UserPage` | paginated | +| `find(id): ?UserDTO` | | +| `update(id, UpdateUserDTO): ?UserDTO` | optimistic-locked (`version`); emits `user.updated` | +| `verifyEmail(id, VerifyEmailDTO): ?UserDTO` | | +| `verifyCredentials(identifier, password): ?UserDTO` | timing-safe, rate-limited; rehash-on-login | +| `delete(id): bool` | emits `user.deleted` | + +`RegisterUserDTO::fromRequest()` also reads the request **`tenant`** attribute +(set by Tenancy's `TenantContextStage`) into `$tenantId` — an opaque string that +is forwarded on the `user.registered` event so Tenancy can assign membership. +User stays tenant-agnostic (no Tenancy import). + +--- + +## TENANT PROFILE READS — `TenantProfileReaderContract` (published) + +`TenantProfileProvisioner` now IMPLEMENTS the published +`TenantProfileReaderContract` (`fullName(userId, tenantId): string`) in two +construction modes: **pinned** (a `UserSettingsRepository` already built +against the resolved tenant connection — the listener path) or **resolver** +(the container binding — resolves the tenant DB per call through Tenancy's +`TenantConnectionResolverContract`). Reads are BEST-EFFORT and never throw — +a missing profile / unreachable tenant DB yields `''`. Consumers: Tenancy's +tenant-selection (the JWT `name` claim) and `UserService::find()` (attaches +`UserDTO.fullName` when a membership pins the tenant). `UserDTO` also carries +`avatarUrl` and `permissions`; `UserProfile::fullName()` composes first + last. +`UserServiceContract::find()` gained `bool $isAuth = false` — skips the +self-or-permission check for issuance-time lookups by Auth (request Identity +is still guest during login). + +--- + +## SERVICE PATTERN (mandatory shape) + +Mutating methods follow the kernel transaction+event pattern (see [04_SERVICE](04_SERVICE.md)): + +``` +collector->beginCollection(); transaction->begin(); + try { entity op → flushEvents() → repository.insert() → commit(); } + catch { rollback(); collector->discard(); throw wrap(...); } +collector->release(); // domain events +audit->record('user.…', [...]); // security audit (also persisted to audit_log) +``` + +Integration events are written to the **transactional outbox** inside the tx +(durable), NOT dispatched inline. + +--- + +## EVENTS — TRANSACTIONAL OUTBOX + +`emits: ["user.registered", "user.updated", "user.deleted"]` + +- `flushEvents()` → `toIntegration()` builds the integration event and + `OutboxWriter::write()`s it into `user_outbox` **in the same transaction** as + the user change (atomic, no lost/phantom events). +- `user:outbox:relay` (CLI command, `Infrastructure/Cli/RelayUserOutboxCommand`) + drains pending rows and dispatches a `GenericIntegrationEvent` (carrying the + stored payload array) to the EventBus. Delivery is **at-least-once** → listeners + must be idempotent. +- `UserRegisteredIntegrationEvent` carries `userId, username, email, occurredAt` + **+ `tenantId`** (origin tenant for self-signup; `''` when none). This is how + Tenancy auto-assigns membership — see [23_TENANCY](23_TENANCY.md). + +--- + +## SECURITY AUDIT — `AuditLogger` + +`Infrastructure/Audit/AuditLogger.php`. Records security-relevant actions +(register, update, email verified, login failed/locked-out, password rehash, +delete) — **identifiers + outcomes only, never passwords/hashes/PII**. + +- Writes a structured JSON line (via `error_log`, tagged `source=user_audit`). +- **Also persists to the shared central `audit_log` table** when a `DatabasePort` + is injected: `userId`→`user_id`, `ip`→`ip`, the rest→JSON `meta`, `event_id` + via `Ulid::generate()`. **Best-effort** (try/catch — an audit write must never + break the audited action; the log line is the durable fallback). +- `tenant_id` is stamped from the `'tenant.current'` container key published by + Tenancy's `TenantContextStage` (`has()`-guarded — no Tenancy dependency); `NULL` + for unscoped/CLI requests. +- Reads/queries of `audit_log` are Tenancy's `AuditReader`/`AuditLogRepository`. + +--- + +## DATA + +| Table | Repository | Notes | +|---|---|---| +| `users` | `UserRepository` (central) | ULID `user_id`; unique username/email; `password_hash`, `remember_token` (60/64 char); `version` (optimistic lock); login gate = `email_verified_at` | +| `user_outbox` | `OutboxWriter` / `OutboxRelay` (central) | transactional integration-event outbox | +| `user_feedback` | `FeedbackRepository` (TENANT) | tenant-scoped; `feedback_id` UUID public id; `user_id` = central ULID (soft ref, no FK) | +| `user_profiles` / `user_preferences` / `user_privacy_settings` / `user_notification_preferences` | `UserSettingsRepository` (TENANT) | per-user singletons; one row per `user_id`; portable `upsert` | + +Central schema → `database/migrations/` (`migrate:run`). Tenant schema → +`database/tenant-template/`, applied per-tenant by the **Tenancy** tooling +(`tenant:migrate`), NOT `migrate:run`. + +- Passwords hashed via `crypto.services` (bcrypt, rehash-on-login). Hashes and + remember tokens NEVER cross the API boundary. +- `UserId`/`Ulid` value objects generate the 26-char public id. +- See [05_REPOSITORY](05_REPOSITORY.md), [18_MIGRATIONS](18_MIGRATIONS.md). + +--- + +## ROUTES (`module.json`) + +- HTML (View): `GET /users[...]`, plus demo pages `GET /account/settings`, + `/account/feedback`. +- JSON identity (`/ajx/users...`): `POST /ajx/users` register (`throttle:10,1` — + **anonymous**, not auth-gated), `GET/PUT/PATCH/DELETE /ajx/users/{id}` + + verify-email (`auth`). +- JSON feedback (`auth` + `tenant`): `POST /ajx/feedback` (`throttle:5,1`), + `GET /ajx/feedback`, `GET /ajx/feedback/{id}`, `PATCH /ajx/feedback/{id}`. +- JSON settings (`auth` + `tenant`): `GET/PUT /ajx/{profile,preferences,privacy, + notification-preferences}` (PUT `throttle:30,1`). + +--- + +## TENANT-SCOPED SUB-RESOURCES (feedback & settings) + +Internal capabilities whose data lives in the **tenant** DB (not central): + +- **Repositories take the request `DatabasePort`** (tenant-routed by + `TenantContextStage`), NOT `self::central()`. `user_id` is the central ULID, + carried as a soft reference (no cross-DB FK). +- **Routes declare `["auth", "tenant"]`.** The `tenant` filter (Tenancy plugin) + returns **409** when no tenant is active → these never silently hit central. +- **Self-scoped** — user id from `Identity`, never the body. AuthZ in the service. +- **Internal, not published** — bound `bindInternal`; controllers depend on the + concrete `FeedbackService` / `UserSettingsService`. They return the domain + **entity** and the controller serialises via `entity->toArray()` (no output DTO). +- **Feedback** = full CRUD (`submit`/`find`/`list`/`updateStatus`, forward-only + status, `feedback:manage` for triage); emits `feedback.submitted` **directly** + (single insert, not the outbox). **Settings** = one `UserSettingsService` + + `UserSettingsRepository` for the 4 singletons, idempotent `PUT` via `upsert`, + audited on write. + +--- + +## ABSOLUTE RULES + +``` +✓ users + user_outbox are CENTRAL — pin repositories to the ConnectionManager default, never the request DatabasePort. +✓ Integration events go through the transactional outbox; relayed at-least-once → idempotent listeners. +✓ Audit records identifiers/outcomes ONLY; DB persistence is best-effort and never aborts the action. +✓ Password hashes / remember tokens never appear in a DTO or response. +✓ Writes are optimistic-locked on `version`. +✓ users/feedback/settings split connections: identity = CENTRAL, feedback/settings = TENANT (request DatabasePort). +✗ Importing a Tenancy class from User — User forwards the opaque 'tenant' request attribute only. +✗ Dispatching user identity events inline instead of via the outbox (feedback.submitted is a single insert → direct dispatch is fine). +✗ Returning entities across the PUBLISHED contract (UserServiceContract) — use API/DTOs. (Internal feedback/settings services return entities; their controllers toArray().) +✗ Reading user IDENTITY from a tenant-routed DatabasePort — always central. (Feedback/settings deliberately DO use the tenant connection.) +✗ Applying tenant-template schema with migrate:run — it is per-tenant (tenant:migrate). +``` diff --git a/docs/guides/25_AUTH.md b/docs/guides/25_AUTH.md new file mode 100644 index 0000000..40ffb26 --- /dev/null +++ b/docs/guides/25_AUTH.md @@ -0,0 +1,324 @@ +# Auth Plugin — Authentication (tokens + sessions) + +> AI reference for `Plugins\Auth\` (solves `auth.identity`). +> Issues credentials (JWT, personal access tokens) and provides the +> SecurityLayer verifiers the kernel runs before any module loads. Pairs with +> [09_SECURITY](09_SECURITY.md), [24_USER](24_USER.md) (verifies credentials), +> [26_OAUTH2](26_OAUTH2.md) (OAuth2 access tokens are the same JWTs this layer +> verifies). + +--- + +## WHAT IT DOES + +The kernel ships **no** token validator — Auth fills the intended "AuthModule +layer" slot. It splits cleanly: + +- **Issuance** lives in `AuthService` (exposed via `AuthServiceContract`): mint + JWTs, create/revoke personal access tokens (PATs), establish/tear down web + sessions, hash/verify passwords. +- **Verification** lives in `SecurityLayer` classes a project wires into + `Kernel::withSecurity([...])`; the SecurityGateway runs them before any module + loads (deny = zero module cost). + +``` +requires: ["database.management", "crypto.services", "user.management"] +exposes: ["Plugins\Auth\API\Contracts\AuthServiceContract"] +``` +Control-plane tables (`personal_access_tokens`) are pinned to the **central** +connection. The session login flow verifies credentials via `UserServiceContract`. + +--- + +## SECURITY LAYERS (wired in the project bootstrap) + +### `JwtAuthLayer` — stateless Bearer JWT +```php +new JwtAuthLayer( + secret: $hsSecretOrPublicKeyPem, // HS secret, or PEM PUBLIC key for RS/ES/PS + algo: 'RS256', // single pinned algo — never trust the token's `alg` + issuer: env('JWT_ISSUER'), // when set, `iss` MUST match + audience: env('JWT_AUDIENCE'), // when set, `aud` MUST contain it (list-aware, hash_equals) + leeway: 60, // clock-skew tolerance for exp/iat/nbf + revocations: $cachePort, // optional jti deny-list +); +``` +- No `Authorization` header → **allow as guest** (public routes keep working). +- Valid Bearer → `Identity` from `sub`/`tnt`/`roles`/`permissions`. +- Malformed / expired / wrong iss|aud / **revoked `jti`** → `deny(401)`. +- Revocation deny-list **fails OPEN** on a cache outage (token is otherwise valid). + +### `PersonalAccessTokenLayer` — DB-backed `Bearer .` +Hashes (`sha256`) and matches against `personal_access_tokens`; **enforces +`expires_at`** (expired = absent), loads the token's `abilities` into +`Identity.permissions`, and stamps `last_used_at`. Empty `tenantId` (unscoped / +central) — consistent with the JWT layer. + +JWT/JOSE verification is the ONLY auth the kernel delegates here; everything else +(firewall, rate-limit, CSRF) is kernel-native. + +--- + +## PUBLISHED CONTRACT — `AuthServiceContract` + +| Method | Notes | +|---|---| +| `issueJwt(userId, claims, ttl): string` | adds `iat/nbf/exp/jti`, plus `iss/aud` when configured. Asymmetric algos sign with the **private key** (`JWT_PRIVATE_KEY[_FILE]`), optional `kid` | +| `revokeJwt(jti, ttl): void` | deny-lists a `jti` via `CachePort` (key `auth:jwt:revoked:`) so a token dies before expiry | +| `createPersonalAccessToken(userId, name, abilities, ttl): {id, token}` | plaintext returned ONCE; only the hash is stored; optional abilities + expiry | +| `revokePersonalAccessToken(id): void` | | +| `tokensFor(userId): list` | lists a user's PATs (newest first), **no secret material**. GDA replacement for the old `HasApiTokens::tokens()` | +| `guard(Request): Guard` | read-only projection over the request `Identity` — replaces the old `AuthManager`/named guards (see below) | +| `startSession(SessionPort, userId, roles, permissions, tenantId): void` | rotates session id (fixation defence), stores identity | +| `endSession(SessionPort): void` | invalidate + rotate | +| `hashPassword / verifyPassword` | bcrypt/argon2 via `HashingPort`, timing-safe | + +--- + +## GUARD — READ-ONLY IDENTITY PROJECTION (replaces `AuthManager`) + +There is no guard/driver factory. The SecurityGateway chain +(`JwtAuthLayer` → `PersonalAccessTokenLayer` → `SessionAuthStage`) already +resolved WHO authenticated and by WHICH credential. `Plugins\Auth\API\Guard` is a +stateless, allocation-cheap projection over the request `Identity`: + +| Method | Meaning | +|---|---| +| `check()` / `guest()` | authenticated? | +| `id()` / `tenantId()` | user id / tenant ('' = central) | +| `via()` | `'jwt' \| 'api_key' \| 'session' \| 'none'` — the "named guard", derived not chosen | +| `viaToken()` / `viaSession()` | Bearer credential vs stateful session | +| `hasRole()` / `hasPermission()` | RBAC | +| `hasScope(s)` | token scope — matches a bare permission OR OAuth2's `scope:` namespaced form | + +Controllers get it via the `Project\Http\Controllers\Concerns\InteractsWithAuth` +concern: `$this->guard()`, `$this->identity()`, `$this->authId()`, +`$this->tokenCan('write')`. Works even without the Auth module loaded (it reads +the kernel `Identity`). + +--- + +## AUTHMANAGER — NAMED GUARDS + PROVIDERS (config-driven) + +For multi-guard apps (session web + token API + jwt), `AuthManager` manages named +**guards** and user **providers** from `config/auth.php`. GDA-native rework of the +old `__DEV__` AuthManager — no global `auth.` alias, no `kernel()`/`config()` +reach-ins, and the kernel `Identity` stays the principal (guards resolve an +`AuthUserProxy` that **emits** an `Identity`). + +```php +$manager->guard(); // default guard (config defaults.guard) +$manager->guard('api')->user(); // ?Authenticatable (AuthUserProxy) +$manager->guard('jwt')->identity(); // kernel Identity +$manager->provider('users'); // a named UserProvider (ModelUserProvider) +``` + +| Piece | Role | +|---|---| +| `AuthManager` | request-scoped registry; `guard($name)`, `user()`, `check()`, `id()`, `provider($name)`. Bind `setRequest($request)` per use (Request is not container-bound) | +| `UserProvider` / `ModelUserProvider` | resolves users from a store. Default `users` provider is ModelUserProvider over `UserServiceContract` (no ORM). `retrieveByCredentials` does the FULL timing-safe verify (the store hides the hash) | +| `AuthUserProxy` | lightweight current-user; carries id/username/email + security context; `identity(): Identity`. NOT the principal | +| `GuardDriver` (`Infrastructure/Auth/Drivers/*`) | `session` (session store), `jwt`/`token` (rehydrate the SecurityGateway verdict by tokenType), `request` (credential-agnostic) | + +**Driver "scan":** `AuthManager::drivers()` filesystem-scans +`Infrastructure/Auth/Drivers/*.php` for `GuardDriver` implementations, keyed by +`driverName()`, **once per process, cached** (boot-time — a deliberate, +documented exception to the GDA no-runtime-discovery rule; never on the hot path). + +Controllers: `Project\Http\Controllers\Concerns\InteractsWithAuthManager` → +`$this->auth('api')->user()`, `$this->authUser()`. A route using it must declare +`"requires": ["auth.identity"]`. Config lives in `config/auth.php` (project copy +wins), read via `auth_config()`. + +--- + +## HIERARCHICAL SCOPE INHERITANCE + +Scopes/abilities are colon-hierarchical: a held scope satisfies every descendant. +`ScopeInheritance::satisfies($held, $required)` powers `Guard::hasScope()`, +`AuthUserProxy::tokenCan()` and `TokenDTO::can()`. + +```php +Guard::actingAs('u1', ['admin'])->hasScope('admin:users:write'); // true (ancestor) +Guard::actingAs('u1', ['reports'])->hasScope('billing'); // false +// '*' grants everything; 'scope:'-namespaced (OAuth2) and bare (PAT) both match; +// non-colon-boundary prefixes never match ('adm' ≠ 'admin'). +``` + +--- + +## PERSONAL ACCESS TOKENS — self-service (`/auth/tokens`) + +First-party user API keys (`Bearer .`), owner-scoped to the caller's +Identity. Backed by `AuthServiceContract` (hash-only storage). NOT OAuth clients, +NOT used by session login. + +| Route | Action | +|---|---| +| `GET /auth/tokens` | list my tokens (no secrets) | +| `POST /auth/tokens` | mint (plaintext returned ONCE) | +| `DELETE /auth/tokens/{id}` | revoke one of MY tokens (else 404) | + +`AuthServiceContract`: `createPersonalAccessToken`, `revokePersonalAccessToken`, +`tokensFor(userId): list`. `PersonalAccessTokenFactory` + +`PersonalAccessTokenResult` mint the one-time result. `AuthUserProxy` exposes +HasApiTokens (`tokens()/token()/tokenCan()/createToken()`). + +--- + +## REFRESH TOKENS — revocable first-party sessions (`/auth/refresh`) + +Relocated from Tenancy (authentication ≠ tenancy). `RefreshTokenServiceContract`: +`issue/rotate/revoke/revokeAllForUser`. One-time-use rotation with rotation-family +reuse detection (replay/race → burn the family → 401). Only the SHA-256 is stored; +the raw token is returned once. Table `refresh_tokens` (central, `family_id`). + +**Tenant-agnostic:** `tenantId` rides through as a scope hint for the paired +access token's `tnt` claim but is NEVER re-verified on refresh — tenant seat checks +live in the Tenancy `/ajx/tenants/{id}/select` flow. + +- `POST /auth/refresh` `{token}` → new access JWT + rotated refresh token (401 on invalid/reuse). +- `POST /auth/refresh/logout` `{token}` → revoke a single session. + +## TRANSIENT TOKEN — first-party SPA (`/auth/token/refresh`) + +`POST /auth/token/refresh` (auth-filtered). A session-authenticated SPA mints a +short-lived (900s) JWT carrying the session identity's real roles/permissions — +the scoped replacement for Passport's blanket transient token. A Bearer/PAT caller +(non-session) is refused. + +## PASSWORD RESET — `PasswordBroker` + +CachePort-backed, enumeration-safe. `sendResetLink(email)` mints a one-time hashed +token (throttled); `validateToken`; `reset(email, token, newPassword)` sets the +password (via `UserServiceContract::resetPassword`, which also clears remember +tokens) and burns the token. Statuses: `RESET_LINK_SENT` / `PASSWORD_RESET` / +`INVALID_USER` / `INVALID_TOKEN` / `THROTTLED`. + +--- + +## SESSION AUTH (web + AJAX) + +The session is opened at `after.load` (`StartSessionStage`, priority 20) — AFTER +the SecurityGateway — so session auth CANNOT be a SecurityLayer. Instead +`SessionAuthStage` is an `after.load` hook at **priority 22** (after session +start, before the route `auth` filter): + +- A request already carrying a token-derived `Identity` is left untouched (token + wins). +- An anonymous request with a logged-in session gets a `tokenType: 'session'` + Identity rebuilt from the session. +- The same `auth` route filter then protects **both** token and session callers. + +**The session Identity is bound into BOTH the request AND the request-scoped +container.** `OnDemandLoader` binds `Identity::class` at `LoadStage` from the +PRE-auth (guest) request — which runs *before* this `after.load` stage. So +`SessionAuthStage::attach()` rebinds `Identity::class` into `$request->container()` +too, not just the request. Without that rebind the `auth` route filter would pass +(it reads the request) but every **service** — which injects `Identity` from the +container — would still see a guest, so service-layer permission checks +(`requirePermission()`, `isGuest()`) would wrongly fail. Token auth is unaffected: +it attaches its Identity in the SecurityGateway (before `LoadStage`), so the +container already holds the right one. Any stage that *elevates* an Identity +mid-pipeline (adds roles/permissions) must follow the same rule — rebind the +container, not only the request. + +Endpoints (`SessionAuthController`): `POST /auth/login` (verifies via User module, +then `startSession`), `POST /auth/logout`, `GET /auth/me`. CSRF is the kernel's +`CsrfTokenLayer` (these routes are outside `/api`). + +### Post-login redirect ("previous page") + +The Session plugin's `StartSessionStage` records the last eligible page view +(GET + 2xx, HTML navigation OR a Pageflow page object via the `X-Pageflow` +response header; auth/OAuth/API/asset paths exempt, extend with +`SESSION_PREVIOUS_EXEMPT`) under **`StartSessionStage::PREVIOUS_URL`** — the +SINGLE source of truth for the key (value `auth.previous_url`; no duplicate +const anywhere). On successful `POST /auth/login`, first match wins: + +1. an explicit `redirectTo` on the login request (query or body), +2. the recorded previous page — PULLED one-time, so a fulfilled intent never + goes stale, +3. `/`. + +Browser form POSTs get a real 302; AJAX/SPA callers get `redirectTo` in the +JSON payload (alongside `user`) and navigate client-side. BOTH candidates pass +the same open-redirect guard (`safeRedirect()`): relative `/…` paths only — +`//host`, `/\` tricks and absolute URLs are rejected. SocialAuth's web +callback consumes the same key (falls back to `SOCIAL_AUTH_SUCCESS_REDIRECT`). + +### Display identity (username / email / fullName / avatarUrl) + +`Identity` carries best-effort display fields. `AuthService` fills +username/email from the central user store at issuance when the caller didn't +supply them (`displayIdentity()` → `UserServiceContract::find(id, false, +isAuth: true)` — `isAuth` skips the self-or-permission check, since at +issuance the request Identity is still guest). They ride as OIDC claims +(`preferred_username`, `email`, `name`) on JWTs — rebuilt statelessly by +`JwtAuthLayer` — and as session keys (`SESSION_USERNAME/EMAIL/NAME/AVATAR`) +for session logins/recaller resurrection. `name` (first + last) lives in the +TENANT `user_profiles` table, so only tenant-aware flows (tenant selection) +mint it. The `users` constructor dep is a **LAZY closure** (`fn(): +UserServiceContract`): an eager `make()` recurses AuthService → UserService → +MembershipService → AuthService until `max_execution_time`. + +### Remember-me (recaller cookie) + +`POST /auth/login` with `remember=true` issues an encrypted `remember_web` +cookie holding a `userId|token` **recaller** (`Plugins\Auth\Domain\ValueObjects\Recaller` +— a flat pipe string; NEVER unserialized). When a later request has no live +session, `SessionAuthStage::fromRecaller()`: + +1. reads + decrypts the cookie (via the essential `CookieJar`); +2. resolves the user by the token's SHA-256 hash (`UserServiceContract::findByRememberToken`), + rejecting a mismatched owner id or a forged/stale token; +3. re-opens the session (`startSession`, rotating the id) and attaches a + `tokenType: 'session'` Identity; +4. **rotates** the token + cookie (`cycleRememberToken`) so a stolen cookie is a + single-use window. + +Logout clears the stored token (`clearRememberToken`) and expires the cookie, so +outstanding recallers die immediately. The `remember_token` column + index live +on the central `users` table. Backed by `UserServiceContract`: +`findByRememberToken(token)`, `cycleRememberToken(userId): plaintext`, +`clearRememberToken(userId)`. + +--- + +## CLI + +- `auth:tokens:prune [--dry] [--watch=SECONDS]` — delete expired PATs (cron or a + supervised loop for no-cron environments). + +--- + +## CONFIG (env) + +`JWT_SECRET`, `JWT_ALGO` (default HS256), `JWT_ISSUER`, `JWT_AUDIENCE`, +`JWT_PRIVATE_KEY` / `JWT_PRIVATE_KEY_FILE` (asymmetric signing — file form keeps +keys off the process env), `JWT_KID`, `AUTH_PAT_TABLE`, +`AUTH_REFRESH_TTL` (refresh-token lifetime, default 30d), +`AUTH_REFRESH_ACCESS_TTL` (paired access-JWT lifetime, default 900s), +`AUTH_GUARD` / `AUTH_PROVIDER` (AuthManager defaults). Guard/provider maps live in +`config/auth.php` (read via `auth_config()`). + +--- + +## RULES + +``` +✓ Verification = SecurityLayers (gateway); issuance = AuthService. Never mix. +✓ Pin a SINGLE algo in JwtAuthLayer — never let the token's `alg` choose the verifier. +✓ Asymmetric (RS/ES/PS) for any deployment where verifiers must not hold the signing secret. +✓ PATs: store only the hash, return plaintext once, enforce expires_at, load abilities as permissions. +✓ Session login AFTER credential verification; rotate the session id (fixation defence). +✓ Guard is a projection over the request Identity — never a stateful driver/AuthManager, never a global. +✓ Remember-me: store only the token HASH, rotate on every use, match the cookie's owner id, clear on logout. +✓ Refresh tokens live in Auth, not Tenancy. One-time-use rotation; a replay/race burns the whole family. +✓ Scopes are hierarchical — an ancestor satisfies its descendants; never do a bare string-equality scope check. +✗ Re-checking tenant seat membership on refresh — refresh is tenant-agnostic; the seat check is at tenant-SELECT. +✗ A SecurityLayer that THROWS — always return a SecurityVerdict. +✗ Unserializing a recaller/cookie value — the recaller is a flat `id|token` string (object-injection safe). +✗ Trusting a `tnt` claim as authorization — it is a routing hint; authz keys on (userId, tenantId, role/permission). +✗ getenv() for JWT_* — use env() (see 11_PROJECT). +``` diff --git a/docs/guides/26_OAUTH2.md b/docs/guides/26_OAUTH2.md new file mode 100644 index 0000000..1438d2c --- /dev/null +++ b/docs/guides/26_OAUTH2.md @@ -0,0 +1,118 @@ +# OAuth2 Plugin — Authorization Server (OAuth 2.1 + OIDC) + +> AI reference for `Plugins\OAuth2\` (solves `oauth.server`). +> A native, dependency-free OAuth 2.1 + OpenID Connect authorization server. +> Access tokens are JWTs signed with the platform JWT keys, so they are verified +> by [25_AUTH](25_AUTH.md)'s `JwtAuthLayer` with no extra wiring. Pairs with +> [24_USER](24_USER.md) (password grant), [09_SECURITY](09_SECURITY.md). + +--- + +## WHAT IT DOES + +A full authorization server for **third-party / delegated** access (the piece a +first-party Auth module can't provide). Reuses `firebase/php-jwt` (already a +kernel dep) — no new vendor packages, honouring native distribution. + +``` +requires: ["database.management", "crypto.services", "user.management", "view.rendering"] +exposes: ["Plugins\OAuth2\Application\Ports\ClientStore"] +``` +All control-plane tables (`oauth_clients`, `oauth_auth_codes`, +`oauth_refresh_tokens`, `oauth_scopes`, `oauth_device_codes`) are pinned to the +**central** connection. + +> **Placement:** OAuth2 is a CENTRAL/control-plane concern — serve `/oauth/*` on +> the **apex/central host**, never tenant sub-domains. In host-tenancy mode set +> `TENANCY_BASE_DOMAINS` so the apex resolves to central. + +--- + +## GRANTS + +| Grant | Notes | +|---|---| +| `authorization_code` (+ **PKCE**) | exact-match `redirect_uri`; PKCE **mandatory for public clients** (S256/plain); codes random, hashed, 60s, single-use (atomic `consume`) | +| `client_credentials` | confidential clients only; no refresh token; `sub = client_id` | +| `refresh_token` | rotating + **family reuse-detection** (replay burns the family); scope narrowing only | +| `password` | confidential client; verifies via `ResourceOwnerVerifier` (User module); deprecated by OAuth 2.1 | +| `urn:…:device_code` | RFC 8628; `authorization_pending` / `slow_down` (interval-enforced) / `access_denied` / `expired_token`; single redemption | + +Confidential clients ALWAYS authenticate (Basic or body secret, `hash_equals`); +public clients are identified by `client_id` + PKCE only. + +--- + +## ENDPOINTS + +| Method · Path | Purpose | +|---|---| +| `GET/POST /oauth/authorize` | Auth-code consent (session-auth gated; request stored **server-side**, form carries only an opaque `authz_id` — no PKCE/scope round-trip) | +| `POST /oauth/token` | token endpoint (all grants) | +| `POST /oauth/device_authorization` | device-code start (device_code + user_code) | +| `GET/POST /oauth/device` | device user-verification page | +| `GET /oauth/userinfo` | OIDC UserInfo (Bearer; requires `scope:openid`) | +| `POST /oauth/introspect` | RFC 7662 (client-authenticated) | +| `POST /oauth/revoke` | RFC 7009 — refresh family revoke **+ JWT `jti` deny-list** | +| `GET /oauth/jwks` | RFC 7517 JWKS (RSA + EC) | +| `GET /.well-known/oauth-authorization-server` · `/openid-configuration` | RFC 8414 / OIDC discovery | +| `GET /oauth/scopes` | scope catalogue **with descriptions** (`ScopeRegistry` over `ScopeStore::describe()`) — public | +| `GET/POST/PUT/DELETE /oauth/clients` · `/clients/{id}` | **self-service client mgmt** (`auth`-gated, owner-scoped via `owner_id`; secret shown ONCE on create; another owner's client → 404) | +| `GET/DELETE /oauth/authorized-tokens` · `/{id}` | **self-service authorized-apps** — list a user's active grants; delete revokes the whole rotation family (`RefreshTokenStore::findByUser`) | + +The mgmt trio is the GDA-native port of Passport's `Client`/`AuthorizedAccessToken`/ +`Scope` controllers. `ScopeRegistry` also exposes `scopesFor()`/`tokensCan()`/ +`hasScope()` for consent screens. Personal (user) API keys are NOT here — those +are Auth PATs (`/auth/tokens`); `oauth_clients` stores APPLICATIONS, not user keys. + +CSRF: the machine POSTs (`/oauth/token`, `/introspect`, `/revoke`, +`/device_authorization`) MUST be in `CsrfTokenLayer` `exemptPaths` (client-auth, +not cookie-auth); the browser consent forms (`/oauth/authorize`, `/oauth/device`) +stay CSRF-protected. + +--- + +## TOKENS + +- **Access token = JWT** signed with the platform key (`JWT_ALGO`/keys), so the + existing `JwtAuthLayer` validates it. Claims: `iss`, `aud` (the **resource + audience** `OAUTH_TOKEN_AUDIENCE` ∕ `JWT_AUDIENCE`, NOT the client), `azp` + (client), `sub`, `scope`, `jti`, and `permissions` as **`scope:`** + (namespaced so an OAuth scope can NEVER satisfy a first-party + `hasPermission('admin')`). +- **id_token** (OIDC) issued when `openid` is granted — carries `nonce`, + `aud = client_id`, `auth_time`. Refused for a **public client under symmetric + (HS) signing** (unverifiable) — OIDC needs RS/ES/PS keys. +- **Refresh token** = opaque, stored hashed, rotating. + +--- + +## CLI + +`oauth:client:create` (`--public` for PKCE clients; secret shown once), +`oauth:client:list`, `oauth:client:revoke`, `oauth:client:rotate`, +`oauth:prune [--watch=SECONDS]` (expired codes/refresh/device rows). + +--- + +## CONFIG (env) + +`OAUTH_ACCESS_TTL`, `OAUTH_REFRESH_TTL`, `OAUTH_CODE_TTL`, `OAUTH_DEVICE_TTL`, +`OAUTH_DEVICE_INTERVAL`, `OAUTH_TOKEN_AUDIENCE` (defaults to `JWT_AUDIENCE`). +Signing keys come from Auth's `JWT_*` (use **RS256 + key files** for OIDC). + +--- + +## RULES + +``` +✓ Serve /oauth/* on the apex/central host (control-plane); set TENANCY_BASE_DOMAINS in host mode. +✓ Access tokens are platform JWTs — verified by JwtAuthLayer, no OAuth-specific resource-server code. +✓ Scopes ride in `scope` AND namespaced `scope:*` permissions — never bare RBAC names. +✓ redirect_uri EXACT match, validated before any error redirect; PKCE mandatory for public clients. +✓ Refresh rotation with family reuse-detection; auth codes single-use (burned on PKCE/redirect failure). +✓ OIDC (public clients) requires asymmetric signing (RS/ES/PS) + key files. +✗ Putting OAuth scopes into bare `permissions` (collision with first-party authz). +✗ CSRF-protecting the machine token/introspect/revoke endpoints (they are client-authenticated). +✗ A new vendor OAuth package — this server is native on firebase/php-jwt. +``` diff --git a/docs/guides/27_ENTITY_SUPPORT.md b/docs/guides/27_ENTITY_SUPPORT.md new file mode 100644 index 0000000..60f4ed0 --- /dev/null +++ b/docs/guides/27_ENTITY_SUPPORT.md @@ -0,0 +1,217 @@ +# 27 — Entity, Casting & Hydration Support (`Project\Support\`) + +> Reusable, DI-free, I/O-free entity-mapping helpers under `projects/Support/`. +> They are the **GDA-compliant decomposition of the legacy `__DEV__/Entity` +> Active Record** — the fat CodeIgniter/Eloquent-style base was split across the +> layers it conflated, and only the genuinely reusable casting / mapping / +> entity-mechanics live here. + +This file is the AI-context summary. The exhaustive, copy-pasteable cookbooks are: + +- `projects/Support/Casting/README.md` — casting engine + hydrator (13 examples) +- `projects/Support/Entity/README.md` — the `Entity` base (18-part cookbook) + +--- + +## Why it exists + +`__DEV__/Entity/Entity.php` was a fat Active Record: magic `__get/__set`, +mutators/accessors, `save()/delete()/restore()`, `performInsert/Update`, +`getRepo_()`, WP-style meta tables, change tracking — all in one base. GDA forbids +ORM/AR in the Domain layer, entities importing infrastructure, and entities +calling their own repository. The responsibilities were therefore split: + +| Old `Entity` responsibility | GDA home | +|---|---| +| Attributes, transitions, change tracking, invariants | **Domain entity** (or the `Entity` base) | +| `save()/delete()/performInsert/Update`/meta tables | **Repository** (`DatabasePort`, tenant-scoped) | +| Type casting + row⇄object mapping | **this Support layer** | +| Mass-assignment + validation | **DTO** at the controller edge (entity keeps a guard as defense-in-depth) | +| `toArray()/jsonSerialize()` | Response **DTO** (entity provides them too) | + +--- + +## Components + +| Namespace | Class | Role | +|---|---|---| +| `Project\Support\Casting` | `DataCaster` | Cast ONE field value, either direction | +| `Project\Support\Casting` | `TypeParser` | Parse a type string into `{nullable, baseType, params}` | +| `Project\Support\Casting` | `CastInterface` / `BaseCast` | Cast contract + identity base | +| `Project\Support\Casting` | `CastException` | Invalid handler / JSON | +| `Project\Support\Casting\Casts` | 11 built-ins | see table below | +| `Project\Support\Hydration` | `DataConverter` | Map a whole DB row ⇄ object | +| `Project\Support\Entity` | `Entity` (abstract) | Enterprise base for domain entities | + +--- + +## DataCaster + +```php +new DataCaster( + ?array $castHandlers = null, // [type => CastInterface::class] merged over defaults + ?array $types = null, // [field => typeString] + ?object $helper = null, // forwarded as 3rd arg to every cast + bool $strict = true, // true: null into a non-nullable type throws +); + +$caster->castAs(mixed $value, string $field, 'get'|'set' $method = 'get'): mixed; +$caster->setTypes(array $types): static; // resets parse cache +``` + +- `'get'` = DataSource → PHP; `'set'` = PHP → DataSource. +- Prefix a type with `?` to pass `null` through. Prefer `?type` over `strict:false`. +- A field absent from `$types` is returned unchanged. + +### Type grammar (`TypeParser`) + +```text +"?"? baseType ( "[" param ( "," param )* "]" )? +``` + +`?json[array]` → nullable JSON decoded as assoc array. `datetime[ms]`, +`datetime[Y-m-d]`, `int-bool`, `csv`, etc. + +### Built-in casts (`Project\Support\Casting\Casts`) + +| Type key(s) | get (DB→PHP) | set (PHP→DB) | +|---|---|---| +| `int` / `integer` | `int` | identity | +| `float` / `double` | `float` | identity | +| `string` | `string` | identity | +| `bool` / `boolean` | `bool` (`filter_var`; `t`/`f` for PG) | identity | +| `int-bool` | `bool` | `int` (0/1) — requires bool input | +| `csv` | `string`→`array` | `array`→`string` | +| `array` | `string`→`array` (native unserialize) | `array`→`string` (`serialize`) | +| `json` | `string`→`stdClass` (or `array` with `[array]`) | value→JSON `string` | +| `object` | `(object)` cast | identity | +| `datetime` | `string`→`DateTimeImmutable` | `DateTimeInterface`→`string` | +| `timestamp` | `int`/`string`→`DateTimeImmutable` | `DateTimeInterface`→`int` | + +> `bool` casts on READ only — use `int-bool` when the column stores `0/1` and the +> WRITE must emit an int. `json[array]` → assoc array; plain `json` → `stdClass`. + +### Custom cast + +Implement `CastInterface` (or extend `BaseCast`) and register via `castHandlers` +(or the entity's `$customCasters`). Custom handlers merge over — and can override — +the defaults. + +--- + +## DataConverter (the Repository hydrator) + +```php +new DataConverter( + array $types, // [column => typeString] + array $castHandlers = [], + ?object $helper = null, + Closure|string $reconstructor = 'reconstitute', // static factory name OR closure + Closure|string $extractor = 'toRawArray', // method name OR closure +); + +$conv->fromDataSource(array $row): array; // row → PHP-typed array +$conv->toDataSource(array $php): array; // PHP → DB-typed array +$conv->reconstruct(string $class, array $row): object; +$conv->extract(object $obj): array; +``` + +Reconstruction order: closure → named static factory → throw (no reflection +back-door). Converters pool `DataCaster` by a hash of `types + castHandlers`. + +--- + +## Entity base (`Project\Support\Entity\Entity`) + +Abstract. Implements `JsonSerializable`, `ArrayAccess`, `Stringable`. All features +are infrastructure-free. + +| Area | API | +|---|---| +| Config | `$primaryKey`, `$casts`, `$customCasters`, `$fillable`, `$guarded`, `$hidden`, `$visible`, `$appends`, `$dates`, `$dateFormat` | +| Mass assignment (secure by default) | `fill()` (honours `$fillable`), `forceFill()` (bypass), `isFillable()` | +| Attribute access | `getAttribute`/`setAttribute`, `getRawAttribute`, `hasAttribute`, `only`, `except`, `get{X}Attribute`/`set{X}Attribute` hooks | +| Typed getters | `getString/getInt/getFloat/getBool/getArray/getDate` | +| Serialization | `toArray`, `toRawArray`, `jsonSerialize`, `toJson`, `__toString`, `makeHidden`/`makeVisible` | +| Change tracking | `syncOriginal`, `isDirty`, `isClean`, `wasChanged`, `getDirty`/`getChanges`, `getOriginal` | +| Identity | `getKey`, `getKeyName`, `exists`, `is`, `isNot` | +| Domain events | `recordEvent` (protected), `hasEvents`, `releaseEvents` | +| Immutability | `seal`, `isSealed` (mutation throws `LogicException`) | +| Lifecycle | `make`, `reconstitute` (records no events), `replicate` (drops PK), `__clone` resets tracking | + +### Security + +- **Mass assignment denied by default** (`$guarded = ['*']`): `fill()` only writes + `$fillable` keys, so over-posting can't set `id`/`is_admin`. Defense-in-depth — + the DTO at the controller edge is still the primary validator. +- **`__debugInfo()` redacts `$hidden`** as `********` — secrets never reach + `var_dump()`, logs or stack traces. +- **`seal()`** yields a read-only snapshot; any write throws. + +--- + +## Repository usage (NOT Active Record) + +```php +final class InvoiceRepository +{ + private DataConverter $converter; + + public function __construct( + private readonly DatabasePort $db, + private readonly Identity $identity, + ) { + $this->converter = new DataConverter( + types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], + reconstructor: 'reconstitute', + extractor: 'toRawArray', + ); + } + + public function find(string $id): Invoice + { + $row = $this->db->queryOne( + 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', + ['id' => $id, 't' => $this->identity->tenantId], + ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); + + return $this->converter->reconstruct(Invoice::class, $row); + } + + public function save(Invoice $invoice): void + { + $this->db->upsert('invoices', $this->converter->extract($invoice), ['id']); + $invoice->syncOriginal(); + } +} +``` + +The Service flushes domain events inside the transaction: + +```php +$invoice->pay(); +foreach ($invoice->releaseEvents() as $event) { + $this->collector->collect($event); // buffered in-tx, discarded on rollback +} +$this->repository->save($invoice); +``` + +--- + +## Rules + +``` +✓ Entities carry data + invariants + events; Repositories carry persistence (DatabasePort). +✓ Hydrate with Entity::reconstitute($row) or DataConverter::reconstruct(); persist with toRawArray()/extract() + $db->upsert(). +✓ Casts are static + stateless (OpenSwoole-safe); DataConverter pools casters by types-hash. +✓ Mark nullable columns ?type; mass assignment is deny-by-default. +✗ save()/delete()/find()/getRepo_() on an entity — that is the Repository's job. +✗ app()/kernel()/config() or a DB query inside an entity — entities never do I/O. +✗ reconstruct() writing private props by reflection — give the entity a static reconstitute()/toRawArray(). +✗ strict:false instead of a nullable ?type. ✗ float for money — custom MoneyCast over integer cents. +``` + +Relationship to the gold standard: a `final` entity with a private constructor and +fully-encapsulated typed state is still preferred for small, well-defined +aggregates. Extend `Entity` when a flexible, meta-driven attribute bag earns its +keep. See also `03_DOMAIN.md`, `05_REPOSITORY.md`, `22_DATA_ACCESS_ORM_BLUEPRINT.md`. diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000..ffdfb40 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,62 @@ +# HKM Kernel — Guides + +Layer-by-layer guides to the **Gated Demand Architecture (GDA)** kernel and its first-party +plugins. Start with the overview, then dive into the layer you're working in. + +> New here? Read the [project README](../../README.md) first for the big picture, install +> steps, and a full end-to-end feature walkthrough. + +## Architecture & lifecycle + +| Guide | What it covers | +|---|---| +| [00 · Overview](00_SENTINEL_OVERVIEW.md) | Full architecture + the request lifecycle | +| [01 · Kernel](01_KERNEL.md) | Boot pipeline, materialization, the fluent builder | +| [02 · Module](02_MODULE.md) | Module contract, `module.json`, on-demand loading | +| [11 · Project](11_PROJECT.md) | Project layer — wiring, domain resolution, bootstrap | + +## The layers + +| Guide | Layer | +|---|---| +| [03 · Domain](03_DOMAIN.md) | Entities, value objects, domain events (zero external imports) | +| [04 · Service](04_SERVICE.md) | Transaction + event orchestration (the mandatory shape) | +| [05 · Repository](05_REPOSITORY.md) | `DatabasePort` only; translate every `\PDOException` | +| [06 · Gateway](06_GATEWAY.md) | Vendor SDKs only; translate vendor exceptions | +| [07 · Controller](07_CONTROLLER.md) | ≤3-line actions, DTO validation, base controllers | +| [08 · Events](08_EVENTS.md) | Domain vs. integration events, the EventBus | + +## Cross-cutting + +| Guide | Topic | +|---|---| +| [09 · Security](09_SECURITY.md) | SecurityGateway, Identity, layers | +| [21 · CSRF](21_CSRF.md) | `CsrfTokenLayer` — HMAC-token CSRF | +| [10 · Testing](10_TESTING.md) | Port fakes, service tests | +| [12 · Worker](12_WORKER.md) | Worker pipeline, jobs, retry strategies | +| [13 · Anti-patterns](13_ANTIPATTERNS.md) | Wrong/correct code pairs | +| [15 · Error handling](15_ERROR_HANDLING.md) | ErrorGuard + ErrorPipeline, notifiers | + +## CLI & data + +| Guide | Topic | +|---|---| +| [14 · CLI](14_CLI.md) | CLI pipeline, `AbstractCommand` | +| [17 · php-io-cli](17_PHP_IO_CLI.md) | The interactive terminal component library | +| [18 · Migrations](18_MIGRATIONS.md) | LetMigrate engine, migrations, seeders | +| [19 · Database](19_DATABASE.md) | Multi-driver `DatabasePort`, connections | +| [22 · Data access blueprint](22_DATA_ACCESS_ORM_BLUEPRINT.md) | Repository/hydrator/entity mapping, portable SQL | +| [27 · Entity support](27_ENTITY_SUPPORT.md) | Casting engine, hydrator, the Entity base | + +## Plugins + +| Guide | Plugin | +|---|---| +| [16 · Plugins](16_PLUGINS.md) | The `plugins/` convention + local-module checklist | +| [20 · First-party plugins](20_FIRST_PARTY_PLUGINS.md) | The bundled plugin catalogue | +| [23 · Tenancy](23_TENANCY.md) | Multi-tenant routing, membership, invitations | +| [24 · User](24_USER.md) | Central identity store, outbox, audit log | +| [25 · Auth](25_AUTH.md) | JWT / PAT / session issuance + verification | +| [26 · OAuth2](26_OAUTH2.md) | OAuth 2.1 + OIDC authorization server | + +Each first-party plugin also ships its own README under `plugins//`. diff --git a/docs/guides/SAFE_DEPLOYMENTS_GUIDE.md b/docs/guides/SAFE_DEPLOYMENTS_GUIDE.md new file mode 100644 index 0000000..632cac4 --- /dev/null +++ b/docs/guides/SAFE_DEPLOYMENTS_GUIDE.md @@ -0,0 +1,389 @@ +# Safe Deployments Guide + +Complete guide for safely deploying database migrations with enterprise safeguards. + +--- + +## Pre-Deployment Checklist + +### 1. Configuration +- [ ] Environment-specific config exists for your target environment +- [ ] Database credentials loaded from secrets manager, NOT config files +- [ ] All required env vars defined in `.env` file +- [ ] Configuration validated: `php cli migrate:status --config=...` + +### 2. Backup +- [ ] Recent backup created manually or automatic backup enabled +- [ ] Backup tested and verified restorable +- [ ] Backup stored in secure, offsite location +- [ ] Backup retention policy defined + +### 3. Approval +- [ ] Migrations reviewed by team +- [ ] Approval request created in system +- [ ] Authorized approver notified +- [ ] Rollback plan documented + +### 4. Testing +- [ ] Migrations tested on identical schema (staging) +- [ ] Dry-run shows expected SQL: `--pretend` flag +- [ ] Performance impact estimated +- [ ] Potential lock contention identified + +--- + +## Environment-Specific Usage + +### Local Development +```bash +# Auto-detects APP_ENV=local +php app/cli/run.php migrate:status + +# Or explicit config +php app/cli/run.php migrate:status --config=config/environments/local.php + +# Features: +# ✅ No safeguards (fast iteration) +# ✅ In-memory SQLite for isolation +# ✅ Transactional: false (speed) +``` + +### Testing +```bash +# Set APP_ENV=testing +APP_ENV=testing php app/cli/run.php migrate:status + +# Features: +# ✅ In-memory SQLite (isolation) +# ✅ Always transactional (safety) +# ✅ Fastest feedback loop +``` + +### Staging +```bash +# Set APP_ENV=staging +APP_ENV=staging php app/cli/run.php migrate:status + +# Features: +# ✅ Real database +# ✅ Always previews first (--pretend=true) +# ✅ Deployment lock enabled +# ✅ Backup before run +``` + +### Production +```bash +# Set APP_ENV=production +APP_ENV=production php app/cli/run.php migrate:status + +# Features: +# 🔴 ALWAYS previews first (--pretend=true) +# 🔴 REQUIRES deployment lock +# 🔴 REQUIRES backup +# 🔴 REQUIRES approval +# 🔴 Limited concurrent approvals +``` + +--- + +## Safe Deployment Workflow + +### Step 1: Create Approval Request +```bash +# In production, create approval before running +php app/cli/run.php migrate:create-approval \ + --env=production \ + --reason="Add users.email_verified_at column" + +# Output: Approval ID: approval_a1b2c3d4e5f6g7h8 +``` + +### Step 2: Get SQL Preview +```bash +# Always preview first in production +php app/cli/run.php migrate:run \ + --config=config/environments/production.php \ + --pretend + +# Review the SQL before approval +``` + +### Step 3: Request Approval +```bash +# Send approval request to authorized reviewer +php app/cli/run.php migrate:request-approval \ + --approval-id=approval_a1b2c3d4e5f6g7h8 \ + --reviewer=devops-team@company.com \ + --reason="Production schema update" + +# Reviewer receives notification and approves/rejects +``` + +### Step 4: Acquire Lock +```bash +# System automatically acquires deployment lock before running +# Other deployments are blocked (configurable timeout) +php app/cli/run.php migrate:run \ + --config=config/environments/production.php \ + --approval=approval_a1b2c3d4e5f6g7h8 + +# Waits for lock, creates backup, runs migrations +# Lock auto-releases after 5 minutes or manual release +``` + +### Step 5: Verify Result +```bash +# Check final status +php app/cli/run.php migrate:status \ + --config=config/environments/production.php + +# View audit log +php app/cli/commands:audit-log \ + --command=migrate:run \ + --recent=10 +``` + +--- + +## Emergency Procedures + +### If Migration Locks Up +```bash +# Check active locks +php app/cli/run.php deployment:locks + +# Force release lock (CAREFUL: only if migration truly failed) +php app/cli/run.php deployment:lock-release \ + --force \ + --reason="Migration failed, manual release" + +# Then investigate the migration failure +``` + +### If Migration Partially Failed +```bash +# Check status - shows which migrations succeeded +php app/cli/run.php migrate:status + +# Option 1: Restore from backup +# Use your database backup tool to restore + +# Option 2: Fix and retry +# Edit migration to fix the issue, then re-run +php app/cli/run.php migrate:run --config=... +``` + +### If You Need to Rollback +```bash +# Check rollback plan +php app/cli/run.php migrate:rollback \ + --config=config/environments/production.php \ + --preview # See what will rollback + +# Execute rollback (requires approval) +php app/cli/run.php migrate:rollback \ + --config=config/environments/production.php \ + --approval=approval_xyz +``` + +--- + +## Command Reference + +### Status Commands +```bash +# Show all migrations and their status +migrate:status + +# Show only pending migrations +migrate:status --pending + +# Show only applied migrations +migrate:status --applied + +# Export as JSON for tooling +migrate:status --json +``` + +### Deployment Commands +```bash +# Create approval request (production only) +migrate:create-approval + +# Request approval from reviewer +migrate:request-approval + +# Run approved migrations with lock +migrate:run + +# Dry-run: preview SQL without applying +migrate:run --pretend + +# Force run (skips safety guards - DANGEROUS) +migrate:run --force +``` + +### Backup Commands +```bash +# Create manual backup +backup:create + +# List all backups +backup:list + +# Restore from backup +backup:restore --id=backup_xyz + +# Cleanup old backups +backup:cleanup +``` + +### Audit Commands +```bash +# View command execution history +commands:audit-log + +# View migrations run history +migrations:audit-log + +# View approvals and rejections +approvals:audit-log +``` + +### Lock Commands +```bash +# View active deployment locks +deployment:locks + +# Release a lock (use carefully!) +deployment:lock-release --force +``` + +--- + +## Secrets Management + +### Environment Variables +```bash +# For local/staging development +export DB_PASSWORD="your-database-password" +php app/cli/run.php migrate:status +``` + +### AWS Secrets Manager +```bash +# Configure AWS +export AWS_REGION=us-east-1 +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... + +# Configure SecretsManager +export SECRETS_PROVIDER=aws + +# Secrets are loaded at runtime +php app/cli/run.php migrate:status +``` + +### HashiCorp Vault +```bash +# Configure Vault +export VAULT_ADDR=https://vault.company.com +export VAULT_TOKEN=... + +# Configure SecretsManager +export SECRETS_PROVIDER=vault + +# Secrets are loaded at runtime +php app/cli/run.php migrate:status +``` + +--- + +## Troubleshooting + +### "Configuration file not found" +```bash +# Solution: Set APP_ENV +export APP_ENV=production +php app/cli/run.php migrate:status + +# Or use explicit config +php app/cli/run.php migrate:status --config=config/environments/production.php +``` + +### "Deployment locked by another process" +```bash +# Solution: Wait for lock to expire (5 min default) or force release +php app/cli/run.php deployment:locks # See who holds lock + +# Wait, then retry +sleep 300 +php app/cli/run.php migrate:run --config=... + +# Or force release if you're sure the other process failed +php app/cli/run.php deployment:lock-release --force +``` + +### "Backup failed: permission denied" +```bash +# Solution: Check storage/backups directory permissions +ls -la storage/backups/ + +# Fix permissions +chmod 755 storage/backups/ +chmod 644 storage/backups/*.sql +``` + +### "Database connection refused" +```bash +# Solution: Verify database credentials +export DB_HOST=your-db-host +export DB_NAME=your-db-name +export DB_USERNAME=your-user +export DB_PASSWORD=your-pass + +# Test connection +php app/cli/run.php migrate:status + +# If still failing, check database server is running +``` + +--- + +## Best Practices + +✅ **DO:** +- Always preview with `--pretend` before production run +- Request approval before production deployments +- Create backups before production migrations +- Use deployment locks to prevent concurrent runs +- Review audit logs after each deployment +- Test migrations on staging first +- Keep audit logs for compliance +- Use secrets manager, NOT config files + +❌ **DON'T:** +- Run migrations directly in production without approval +- Use `--force` to skip safety checks +- Store passwords in config files +- Run multiple migrations concurrently +- Skip backups in production +- Delete migrations after they've been applied +- Commit secrets to version control +- Ignore deployment lock timeouts + +--- + +## Support & Runbooks + +See the following for more information: + +- **Enterprise Analysis:** `ENTERPRISE_ANALYSIS.md` +- **Implementation Guide:** `ENTERPRISE_IMPLEMENTATION_GUIDE.md` +- **Commands Reference:** `COMMANDS_ARCHITECTURE_GUIDE.md` + +For emergencies, contact the DevOps team with the following info: +1. What migration failed and on which environment +2. Full error message from `migrate:status` +3. Time the failure occurred +4. Recent backup ID (from `backup:list`) diff --git a/src/Kernel/Error/DebugPageRenderer.php b/src/Kernel/Error/DebugPageRenderer.php index 4dbf972..c6d4d0b 100644 --- a/src/Kernel/Error/DebugPageRenderer.php +++ b/src/Kernel/Error/DebugPageRenderer.php @@ -48,7 +48,7 @@ public static function renderCli(\Throwable $e, ?string $basePath = null): strin $white = "\033[1;37m"; $reset = "\033[0m"; $dim = "\033[2m"; $bar = $gray . str_repeat('═', 70) . $reset; - $out = "\n{$bar}\n{$magenta}⚡ Sentinel Exception{$reset}\n{$bar}\n\n"; + $out = "\n{$bar}\n{$magenta}⚡ HKM Exception{$reset}\n{$bar}\n\n"; $out .= "{$red}✗ " . $e::class . "{$reset}\n\n"; $out .= "{$cyan}Message:{$reset}\n{$yellow}" . $e->getMessage() . "{$reset}\n\n"; $out .= "{$cyan}Location:{$reset}\n{$white}" . self::sanitizePath($e->getFile(), $basePath) . "{$reset}{$dim} at line {$reset}{$green}" . $e->getLine() . "{$reset}\n\n"; @@ -161,7 +161,7 @@ private static function shell( return ' -Sentinel Exception • ' . $class . ' +HKM Exception • ' . $class . '
-
Sentinel
debug
+
HKM Kernel
debug
' . $class . '
' . $message . '
diff --git a/src/System/GlobalKernelProjectScaffolder.php b/src/System/GlobalKernelProjectScaffolder.php index b731396..2f03dfb 100644 --- a/src/System/GlobalKernelProjectScaffolder.php +++ b/src/System/GlobalKernelProjectScaffolder.php @@ -365,7 +365,7 @@ function psp_require_kernel_autoload(): void } } - $msg = "[Sentinel] Could not load the global kernel autoload.\n" + $msg = "[HKM] 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"; diff --git a/tools/src/commands/upgrade.zig b/tools/src/commands/upgrade.zig index 5c2199a..2069a0a 100644 --- a/tools/src/commands/upgrade.zig +++ b/tools/src/commands/upgrade.zig @@ -5,7 +5,7 @@ //! //! "Latest" is the highest v* tag on the kernel repo, discovered with //! `git ls-remote` (no API token, works for the public repo). The header is the -//! Sentinel banner + current version. +//! HKM banner + current version. const std = @import("std"); const banner = @import("../lib/banner.zig"); diff --git a/tools/src/lib/banner.zig b/tools/src/lib/banner.zig index 8466e00..c751925 100644 --- a/tools/src/lib/banner.zig +++ b/tools/src/lib/banner.zig @@ -1,4 +1,4 @@ -//! Sentinel ASCII banner + version header, shared by `hkm --version` and the +//! HKM ASCII banner + version header, shared by `hkm --version` and the //! update commands. Kept dependency-free (just std.debug.print + ANSI). const std = @import("std"); @@ -9,14 +9,14 @@ const bold = "\x1b[1m"; const dim = "\x1b[2m"; const reset = "\x1b[0m"; -/// The framework's kernel is "Sentinel" — see docs/ai-context/00_SENTINEL_OVERVIEW. +/// The product/brand is "HKM" (HKM Kernel). const art = - \\ ██████╗ ███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗ - \\ ██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║ - \\ ███████╗ █████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║ - \\ ╚════██║ ██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║ - \\ ██████╔╝ ███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗ - \\ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ + \\ ██╗ ██╗ ██╗ ██╗ ███╗ ███╗ + \\ ██║ ██║ ██║ ██╔╝ ████╗ ████║ + \\ ███████║ █████╔╝ ██╔████╔██║ + \\ ██╔══██║ ██╔═██╗ ██║╚██╔╝██║ + \\ ██║ ██║ ██║ ██╗ ██║ ╚═╝ ██║ + \\ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ; pub fn version() []const u8 { @@ -31,11 +31,11 @@ pub fn repo() []const u8 { /// version/update commands. pub fn print() void { std.debug.print("\n{s}{s}{s}\n", .{ cyan, art, reset }); - std.debug.print(" {s}Sentinel{s} {s}· PhpServicePlatform kernel{s}\n", .{ bold, reset, dim, reset }); + std.debug.print(" {s}HKM Kernel{s} {s}· Gated Demand Architecture{s}\n", .{ bold, reset, dim, reset }); std.debug.print(" {s}version {s}{s}{s}\n\n", .{ dim, reset, build_info.version, reset }); } /// One-line version, for `hkm --version` piped/scripted use. pub fn printShort() void { - std.debug.print("hkm (Sentinel) {s}\n", .{build_info.version}); + std.debug.print("hkm (HKM Kernel) {s}\n", .{build_info.version}); } diff --git a/tools/src/main.zig b/tools/src/main.zig index 82373d1..5bcfcbf 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -32,7 +32,7 @@ fn printHelp() void { prompt.item("hkm update ", "refresh a project's kernel registry entry"); prompt.item("hkm upgrade [--check]", "check for / apply a kernel update"); prompt.item("hkm doctor", "diagnose the local environment"); - prompt.item("hkm version", "show the Sentinel banner + version (also --version, -v)"); + prompt.item("hkm version", "show the HKM banner + version (also --version, -v)"); prompt.item("hkm help", "show this help"); prompt.item("hkm --dev", "use the development kernel (this monorepo) instead of the installed stable copy"); prompt.blank();