diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca586b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,119 @@ +# ───────────────────────────────────────────────────────────────────────────── +# AlfacodeTeam PhpServicePlatform — example environment +# +# Copy to `.env` and fill in real values: cp .env.example .env +# Read config in first-party code via env(), never getenv(). +# This file covers the CORE + commonly-used plugin settings. Each plugin +# documents its full env surface in plugins//README.md. +# Run `hkm doctor` to verify your PHP runtime and required extensions. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Application ────────────────────────────────────────────────────────────── +APP_ENV=production # local | production +APP_DEBUG=false # NEVER true in production (leaks source/traces) +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_LOCALES=en # comma-separated negotiation set (e.g. en,fr,es) + +# 32+ byte secret used for app crypto, CSRF token signing, cookie encryption. +# Generate one: php -r "echo base64_encode(random_bytes(32)).PHP_EOL;" +APP_KEY= +# APP_KEY_PREVIOUS= # set during key rotation to decrypt old data + +# ── Database (DatabasePort) ────────────────────────────────────────────────── +DB_DRIVER=mysql # mysql | pgsql | sqlite | sqlsrv +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=app +DB_USERNAME=root +DB_PASSWORD= +DB_CHARSET=utf8mb4 +# Connection pool (OpenSwoole only; ignored under PHP-FPM): +DB_POOL_ENABLED=false +DB_POOL_MIN=1 +DB_POOL_MAX=10 + +# ── Cache & Queue — Redis (CachePort / QueuePort) ──────────────────────────── +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_PREFIX=hkm: +REDIS_OVERRIDE=false # true = use Redis even if another cache is bound + +# ── Sessions (Session plugin) ──────────────────────────────────────────────── +SESSION_DRIVER=cookie # cookie | file | array +SESSION_LIFETIME=43200 # seconds (12h) — MUST match CSRF lifetime below +SESSION_IDLE_TIMEOUT=7200 +SESSION_COOKIE=hkm_session +SESSION_SECURE=true # send only over HTTPS +SESSION_SAMESITE=lax # lax | strict | none +SESSION_SIGNING_KEY= # defaults to APP_KEY when empty + +# ── Authentication / JWT (Auth plugin) ─────────────────────────────────────── +JWT_ALGO=HS256 # HS256 | RS256 | ES256 | PS256 +JWT_SECRET= # required for HS*; use JWT_PRIVATE_KEY(_FILE) for RS/ES/PS +JWT_ISSUER=hkm +JWT_AUDIENCE=hkm +# JWT_PRIVATE_KEY_FILE=/path/to/private.pem +# JWT_PUBLIC_KEY_FILE=/path/to/public.pem +AUTH_SESSION_TTL=1209600 # remember-me seconds (14d) +AUTH_REFRESH_TTL=2592000 # refresh-token seconds (30d) +HASH_BCRYPT_COST=12 + +# ── CSRF (kernel CsrfTokenLayer) ───────────────────────────────────────────── +# Lifetime MUST equal SESSION_LIFETIME. The bound cookie must be in +# COOKIE_ENCRYPT_EXEMPT (the layer reads the RAW cookie at SecurityStage). +# CSRF secret defaults to APP_KEY. + +# ── Security filters (SecurityFilters plugin) ──────────────────────────────── +CORS_ALLOWED_ORIGINS=* +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-CSRF-Token +CORS_ALLOW_CREDENTIALS=false +RATE_LIMIT_MAX=60 +RATE_LIMIT_WINDOW=60 +# CONTENT_SECURITY_POLICY= +HSTS_MAX_AGE=31536000 + +# ── Mail (Mail plugin) ─────────────────────────────────────────────────────── +MAIL_TRANSPORT=array # smtp | sendmail | mail | array | log +MAIL_HOST=127.0.0.1 +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls # tls | ssl | none +MAIL_FROM_ADDRESS=no-reply@example.com +MAIL_FROM_NAME="AlfaCode Platform" + +# ── Storage (Storage plugin — StoragePort) ─────────────────────────────────── +STORAGE_DRIVER=local # local | s3 +STORAGE_ROOT=userdata/storage +# S3 / S3-compatible: +# STORAGE_S3_KEY= +# STORAGE_S3_SECRET= +# STORAGE_S3_REGION=us-east-1 +# STORAGE_S3_BUCKET= +# STORAGE_S3_ENDPOINT= +# STORAGE_S3_PATH_STYLE=false + +# ── Outbound HTTP (HttpClient plugin — HttpClientPort) ─────────────────────── +HTTP_CLIENT_TIMEOUT=30 +HTTP_CLIENT_CONNECT_TIMEOUT=10 +HTTP_CLIENT_RETRY=2 +HTTP_CLIENT_MAX_RESPONSE_BYTES=33554432 # 32 MiB OOM guard + +# ── Multi-tenancy (Tenancy plugin — control plane) ─────────────────────────── +# Only needed when deploying the multi-tenant control plane. +# TENANCY_MODE=subdomain +# TENANCY_BASE_DOMAINS=example.com +# TENANCY_CONTROL_PLANE=admin.example.com + +# ── Views / Frontend (View + ViteManifest plugins) ─────────────────────────── +# VIEW_PATHS= # extra template roots, prepended to the cascade +# VITE_MANIFEST=manifest.json +# VITE_SURFACE=app + +# ── SEO / IndexNow (SiteSEO plugin) ────────────────────────────────────────── +# INDEXNOW_KEY= # required to submit URLs to search engines +# INDEXNOW_LIVE=false # true = real submit, else enqueued dry run diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1946b65..2a9fe85 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,5 @@ -# Every change requires review from a repo owner. -* @hakeemRash @Alshatri +# Code owners — required reviewers for every change into `main`. +# A PR needs approval from at least one of these owners (branch protection +# enforces 1 code-owner review). Authors cannot approve their own PR, so keep +# more than one owner listed. +* @hakeemRash @Alshatri @craftdevscommunity diff --git a/.gitmodules b/.gitmodules index b9718e4..b4cc66e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,7 @@ [submodule "modules/let-migrate"] path = modules/let-migrate url = https://github.com/AlfaCode-Team/Let-Migrate.git +[submodule "modules/http"] + path = modules/http + url = git@github.com:AlfaCode-Team/http.git + branch = master diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eee235..a8cbd29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.20] - 2026-07-22 + +### Added +- **`hkm module` command** for managing first-party kernel packages (the + `modules/` submodules: bind-it, php-io-cli, let-migrate, http) — inspect, + and update the pinned package set from one CLI entry point. +- **`alfacode-team/http` as a first-party package dependency** (`^1.0`; + dev-master inside the monorepo, `v1.0.0` for stable releases). The http + submodule is pinned at its latest master. + +### Changed +- **Pageflow stages refactored and consolidated** — the SPA-bridge pipeline + stages are simplified into fewer, clearer units. +- **Open-source readiness** — license, composer package metadata, and a + `.env.example` added; issue/PR templates, CODEOWNERS, and required-reviewer + configuration for `main`. + +### Fixed +- **`MigrateListCommand`** parent wiring repaired and the **`OutboxWriter`** + port contract corrected. +- **CI analysis gates** — PHPStan level-5 config + baseline made a blocking + gate (optional Swoole/OpenSwoole coroutine calls ignored); CodeQL, Semgrep, + and `composer audit` wired in. + ## [1.0.19] - 2026-07-21 ### Added diff --git a/LICENSE b/LICENSE index 261eeb9..dd42855 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2026 Hakeem Shamavu (AlfaCode Team) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/composer.json b/composer.json index 974d43f..a588ae0 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,34 @@ "name": "alfacode-team/php-service-platform", "description": "A modular PHP backend boilerplate built with OpenSwoole and a multi-repository architecture using Git submodules. Designed for scalable, high-performance microservices and service-oriented applications.", "type": "library", + "license": "MIT", + "keywords": [ + "framework", + "php", + "microservices", + "gated-demand-architecture", + "openswoole", + "modular", + "service-platform", + "ddd" + ], + "homepage": "https://github.com/AlfaCode-Team/php-service-platform", + "support": { + "issues": "https://github.com/AlfaCode-Team/php-service-platform/issues", + "source": "https://github.com/AlfaCode-Team/php-service-platform", + "security": "https://github.com/AlfaCode-Team/php-service-platform/security/policy" + }, "require": { + "alfacode-team/http": "^1.0", + "php": ">=8.4", + "ext-json": "*", + "ext-mbstring": "*", + "ext-ctype": "*", + "ext-tokenizer": "*", + "ext-filter": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-fileinfo": "*", "psr/http-message": "2.0.x-dev", "psr/container": "^2.0", "psr/log": "^3.0", @@ -30,6 +57,10 @@ "composer/composer": "^2.7" }, "repositories": [ + { + "type": "path", + "url": "modules/http" + }, { "type": "path", "url": "modules/common-type-alias" @@ -47,7 +78,6 @@ "url": "modules/let-migrate" } ], - "license": "MIT", "autoload": { "psr-4": { "AlfacodeTeam\\PhpServicePlatform\\": "src/", diff --git a/composer.lock b/composer.lock index 9b5944b..45a0007 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,87 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8c9ca5774f3ba8825a7acb33b07ca2d7", + "content-hash": "58e21feb6f2ea0a8a95805afa209d27a", "packages": [ + { + "name": "alfacode-team/http", + "version": "dev-master", + "dist": { + "type": "path", + "url": "modules/http", + "reference": "c7225893e59262f996294d99fc9ffcac6e5d4c26" + }, + "require": { + "php": "^8.4", + "psr/http-message": "^1.1 || ^2.0", + "symfony/http-foundation": "^8.1", + "symfony/mime": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.0 || ^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Http\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Http\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "phpunit" + ], + "analyse": [ + "phpstan analyse" + ], + "check": [ + "@analyse", + "@test" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "AlfaCode Team", + "homepage": "https://github.com/AlfaCode-Team" + }, + { + "name": "Hakeem Shamavu", + "email": "shamavurasheed@gmail.com" + } + ], + "description": "PhpServicePlatform kernel HTTP layer — immutable Request/Response value objects, PSR-7 URI, content negotiation and Swoole-safe uploads, built on Symfony HttpFoundation.", + "homepage": "https://github.com/AlfaCode-Team/http", + "keywords": [ + "content-negotiation", + "http", + "http-foundation", + "phpserviceplatform", + "psr-7", + "request", + "response", + "uri" + ], + "support": { + "issues": "https://github.com/AlfaCode-Team/http/issues", + "source": "https://github.com/AlfaCode-Team/http" + }, + "transport-options": { + "relative": true + } + }, { "name": "alfacode-team/let-migrate", "version": "dev-dev", @@ -282,16 +361,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.387.3", + "version": "3.388.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "20818be961ace3ef01c1aed3c247e71b26020149" + "reference": "ee6462591ad92c79635fb453732a34f245787e0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/20818be961ace3ef01c1aed3c247e71b26020149", - "reference": "20818be961ace3ef01c1aed3c247e71b26020149", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ee6462591ad92c79635fb453732a34f245787e0f", + "reference": "ee6462591ad92c79635fb453732a34f245787e0f", "shasum": "" }, "require": { @@ -373,9 +452,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.387.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.388.11" }, - "time": "2026-07-06T18:13:24+00:00" + "time": "2026-07-21T18:08:14+00:00" }, { "name": "composer/pcre", @@ -1039,16 +1118,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1058,7 +1137,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1079,7 +1158,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -1091,7 +1170,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "mtdowling/jmespath.php", @@ -1628,12 +1707,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817" + "reference": "2de2366b98a3669fe6155adad65fea3e477290fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/c14decc1b0755b1e8ab6babeef56e1880348e817", - "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817", + "url": "https://api.github.com/repos/symfony/cache/zipball/2de2366b98a3669fe6155adad65fea3e477290fe", + "reference": "2de2366b98a3669fe6155adad65fea3e477290fe", "shasum": "" }, "require": { @@ -1719,7 +1798,7 @@ "type": "tidelift" } ], - "time": "2026-06-17T15:04:37+00:00" + "time": "2026-07-09T09:37:07+00:00" }, { "name": "symfony/cache-contracts", @@ -3279,16 +3358,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.12", + "version": "1.5.13", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/c008272789979f709f7fcb32c2ecf1d2db5e84e5", + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5", "shasum": "" }, "require": { @@ -3335,7 +3414,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "source": "https://github.com/composer/ca-bundle/tree/1.5.13" }, "funding": [ { @@ -3347,7 +3426,7 @@ "type": "github" } ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2026-07-18T12:35:13+00:00" }, { "name": "composer/class-map-generator", @@ -4057,16 +4136,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.12", + "version": "v3.95.15", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "b1b9055997a98dce3c2338e884626e718a25a923" + "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923", - "reference": "b1b9055997a98dce3c2338e884626e718a25a923", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3e47e5d50046f87e3244acde2fe655d1a3b72555", + "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555", "shasum": "" }, "require": { @@ -4106,7 +4185,7 @@ "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31", "symfony/polyfill-php85": "^1.38", "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" @@ -4150,7 +4229,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.15" }, "funding": [ { @@ -4158,7 +4237,7 @@ "type": "github" } ], - "time": "2026-07-07T13:29:36+00:00" + "time": "2026-07-15T09:51:47+00:00" }, { "name": "justinrainbow/json-schema", @@ -4548,8 +4627,8 @@ "version": "2.2.x-dev", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1acda961f521a58b9b8c4cbde66d21c20d36f8ce", - "reference": "1acda961f521a58b9b8c4cbde66d21c20d36f8ce", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/905d6cdf581b0ad307924aa8e4ed95772864fe93", + "reference": "905d6cdf581b0ad307924aa8e4ed95772864fe93", "shasum": "" }, "require": { @@ -4606,7 +4685,7 @@ "type": "github" } ], - "time": "2026-07-07T16:54:18+00:00" + "time": "2026-07-22T08:07:53+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4997,12 +5076,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6b667a5ca0f7e0f93c79846f776bb28b66282ccb" + "reference": "6baab93983ce97e1f84834037d12913228d40647" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6b667a5ca0f7e0f93c79846f776bb28b66282ccb", - "reference": "6b667a5ca0f7e0f93c79846f776bb28b66282ccb", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6baab93983ce97e1f84834037d12913228d40647", + "reference": "6baab93983ce97e1f84834037d12913228d40647", "shasum": "" }, "require": { @@ -5025,7 +5104,7 @@ "sebastian/comparator": "^8.3.0", "sebastian/diff": "^9.0", "sebastian/environment": "^9.3.2", - "sebastian/exporter": "^8.1.0", + "sebastian/exporter": "^8.1.1", "sebastian/file-filter": "^1.0", "sebastian/git-state": "^1.0", "sebastian/global-state": "^9.0.1", @@ -5081,7 +5160,7 @@ "type": "other" } ], - "time": "2026-07-07T14:07:20+00:00" + "time": "2026-07-19T22:03:11+00:00" }, { "name": "psr/event-dispatcher", @@ -6047,16 +6126,16 @@ }, { "name": "sebastian/exporter", - "version": "8.1.0", + "version": "8.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", "shasum": "" }, "require": { @@ -6065,7 +6144,7 @@ "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { @@ -6113,7 +6192,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" }, "funding": [ { @@ -6133,7 +6212,7 @@ "type": "tidelift" } ], - "time": "2026-05-21T11:50:56+00:00" + "time": "2026-07-13T11:35:11+00:00" }, { "name": "sebastian/file-filter", @@ -6349,24 +6428,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { @@ -6395,7 +6474,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { @@ -6415,7 +6494,7 @@ "type": "tidelift" } ], - "time": "2026-05-19T16:23:37+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { "name": "sebastian/object-enumerator", @@ -7815,7 +7894,17 @@ }, "prefer-stable": true, "prefer-lowest": false, - "platform": {}, + "platform": { + "php": ">=8.4", + "ext-json": "*", + "ext-mbstring": "*", + "ext-ctype": "*", + "ext-tokenizer": "*", + "ext-filter": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-fileinfo": "*" + }, "platform-dev": {}, "plugin-api-version": "2.9.0" } diff --git a/modules/http b/modules/http new file mode 160000 index 0000000..5bc998a --- /dev/null +++ b/modules/http @@ -0,0 +1 @@ +Subproject commit 5bc998ac4a9575a560a027073b8e2792bfff27ae diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2594378..8675805 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2340,12 +2340,6 @@ parameters: count: 1 path: src/Commands/Migrate/MigrateStatusCommand.php - - - message: '#^Offset 1 on array\{non\-falsy\-string, non\-empty\-string\} on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.offset - count: 1 - path: src/Kernel/Http/UserAgent.php - - message: '#^PHPDoc tag @param for parameter \$default with type mixed is not subtype of native type array\.$#' identifier: parameter.phpDocType diff --git a/plugins/Pageflow/API/Contracts/PageflowSharerContract.php b/plugins/Pageflow/API/Contracts/PageflowSharerContract.php index b222454..d73a875 100644 --- a/plugins/Pageflow/API/Contracts/PageflowSharerContract.php +++ b/plugins/Pageflow/API/Contracts/PageflowSharerContract.php @@ -11,7 +11,7 @@ * Contributes shared props to every Pageflow render — the GDA replacement for * the legacy `do_action('pageflow_share')` hook. * - * The project binds ONE implementation into the CoreContainer; PageflowShareStage + * The project binds ONE implementation into the CoreContainer; PageflowStage * invokes it (after.load) on every request that has the Pageflow responder in * scope, letting it call $responder->share()/mergeShared() with request-derived * data (auth user, flash, CSRF token, locale, …). diff --git a/plugins/Pageflow/Http/CompositePageflowSharer.php b/plugins/Pageflow/Http/CompositePageflowSharer.php index d75fd99..768066d 100644 --- a/plugins/Pageflow/Http/CompositePageflowSharer.php +++ b/plugins/Pageflow/Http/CompositePageflowSharer.php @@ -11,7 +11,7 @@ * Runs several PageflowSharerContract contributors in order, so shares can come * from independent sources (auth, flash, cart, …) instead of one class. * - * Bind this as the PageflowSharerContract in the project; PageflowShareStage + * Bind this as the PageflowSharerContract in the project; PageflowStage * invokes it once per render and it fans out to each contributor. Later * contributors override earlier ones on a key collision (last wins). */ diff --git a/plugins/Pageflow/Http/PageflowPrecognitionStage.php b/plugins/Pageflow/Http/PageflowPrecognitionStage.php deleted file mode 100644 index 0dacc8e..0000000 --- a/plugins/Pageflow/Http/PageflowPrecognitionStage.php +++ /dev/null @@ -1,65 +0,0 @@ -attribute('precognition') === true) { ...refuse writes... } - * - * It intentionally does NOT auto-wrap a DB transaction: the platform's Services - * manage their own (possibly committing) transactions, and a blanket outer - * rollback could conflict or silently swallow a real commit. The reliable guard - * remains the controller short-circuit; this attribute makes that intent visible - * to every layer. - */ -final class PageflowPrecognitionStage implements HttpStageContract -{ - public function handle(Request $request, callable $next): Response - { - if (strtolower((string) ($request->header('Precognition') ?? '')) !== 'true') { - return $next($request); - } - - $fields = $this->fields($request); - - // We deliberately do NOT open an outer DB transaction here: the platform's - // Services manage their own (nesting an outer tx would make their - // beginTransaction() throw "already in transaction"). Instead we expose - // the intent as attributes so a Repository/Service can assert read-only - // when it sees them. Enforcement stays where it can be correct. - $rollback = (bool) (env('PAGEFLOW_PRECOGNITION_ROLLBACK') ?: false); - - $request = $request - ->withAttribute('precognition', true) - ->withAttribute('precognition_fields', $fields) - ->withAttribute('precognition_rollback', $rollback); - - return $next($request); - } - - /** @return list */ - private function fields(Request $request): array - { - $raw = (string) ($request->header('Precognition-Validate-Only') ?? ''); - if ($raw === '') { - return []; - } - return array_values(array_filter( - array_map('trim', explode(',', $raw)), - static fn(string $s): bool => $s !== '', - )); - } -} diff --git a/plugins/Pageflow/Http/PageflowShareStage.php b/plugins/Pageflow/Http/PageflowShareStage.php deleted file mode 100644 index eab4bff..0000000 --- a/plugins/Pageflow/Http/PageflowShareStage.php +++ /dev/null @@ -1,40 +0,0 @@ -container(); - - if ($container !== null - && $container->has(PageflowResponder::class) - && $container->has(PageflowSharerContract::class) - ) { - /** @var PageflowResponder $responder */ - $responder = $container->make(PageflowResponder::class); - /** @var PageflowSharerContract $sharer */ - $sharer = $container->make(PageflowSharerContract::class); - $sharer->share($request, $responder); - } - - return $next($request); - } -} diff --git a/plugins/Pageflow/Http/PageflowStage.php b/plugins/Pageflow/Http/PageflowStage.php new file mode 100644 index 0000000..2109682 --- /dev/null +++ b/plugins/Pageflow/Http/PageflowStage.php @@ -0,0 +1,210 @@ +isPageflow($request) && strtoupper($request->method()) === 'GET') { + $clientVersion = (string) ($request->header('X-Pageflow-Version') ?? ''); + $currentVersion = (string) (env('PAGEFLOW_VERSION') ?: ''); + if ($currentVersion !== '' && $clientVersion !== $currentVersion) { + return Response::json([], 409, [ + 'X-Pageflow-Location' => $this->fullUrl($request), + ]); + } + } + + // 2. Flag precognition requests so every layer can refuse side effects. + if ($this->isPrecognitive($request)) { + $rollback = (bool) (env('PAGEFLOW_PRECOGNITION_ROLLBACK') ?: false); + $request = $request + ->withAttribute('precognition', true) + ->withAttribute('precognition_fields', $this->precognitionFields($request)) + ->withAttribute('precognition_rollback', $rollback); + } + + // 3. Populate shared props once modules are loaded. + $container = $request->container(); + if ($container !== null + && $container->has(PageflowResponder::class) + && $container->has(PageflowSharerContract::class) + ) { + /** @var PageflowResponder $responder */ + $responder = $container->make(PageflowResponder::class); + /** @var PageflowSharerContract $sharer */ + $sharer = $container->make(PageflowSharerContract::class); + $sharer->share($request, $responder); + } + + // 4. Wrap execution to translate validation errors into Pageflow's shape. + try { + return $next($request); + } catch (ValidationException $e) { + if (!$this->isPageflow($request)) { + throw $e; // let the kernel ErrorStage render it (non-SPA client) + } + + $errors = $this->flatten($e->errors); + + if ($this->isPrecognitive($request)) { + return Response::json(['errors' => $errors], 422, [ + 'X-Pageflow' => 'true', + 'Precognition' => 'true', + 'Vary' => 'X-Pageflow, Precognition', + ]); + } + + $session = $this->session($request); + if ($session !== null) { + $bag = (string) ($request->header('X-Pageflow-Error-Bag') ?? ''); + $session->flash(self::ERROR_FLASH_KEY, $bag !== '' ? [$bag => $errors] : $errors); + + return Response::redirect($this->backUrl($request), 303); + } + + // No session plugin — the client's useForm won't auto-populate, but a + // manual onError handler still receives these. Session is recommended. + return Response::json(['errors' => $errors], 422, ['X-Pageflow' => 'true']); + } + } + + private function isPageflow(Request $request): bool + { + return strtolower((string) ($request->header('X-Pageflow') ?? '')) === 'true'; + } + + private function isPrecognitive(Request $request): bool + { + return strtolower((string) ($request->header('Precognition') ?? '')) === 'true'; + } + + private function session(Request $request): ?SessionPort + { + $container = $request->container(); + if ($container !== null && $container->has(SessionPort::class)) { + /** @var SessionPort $session */ + $session = $container->make(SessionPort::class); + return $session; + } + return null; + } + + private function fullUrl(Request $request): string + { + $path = $request->path(); + $query = http_build_query($request->queryAll()); + return $query === '' ? $path : $path . '?' . $query; + } + + /** @return list */ + private function precognitionFields(Request $request): array + { + $raw = (string) ($request->header('Precognition-Validate-Only') ?? ''); + if ($raw === '') { + return []; + } + return array_values(array_filter( + array_map('trim', explode(',', $raw)), + static fn(string $s): bool => $s !== '', + )); + } + + /** + * Prefer the referer; fall back to the client-declared URL, then the path. + * + * SECURITY: every candidate is reduced to a same-origin path (+query) — the + * scheme/host are stripped — so the 303 Location can never point off-site. + */ + private function backUrl(Request $request): string + { + $referer = $this->pathOnly((string) ($request->header('referer') ?? '')); + if ($referer !== '') { + return $referer; + } + + $headerUrl = $this->pathOnly((string) ($request->header('X-Pageflow-Url') ?? '')); + if ($headerUrl !== '') { + return $headerUrl; + } + + return $request->path(); + } + + /** Reduce any URL to a same-origin "/path?query" (never a scheme/host). */ + private function pathOnly(string $url): string + { + if ($url === '') { + return ''; + } + + $path = parse_url($url, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + return ''; + } + + // Guard against protocol-relative ("//evil.com/x") and backslash tricks. + $path = '/' . ltrim(str_replace('\\', '/', $path), '/'); + + $query = parse_url($url, PHP_URL_QUERY); + return is_string($query) && $query !== '' ? $path . '?' . $query : $path; + } + + /** + * Normalise ValidationException errors (string|string[]) to field => message. + * + * @param array $errors + * @return array + */ + private function flatten(array $errors): array + { + $out = []; + foreach ($errors as $field => $message) { + $out[(string) $field] = is_array($message) + ? (string) ($message[0] ?? '') + : (string) $message; + } + return $out; + } +} diff --git a/plugins/Pageflow/Http/PageflowValidationStage.php b/plugins/Pageflow/Http/PageflowValidationStage.php deleted file mode 100644 index 964e83d..0000000 --- a/plugins/Pageflow/Http/PageflowValidationStage.php +++ /dev/null @@ -1,152 +0,0 @@ -isPageflow($request)) { - throw $e; // let the kernel ErrorStage render it (non-SPA client) - } - - $errors = $this->flatten($e->errors); - - if ($this->isPrecognitive($request)) { - return Response::json(['errors' => $errors], 422, [ - 'X-Pageflow' => 'true', - 'Precognition' => 'true', - 'Vary' => 'X-Pageflow, Precognition', - ]); - } - - $session = $this->session($request); - if ($session !== null) { - $bag = (string) ($request->header('X-Pageflow-Error-Bag') ?? ''); - $session->flash(self::ERROR_FLASH_KEY, $bag !== '' ? [$bag => $errors] : $errors); - - return Response::redirect($this->backUrl($request), 303); - } - - // No session plugin — the client's useForm won't auto-populate, but a - // manual onError handler still receives these. Session is recommended. - return Response::json(['errors' => $errors], 422, ['X-Pageflow' => 'true']); - } - } - - private function isPageflow(Request $request): bool - { - return (string) ($request->header('X-Pageflow') ?? '') !== ''; - } - - private function isPrecognitive(Request $request): bool - { - return strtolower((string) ($request->header('Precognition') ?? '')) === 'true'; - } - - private function session(Request $request): ?SessionPort - { - $container = $request->container(); - if ($container !== null && $container->has(SessionPort::class)) { - /** @var SessionPort $session */ - $session = $container->make(SessionPort::class); - return $session; - } - return null; - } - - /** - * Prefer the referer; fall back to the client-declared URL, then the path. - * - * SECURITY: every candidate is reduced to a same-origin path (+query) — the - * scheme/host are stripped — so the 303 Location can never point off-site. - * This closes an open-redirect vector via a forged referer / X-Pageflow-Url. - */ - private function backUrl(Request $request): string - { - $referer = $this->pathOnly((string) ($request->header('referer') ?? '')); - if ($referer !== '') { - return $referer; - } - - $headerUrl = $this->pathOnly((string) ($request->header('X-Pageflow-Url') ?? '')); - if ($headerUrl !== '') { - return $headerUrl; - } - - return $request->path(); - } - - /** Reduce any URL to a same-origin "/path?query" (never a scheme/host). */ - private function pathOnly(string $url): string - { - if ($url === '') { - return ''; - } - - $path = parse_url($url, PHP_URL_PATH); - if (!is_string($path) || $path === '') { - return ''; - } - - // Guard against protocol-relative ("//evil.com/x") and backslash tricks. - $path = '/' . ltrim(str_replace('\\', '/', $path), '/'); - - $query = parse_url($url, PHP_URL_QUERY); - return is_string($query) && $query !== '' ? $path . '?' . $query : $path; - } - - /** - * Normalise ValidationException errors (string|string[]) to field => message. - * - * @param array $errors - * @return array - */ - private function flatten(array $errors): array - { - $out = []; - foreach ($errors as $field => $message) { - $out[(string) $field] = is_array($message) - ? (string) ($message[0] ?? '') - : (string) $message; - } - return $out; - } -} diff --git a/plugins/Pageflow/Http/PageflowVersionStage.php b/plugins/Pageflow/Http/PageflowVersionStage.php deleted file mode 100644 index 191b8dc..0000000 --- a/plugins/Pageflow/Http/PageflowVersionStage.php +++ /dev/null @@ -1,46 +0,0 @@ -header('X-Pageflow') ?? '')) === 'true'; - - if ($isPageflow && strtoupper($request->method()) === 'GET') { - $clientVersion = (string) ($request->header('X-Pageflow-Version') ?? ''); - $currentVersion = (string) (env('PAGEFLOW_VERSION') ?: ''); - - if ($currentVersion !== '' && $clientVersion !== $currentVersion) { - return Response::json([], 409, [ - 'X-Pageflow-Location' => $this->fullUrl($request), - ]); - } - } - - return $next($request); - } - - private function fullUrl(Request $request): string - { - $path = $request->path(); - $query = http_build_query($request->queryAll()); - return $query === '' ? $path : $path . '?' . $query; - } -} diff --git a/plugins/Pageflow/Provider.php b/plugins/Pageflow/Provider.php index 4216d05..f5b50e2 100644 --- a/plugins/Pageflow/Provider.php +++ b/plugins/Pageflow/Provider.php @@ -18,11 +18,8 @@ use Plugins\Pageflow\API\Contracts\PageflowSharerContract; use Plugins\Pageflow\Http\PageflowAuth; use Plugins\Pageflow\Http\PageflowChannel; -use Plugins\Pageflow\Http\PageflowPrecognitionStage; use Plugins\Pageflow\Http\PageflowResponder; -use Plugins\Pageflow\Http\PageflowShareStage; -use Plugins\Pageflow\Http\PageflowValidationStage; -use Plugins\Pageflow\Http\PageflowVersionStage; +use Plugins\Pageflow\Http\PageflowStage; use Plugins\Pageflow\Http\RegistryPageflowSharer; use Plugins\Pageflow\Cli\PageflowTypesCommand; @@ -108,25 +105,15 @@ public function register(ModuleContainer $container): void public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void { - // Stale-asset guard runs before modules load (cheap 409 reject). - $http->hook('after.security', PageflowVersionStage::class, priority: 18); - - // Translate ValidationException into the Pageflow error envelope. Wraps - // execution, so it sits INSIDE the pipeline (before the kernel ErrorStage) - // but OUTSIDE the controller — priority 12 keeps it near the top of the - // after.security onion so it wraps everything below. - $http->hook('after.security', PageflowValidationStage::class, priority: 12); - - // Flag precognition ("validate only") requests so every layer can refuse - // side effects. Runs just before execution. - $http->hook('after.load', PageflowPrecognitionStage::class, priority: 40); + // One stage carries the whole Pageflow HTTP protocol at after.load (the + // container/responder exist there): stale-asset 409 guard, precognition + // flagging, shared-prop population (do_action('pageflow_share')), and the + // ValidationException → Pageflow error envelope translation around $next. + $http->hook('after.load', PageflowStage::class, priority: 40); // CLI: generate TypeScript typings (shared props + page registry). $cli->command(PageflowTypesCommand::class); - // Populate shared props once modules are loaded (do_action('pageflow_share')). - $http->hook('after.load', PageflowShareStage::class, priority: 45); - // ── Built-in shared props ──────────────────────────────────────────── // Auth projection on every page (UI: useAuth()/). NON-SENSITIVE // fields only — never tokens. This is for UX gating; the Service layer @@ -135,7 +122,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke return PageflowAuth::resolve($request->identity()); }); - // Validation errors flashed by PageflowValidationStage on the previous + // Validation errors flashed by PageflowStage on the previous // request surface here (UI: useForm reads props.errors). Pull-and-clear. pageflow_share('errors', static function (Request $request): array { $container = $request->container(); @@ -144,7 +131,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke } /** @var SessionPort $session */ $session = $container->make(SessionPort::class); - $errors = $session->pull(PageflowValidationStage::ERROR_FLASH_KEY, []); + $errors = $session->pull(PageflowStage::ERROR_FLASH_KEY, []); return is_array($errors) ? $errors : []; }); } diff --git a/plugins/Pageflow/Support/helpers.php b/plugins/Pageflow/Support/helpers.php index d776f9f..68b18f1 100644 --- a/plugins/Pageflow/Support/helpers.php +++ b/plugins/Pageflow/Support/helpers.php @@ -41,7 +41,7 @@ function pageflow_auth_projection(?callable $projector): void * // ... real work only reached on a normal submit * } * - * The PageflowValidationStage turns any ValidationException into the 422 + * The PageflowStage turns any ValidationException into the 422 * error envelope the client reads, so you only handle the success path. */ function pageflow_precognition(Request $request): bool diff --git a/plugins/SecurityFilters/Infrastructure/Http/Stages/CorsStage.php b/plugins/SecurityFilters/Infrastructure/Http/Stages/CorsStage.php deleted file mode 100644 index 307c494..0000000 --- a/plugins/SecurityFilters/Infrastructure/Http/Stages/CorsStage.php +++ /dev/null @@ -1,107 +0,0 @@ -header('origin'); - - // Not a cross-origin request — nothing to do. - if ($origin === null || $origin === '') { - return $next($request); - } - - $allowOrigin = $this->resolveAllowedOrigin($origin); - if ($allowOrigin === null) { - // Origin not permitted: for preflight short-circuit, otherwise pass - // through without CORS headers (the browser will block the read). - return $request->isMethod('OPTIONS') ? Response::empty(204) : $next($request); - } - - $headers = $this->corsHeaders($allowOrigin); - - if ($request->isMethod('OPTIONS')) { - $headers['Access-Control-Allow-Methods'] = (string) (env('CORS_ALLOWED_METHODS') - ?: 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); - $headers['Access-Control-Allow-Headers'] = $request->header('access-control-request-headers') - ?? (string) (env('CORS_ALLOWED_HEADERS') ?: 'Content-Type,Authorization,X-Requested-With'); - $maxAge = (int) (env('CORS_MAX_AGE') ?: 0); - if ($maxAge > 0) { - $headers['Access-Control-Max-Age'] = (string) $maxAge; - } - return Response::empty(204)->withHeaders($headers); - } - - $exposed = trim((string) (env('CORS_EXPOSED_HEADERS') ?: '')); - if ($exposed !== '') { - $headers['Access-Control-Expose-Headers'] = $exposed; - } - - return $next($request)->withHeaders($headers); - } - - /** - * @return array - */ - private function corsHeaders(string $allowOrigin): array - { - $headers = [ - 'Access-Control-Allow-Origin' => $allowOrigin, - 'Vary' => 'Origin', - ]; - if ($this->allowsCredentials()) { - $headers['Access-Control-Allow-Credentials'] = 'true'; - } - return $headers; - } - - private function resolveAllowedOrigin(string $origin): ?string - { - $configured = trim((string) (env('CORS_ALLOWED_ORIGINS') ?: '*')); - - if ($configured === '*') { - // Cannot send "*" together with credentials — echo the origin. - return $this->allowsCredentials() ? $origin : '*'; - } - - foreach (explode(',', $configured) as $candidate) { - if (strcasecmp(trim($candidate), $origin) === 0) { - return $origin; - } - } - return null; - } - - private function allowsCredentials(): bool - { - return filter_var(env('CORS_ALLOW_CREDENTIALS') ?: 'false', FILTER_VALIDATE_BOOLEAN); - } -} diff --git a/plugins/SecurityFilters/Infrastructure/Http/Stages/SecureHeadersStage.php b/plugins/SecurityFilters/Infrastructure/Http/Stages/SecureHeadersStage.php deleted file mode 100644 index 6da390a..0000000 --- a/plugins/SecurityFilters/Infrastructure/Http/Stages/SecureHeadersStage.php +++ /dev/null @@ -1,48 +0,0 @@ - */ - private const DEFAULT_HEADERS = [ - 'X-Frame-Options' => 'SAMEORIGIN', - 'X-Content-Type-Options' => 'nosniff', - 'X-Permitted-Cross-Domain-Policies' => 'none', - 'Referrer-Policy' => 'strict-origin-when-cross-origin', - 'Cross-Origin-Opener-Policy' => 'same-origin', - ]; - - public function handle(Request $request, callable $next): Response - { - $response = $next($request); - $headers = self::DEFAULT_HEADERS; - - if ($request->isSecure()) { - $maxAge = (int) (env('HSTS_MAX_AGE') ?: 31536000); - $headers['Strict-Transport-Security'] = 'max-age=' . $maxAge . '; includeSubDomains'; - } - - $csp = trim((string) (env('CONTENT_SECURITY_POLICY') ?: '')); - if ($csp !== '') { - $headers['Content-Security-Policy'] = $csp; - } - - return $response->withHeaders($headers); - } -} diff --git a/plugins/SecurityFilters/Infrastructure/Http/Stages/SecurityHeadersStage.php b/plugins/SecurityFilters/Infrastructure/Http/Stages/SecurityHeadersStage.php new file mode 100644 index 0000000..4be9d5d --- /dev/null +++ b/plugins/SecurityFilters/Infrastructure/Http/Stages/SecurityHeadersStage.php @@ -0,0 +1,142 @@ + */ + private const SECURE_HEADERS = [ + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-Permitted-Cross-Domain-Policies' => 'none', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Cross-Origin-Opener-Policy' => 'same-origin', + ]; + + public function handle(Request $request, callable $next): Response + { + $origin = $request->header('origin'); + $isCors = $origin !== null && $origin !== ''; + $allowOrigin = $isCors ? $this->resolveAllowedOrigin($origin) : null; + + // ── CORS preflight — answer before auth/modules ────────────────────── + if ($isCors && $request->isMethod('OPTIONS')) { + if ($allowOrigin === null) { + return Response::empty(204); // disallowed origin — no CORS headers + } + $headers = $this->corsHeaders($allowOrigin); + $headers['Access-Control-Allow-Methods'] = (string) (env('CORS_ALLOWED_METHODS') + ?: 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + $headers['Access-Control-Allow-Headers'] = $request->header('access-control-request-headers') + ?? (string) (env('CORS_ALLOWED_HEADERS') ?: 'Content-Type,Authorization,X-Requested-With'); + $maxAge = (int) (env('CORS_MAX_AGE') ?: 0); + if ($maxAge > 0) { + $headers['Access-Control-Max-Age'] = (string) $maxAge; + } + return Response::empty(204)->withHeaders($headers); + } + + // ── Actual request — run the pipeline, then decorate the response ───── + $response = $next($request); + $headers = $this->secureHeaders($request); + + if ($isCors && $allowOrigin !== null) { + $headers += $this->corsHeaders($allowOrigin); + $exposed = trim((string) (env('CORS_EXPOSED_HEADERS') ?: '')); + if ($exposed !== '') { + $headers['Access-Control-Expose-Headers'] = $exposed; + } + } + + return $response->withHeaders($headers); + } + + /** @return array */ + private function secureHeaders(Request $request): array + { + $headers = self::SECURE_HEADERS; + + if ($request->isSecure()) { + $maxAge = (int) (env('HSTS_MAX_AGE') ?: 31536000); + $headers['Strict-Transport-Security'] = 'max-age=' . $maxAge . '; includeSubDomains'; + } + + $csp = trim((string) (env('CONTENT_SECURITY_POLICY') ?: '')); + if ($csp !== '') { + $headers['Content-Security-Policy'] = $csp; + } + + return $headers; + } + + /** @return array */ + private function corsHeaders(string $allowOrigin): array + { + $headers = [ + 'Access-Control-Allow-Origin' => $allowOrigin, + 'Vary' => 'Origin', + ]; + if ($this->allowsCredentials()) { + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + return $headers; + } + + private function resolveAllowedOrigin(string $origin): ?string + { + $configured = trim((string) (env('CORS_ALLOWED_ORIGINS') ?: '*')); + + if ($configured === '*') { + // Cannot send "*" together with credentials — echo the origin. + return $this->allowsCredentials() ? $origin : '*'; + } + + foreach (explode(',', $configured) as $candidate) { + if (strcasecmp(trim($candidate), $origin) === 0) { + return $origin; + } + } + return null; + } + + private function allowsCredentials(): bool + { + return filter_var(env('CORS_ALLOW_CREDENTIALS') ?: 'false', FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/plugins/SecurityFilters/Provider.php b/plugins/SecurityFilters/Provider.php index 66b5a65..7f0bd35 100644 --- a/plugins/SecurityFilters/Provider.php +++ b/plugins/SecurityFilters/Provider.php @@ -11,10 +11,9 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; use Plugins\SecurityFilters\Infrastructure\Http\Stages\ApiRateLimitStage; -use Plugins\SecurityFilters\Infrastructure\Http\Stages\CorsStage; use Plugins\SecurityFilters\Infrastructure\Http\Stages\HmacSignedStage; use Plugins\SecurityFilters\Infrastructure\Http\Stages\RequireAuthStage; -use Plugins\SecurityFilters\Infrastructure\Http\Stages\SecureHeadersStage; +use Plugins\SecurityFilters\Infrastructure\Http\Stages\SecurityHeadersStage; use Plugins\SecurityFilters\Infrastructure\Http\Stages\ShieldStage; /** @@ -53,10 +52,10 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke { // ── GLOBAL hooks — always-on, every request/response ───────────────── // These genuinely apply to ALL traffic, so they stay global (not route - // filters). CORS first: answers preflight (OPTIONS) before auth so it is - // never rejected. SecureHeaders decorates every outgoing response. - $http->hook('after.security', CorsStage::class, priority: 10); - $http->hook('after.execute', SecureHeadersStage::class, priority: 90); + // filters). One stage: it answers CORS preflight (OPTIONS) before auth so + // it is never rejected, then decorates every outgoing response with the + // CORS + OWASP security headers on the way back out. + $http->hook('after.security', SecurityHeadersStage::class, priority: 10); // ── DECLARATIVE route filters — opt-in per route ───────────────────── // Routes name these in module.json / proj.json: diff --git a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php index f2b5fda..a163581 100644 --- a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php +++ b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php @@ -56,8 +56,46 @@ public function __construct( ) { } + /** + * Paths that are served WITHOUT a tenant scope (health/infra endpoints). + * Configured via TENANCY_EXEMPT (comma-separated; trailing '*' = prefix + * match), defaulting to '/ping'. Memoised once — this stage is an + * app-lifetime hook, not request-scoped, so the static cache is safe. + */ + private function isExempt(string $path): bool + { + static $paths = null; + if ($paths === null) { + $raw = (string) (env('TENANCY_EXEMPT', '/ping') ?? '/ping'); + $paths = array_values(array_filter( + array_map('trim', explode(',', $raw)), + static fn (string $p): bool => $p !== '', + )); + } + + foreach ($paths as $p) { + if (str_ends_with($p, '*')) { + if (str_starts_with($path, rtrim($p, '*'))) { + return true; + } + } elseif ($path === $p) { + return true; + } + } + + return false; + } + public function handle(Request $request, callable $next): Response { + // Infrastructure/health endpoints (TENANCY_EXEMPT, default '/ping') carry + // no tenant scope — skip identification AND the per-request DB rebind + // entirely. This stage is an always-on essential hook, so without this a + // bare /ping would still pay a host->tenant DB lookup it never needs. + if ($this->isExempt($request->path())) { + return $next($request); + } + $container = $request->container(); // This stage is an always-on after.load hook, but its collaborators are diff --git a/src/Kernel/Http/Concerns/ManagesResponse.php b/src/Kernel/Http/Concerns/ManagesResponse.php deleted file mode 100644 index 44ed46c..0000000 --- a/src/Kernel/Http/Concerns/ManagesResponse.php +++ /dev/null @@ -1,110 +0,0 @@ -headers is a - * ResponseHeaderBag). Every "with*" returns a clone; the original is untouched. - */ -trait ManagesResponse -{ - public function __clone(): void - { - $this->headers = clone $this->headers; - } - - public function withHeader(string $name, string $value): static - { - $clone = clone $this; - $clone->headers->set($name, $value); - - return $clone; - } - - /** @param array $headers */ - public function withHeaders(array $headers): static - { - $clone = clone $this; - foreach ($headers as $name => $value) { - $clone->headers->set($name, $value); - } - - return $clone; - } - - public function withStatus(int $status): static - { - $clone = clone $this; - $clone->setStatusCode($status); - - return $clone; - } - - /** Queue a Set-Cookie header with secure defaults. Returns a clone. */ - public function withCookie( - string $name, - string $value, - int $maxAge = 0, - string $path = '/', - ?string $domain = null, - bool $secure = true, - bool $httpOnly = true, - string $sameSite = Cookie::SAMESITE_LAX, - ): static { - $clone = clone $this; - $clone->headers->setCookie(Cookie::create( - name: $name, - value: $value, - expire: $maxAge === 0 ? 0 : time() + $maxAge, - path: $path, - domain: $domain, - secure: $secure, - httpOnly: $httpOnly, - sameSite: $sameSite, - )); - - return $clone; - } - - /** Expire a cookie immediately. */ - public function withoutCookie(string $name, string $path = '/', ?string $domain = null): static - { - $clone = clone $this; - $clone->headers->clearCookie($name, $path, $domain); - - return $clone; - } - - public function status(): int - { - return $this->getStatusCode(); - } - - public function body(): string - { - return (string) $this->getContent(); - } - - /** @return array flattened response headers (original case, excluding cookies) */ - public function headers(): array - { - $out = []; - foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { - $out[$name] = \is_array($values) ? (string) ($values[0] ?? '') : (string) $values; - } - - return $out; - } - - /** @return string[] raw Set-Cookie header lines (for Swoole adapters) */ - public function cookies(): array - { - return array_map(static fn (Cookie $c): string => (string) $c, $this->headers->getCookies()); - } -} diff --git a/src/Kernel/Http/Contracts/RequestAware.php b/src/Kernel/Http/Contracts/RequestAware.php deleted file mode 100644 index e4c47bc..0000000 --- a/src/Kernel/Http/Contracts/RequestAware.php +++ /dev/null @@ -1,24 +0,0 @@ -method()) to lift a Request into a typed value. - */ -enum Method: string -{ - case CONNECT = 'CONNECT'; - case DELETE = 'DELETE'; - case GET = 'GET'; - case HEAD = 'HEAD'; - case OPTIONS = 'OPTIONS'; - case PATCH = 'PATCH'; - case POST = 'POST'; - case PUT = 'PUT'; - case TRACE = 'TRACE'; - - /** Safe methods do not mutate state (RFC 9110 §9.2.1). */ - public function isSafe(): bool - { - return match ($this) { - self::GET, self::HEAD, self::OPTIONS, self::TRACE => true, - default => false, - }; - } - - /** Idempotent methods may be retried without additional effect. */ - public function isIdempotent(): bool - { - return match ($this) { - self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE => true, - default => false, - }; - } - - /** Whether responses to this method are cacheable by default. */ - public function isCacheable(): bool - { - return $this === self::GET || $this === self::HEAD; - } - - /** @return list all method values */ - public static function all(): array - { - return array_map(static fn (self $m): string => $m->value, self::cases()); - } -} diff --git a/src/Kernel/Http/Negotiate.php b/src/Kernel/Http/Negotiate.php deleted file mode 100644 index a28a90b..0000000 --- a/src/Kernel/Http/Negotiate.php +++ /dev/null @@ -1,90 +0,0 @@ -negotiate()->language(['en','fr'])`. - * - Representation selection: serve JSON vs HTML vs CSV from one endpoint by the - * best acceptable media type, beyond the boolean Request::wantsJson(). - * - Transport: choose a response charset / compression encoding (gzip, br) the - * client actually accepts. - * - * Each method returns the best supported value, falling back to the first - * supported option (or the supplied default) when the client expresses no usable - * preference — the conservative behaviour servers want. Stateless / immutable. - */ -final readonly class Negotiate -{ - public function __construct(private Request $request) {} - - public static function for(Request $request): self - { - return new self($request); - } - - /** - * Best matching media (content) type. - * - * @param string[] $supported - */ - public function media(array $supported, ?string $default = null): ?string - { - return $this->request->accepts($supported) ?? $default ?? ($supported[0] ?? null); - } - - /** - * Best matching charset. - * - * @param string[] $supported - */ - public function charset(array $supported, ?string $default = null): ?string - { - return $this->best($this->request->getCharsets(), $supported, $default); - } - - /** - * Best matching content encoding (gzip, br, …). - * - * @param string[] $supported - */ - public function encoding(array $supported, ?string $default = null): ?string - { - return $this->best($this->request->getEncodings(), $supported, $default); - } - - /** - * Best matching language. - * - * @param string[] $supported - */ - public function language(array $supported, ?string $default = null): ?string - { - return $this->best($this->request->getLanguages(), $supported, $default); - } - - /** - * @param string[] $accepted client-ranked acceptable values - * @param string[] $supported values the server can produce - */ - private function best(array $accepted, array $supported, ?string $default): ?string - { - if ($supported === []) { - return $default; - } - foreach ($accepted as $value) { - foreach ($supported as $candidate) { - if (strtolower($value) === strtolower($candidate) || $value === '*') { - return $candidate; - } - } - } - - return $default ?? $supported[0]; - } -} diff --git a/src/Kernel/Http/Request.php b/src/Kernel/Http/Request.php deleted file mode 100644 index 15cd8cd..0000000 --- a/src/Kernel/Http/Request.php +++ /dev/null @@ -1,633 +0,0 @@ -json !== null && $this->json === $this->request; - - $this->query = clone $this->query; - $this->request = clone $this->request; - $this->attributes = clone $this->attributes; - $this->cookies = clone $this->cookies; - $this->files = clone $this->files; - $this->server = clone $this->server; - $this->headers = clone $this->headers; - - if ($this->json !== null) { - $this->json = $jsonAliasedToRequest ? $this->request : clone $this->json; - } - } - - // ── Factory ─────────────────────────────────────────────────────────────── - - /** - * Build a Request from PHP superglobals (classic SAPI entry point). - * Swoole adapters construct the Request directly instead. - */ - public static function capture(): static - { - static::enableHttpMethodParameterOverride(); - - return static::createFromBase(SymfonyRequest::createFromGlobals()); - } - - /** - * Build a Request from discrete components (Swoole / test adapters). - * - * Maps the framework's component-style inputs onto the Symfony engine so - * non-SAPI transports never touch superglobals. - * - * @param array $headers - * @param array $query - * @param array $body - * @param array $cookies - * @param array $files - * @param array $server - */ - public static function build( - string $method, - string $path, - array $headers = [], - array $query = [], - array $body = [], - string $rawBody = '', - array $cookies = [], - array $files = [], - array $server = [], - ): static { - $serverParams = $server; - $serverParams['REQUEST_METHOD'] = strtoupper($method); - $serverParams['REQUEST_URI'] ??= $path; - - // Adapters (e.g. Swoole) pass the path and query separately, so the - // QUERY_STRING server param is absent — without it getQueryString() and - // therefore fullUrl()/uri() would drop the query. Derive it from $query. - if ($query !== [] && !isset($serverParams['QUERY_STRING'])) { - $serverParams['QUERY_STRING'] = http_build_query($query); - } - - foreach ($headers as $name => $value) { - $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); - $serverParams[$key] = $value; - if (strtolower($name) === 'content-type') { - $serverParams['CONTENT_TYPE'] = $value; - } - if (strtolower($name) === 'content-length') { - $serverParams['CONTENT_LENGTH'] = $value; - } - } - - $base = new SymfonyRequest($query, $body, [], $cookies, $files, $serverParams, $rawBody); - - return static::createFromBase($base); - } - - /** Promote a base Symfony request into a kernel Request. */ - public static function createFromBase(SymfonyRequest $request): static - { - $new = new static( - $request->query->all(), - $request->request->all(), - $request->attributes->all(), - $request->cookies->all(), - $request->files->all(), - $request->server->all(), - $request->getContent(), - ); - - $new->headers->replace($request->headers->all()); - - if ($new->isJson()) { - $new->request = $new->json(); - } - - return $new; - } - - // ── Core accessors (GDA contract) ─────────────────────────────────────────── - - public function method(): string - { - return $this->getMethod(); - } - - /** Path info with leading slash, e.g. "/api/invoices". */ - public function path(): string - { - return $this->getPathInfo(); - } - - /** Decoded, normalised path without surrounding slashes ("/" stays "/"). */ - public function decodedPath(): string - { - $path = trim(rawurldecode($this->getPathInfo()), '/'); - - return $path === '' ? '/' : $path; - } - - public function rawBody(): string - { - return $this->getContent(); - } - - public function header(string $name, ?string $default = null): ?string - { - return $this->headers->get($name, $default); - } - - public function hasHeader(string $name): bool - { - return $this->headers->has($name); - } - - /** @return array */ - public function headersAll(): array - { - return $this->headers->all(); - } - - public function cookie(string $name, ?string $default = null): ?string - { - return $this->cookies->get($name, $default); - } - - public function hasCookie(string $name): bool - { - return $this->cookies->has($name); - } - - /** @return array */ - public function cookiesAll(): array - { - return $this->cookies->all(); - } - - public function attribute(string $key, mixed $default = null): mixed - { - return $this->attributes->get($key, $default); - } - - /** @return array */ - public function attributesAll(): array - { - return $this->attributes->all(); - } - - public function identity(): ?Identity - { - return $this->identity; - } - - public function container(): ?ModuleContainer - { - return $this->container; - } - - // ── Input ─────────────────────────────────────────────────────────────────── - - /** Active input source: JSON body for JSON requests, query for GET/HEAD, else body. */ - public function getInputSource(): InputBag - { - if ($this->isJson()) { - return $this->json(); - } - - return \in_array($this->getRealMethod(), ['GET', 'HEAD'], true) ? $this->query : $this->request; - } - - public function input(string $key, mixed $default = null): mixed - { - return $this->getInputSource()->all()[$key] - ?? $this->query->all()[$key] - ?? $default; - } - - /** @return array merged body + query (+ files) */ - public function all(): array - { - return $this->getInputSource()->all() + $this->query->all(); - } - - /** - * The parsed request BODY only (decoded JSON, or form fields) — excludes the - * query string. Empty for bodyless methods. Convenient for DTO::fromRequest(). - * - * @return array - */ - public function body(): array - { - return $this->isJson() ? $this->json()->all() : $this->request->all(); - } - - public function query(string $key, mixed $default = null): mixed - { - return $this->query->all()[$key] ?? $default; - } - - /** @return array */ - public function queryAll(): array - { - return $this->query->all(); - } - - public function post(string $key, mixed $default = null): mixed - { - return $this->request->all()[$key] ?? $default; - } - - public function server(string $key, mixed $default = null): mixed - { - return $this->server->get($key, $default); - } - - /** Decoded JSON body as an InputBag (empty bag when not JSON / unparsable). */ - public function json(): InputBag - { - if ($this->json === null) { - $decoded = json_decode($this->getContent(), true); - $this->json = new InputBag(\is_array($decoded) ? $decoded : []); - } - - return $this->json; - } - - public function has(string $key): bool - { - $all = $this->all(); - - return \array_key_exists($key, $all); - } - - public function filled(string $key): bool - { - $value = $this->input($key); - - return $value !== null && $value !== '' && $value !== []; - } - - public function missing(string $key): bool - { - return !$this->has($key); - } - - public function boolean(string $key, bool $default = false): bool - { - $value = $this->input($key); - - return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - public function integer(string $key, int $default = 0): int - { - $value = $this->input($key); - - return $value === null ? $default : (int) $value; - } - - public function float(string $key, float $default = 0.0): float - { - $value = $this->input($key); - - return $value === null ? $default : (float) $value; - } - - public function string(string $key, string $default = ''): string - { - $value = $this->input($key); - - return \is_scalar($value) ? (string) $value : $default; - } - - /** - * @param string[] $keys - * @return array - */ - public function only(array $keys): array - { - $all = $this->all(); - $out = []; - foreach ($keys as $key) { - if (\array_key_exists($key, $all)) { - $out[$key] = $all[$key]; - } - } - - return $out; - } - - /** - * @param string[] $keys - * @return array - */ - public function except(array $keys): array - { - return array_diff_key($this->all(), array_flip($keys)); - } - - // ── Files ───────────────────────────────────────────────────────────────── - - public function file(string $key): ?UploadedFile - { - $file = $this->files->get($key); - - // Already a kernel UploadedFile (e.g. injected by the Swoole adapter in - // test mode) — return as-is to preserve its move semantics. - if ($file instanceof UploadedFile) { - return $file; - } - - // A real PHP-FPM upload — wrap it preserving is_uploaded_file() safety. - return $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile - ? UploadedFile::createFromBase($file) - : null; - } - - public function hasFile(string $key): bool - { - $file = $this->files->get($key); - - return $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile && $file->getPathname() !== ''; - } - - // ── URL / connection ────────────────────────────────────────────────────── - - public function isMethod(string $method): bool - { - return $this->getMethod() === strtoupper($method); - } - - public function isSecure(): bool - { - return parent::isSecure(); - } - - public function scheme(): string - { - return $this->getScheme(); - } - - public function host(): string - { - return $this->getHost(); - } - - public function url(): string - { - return rtrim(preg_replace('/\?.*/', '', $this->getUri()) ?? '', '/'); - } - - public function fullUrl(): string - { - $query = $this->getQueryString(); - - return $query === null ? $this->url() : $this->url() . '?' . $query; - } - - /** - * Immutable PSR-7 view of the current full URL, for safe manipulation — - * e.g. `$request->uri()->withPath('/login')->withQuery('')` to build a - * redirect target, or `->withQuery('')` for a canonical URL. See Uri. - */ - public function uri(): Uri - { - return Uri::fromRequest($this); - } - - /** - * Absolute-URL generator rooted at this request's scheme://host, for links - * that must not hardcode the host (OAuth callbacks, email links, sitemaps) — - * e.g. `$request->site()->to('auth/callback')`. See SiteUri. - */ - public function site(): SiteUri - { - return SiteUri::fromRequest($this); - } - - /** - * Content negotiator over this request's Accept-* headers — pick the best - * response language / media type / charset / encoding the client accepts, - * e.g. `$request->negotiate()->language(['en', 'fr'])`. See Negotiate. - */ - public function negotiate(): Negotiate - { - return Negotiate::for($this); - } - - public function ip(): ?string - { - return $this->getClientIp(); - } - - public function userAgent(): ?string - { - return $this->headers->get('User-Agent'); - } - - public function contentType(): ?string - { - return $this->headers->get('Content-Type'); - } - - - /** Extract a Bearer token from the Authorization header (no global lookups). */ - public function bearerToken(): ?string - { - $header = (string) $this->headers->get('Authorization', ''); - if (stripos($header, 'Bearer ') === 0) { - $token = substr($header, 7); - $token = str_contains($token, ',') ? strstr($token, ',', true) : $token; - - return $token !== '' ? trim((string) $token) : null; - } - - return null; - } - - // ── Path matching ─────────────────────────────────────────────────────────── - - /** @return string[] non-empty path segments */ - public function segments(): array - { - return array_values(array_filter( - explode('/', $this->decodedPath()), - static fn($s): bool => $s !== '', - )); - } - - public function segment(int $index, ?string $default = null): ?string - { - return $this->segments()[$index - 1] ?? $default; - } - - /** Match the path against shell-style wildcard patterns (e.g. "api/*"). */ - public function is(string ...$patterns): bool - { - $path = $this->decodedPath(); - foreach ($patterns as $pattern) { - $pattern = trim($pattern, '/') ?: '/'; - if ($pattern === $path) { - return true; - } - $regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#'; - if (preg_match($regex, $path) === 1) { - return true; - } - } - - return false; - } - - // ── Content negotiation ───────────────────────────────────────────────────── - - public function isJson(): bool - { - $type = (string) $this->headers->get('Content-Type'); - - return str_contains($type, '/json') || str_contains($type, '+json'); - } - - public function isXmlHttpRequest(): bool - { - return parent::isXmlHttpRequest(); - } - - public function wantsJson(): bool - { - $acceptable = $this->getAcceptableContentTypes(); - $first = isset($acceptable[0]) ? strtolower($acceptable[0]) : ''; - - return $first !== '' && (str_contains($first, '/json') || str_contains($first, '+json')); - } - - public function expectsJson(): bool - { - return $this->isXmlHttpRequest() || $this->wantsJson() || $this->isJson(); - } - - /** - * Pick the best supported content type against the Accept header; falls back - * to the first supported type when nothing matches. - * - * @param string[] $supported - */ - public function accepts(array $supported): ?string - { - if ($supported === []) { - return null; - } - $accepts = $this->getAcceptableContentTypes(); - if ($accepts === []) { - return $supported[0]; - } - foreach ($accepts as $accept) { - if ($accept === '*/*' || $accept === '*') { - return $supported[0]; - } - foreach ($supported as $type) { - if (strtolower($accept) === strtolower($type)) { - return $type; - } - } - } - - return $supported[0]; - } - - // ── Immutable mutators (return clones) ──────────────────────────────────── - - public function withHeader(string $name, string $value): static - { - $clone = clone $this; - $clone->headers->set($name, $value); - - return $clone; - } - - public function withAttribute(string $key, mixed $value): static - { - $clone = clone $this; - $clone->attributes->set($key, $value); - - return $clone; - } - - public function withIdentity(Identity $identity): static - { - $clone = clone $this; - $clone->identity = $identity; - - return $clone; - } - - public function withContainer(ModuleContainer $container): static - { - $clone = clone $this; - $clone->container = $container; - - return $clone; - } - - /** - * Return a NEW request with $input merged into the active input source. - * - * @param array $input - */ - public function merge(array $input): static - { - $clone = clone $this; - $clone->getInputSource()->add($input); - - return $clone; - } - - /** - * Return a NEW request whose active input source is replaced by $input. - * - * @param array $input - */ - public function replace(array $input): static - { - $clone = clone $this; - $clone->getInputSource()->replace($input); - - return $clone; - } -} diff --git a/src/Kernel/Http/Response.php b/src/Kernel/Http/Response.php deleted file mode 100644 index d27ba5b..0000000 --- a/src/Kernel/Http/Response.php +++ /dev/null @@ -1,335 +0,0 @@ - $headers */ - public static function json(mixed $data, int $status = 200, array $headers = []): self - { - return new self( - json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}', - $status, - ['Content-Type' => 'application/json'] + $headers, - ); - } - - public static function text(string $body, int $status = 200): self - { - return new self($body, $status, ['Content-Type' => 'text/plain; charset=utf-8']); - } - - public static function html(string $body, int $status = 200): self - { - return new self($body, $status, ['Content-Type' => 'text/html; charset=utf-8']); - } - - /** Convenience: a 200 JSON success envelope. */ - public static function success(mixed $data = null, int $status = 200): self - { - return self::json(['success' => true, 'data' => $data], $status); - } - - /** 201 Created — optional Location header for the new resource. */ - public static function created(mixed $data, ?string $location = null): self - { - $response = self::json($data, 201); - - return $location !== null ? $response->withHeader('Location', $location) : $response; - } - - /** 202 Accepted — request queued for async processing. */ - public static function accepted(mixed $data = null): self - { - return $data === null ? self::empty(202) : self::json($data, 202); - } - - public static function empty(int $status = 204): self - { - return new self('', $status, []); - } - - /** Alias for empty(204). */ - public static function noContent(): self - { - return self::empty(204); - } - - public static function notFound(string $message = 'Resource not found.'): self - { - return self::error('not_found', $message, 404); - } - - public static function unauthorized(string $message = 'Unauthenticated.'): self - { - return self::error('unauthorized', $message, 401); - } - - public static function forbidden(string $message = 'Forbidden.'): self - { - return self::error('forbidden', $message, 403); - } - - public static function badRequest(string $message = 'Bad request.'): self - { - return self::error('bad_request', $message, 400); - } - - public static function conflict(string $message = 'Conflict.'): self - { - return self::error('conflict', $message, 409); - } - - /** 429 — sets Retry-After when a delay is supplied. */ - public static function tooManyRequests(string $message = 'Too many requests.', ?int $retryAfter = null): self - { - $response = self::error('too_many_requests', $message, 429); - - return $retryAfter !== null ? $response->withHeader('Retry-After', (string) $retryAfter) : $response; - } - - /** @param array $errors */ - public static function unprocessable(array $errors, string $message = 'Validation failed.'): self - { - return self::json([ - 'error' => [ - 'code' => 'validation_failed', - 'message' => $message, - 'fields' => $errors, - ], - ], 422); - } - - public static function serverError(string $message = 'An internal error occurred.'): self - { - return self::error('server_error', $message, 500); - } - - public static function redirect(string $url, int $status = 302): self - { - return new self('', $status, ['Location' => $url]); - } - - /** 301 — permanent redirect (cacheable, changes the canonical URL). */ - public static function permanentRedirect(string $url): self - { - return self::redirect($url, 301); - } - - /** - * Redirect back to where the request came from. Pass the request's Referer - * (e.g. $request->header('referer')); falls back to $fallback when absent. - */ - public static function back(?string $referer, string $fallback = '/', int $status = 302): self - { - return self::redirect($referer !== null && $referer !== '' ? $referer : $fallback, $status); - } - - private static function error(string $code, string $message, int $status): self - { - return self::json(['error' => ['code' => $code, 'message' => $message]], $status); - } - - /** JSONP — wraps a JSON payload in a JavaScript callback invocation. */ - public static function jsonp(string $callback, mixed $data, int $status = 200): self - { - $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'null'; - - return new self( - sprintf('/**/%s(%s);', $callback, $json), - $status, - ['Content-Type' => 'application/javascript'], - ); - } - - /** - * Stream output produced by a callback (no in-memory body). - * - * @param array $headers - */ - public static function stream(callable $callback, int $status = 200, array $headers = []): self - { - $response = new self('', $status, $headers); - $response->streamCallback = \Closure::fromCallable($callback); - - return $response; - } - - /** - * Stream a download produced by a callback (no file on disk). - * - * @param array $headers - */ - public static function streamDownload(callable $callback, string $name, array $headers = []): self - { - $response = self::stream($callback, 200, $headers); - $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition( - ResponseHeaderBag::DISPOSITION_ATTACHMENT, - $name, - str_replace('%', '', (string) iconv('UTF-8', 'ASCII//TRANSLIT', $name)), - )); - - return $response; - } - - /** - * Force a file download from disk. - * - * @param array $headers - */ - public static function download(\SplFileInfo|string $file, ?string $name = null, array $headers = []): self - { - return self::fileResponse($file, $name, $headers, ResponseHeaderBag::DISPOSITION_ATTACHMENT); - } - - /** Serve a file inline (rendered in-browser). */ - public static function file(\SplFileInfo|string $file, ?string $name = null): self - { - return self::fileResponse($file, $name, [], ResponseHeaderBag::DISPOSITION_INLINE); - } - - /** @param array $headers */ - private static function fileResponse(\SplFileInfo|string $file, ?string $name, array $headers, string $disposition): self - { - $path = $file instanceof \SplFileInfo ? $file->getPathname() : $file; - $name ??= basename($path); - - $response = new self('', 200, $headers); - $response->filePath = $path; - if (! $response->headers->has('Content-Type')) { - $response->headers->set('Content-Type', 'application/octet-stream'); - } - $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition( - $disposition, - $name, - str_replace('%', '', (string) iconv('UTF-8', 'ASCII//TRANSLIT', $name)), - )); - - return $response; - } - - // ── Sending (SAPI) ────────────────────────────────────────────────────────── - - public function send(bool $flush = true): static - { - if ($this->streamCallback !== null) { - if (! headers_sent()) { - $this->sendHeaders(); - } - ($this->streamCallback)(); - - return $this; - } - - if ($this->filePath !== null) { - if (! headers_sent()) { - if (! $this->headers->has('Content-Length') && is_file($this->filePath)) { - $this->headers->set('Content-Length', (string) filesize($this->filePath)); - } - $this->sendHeaders(); - } - if (is_file($this->filePath)) { - readfile($this->filePath); - } - - return $this; - } - - return parent::send($flush); - } - - /** - * Materialise the body as a string (used by Swoole adapters that read - * status()/headers()/body()/cookies() instead of calling send()). - */ - public function body(): string - { - if ($this->filePath !== null) { - return is_file($this->filePath) ? (file_get_contents($this->filePath) ?: '') : ''; - } - - if ($this->streamCallback !== null) { - ob_start(); - ($this->streamCallback)(); - - return (string) ob_get_clean(); - } - - return (string) $this->getContent(); - } - - // ── Transport-agnostic emission (Swoole adapters) ──────────────────────────── - - /** True when this response streams a file from disk (use sendfile()). */ - public function isFile(): bool - { - return $this->filePath !== null; - } - - /** True when this response streams output from a callback. */ - public function isStreamed(): bool - { - return $this->streamCallback !== null; - } - - /** Absolute path of the file to stream, or null for non-file responses. */ - public function filePath(): ?string - { - return $this->filePath; - } - - /** - * Emit a streamed response in chunks via $writer, without buffering the whole - * body in memory. For non-streamed responses, $writer receives the full body - * once. Lets a Swoole adapter pipe output through $res->write(). - * - * @param callable(string): void $writer - */ - public function streamTo(callable $writer): void - { - if ($this->streamCallback === null) { - $writer($this->body()); - - return; - } - - ob_start(static function (string $buffer) use ($writer): string { - if ($buffer !== '') { - $writer($buffer); - } - - return ''; - }, 8192); - - ($this->streamCallback)(); - - ob_end_flush(); - } -} diff --git a/src/Kernel/Http/SiteUri.php b/src/Kernel/Http/SiteUri.php deleted file mode 100644 index 295f32a..0000000 --- a/src/Kernel/Http/SiteUri.php +++ /dev/null @@ -1,66 +0,0 @@ -to('auth/callback')` for an absolute URL. Immutable and side-effect free. - */ -final readonly class SiteUri -{ - private string $baseUrl; - - public function __construct(string $baseUrl) - { - $this->baseUrl = rtrim($baseUrl, '/'); - } - - /** Base URL = scheme://host[:port] of the incoming request. */ - public static function fromRequest(Request $request): self - { - return new self($request->scheme() . '://' . $request->getHttpHost()); - } - - public function base(): string - { - return $this->baseUrl; - } - - /** - * Absolute URL for a path, with an optional query. - * - * @param array $query - */ - public function to(string $path = '', array $query = []): string - { - $url = $this->baseUrl . '/' . ltrim($path, '/'); - if ($query !== []) { - $url .= '?' . http_build_query($query); - } - - return $url; - } - - /** Absolute URL for a static asset (alias of to() for readability). */ - public function asset(string $path): string - { - return $this->to($path); - } - - /** As a PSR-7 Uri value object (for further immutable manipulation). */ - public function uri(string $path = ''): Uri - { - return new Uri($this->to($path)); - } -} diff --git a/src/Kernel/Http/UploadedFile.php b/src/Kernel/Http/UploadedFile.php deleted file mode 100644 index 6cb2244..0000000 --- a/src/Kernel/Http/UploadedFile.php +++ /dev/null @@ -1,94 +0,0 @@ -getPathname(), - $file->getClientOriginalName(), - $file->getClientMimeType(), - $file->getError(), - $test, - ); - } - - /** - * Build a kernel UploadedFile from an OpenSwoole file entry. - * - * Swoole's per-file array is $_FILES-shaped (name/type/tmp_name/error/size) - * but the temp file was NOT created by PHP's multipart handler, so - * `is_uploaded_file()` would reject it. We therefore construct in test mode - * (test=true) so moveTo() uses rename() instead of move_uploaded_file(). - * - * @param array{name?: string, type?: string, tmp_name?: string, error?: int} $file - */ - public static function fromSwoole(array $file): static - { - return new static( - $file['tmp_name'] ?? '', - $file['name'] ?? '', - $file['type'] ?? null, - $file['error'] ?? UPLOAD_ERR_OK, - true, - ); - } - - public function clientName(): string - { - return $this->getClientOriginalName(); - } - - public function clientMimeType(): string - { - return $this->getClientMimeType(); - } - - public function size(): int - { - return (int) $this->getSize(); - } - - public function tempPath(): string - { - return $this->getPathname(); - } - - public function isValid(): bool - { - return parent::isValid(); - } - - public function contents(): string - { - $path = $this->getPathname(); - - return is_readable($path) ? (file_get_contents($path) ?: '') : ''; - } - - public function extension(): string - { - return strtolower($this->getClientOriginalExtension()); - } -} diff --git a/src/Kernel/Http/Uri.php b/src/Kernel/Http/Uri.php deleted file mode 100644 index 8d1d463..0000000 --- a/src/Kernel/Http/Uri.php +++ /dev/null @@ -1,198 +0,0 @@ -uri()->withPath('/login')->withQuery('')`. - * - Canonical URLs: strip the query / force https / drop a default port for - * cache keys, , sitemaps, signed-URL bases. - * - Interop: implementing the PSR interface means it drops straight into any - * PSR-7 aware code (middleware, HTTP clients) without adapters. - * - * Get one from the current request via Request::uri(), or parse any string with - * `new Uri($string)`. Every "with*" returns a new instance; nothing mutates. - */ -final class Uri implements UriInterface, \Stringable -{ - private string $scheme = ''; - private string $userInfo = ''; - private string $host = ''; - private ?int $port = null; - private string $path = ''; - private string $query = ''; - private string $fragment = ''; - - public function __construct(string $uri = '') - { - if ($uri === '') { - return; - } - $parts = parse_url($uri); - if ($parts === false) { - throw new \InvalidArgumentException("Unable to parse URI: {$uri}"); - } - $this->scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : ''; - $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; - $this->port = $this->filterPort($parts['port'] ?? null); - $this->path = $parts['path'] ?? ''; - $this->query = $parts['query'] ?? ''; - $this->fragment = $parts['fragment'] ?? ''; - $this->userInfo = $parts['user'] ?? ''; - if (isset($parts['pass'])) { - $this->userInfo .= ':' . $parts['pass']; - } - } - - /** Build a Uri from the current request's full URL. */ - public static function fromRequest(Request $request): self - { - return new self($request->fullUrl()); - } - - public function getScheme(): string - { - return $this->scheme; - } - - public function getAuthority(): string - { - if ($this->host === '') { - return ''; - } - $authority = $this->host; - if ($this->userInfo !== '') { - $authority = $this->userInfo . '@' . $authority; - } - if ($this->port !== null) { - $authority .= ':' . $this->port; - } - - return $authority; - } - - public function getUserInfo(): string - { - return $this->userInfo; - } - - public function getHost(): string - { - return $this->host; - } - - public function getPort(): ?int - { - return $this->port; - } - - public function getPath(): string - { - return $this->path; - } - - public function getQuery(): string - { - return $this->query; - } - - public function getFragment(): string - { - return $this->fragment; - } - - public function withScheme(string $scheme): UriInterface - { - $clone = clone $this; - $clone->scheme = strtolower($scheme); - $clone->port = $clone->filterPort($clone->port); - - return $clone; - } - - public function withUserInfo(string $user, ?string $password = null): UriInterface - { - $clone = clone $this; - $clone->userInfo = $password !== null && $password !== '' ? "{$user}:{$password}" : $user; - - return $clone; - } - - public function withHost(string $host): UriInterface - { - $clone = clone $this; - $clone->host = strtolower($host); - - return $clone; - } - - public function withPort(?int $port): UriInterface - { - $clone = clone $this; - $clone->port = $clone->filterPort($port); - - return $clone; - } - - public function withPath(string $path): UriInterface - { - $clone = clone $this; - $clone->path = $path; - - return $clone; - } - - public function withQuery(string $query): UriInterface - { - $clone = clone $this; - $clone->query = ltrim($query, '?'); - - return $clone; - } - - public function withFragment(string $fragment): UriInterface - { - $clone = clone $this; - $clone->fragment = ltrim($fragment, '#'); - - return $clone; - } - - public function __toString(): string - { - $uri = ''; - if ($this->scheme !== '') { - $uri .= $this->scheme . ':'; - } - $authority = $this->getAuthority(); - if ($authority !== '' || $this->scheme === 'file') { - $uri .= '//' . $authority; - } - $uri .= $this->path; - if ($this->query !== '') { - $uri .= '?' . $this->query; - } - if ($this->fragment !== '') { - $uri .= '#' . $this->fragment; - } - - return $uri; - } - - /** Drop the port when it equals the scheme's default. */ - private function filterPort(?int $port): ?int - { - if ($port === null) { - return null; - } - $defaults = ['http' => 80, 'https' => 443, 'ftp' => 21]; - - return ($defaults[$this->scheme] ?? null) === $port ? null : $port; - } -} diff --git a/src/Kernel/Http/UserAgent.php b/src/Kernel/Http/UserAgent.php deleted file mode 100644 index 74e6c4b..0000000 --- a/src/Kernel/Http/UserAgent.php +++ /dev/null @@ -1,126 +0,0 @@ - platform name => match needle (lower-case) */ - private const PLATFORMS = [ - 'Windows' => 'windows', - 'Android' => 'android', - 'iOS' => 'iphone', - 'iPadOS' => 'ipad', - 'macOS' => 'macintosh', - 'Linux' => 'linux', - 'Chrome OS' => 'cros', - ]; - - /** Browser tokens in priority order (first match wins). */ - private const BROWSERS = [ - 'Edge' => '/edg(?:e|ios|a)?\/([\d.]+)/i', - 'Opera' => '/(?:opera|opr)\/([\d.]+)/i', - 'Firefox' => '/firefox\/([\d.]+)/i', - 'Chrome' => '/(?:chrome|crios)\/([\d.]+)/i', - 'Safari' => '/version\/([\d.]+).*safari/i', - ]; - - private function __construct( - private readonly string $raw, - private readonly string $platform, - private readonly string $browser, - private readonly string $version, - private readonly bool $robot, - private readonly bool $mobile, - ) {} - - public static function fromRequest(Request $request): self - { - return self::parse($request->userAgent() ?? ''); - } - - public static function parse(string $ua): self - { - $lower = strtolower($ua); - - $platform = ''; - foreach (self::PLATFORMS as $name => $needle) { - if (str_contains($lower, $needle)) { - $platform = $name; - break; - } - } - - $browser = ''; - $version = ''; - foreach (self::BROWSERS as $name => $pattern) { - if (preg_match($pattern, $ua, $m) === 1) { - $browser = $name; - $version = $m[1] ?? ''; - break; - } - } - - $robot = $ua !== '' && preg_match('/bot|crawl|slurp|spider|mediapartners|facebookexternalhit|curl|wget|python-requests/i', $ua) === 1; - $mobile = preg_match('/mobile|android|iphone|ipod|blackberry|windows phone/i', $ua) === 1; - - return new self($ua, $platform, $browser, $version, $robot, $mobile); - } - - public function raw(): string - { - return $this->raw; - } - - public function platform(): string - { - return $this->platform; - } - - public function browser(): string - { - return $this->browser; - } - - public function version(): string - { - return $this->version; - } - - public function isRobot(): bool - { - return $this->robot; - } - - public function isMobile(): bool - { - return $this->mobile; - } - - /** A real browser: a known browser token and not a bot. */ - public function isBrowser(): bool - { - return $this->browser !== '' && !$this->robot; - } - - public function isEmpty(): bool - { - return $this->raw === ''; - } - - public function __toString(): string - { - return $this->raw; - } -} diff --git a/tests/Unit/Plugins/Pageflow/PageflowResponderTest.php b/tests/Unit/Plugins/Pageflow/PageflowResponderTest.php index 00f2b34..4a1ed24 100644 --- a/tests/Unit/Plugins/Pageflow/PageflowResponderTest.php +++ b/tests/Unit/Plugins/Pageflow/PageflowResponderTest.php @@ -17,13 +17,11 @@ use Plugins\Pageflow\Http\PageflowResponder; use Plugins\Pageflow\Http\PageflowShares; use Plugins\Pageflow\Http\RegistryPageflowSharer; -use Plugins\Pageflow\Http\PageflowShareStage; -use Plugins\Pageflow\Http\PageflowVersionStage; +use Plugins\Pageflow\Http\PageflowStage; #[CoversClass(PageflowResponder::class)] #[CoversClass(PageflowPage::class)] -#[CoversClass(PageflowVersionStage::class)] -#[CoversClass(PageflowShareStage::class)] +#[CoversClass(PageflowStage::class)] final class PageflowResponderTest extends TestCase { private ?string $layoutPath = null; @@ -173,7 +171,7 @@ public function test_partial_rules_ignored_when_component_differs(): void public function test_version_stage_returns_409_for_stale_client_version(): void { putenv('PAGEFLOW_VERSION=v2'); - $stage = new PageflowVersionStage(); + $stage = new PageflowStage(); $next = static fn(Request $r) => \AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response::text('OK'); $response = $stage->handle( @@ -189,7 +187,7 @@ public function test_version_stage_returns_409_for_stale_client_version(): void public function test_version_stage_passes_matching_version_through(): void { putenv('PAGEFLOW_VERSION=v2'); - $stage = new PageflowVersionStage(); + $stage = new PageflowStage(); $next = static fn(Request $r) => \AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response::text('OK'); $response = $stage->handle( @@ -218,7 +216,7 @@ public function share(Request $request, PageflowResponder $responder): void $request = $this->request(['X-Pageflow' => 'true'])->withContainer($container); $reached = false; - (new PageflowShareStage())->handle($request, static function (Request $r) use (&$reached) { + (new PageflowStage())->handle($request, static function (Request $r) use (&$reached) { $reached = true; return Response::text('OK'); }); @@ -277,7 +275,7 @@ public function test_pageflow_share_rejects_a_key_without_a_resolver(): void public function test_share_stage_passes_through_without_a_container(): void { - $response = (new PageflowShareStage())->handle( + $response = (new PageflowStage())->handle( $this->request(), static fn(Request $r) => Response::text('OK'), ); diff --git a/tools/ci/protect-main.sh b/tools/ci/protect-main.sh index 0559025..e120f97 100755 --- a/tools/ci/protect-main.sh +++ b/tools/ci/protect-main.sh @@ -53,7 +53,7 @@ gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" \ { "required_status_checks": { "strict": true, - "contexts": ["PHPUnit (PHP 8.4)", "Zig build (all targets)"] + "contexts": ["PHPUnit (PHP 8.4)", "Zig build (all targets)", "PHPStan", "composer audit"] }, "enforce_admins": true, "required_pull_request_reviews": { diff --git a/tools/src/commands/module.zig b/tools/src/commands/module.zig new file mode 100644 index 0000000..ec84875 --- /dev/null +++ b/tools/src/commands/module.zig @@ -0,0 +1,641 @@ +//! `hkm module` — manage FIRST-PARTY kernel packages under the monorepo's +//! `modules/` directory (bind-it, php-io-cli, let-migrate, …) as git submodules. +//! +//! These are standalone composer *path repositories*, each its own git repo, +//! registered in `.gitmodules` — NOT plugins (which live in `plugins/`, use the +//! `Plugins\` namespace and are managed by `hkm plugins`). +//! +//! hkm module list the kernel modules +//! hkm module add [org] add a submodule + wire composer.json +//! hkm module remove deinit + remove the submodule + unwire +//! +//! Options: +//! --org / --vendor composer vendor (default: alfacode-team; or 3rd arg) +//! --namespace PSR-4 root (default: \) +//! --desc composer description for a generated composer.json +//! --offline composer update with the network disabled (--no-dev) +//! --no-composer skip the composer update step +//! --dry-run, -n preview the git/composer actions without running them +//! +//! `add` mirrors the proven module.sh flow: `git submodule add modules/` +//! → `git submodule update --init --recursive` → ensure src/ + composer.json exist +//! → wire the root composer.json (path repo + require "*") → `composer update`. +//! The remote at must already exist (create it empty first). +//! +//! Only runnable from inside the kernel monorepo (a dir holding composer.json AND +//! a modules/ subtree). Git operations are STAGED, never committed — review with +//! `git status`/`git diff` and commit yourself. + +const std = @import("std"); +const prompt = @import("../lib/prompt.zig"); +const util = @import("../lib/util.zig"); + +const Dir = std.Io.Dir; +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +const Action = enum { list, add, remove }; + +const Opts = struct { + vendor: []const u8 = "alfacode-team", + namespace: []const u8 = "", + desc: []const u8 = "", + url: []const u8 = "", + offline: bool = false, + no_composer: bool = false, + dry_run: bool = false, +}; + +pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { + var action: Action = .list; + var saw_action = false; + var opts: Opts = .{}; + + var operands: std.ArrayList([]const u8) = .empty; + + var i: usize = 2; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { + printHelp(); + return 0; + } else if (std.mem.eql(u8, a, "--dry-run") or std.mem.eql(u8, a, "-n")) { + opts.dry_run = true; + } else if (std.mem.eql(u8, a, "--offline")) { + opts.offline = true; + } else if (std.mem.eql(u8, a, "--no-composer")) { + opts.no_composer = true; + } else if (std.mem.eql(u8, a, "--org") or std.mem.eql(u8, a, "--vendor")) { + i += 1; + if (i < args.len) opts.vendor = args[i]; + } else if (std.mem.startsWith(u8, a, "--org=")) { + opts.vendor = a["--org=".len..]; + } else if (std.mem.startsWith(u8, a, "--vendor=")) { + opts.vendor = a["--vendor=".len..]; + } else if (std.mem.eql(u8, a, "--namespace")) { + i += 1; + if (i < args.len) opts.namespace = args[i]; + } else if (std.mem.startsWith(u8, a, "--namespace=")) { + opts.namespace = a["--namespace=".len..]; + } else if (std.mem.eql(u8, a, "--desc")) { + i += 1; + if (i < args.len) opts.desc = args[i]; + } else if (std.mem.startsWith(u8, a, "--desc=")) { + opts.desc = a["--desc=".len..]; + } else if (std.mem.eql(u8, a, "--url")) { + i += 1; + if (i < args.len) opts.url = args[i]; + } else if (std.mem.startsWith(u8, a, "--url=")) { + opts.url = a["--url=".len..]; + } else if (a.len > 0 and a[0] == '-') { + // unknown flag — ignore + } else if (!saw_action and operands.items.len == 0 and actionFromWord(a) != null) { + action = actionFromWord(a).?; + saw_action = true; + } else { + try operands.append(allocator, a); + } + } + + const root = (try kernelRoot(allocator, io, env)) orelse { + prompt.err("Not inside the kernel monorepo — no ancestor holds composer.json + modules/."); + prompt.note("Kernel modules are first-party packages; manage them from the kernel repo root."); + return 1; + }; + + const ops = operands.items; + return switch (action) { + .list => listModules(allocator, io, root), + .add => { + // positional: add [git-url] [org] + if (ops.len == 0) { + prompt.err("Usage: hkm module add [org] [--offline]"); + return 2; + } + if (opts.url.len == 0 and ops.len >= 2) opts.url = ops[1]; + if (ops.len >= 3) opts.vendor = ops[2]; + if (opts.url.len == 0) { + prompt.err("A git remote URL is required — kernel modules are submodules."); + prompt.note("Create an (empty) repo first, then: hkm module add [org]"); + return 2; + } + return addModule(allocator, io, env, root, ops[0], opts); + }, + .remove => { + if (ops.len == 0) { + prompt.err("Usage: hkm module remove [--dry-run]"); + return 2; + } + return removeModule(allocator, io, env, root, ops[0], opts); + }, + }; +} + +fn actionFromWord(a: []const u8) ?Action { + if (std.mem.eql(u8, a, "list") or std.mem.eql(u8, a, "ls")) return .list; + if (std.mem.eql(u8, a, "add") or std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or + std.mem.eql(u8, a, "scaffold")) return .add; + if (std.mem.eql(u8, a, "remove") or std.mem.eql(u8, a, "delete") or std.mem.eql(u8, a, "del") or + std.mem.eql(u8, a, "rm") or std.mem.eql(u8, a, "destroy")) return .remove; + return null; +} + +/// Climb from PWD until an ancestor is the kernel monorepo root: it holds both a +/// composer.json AND a modules/ subtree. Returns null when none is found. +fn kernelRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + var cur = try util.absPath(allocator, env, "."); + var depth: usize = 0; + while (depth < 32) : (depth += 1) { + const composer = try util.join(allocator, cur, "composer.json"); + const modules = try util.join(allocator, cur, "modules"); + if (util.fileExists(io, composer) and util.dirExists(Dir.cwd(), io, modules)) return cur; + const parent = std.fs.path.dirname(cur) orelse return null; + if (std.mem.eql(u8, parent, cur)) return null; + cur = parent; + } + return null; +} + +// ── list ────────────────────────────────────────────────────────────────────── + +fn listModules(allocator: std.mem.Allocator, io: Io, root: []const u8) !u8 { + const modules_dir = try util.join(allocator, root, "modules"); + + prompt.intro("hkm module"); + prompt.ok(try std.fmt.allocPrint(allocator, "kernel {s}", .{root})); + + var rows: std.ArrayList([]const []const u8) = .empty; + var d = Dir.cwd().openDir(io, modules_dir, .{ .iterate = true }) catch { + prompt.muted("No modules/ directory."); + prompt.outro("0 modules"); + return 0; + }; + defer d.close(io); + var it = d.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .directory) continue; + if (entry.name.len > 0 and entry.name[0] == '.') continue; + const folder = try allocator.dupe(u8, entry.name); + const meta = try readComposerMeta(allocator, io, modules_dir, folder); + const row = try allocator.dupe([]const u8, &.{ + folder, + meta.name orelse "—", + meta.version orelse "—", + }); + try rows.append(allocator, row); + } + + if (rows.items.len == 0) { + prompt.muted("No modules found."); + } else { + prompt.table(allocator, &.{ "Folder", "Package", "Version" }, rows.items); + } + prompt.outro(try std.fmt.allocPrint(allocator, "{d} module(s) · modules/", .{rows.items.len})); + return 0; +} + +const ComposerMeta = struct { name: ?[]const u8 = null, version: ?[]const u8 = null }; + +fn readComposerMeta(allocator: std.mem.Allocator, io: Io, modules_dir: []const u8, folder: []const u8) !ComposerMeta { + const path = try std.fmt.allocPrint(allocator, "{s}/{s}/composer.json", .{ modules_dir, folder }); + const content = Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return .{}; + const parsed = std.json.parseFromSliceLeaky(std.json.Value, allocator, content, .{}) catch return .{}; + if (parsed != .object) return .{}; + var meta: ComposerMeta = .{}; + if (parsed.object.get("name")) |v| { + if (v == .string) meta.name = v.string; + } + if (parsed.object.get("version")) |v| { + if (v == .string) meta.version = v.string; + } + return meta; +} + +// ── add (git submodule + bootstrap + wire + composer update) ────────────────── + +fn addModule(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, nameArg: []const u8, opts: Opts) !u8 { + const folder = try kebab(allocator, nameArg); + const rel = try std.fmt.allocPrint(allocator, "modules/{s}", .{folder}); + const modulePath = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ root, rel }); + const pkg = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ opts.vendor, folder }); + + const ns = if (opts.namespace.len > 0) + std.mem.trimEnd(u8, opts.namespace, "\\") + else + try std.fmt.allocPrint(allocator, "{s}\\{s}", .{ try util.studly(allocator, opts.vendor), try util.studly(allocator, folder) }); + + prompt.intro("hkm module add"); + prompt.ok(try std.fmt.allocPrint(allocator, "kernel {s}", .{root})); + prompt.muted(try std.fmt.allocPrint(allocator, "package {s}", .{pkg})); + prompt.muted(try std.fmt.allocPrint(allocator, "namespace {s}\\", .{ns})); + prompt.muted(try std.fmt.allocPrint(allocator, "path {s}", .{rel})); + prompt.muted(try std.fmt.allocPrint(allocator, "remote {s}", .{opts.url})); + + // Idempotency — already a registered submodule? + if (try gitmodulesHasPath(allocator, io, root, rel)) { + prompt.warn(try std.fmt.allocPrint(allocator, "{s} is already a registered submodule.", .{rel})); + prompt.outro("No changes made"); + return 0; + } + if (util.dirExists(Dir.cwd(), io, modulePath)) { + prompt.err(try std.fmt.allocPrint(allocator, "{s} already exists on disk but is not a submodule — resolve manually.", .{rel})); + return 1; + } + + if (opts.dry_run) { + prompt.section("Would run"); + prompt.muted(try std.fmt.allocPrint(allocator, " git submodule add {s} {s}", .{ opts.url, rel })); + prompt.muted(" git submodule update --init --recursive"); + prompt.muted(try std.fmt.allocPrint(allocator, " ensure {s}/src/ + composer.json", .{rel})); + prompt.muted(" wire composer.json (repositories[] + require)"); + if (!opts.no_composer) prompt.muted(if (opts.offline) " COMPOSER_DISABLE_NETWORK=1 composer update --no-dev" else " composer update"); + prompt.outro("Dry run — nothing changed"); + return 0; + } + + // 1. git submodule add + init (clones the remote into modules/). + prompt.ok("Adding submodule…"); + if (!(try runGit(allocator, io, env, root, &.{ "submodule", "add", opts.url, rel }))) { + prompt.err("git submodule add failed — is the remote reachable and empty/matching?"); + return 1; + } + _ = try runGit(allocator, io, env, root, &.{ "submodule", "update", "--init", "--recursive" }); + + // 2. Bootstrap structure — only what the cloned repo does not already have. + const cwd = Dir.cwd(); + const srcDir = try std.fmt.allocPrint(allocator, "{s}/src", .{modulePath}); + if (!util.dirExists(cwd, io, srcDir)) { + try cwd.createDirPath(io, srcDir); + try cwd.writeFile(io, .{ .sub_path = try std.fmt.allocPrint(allocator, "{s}/.gitkeep", .{srcDir}), .data = "" }); + prompt.muted(" created src/"); + } + const composerPath = try std.fmt.allocPrint(allocator, "{s}/composer.json", .{modulePath}); + if (!util.fileExists(io, composerPath)) { + const desc = if (opts.desc.len > 0) opts.desc else try std.fmt.allocPrint(allocator, "{s} — a first-party PhpServicePlatform module.", .{pkg}); + try cwd.writeFile(io, .{ .sub_path = composerPath, .data = try moduleComposer(allocator, pkg, ns, desc) }); + prompt.muted(" created composer.json"); + } else { + prompt.muted(" composer.json already present in the repo — kept as is"); + } + + // 3. Wire the root composer.json (path repo + require "*"). + switch (try wireComposer(allocator, io, root, pkg, folder)) { + .wired => prompt.ok("Wired into composer.json (repositories[] + require)"), + .already => prompt.muted("composer.json already references this module — left as is."), + .failed => prompt.warn("Could not edit composer.json automatically — add the path repo + require by hand."), + } + + // 4. composer update. + if (!opts.no_composer) { + try composerUpdate(allocator, io, env, root, opts.offline); + } else { + prompt.muted("Skipped composer update (--no-composer)."); + } + + prompt.note("Changes are STAGED, not committed — review with `git status` and commit yourself."); + prompt.outro(try std.fmt.allocPrint(allocator, "Module {s} added", .{folder})); + return 0; +} + +// ── remove (deinit + rm + unwire) ───────────────────────────────────────────── + +fn removeModule(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, nameArg: []const u8, opts: Opts) !u8 { + const folder = try kebab(allocator, nameArg); + const rel = try std.fmt.allocPrint(allocator, "modules/{s}", .{folder}); + const modulePath = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ root, rel }); + + prompt.intro("hkm module remove"); + prompt.ok(try std.fmt.allocPrint(allocator, "kernel {s}", .{root})); + prompt.muted(try std.fmt.allocPrint(allocator, "path {s}", .{rel})); + + const is_submodule = try gitmodulesHasPath(allocator, io, root, rel); + if (!is_submodule and !util.dirExists(Dir.cwd(), io, modulePath)) { + prompt.err(try std.fmt.allocPrint(allocator, "{s} is neither a registered submodule nor present on disk.", .{rel})); + return 1; + } + + if (opts.dry_run) { + prompt.section("Would run"); + prompt.muted(try std.fmt.allocPrint(allocator, " git submodule deinit -f {s}", .{rel})); + prompt.muted(try std.fmt.allocPrint(allocator, " git rm -f {s}", .{rel})); + prompt.muted(try std.fmt.allocPrint(allocator, " rm -rf .git/modules/{s} and {s}", .{ rel, rel })); + prompt.muted(try std.fmt.allocPrint(allocator, " git config -f .gitmodules --remove-section submodule.{s}", .{rel})); + prompt.muted(" unwire composer.json (repositories[] + require)"); + prompt.outro("Dry run — nothing changed"); + return 0; + } + + const label = try std.fmt.allocPrint(allocator, "Permanently remove submodule {s}?", .{rel}); + if (!prompt.confirm(io, label, false)) { + prompt.outro("Cancelled — nothing removed"); + return 0; + } + + // Mirror module.sh remove — each step best-effort (|| true), then clean up. + _ = try runGit(allocator, io, env, root, &.{ "submodule", "deinit", "-f", rel }); + _ = try runGit(allocator, io, env, root, &.{ "rm", "-f", rel }); + Dir.cwd().deleteTree(io, try std.fmt.allocPrint(allocator, "{s}/.git/modules/{s}", .{ root, rel })) catch {}; + Dir.cwd().deleteTree(io, modulePath) catch {}; + // `git rm` already strips the .gitmodules section on modern git, so this is a + // best-effort fallback for older git — silence its "no such section" noise. + _ = try runGitQuiet(allocator, io, env, root, &.{ "config", "-f", ".gitmodules", "--remove-section", try std.fmt.allocPrint(allocator, "submodule.{s}", .{rel}) }); + _ = try runGit(allocator, io, env, root, &.{ "add", ".gitmodules" }); + prompt.ok(try std.fmt.allocPrint(allocator, "Removed submodule {s}", .{rel})); + + // Unwire the root composer.json too (the script left this to composer update). + if (try unwireComposer(allocator, io, root, folder)) + prompt.ok("Unwired from composer.json (repositories[] + require)") + else + prompt.muted("composer.json had no reference to this module."); + + prompt.note("Changes are STAGED, not committed — review with `git status` and commit yourself."); + prompt.note("Run `composer update` to refresh the autoloader."); + prompt.outro("Module removed"); + return 0; +} + +// ── git / composer runners ──────────────────────────────────────────────────── + +/// Run `git ` with `cwd` = root, inheriting stdio. Returns true on exit 0. +/// A missing git binary is reported and returns false. +fn runGit(allocator: std.mem.Allocator, io: Io, env: *EnvMap, cwd: []const u8, args: []const []const u8) !bool { + return runGitImpl(allocator, io, env, cwd, args, false); +} + +/// Like `runGit` but discards stdout/stderr — for best-effort calls whose +/// failure is expected and whose noise would alarm the user. +fn runGitQuiet(allocator: std.mem.Allocator, io: Io, env: *EnvMap, cwd: []const u8, args: []const []const u8) !bool { + return runGitImpl(allocator, io, env, cwd, args, true); +} + +fn runGitImpl(allocator: std.mem.Allocator, io: Io, env: *EnvMap, cwd: []const u8, args: []const []const u8, quiet: bool) !bool { + const git = env.get("HKM_GIT_BIN") orelse "git"; + var argv: std.ArrayList([]const u8) = .empty; + try argv.append(allocator, git); + try argv.appendSlice(allocator, args); + + var child = std.process.spawn(io, .{ + .argv = argv.items, + .environ_map = env, + .cwd = .{ .path = cwd }, + .stdin = .inherit, + .stdout = if (quiet) .ignore else .inherit, + .stderr = if (quiet) .ignore else .inherit, + }) catch { + if (!quiet) prompt.warn("git not found — install it or set HKM_GIT_BIN."); + return false; + }; + const term = child.wait(io) catch return false; + return switch (term) { + .exited => |code| code == 0, + else => false, + }; +} + +fn composerUpdate(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, offline: bool) !void { + const composer = env.get("HKM_COMPOSER_BIN") orelse "composer"; + if (offline) { + try env.put("COMPOSER_DISABLE_NETWORK", "1"); + prompt.ok("Running composer update --no-dev (offline)…"); + } else { + prompt.ok("Running composer update…"); + } + + const argv: []const []const u8 = if (offline) + &.{ composer, "update", "--no-dev" } + else + &.{ composer, "update" }; + + var child = std.process.spawn(io, .{ + .argv = argv, + .environ_map = env, + .cwd = .{ .path = root }, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch { + prompt.warn("composer not found — skipped. Run `composer update` yourself (or set HKM_COMPOSER_BIN)."); + return; + }; + const term = child.wait(io) catch { + prompt.warn("composer update did not complete cleanly."); + return; + }; + switch (term) { + .exited => |code| { + if (code == 0) prompt.ok("Dependencies updated") else prompt.warn(try std.fmt.allocPrint(allocator, "composer update exited with code {d}.", .{code})); + }, + else => prompt.warn("composer update was terminated."), + } +} + +// ── .gitmodules probe ───────────────────────────────────────────────────────── + +/// True when `.gitmodules` already declares a submodule at `rel` (modules/). +fn gitmodulesHasPath(allocator: std.mem.Allocator, io: Io, root: []const u8, rel: []const u8) !bool { + const path = try util.join(allocator, root, ".gitmodules"); + const src = Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return false; + const needle = try std.fmt.allocPrint(allocator, "path = {s}", .{rel}); + if (std.mem.indexOf(u8, src, needle) != null) return true; + // Also match a section header form `[submodule "modules/"]`. + const hdr = try std.fmt.allocPrint(allocator, "\"{s}\"", .{rel}); + return std.mem.indexOf(u8, src, hdr) != null; +} + +// ── composer.json wiring ────────────────────────────────────────────────────── + +const WireResult = enum { wired, already, failed }; + +/// Insert a `{ type: path, url: modules/ }` repository and a +/// `"": "*"` require line into the root composer.json via targeted string +/// insertion (preserves the file's existing formatting). Idempotent. +fn wireComposer(allocator: std.mem.Allocator, io: Io, root: []const u8, pkg: []const u8, folder: []const u8) !WireResult { + const path = try util.join(allocator, root, "composer.json"); + const src = Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024)) catch return .failed; + + const url_needle = try std.fmt.allocPrint(allocator, "modules/{s}\"", .{folder}); + if (std.mem.indexOf(u8, src, url_needle) != null) return .already; + + var out: []const u8 = src; + + // 1. require — insert as the first entry right after `"require": {`. + const req_marker = "\"require\": {"; + if (std.mem.indexOf(u8, out, req_marker)) |ri| { + const after = ri + req_marker.len; + const line = try std.fmt.allocPrint(allocator, "\n \"{s}\": \"*\",", .{pkg}); + out = try spliceAt(allocator, out, after, line); + } else return .failed; + + // 2. repositories — insert a new path object right after `"repositories": [`. + const repo_marker = "\"repositories\": ["; + if (std.mem.indexOf(u8, out, repo_marker)) |pi| { + const after = pi + repo_marker.len; + const block = try std.fmt.allocPrint( + allocator, + "\n {{\n \"type\": \"path\",\n \"url\": \"modules/{s}\"\n }},", + .{folder}, + ); + out = try spliceAt(allocator, out, after, block); + } else return .failed; + + Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out }) catch return .failed; + return .wired; +} + +/// Remove the `require` line and the `repositories[]` path object for +/// `modules/` from the root composer.json. Returns true when anything +/// was removed. Line-oriented so it stays robust against surrounding formatting. +fn unwireComposer(allocator: std.mem.Allocator, io: Io, root: []const u8, folder: []const u8) !bool { + const path = try util.join(allocator, root, "composer.json"); + const src = Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024)) catch return false; + + const req_needle = try std.fmt.allocPrint(allocator, "/{s}\":", .{folder}); // "/": + const url_needle = try std.fmt.allocPrint(allocator, "\"url\": \"modules/{s}\"", .{folder}); + + var out: std.ArrayList(u8) = .empty; + var changed = false; + // Buffer lines so we can drop a whole `{ … }` repo object when its url matches. + var lines: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, src, '\n'); + while (it.next()) |l| try lines.append(allocator, l); + + var skip_until_brace = false; + for (lines.items, 0..) |line, li| { + if (skip_until_brace) { + // We are inside a repo object being dropped; end at its closing `},`. + const t = std.mem.trim(u8, line, " \t\r"); + if (std.mem.eql(u8, t, "},") or std.mem.eql(u8, t, "}")) skip_until_brace = false; + changed = true; + continue; + } + // Drop the require line for this package. + if (std.mem.indexOf(u8, line, req_needle) != null and std.mem.indexOf(u8, line, "\": \"") != null) { + changed = true; + continue; + } + // A `{` opening a repo object whose url line is this module → drop the block. + const t = std.mem.trim(u8, line, " \t\r"); + if (std.mem.eql(u8, t, "{") and repoObjectMatches(lines.items, li, url_needle)) { + skip_until_brace = true; + changed = true; + continue; + } + try out.appendSlice(allocator, line); + if (li + 1 < lines.items.len) try out.append(allocator, '\n'); + } + + if (!changed) return false; + Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out.items }) catch return false; + return true; +} + +/// Does the repo object opening at `open` contain `url_needle` before it closes? +fn repoObjectMatches(lines: []const []const u8, open: usize, url_needle: []const u8) bool { + var j = open + 1; + while (j < lines.len) : (j += 1) { + const t = std.mem.trim(u8, lines[j], " \t\r"); + if (std.mem.eql(u8, t, "},") or std.mem.eql(u8, t, "}")) return false; + if (std.mem.indexOf(u8, lines[j], url_needle) != null) return true; + } + return false; +} + +/// Return `src` with `insert` spliced in at byte offset `at`. +fn spliceAt(allocator: std.mem.Allocator, src: []const u8, at: usize, insert: []const u8) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + try buf.appendSlice(allocator, src[0..at]); + try buf.appendSlice(allocator, insert); + try buf.appendSlice(allocator, src[at..]); + return buf.toOwnedSlice(allocator); +} + +// ── module composer.json template ───────────────────────────────────────────── + +fn moduleComposer(allocator: std.mem.Allocator, pkg: []const u8, ns: []const u8, desc: []const u8) ![]const u8 { + const ns_json = try replaceAll(allocator, ns, "\\", "\\\\"); + return std.fmt.allocPrint(allocator, + \\{{ + \\ "name": "{s}", + \\ "description": "{s}", + \\ "type": "library", + \\ "license": "MIT", + \\ "autoload": {{ + \\ "psr-4": {{ + \\ "{s}\\": "src/" + \\ }} + \\ }}, + \\ "require": {{ + \\ "php": "^8.2" + \\ }}, + \\ "minimum-stability": "dev", + \\ "prefer-stable": true + \\}} + \\ + , .{ pkg, desc, ns_json }); +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// kebab-case a name: `BillingEngine` / `billing_engine` / `Billing Engine` +/// → `billing-engine`. A hyphen already present is preserved. +fn kebab(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + var prev_sep = true; // suppress a leading separator + for (name, 0..) |c, idx| { + if (c == '-' or c == '_' or c == ' ') { + if (!prev_sep and out.items.len > 0) try out.append(allocator, '-'); + prev_sep = true; + continue; + } + if (std.ascii.isUpper(c)) { + if (idx > 0 and !prev_sep) try out.append(allocator, '-'); + try out.append(allocator, std.ascii.toLower(c)); + } else { + try out.append(allocator, c); + } + prev_sep = false; + } + return out.toOwnedSlice(allocator); +} + +fn replaceAll(allocator: std.mem.Allocator, input: []const u8, needle: []const u8, value: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + var i: usize = 0; + while (i < input.len) { + if (std.mem.startsWith(u8, input[i..], needle)) { + try out.appendSlice(allocator, value); + i += needle.len; + } else { + try out.append(allocator, input[i]); + i += 1; + } + } + return out.toOwnedSlice(allocator); +} + +// ── help ────────────────────────────────────────────────────────────────────── + +fn printHelp() void { + prompt.intro("hkm module — first-party kernel packages (modules/ submodules)"); + prompt.section("Usage"); + prompt.item("hkm module", "list the kernel modules"); + prompt.item("hkm module add [org]", "add a submodule + wire composer.json + update"); + prompt.item("hkm module remove ", "deinit + remove the submodule + unwire composer.json"); + prompt.blank(); + prompt.section("Options"); + prompt.item("--org, --vendor ", "composer vendor (default: alfacode-team; or the 3rd arg)"); + prompt.item("--namespace ", "PSR-4 root (default: \\)"); + prompt.item("--desc ", "description for a generated composer.json"); + prompt.item("--offline", "composer update with the network disabled (--no-dev)"); + prompt.item("--no-composer", "skip the composer update step"); + prompt.item("--dry-run, -n", "preview the git/composer actions without running them"); + prompt.item("--help, -h", "show this help"); + prompt.blank(); + prompt.section("Notes"); + prompt.item("submodule", "add runs `git submodule add modules/` — the remote must exist"); + prompt.item("scope", "kernel modules are reusable libraries (NOT plugins — see `hkm plugins`)"); + prompt.item("location", "run from inside the kernel monorepo (dir with composer.json + modules/)"); + prompt.item("commits", "git changes are STAGED, never committed — review + commit yourself"); + prompt.item("aliases", "add=create/new/scaffold · remove=delete/del/rm · list=ls"); + prompt.outro("Mirrors the module.sh add/remove flow, git-submodule aware"); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index 5bcfa49..82373d1 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -5,6 +5,7 @@ const run_cmd = @import("commands/run.zig"); const list_cmd = @import("commands/list.zig"); const discover_cmd = @import("commands/discover.zig"); const plugins_cmd = @import("commands/plugins.zig"); +const module_cmd = @import("commands/module.zig"); const ui_cmd = @import("commands/ui.zig"); const cli_cmd = @import("commands/cli.zig"); const doctor_cmd = @import("commands/doctor.zig"); @@ -26,6 +27,7 @@ fn printHelp() void { prompt.item("hkm list", "list registered projects (alias: ls)"); prompt.item("hkm discover [root]", "find projects on disk and register them (alias: scan)"); prompt.item("hkm plugins [path|name]", "analyse a project's enabled plugins/modules"); + prompt.item("hkm module [create|delete]", "scaffold a first-party kernel package (modules/)"); prompt.item("hkm ui [sync|list|link|clean]", "federate enabled plugins' UIs into the frontend"); prompt.item("hkm update ", "refresh a project's kernel registry entry"); prompt.item("hkm upgrade [--check]", "check for / apply a kernel update"); @@ -181,6 +183,10 @@ pub fn main(init: std.process.Init.Minimal) !void { const code = try discover_cmd.run(allocator, io, &env_map, args); std.process.exit(code); } + if (std.mem.eql(u8, cmd, "module")) { + const code = try module_cmd.run(allocator, io, &env_map, args); + std.process.exit(code); + } if (std.mem.eql(u8, cmd, "plugins") or std.mem.eql(u8, cmd, "modules")) { const code = try plugins_cmd.run(allocator, io, &env_map, args); std.process.exit(code);