From 3c4d117cba8717cf3e947a1d37ff3063ff503ffb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:00:01 +0800 Subject: [PATCH 01/16] Move the Verdaccio registry into legacy/ The v2 Registry Service takes over the repository root. The Verdaccio build, compose file, config and plugin submodules keep working from legacy/; the deploy workflow now runs the bake file from that directory. --- .github/workflows/cd.yml | 1 + .gitignore | 8 ++++---- .gitmodules | 8 ++++---- Dockerfile.minio => legacy/Dockerfile.minio | 0 Dockerfile.verdaccio => legacy/Dockerfile.verdaccio | 0 legacy/LEGACY.md | 13 +++++++++++++ README.md => legacy/README.md | 0 {config => legacy/config}/config.yaml | 0 create-bucket.sh => legacy/create-bucket.sh | 0 docker-bake.hcl => legacy/docker-bake.hcl | 0 docker-compose.yml => legacy/docker-compose.yml | 0 install-plugins.sh => legacy/install-plugins.sh | 0 {plugins => legacy/plugins}/install.sh | 0 {plugins => legacy/plugins}/rebuild.sh | 0 .../plugins}/verdaccio-composer-middleware | 0 .../plugins}/verdaccio-fleetbase-auth | 0 .../verdaccio-fleetbase-extensions-middleware | 0 .../plugins}/verdaccio-fleetbase-s3-storage | 0 18 files changed, 22 insertions(+), 8 deletions(-) rename Dockerfile.minio => legacy/Dockerfile.minio (100%) rename Dockerfile.verdaccio => legacy/Dockerfile.verdaccio (100%) create mode 100644 legacy/LEGACY.md rename README.md => legacy/README.md (100%) rename {config => legacy/config}/config.yaml (100%) rename create-bucket.sh => legacy/create-bucket.sh (100%) rename docker-bake.hcl => legacy/docker-bake.hcl (100%) rename docker-compose.yml => legacy/docker-compose.yml (100%) rename install-plugins.sh => legacy/install-plugins.sh (100%) rename {plugins => legacy/plugins}/install.sh (100%) rename {plugins => legacy/plugins}/rebuild.sh (100%) rename {plugins => legacy/plugins}/verdaccio-composer-middleware (100%) rename {plugins => legacy/plugins}/verdaccio-fleetbase-auth (100%) rename {plugins => legacy/plugins}/verdaccio-fleetbase-extensions-middleware (100%) rename {plugins => legacy/plugins}/verdaccio-fleetbase-s3-storage (100%) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 76f252e..7ae0726 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -52,6 +52,7 @@ jobs: VERSION: ${{ env.VERSION }} CACHE: type=gha with: + workdir: ./legacy push: true files: | ./docker-bake.hcl diff --git a/.gitignore b/.gitignore index 1b1fdc1..4387827 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ docker-compose.override.yml npm-debug.log yarn-error.log .env -*/.npmrc +legacy/*/.npmrc storage/* -config/htpasswd -plugins/*/node_modules -minio/ \ No newline at end of file +legacy/config/htpasswd +legacy/plugins/*/node_modules +legacy/minio/ diff --git a/.gitmodules b/.gitmodules index 4395015..850185d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,12 @@ [submodule "plugins/verdaccio-composer-middleware"] - path = plugins/verdaccio-composer-middleware + path = legacy/plugins/verdaccio-composer-middleware url = git@github.com:fleetbase/verdaccio-composer-middleware.git [submodule "plugins/verdaccio-fleetbase-extensions-middleware"] - path = plugins/verdaccio-fleetbase-extensions-middleware + path = legacy/plugins/verdaccio-fleetbase-extensions-middleware url = git@github.com:fleetbase/verdaccio-fleetbase-extensions-middleware.git [submodule "plugins/verdaccio-fleetbase-s3-storage"] - path = plugins/verdaccio-fleetbase-s3-storage + path = legacy/plugins/verdaccio-fleetbase-s3-storage url = git@github.com:fleetbase/verdaccio-fleetbase-s3-storage.git [submodule "plugins/verdaccio-fleetbase-auth"] - path = plugins/verdaccio-fleetbase-auth + path = legacy/plugins/verdaccio-fleetbase-auth url = git@github.com:fleetbase/verdaccio-fleetbase-auth.git diff --git a/Dockerfile.minio b/legacy/Dockerfile.minio similarity index 100% rename from Dockerfile.minio rename to legacy/Dockerfile.minio diff --git a/Dockerfile.verdaccio b/legacy/Dockerfile.verdaccio similarity index 100% rename from Dockerfile.verdaccio rename to legacy/Dockerfile.verdaccio diff --git a/legacy/LEGACY.md b/legacy/LEGACY.md new file mode 100644 index 0000000..3f48c57 --- /dev/null +++ b/legacy/LEGACY.md @@ -0,0 +1,13 @@ +# Legacy Verdaccio registry + +This directory holds the Verdaccio-based registry (v1) exactly as it ran before the +Registry Service rewrite. It stays deployable until the Phase 2 publishing cutover. + +Run it locally from the repository root: + + docker compose -f legacy/docker-compose.yml up --build + +Build and deploy use `legacy/docker-bake.hcl`; the `deploy/*` workflow sets +`workdir: ./legacy` so every path in the bake file and Dockerfiles stays relative +to this directory. The four Verdaccio plugins remain git submodules under +`legacy/plugins/`. diff --git a/README.md b/legacy/README.md similarity index 100% rename from README.md rename to legacy/README.md diff --git a/config/config.yaml b/legacy/config/config.yaml similarity index 100% rename from config/config.yaml rename to legacy/config/config.yaml diff --git a/create-bucket.sh b/legacy/create-bucket.sh similarity index 100% rename from create-bucket.sh rename to legacy/create-bucket.sh diff --git a/docker-bake.hcl b/legacy/docker-bake.hcl similarity index 100% rename from docker-bake.hcl rename to legacy/docker-bake.hcl diff --git a/docker-compose.yml b/legacy/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to legacy/docker-compose.yml diff --git a/install-plugins.sh b/legacy/install-plugins.sh similarity index 100% rename from install-plugins.sh rename to legacy/install-plugins.sh diff --git a/plugins/install.sh b/legacy/plugins/install.sh similarity index 100% rename from plugins/install.sh rename to legacy/plugins/install.sh diff --git a/plugins/rebuild.sh b/legacy/plugins/rebuild.sh similarity index 100% rename from plugins/rebuild.sh rename to legacy/plugins/rebuild.sh diff --git a/plugins/verdaccio-composer-middleware b/legacy/plugins/verdaccio-composer-middleware similarity index 100% rename from plugins/verdaccio-composer-middleware rename to legacy/plugins/verdaccio-composer-middleware diff --git a/plugins/verdaccio-fleetbase-auth b/legacy/plugins/verdaccio-fleetbase-auth similarity index 100% rename from plugins/verdaccio-fleetbase-auth rename to legacy/plugins/verdaccio-fleetbase-auth diff --git a/plugins/verdaccio-fleetbase-extensions-middleware b/legacy/plugins/verdaccio-fleetbase-extensions-middleware similarity index 100% rename from plugins/verdaccio-fleetbase-extensions-middleware rename to legacy/plugins/verdaccio-fleetbase-extensions-middleware diff --git a/plugins/verdaccio-fleetbase-s3-storage b/legacy/plugins/verdaccio-fleetbase-s3-storage similarity index 100% rename from plugins/verdaccio-fleetbase-s3-storage rename to legacy/plugins/verdaccio-fleetbase-s3-storage From a79fd4d719aaa814537841f6b56eb175f2029611 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:28:07 +0800 Subject: [PATCH 02/16] Scaffold the Registry Service as a Laravel 12 app Laravel 12 skeleton at the repository root (Laravel 11 is blocked by Composer's security-advisory policy; fleetbase/core-api cannot be a dependency because it pins illuminate ^9|^10 and sanctum 3.2.4 and auto-loads conflicting migrations). The core-api conventions the plan relies on are ported into the app with the same names and behaviour: Model (UUID keys, findById), HasUuid, HasPublicId, Money cast, Filter, FleetbaseResource and FleetbaseResourceCollection with the flat meta pagination block. --- .editorconfig | 18 + .env.example | 65 + .gitattributes | 11 + .gitignore | 34 +- README.md | 59 + app/Casts/Money.php | 45 + app/Http/Controllers/Controller.php | 8 + app/Http/Filter/Filter.php | 107 + app/Http/Resources/FleetbaseResource.php | 71 + .../Resources/FleetbaseResourceCollection.php | 75 + .../FleetbasePaginatedResourceResponse.php | 34 + app/Models/Model.php | 81 + app/Providers/AppServiceProvider.php | 24 + app/Support/Http.php | 39 + app/Support/Utils.php | 77 + app/Traits/Filterable.php | 26 + app/Traits/HasPublicId.php | 58 + app/Traits/HasUuid.php | 44 + artisan | 18 + bootstrap/app.php | 18 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 95 + composer.lock | 9706 +++++++++++++++++ config/app.php | 126 + config/auth.php | 117 + config/cache.php | 117 + config/database.php | 184 + config/filesystems.php | 80 + config/logging.php | 132 + config/mail.php | 118 + config/queue.php | 129 + config/services.php | 38 + config/session.php | 217 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + database/seeders/DatabaseSeeder.php | 15 + phpunit.xml | 36 + public/.htaccess | 25 + public/favicon.ico | 0 public/index.php | 20 + public/robots.txt | 2 + resources/views/welcome.blade.php | 277 + routes/console.php | 8 + routes/web.php | 7 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/ExampleTest.php | 19 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + 58 files changed, 12527 insertions(+), 10 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 README.md create mode 100644 app/Casts/Money.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Filter/Filter.php create mode 100644 app/Http/Resources/FleetbaseResource.php create mode 100644 app/Http/Resources/FleetbaseResourceCollection.php create mode 100644 app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php create mode 100644 app/Models/Model.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Support/Http.php create mode 100644 app/Support/Utils.php create mode 100644 app/Traits/Filterable.php create mode 100644 app/Traits/HasPublicId.php create mode 100644 app/Traits/HasUuid.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore index 4387827..8c57825 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,25 @@ -.npmrc -.pnpm-store -docker-compose.override.yml -npm-debug.log -yarn-error.log +*.log +.DS_Store .env -legacy/*/.npmrc -storage/* -legacy/config/htpasswd -legacy/plugins/*/node_modules -legacy/minio/ +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db +/database/*.sqlite* diff --git a/README.md b/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Casts/Money.php b/app/Casts/Money.php new file mode 100644 index 0000000..a9f7a71 --- /dev/null +++ b/app/Casts/Money.php @@ -0,0 +1,45 @@ +Between()`. Ported from `Fleetbase\Http\Filter\Filter`. + */ +abstract class Filter +{ + protected Request $request; + + protected Builder $builder; + + protected array $methodCache = []; + + protected static array $skipParams = [ + 'limit', 'offset', 'page', 'per_page', 'sort', 'order', 'with', 'expand', 'without', 'with_count', 'without_relations', + ]; + + protected static array $rangePatterns = ['after:before', 'from:to', 'min:max', 'start:end', 'gte:lte', 'greater:less']; + + public function __construct(Request $request) + { + $this->request = $request; + } + + public function apply(Builder $builder): Builder + { + $this->builder = $builder; + + $filterParams = array_diff_key($this->request->all(), array_flip(static::$skipParams)); + + foreach ($filterParams as $name => $value) { + $this->applyFilter((string) $name, $value); + } + + $this->applyRangeFilters(); + + if (Http::isInternalRequest($this->request) && method_exists($this, 'queryForInternal')) { + $this->queryForInternal(); + } + + if (Http::isPublicRequest($this->request) && method_exists($this, 'queryForPublic')) { + $this->queryForPublic(); + } + + return $this->builder; + } + + private function applyFilter(string $name, $value): void + { + if ($value === null || $value === '' || $value === []) { + return; + } + + if (! array_key_exists($name, $this->methodCache)) { + $this->methodCache[$name] = null; + foreach ([$name, Str::camel($name)] as $methodName) { + if (method_exists($this, $methodName) && ! in_array($methodName, ['apply', 'applyFilter', 'applyRangeFilters'], true)) { + $this->methodCache[$name] = $methodName; + break; + } + } + } + + if ($this->methodCache[$name]) { + $this->{$this->methodCache[$name]}($value); + } + } + + private function applyRangeFilters(): void + { + $prepositions = Arr::flatten(array_map(fn ($range) => explode(':', $range), static::$rangePatterns)); + + $callbacks = collect($this->request->all()) + ->keys() + ->filter(fn ($param) => Str::endsWith($param, array_map(fn ($p) => '_'.$p, $prepositions))) + ->mapWithKeys(function ($param) { + $preposition = Arr::last(explode('_', $param)); + $column = Str::beforeLast($param, '_'.$preposition); + + if ($column === '') { + return []; + } + + $range = Arr::first(static::$rangePatterns, fn ($range) => in_array($preposition, explode(':', $range), true)); + $values = $this->request->all(array_map(fn ($p) => $column.'_'.$p, explode(':', $range))); + + return [Str::camel($column).'Between' => array_values($values)]; + }) + ->toArray(); + + foreach ($callbacks as $method => $values) { + if (method_exists($this, $method)) { + $this->{$method}(...$values); + } + } + } +} diff --git a/app/Http/Resources/FleetbaseResource.php b/app/Http/Resources/FleetbaseResource.php new file mode 100644 index 0000000..adae9fc --- /dev/null +++ b/app/Http/Resources/FleetbaseResource.php @@ -0,0 +1,71 @@ +filterExcluded(parent::toArray($request)); + } + + public static function collection($resource) + { + return tap(new FleetbaseResourceCollection($resource, static::class), function ($collection) { + if (property_exists(static::class, 'preserveKeys')) { + $collection->preserveKeys = (new static([]))->preserveKeys === true; + } + }); + } + + public function isEmpty(): bool + { + return is_null($this->resource) || is_null($this->resource->resource ?? $this->resource); + } + + /** + * Every `*_uuid` attribute, included only on internal requests. + */ + public function getInternalIds(): array + { + $internalIds = []; + + foreach ($this->resource?->getAttributes() ?? [] as $key => $value) { + if (Str::endsWith($key, '_uuid')) { + $internalIds[$key] = $this->when(Http::isInternalRequest(), $value); + } + } + + return $internalIds; + } + + public function without(array|string $keys): static + { + $clone = clone $this; + $clone->excluded = array_merge($this->excluded, (array) $keys); + + return $clone; + } + + protected function filterExcluded(array $data): array + { + foreach ($this->excluded as $key) { + Arr::forget($data, $key); + } + + return $data; + } +} diff --git a/app/Http/Resources/FleetbaseResourceCollection.php b/app/Http/Resources/FleetbaseResourceCollection.php new file mode 100644 index 0000000..29d2c82 --- /dev/null +++ b/app/Http/Resources/FleetbaseResourceCollection.php @@ -0,0 +1,75 @@ +collects = $collects; + + parent::__construct($resource); + } + + public function without(array|string $keys): static + { + $clone = clone $this; + $clone->excluded = array_values(array_unique(array_merge($this->excluded, (array) $keys))); + + return $clone; + } + + public function toArray($request): array + { + return $this->collection->map(function ($item) use ($request) { + if ($item instanceof FleetbaseResource) { + return $this->applyExclusions($item->without($this->excluded)->resolve($request)); + } + + if ($item instanceof JsonResource) { + return $this->applyExclusions($item->resolve($request)); + } + + if (is_string($this->collects) && class_exists($this->collects)) { + $resource = new $this->collects($item); + + return $resource instanceof FleetbaseResource + ? $resource->without($this->excluded)->resolve($request) + : $this->applyExclusions($resource->resolve($request)); + } + + return $this->applyExclusions(is_array($item) ? $item : (array) $item); + })->all(); + } + + public function toResponse($request) + { + return $this->resource instanceof Paginator + ? (new FleetbasePaginatedResourceResponse($this))->toResponse($request) + : parent::toResponse($request); + } + + protected function applyExclusions(array $array): array + { + foreach ($this->excluded as $key) { + Arr::forget($array, $key); + } + + return $array; + } +} diff --git a/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php b/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php new file mode 100644 index 0000000..23a2100 --- /dev/null +++ b/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php @@ -0,0 +1,34 @@ +resource->resource->toArray(); + + return ['meta' => $this->meta($paginated)]; + } + + protected function meta($paginated) + { + $metaData = parent::meta($paginated); + $startedAt = defined('LARAVEL_START') ? LARAVEL_START : microtime(true); + + return [ + 'total' => $metaData['total'] ?? null, + 'per_page' => $metaData['per_page'] ?? null, + 'current_page' => $metaData['current_page'] ?? null, + 'last_page' => $metaData['last_page'] ?? null, + 'from' => $metaData['from'] ?? null, + 'to' => $metaData['to'] ?? null, + 'time' => (int) round((microtime(true) - $startedAt) * 1000), + ]; + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..87200c8 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,81 @@ +save(); + + return $this; + } + + public function getResource(): ?string + { + return $this->httpResource ?? $this->resource ?? null; + } + + public function getFilter(): ?string + { + return $this->httpFilter ?? $this->filter ?? null; + } + + /** + * Find a model by UUID or public id. + */ + public static function findById(self|string|null $identifier, array $with = [], array $columns = ['*'], bool $withTrashed = false): ?static + { + if ($identifier instanceof self) { + return $identifier; + } + + if ($identifier === null || $identifier === '') { + return null; + } + + $query = static::query()->with($with)->select($columns); + + if ($withTrashed && in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { + $query->withTrashed(); + } + + $column = Str::isUuid($identifier) ? static::UUID_COLUMN : static::PUBLIC_ID_COLUMN; + + return $query->where($column, $identifier)->first(); + } + + public static function findByIdOrFail(self|string|null $identifier, array $with = [], array $columns = ['*'], bool $withTrashed = false): static + { + $result = static::findById($identifier, $with, $columns, $withTrashed); + + if ($result === null) { + throw (new ModelNotFoundException)->setModel(static::class, [$identifier]); + } + + return $result; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +route(); + + if ($route === null) { + return false; + } + + return Str::startsWith(ltrim((string) $route->uri(), '/'), 'int/'); + } + + public static function isPublicRequest(?Request $request = null): bool + { + $request = $request ?? request(); + $route = $request->route(); + + if ($route === null) { + return false; + } + + return Str::startsWith(ltrim((string) $route->uri(), '/'), 'v1/'); + } +} diff --git a/app/Support/Utils.php b/app/Support/Utils.php new file mode 100644 index 0000000..d5b6cff --- /dev/null +++ b/app/Support/Utils.php @@ -0,0 +1,77 @@ +getFilter(); + if (! $filterClass || ! is_subclass_of($filterClass, Filter::class)) { + return $query; + } + + /** @var Filter $filter */ + $filter = new $filterClass($request ?? request()); + + return $filter->apply($query); + } +} diff --git a/app/Traits/HasPublicId.php b/app/Traits/HasPublicId.php new file mode 100644 index 0000000..3379abb --- /dev/null +++ b/app/Traits/HasPublicId.php @@ -0,0 +1,58 @@ +public_id = static::generatePublicId($model->publicIdType ?? null); + }); + } + + public static function getPublicId(): string + { + return Str::lower(Str::random(10)); + } + + public static function generatePublicId(?string $type = null, int $attempt = 0): string + { + if ($attempt > 10) { + throw new \RuntimeException('Failed to generate unique public_id after 10 attempts.'); + } + + $type ??= static::getPublicIdType() ?? strtolower(Utils::classBasename(static::class)); + $publicId = $type.'_'.static::getPublicId(); + + $query = (new static)->newQuery()->where('public_id', $publicId); + if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { + $query->withTrashed(); + } + + if ($query->exists()) { + usleep((2 ** $attempt) * 1000); + + return static::generatePublicId($type, $attempt + 1); + } + + return $publicId; + } + + public static function getPublicIdType(): ?string + { + return (new static)->publicIdType ?? null; + } +} diff --git a/app/Traits/HasUuid.php b/app/Traits/HasUuid.php new file mode 100644 index 0000000..e58ab12 --- /dev/null +++ b/app/Traits/HasUuid.php @@ -0,0 +1,44 @@ +uuid)) { + return; + } + + if (isset($model->uuidColumn)) { + foreach ((array) $model->uuidColumn as $column) { + $model->{$column} = static::generateUuid($column); + } + + return; + } + + $model->uuid = static::generateUuid(); + }); + } + + public static function generateUuid(string $column = 'uuid'): string + { + $uuid = (string) Str::uuid(); + $query = (new static)->newQuery()->where($column, $uuid); + + if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { + $query->withTrashed(); + } + + return $query->exists() ? static::generateUuid($column) : $uuid; + } +} diff --git a/artisan b/artisan new file mode 100755 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..c183276 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.401.4", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "b5941e2da9fb860f06ad5046d96219c4288ab6b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b5941e2da9fb860f06ad5046d96219c4288ab6b8", + "reference": "b5941e2da9fb860f06ad5046d96219c4288ab6b8", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "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.401.4" + }, + "time": "2026-10-20T18:15:27+00:00" + }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "4189372ffeff09bf14f731f67a9de90c7b413ebe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/4189372ffeff09bf14f731f67a9de90c7b413ebe", + "reference": "4189372ffeff09bf14f731f67a9de90c7b413ebe", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^11.5.43 || ^12.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2026-10-19T17:53:57+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.16.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ffb62ec787050ae4fa9449cdd6b80335db69bda2", + "reference": "ffb62ec787050ae4fa9449cdd6b80335db69bda2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.6", + "guzzlehttp/psr7": "^2.13.2", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.16.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-10-18T13:38:36+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "3d88c829bd5f13d13847b39067dbf09a60af2154" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/3d88c829bd5f13d13847b39067dbf09a60af2154", + "reference": "3d88c829bd5f13d13847b39067dbf09a60af2154", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.6.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-10-18T12:46:59+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "adee536f95101929f262b8c59e8a61472f347d58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/adee536f95101929f262b8c59e8a61472f347d58", + "reference": "adee536f95101929f262b8c59e8a61472f347d58", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-10-17T23:49:46+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.11", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:15:32+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.70.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "4a4554f4d81f4095c8ff1911ec8a58144b0ed71b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/4a4554f4d81f4095c8ff1911ec8a58144b0ed71b", + "reference": "4a4554f4d81f4095c8ff1911ec8a58144b0ed71b", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.15.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.2.13", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-13T13:24:10+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.26", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "source": "https://github.com/laravel/prompts/tree/v0.3.26" + }, + "time": "2026-10-09T14:45:13+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.4", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/40821b7606d8d946bac57dbd2290076c722e3a65", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-10-17T19:59:50+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" + }, + "time": "2026-08-08T16:19:23+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "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.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.9.0", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.15.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/217d38553209bdb44e01590d9e9a2e4df8354cce", + "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-time": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-18T18:27:53+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.7", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c1162897273bf9499ca57924c4688a19d82c9310" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c1162897273bf9499ca57924c4688a19d82c9310", + "reference": "c1162897273bf9499ca57924c4688a19d82c9310", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.6" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.7" + }, + "time": "2026-10-20T15:46:50+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.6", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/6bf24518accf32cf845bca305e0b92078700fd01", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.6" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.6" + }, + "time": "2026-10-20T17:07:56+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "07437f612dca89c1894c1db04491002b1e798f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/07437f612dca89c1894c1db04491002b1e798f03", + "reference": "07437f612dca89c1894c1db04491002b1e798f03", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T18:45:38+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-22T18:56:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-06T20:05:19+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-14T06:44:08+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-15T06:01:15+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-04T10:45:44+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-time", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-time.git", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-time/zipball/ac9f1ed802dd58f37e720388130c088434aa09fc", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Time\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Philippe Nyamagana Butera", + "email": "nyamsprod@gmail.com" + }, + { + "name": "Tim Düsterhus", + "email": "tim@tideways-gmbh.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Time API added to PHP 8.6", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "duration", + "polyfill", + "portable", + "shim", + "time" + ], + "support": { + "source": "https://github.com/symfony/polyfill-time/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T13:08:13+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-11T18:40:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-03T15:15:30+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/3e706a245b5343ea93be9cafe84525bfb54e60fc", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T05:48:33+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.8.6", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "58988a27ede69f89a1dcc27a6af6b1e1c3936df0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/58988a27ede69f89a1dcc27a6af6b1e1c3936df0", + "reference": "58988a27ede69f89a1dcc27a6af6b1e1c3936df0", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.4.1", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-timer": "^7.0.1", + "phpunit/phpunit": "^11.5.56", + "sebastian/environment": "^7.2.1", + "symfony/console": "^6.4.22 || ^7.4.20 || ^8.1.8", + "symfony/process": "^6.4.20 || ^7.4.19 || ^8.1.7" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0.0", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.2.16", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.0.21", + "phpstan/phpstan-strict-rules": "^2.0.12", + "squizlabs/php_codesniffer": "^3.13.6", + "symfony/filesystem": "^6.4.13 || ^7.4.18 || ^8.1.6" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.8.6" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-10-04T06:17:44+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0 || ^1.1.2", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2026-09-29T22:11:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.7", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "50670ba239926c50f2073e868f6672f775cd57ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/50670ba239926c50f2073e868f6672f775cd57ec", + "reference": "50670ba239926c50f2073e868f6672f775cd57ec", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.7" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-18T13:17:38+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.30.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^12.65.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.7" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-08-05T16:47:22+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "pestphp/pest", + "version": "v3.8.7", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "f108313b52e8c28dc7121ce34303f817a3790202" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/f108313b52e8c28dc7121ce34303f817a3790202", + "reference": "f108313b52e8c28dc7121ce34303f817a3790202", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^3.0.0", + "pestphp/pest-plugin-arch": "^3.1.1", + "pestphp/pest-plugin-mutate": "^3.0.5", + "php": "^8.2.0", + "phpunit/phpunit": "^11.5.56" + }, + "conflict": { + "filp/whoops": "<2.16.0", + "phpunit/phpunit": ">11.5.56", + "sebastian/exporter": "<6.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^3.4.0", + "pestphp/pest-plugin-type-coverage": "^3.6.1", + "symfony/process": "^7.4.13" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v3.8.7" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-07-06T17:04:21+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e79b26c65bc11c41093b10150c1341cc5cdbea83", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.2" + }, + "conflict": { + "pestphp/pest": "<3.0.0" + }, + "require-dev": { + "composer/composer": "^2.7.9", + "pestphp/pest": "^3.0.0", + "pestphp/pest-dev-tools": "^3.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-09-08T23:21:41+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "ta-tikoma/phpunit-architecture-test": "^0.8.4" + }, + "require-dev": { + "pestphp/pest": "^3.8.1", + "pestphp/pest-dev-tools": "^3.4.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-04-16T22:59:48+00:00" + }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "6801be82fd92b96e82dd72e563e5674b1ce365fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/6801be82fd92b96e82dd72e563e5674b1ce365fc", + "reference": "6801be82fd92b96e82dd72e563e5674b1ce365fc", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.39.1|^12.9.2", + "pestphp/pest": "^3.8.2", + "php": "^8.2.0" + }, + "require-dev": { + "laravel/dusk": "^8.2.13|dev-develop", + "orchestra/testbench": "^9.9.0|^10.2.1", + "pestphp/pest-dev-tools": "^3.4.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-04-21T07:40:53+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v3.0.5", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.2.0", + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^3.0.8", + "pestphp/pest-dev-tools": "^3.0.0", + "pestphp/pest-plugin-type-coverage": "^3.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v3.0.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-09-22T07:54:40+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b", + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "https://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1" + }, + "time": "2025-11-10T21:21:55+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/dbe2133bb23e2e33a6834f4ed417f24a1b167769", + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.1.0" + }, + "time": "2026-09-27T19:32:54+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.6", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + }, + "time": "2026-09-27T20:07:45+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "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/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/8a163706bbcac613733dba03f51e13b5eb1bb819", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.5.0" + }, + "time": "2026-10-05T23:14:19+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..f660b58 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call([ + CategorySeeder::class, + ]); + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} From 96be381370a0bc4edc57f9b2c4e28e2bca4dc634 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:28:08 +0800 Subject: [PATCH 03/16] Add registry data model migrations, models and category seeder Tables from the plan's data model: accounts, organizations, memberships, tokens, categories, extensions, extension_packages, releases, release_checks, reviews, prices, files, instances, installs, licences, licence_activations, purchases and events, plus verification_codes for email verification codes. Public id prefixes: ext_, rel_, lic_, inst_, tok_ (and acct_, org_, cat_, file_, pur_). Categories are seeded from registry-bridge's ExtensionsCategorySeeder. --- app/Models/Account.php | 125 ++++++ app/Models/Category.php | 38 ++ app/Models/Event.php | 63 +++ app/Models/Extension.php | 162 +++++++ app/Models/ExtensionPackage.php | 31 ++ app/Models/File.php | 64 +++ app/Models/Install.php | 55 +++ app/Models/Instance.php | 66 +++ app/Models/Licence.php | 70 +++ app/Models/LicenceActivation.php | 34 ++ app/Models/Membership.php | 39 ++ app/Models/Organization.php | 76 ++++ app/Models/Price.php | 66 +++ app/Models/Purchase.php | 61 +++ app/Models/Release.php | 153 +++++++ app/Models/ReleaseCheck.php | 41 ++ app/Models/Review.php | 49 +++ app/Models/Token.php | 155 +++++++ app/Models/VerificationCode.php | 69 +++ database/.gitignore | 1 + database/factories/AccountFactory.php | 37 ++ database/factories/CategoryFactory.php | 30 ++ database/factories/ExtensionFactory.php | 87 ++++ database/factories/InstallFactory.php | 38 ++ database/factories/InstanceFactory.php | 28 ++ database/factories/OrganizationFactory.php | 38 ++ database/factories/PriceFactory.php | 41 ++ database/factories/ReleaseFactory.php | 47 ++ ...026_10_21_000001_create_accounts_table.php | 39 ++ ...0_21_000002_create_organizations_table.php | 35 ++ ..._10_21_000003_create_memberships_table.php | 28 ++ .../2026_10_21_000004_create_tokens_table.php | 35 ++ ...000005_create_verification_codes_table.php | 29 ++ ...6_10_21_000006_create_categories_table.php | 32 ++ .../2026_10_21_000007_create_files_table.php | 39 ++ ...6_10_21_000008_create_extensions_table.php | 53 +++ ...000009_create_extension_packages_table.php | 28 ++ .../2026_10_21_000010_create_prices_table.php | 35 ++ ...026_10_21_000011_create_releases_table.php | 43 ++ ..._21_000012_create_release_checks_table.php | 29 ++ ...2026_10_21_000013_create_reviews_table.php | 28 ++ ...26_10_21_000014_create_instances_table.php | 36 ++ ...026_10_21_000015_create_installs_table.php | 33 ++ ...026_10_21_000016_create_licences_table.php | 35 ++ ...00017_create_licence_activations_table.php | 28 ++ ...26_10_21_000018_create_purchases_table.php | 38 ++ .../2026_10_21_000019_create_events_table.php | 32 ++ database/seeders/CategorySeeder.php | 401 ++++++++++++++++++ 48 files changed, 2820 insertions(+) create mode 100644 app/Models/Account.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Event.php create mode 100644 app/Models/Extension.php create mode 100644 app/Models/ExtensionPackage.php create mode 100644 app/Models/File.php create mode 100644 app/Models/Install.php create mode 100644 app/Models/Instance.php create mode 100644 app/Models/Licence.php create mode 100644 app/Models/LicenceActivation.php create mode 100644 app/Models/Membership.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/Price.php create mode 100644 app/Models/Purchase.php create mode 100644 app/Models/Release.php create mode 100644 app/Models/ReleaseCheck.php create mode 100644 app/Models/Review.php create mode 100644 app/Models/Token.php create mode 100644 app/Models/VerificationCode.php create mode 100644 database/.gitignore create mode 100644 database/factories/AccountFactory.php create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/factories/ExtensionFactory.php create mode 100644 database/factories/InstallFactory.php create mode 100644 database/factories/InstanceFactory.php create mode 100644 database/factories/OrganizationFactory.php create mode 100644 database/factories/PriceFactory.php create mode 100644 database/factories/ReleaseFactory.php create mode 100644 database/migrations/2026_10_21_000001_create_accounts_table.php create mode 100644 database/migrations/2026_10_21_000002_create_organizations_table.php create mode 100644 database/migrations/2026_10_21_000003_create_memberships_table.php create mode 100644 database/migrations/2026_10_21_000004_create_tokens_table.php create mode 100644 database/migrations/2026_10_21_000005_create_verification_codes_table.php create mode 100644 database/migrations/2026_10_21_000006_create_categories_table.php create mode 100644 database/migrations/2026_10_21_000007_create_files_table.php create mode 100644 database/migrations/2026_10_21_000008_create_extensions_table.php create mode 100644 database/migrations/2026_10_21_000009_create_extension_packages_table.php create mode 100644 database/migrations/2026_10_21_000010_create_prices_table.php create mode 100644 database/migrations/2026_10_21_000011_create_releases_table.php create mode 100644 database/migrations/2026_10_21_000012_create_release_checks_table.php create mode 100644 database/migrations/2026_10_21_000013_create_reviews_table.php create mode 100644 database/migrations/2026_10_21_000014_create_instances_table.php create mode 100644 database/migrations/2026_10_21_000015_create_installs_table.php create mode 100644 database/migrations/2026_10_21_000016_create_licences_table.php create mode 100644 database/migrations/2026_10_21_000017_create_licence_activations_table.php create mode 100644 database/migrations/2026_10_21_000018_create_purchases_table.php create mode 100644 database/migrations/2026_10_21_000019_create_events_table.php create mode 100644 database/seeders/CategorySeeder.php diff --git a/app/Models/Account.php b/app/Models/Account.php new file mode 100644 index 0000000..9b46d1a --- /dev/null +++ b/app/Models/Account.php @@ -0,0 +1,125 @@ + 'datetime', + 'last_login_at' => 'datetime', + 'meta' => 'array', + 'password' => 'hashed', + ]; + + /** The token used to authenticate the current request, if any. */ + protected ?Token $currentToken = null; + + protected static function newFactory(): AccountFactory + { + return AccountFactory::new(); + } + + public function tokens(): HasMany + { + return $this->hasMany(Token::class, 'account_uuid', 'uuid'); + } + + public function memberships(): HasMany + { + return $this->hasMany(Membership::class, 'account_uuid', 'uuid'); + } + + public function organizations(): BelongsToMany + { + return $this->belongsToMany(Organization::class, 'memberships', 'account_uuid', 'organization_uuid', 'uuid', 'uuid') + ->withPivot('role') + ->withTimestamps(); + } + + public function verificationCodes(): HasMany + { + return $this->hasMany(VerificationCode::class, 'subject_uuid', 'uuid'); + } + + public function hasVerifiedEmail(): bool + { + return $this->email_verified_at !== null; + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function markEmailAsVerified(): static + { + $this->forceFill([ + 'email_verified_at' => now(), + 'status' => $this->status === self::STATUS_PENDING_VERIFICATION ? self::STATUS_ACTIVE : $this->status, + ])->save(); + + return $this; + } + + public function withCurrentToken(?Token $token): static + { + $this->currentToken = $token; + + return $this; + } + + public function currentToken(): ?Token + { + return $this->currentToken; + } + + public function tokenCan(string $scope): bool + { + return $this->currentToken?->can($scope) ?? false; + } + + /** + * The account's personal organization (created at registration). + */ + public function personalOrganization(): ?Organization + { + return $this->organizations()->where('is_personal', true)->first(); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..d06bba4 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,38 @@ + 'array', 'sort' => 'integer']; + + protected static function newFactory(): CategoryFactory + { + return CategoryFactory::new(); + } + + public function extensions(): HasMany + { + return $this->hasMany(Extension::class, 'category_uuid', 'uuid'); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php new file mode 100644 index 0000000..0d8671a --- /dev/null +++ b/app/Models/Event.php @@ -0,0 +1,63 @@ + 'array', 'created_at' => 'datetime']; + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + public function actor(): MorphTo + { + return $this->morphTo('actor', 'actor_type', 'actor_uuid', 'uuid'); + } + + /** + * Record an event. Never throws; auditing must not break the request. + */ + public static function record(string $kind, ?EloquentModel $subject = null, ?EloquentModel $actor = null, array $payload = [], ?string $ip = null): ?static + { + try { + return static::create([ + 'kind' => $kind, + 'subject_type' => $subject?->getMorphClass(), + 'subject_uuid' => $subject?->getKey(), + 'actor_type' => $actor?->getMorphClass(), + 'actor_uuid' => $actor?->getKey(), + 'payload' => $payload, + 'ip_address' => $ip ?? request()?->ip(), + ]); + } catch (\Throwable $e) { + report($e); + + return null; + } + } +} diff --git a/app/Models/Extension.php b/app/Models/Extension.php new file mode 100644 index 0000000..a678b1b --- /dev/null +++ b/app/Models/Extension.php @@ -0,0 +1,162 @@ + 'array', + 'languages' => 'array', + 'published_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): ExtensionFactory + { + return ExtensionFactory::new(); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function publisher(): BelongsTo + { + return $this->organization(); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class, 'category_uuid', 'uuid'); + } + + public function icon(): BelongsTo + { + return $this->belongsTo(File::class, 'icon_file_uuid', 'uuid'); + } + + public function screenshots(): MorphMany + { + return $this->morphMany(File::class, 'subject', 'subject_type', 'subject_uuid', 'uuid') + ->where('type', File::TYPE_SCREENSHOT) + ->orderBy('created_at'); + } + + public function packages(): HasMany + { + return $this->hasMany(ExtensionPackage::class, 'extension_uuid', 'uuid'); + } + + public function price(): HasOne + { + return $this->hasOne(Price::class, 'extension_uuid', 'uuid'); + } + + public function releases(): HasMany + { + return $this->hasMany(Release::class, 'extension_uuid', 'uuid'); + } + + public function publishedReleases(): HasMany + { + return $this->releases()->where('status', Release::STATUS_PUBLISHED)->orderByDesc('published_at'); + } + + /** + * Newest published stable release (falls back to any channel). + */ + public function latestRelease(): HasOne + { + return $this->hasOne(Release::class, 'extension_uuid', 'uuid') + ->where('status', Release::STATUS_PUBLISHED) + ->ofMany(['published_at' => 'max', 'id' => 'max'], fn (Builder $q) => $q->where('status', Release::STATUS_PUBLISHED)); + } + + public function installs(): HasMany + { + return $this->hasMany(Install::class, 'extension_uuid', 'uuid'); + } + + public function licences(): HasMany + { + return $this->hasMany(Licence::class, 'extension_uuid', 'uuid'); + } + + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class, 'extension_uuid', 'uuid'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function isPaid(): bool + { + return $this->price !== null && $this->price->model !== Price::MODEL_FREE && (int) $this->price->amount > 0; + } +} diff --git a/app/Models/ExtensionPackage.php b/app/Models/ExtensionPackage.php new file mode 100644 index 0000000..7c27a5d --- /dev/null +++ b/app/Models/ExtensionPackage.php @@ -0,0 +1,31 @@ +belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } +} diff --git a/app/Models/File.php b/app/Models/File.php new file mode 100644 index 0000000..99d78d6 --- /dev/null +++ b/app/Models/File.php @@ -0,0 +1,64 @@ + 'integer', 'meta' => 'array']; + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + /** + * Public URL for icons and screenshots. Package tarballs are served through the + * package endpoints instead so licence checks apply. + */ + public function getUrlAttribute(): ?string + { + if (! in_array($this->type, [self::TYPE_ICON, self::TYPE_SCREENSHOT], true)) { + return null; + } + + try { + return Storage::disk($this->disk)->url($this->path); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Models/Install.php b/app/Models/Install.php new file mode 100644 index 0000000..d7c990d --- /dev/null +++ b/app/Models/Install.php @@ -0,0 +1,55 @@ + 'datetime', 'finished_at' => 'datetime']; + + protected static function newFactory(): InstallFactory + { + return InstallFactory::new(); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php new file mode 100644 index 0000000..da044ab --- /dev/null +++ b/app/Models/Instance.php @@ -0,0 +1,66 @@ + 'datetime', 'meta' => 'array']; + + protected static function newFactory(): InstanceFactory + { + return InstanceFactory::new(); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function registeredBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'registered_by_account_uuid', 'uuid'); + } + + public function installs(): HasMany + { + return $this->hasMany(Install::class, 'instance_uuid', 'uuid'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenceActivation::class, 'instance_uuid', 'uuid'); + } +} diff --git a/app/Models/Licence.php b/app/Models/Licence.php new file mode 100644 index 0000000..1c90387 --- /dev/null +++ b/app/Models/Licence.php @@ -0,0 +1,70 @@ + 'integer', 'expires_at' => 'datetime', 'meta' => 'array']; + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class, 'purchase_uuid', 'uuid'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenceActivation::class, 'licence_uuid', 'uuid'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE) + ->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE && ($this->expires_at === null || $this->expires_at->isFuture()); + } +} diff --git a/app/Models/LicenceActivation.php b/app/Models/LicenceActivation.php new file mode 100644 index 0000000..e107194 --- /dev/null +++ b/app/Models/LicenceActivation.php @@ -0,0 +1,34 @@ + 'datetime', 'deactivated_at' => 'datetime']; + + public function licence(): BelongsTo + { + return $this->belongsTo(Licence::class, 'licence_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Membership.php b/app/Models/Membership.php new file mode 100644 index 0000000..3bcd5db --- /dev/null +++ b/app/Models/Membership.php @@ -0,0 +1,39 @@ +belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000..4bc13bf --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,76 @@ + 'boolean', + 'verified_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): OrganizationFactory + { + return OrganizationFactory::new(); + } + + public function memberships(): HasMany + { + return $this->hasMany(Membership::class, 'organization_uuid', 'uuid'); + } + + public function accounts(): BelongsToMany + { + return $this->belongsToMany(Account::class, 'memberships', 'organization_uuid', 'account_uuid', 'uuid', 'uuid') + ->withPivot('role') + ->withTimestamps(); + } + + public function extensions(): HasMany + { + return $this->hasMany(Extension::class, 'organization_uuid', 'uuid'); + } + + public function instances(): HasMany + { + return $this->hasMany(Instance::class, 'organization_uuid', 'uuid'); + } + + public function licences(): HasMany + { + return $this->hasMany(Licence::class, 'organization_uuid', 'uuid'); + } + + public function isVerified(): bool + { + return $this->verified_at !== null; + } +} diff --git a/app/Models/Price.php b/app/Models/Price.php new file mode 100644 index 0000000..a8299da --- /dev/null +++ b/app/Models/Price.php @@ -0,0 +1,66 @@ + Money::class, + 'sale_amount' => Money::class, + 'on_sale' => 'boolean', + ]; + + protected static function newFactory(): PriceFactory + { + return PriceFactory::new(); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function isFree(): bool + { + return $this->model === self::MODEL_FREE || (int) $this->amount === 0; + } + + /** + * The amount a buyer pays right now, honouring an active sale. + */ + public function getEffectiveAmountAttribute(): int + { + if ($this->on_sale && $this->sale_amount !== null) { + return (int) $this->sale_amount; + } + + return (int) $this->amount; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php new file mode 100644 index 0000000..5ba04d9 --- /dev/null +++ b/app/Models/Purchase.php @@ -0,0 +1,61 @@ + 'array', + 'amount_total' => Money::class, + 'fee_amount' => Money::class, + 'net_amount' => Money::class, + 'meta' => 'array', + ]; + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function buyer(): BelongsTo + { + return $this->belongsTo(Organization::class, 'buyer_organization_uuid', 'uuid'); + } + + public function licence(): HasOne + { + return $this->hasOne(Licence::class, 'purchase_uuid', 'uuid'); + } +} diff --git a/app/Models/Release.php b/app/Models/Release.php new file mode 100644 index 0000000..a195e71 --- /dev/null +++ b/app/Models/Release.php @@ -0,0 +1,153 @@ + 'array', + 'compat' => 'array', + 'size_bytes' => 'integer', + 'published_at' => 'datetime', + 'yanked_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): ReleaseFactory + { + return ReleaseFactory::new(); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'created_by_account_uuid', 'uuid'); + } + + public function engineTarball(): BelongsTo + { + return $this->belongsTo(File::class, 'engine_tarball_file_uuid', 'uuid'); + } + + public function apiTarball(): BelongsTo + { + return $this->belongsTo(File::class, 'api_tarball_file_uuid', 'uuid'); + } + + public function engineDist(): BelongsTo + { + return $this->belongsTo(File::class, 'engine_dist_file_uuid', 'uuid'); + } + + public function checks(): HasMany + { + return $this->hasMany(ReleaseCheck::class, 'release_uuid', 'uuid'); + } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class, 'release_uuid', 'uuid')->orderBy('created_at'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function isPublished(): bool + { + return $this->status === self::STATUS_PUBLISHED; + } + + public function isYanked(): bool + { + return $this->status === self::STATUS_YANKED; + } + + /** + * All automated checks passed (and at least one exists). + */ + public function checksPassed(): bool + { + $checks = $this->relationLoaded('checks') ? $this->checks : $this->checks()->get(); + + return $checks->isNotEmpty() && $checks->every(fn (ReleaseCheck $check) => $check->passed); + } + + /** + * Rule from the data model: publishable when every check passes and the latest + * review action is `approve`. Auto-publish policy (D6) is applied by the caller. + */ + public function isPublishable(): bool + { + if (! $this->checksPassed()) { + return false; + } + + $latestReview = $this->reviews()->latest('created_at')->first(); + + return $latestReview?->action === Review::ACTION_APPROVE; + } +} diff --git a/app/Models/ReleaseCheck.php b/app/Models/ReleaseCheck.php new file mode 100644 index 0000000..3bb5d83 --- /dev/null +++ b/app/Models/ReleaseCheck.php @@ -0,0 +1,41 @@ + 'boolean', 'output' => 'array']; + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..411abbe --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,49 @@ + 'array']; + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(Account::class, 'reviewer_account_uuid', 'uuid'); + } +} diff --git a/app/Models/Token.php b/app/Models/Token.php new file mode 100644 index 0000000..b9aee84 --- /dev/null +++ b/app/Models/Token.php @@ -0,0 +1,155 @@ + 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** Plaintext, only populated on the instance returned by `issue()`. */ + public ?string $plainTextToken = null; + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + /** + * Create a token for an account and return it with `plainTextToken` set. + */ + public static function issue(Account $account, string $name, array $scopes = [self::SCOPE_ALL], ?\DateTimeInterface $expiresAt = null, ?string $organizationUuid = null, string $prefix = self::PREFIX_PERSONAL): static + { + $plain = $prefix.Str::random(40); + + $token = static::create([ + 'account_uuid' => $account->uuid, + 'organization_uuid' => $organizationUuid, + 'name' => $name, + 'token_hash' => static::hash($plain), + 'prefix' => $prefix, + 'last_four' => substr($plain, -4), + 'scopes' => array_values(array_unique($scopes)), + 'expires_at' => $expiresAt ?? now()->addDays(static::MAX_LIFETIME_DAYS), + ]); + + $token->plainTextToken = $plain; + + return $token; + } + + public static function hash(string $plain): string + { + return hash('sha256', $plain); + } + + /** + * Resolve a plaintext token to its active row, or null. + */ + public static function findActive(string $plain): ?static + { + if ($plain === '') { + return null; + } + + return static::query()->active()->where('token_hash', static::hash($plain))->first(); + } + + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('revoked_at') + ->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } + + public function can(string $scope): bool + { + $scopes = $this->scopes ?? []; + + return in_array(self::SCOPE_ALL, $scopes, true) || in_array($scope, $scopes, true); + } + + public function revoke(): static + { + if (! $this->isRevoked()) { + $this->forceFill(['revoked_at' => now()])->save(); + } + + return $this; + } + + public function touchUsage(?string $ip): void + { + $this->forceFill(['last_used_at' => now(), 'last_used_ip' => $ip])->saveQuietly(); + } + + /** + * Masked display form, e.g. `flbp_…a1b2`. + */ + public function getMaskedAttribute(): string + { + return $this->prefix.'…'.$this->last_four; + } +} diff --git a/app/Models/VerificationCode.php b/app/Models/VerificationCode.php new file mode 100644 index 0000000..544f18a --- /dev/null +++ b/app/Models/VerificationCode.php @@ -0,0 +1,69 @@ + 'datetime', 'used_at' => 'datetime']; + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + public static function generateFor(EloquentModel $subject, string $for = self::FOR_EMAIL_VERIFICATION): static + { + // Only one live code per subject and purpose. + static::query()->where('subject_uuid', $subject->getKey())->where('for', $for)->whereNull('used_at')->delete(); + + return static::create([ + 'subject_uuid' => $subject->getKey(), + 'subject_type' => $subject::class, + 'for' => $for, + 'code' => str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT), + 'expires_at' => now()->addMinutes(static::LIFETIME_MINUTES), + ]); + } + + public static function consume(EloquentModel $subject, string $code, string $for = self::FOR_EMAIL_VERIFICATION): bool + { + $record = static::query() + ->where('subject_uuid', $subject->getKey()) + ->where('for', $for) + ->where('code', $code) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->first(); + + if (! $record) { + return false; + } + + $record->forceFill(['used_at' => now()])->save(); + + return true; + } +} diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/AccountFactory.php b/database/factories/AccountFactory.php new file mode 100644 index 0000000..95c8249 --- /dev/null +++ b/database/factories/AccountFactory.php @@ -0,0 +1,37 @@ + + */ +class AccountFactory extends Factory +{ + protected $model = Account::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'username' => fake()->unique()->userName(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + 'email_verified_at' => now(), + 'status' => Account::STATUS_ACTIVE, + 'timezone' => 'UTC', + ]; + } + + public function unverified(): static + { + return $this->state(fn () => ['email_verified_at' => null, 'status' => Account::STATUS_PENDING_VERIFICATION]); + } + + public function suspended(): static + { + return $this->state(fn () => ['status' => Account::STATUS_SUSPENDED]); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..ce7b92b --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,30 @@ + + */ +class CategoryFactory extends Factory +{ + protected $model = Category::class; + + public function definition(): array + { + $name = Str::title(fake()->unique()->words(2, true)); + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'internal_id' => 'FLB'.Str::upper(Str::random(6)), + 'description' => fake()->sentence(), + 'icon' => 'cube', + 'tags' => fake()->words(3), + 'sort' => fake()->numberBetween(0, 50), + ]; + } +} diff --git a/database/factories/ExtensionFactory.php b/database/factories/ExtensionFactory.php new file mode 100644 index 0000000..44044f2 --- /dev/null +++ b/database/factories/ExtensionFactory.php @@ -0,0 +1,87 @@ + + */ +class ExtensionFactory extends Factory +{ + protected $model = Extension::class; + + public function definition(): array + { + $name = Str::title(fake()->unique()->words(2, true)); + + return [ + 'organization_uuid' => Organization::factory(), + 'category_uuid' => Category::factory(), + 'name' => $name, + 'slug' => Str::slug($name), + 'subtitle' => fake()->sentence(6), + 'description' => fake()->paragraph(), + 'tags' => fake()->words(3), + 'languages' => ['en'], + 'hosting' => Extension::HOSTING_BOTH, + 'status' => Extension::STATUS_DRAFT, + 'fa_icon' => 'puzzle-piece', + ]; + } + + public function published(): static + { + return $this->state(fn () => ['status' => Extension::STATUS_PUBLISHED, 'published_at' => now()]); + } + + public function hosting(string $hosting): static + { + return $this->state(fn () => ['hosting' => $hosting]); + } + + /** + * Attach a free price row. + */ + public function free(): static + { + return $this->has(Price::factory()->free(), 'price'); + } + + /** + * Attach a one-time price in minor units. + */ + public function paid(int $amount = 4900, string $currency = 'USD'): static + { + return $this->has(Price::factory()->oneTime($amount, $currency), 'price'); + } + + /** + * Attach npm and composer package names derived from the slug. + */ + public function withPackages(?string $scope = null): static + { + return $this->afterCreating(function (Extension $extension) use ($scope) { + $scope ??= $extension->organization?->slug ?? 'fleetbase'; + $extension->packages()->createMany([ + ['kind' => ExtensionPackage::KIND_NPM, 'name' => '@'.$scope.'/'.$extension->slug.'-engine'], + ['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => $scope.'/'.$extension->slug.'-api'], + ]); + }); + } + + /** + * Attach one or more published releases. + */ + public function withPublishedRelease(string $version = '1.0.0', string $channel = Release::CHANNEL_STABLE): static + { + return $this->has(Release::factory()->published()->state(['version' => $version, 'channel' => $channel]), 'releases'); + } +} diff --git a/database/factories/InstallFactory.php b/database/factories/InstallFactory.php new file mode 100644 index 0000000..6a4a7f3 --- /dev/null +++ b/database/factories/InstallFactory.php @@ -0,0 +1,38 @@ + + */ +class InstallFactory extends Factory +{ + protected $model = Install::class; + + public function definition(): array + { + return [ + 'instance_uuid' => Instance::factory(), + 'extension_uuid' => Extension::factory(), + 'status' => Install::STATUS_INSTALLED, + 'installed_by' => 'console', + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + ]; + } + + public function failed(): static + { + return $this->state(fn () => ['status' => Install::STATUS_FAILED, 'error' => 'composer require failed']); + } + + public function uninstalled(): static + { + return $this->state(fn () => ['status' => Install::STATUS_UNINSTALLED]); + } +} diff --git a/database/factories/InstanceFactory.php b/database/factories/InstanceFactory.php new file mode 100644 index 0000000..e6f6d07 --- /dev/null +++ b/database/factories/InstanceFactory.php @@ -0,0 +1,28 @@ + + */ +class InstanceFactory extends Factory +{ + protected $model = Instance::class; + + public function definition(): array + { + return [ + 'organization_uuid' => Organization::factory(), + 'name' => fake()->domainWord().' instance', + 'host' => fake()->domainName(), + 'kind' => Instance::KIND_SELF_HOSTED, + 'console_version' => '0.5.0', + 'api_version' => '1.6.0', + 'status' => 'active', + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 0000000..f6cbb1a --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,38 @@ + + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + public function definition(): array + { + $name = fake()->unique()->company(); + + return [ + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(4)), + 'website' => fake()->url(), + 'bio' => fake()->sentence(), + 'is_personal' => false, + ]; + } + + public function verified(): static + { + return $this->state(fn () => ['verified_at' => now()]); + } + + public function personal(): static + { + return $this->state(fn () => ['is_personal' => true]); + } +} diff --git a/database/factories/PriceFactory.php b/database/factories/PriceFactory.php new file mode 100644 index 0000000..31bf602 --- /dev/null +++ b/database/factories/PriceFactory.php @@ -0,0 +1,41 @@ + + */ +class PriceFactory extends Factory +{ + protected $model = Price::class; + + public function definition(): array + { + return [ + 'extension_uuid' => Extension::factory(), + 'model' => Price::MODEL_FREE, + 'amount' => 0, + 'currency' => 'USD', + 'on_sale' => false, + ]; + } + + public function free(): static + { + return $this->state(fn () => ['model' => Price::MODEL_FREE, 'amount' => 0]); + } + + public function oneTime(int $amount = 4900, string $currency = 'USD'): static + { + return $this->state(fn () => ['model' => Price::MODEL_ONE_TIME, 'amount' => $amount, 'currency' => $currency]); + } + + public function onSale(int $saleAmount): static + { + return $this->state(fn () => ['on_sale' => true, 'sale_amount' => $saleAmount]); + } +} diff --git a/database/factories/ReleaseFactory.php b/database/factories/ReleaseFactory.php new file mode 100644 index 0000000..1261d3c --- /dev/null +++ b/database/factories/ReleaseFactory.php @@ -0,0 +1,47 @@ + + */ +class ReleaseFactory extends Factory +{ + protected $model = Release::class; + + public function definition(): array + { + $version = fake()->numberBetween(0, 3).'.'.fake()->numberBetween(0, 9).'.'.fake()->numberBetween(0, 9); + + return [ + 'extension_uuid' => Extension::factory(), + 'version' => $version, + 'channel' => Release::CHANNEL_STABLE, + 'status' => Release::STATUS_UPLOADED, + 'manifest' => ['name' => 'example', 'version' => $version], + 'compat' => ['console' => '>=0.5.0', 'api' => '>=1.6.0'], + 'size_bytes' => fake()->numberBetween(10_000, 5_000_000), + 'checksum' => 'sha256:'.fake()->sha256(), + 'notes' => fake()->sentence(), + ]; + } + + public function published(): static + { + return $this->state(fn () => ['status' => Release::STATUS_PUBLISHED, 'published_at' => now()]); + } + + public function beta(): static + { + return $this->state(fn () => ['channel' => Release::CHANNEL_BETA]); + } + + public function yanked(): static + { + return $this->state(fn () => ['status' => Release::STATUS_YANKED, 'published_at' => now()->subDay(), 'yanked_at' => now()]); + } +} diff --git a/database/migrations/2026_10_21_000001_create_accounts_table.php b/database/migrations/2026_10_21_000001_create_accounts_table.php new file mode 100644 index 0000000..12f9ab8 --- /dev/null +++ b/database/migrations/2026_10_21_000001_create_accounts_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->string('email')->unique(); + $table->string('password')->nullable(); + $table->string('name')->nullable(); + $table->string('username')->nullable()->unique(); + $table->string('avatar_url')->nullable(); + $table->string('github_id')->nullable()->index(); + $table->string('fleetbase_sub')->nullable()->index(); + $table->timestamp('email_verified_at')->nullable()->index(); + $table->string('status')->default('pending_verification')->index(); + $table->string('timezone')->nullable(); + $table->string('last_login_ip')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->json('meta')->nullable(); + $table->rememberToken(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounts'); + } +}; diff --git a/database/migrations/2026_10_21_000002_create_organizations_table.php b/database/migrations/2026_10_21_000002_create_organizations_table.php new file mode 100644 index 0000000..2b2b0ff --- /dev/null +++ b/database/migrations/2026_10_21_000002_create_organizations_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->string('slug')->unique(); + $table->string('name'); + $table->string('website')->nullable(); + $table->text('bio')->nullable(); + $table->string('avatar_url')->nullable(); + $table->boolean('is_personal')->default(false); + $table->timestamp('verified_at')->nullable(); + $table->string('stripe_connect_id')->nullable()->index(); + $table->string('payout_status')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_10_21_000003_create_memberships_table.php b/database/migrations/2026_10_21_000003_create_memberships_table.php new file mode 100644 index 0000000..610d16e --- /dev/null +++ b/database/migrations/2026_10_21_000003_create_memberships_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('organization_uuid')->index(); + $table->uuid('account_uuid')->index(); + $table->string('role')->default('member'); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['organization_uuid', 'account_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('memberships'); + } +}; diff --git a/database/migrations/2026_10_21_000004_create_tokens_table.php b/database/migrations/2026_10_21_000004_create_tokens_table.php new file mode 100644 index 0000000..80f8019 --- /dev/null +++ b/database/migrations/2026_10_21_000004_create_tokens_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('account_uuid')->index(); + $table->uuid('organization_uuid')->nullable()->index(); + $table->string('name'); + $table->string('token_hash', 64)->unique(); + $table->string('prefix', 16)->index(); + $table->string('last_four', 4)->nullable(); + $table->json('scopes')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamp('last_used_at')->nullable(); + $table->string('last_used_ip')->nullable(); + $table->timestamp('revoked_at')->nullable()->index(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tokens'); + } +}; diff --git a/database/migrations/2026_10_21_000005_create_verification_codes_table.php b/database/migrations/2026_10_21_000005_create_verification_codes_table.php new file mode 100644 index 0000000..49913e8 --- /dev/null +++ b/database/migrations/2026_10_21_000005_create_verification_codes_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('subject_uuid')->index(); + $table->string('subject_type'); + $table->string('for')->index(); + $table->string('code', 16); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamp('used_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('verification_codes'); + } +}; diff --git a/database/migrations/2026_10_21_000006_create_categories_table.php b/database/migrations/2026_10_21_000006_create_categories_table.php new file mode 100644 index 0000000..a99d946 --- /dev/null +++ b/database/migrations/2026_10_21_000006_create_categories_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->string('slug')->unique(); + $table->string('name'); + $table->string('internal_id')->nullable()->index(); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->json('tags')->nullable(); + $table->unsignedInteger('sort')->default(0)->index(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_10_21_000007_create_files_table.php b/database/migrations/2026_10_21_000007_create_files_table.php new file mode 100644 index 0000000..464ca37 --- /dev/null +++ b/database/migrations/2026_10_21_000007_create_files_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('uploader_account_uuid')->nullable()->index(); + $table->string('subject_type')->nullable(); + $table->uuid('subject_uuid')->nullable(); + $table->string('type')->nullable()->index(); + $table->string('disk')->default('s3'); + $table->string('path'); + $table->string('name')->nullable(); + $table->string('original_filename')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->string('mime')->nullable(); + $table->string('checksum', 128)->nullable()->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->index(['subject_type', 'subject_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('files'); + } +}; diff --git a/database/migrations/2026_10_21_000008_create_extensions_table.php b/database/migrations/2026_10_21_000008_create_extensions_table.php new file mode 100644 index 0000000..a4f7b16 --- /dev/null +++ b/database/migrations/2026_10_21_000008_create_extensions_table.php @@ -0,0 +1,53 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('organization_uuid')->index(); + $table->uuid('category_uuid')->nullable()->index(); + $table->uuid('icon_file_uuid')->nullable()->index(); + $table->uuid('created_by_account_uuid')->nullable()->index(); + $table->string('slug')->unique(); + $table->string('name'); + $table->string('subtitle')->nullable(); + $table->text('description')->nullable(); + $table->text('promotional_text')->nullable(); + $table->json('tags')->nullable(); + $table->json('languages')->nullable(); + $table->string('hosting')->default('both')->index(); + $table->string('status')->default('draft')->index(); + $table->string('website_url')->nullable(); + $table->string('repo_url')->nullable(); + $table->string('support_url')->nullable(); + $table->string('privacy_url')->nullable(); + $table->string('tos_url')->nullable(); + $table->string('copyright')->nullable(); + $table->string('fa_icon')->nullable(); + $table->timestamp('published_at')->nullable()->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable()->index(); + }); + + if (Schema::getConnection()->getDriverName() === 'mysql') { + Schema::table('extensions', function (Blueprint $table) { + $table->fullText(['name', 'subtitle', 'description'], 'extensions_search_fulltext'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('extensions'); + } +}; diff --git a/database/migrations/2026_10_21_000009_create_extension_packages_table.php b/database/migrations/2026_10_21_000009_create_extension_packages_table.php new file mode 100644 index 0000000..9e19b9d --- /dev/null +++ b/database/migrations/2026_10_21_000009_create_extension_packages_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('extension_uuid')->index(); + $table->string('kind', 16); + $table->string('name'); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['kind', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_packages'); + } +}; diff --git a/database/migrations/2026_10_21_000010_create_prices_table.php b/database/migrations/2026_10_21_000010_create_prices_table.php new file mode 100644 index 0000000..a0a65aa --- /dev/null +++ b/database/migrations/2026_10_21_000010_create_prices_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('extension_uuid')->unique(); + $table->string('model', 32)->default('free')->index(); + $table->bigInteger('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->bigInteger('sale_amount')->nullable(); + $table->boolean('on_sale')->default(false); + $table->string('stripe_product_id')->nullable()->index(); + $table->string('stripe_price_id')->nullable()->index(); + // Reserved for subscriptions; unused in this phase. + $table->string('interval', 16)->nullable(); + $table->unsignedSmallInteger('interval_count')->nullable(); + $table->unsignedSmallInteger('trial_days')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('prices'); + } +}; diff --git a/database/migrations/2026_10_21_000011_create_releases_table.php b/database/migrations/2026_10_21_000011_create_releases_table.php new file mode 100644 index 0000000..f26dd8f --- /dev/null +++ b/database/migrations/2026_10_21_000011_create_releases_table.php @@ -0,0 +1,43 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('extension_uuid')->index(); + $table->uuid('created_by_account_uuid')->nullable()->index(); + $table->string('version', 64); + $table->string('channel', 16)->default('stable')->index(); + $table->string('status', 32)->default('uploaded')->index(); + $table->json('manifest')->nullable(); + $table->json('compat')->nullable(); + $table->uuid('engine_tarball_file_uuid')->nullable(); + $table->uuid('api_tarball_file_uuid')->nullable(); + $table->uuid('engine_dist_file_uuid')->nullable(); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->string('checksum', 128)->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('yanked_at')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['extension_uuid', 'version']); + }); + } + + public function down(): void + { + Schema::dropIfExists('releases'); + } +}; diff --git a/database/migrations/2026_10_21_000012_create_release_checks_table.php b/database/migrations/2026_10_21_000012_create_release_checks_table.php new file mode 100644 index 0000000..ae02993 --- /dev/null +++ b/database/migrations/2026_10_21_000012_create_release_checks_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('release_uuid')->index(); + $table->string('check', 32)->index(); + $table->boolean('passed')->default(false); + $table->json('output')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['release_uuid', 'check']); + }); + } + + public function down(): void + { + Schema::dropIfExists('release_checks'); + } +}; diff --git a/database/migrations/2026_10_21_000013_create_reviews_table.php b/database/migrations/2026_10_21_000013_create_reviews_table.php new file mode 100644 index 0000000..04b1745 --- /dev/null +++ b/database/migrations/2026_10_21_000013_create_reviews_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('release_uuid')->index(); + $table->uuid('reviewer_account_uuid')->nullable()->index(); + $table->string('action', 32)->index(); + $table->text('reason')->nullable(); + $table->json('items')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reviews'); + } +}; diff --git a/database/migrations/2026_10_21_000014_create_instances_table.php b/database/migrations/2026_10_21_000014_create_instances_table.php new file mode 100644 index 0000000..6856ef8 --- /dev/null +++ b/database/migrations/2026_10_21_000014_create_instances_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('organization_uuid')->index(); + $table->uuid('registered_by_account_uuid')->nullable()->index(); + $table->string('name'); + $table->string('host')->nullable()->index(); + $table->string('kind', 16)->default('self_hosted')->index(); + $table->string('console_version', 64)->nullable(); + $table->string('api_version', 64)->nullable(); + $table->string('token_hash', 64)->nullable()->unique(); + $table->string('status', 32)->default('active')->index(); + $table->timestamp('last_seen_at')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('instances'); + } +}; diff --git a/database/migrations/2026_10_21_000015_create_installs_table.php b/database/migrations/2026_10_21_000015_create_installs_table.php new file mode 100644 index 0000000..bb38dcf --- /dev/null +++ b/database/migrations/2026_10_21_000015_create_installs_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('instance_uuid')->index(); + $table->uuid('extension_uuid')->index(); + $table->uuid('release_uuid')->nullable()->index(); + $table->string('status', 32)->default('installing')->index(); + $table->string('installed_by')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->index(['extension_uuid', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('installs'); + } +}; diff --git a/database/migrations/2026_10_21_000016_create_licences_table.php b/database/migrations/2026_10_21_000016_create_licences_table.php new file mode 100644 index 0000000..a8d2fbf --- /dev/null +++ b/database/migrations/2026_10_21_000016_create_licences_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('extension_uuid')->index(); + $table->uuid('organization_uuid')->index(); + $table->uuid('purchase_uuid')->nullable()->index(); + $table->string('kind', 16)->default('free')->index(); + $table->unsignedInteger('max_activations')->default(5); + $table->string('status', 32)->default('active')->index(); + $table->timestamp('expires_at')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->index(['extension_uuid', 'organization_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('licences'); + } +}; diff --git a/database/migrations/2026_10_21_000017_create_licence_activations_table.php b/database/migrations/2026_10_21_000017_create_licence_activations_table.php new file mode 100644 index 0000000..6a1dfa4 --- /dev/null +++ b/database/migrations/2026_10_21_000017_create_licence_activations_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('licence_uuid')->index(); + $table->uuid('instance_uuid')->index(); + $table->uuid('release_uuid')->nullable()->index(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('licence_activations'); + } +}; diff --git a/database/migrations/2026_10_21_000018_create_purchases_table.php b/database/migrations/2026_10_21_000018_create_purchases_table.php new file mode 100644 index 0000000..0db56e2 --- /dev/null +++ b/database/migrations/2026_10_21_000018_create_purchases_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('extension_uuid')->index(); + $table->uuid('buyer_organization_uuid')->index(); + $table->uuid('buyer_account_uuid')->nullable()->index(); + $table->json('price_snapshot')->nullable(); + $table->bigInteger('amount_total')->default(0); + $table->bigInteger('fee_amount')->default(0); + $table->bigInteger('net_amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->string('stripe_checkout_session_id')->nullable()->unique(); + $table->string('stripe_payment_intent_id')->nullable()->index(); + $table->string('status', 32)->default('paid')->index(); + $table->string('receipt_url')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchases'); + } +}; diff --git a/database/migrations/2026_10_21_000019_create_events_table.php b/database/migrations/2026_10_21_000019_create_events_table.php new file mode 100644 index 0000000..6568fb6 --- /dev/null +++ b/database/migrations/2026_10_21_000019_create_events_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('subject_type')->nullable(); + $table->uuid('subject_uuid')->nullable(); + $table->string('actor_type')->nullable(); + $table->uuid('actor_uuid')->nullable(); + $table->string('kind')->index(); + $table->json('payload')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('created_at')->nullable()->index(); + + $table->index(['subject_type', 'subject_uuid']); + $table->index(['actor_type', 'actor_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..083ae37 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,401 @@ +updateOrCreate( + ['internal_id' => $category['internal_id']], + array_merge($category, ['deleted_at' => null]) + ); + } + } + + /** + * @return array> + */ + public static function categories(): array + { + return [ + [ + 'slug' => 'telematics', + 'name' => 'Telematics', + 'internal_id' => 'FLBTELEMATICS', + 'icon' => 'satellite-dish', + 'description' => 'Advanced tracking and diagnostics for vehicle fleets, including real-time data and analytics.', + 'tags' => ['tracking', 'diagnostics', 'real-time', 'analytics'], + 'sort' => 0, + ], + [ + 'slug' => 'inventory', + 'name' => 'Inventory', + 'internal_id' => 'FLBINVENTORY', + 'icon' => 'boxes-packing', + 'description' => 'Tools for inventory management, stock control, and supply chain visibility.', + 'tags' => ['management', 'stock', 'supply chain'], + 'sort' => 1, + ], + [ + 'slug' => 'accounting', + 'name' => 'Accounting', + 'internal_id' => 'FLBACCOUNTING', + 'icon' => 'cash-register', + 'description' => 'Financial management solutions including invoicing, expense tracking, and financial reporting.', + 'tags' => ['finance', 'invoicing', 'expenses', 'reporting'], + 'sort' => 2, + ], + [ + 'slug' => 'route-optimization', + 'name' => 'Route Optimization', + 'internal_id' => 'FLBROUTE', + 'icon' => 'route', + 'description' => 'Enhancements for efficient route planning and optimization to reduce travel time and costs.', + 'tags' => ['route', 'planning', 'optimization', 'GPS', 'mapping'], + 'sort' => 3, + ], + [ + 'slug' => 'fleet-management', + 'name' => 'Fleet Management', + 'internal_id' => 'FLBFLEET', + 'icon' => 'truck-moving', + 'description' => 'Tools for managing fleet vehicles, including maintenance, tracking, and performance analysis.', + 'tags' => ['vehicles', 'maintenance', 'tracking', 'analytics'], + 'sort' => 4, + ], + [ + 'slug' => 'order-management', + 'name' => 'Order Management', + 'internal_id' => 'FLBORDER', + 'icon' => 'clipboard-list', + 'description' => 'Solutions for handling orders from placement to delivery, including tracking and customer communication.', + 'tags' => ['orders', 'tracking', 'delivery', 'customer service'], + 'sort' => 5, + ], + [ + 'slug' => 'warehouse-management', + 'name' => 'Warehouse Management', + 'internal_id' => 'FLBWAREHOUSE', + 'icon' => 'warehouse', + 'description' => 'Systems for managing warehouse operations, inventory storage, and logistics.', + 'tags' => ['warehouse', 'inventory', 'logistics', 'storage'], + 'sort' => 6, + ], + [ + 'slug' => 'compliance-and-regulations', + 'name' => 'Compliance and Regulations', + 'internal_id' => 'FLBCOMPLIANCE', + 'icon' => 'scale-balanced', + 'description' => 'Tools to ensure compliance with local and international shipping and logistics regulations.', + 'tags' => ['compliance', 'regulations', 'shipping', 'international'], + 'sort' => 7, + ], + [ + 'slug' => 'analytics-and-reporting', + 'name' => 'Analytics and Reporting', + 'internal_id' => 'FLBANALYTICS', + 'icon' => 'chart-line', + 'description' => 'Advanced analytics and reporting tools for insights into logistics operations.', + 'tags' => ['analytics', 'reporting', 'data', 'insights'], + 'sort' => 8, + ], + [ + 'slug' => 'customer-relationship-management', + 'name' => 'Customer Relationship Management (CRM)', + 'internal_id' => 'FLBCRM', + 'icon' => 'users', + 'description' => 'Extensions to manage customer interactions, support, and relationships.', + 'tags' => ['CRM', 'customer', 'support', 'relationship'], + 'sort' => 9, + ], + [ + 'slug' => 'e-commerce', + 'name' => 'E-commerce', + 'internal_id' => 'FLBECOMMERCE', + 'icon' => 'store', + 'description' => 'Comprehensive e-commerce solutions, ranging from integration tools with existing platforms to complete e-commerce applications, enhancing online retail and digital transactions.', + 'tags' => ['e-commerce', 'integration', 'shopping', 'online'], + 'sort' => 10, + ], + [ + 'slug' => 'security-and-risk-management', + 'name' => 'Security and Risk Management', + 'internal_id' => 'FLBSECURITY', + 'icon' => 'shield-alt', + 'description' => 'Enhancements for improving security and managing risks in logistics operations.', + 'tags' => ['security', 'risk management', 'safety', 'protection'], + 'sort' => 11, + ], + [ + 'slug' => 'transportation-management', + 'name' => 'Transportation Management', + 'internal_id' => 'FLBTRANSPORT', + 'icon' => 'truck-fast', + 'description' => 'Solutions for managing and optimizing different modes of transportation, including carrier selection and freight consolidation.', + 'tags' => ['transportation', 'freight', 'carrier management', 'optimization'], + 'sort' => 12, + ], + [ + 'slug' => 'customs-and-trade-compliance', + 'name' => 'Customs and Trade Compliance', + 'internal_id' => 'FLBCUSTOMS', + 'icon' => 'passport', + 'description' => 'Tools to navigate and comply with international trade laws, customs regulations, and import/export requirements.', + 'tags' => ['customs', 'trade', 'compliance', 'import', 'export'], + 'sort' => 13, + ], + [ + 'slug' => 'supplier-relationship-management', + 'name' => 'Supplier Relationship Management', + 'internal_id' => 'FLBSUPPLIER', + 'icon' => 'handshake', + 'description' => 'Extensions to manage and enhance relationships with suppliers, including communication and performance tracking.', + 'tags' => ['suppliers', 'relationship management', 'performance', 'communication'], + 'sort' => 14, + ], + [ + 'slug' => 'quality-control', + 'name' => 'Quality Control', + 'internal_id' => 'FLBQUALITY', + 'icon' => 'check-circle', + 'description' => 'Tools for quality assurance in supply chain processes, including product inspections and compliance monitoring.', + 'tags' => ['quality control', 'inspection', 'compliance', 'assurance'], + 'sort' => 15, + ], + [ + 'slug' => 'forecasting-and-demand-planning', + 'name' => 'Forecasting and Demand Planning', + 'internal_id' => 'FLBFORECASTING', + 'icon' => 'magnifying-glass-location', + 'description' => 'Analytical tools for accurate demand forecasting and inventory optimization based on market trends and data analysis.', + 'tags' => ['forecasting', 'demand planning', 'inventory', 'market trends'], + 'sort' => 16, + ], + [ + 'slug' => 'procurement-and-purchasing', + 'name' => 'Procurement and Purchasing', + 'internal_id' => 'FLBPROCUREMENT', + 'icon' => 'basket-shopping', + 'description' => 'Solutions for managing procurement processes, including purchase orders, vendor selection, and contract management.', + 'tags' => ['procurement', 'purchasing', 'vendors', 'contracts'], + 'sort' => 17, + ], + [ + 'slug' => 'asset-tracking-and-management', + 'name' => 'Asset Tracking and Management', + 'internal_id' => 'FLBASSET', + 'icon' => 'barcode', + 'description' => 'Tools for tracking and managing assets within the supply chain, including RFID, barcode, and IoT technologies.', + 'tags' => ['asset tracking', 'management', 'RFID', 'IoT'], + 'sort' => 18, + ], + [ + 'slug' => 'sustainability-and-environmental-compliance', + 'name' => 'Sustainability and Environmental Compliance', + 'internal_id' => 'FLBENVIRONMENT', + 'icon' => 'leaf', + 'description' => 'Solutions focused on environmental compliance and sustainable practices in logistics and supply chain operations.', + 'tags' => ['sustainability', 'environmental compliance', 'green logistics', 'eco-friendly'], + 'sort' => 19, + ], + [ + 'slug' => 'human-resources-management', + 'name' => 'Human Resources Management', + 'internal_id' => 'FLBHR', + 'icon' => 'user-tie', + 'description' => 'Extensions for managing logistics workforce, including scheduling, training, and performance evaluations.', + 'tags' => ['human resources', 'workforce management', 'training', 'scheduling'], + 'sort' => 20, + ], + [ + 'slug' => 'collaboration-and-communication-tools', + 'name' => 'Collaboration and Communication Tools', + 'internal_id' => 'FLBCOLLABORATION', + 'icon' => 'comments', + 'description' => 'Platforms and tools to facilitate effective collaboration and communication among supply chain stakeholders.', + 'tags' => ['collaboration', 'communication', 'teamwork', 'stakeholders'], + 'sort' => 21, + ], + [ + 'slug' => 'artificial-intelligence', + 'name' => 'Artificial Intelligence', + 'internal_id' => 'FLBAI', + 'icon' => 'robot', + 'description' => 'Advanced AI-driven tools and applications for predictive analytics, automation, and intelligent decision-making in logistics.', + 'tags' => ['AI', 'predictive analytics', 'automation', 'intelligence'], + 'sort' => 22, + ], + [ + 'slug' => 'freight-forwarding', + 'name' => 'Freight Forwarding', + 'internal_id' => 'FLBFREIGHTFORWARD', + 'icon' => 'truck-ramp-box', + 'description' => 'Comprehensive solutions for managing and optimizing the process of freight forwarding, including documentation, cargo handling, and customs brokerage.', + 'tags' => ['freight forwarding', 'cargo handling', 'customs', 'logistics'], + 'sort' => 23, + ], + [ + 'slug' => 'air-cargo-logistics', + 'name' => 'Air Cargo Logistics', + 'internal_id' => 'FLBAIRCARGO', + 'icon' => 'plane', + 'description' => 'Specialized tools for air cargo handling, including tracking, capacity management, and air freight optimization.', + 'tags' => ['air cargo', 'tracking', 'capacity management', 'air freight'], + 'sort' => 24, + ], + [ + 'slug' => 'bulk-shipping', + 'name' => 'Bulk Shipping', + 'internal_id' => 'FLBBULKSHIPPING', + 'icon' => 'anchor', + 'description' => 'Solutions for managing bulk cargo shipments, including vessel chartering, loading and unloading operations, and safety compliance.', + 'tags' => ['bulk shipping', 'vessel chartering', 'cargo operations', 'safety'], + 'sort' => 25, + ], + [ + 'slug' => 'lng-logistics', + 'name' => 'LNG Logistics', + 'internal_id' => 'FLBLNGLOGISTICS', + 'icon' => 'gas-pump', + 'description' => 'Tools and technologies for handling Liquefied Natural Gas (LNG) logistics, covering transportation, storage, and distribution.', + 'tags' => ['LNG', 'liquefied natural gas', 'transportation', 'storage'], + 'sort' => 26, + ], + [ + 'slug' => 'train-cargo-management', + 'name' => 'Train Cargo Management', + 'internal_id' => 'FLBTRAINCARGO', + 'icon' => 'train', + 'description' => 'Systems for managing train cargo logistics, including scheduling, cargo tracking, and railway compliance.', + 'tags' => ['train cargo', 'scheduling', 'tracking', 'railway'], + 'sort' => 27, + ], + [ + 'slug' => 'container-haulage', + 'name' => 'Container Haulage', + 'internal_id' => 'FLBCONTAINERHAULAGE', + 'icon' => 'trailer', + 'description' => 'Efficient management of container haulage operations, with features for container tracking, port operations, and haulier scheduling.', + 'tags' => ['container', 'haulage', 'tracking', 'port operations'], + 'sort' => 28, + ], + [ + 'slug' => 'tactical-logistics', + 'name' => 'Tactical Logistics', + 'internal_id' => 'FLBTACTICAL', + 'icon' => 'jet-fighter', + 'description' => 'Solutions for tactical support, including field logistics, deployment planning, and military supply chain management.', + 'tags' => ['tactical', 'field logistics', 'deployment', 'military supply'], + 'sort' => 29, + ], + [ + 'slug' => 'secure-communications', + 'name' => 'Secure Communications', + 'internal_id' => 'FLBSECURECOMMS', + 'icon' => 'walkie-talkie', + 'description' => 'Secure and encrypted communication tools designed for military-grade confidentiality and reliability.', + 'tags' => ['secure', 'encrypted', 'communication', 'military'], + 'sort' => 30, + ], + [ + 'slug' => 'surveillance-and-reconnaissance', + 'name' => 'Surveillance and Reconnaissance', + 'internal_id' => 'FLBSURVEILLANCE', + 'icon' => 'binoculars', + 'description' => 'Advanced surveillance and reconnaissance tools, including UAVs, sensors, and satellite imaging for operational intelligence.', + 'tags' => ['surveillance', 'reconnaissance', 'UAV', 'intelligence'], + 'sort' => 31, + ], + [ + 'slug' => 'cybersecurity', + 'name' => 'Cybersecurity', + 'internal_id' => 'FLBCYBERSECURITY', + 'icon' => 'fingerprint', + 'description' => 'Cybersecurity solutions to protect logistics data and operations from cyber threats and attacks.', + 'tags' => ['cybersecurity', 'data protection', 'cyber threats', 'security'], + 'sort' => 32, + ], + [ + 'slug' => 'mission-planning-and-analysis', + 'name' => 'Mission Planning and Analysis', + 'internal_id' => 'FLBMISSIONPLANNING', + 'icon' => 'map-marked-alt', + 'description' => 'Tools for strategic mission planning and analysis, including scenario modeling and operational readiness assessments.', + 'tags' => ['mission planning', 'analysis', 'modeling', 'readiness'], + 'sort' => 33, + ], + [ + 'slug' => 'armaments-and-ammunition-management', + 'name' => 'Armaments and Ammunition Management', + 'internal_id' => 'FLBARMAMANAGEMENT', + 'icon' => 'person-rifle', + 'description' => 'Management tools for tracking and maintaining armaments and ammunition inventories.', + 'tags' => ['armaments', 'ammunition', 'inventory', 'management'], + 'sort' => 34, + ], + [ + 'slug' => 'emergency-and-disaster-response', + 'name' => 'Emergency and Disaster Response', + 'internal_id' => 'FLBEMERGENCYRESPONSE', + 'icon' => 'truck-medical', + 'description' => 'Solutions for organizing and managing logistics in emergency and disaster response scenarios.', + 'tags' => ['emergency', 'disaster response', 'logistics', 'crisis management'], + 'sort' => 35, + ], + [ + 'slug' => 'training-and-simulation', + 'name' => 'Training and Simulation', + 'internal_id' => 'FLBTRAININGSIM', + 'icon' => 'person-running', + 'description' => 'Advanced training tools and simulation software for military logistics and operational training.', + 'tags' => ['training', 'simulation', 'military', 'logistics'], + 'sort' => 36, + ], + [ + 'slug' => 'vehicle-and-equipment-maintenance', + 'name' => 'Vehicle and Equipment Maintenance', + 'internal_id' => 'FLBVEHICLEMAINT', + 'icon' => 'wrench', + 'description' => 'Maintenance management systems for military vehicles and equipment, including predictive maintenance tools.', + 'tags' => ['vehicle', 'equipment', 'maintenance', 'predictive tools'], + 'sort' => 37, + ], + [ + 'slug' => 'manufacturing-operations', + 'name' => 'Manufacturing Operations', + 'internal_id' => 'FLBMANUFACTURING', + 'icon' => 'industry', + 'description' => 'Tools and systems designed for the manufacturing industry, including automation, process control, and production planning.', + 'tags' => ['manufacturing', 'automation', 'process control', 'production'], + 'sort' => 38, + ], + [ + 'slug' => 'medical-logistics', + 'name' => 'Medical Logistics', + 'internal_id' => 'FLBMEDICAL', + 'icon' => 'kit-medical', + 'description' => 'Solutions tailored for the medical industry, focusing on medical supply chain, equipment tracking, and pharmaceutical logistics.', + 'tags' => ['medical', 'pharmaceutical', 'supply chain', 'equipment tracking'], + 'sort' => 39, + ], + [ + 'slug' => 'mining-industry', + 'name' => 'Mining Industry', + 'internal_id' => 'FLBMINING', + 'icon' => 'gem', + 'description' => 'Specialized solutions for the mining industry, encompassing resource management, excavation planning, and safety protocols.', + 'tags' => ['mining', 'resource management', 'excavation', 'safety'], + 'sort' => 40, + ], + ]; + } +} From 68d5bb6584c24db0386737cc8c52e4b4de9ca48e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:48:38 +0800 Subject: [PATCH 04/16] Add account auth: register, verification codes, login, tokens - POST /v1/auth/register creates the account (pending_verification) and a personal organization with an owner membership, then emails a 6-digit code; /v1/auth/verify activates it; /v1/auth/verify/resend never reveals whether an account exists. - POST /v1/auth/login exchanges email and password (Hash::check, the same bcrypt scheme as core-api's Auth::checkPassword) for a personal token returned once; /v1/auth/logout revokes the current token. - Personal tokens: flbp_ prefix, SHA-256 hashed at rest, scoped (read, publish, install, instance:register, *), expiring (max 365 days), revocable, last-use stamped; prefix + last four shown in lists. TokenGuard backs the auth:token guard; scope: middleware. - GET /v1/account returns the caller with organizations and roles. - Fleetbase error envelope {errors: [...]} for API exceptions, rate limits on auth routes, Pest feature tests for every flow. --- app/Actions/RegisterAccount.php | 88 ++++++++++++ app/Auth/TokenGuard.php | 42 ++++++ .../Controllers/Account/AccountController.php | 18 +++ .../Controllers/Account/TokenController.php | 89 ++++++++++++ app/Http/Controllers/Auth/AuthController.php | 135 ++++++++++++++++++ app/Http/Middleware/EnsureTokenScope.php | 31 ++++ .../Requests/Account/CreateTokenRequest.php | 26 ++++ app/Http/Requests/Auth/LoginRequest.php | 27 ++++ app/Http/Requests/Auth/RegisterRequest.php | 32 +++++ app/Http/Requests/Auth/VerifyEmailRequest.php | 21 +++ app/Http/Resources/AccountResource.php | 34 +++++ app/Http/Resources/FleetbaseResource.php | 2 - app/Http/Resources/OrganizationResource.php | 27 ++++ app/Http/Resources/TokenResource.php | 30 ++++ app/Mail/VerificationCodeMail.php | 36 +++++ app/Models/Account.php | 1 + app/Models/Membership.php | 14 +- app/Models/Organization.php | 1 + app/Providers/AppServiceProvider.php | 57 +++++++- bootstrap/app.php | 45 +++++- config/auth.php | 96 ++----------- config/registry.php | 36 +++++ .../views/mail/verification-code.blade.php | 18 +++ routes/api.php | 35 +++++ routes/web.php | 9 +- tests/Feature/Auth/AccountTest.php | 51 +++++++ tests/Feature/Auth/EmailVerificationTest.php | 55 +++++++ tests/Feature/Auth/LoginTest.php | 105 ++++++++++++++ tests/Feature/Auth/RegistrationTest.php | 73 ++++++++++ tests/Feature/Auth/TokenTest.php | 78 ++++++++++ tests/Pest.php | 24 ++++ tests/TestCase.php | 10 +- tests/Unit/ExampleTest.php | 16 --- tests/Unit/MoneyCastTest.php | 11 ++ tests/Unit/PublicIdTest.php | 15 ++ 35 files changed, 1274 insertions(+), 114 deletions(-) create mode 100644 app/Actions/RegisterAccount.php create mode 100644 app/Auth/TokenGuard.php create mode 100644 app/Http/Controllers/Account/AccountController.php create mode 100644 app/Http/Controllers/Account/TokenController.php create mode 100644 app/Http/Controllers/Auth/AuthController.php create mode 100644 app/Http/Middleware/EnsureTokenScope.php create mode 100644 app/Http/Requests/Account/CreateTokenRequest.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/Auth/RegisterRequest.php create mode 100644 app/Http/Requests/Auth/VerifyEmailRequest.php create mode 100644 app/Http/Resources/AccountResource.php create mode 100644 app/Http/Resources/OrganizationResource.php create mode 100644 app/Http/Resources/TokenResource.php create mode 100644 app/Mail/VerificationCodeMail.php create mode 100644 config/registry.php create mode 100644 resources/views/mail/verification-code.blade.php create mode 100644 routes/api.php create mode 100644 tests/Feature/Auth/AccountTest.php create mode 100644 tests/Feature/Auth/EmailVerificationTest.php create mode 100644 tests/Feature/Auth/LoginTest.php create mode 100644 tests/Feature/Auth/RegistrationTest.php create mode 100644 tests/Feature/Auth/TokenTest.php create mode 100644 tests/Pest.php delete mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/MoneyCastTest.php create mode 100644 tests/Unit/PublicIdTest.php diff --git a/app/Actions/RegisterAccount.php b/app/Actions/RegisterAccount.php new file mode 100644 index 0000000..5a051b0 --- /dev/null +++ b/app/Actions/RegisterAccount.php @@ -0,0 +1,88 @@ +uniqueUsername($input['email']); + + $account = Account::create([ + 'name' => $input['name'], + 'email' => Str::lower($input['email']), + 'username' => $username, + 'password' => $input['password'], + 'status' => Account::STATUS_PENDING_VERIFICATION, + 'last_login_ip' => $ip, + ]); + + $organization = Organization::create([ + 'slug' => $this->uniqueSlug($username), + 'name' => $input['organization_name'] ?? $input['name'], + 'is_personal' => true, + ]); + + Membership::create([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'role' => Membership::ROLE_OWNER, + ]); + + Event::record('account.registered', $account, $account, ['organization' => $organization->public_id], $ip); + + return $account; + }); + + $this->sendVerificationCode($account); + + return $account; + } + + public function sendVerificationCode(Account $account): VerificationCode + { + $code = VerificationCode::generateFor($account); + + Mail::to($account->email)->send(new VerificationCodeMail($account, $code)); + + return $code; + } + + protected function uniqueUsername(string $email): string + { + $base = Utils::slugify(Str::before($email, '@')); + $candidate = $base; + + while (Account::withTrashed()->where('username', $candidate)->exists() || Organization::withTrashed()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.Str::lower(Str::random(4)); + } + + return $candidate; + } + + protected function uniqueSlug(string $base): string + { + $candidate = Utils::slugify($base); + + while (Organization::withTrashed()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.Str::lower(Str::random(4)); + } + + return $candidate; + } +} diff --git a/app/Auth/TokenGuard.php b/app/Auth/TokenGuard.php new file mode 100644 index 0000000..93d279a --- /dev/null +++ b/app/Auth/TokenGuard.php @@ -0,0 +1,42 @@ +bearerToken(); + + if ($plain === '' || ! str_starts_with($plain, Token::PREFIX_PERSONAL)) { + return null; + } + + $token = Token::findActive($plain); + if (! $token) { + return null; + } + + $account = $token->account; + if (! $account || ! $account->isActive()) { + return null; + } + + $token->touchUsage($request->ip()); + $request->attributes->set('token', $token); + + return $account->withCurrentToken($token); + } +} diff --git a/app/Http/Controllers/Account/AccountController.php b/app/Http/Controllers/Account/AccountController.php new file mode 100644 index 0000000..b83ad74 --- /dev/null +++ b/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,18 @@ +user()->load('organizations')); + } +} diff --git a/app/Http/Controllers/Account/TokenController.php b/app/Http/Controllers/Account/TokenController.php new file mode 100644 index 0000000..2033eba --- /dev/null +++ b/app/Http/Controllers/Account/TokenController.php @@ -0,0 +1,89 @@ +user(); + + $tokens = $account->tokens()->with('organization')->whereNull('revoked_at')->orderByDesc('created_at')->get(); + + return TokenResource::collection($tokens); + } + + /** + * POST /v1/account/tokens — creates a token; the plaintext is only returned here. + */ + public function store(CreateTokenRequest $request): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + + $organizationUuid = null; + if ($request->filled('organization')) { + $organization = $account->organizations() + ->where(fn ($q) => $q->where('organizations.public_id', $request->input('organization'))->orWhere('organizations.slug', $request->input('organization'))) + ->first(); + + if (! $organization instanceof Organization) { + return response()->error('You are not a member of that organization.', 422); + } + + $organizationUuid = $organization->uuid; + } + + $token = Token::issue( + $account, + $request->input('name'), + $request->input('scopes'), + now()->addDays((int) ($request->input('expires_in_days') ?: Token::MAX_LIFETIME_DAYS)), + $organizationUuid, + ); + + $token->load('organization'); + Event::record('token.created', $token, $account, ['scopes' => $token->scopes], $request->ip()); + + return response()->json([ + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + 'expires_at' => $token->expires_at, + 'token_details' => (new TokenResource($token))->resolve($request), + ], 201); + } + + /** + * DELETE /v1/account/tokens/{token} — revokes one of the caller's tokens. + */ + public function destroy(Request $request, string $id): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + + $token = $account->tokens()->where(fn ($q) => $q->where('public_id', $id)->orWhere('uuid', $id))->first(); + + if (! $token) { + return response()->error('Token not found.', 404); + } + + $token->revoke(); + Event::record('token.revoked', $token, $account, [], $request->ip()); + + return response()->json(['revoked' => true, 'id' => $token->public_id]); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..f45bc76 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,135 @@ +validated(), $request->ip()); + + return response()->json([ + 'account' => (new AccountResource($account))->resolve($request), + 'message' => 'Account created. Check your email for a verification code.', + ], 201); + } + + /** + * POST /v1/auth/verify + */ + public function verify(VerifyEmailRequest $request): JsonResponse + { + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if (! $account) { + return response()->error('No account found for that email.', 404); + } + + if ($account->hasVerifiedEmail()) { + return response()->json(['verified' => true, 'message' => 'Email already verified.']); + } + + if (! VerificationCode::consume($account, $request->input('code'))) { + return response()->error('Invalid or expired verification code.', 422); + } + + $account->markEmailAsVerified(); + Event::record('account.verified', $account, $account, [], $request->ip()); + + return response()->json(['verified' => true, 'message' => 'Email verified. You can now log in.']); + } + + /** + * POST /v1/auth/verify/resend + */ + public function resendVerification(Request $request, RegisterAccount $registerAccount): JsonResponse + { + $request->validate(['email' => ['required', 'email:rfc']]); + + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + // Always answer the same way so this cannot be used to enumerate accounts. + if ($account && ! $account->hasVerifiedEmail()) { + $registerAccount->sendVerificationCode($account); + } + + return response()->json(['message' => 'If that account needs verification, a new code has been sent.']); + } + + /** + * POST /v1/auth/login + * + * Exchanges email and password for a personal token, shown once. + * The password check uses Hash::check, the same as core-api's Auth::checkPassword. + */ + public function login(LoginRequest $request): JsonResponse + { + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if (! $account || ! $account->password || ! Hash::check($request->input('password'), $account->password)) { + Event::record('account.login_failed', $account, null, ['email' => Str::lower($request->input('email'))], $request->ip()); + + return response()->error('Invalid email or password.', 401); + } + + if (! $account->hasVerifiedEmail()) { + return response()->error('Email address is not verified.', 403, ['code' => 'email_unverified']); + } + + if (! $account->isActive()) { + return response()->error('This account is '.$account->status.'.', 403, ['code' => 'account_'.$account->status]); + } + + $token = Token::issue( + $account, + $request->input('token_name') ?: 'login '.now()->toDateString(), + $request->input('scopes') ?: [Token::SCOPE_ALL], + now()->addDays((int) ($request->input('expires_in_days') ?: 30)), + ); + + $account->forceFill(['last_login_at' => now(), 'last_login_ip' => $request->ip()])->save(); + Event::record('account.login', $account, $account, ['token' => $token->public_id], $request->ip()); + + return response()->json([ + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + 'expires_at' => $token->expires_at, + 'token_details' => (new TokenResource($token))->resolve($request), + 'account' => (new AccountResource($account->load('organizations')))->resolve($request), + ]); + } + + /** + * POST /v1/auth/logout — revokes the token used for this request. + */ + public function logout(Request $request): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + $token = $account->currentToken(); + + $token?->revoke(); + Event::record('account.logout', $account, $account, ['token' => $token?->public_id], $request->ip()); + + return response()->json(['message' => 'Logged out.']); + } +} diff --git a/app/Http/Middleware/EnsureTokenScope.php b/app/Http/Middleware/EnsureTokenScope.php new file mode 100644 index 0000000..373e08c --- /dev/null +++ b/app/Http/Middleware/EnsureTokenScope.php @@ -0,0 +1,31 @@ +user(); + + if (! $account instanceof Account || ! $account->currentToken()) { + return response()->error('Unauthenticated.', 401); + } + + foreach ($scopes as $scope) { + if (! $account->tokenCan($scope)) { + return response()->error("This token does not have the '{$scope}' scope.", 403); + } + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Account/CreateTokenRequest.php b/app/Http/Requests/Account/CreateTokenRequest.php new file mode 100644 index 0000000..164cb1a --- /dev/null +++ b/app/Http/Requests/Account/CreateTokenRequest.php @@ -0,0 +1,26 @@ + ['required', 'string', 'max:80'], + 'scopes' => ['required', 'array', 'min:1'], + 'scopes.*' => ['string', Rule::in(Token::SCOPES)], + 'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:'.Token::MAX_LIFETIME_DAYS], + 'organization' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..c5624db --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'email:rfc'], + 'password' => ['required', 'string'], + 'token_name' => ['nullable', 'string', 'max:80'], + 'scopes' => ['nullable', 'array'], + 'scopes.*' => ['string', Rule::in(Token::SCOPES)], + 'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:'.Token::MAX_LIFETIME_DAYS], + ]; + } +} diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..71b0432 --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:120'], + 'email' => ['required', 'string', 'email:rfc', 'max:191', 'unique:accounts,email'], + 'username' => ['nullable', 'string', 'min:3', 'max:40', 'regex:/^[a-z0-9][a-z0-9-]*$/', 'unique:accounts,username', 'unique:organizations,slug'], + 'password' => ['required', 'string', Password::min(8)->letters()->numbers(), 'max:128'], + 'organization_name' => ['nullable', 'string', 'max:120'], + ]; + } + + public function messages(): array + { + return [ + 'username.regex' => 'The username may only contain lowercase letters, numbers and dashes.', + ]; + } +} diff --git a/app/Http/Requests/Auth/VerifyEmailRequest.php b/app/Http/Requests/Auth/VerifyEmailRequest.php new file mode 100644 index 0000000..1fdda9d --- /dev/null +++ b/app/Http/Requests/Auth/VerifyEmailRequest.php @@ -0,0 +1,21 @@ + ['required', 'string', 'email:rfc'], + 'code' => ['required', 'string', 'digits:6'], + ]; + } +} diff --git a/app/Http/Resources/AccountResource.php b/app/Http/Resources/AccountResource.php new file mode 100644 index 0000000..c2114e7 --- /dev/null +++ b/app/Http/Resources/AccountResource.php @@ -0,0 +1,34 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'avatar_url' => $this->avatar_url, + 'timezone' => $this->timezone, + 'status' => $this->status, + 'email_verified_at' => $this->email_verified_at, + 'organizations' => $this->whenLoaded('organizations', fn () => $this->organizations->map(fn ($organization) => [ + 'id' => $organization->public_id, + 'slug' => $organization->slug, + 'name' => $organization->name, + 'role' => $organization->pivot->role, + 'is_personal' => $organization->is_personal, + ])->all()), + 'last_login_at' => $this->last_login_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/FleetbaseResource.php b/app/Http/Resources/FleetbaseResource.php index adae9fc..a3248ee 100644 --- a/app/Http/Resources/FleetbaseResource.php +++ b/app/Http/Resources/FleetbaseResource.php @@ -13,8 +13,6 @@ */ class FleetbaseResource extends JsonResource { - public static $wrap = 'data'; - protected array $excluded = []; public function toArray($request) diff --git a/app/Http/Resources/OrganizationResource.php b/app/Http/Resources/OrganizationResource.php new file mode 100644 index 0000000..e84fbb4 --- /dev/null +++ b/app/Http/Resources/OrganizationResource.php @@ -0,0 +1,27 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'website' => $this->website, + 'bio' => $this->bio, + 'avatar_url' => $this->avatar_url, + 'is_personal' => $this->is_personal, + 'is_verified' => $this->verified_at !== null, + 'verified_at' => $this->verified_at, + 'extensions_count' => $this->whenCounted('extensions'), + 'extensions' => $this->whenLoaded('extensions', fn () => ExtensionSummaryResource::collection($this->extensions)->resolve($request)), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/TokenResource.php b/app/Http/Resources/TokenResource.php new file mode 100644 index 0000000..bb95a78 --- /dev/null +++ b/app/Http/Resources/TokenResource.php @@ -0,0 +1,30 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'name' => $this->name, + 'masked' => $this->masked, + 'prefix' => $this->prefix, + 'last_four' => $this->last_four, + 'scopes' => $this->scopes ?? [], + 'organization' => $this->whenLoaded('organization', fn () => $this->organization?->public_id, null), + 'expires_at' => $this->expires_at, + 'last_used_at' => $this->last_used_at, + 'last_used_ip' => $this->last_used_ip, + 'revoked_at' => $this->revoked_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Mail/VerificationCodeMail.php b/app/Mail/VerificationCodeMail.php new file mode 100644 index 0000000..a5e1cdb --- /dev/null +++ b/app/Mail/VerificationCodeMail.php @@ -0,0 +1,36 @@ + $this->account->name, + 'code' => $this->verificationCode->code, + 'minutes' => VerificationCode::LIFETIME_MINUTES, + ], + ); + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 9b46d1a..6759e74 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -69,6 +69,7 @@ public function memberships(): HasMany public function organizations(): BelongsToMany { return $this->belongsToMany(Organization::class, 'memberships', 'account_uuid', 'organization_uuid', 'uuid', 'uuid') + ->using(Membership::class) ->withPivot('role') ->withTimestamps(); } diff --git a/app/Models/Membership.php b/app/Models/Membership.php index 3bcd5db..ac34893 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -4,8 +4,12 @@ use App\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Pivot; -class Membership extends Model +/** + * Account ↔ organization link with a role. A pivot model so `attach()` still assigns a uuid. + */ +class Membership extends Pivot { use HasUuid; @@ -23,6 +27,14 @@ class Membership extends Model protected $table = 'memberships'; + protected $primaryKey = 'uuid'; + + protected $keyType = 'string'; + + public $incrementing = false; + + public $timestamps = true; + protected $fillable = ['organization_uuid', 'account_uuid', 'role']; protected $hidden = ['id']; diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 4bc13bf..5689bc6 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -50,6 +50,7 @@ public function memberships(): HasMany public function accounts(): BelongsToMany { return $this->belongsToMany(Account::class, 'memberships', 'organization_uuid', 'account_uuid', 'uuid', 'uuid') + ->using(Membership::class) ->withPivot('role') ->withTimestamps(); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..cc5b733 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,68 @@ namespace App\Providers; +use App\Auth\TokenGuard; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Response; +use Illuminate\Support\MessageBag; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { // } + public function boot(): void + { + // Fleetbase resources are returned unwrapped; paginated lists get { data, meta }. + JsonResource::withoutWrapping(); + + Model::shouldBeStrict(! $this->app->isProduction()); + + $this->registerAuthGuard(); + $this->registerResponseMacros(); + $this->registerRateLimiters(); + } + /** - * Bootstrap any application services. + * `auth:token` resolves personal tokens (flbp_…) to accounts. */ - public function boot(): void + protected function registerAuthGuard(): void { - // + Auth::viaRequest('registry-token', new TokenGuard); + } + + /** + * `response()->error('message', 422)` → { "errors": ["message"], ...extra } + */ + protected function registerResponseMacros(): void + { + Response::macro('error', function ($error, int $statusCode = 400, array $data = []): JsonResponse { + if ($error instanceof MessageBag) { + $error = $error->all(); + } + + return Response::json(array_merge(['errors' => is_array($error) ? array_values($error) : [$error]], $data), $statusCode); + }); + } + + protected function registerRateLimiters(): void + { + RateLimiter::for('auth', fn (Request $request) => [ + Limit::perMinute(10)->by($request->ip()), + Limit::perMinute(5)->by('email:'.strtolower((string) $request->input('email')).':'.$request->ip()), + ]); + + RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..3591d36 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,18 +1,59 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + apiPrefix: '', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'scope' => EnsureTokenScope::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $isApi = fn (Request $request) => $request->is('v1/*') || $request->is('v1') || $request->expectsJson(); + + $exceptions->shouldRenderJsonWhen(fn (Request $request) => $isApi($request)); + + // Fleetbase-style error envelope: { "errors": ["..."] } + $exceptions->render(function (ValidationException $e, Request $request) use ($isApi) { + if ($isApi($request)) { + return response()->error($e->validator->errors()->all(), 422, ['fields' => $e->errors()]); + } + }); + + $exceptions->render(function (AuthenticationException $e, Request $request) use ($isApi) { + if ($isApi($request)) { + return response()->error('Unauthenticated.', 401); + } + }); + + $exceptions->render(function (ModelNotFoundException|NotFoundHttpException $e, Request $request) use ($isApi) { + if ($isApi($request)) { + return response()->error('Resource not found.', 404); + } + }); + + $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isApi) { + if ($isApi($request)) { + $message = $e->getMessage() ?: (Response::$statusTexts[$e->getStatusCode()] ?? 'Request failed.'); + + return response()->error($message, $e->getStatusCode())->withHeaders($e->getHeaders()); + } + }); })->create(); diff --git a/config/auth.php b/config/auth.php index d7568ff..b4154d1 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,6 +1,6 @@ [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + 'guard' => env('AUTH_GUARD', 'token'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'accounts'), ], - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', + 'token' => [ + 'driver' => 'registry-token', + 'provider' => 'accounts', ], ], - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - 'providers' => [ - 'users' => [ + 'accounts' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), + 'model' => Account::class, ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], ], - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'accounts' => [ + 'provider' => 'accounts', + 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60, ], ], - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), ]; diff --git a/config/registry.php b/config/registry.php new file mode 100644 index 0000000..a533c56 --- /dev/null +++ b/config/registry.php @@ -0,0 +1,36 @@ + env('REGISTRY_VERSION', '2.0.0-dev'), + + // Cache lifetime for public catalog responses, in seconds. + 'catalog_cache_seconds' => (int) env('REGISTRY_CATALOG_CACHE_SECONDS', 60), + + // Package namespaces reserved for the Fleetbase staff organization. + 'reserved_scopes' => ['fleetbase'], + + // Slug of the staff organization that may publish reserved scopes and review releases. + 'staff_organization' => env('REGISTRY_STAFF_ORGANIZATION', 'fleetbase'), + + // Maximum uploaded bundle size in bytes (used by release checks in Workstream C). + 'max_bundle_bytes' => (int) env('REGISTRY_MAX_BUNDLE_BYTES', 50 * 1024 * 1024), + + 'storage' => [ + // Disk that holds tarballs, prebuilt dist archives, icons and screenshots. + 'disk' => env('REGISTRY_STORAGE_DISK', 's3'), + 'prefixes' => [ + 'tarballs' => 'tarballs', + 'dist' => 'dist', + 'icons' => 'icons', + 'screenshots' => 'screenshots', + ], + ], + +]; diff --git a/resources/views/mail/verification-code.blade.php b/resources/views/mail/verification-code.blade.php new file mode 100644 index 0000000..908ad2e --- /dev/null +++ b/resources/views/mail/verification-code.blade.php @@ -0,0 +1,18 @@ + +# Verify your email + +Hi {{ $name }}, + +Enter this code to verify your {{ config('app.name') }} account. It expires in {{ $minutes }} minutes. + + +{{ $code }} + + +From the CLI, run `flb verify --email {{ $account->email }} --code {{ $code }}`. + +If you did not create an account you can ignore this email. + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..dd00e10 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,35 @@ +name('v1.')->group(function () { + Route::prefix('auth')->name('auth.')->middleware('throttle:auth')->group(function () { + Route::post('register', [AuthController::class, 'register'])->name('register'); + Route::post('verify', [AuthController::class, 'verify'])->name('verify'); + Route::post('verify/resend', [AuthController::class, 'resendVerification'])->name('verify.resend'); + Route::post('login', [AuthController::class, 'login'])->name('login'); + Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:token')->withoutMiddleware('throttle:auth')->name('logout'); + }); + + Route::middleware(['auth:token', 'throttle:api'])->group(function () { + Route::get('account', [AccountController::class, 'show'])->name('account'); + Route::get('account/tokens', [TokenController::class, 'index'])->name('account.tokens.index'); + Route::post('account/tokens', [TokenController::class, 'store'])->name('account.tokens.store'); + Route::delete('account/tokens/{token}', [TokenController::class, 'destroy'])->name('account.tokens.destroy'); + }); +}); diff --git a/routes/web.php b/routes/web.php index 86a06c5..2b95af1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,9 @@ use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -}); +Route::get('/', fn () => response()->json([ + 'service' => config('app.name'), + 'version' => config('registry.version'), + 'docs' => 'https://docs.fleetbase.io/registry', + 'catalog' => url('/v1/catalog/extensions'), +])); diff --git a/tests/Feature/Auth/AccountTest.php b/tests/Feature/Auth/AccountTest.php new file mode 100644 index 0000000..0f0920d --- /dev/null +++ b/tests/Feature/Auth/AccountTest.php @@ -0,0 +1,51 @@ +create(); + $organization = Organization::factory()->create(); + $account->organizations()->attach($organization->uuid, ['role' => 'publisher']); + actingAsAccount($account); + + $this->getJson('/v1/account') + ->assertOk() + ->assertJsonPath('id', $account->public_id) + ->assertJsonPath('email', $account->email) + ->assertJsonPath('organizations.0.slug', $organization->slug) + ->assertJsonPath('organizations.0.role', 'publisher') + ->assertJsonMissingPath('uuid') + ->assertJsonMissingPath('password'); +}); + +it('requires a bearer token', function () { + $this->getJson('/v1/account')->assertStatus(401); + $this->withHeader('Authorization', 'Bearer flbp_doesnotexist')->getJson('/v1/account')->assertStatus(401); +}); + +it('rejects expired, revoked and foreign-prefix tokens', function () { + $account = Account::factory()->create(); + + $expired = Token::issue($account, 'expired', ['*'], now()->subMinute()); + $this->withHeader('Authorization', 'Bearer '.$expired->plainTextToken)->getJson('/v1/account')->assertStatus(401); + + $revoked = Token::issue($account, 'revoked'); + $revoked->revoke(); + $this->withHeader('Authorization', 'Bearer '.$revoked->plainTextToken)->getJson('/v1/account')->assertStatus(401); + + $instance = Token::issue($account, 'instance', ['*'], null, null, Token::PREFIX_INSTANCE); + $this->withHeader('Authorization', 'Bearer '.$instance->plainTextToken)->getJson('/v1/account')->assertStatus(401); +}); + +it('rejects tokens of suspended accounts and records usage on success', function () { + $account = Account::factory()->create(); + $token = actingAsAccount($account); + + $this->getJson('/v1/account')->assertOk(); + expect($token->fresh()->last_used_at)->not->toBeNull(); + + $account->forceFill(['status' => Account::STATUS_SUSPENDED])->save(); + $this->getJson('/v1/account')->assertStatus(401); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..c6c08c3 --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,55 @@ +unverified()->create(); + $code = VerificationCode::generateFor($account); + + $this->postJson('/v1/auth/verify', ['email' => $account->email, 'code' => $code->code]) + ->assertOk() + ->assertJsonPath('verified', true); + + $account->refresh(); + expect($account->hasVerifiedEmail())->toBeTrue() + ->and($account->status)->toBe(Account::STATUS_ACTIVE) + ->and($code->fresh()->used_at)->not->toBeNull(); +}); + +it('rejects wrong, reused and expired codes', function () { + $account = Account::factory()->unverified()->create(); + $code = VerificationCode::generateFor($account); + + $this->postJson('/v1/auth/verify', ['email' => $account->email, 'code' => '000000']) + ->assertStatus(422)->assertJsonPath('errors.0', 'Invalid or expired verification code.'); + + $code->forceFill(['expires_at' => now()->subMinute()])->save(); + $this->postJson('/v1/auth/verify', ['email' => $account->email, 'code' => $code->code])->assertStatus(422); + + expect($account->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('resends a code only for unverified accounts without revealing whether one exists', function () { + Mail::fake(); + $pending = Account::factory()->unverified()->create(); + $verified = Account::factory()->create(); + + $this->postJson('/v1/auth/verify/resend', ['email' => $pending->email])->assertOk(); + $this->postJson('/v1/auth/verify/resend', ['email' => $verified->email])->assertOk(); + $this->postJson('/v1/auth/verify/resend', ['email' => 'nobody@example.com'])->assertOk(); + + Mail::assertSent(VerificationCodeMail::class, 1); +}); + +it('generates a fresh code and invalidates the previous one', function () { + $account = Account::factory()->unverified()->create(); + $first = VerificationCode::generateFor($account); + $second = VerificationCode::generateFor($account); + + expect(VerificationCode::where('subject_uuid', $account->uuid)->count())->toBe(1) + ->and(VerificationCode::consume($account, $first->code))->toBeFalse() + ->and(VerificationCode::consume($account, $second->code))->toBeTrue(); +}); diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php new file mode 100644 index 0000000..79082c8 --- /dev/null +++ b/tests/Feature/Auth/LoginTest.php @@ -0,0 +1,105 @@ +create(['email' => 'dev@example.com', 'password' => 'correct-horse-1']); + + $response = $this->postJson('/v1/auth/login', ['email' => 'DEV@example.com', 'password' => 'correct-horse-1']) + ->assertOk() + ->assertJsonPath('token_type', 'Bearer') + ->assertJsonPath('account.email', 'dev@example.com') + ->assertJsonPath('token_details.scopes', ['*']); + + $plain = $response->json('token'); + expect($plain)->toStartWith('flbp_')->toHaveLength(45); + + $token = Token::firstOrFail(); + expect($token->token_hash)->toBe(hash('sha256', $plain)) + ->and($token->last_four)->toBe(substr($plain, -4)) + ->and($token->public_id)->toStartWith('tok_') + ->and($token->expires_at)->not->toBeNull() + ->and($account->fresh()->last_login_at)->not->toBeNull(); + + // The plaintext is never stored and never shown again. + expect(Token::where('token_hash', $plain)->exists())->toBeFalse(); + $this->withHeader('Authorization', 'Bearer '.$plain) + ->getJson('/v1/account/tokens') + ->assertOk() + ->assertJsonMissing(['token' => $plain]); +}); + +it('accepts password hashes produced by core-api (bcrypt via Hash::make)', function () { + // Simulates an imported cloud user whose hash was produced outside this app. The + // `hashed` cast rejects costs above BCRYPT_ROUNDS, so production must keep rounds >= 10. + $imported = password_hash('legacy-pass-1', PASSWORD_BCRYPT, ['cost' => (int) config('hashing.bcrypt.rounds')]); + Account::factory()->create(['email' => 'cloud@example.com', 'password' => $imported]); + + expect(Hash::check('legacy-pass-1', $imported))->toBeTrue(); + + $this->postJson('/v1/auth/login', ['email' => 'cloud@example.com', 'password' => 'legacy-pass-1'])->assertOk(); +}); + +it('rejects bad credentials with a 401 error envelope', function () { + Account::factory()->create(['email' => 'dev@example.com', 'password' => 'correct-horse-1']); + + $this->postJson('/v1/auth/login', ['email' => 'dev@example.com', 'password' => 'nope']) + ->assertStatus(401) + ->assertExactJson(['errors' => ['Invalid email or password.']]); + + $this->postJson('/v1/auth/login', ['email' => 'ghost@example.com', 'password' => 'nope'])->assertStatus(401); +}); + +it('refuses login until the email is verified', function () { + Account::factory()->unverified()->create(['email' => 'new@example.com', 'password' => 'correct-horse-1']); + + $this->postJson('/v1/auth/login', ['email' => 'new@example.com', 'password' => 'correct-horse-1']) + ->assertStatus(403) + ->assertJsonPath('code', 'email_unverified'); +}); + +it('refuses login for suspended accounts', function () { + Account::factory()->suspended()->create(['email' => 'bad@example.com', 'password' => 'correct-horse-1']); + + $this->postJson('/v1/auth/login', ['email' => 'bad@example.com', 'password' => 'correct-horse-1']) + ->assertStatus(403) + ->assertJsonPath('code', 'account_suspended'); +}); + +it('honours requested scopes and expiry on login', function () { + Account::factory()->create(['email' => 'dev@example.com', 'password' => 'correct-horse-1']); + + $response = $this->postJson('/v1/auth/login', [ + 'email' => 'dev@example.com', + 'password' => 'correct-horse-1', + 'token_name' => 'flb cli', + 'scopes' => ['read', 'publish'], + 'expires_in_days' => 7, + ])->assertOk()->assertJsonPath('token_details.name', 'flb cli')->assertJsonPath('token_details.scopes', ['read', 'publish']); + + $token = Token::firstOrFail(); + expect($token->expires_at->diffInDays(now()->addDays(7), true))->toBeLessThan(1); + expect($response->json('token_details.scopes'))->toBe(['read', 'publish']); +}); + +it('logs out by revoking the token used for the request', function () { + $account = Account::factory()->create(); + $token = actingAsAccount($account); + + $this->postJson('/v1/auth/logout')->assertOk(); + + expect($token->fresh()->isRevoked())->toBeTrue(); + $this->getJson('/v1/account')->assertStatus(401)->assertExactJson(['errors' => ['Unauthenticated.']]); +}); + +it('throttles repeated login attempts', function () { + Account::factory()->create(['email' => 'dev@example.com', 'password' => 'correct-horse-1']); + + foreach (range(1, 5) as $i) { + $this->postJson('/v1/auth/login', ['email' => 'dev@example.com', 'password' => 'nope'])->assertStatus(401); + } + + $this->postJson('/v1/auth/login', ['email' => 'dev@example.com', 'password' => 'nope'])->assertStatus(429); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..3d6a185 --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,73 @@ +postJson('/v1/auth/register', [ + 'name' => 'Ada Lovelace', + 'email' => 'Ada@Example.com', + 'username' => 'ada', + 'password' => 'correct-horse-1', + ]); + + $response->assertCreated() + ->assertJsonPath('account.email', 'ada@example.com') + ->assertJsonPath('account.status', Account::STATUS_PENDING_VERIFICATION) + ->assertJsonMissingPath('account.password'); + + expect($response->json('account.id'))->toStartWith('acct_'); + + $account = Account::where('email', 'ada@example.com')->firstOrFail(); + expect($account->hasVerifiedEmail())->toBeFalse() + ->and(Hash::check('correct-horse-1', $account->password))->toBeTrue() + ->and($account->organizations)->toHaveCount(1); + + $organization = $account->organizations->first(); + expect($organization->slug)->toBe('ada') + ->and($organization->is_personal)->toBeTrue() + ->and($organization->pivot->role)->toBe(Membership::ROLE_OWNER) + ->and($organization->public_id)->toStartWith('org_'); + + expect(VerificationCode::where('subject_uuid', $account->uuid)->count())->toBe(1); + Mail::assertSent(VerificationCodeMail::class, fn (VerificationCodeMail $mail) => $mail->hasTo('ada@example.com')); +}); + +it('derives a username from the email when none is given', function () { + Mail::fake(); + + $this->postJson('/v1/auth/register', [ + 'name' => 'Grace', + 'email' => 'grace.hopper@example.com', + 'password' => 'correct-horse-1', + ])->assertCreated()->assertJsonPath('account.username', 'gracehopper'); +}); + +it('rejects duplicate emails and weak passwords with the error envelope', function () { + Account::factory()->create(['email' => 'taken@example.com']); + + $this->postJson('/v1/auth/register', [ + 'name' => 'Dup', + 'email' => 'taken@example.com', + 'password' => 'short', + ])->assertStatus(422) + ->assertJsonStructure(['errors', 'fields' => ['email', 'password']]); +}); + +it('rejects usernames that collide with an existing organization slug', function () { + Organization::factory()->create(['slug' => 'fleetbase']); + + $this->postJson('/v1/auth/register', [ + 'name' => 'Imposter', + 'email' => 'imposter@example.com', + 'username' => 'fleetbase', + 'password' => 'correct-horse-1', + ])->assertStatus(422)->assertJsonPath('fields.username.0', fn ($m) => is_string($m)); +}); diff --git a/tests/Feature/Auth/TokenTest.php b/tests/Feature/Auth/TokenTest.php new file mode 100644 index 0000000..4caa6ca --- /dev/null +++ b/tests/Feature/Auth/TokenTest.php @@ -0,0 +1,78 @@ +create(); + actingAsAccount($account); + + $response = $this->postJson('/v1/account/tokens', [ + 'name' => 'ci publisher', + 'scopes' => ['publish', 'read'], + 'expires_in_days' => 90, + ])->assertCreated() + ->assertJsonPath('token_details.name', 'ci publisher') + ->assertJsonPath('token_details.scopes', ['publish', 'read']); + + $plain = $response->json('token'); + expect($plain)->toStartWith('flbp_'); + expect($response->json('token_details.masked'))->toBe('flbp_…'.substr($plain, -4)); + + $list = $this->getJson('/v1/account/tokens')->assertOk()->json(); + expect($list)->toHaveCount(2); // the acting token + the new one + expect(collect($list)->pluck('masked')->all())->toContain('flbp_…'.substr($plain, -4)); + expect(json_encode($list))->not->toContain($plain); +}); + +it('validates scopes, lifetime and organization membership', function () { + $account = Account::factory()->create(); + actingAsAccount($account); + + $invalid = $this->postJson('/v1/account/tokens', ['name' => 'x', 'scopes' => ['admin']])->assertStatus(422); + expect($invalid->json('fields'))->toHaveKey('scopes.0'); + + $this->postJson('/v1/account/tokens', ['name' => 'x', 'scopes' => ['read'], 'expires_in_days' => 400]) + ->assertStatus(422); + + $foreign = Organization::factory()->create(); + $this->postJson('/v1/account/tokens', ['name' => 'x', 'scopes' => ['read'], 'organization' => $foreign->slug]) + ->assertStatus(422)->assertJsonPath('errors.0', 'You are not a member of that organization.'); + + $account->organizations()->attach($foreign->uuid, ['role' => 'publisher']); + $this->postJson('/v1/account/tokens', ['name' => 'x', 'scopes' => ['read'], 'organization' => $foreign->slug]) + ->assertCreated()->assertJsonPath('token_details.organization', $foreign->public_id); +}); + +it('revokes a token by public id and refuses other accounts tokens', function () { + $account = Account::factory()->create(); + $other = Account::factory()->create(); + $otherToken = Token::issue($other, 'theirs'); + actingAsAccount($account); + + $mine = Token::issue($account, 'mine'); + + $this->deleteJson('/v1/account/tokens/'.$mine->public_id)->assertOk()->assertJsonPath('revoked', true); + expect($mine->fresh()->isRevoked())->toBeTrue(); + + $this->deleteJson('/v1/account/tokens/'.$otherToken->public_id)->assertStatus(404); + expect($otherToken->fresh()->isRevoked())->toBeFalse(); + + $this->withHeader('Authorization', 'Bearer '.$mine->plainTextToken)->getJson('/v1/account')->assertStatus(401); +}); + +it('enforces token scopes through the scope middleware', function () { + $account = Account::factory()->create(); + + Route::middleware(['auth:token', 'scope:publish'])->get('/v1/_scope-test', fn () => ['ok' => true]); + + actingAsAccount($account, ['read']); + $this->getJson('/v1/_scope-test')->assertStatus(403); + + actingAsAccount($account, ['publish']); + $this->getJson('/v1/_scope-test')->assertOk(); + + actingAsAccount($account, ['*']); + $this->getJson('/v1/_scope-test')->assertOk(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..48d34a3 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,24 @@ +extend(TestCase::class) + ->use(RefreshDatabase::class) + ->in('Feature'); + +pest()->extend(TestCase::class)->in('Unit'); + +/** + * Issue a token for the account and authenticate subsequent requests with it. + */ +function actingAsAccount(Account $account, array $scopes = [Token::SCOPE_ALL]): Token +{ + $token = Token::issue($account, 'test', $scopes); + + test()->withHeader('Authorization', 'Bearer '.$token->plainTextToken); + + return $token; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..914839d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,13 @@ abstract class TestCase extends BaseTestCase { - // + /** + * Each simulated request resolves auth from scratch, as separate HTTP requests would. + */ + public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null) + { + $this->app['auth']->forgetGuards(); + + return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content); + } } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/MoneyCastTest.php b/tests/Unit/MoneyCastTest.php new file mode 100644 index 0000000..0f0a0dd --- /dev/null +++ b/tests/Unit/MoneyCastTest.php @@ -0,0 +1,11 @@ +toBe(1234) + ->and(Money::apply('49.00'))->toBe(4900) + ->and(Money::apply(null))->toBe(0) + ->and(Money::apply(4900))->toBe(4900) + ->and(Money::apply('€-5'))->toBe(-5); +}); diff --git a/tests/Unit/PublicIdTest.php b/tests/Unit/PublicIdTest.php new file mode 100644 index 0000000..a133cb3 --- /dev/null +++ b/tests/Unit/PublicIdTest.php @@ -0,0 +1,15 @@ +toBe('ext') + ->and(Release::getPublicIdType())->toBe('rel') + ->and(Licence::getPublicIdType())->toBe('lic') + ->and(Instance::getPublicIdType())->toBe('inst') + ->and(Token::getPublicIdType())->toBe('tok'); +}); From 83f0f37d9d9f7ae86b3c46f3b910508a7c794b02 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:48:39 +0800 Subject: [PATCH 05/16] Add the public catalog API GET /v1/catalog/extensions (q, category, pricing, hosting, publisher, tag, sort, page, limit), /v1/catalog/extensions/{slug}, /{slug}/releases, /v1/catalog/publishers/{slug} and /v1/catalog/categories. Only published listings are visible; install counts come from installs rows; fulltext search on MySQL with a LIKE fallback elsewhere. Responses are cached for 60 seconds per URL with a matching Cache-Control header and use Fleetbase-style resources with the flat meta pagination block. --- .../Controllers/Catalog/CatalogController.php | 157 ++++++++++++++++ app/Http/Filter/ExtensionFilter.php | 138 +++++++++++++++ app/Http/Resources/CategoryResource.php | 23 +++ app/Http/Resources/ExtensionResource.php | 38 ++++ .../Resources/ExtensionSummaryResource.php | 51 ++++++ app/Http/Resources/PriceResource.php | 22 +++ app/Http/Resources/PublisherResource.php | 25 +++ app/Http/Resources/ReleaseResource.php | 26 +++ routes/api.php | 9 + .../Feature/Catalog/CatalogExtensionsTest.php | 167 ++++++++++++++++++ .../CatalogPublishersAndCategoriesTest.php | 55 ++++++ 11 files changed, 711 insertions(+) create mode 100644 app/Http/Controllers/Catalog/CatalogController.php create mode 100644 app/Http/Filter/ExtensionFilter.php create mode 100644 app/Http/Resources/CategoryResource.php create mode 100644 app/Http/Resources/ExtensionResource.php create mode 100644 app/Http/Resources/ExtensionSummaryResource.php create mode 100644 app/Http/Resources/PriceResource.php create mode 100644 app/Http/Resources/PublisherResource.php create mode 100644 app/Http/Resources/ReleaseResource.php create mode 100644 tests/Feature/Catalog/CatalogExtensionsTest.php create mode 100644 tests/Feature/Catalog/CatalogPublishersAndCategoriesTest.php diff --git a/app/Http/Controllers/Catalog/CatalogController.php b/app/Http/Controllers/Catalog/CatalogController.php new file mode 100644 index 0000000..c458bc1 --- /dev/null +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -0,0 +1,157 @@ +cached($request, function () use ($request) { + $perPage = min(max((int) $request->input('limit', $request->input('per_page', 24)), 1), self::MAX_PER_PAGE); + + $filter = new ExtensionFilter($request); + $query = $filter->apply($this->listingQuery()); + $filter->applySort($request->input('sort')); + + $paginator = $query->paginate($perPage)->withQueryString(); + + return ExtensionSummaryResource::collection($paginator)->response($request)->getData(true); + }); + } + + /** + * GET /v1/catalog/extensions/{slug} + */ + public function extension(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $extension = $this->listingQuery() + ->with(['screenshots', 'publishedReleases']) + ->withCount('publishedReleases') + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->firstOrFail(); + + return (new ExtensionResource($extension))->resolve($request); + }); + } + + /** + * GET /v1/catalog/extensions/{slug}/releases + */ + public function releases(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $extension = Extension::query()->published() + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->firstOrFail(); + + $releases = $extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->when($request->filled('channel'), fn ($q) => $q->where('channel', $request->input('channel'))) + ->orderByDesc('published_at') + ->orderByDesc('id') + ->get(); + + return [ + 'extension' => $extension->slug, + 'latest' => $releases->first(fn (Release $r) => $r->isPublished() && $r->channel === Release::CHANNEL_STABLE)?->version + ?? $releases->first(fn (Release $r) => $r->isPublished())?->version, + 'channels' => $releases->where('status', Release::STATUS_PUBLISHED) + ->groupBy('channel') + ->map(fn ($group) => $group->first()->version) + ->all(), + 'releases' => ReleaseResource::collection($releases)->resolve($request), + ]; + }); + } + + /** + * GET /v1/catalog/publishers/{slug} + */ + public function publisher(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $organization = Organization::query() + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->with(['extensions' => fn ($q) => $this->listingScopes($q->published())->orderByDesc('published_at')]) + ->withCount(['extensions' => fn ($q) => $q->published()]) + ->firstOrFail(); + + return (new PublisherResource($organization))->resolve($request); + }); + } + + /** + * GET /v1/catalog/categories + */ + public function categories(Request $request): JsonResponse + { + return $this->cached($request, function () use ($request) { + $categories = Category::query() + ->withCount(['extensions' => fn ($q) => $q->published()]) + ->orderBy('sort') + ->orderBy('name') + ->get(); + + return CategoryResource::collection($categories)->resolve($request); + }); + } + + /** + * Base query for published listings with everything the summary resource needs. + */ + protected function listingQuery(): Builder + { + return $this->listingScopes(Extension::query()->published()); + } + + protected function listingScopes(Builder|Relation $query) + { + return $query + ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease']) + ->withCount(['installs' => fn ($q) => $q->where('status', Install::STATUS_INSTALLED)]); + } + + /** + * Serve from cache for 60 seconds. The closure must return array data. + */ + protected function cached(Request $request, \Closure $build): JsonResponse + { + $key = 'catalog:'.sha1($request->fullUrl()); + + $data = Cache::remember($key, self::CACHE_SECONDS, $build); + + return response()->json($data) + ->header('Cache-Control', 'public, max-age='.self::CACHE_SECONDS.', s-maxage='.self::CACHE_SECONDS) + ->header('X-Cache-Ttl', (string) self::CACHE_SECONDS); + } +} diff --git a/app/Http/Filter/ExtensionFilter.php b/app/Http/Filter/ExtensionFilter.php new file mode 100644 index 0000000..3535349 --- /dev/null +++ b/app/Http/Filter/ExtensionFilter.php @@ -0,0 +1,138 @@ +builder->getConnection()->getDriverName() === 'mysql') { + $this->builder->where(function (Builder $builder) use ($query) { + $builder->whereFullText(['name', 'subtitle', 'description'], $query) + ->orWhere('name', 'like', $query.'%') + ->orWhere('slug', 'like', Str::slug($query).'%'); + }); + + return; + } + + $like = '%'.$query.'%'; + $this->builder->where(function (Builder $builder) use ($like) { + $builder->where('name', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('description', 'like', $like) + ->orWhere('slug', 'like', $like); + }); + } + + /** + * Category by slug or public id; a comma-separated list matches any. + */ + public function category(string $category): void + { + $values = array_filter(array_map('trim', explode(',', $category))); + + $this->builder->whereIn('category_uuid', Category::query() + ->where(fn (Builder $q) => $q->whereIn('slug', $values)->orWhereIn('public_id', $values)) + ->select('uuid')); + } + + /** + * `free` or `paid`. + */ + public function pricing(string $pricing): void + { + $pricing = Str::lower($pricing); + + if ($pricing === 'free') { + $this->builder->where(function (Builder $builder) { + $builder->whereDoesntHave('price') + ->orWhereHas('price', fn (Builder $q) => $q->where('model', Price::MODEL_FREE)->orWhere('amount', '<=', 0)); + }); + } elseif ($pricing === 'paid') { + $this->builder->whereHas('price', fn (Builder $q) => $q->where('model', '!=', Price::MODEL_FREE)->where('amount', '>', 0)); + } + } + + /** + * `cloud`, `self_hosted` or `both`. Extensions marked `both` match either side. + */ + public function hosting(string $hosting): void + { + $hosting = Str::lower($hosting); + + if ($hosting === Extension::HOSTING_BOTH) { + $this->builder->where('hosting', Extension::HOSTING_BOTH); + } elseif (in_array($hosting, Extension::HOSTINGS, true)) { + $this->builder->whereIn('hosting', [$hosting, Extension::HOSTING_BOTH]); + } + } + + /** + * Publisher organization by slug or public id. + */ + public function publisher(string $publisher): void + { + $this->builder->whereHas('organization', fn (Builder $q) => $q->where('slug', $publisher)->orWhere('public_id', $publisher)); + } + + /** + * Any of the listing's tags. + */ + public function tag(string $tag): void + { + $this->builder->whereJsonContains('tags', $tag); + } + + /** + * Applied by the catalog controller because `sort` is skipped by the generic loop. + */ + public function applySort(?string $sort): void + { + $sort = in_array($sort, self::SORTS, true) ? $sort : 'popular'; + + match ($sort) { + 'newest' => $this->builder->orderByDesc('published_at')->orderByDesc('created_at'), + 'updated' => $this->builder->orderByDesc('updated_at'), + 'name' => $this->builder->orderBy('name'), + 'price' => $this->builder->orderBy( + Price::query()->select('amount')->whereColumn('prices.extension_uuid', 'extensions.uuid')->limit(1) + )->orderBy('name'), + default => $this->builder + ->orderByDesc( + Install::query()->selectRaw('count(*)') + ->whereColumn('installs.extension_uuid', 'extensions.uuid') + ->where('installs.status', Install::STATUS_INSTALLED) + ) + ->orderByDesc('published_at'), + }; + } + + /** + * Only published listings on the public API. + */ + public function queryForPublic(): void + { + $this->builder->where('extensions.status', Extension::STATUS_PUBLISHED); + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..c284f8e --- /dev/null +++ b/app/Http/Resources/CategoryResource.php @@ -0,0 +1,23 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'description' => $this->description, + 'icon' => $this->icon, + 'tags' => $this->tags ?? [], + 'sort' => $this->sort, + 'extensions_count' => $this->whenCounted('extensions'), + ]; + } +} diff --git a/app/Http/Resources/ExtensionResource.php b/app/Http/Resources/ExtensionResource.php new file mode 100644 index 0000000..4b59394 --- /dev/null +++ b/app/Http/Resources/ExtensionResource.php @@ -0,0 +1,38 @@ + $this->description, + 'promotional_text' => $this->promotional_text, + 'languages' => $this->languages ?? [], + 'website_url' => $this->website_url, + 'repo_url' => $this->repo_url, + 'support_url' => $this->support_url, + 'privacy_url' => $this->privacy_url, + 'tos_url' => $this->tos_url, + 'copyright' => $this->copyright, + 'screenshots' => $this->whenLoaded('screenshots', fn () => $this->screenshots->map(fn ($file) => [ + 'id' => $file->public_id, + 'url' => $file->url, + 'name' => $file->name, + 'mime' => $file->mime, + ])->all()), + 'versions' => $this->whenLoaded('publishedReleases', fn () => $this->publishedReleases->map(fn ($release) => [ + 'id' => $release->public_id, + 'version' => $release->version, + 'channel' => $release->channel, + 'published_at' => $release->published_at, + ])->all()), + 'versions_count' => $this->whenCounted('publishedReleases'), + 'publisher' => $this->whenLoaded('organization', fn () => (new PublisherResource($this->organization))->resolve($request)), + ]); + } +} diff --git a/app/Http/Resources/ExtensionSummaryResource.php b/app/Http/Resources/ExtensionSummaryResource.php new file mode 100644 index 0000000..7aac8dc --- /dev/null +++ b/app/Http/Resources/ExtensionSummaryResource.php @@ -0,0 +1,51 @@ +whenLoaded('latestRelease', fn () => $this->latestRelease); + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'subtitle' => $this->subtitle, + 'fa_icon' => $this->fa_icon, + 'icon_url' => $this->whenLoaded('icon', fn () => $this->icon?->url, null), + 'tags' => $this->tags ?? [], + 'hosting' => $this->hosting, + 'status' => $this->status, + 'publisher' => $this->whenLoaded('organization', fn () => [ + 'id' => $this->organization->public_id, + 'slug' => $this->organization->slug, + 'name' => $this->organization->name, + 'is_verified' => $this->organization->verified_at !== null, + ]), + 'category' => $this->whenLoaded('category', fn () => $this->category ? [ + 'id' => $this->category->public_id, + 'slug' => $this->category->slug, + 'name' => $this->category->name, + 'icon' => $this->category->icon, + ] : null), + 'latest_release' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease ? (new ReleaseResource($this->latestRelease))->resolve($request) : null), + 'latest_version' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease?->version), + 'compat' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease?->compat ?? []), + 'packages' => $this->whenLoaded('packages', fn () => $this->packages->mapWithKeys(fn ($package) => [$package->kind => $package->name])->all()), + 'price' => (new PriceResource($this->whenLoaded('price', fn () => $this->price, null)))->resolve($request), + 'is_paid' => $this->whenLoaded('price', fn () => $this->price !== null && ! $this->price->isFree(), false), + 'installs_count' => (int) ($this->installs_count ?? 0), + 'published_at' => $this->published_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/PriceResource.php b/app/Http/Resources/PriceResource.php new file mode 100644 index 0000000..59652c8 --- /dev/null +++ b/app/Http/Resources/PriceResource.php @@ -0,0 +1,22 @@ +resource === null) { + return ['model' => 'free', 'amount' => 0, 'currency' => 'USD', 'on_sale' => false, 'sale_amount' => null, 'effective_amount' => 0]; + } + + return [ + 'model' => $this->model, + 'amount' => (int) $this->amount, + 'currency' => $this->currency, + 'on_sale' => (bool) $this->on_sale, + 'sale_amount' => $this->sale_amount !== null ? (int) $this->sale_amount : null, + 'effective_amount' => $this->effective_amount, + ]; + } +} diff --git a/app/Http/Resources/PublisherResource.php b/app/Http/Resources/PublisherResource.php new file mode 100644 index 0000000..79dac43 --- /dev/null +++ b/app/Http/Resources/PublisherResource.php @@ -0,0 +1,25 @@ + $this->public_id, + 'slug' => $this->slug, + 'name' => $this->name, + 'website' => $this->website, + 'bio' => $this->bio, + 'avatar_url' => $this->avatar_url, + 'is_verified' => $this->verified_at !== null, + 'extensions_count' => $this->whenCounted('extensions'), + 'extensions' => $this->whenLoaded('extensions', fn () => ExtensionSummaryResource::collection($this->extensions)->resolve($request)), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseResource.php b/app/Http/Resources/ReleaseResource.php new file mode 100644 index 0000000..82d6aed --- /dev/null +++ b/app/Http/Resources/ReleaseResource.php @@ -0,0 +1,26 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'version' => $this->version, + 'channel' => $this->channel, + 'status' => $this->status, + 'compat' => $this->compat ?? [], + 'size_bytes' => (int) $this->size_bytes, + 'checksum' => $this->checksum, + 'notes' => $this->notes, + 'published_at' => $this->published_at, + 'yanked_at' => $this->yanked_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index dd00e10..6dadc40 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Account\AccountController; use App\Http\Controllers\Account\TokenController; use App\Http\Controllers\Auth\AuthController; +use App\Http\Controllers\Catalog\CatalogController; use Illuminate\Support\Facades\Route; /* @@ -18,6 +19,14 @@ */ Route::prefix('v1')->name('v1.')->group(function () { + Route::prefix('catalog')->name('catalog.')->middleware('throttle:catalog')->group(function () { + Route::get('extensions', [CatalogController::class, 'extensions'])->name('extensions'); + Route::get('extensions/{slug}', [CatalogController::class, 'extension'])->name('extension'); + Route::get('extensions/{slug}/releases', [CatalogController::class, 'releases'])->name('releases'); + Route::get('publishers/{slug}', [CatalogController::class, 'publisher'])->name('publisher'); + Route::get('categories', [CatalogController::class, 'categories'])->name('categories'); + }); + Route::prefix('auth')->name('auth.')->middleware('throttle:auth')->group(function () { Route::post('register', [AuthController::class, 'register'])->name('register'); Route::post('verify', [AuthController::class, 'verify'])->name('verify'); diff --git a/tests/Feature/Catalog/CatalogExtensionsTest.php b/tests/Feature/Catalog/CatalogExtensionsTest.php new file mode 100644 index 0000000..c2b6101 --- /dev/null +++ b/tests/Feature/Catalog/CatalogExtensionsTest.php @@ -0,0 +1,167 @@ +published()->withPackages(); + $factory = ($options['paid'] ?? false) ? $factory->paid($options['amount'] ?? 4900) : $factory->free(); + $factory = $factory->withPublishedRelease($options['version'] ?? '1.0.0'); + + return $factory->create($attributes); +} + +it('lists published extensions with publisher, category, latest release, installs and price', function () { + $publisher = Organization::factory()->verified()->create(['slug' => 'acme']); + $category = Category::factory()->create(['slug' => 'telematics']); + $extension = catalogExtension(['organization_uuid' => $publisher->uuid, 'category_uuid' => $category->uuid, 'slug' => 'acme-tracker'], ['paid' => true, 'version' => '2.1.0']); + Install::factory()->count(3)->create(['extension_uuid' => $extension->uuid]); + Install::factory()->failed()->create(['extension_uuid' => $extension->uuid]); + Extension::factory()->create(['status' => Extension::STATUS_DRAFT]); + Extension::factory()->create(['status' => Extension::STATUS_SUSPENDED]); + + $response = $this->getJson('/v1/catalog/extensions')->assertOk(); + + $response->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $extension->public_id) + ->assertJsonPath('data.0.slug', 'acme-tracker') + ->assertJsonPath('data.0.publisher.slug', 'acme') + ->assertJsonPath('data.0.publisher.is_verified', true) + ->assertJsonPath('data.0.category.slug', 'telematics') + ->assertJsonPath('data.0.latest_version', '2.1.0') + ->assertJsonPath('data.0.latest_release.version', '2.1.0') + ->assertJsonPath('data.0.compat.console', '>=0.5.0') + ->assertJsonPath('data.0.installs_count', 3) + ->assertJsonPath('data.0.price.model', 'one_time') + ->assertJsonPath('data.0.price.amount', 4900) + ->assertJsonPath('data.0.is_paid', true) + ->assertJsonPath('data.0.packages.npm', '@acme/acme-tracker-engine') + ->assertJsonPath('data.0.packages.composer', 'acme/acme-tracker-api') + ->assertJsonStructure(['data', 'meta' => ['total', 'per_page', 'current_page', 'last_page', 'from', 'to', 'time']]) + ->assertJsonMissingPath('data.0.uuid'); + + expect($response->json('data.0.latest_release.id'))->toStartWith('rel_'); + expect($response->headers->get('Cache-Control'))->toContain('max-age=60'); +}); + +it('searches by q and filters by category, pricing and hosting', function () { + $telematics = Category::factory()->create(['slug' => 'telematics']); + $accounting = Category::factory()->create(['slug' => 'accounting']); + + $gps = catalogExtension(['name' => 'GPS Tracker', 'slug' => 'gps-tracker', 'category_uuid' => $telematics->uuid, 'hosting' => 'both']); + $books = catalogExtension(['name' => 'Quick Books', 'slug' => 'quick-books', 'category_uuid' => $accounting->uuid, 'hosting' => 'cloud'], ['paid' => true]); + $routes = catalogExtension(['name' => 'Route Planner', 'slug' => 'route-planner', 'subtitle' => 'tracker for routes', 'category_uuid' => $telematics->uuid, 'hosting' => 'self_hosted']); + + $slugs = fn (string $query) => collect($this->getJson('/v1/catalog/extensions?'.$query)->assertOk()->json('data'))->pluck('slug')->sort()->values()->all(); + + expect($slugs('q=tracker'))->toBe(['gps-tracker', 'route-planner']) + ->and($slugs('category=telematics'))->toBe(['gps-tracker', 'route-planner']) + ->and($slugs('category='.$accounting->public_id))->toBe(['quick-books']) + ->and($slugs('pricing=paid'))->toBe(['quick-books']) + ->and($slugs('pricing=free'))->toBe(['gps-tracker', 'route-planner']) + ->and($slugs('hosting=cloud'))->toBe(['gps-tracker', 'quick-books']) + ->and($slugs('hosting=self_hosted'))->toBe(['gps-tracker', 'route-planner']) + ->and($slugs('hosting=both'))->toBe(['gps-tracker']) + ->and($slugs('q=tracker&category=telematics&pricing=free&hosting=self_hosted'))->toBe(['gps-tracker', 'route-planner']); +}); + +it('sorts by popular, newest, name and price and paginates', function () { + $old = catalogExtension(['name' => 'Bravo', 'slug' => 'bravo', 'published_at' => now()->subDays(10)], ['paid' => true, 'amount' => 9900]); + $new = catalogExtension(['name' => 'Alpha', 'slug' => 'alpha', 'published_at' => now()->subDay()]); + $popular = catalogExtension(['name' => 'Charlie', 'slug' => 'charlie', 'published_at' => now()->subDays(5)], ['paid' => true, 'amount' => 1000]); + Install::factory()->count(5)->create(['extension_uuid' => $popular->uuid]); + Install::factory()->count(2)->create(['extension_uuid' => $old->uuid]); + + $slugs = fn (string $query) => collect($this->getJson('/v1/catalog/extensions?'.$query)->assertOk()->json('data'))->pluck('slug')->all(); + + expect($slugs('sort=popular'))->toBe(['charlie', 'bravo', 'alpha']) + ->and($slugs(''))->toBe(['charlie', 'bravo', 'alpha']) + ->and($slugs('sort=newest'))->toBe(['alpha', 'charlie', 'bravo']) + ->and($slugs('sort=name'))->toBe(['alpha', 'bravo', 'charlie']) + ->and($slugs('sort=price'))->toBe(['alpha', 'charlie', 'bravo']); + + $page = $this->getJson('/v1/catalog/extensions?sort=name&limit=2&page=2')->assertOk(); + $page->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.slug', 'charlie') + ->assertJsonPath('meta.total', 3) + ->assertJsonPath('meta.per_page', 2) + ->assertJsonPath('meta.current_page', 2) + ->assertJsonPath('meta.last_page', 2); +}); + +it('caches catalog pages for 60 seconds', function () { + catalogExtension(['slug' => 'first']); + + $this->getJson('/v1/catalog/extensions')->assertOk()->assertJsonCount(1, 'data'); + + catalogExtension(['slug' => 'second']); + $this->getJson('/v1/catalog/extensions')->assertOk()->assertJsonCount(1, 'data'); + $this->getJson('/v1/catalog/extensions?sort=name')->assertOk()->assertJsonCount(2, 'data'); + + $this->travel(61)->seconds(); + $this->getJson('/v1/catalog/extensions')->assertOk()->assertJsonCount(2, 'data'); + + Cache::flush(); +}); + +it('shows one extension with description, links, versions and screenshots', function () { + $publisher = Organization::factory()->create(['slug' => 'acme']); + $extension = catalogExtension(['organization_uuid' => $publisher->uuid, 'slug' => 'acme-tracker', 'description' => 'Tracks things.', 'repo_url' => 'https://github.com/acme/tracker'], ['version' => '1.0.0']); + Release::factory()->published()->create(['extension_uuid' => $extension->uuid, 'version' => '1.1.0', 'published_at' => now()->addMinute()]); + Release::factory()->published()->beta()->create(['extension_uuid' => $extension->uuid, 'version' => '2.0.0-beta.1', 'published_at' => now()->addMinutes(2)]); + Release::factory()->create(['extension_uuid' => $extension->uuid, 'version' => '3.0.0', 'status' => Release::STATUS_IN_REVIEW]); + $extension->screenshots()->create(['type' => 'screenshot', 'disk' => 'local', 'path' => 'screenshots/one.png', 'name' => 'one.png', 'mime' => 'image/png', 'size' => 10]); + + $this->getJson('/v1/catalog/extensions/acme-tracker') + ->assertOk() + ->assertJsonPath('id', $extension->public_id) + ->assertJsonPath('description', 'Tracks things.') + ->assertJsonPath('repo_url', 'https://github.com/acme/tracker') + ->assertJsonPath('publisher.slug', 'acme') + ->assertJsonPath('latest_version', '2.0.0-beta.1') + ->assertJsonPath('versions_count', 3) + ->assertJsonCount(3, 'versions') + ->assertJsonCount(1, 'screenshots') + ->assertJsonPath('screenshots.0.name', 'one.png'); + + $this->getJson('/v1/catalog/extensions/'.$extension->public_id)->assertOk()->assertJsonPath('slug', 'acme-tracker'); +}); + +it('returns 404 for unknown or unpublished extensions', function () { + Extension::factory()->create(['slug' => 'draft-only']); + + $this->getJson('/v1/catalog/extensions/missing')->assertStatus(404)->assertExactJson(['errors' => ['Resource not found.']]); + $this->getJson('/v1/catalog/extensions/draft-only')->assertStatus(404); +}); + +it('lists releases with channels, dates, sizes, notes and compat, including yanked ones', function () { + $extension = catalogExtension(['slug' => 'acme-tracker'], ['version' => '1.0.0']); + Release::factory()->published()->create(['extension_uuid' => $extension->uuid, 'version' => '1.1.0', 'published_at' => now()->addMinute(), 'notes' => 'Fixes', 'size_bytes' => 1234]); + Release::factory()->published()->beta()->create(['extension_uuid' => $extension->uuid, 'version' => '1.2.0-beta.1', 'published_at' => now()->addMinutes(2)]); + Release::factory()->yanked()->create(['extension_uuid' => $extension->uuid, 'version' => '0.9.0']); + Release::factory()->create(['extension_uuid' => $extension->uuid, 'version' => '9.9.9', 'status' => Release::STATUS_UPLOADED]); + + $response = $this->getJson('/v1/catalog/extensions/acme-tracker/releases')->assertOk(); + + $response->assertJsonPath('extension', 'acme-tracker') + ->assertJsonPath('latest', '1.1.0') + ->assertJsonPath('channels.stable', '1.1.0') + ->assertJsonPath('channels.beta', '1.2.0-beta.1') + ->assertJsonCount(4, 'releases'); + + $versions = collect($response->json('releases'))->pluck('version')->all(); + expect($versions)->toContain('0.9.0')->not->toContain('9.9.9'); + + $fixes = collect($response->json('releases'))->firstWhere('version', '1.1.0'); + expect($fixes['notes'])->toBe('Fixes') + ->and($fixes['size_bytes'])->toBe(1234) + ->and($fixes['channel'])->toBe('stable') + ->and($fixes['compat'])->toHaveKeys(['console', 'api']); + + $this->getJson('/v1/catalog/extensions/acme-tracker/releases?channel=beta')->assertOk()->assertJsonCount(1, 'releases'); +}); diff --git a/tests/Feature/Catalog/CatalogPublishersAndCategoriesTest.php b/tests/Feature/Catalog/CatalogPublishersAndCategoriesTest.php new file mode 100644 index 0000000..71eb8ed --- /dev/null +++ b/tests/Feature/Catalog/CatalogPublishersAndCategoriesTest.php @@ -0,0 +1,55 @@ +verified()->create(['slug' => 'acme', 'name' => 'Acme Corp', 'website' => 'https://acme.test']); + Extension::factory()->published()->free()->withPublishedRelease()->create(['organization_uuid' => $publisher->uuid, 'slug' => 'one']); + Extension::factory()->published()->free()->withPublishedRelease()->create(['organization_uuid' => $publisher->uuid, 'slug' => 'two']); + Extension::factory()->create(['organization_uuid' => $publisher->uuid, 'slug' => 'draft']); + + $response = $this->getJson('/v1/catalog/publishers/acme')->assertOk(); + + $response->assertJsonPath('id', $publisher->public_id) + ->assertJsonPath('name', 'Acme Corp') + ->assertJsonPath('website', 'https://acme.test') + ->assertJsonPath('is_verified', true) + ->assertJsonPath('extensions_count', 2) + ->assertJsonCount(2, 'extensions') + ->assertJsonMissingPath('stripe_connect_id') + ->assertJsonMissingPath('uuid'); + + expect(collect($response->json('extensions'))->pluck('slug')->all())->not->toContain('draft'); + + $this->getJson('/v1/catalog/publishers/'.$publisher->public_id)->assertOk(); + $this->getJson('/v1/catalog/publishers/nobody')->assertStatus(404); +}); + +it('lists categories in sort order with published extension counts', function () { + $a = Category::factory()->create(['slug' => 'accounting', 'name' => 'Accounting', 'sort' => 2]); + $t = Category::factory()->create(['slug' => 'telematics', 'name' => 'Telematics', 'sort' => 1]); + Extension::factory()->published()->count(2)->create(['category_uuid' => $t->uuid]); + Extension::factory()->create(['category_uuid' => $t->uuid]); + + $this->getJson('/v1/catalog/categories') + ->assertOk() + ->assertJsonCount(2) + ->assertJsonPath('0.slug', 'telematics') + ->assertJsonPath('0.extensions_count', 2) + ->assertJsonPath('1.slug', 'accounting') + ->assertJsonPath('1.extensions_count', 0); + + expect($this->getJson('/v1/catalog/categories')->json('0.id'))->toStartWith('cat_'); +}); + +it('seeds the 41 catalog categories from registry-bridge', function () { + $this->seed(CategorySeeder::class); + $this->seed(CategorySeeder::class); + + expect(Category::count())->toBe(41) + ->and(Category::where('slug', 'telematics')->firstOrFail()->internal_id)->toBe('FLBTELEMATICS') + ->and(Category::where('internal_id', 'FLBCRM')->firstOrFail()->slug)->toBe('customer-relationship-management'); +}); From 663e71f2c6702dc88c7aa927c1ecebca5be46fec Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 16:52:41 +0800 Subject: [PATCH 06/16] Add Dockerfile, local compose stack, CI and README - Dockerfile: composer vendor stage, serversideup php 8.3 fpm-nginx runtime, app target for local use and aws-app target wrapped with ssm-parent like the v1 deploy; docker-bake.hcl builds aws-app. - docker-compose.yml: app, queue worker, MySQL 8, MinIO (+ bucket init), Mailpit for verification emails. - .env.example for a zero-dependency SQLite run; .env.docker for the compose stack. - GitHub Actions CI: Pint and Pest on PHP 8.3 and 8.4, then a Docker build of the app target. The deploy/* workflow still builds the Verdaccio image from legacy/. --- .dockerignore | 16 +++ .env.docker | 13 +++ .env.example | 41 ++++---- .github/workflows/ci.yml | 72 +++++++++++++ Dockerfile | 53 ++++++++++ README.md | 183 +++++++++++++++++++++++----------- docker-bake.hcl | 23 +++++ docker-compose.yml | 136 +++++++++++++++++++++++++ docker/create-bucket.sh | 14 +++ tests/Feature/ExampleTest.php | 19 ---- 10 files changed, 478 insertions(+), 92 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.docker create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-bake.hcl create mode 100644 docker-compose.yml create mode 100755 docker/create-bucket.sh delete mode 100644 tests/Feature/ExampleTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5a9ff2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.claude +legacy +node_modules +vendor +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +database/*.sqlite* +.env +.env.* +!.env.example +tests +*.md diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..4e0caa3 --- /dev/null +++ b/.env.docker @@ -0,0 +1,13 @@ +# Values shared by the app and queue containers in docker-compose.yml. +# Service-specific values (DB, mail, S3) are set in the compose file itself. +APP_NAME=Registry +APP_ENV=local +APP_KEY=base64:mOXOfgDmHCBgK9Dh04IkWop7kCUkE8yo+79PR3JVvxE= +APP_DEBUG=true +APP_TIMEZONE=UTC +LOG_CHANNEL=stderr +LOG_LEVEL=debug +BCRYPT_ROUNDS=12 +REGISTRY_VERSION=2.0.0-dev +REGISTRY_CATALOG_CACHE_SECONDS=60 +REGISTRY_STAFF_ORGANIZATION=fleetbase diff --git a/.env.example b/.env.example index c0660ea..71887f1 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,15 @@ -APP_NAME=Laravel +APP_NAME=Registry APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database - -# PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 @@ -20,12 +18,13 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# sqlite for a zero-dependency local run; docker-compose overrides this with mysql. DB_CONNECTION=sqlite # DB_HOST=127.0.0.1 # DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +# DB_DATABASE=registry +# DB_USERNAME=registry +# DB_PASSWORD=registry SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -34,32 +33,40 @@ SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local QUEUE_CONNECTION=database - CACHE_STORE=database # CACHE_PREFIX= -MEMCACHED_HOST=127.0.0.1 - REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 +# Verification codes go out by mail. Mailpit (docker compose) listens on 1025 / UI on 8025. MAIL_MAILER=log -MAIL_SCHEME=null MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="registry@fleetbase.local" MAIL_FROM_NAME="${APP_NAME}" +# Package store. Local disk by default; S3 / MinIO in docker and production. +FILESYSTEM_DISK=local +REGISTRY_STORAGE_DISK=local AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= +AWS_BUCKET=fleetbase-registry +AWS_ENDPOINT= +AWS_URL= AWS_USE_PATH_STYLE_ENDPOINT=false -VITE_APP_NAME="${APP_NAME}" +# Registry +REGISTRY_VERSION=2.0.0-dev +REGISTRY_CATALOG_CACHE_SECONDS=60 +REGISTRY_STAFF_ORGANIZATION=fleetbase +HASH_VERIFY=true + +# Sentry (optional) +SENTRY_LARAVEL_DSN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8ece8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: Registry Service CI + +on: + push: + branches: ["main", "v2", "dev-*"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Pest (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.3", "8.4"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, bcmath, gd, zip, pdo_sqlite, sqlite3 + coverage: none + tools: composer:v2 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}-${{ matrix.php }}- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare environment + run: | + cp .env.example .env + php artisan key:generate --ansi + + - name: Lint (Pint) + run: vendor/bin/pint --test + + - name: Run tests + run: vendor/bin/pest --ci --colors=always + + docker: + name: Docker image builds + runs-on: ubuntu-latest + needs: tests + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build app target + uses: docker/build-push-action@v6 + with: + context: . + target: app + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d713e08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.7 +# +# Fleetbase Registry Service +# app : local development / generic container (nginx + php-fpm) +# aws-app : same image wrapped with ssm-parent so env comes from SSM on ECS +# +FROM ghcr.io/springload/ssm-parent:1.8.3 AS ssm-parent + +# ---- Composer dependencies ------------------------------------------------- +FROM composer:2 AS vendor +WORKDIR /app +COPY composer.json composer.lock ./ +RUN --mount=type=cache,target=/tmp/cache \ + composer install --no-dev --no-interaction --no-scripts --prefer-dist --no-progress --ignore-platform-reqs + +# ---- Runtime --------------------------------------------------------------- +FROM serversideup/php:8.3-fpm-nginx-alpine AS base + +ENV PHP_OPCACHE_ENABLE=1 \ + AUTORUN_ENABLED=false \ + AUTORUN_LARAVEL_MIGRATION=false \ + NGINX_WEBROOT=/var/www/html/public \ + SSL_MODE=off + +USER root +RUN install-php-extensions bcmath intl gd zip pdo_mysql redis pcntl exif \ + && apk add --no-cache mysql-client + +WORKDIR /var/www/html +COPY --chown=www-data:www-data . . +COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor + +RUN composer dump-autoload --optimize --classmap-authoritative --no-dev 2>/dev/null || true \ + && mkdir -p storage/framework/{cache,sessions,views} storage/logs bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache + +USER www-data +EXPOSE 8080 + +# ---- Local / generic ------------------------------------------------------- +FROM base AS app +ENV AUTORUN_ENABLED=true \ + AUTORUN_LARAVEL_MIGRATION=true + +# ---- AWS ECS --------------------------------------------------------------- +FROM base AS aws-app +USER root +COPY --from=ssm-parent /usr/bin/ssm-parent /usr/bin/ssm-parent +USER www-data +ENV AUTORUN_ENABLED=true \ + AUTORUN_LARAVEL_MIGRATION=true +ENTRYPOINT ["/usr/bin/ssm-parent", "run", "-e", "-n", "$SSM_PARAMETER", "--", "docker-php-serversideup-entrypoint"] +CMD ["s6-svscan", "/etc/s6-overlay/s6-rc.d"] diff --git a/README.md b/README.md index 0165a77..327266f 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,130 @@ -

Laravel Logo

- -

-Build Status -Total Downloads -Latest Stable Version -License -

- -## About Laravel - -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). - -Laravel is accessible, powerful, and provides tools required for large, robust applications. - -## Learning Laravel - -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. - -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. - -## Laravel Sponsors - -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). - -### Premium Partners - -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** - -## Contributing - -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). - -## Code of Conduct - -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). - -## Security Vulnerabilities - -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +# Fleetbase Extensions Registry + +The Registry Service is the single system of record for Fleetbase extensions: accounts and +publishers, the public catalog, releases and their review, package delivery for npm and +Composer, licences and purchases. Every Fleetbase instance (cloud or self-hosted), the `flb` +CLI and the publisher website are clients of it. + +This branch (`v2`) holds the rewrite described in the implementation plan. The previous +Verdaccio-based registry is kept under [`legacy/`](legacy/LEGACY.md) and still deploys from +there until the publishing cutover. + +## Status + +Phase 1, Workstreams A and B: + +| Area | State | +| --- | --- | +| Data model (18 tables + verification codes), category seeder | done | +| Account auth: register, email verification code, login, logout, personal tokens | done | +| Public catalog API (`/v1/catalog/*`) with 60 s cache | done | +| Docker image, compose stack (app, queue, MySQL, MinIO, Mailpit), CI | done | +| npm packument and tarball endpoints, Composer v2 repository | next | +| Releases upload, checks, review; instance API; Stripe | later workstreams | + +## Stack + +Laravel 12 on PHP 8.3, MySQL 8, S3-compatible object storage. The Fleetbase core-api +conventions the plan relies on (`Model` with UUID keys and public ids, `HasUuid`, +`HasPublicId`, the `Money` cast, `Http\Filter\Filter`, `FleetbaseResource`) are ported +into `app/` with the same names and behaviour, so code written against them reads like +any other Fleetbase API package. + +## Local development + +Zero-dependency (SQLite, mail to log): + +```bash +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate --seed +php artisan serve +``` + +Full stack with Docker: + +```bash +docker compose up --build +``` + +| Service | URL | +| --- | --- | +| API | http://localhost:8000 | +| MinIO console | http://localhost:9001 (minio / miniosecret) | +| Mailpit (verification emails) | http://localhost:8025 | +| MySQL | localhost:3307 (registry / registry) | + +Run the tests: + +```bash +composer test +``` + +## API overview + +All routes live under `/v1`. Errors use the Fleetbase envelope `{"errors": ["..."]}`; +validation failures add a `fields` map. Resources are returned unwrapped; paged lists return +`{"data": [...], "meta": {total, per_page, current_page, last_page, from, to, time}}`. + +### Auth and account + +| Method and path | Purpose | +| --- | --- | +| `POST /v1/auth/register` | name, email, password, optional username. Creates the account (status `pending_verification`) and a personal organization, emails a 6-digit code | +| `POST /v1/auth/verify` | email + code. Activates the account | +| `POST /v1/auth/verify/resend` | email. Sends a new code for unverified accounts | +| `POST /v1/auth/login` | email + password (+ token_name, scopes, expires_in_days). Returns a personal token once | +| `POST /v1/auth/logout` | Revokes the token used for the request | +| `GET /v1/account` | The caller's account with organizations and roles | +| `GET /v1/account/tokens` | The caller's tokens, masked (`flbp_…a1b2`) | +| `POST /v1/account/tokens` | name, scopes (`read`, `publish`, `install`, `instance:register`, `*`), expires_in_days (max 365), optional organization. Plaintext returned once | +| `DELETE /v1/account/tokens/{id}` | Revoke | + +Tokens are `flbp_` + 40 random characters, stored as SHA-256, expiring, scoped and revocable. +Send them as `Authorization: Bearer flbp_…`. Passwords are bcrypt hashes checked with +`Hash::check`, the same as core-api's `Auth::checkPassword`, so cloud users import without +a reset. Keep `BCRYPT_ROUNDS` at or above the cost of imported hashes (cloud uses 10; the +default here is 12) because Laravel's `hashed` cast refuses hashes above the configured cost. + +### Public catalog (no auth, cached 60 s) + +| Method and path | Returns | +| --- | --- | +| `GET /v1/catalog/extensions?q=&category=&pricing=&hosting=&sort=&page=&limit=` | Paged listings with publisher, category, latest release, install count, compat, price, package names | +| `GET /v1/catalog/extensions/{slug}` | Full listing, screenshots, publisher, versions summary | +| `GET /v1/catalog/extensions/{slug}/releases?channel=` | Published (and yanked) releases: versions, channels, dates, sizes, notes, compat | +| `GET /v1/catalog/publishers/{slug}` | Publisher profile and their published extensions | +| `GET /v1/catalog/categories` | Categories with published extension counts | + +Filters: `pricing=free|paid`, `hosting=cloud|self_hosted|both` (listings marked `both` +match either side), `category=[,…]`, `publisher=`, `tag=`. +Sorts: `popular` (default, by installs), `newest`, `updated`, `name`, `price`. +Slugs and public ids (`ext_…`, `org_…`) are both accepted where a slug is expected. + +## Repository layout + +``` +app/ + Auth/TokenGuard.php bearer-token guard (auth:token) + Casts/Money.php minor-unit money cast + Http/Filter/ request-driven query filters (ExtensionFilter) + Http/Resources/ Fleetbase-style JSON resources + Models/ one model per table; Model.php is the base + Support/ Http and Utils helpers +database/migrations/ the data model +database/seeders/ CategorySeeder (41 categories from registry-bridge) +docker/ compose helpers +legacy/ the Verdaccio registry (v1) +tests/Feature/{Auth,Catalog} Pest feature tests (SQLite in memory) +``` + +## Deployment + +`docker-bake.hcl` at the root builds the `aws-app` target (the image wrapped with +`ssm-parent`, as the v1 deploy does). The `deploy/*` workflow still builds the Verdaccio +image from `legacy/` until the catalog cutover; point it at the root bake file when the +service goes to staging. ## License -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +AGPL-3.0-or-later. See [LICENSE.md](LICENSE.md). diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..50da891 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,23 @@ +// docker-bake.hcl — Registry Service (v2). The Verdaccio build lives in legacy/. +variable "REGISTRY" { default = "" } +variable "VERSION" { default = "latest" } +variable "CACHE" { default = "" } + +group "default" { + targets = ["app"] +} + +target "app" { + context = "./" + dockerfile = "Dockerfile" + platforms = ["linux/amd64"] + target = "aws-app" + + tags = notequal("", REGISTRY) ? formatlist( + "${REGISTRY}:%s", + compact(["latest", VERSION]) + ) : [] + + cache-from = notequal("", CACHE) ? ["${CACHE}"] : [] + cache-to = notequal("", CACHE) ? ["${CACHE},mode=max,ignore-error=true"] : [] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e56767 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,136 @@ +# Local development stack for the Registry Service. +# docker compose up --build +# API: http://localhost:8000 +# MinIO: http://localhost:9001 (console, minio / miniosecret) +# Mailpit: http://localhost:8025 +name: fleetbase-registry + +services: + app: + build: + context: . + target: app + ports: + - "8000:8080" + env_file: + - .env.docker + environment: + APP_URL: http://localhost:8000 + DB_CONNECTION: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: registry + DB_USERNAME: registry + DB_PASSWORD: registry + CACHE_STORE: database + QUEUE_CONNECTION: database + SESSION_DRIVER: database + MAIL_MAILER: smtp + MAIL_HOST: mailpit + MAIL_PORT: 1025 + MAIL_FROM_ADDRESS: registry@fleetbase.local + FILESYSTEM_DISK: s3 + REGISTRY_STORAGE_DISK: s3 + AWS_ACCESS_KEY_ID: minio + AWS_SECRET_ACCESS_KEY: miniosecret + AWS_DEFAULT_REGION: us-east-1 + AWS_BUCKET: fleetbase-registry + AWS_ENDPOINT: http://minio:9000 + AWS_URL: http://localhost:9000/fleetbase-registry + AWS_USE_PATH_STYLE_ENDPOINT: "true" + volumes: + - ./:/var/www/html + - app_vendor:/var/www/html/vendor + depends_on: + mysql: + condition: service_healthy + minio: + condition: service_started + mailpit: + condition: service_started + + queue: + build: + context: . + target: app + command: ["php", "artisan", "queue:work", "--tries=3", "--timeout=120"] + env_file: + - .env.docker + environment: + AUTORUN_ENABLED: "false" + DB_CONNECTION: mysql + DB_HOST: mysql + DB_DATABASE: registry + DB_USERNAME: registry + DB_PASSWORD: registry + QUEUE_CONNECTION: database + MAIL_MAILER: smtp + MAIL_HOST: mailpit + MAIL_PORT: 1025 + MAIL_FROM_ADDRESS: registry@fleetbase.local + FILESYSTEM_DISK: s3 + AWS_ACCESS_KEY_ID: minio + AWS_SECRET_ACCESS_KEY: miniosecret + AWS_DEFAULT_REGION: us-east-1 + AWS_BUCKET: fleetbase-registry + AWS_ENDPOINT: http://minio:9000 + AWS_USE_PATH_STYLE_ENDPOINT: "true" + volumes: + - ./:/var/www/html + - app_vendor:/var/www/html/vendor + depends_on: + app: + condition: service_started + + mysql: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: registry + MYSQL_USER: registry + MYSQL_PASSWORD: registry + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 5s + timeout: 5s + retries: 20 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minio + MINIO_ROOT_PASSWORD: miniosecret + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + + minio-init: + image: minio/mc:latest + entrypoint: ["/bin/sh", "/create-bucket.sh"] + volumes: + - ./docker/create-bucket.sh:/create-bucket.sh:ro + depends_on: + minio: + condition: service_started + + mailpit: + image: axllent/mailpit:latest + ports: + - "8025:8025" + - "1025:1025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + +volumes: + mysql_data: + minio_data: + app_vendor: diff --git a/docker/create-bucket.sh b/docker/create-bucket.sh new file mode 100755 index 0000000..fff5974 --- /dev/null +++ b/docker/create-bucket.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Creates the registry bucket in the local MinIO once it is reachable. +set -e + +until mc alias set local http://minio:9000 minio miniosecret >/dev/null 2>&1; do + echo "waiting for minio..." + sleep 2 +done + +mc mb --ignore-existing local/fleetbase-registry +# Icons and screenshots are served straight from the bucket; tarballs stay private. +mc anonymous set download local/fleetbase-registry/icons +mc anonymous set download local/fleetbase-registry/screenshots +echo "bucket fleetbase-registry ready" diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} From 317e22e52d3717e1fbd6231adb9c6112a7fa4687 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:36:10 +0800 Subject: [PATCH 07/16] Add ReleaseStorage: package store over the files table with sha256, sha1 and SRI sha512 Objects live at {prefix}/{file uuid}/{filename} under tarballs/, dist/, icons/ and screenshots/ on the registry disk. Package tarballs keep their decoded package.json or composer.json in files.meta so metadata endpoints never open archives. A small streaming tar reader replaces PharData for that. Downloads answer If-None-Match and re-hash the bytes as they stream. Fixture packages for the seed helper live in database/fixtures. --- app/Models/File.php | 15 +- app/Services/ReleaseStorage.php | 267 ++++++++++++++++++ app/Support/Tarball.php | 167 +++++++++++ database/fixtures/packages/build.sh | 12 + .../fixtures/packages/hello-api-1.0.0.tar.gz | Bin 0 -> 484 bytes .../fixtures/packages/hello-engine-1.0.0.tgz | Bin 0 -> 398 bytes .../fixtures/packages/pro-api-1.0.0.tar.gz | Bin 0 -> 481 bytes .../fixtures/packages/pro-engine-1.0.0.tgz | Bin 0 -> 392 bytes .../packages/src/hello-api/composer.json | 15 + .../packages/src/hello-api/src/Hello.php | 11 + .../packages/src/hello-engine/index.js | 3 + .../packages/src/hello-engine/package.json | 15 + .../packages/src/pro-api/composer.json | 15 + .../fixtures/packages/src/pro-api/src/Pro.php | 11 + .../fixtures/packages/src/pro-engine/index.js | 3 + .../packages/src/pro-engine/package.json | 15 + ...1_add_package_checksums_to_files_table.php | 28 ++ tests/Feature/Packages/ReleaseStorageTest.php | 76 +++++ tests/Pest.php | 38 +++ tests/Unit/Support/TarballTest.php | 40 +++ 20 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 app/Services/ReleaseStorage.php create mode 100644 app/Support/Tarball.php create mode 100755 database/fixtures/packages/build.sh create mode 100644 database/fixtures/packages/hello-api-1.0.0.tar.gz create mode 100644 database/fixtures/packages/hello-engine-1.0.0.tgz create mode 100644 database/fixtures/packages/pro-api-1.0.0.tar.gz create mode 100644 database/fixtures/packages/pro-engine-1.0.0.tgz create mode 100644 database/fixtures/packages/src/hello-api/composer.json create mode 100644 database/fixtures/packages/src/hello-api/src/Hello.php create mode 100644 database/fixtures/packages/src/hello-engine/index.js create mode 100644 database/fixtures/packages/src/hello-engine/package.json create mode 100644 database/fixtures/packages/src/pro-api/composer.json create mode 100644 database/fixtures/packages/src/pro-api/src/Pro.php create mode 100644 database/fixtures/packages/src/pro-engine/index.js create mode 100644 database/fixtures/packages/src/pro-engine/package.json create mode 100644 database/migrations/2026_10_22_000001_add_package_checksums_to_files_table.php create mode 100644 tests/Feature/Packages/ReleaseStorageTest.php create mode 100644 tests/Unit/Support/TarballTest.php diff --git a/app/Models/File.php b/app/Models/File.php index 99d78d6..5959b5c 100644 --- a/app/Models/File.php +++ b/app/Models/File.php @@ -33,13 +33,26 @@ class File extends Model protected $fillable = [ 'uploader_account_uuid', 'subject_type', 'subject_uuid', 'type', 'disk', 'path', 'name', - 'original_filename', 'size', 'mime', 'checksum', 'meta', + 'original_filename', 'size', 'mime', 'checksum', 'shasum', 'integrity', 'meta', ]; protected $hidden = ['id', 'disk', 'path']; protected $casts = ['size' => 'integer', 'meta' => 'array']; + /** + * Decoded `package.json` (engine tarballs) or `composer.json` (API tarballs) captured + * by `ReleaseStorage` when the archive was stored. + */ + public function manifest(): ?array + { + return match ($this->type) { + self::TYPE_ENGINE_TARBALL => $this->meta['package_json'] ?? null, + self::TYPE_API_TARBALL => $this->meta['composer_json'] ?? null, + default => null, + }; + } + public function subject(): MorphTo { return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); diff --git a/app/Services/ReleaseStorage.php b/app/Services/ReleaseStorage.php new file mode 100644 index 0000000..5548583 --- /dev/null +++ b/app/Services/ReleaseStorage.php @@ -0,0 +1,267 @@ + 'tarballs', + File::TYPE_API_TARBALL => 'tarballs', + File::TYPE_ENGINE_DIST => 'dist', + File::TYPE_ICON => 'icons', + File::TYPE_SCREENSHOT => 'screenshots', + ]; + + /** Manifest kept in `files.meta` for each package tarball type. */ + public const MANIFEST_FOR_TYPE = [ + File::TYPE_ENGINE_TARBALL => 'package.json', + File::TYPE_API_TARBALL => 'composer.json', + ]; + + public const CHUNK_BYTES = 1024 * 1024; + + public function __construct(protected ?string $diskName = null) {} + + public function diskName(): string + { + return $this->diskName ?? config('registry.storage.disk', 'local'); + } + + public function disk(): Filesystem + { + return Storage::disk($this->diskName()); + } + + public function prefix(string $type): string + { + $key = self::PREFIX_FOR_TYPE[$type] ?? throw new \InvalidArgumentException("Unknown file type [{$type}]."); + + return trim(config("registry.storage.prefixes.{$key}", $key), '/'); + } + + /** + * Copy a local file into the store and record it. + * + * @param array $attributes extra `files` columns (name, original_filename, uploader_account_uuid, mime, meta) + */ + public function store(string $sourcePath, string $type, ?Model $subject = null, array $attributes = []): File + { + if (! is_file($sourcePath) || ! is_readable($sourcePath)) { + throw new \InvalidArgumentException("Source file [{$sourcePath}] is not readable."); + } + + $digests = static::digests($sourcePath); + $filename = static::safeFilename($attributes['name'] ?? basename($sourcePath)); + $uuid = (string) Str::uuid(); + $path = $this->prefix($type).'/'.$uuid.'/'.$filename; + + $meta = $attributes['meta'] ?? []; + if ($manifestName = self::MANIFEST_FOR_TYPE[$type] ?? null) { + $manifest = Tarball::manifest($sourcePath, $manifestName); + if ($manifest !== null) { + $meta[Str::snake(str_replace('.', '_', $manifestName))] = $manifest; + } + } + + $stream = fopen($sourcePath, 'rb'); + try { + if ($this->disk()->writeStream($path, $stream) === false) { + throw new \RuntimeException("Unable to write [{$path}] to disk [{$this->diskName()}]."); + } + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + return (new File)->forceFill(array_merge([ + 'original_filename' => basename($sourcePath), + 'mime' => static::detectMime($sourcePath, $type), + ], $attributes, [ + 'uuid' => $uuid, + 'subject_type' => $subject?->getMorphClass(), + 'subject_uuid' => $subject?->getKey(), + 'type' => $type, + 'disk' => $this->diskName(), + 'path' => $path, + 'name' => $filename, + 'size' => $digests['size'], + 'checksum' => $digests['sha256'], + 'shasum' => $digests['sha1'], + 'integrity' => $digests['integrity'], + 'meta' => $meta ?: null, + ]))->saveInstance(); + } + + /** + * Store raw bytes (fixtures, generated archives). + */ + public function storeContents(string $contents, string $type, string $filename, ?Model $subject = null, array $attributes = []): File + { + $tmp = tempnam(sys_get_temp_dir(), 'reg'); + try { + file_put_contents($tmp, $contents); + + return $this->store($tmp, $type, $subject, ['name' => $filename, 'original_filename' => $filename] + $attributes); + } finally { + @unlink($tmp); + } + } + + /** + * sha256 + sha1 hex digests, the SRI sha512 integrity string and the byte size. + * + * @return array{sha256: string, sha1: string, integrity: string, size: int} + */ + public static function digests(string $path): array + { + $contexts = ['sha256' => hash_init('sha256'), 'sha1' => hash_init('sha1'), 'sha512' => hash_init('sha512')]; + $handle = fopen($path, 'rb'); + $size = 0; + while (! feof($handle)) { + $chunk = fread($handle, self::CHUNK_BYTES); + if ($chunk === false) { + break; + } + $size += strlen($chunk); + foreach ($contexts as $context) { + hash_update($context, $chunk); + } + } + fclose($handle); + + return [ + 'sha256' => hash_final($contexts['sha256']), + 'sha1' => hash_final($contexts['sha1']), + 'integrity' => 'sha512-'.base64_encode(hash_final($contexts['sha512'], true)), + 'size' => $size, + ]; + } + + public function exists(File $file): bool + { + return Storage::disk($file->disk)->exists($file->path); + } + + /** + * Recompute the sha256 of the stored object and compare it to the recorded checksum. + */ + public function verify(File $file): bool + { + $stream = Storage::disk($file->disk)->readStream($file->path); + if (! is_resource($stream)) { + return false; + } + + $context = hash_init('sha256'); + hash_update_stream($context, $stream); + fclose($stream); + + return hash_equals((string) $file->checksum, hash_final($context)); + } + + public function delete(File $file): void + { + Storage::disk($file->disk)->delete($file->path); + $file->delete(); + } + + /** + * Stream a stored object. Answers `If-None-Match` with 304 (the ETag is the sha256), + * and re-hashes the bytes as they go out so a corrupted object is logged. + */ + public function download(File $file, Request $request, array $headers = []): Response + { + $headers = array_merge([ + 'Content-Type' => $file->mime ?: 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="'.addcslashes((string) $file->name, '"\\').'"', + 'X-Checksum-Sha256' => $file->checksum, + ], $headers); + + $notModified = new Response; + $notModified->setEtag((string) $file->checksum); + if ($notModified->isNotModified($request)) { + $notModified->headers->add(array_intersect_key($headers, array_flip(['Cache-Control', 'Vary']))); + + return $notModified; + } + + $disk = Storage::disk($file->disk); + $stream = $disk->readStream($file->path); + if (! is_resource($stream)) { + Log::error('Package store object missing', ['file' => $file->public_id, 'disk' => $file->disk, 'path' => $file->path]); + abort(404, 'Package file not found.'); + } + + $response = new StreamedResponse(function () use ($stream, $file) { + $context = hash_init('sha256'); + while (! feof($stream)) { + $chunk = fread($stream, self::CHUNK_BYTES); + if ($chunk === false) { + break; + } + hash_update($context, $chunk); + echo $chunk; + flush(); + } + fclose($stream); + + $actual = hash_final($context); + if ($file->checksum && ! hash_equals($file->checksum, $actual)) { + Log::critical('Package store checksum mismatch on download', [ + 'file' => $file->public_id, 'expected' => $file->checksum, 'actual' => $actual, + ]); + } + }, 200, $headers); + + $response->setEtag((string) $file->checksum); + if ($file->size > 0) { + $response->headers->set('Content-Length', (string) $file->size); + } + + return $response; + } + + public static function safeFilename(string $name): string + { + $name = basename(str_replace('\\', '/', $name)); + $name = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name); + + return trim($name, '.-') ?: 'file'; + } + + protected static function detectMime(string $path, string $type): string + { + $magic = (string) file_get_contents($path, false, null, 0, 262); + + return match (true) { + str_starts_with($magic, "\x1f\x8b") => 'application/gzip', + strlen($magic) >= 262 && substr($magic, 257, 5) === 'ustar' => 'application/x-tar', + in_array($type, [File::TYPE_ICON, File::TYPE_SCREENSHOT], true) => (function_exists('mime_content_type') ? mime_content_type($path) : null) ?: 'application/octet-stream', + default => 'application/octet-stream', + }; + } +} diff --git a/app/Support/Tarball.php b/app/Support/Tarball.php new file mode 100644 index 0000000..6076767 --- /dev/null +++ b/app/Support/Tarball.php @@ -0,0 +1,167 @@ + 0) { + self::skip($handle, $padded); + } + } + } finally { + gzclose($handle); + } + } + + /** + * Decode a manifest (`package.json`, `composer.json`) at the archive root or inside a + * single top-level folder (npm packs into `package/`), preferring the shallowest. + */ + public static function manifest(string $path, string $filename): ?array + { + $candidates = []; + + self::walk( + $path, + fn (string $name, string $type) => $type === '0' && preg_match('#^([^/]+/)?'.preg_quote($filename, '#').'$#', $name) === 1, + function (string $name, string $contents) use (&$candidates) { + $candidates[substr_count($name, '/')] ??= $contents; + }, + ); + + if ($candidates === []) { + return null; + } + + ksort($candidates); + $decoded = json_decode(reset($candidates), true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * Entry names in archive order (for tests and diagnostics). + */ + public static function names(string $path): array + { + $names = []; + self::walk($path, function (string $name) use (&$names) { + $names[] = $name; + + return false; + }); + + return $names; + } + + protected static function readExactly($handle, int $length): ?string + { + $buffer = ''; + while (strlen($buffer) < $length) { + $chunk = gzread($handle, $length - strlen($buffer)); + if ($chunk === false || $chunk === '') { + return $buffer === '' ? null : str_pad($buffer, $length, "\0"); + } + $buffer .= $chunk; + } + + return $buffer; + } + + protected static function skip($handle, int $length): void + { + while ($length > 0) { + $chunk = gzread($handle, min($length, 65536)); + if ($chunk === false || $chunk === '') { + return; + } + $length -= strlen($chunk); + } + } + + protected static function parseSize(string $field): int + { + // Base-256 sizes (high bit set) are only used for entries over 8 GiB. + if ((ord($field[0]) & 0x80) !== 0) { + throw new \RuntimeException('Archive entry too large.'); + } + + return (int) octdec(trim(self::cString($field)) ?: '0'); + } + + protected static function cString(string $value): string + { + $nul = strpos($value, "\0"); + + return $nul === false ? $value : substr($value, 0, $nul); + } +} diff --git a/database/fixtures/packages/build.sh b/database/fixtures/packages/build.sh new file mode 100755 index 0000000..7d3ded2 --- /dev/null +++ b/database/fixtures/packages/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +# Rebuild the fixture tarballs from src/. Engine tarballs come from `npm pack` (the +# `package/` layout npm publishes); API tarballs are gzipped ustar archives with the same +# single top-level folder, which Composer strips on extraction. +set -eu +cd "$(dirname "$0")" +for kind in hello pro; do + packed=$(cd "src/$kind-engine" && npm pack --silent | tail -n 1) + mv "src/$kind-engine/$packed" "$kind-engine-1.0.0.tgz" + COPYFILE_DISABLE=1 tar --format ustar --uid 0 --gid 0 --uname '' --gname '' \ + -s "#^src/$kind-api#package#" -czf "$kind-api-1.0.0.tar.gz" "src/$kind-api" +done diff --git a/database/fixtures/packages/hello-api-1.0.0.tar.gz b/database/fixtures/packages/hello-api-1.0.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..9d708f9c16475b893705ccaecf0b93768e66db27 GIT binary patch literal 484 zcmVXLk{U4jpk;l}Icz_Q+3fcD=ksE3 z{#lmi6LRO7{0#o{`F{?GMOd>fgbNZPY`8Yq(h9b6dI3U6$`lWeJ6Z+0M6L{%wPU~Q z*<{VHOoOl>YXl&tXKzSioA;WfX3tmF4 z{g=~MXK!g1#Z)2{%m5CYOK3kyth6Pahid^~RyHz)D`S6k<-YHU2p=1cE?Bp9?^GAc zMSuS6Nfd|Pz3b_$F;YlY?eR6z*$tO>5B+#)o3~uPcHLVpU0{vnH2lilbs2(P@Vlpf zeg9pBhsyEm`k%~_bkzTcz_;`F2R5>I;huB+NdMFHJN-|JV$}bKz|$9Y-a+8>U)!?) zW5e5C8z@yYhno literal 0 HcmV?d00001 diff --git a/database/fixtures/packages/hello-engine-1.0.0.tgz b/database/fixtures/packages/hello-engine-1.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c009e9adbd98a54dc58f763946b5ad737e5a5900 GIT binary patch literal 398 zcmV;90df8xiwFP!00002|Lv7cPvbBUhI7uZ82Qo*4mPv_1+m&aap8ctA`Wi-wrfc2 z%66a?>VHQ~oC;Q}J+YOL@?1ul8PA86H(9I2vs&TQSdC5b6te$-5Tcw;>2Ll$QcB9s zXQhy`EX9;EA;pC(DHGWdXhT#!6XFY&6hhF!eiJ5b0EOtyB2KRIym7j%QDC!nJ_fp` zH?pchMU$T%D-)vMa*f8F{zhFp4!bfI6WU$n*~oWfo%Ek<@f4grvi{52*`NBCvzZ+A ze+=GoA|kdIIje~ML*?wGPkn(8#@T(gEJPs&zeZSivyLftp9;~^Y+~zC(b5Cd*P`^g zQ5&FXzNMjU6#z|~?+y2;p#qAb!!x$8&g;M`dSJ^Mh;tRdv5BzVNGHV5%;CAx`r~6- z;4#&y%>p)b`~JGQyXEJF;Lh_}MfjmtqfEEP@LwGKOH-pC;1C(g9wuTH_1D_Dy=!CF stGi6r1$OC3KUnyDz&hRK|F|Z3ney}9_<6_?u?Y3QbDa-ZE2e?AjsZCjaeH%eeBPuAY0BJ4*dMz;Q5t7i z5{J=A%)%(l;$*_oLpC*_HdY|Da#x-Gm-|bgd;fE_(aHb@pNy*Rc@A2SY&N^u|CBu6 z^*>3{Xu|I7$*l%h5#~$_S+MiP8`keCRvReTvSEYl+yE6gcLPT#K^O=IdE4l=hf*$4U^A3UnL}mT zJLj(#Z+Q}gTp=%o1sph+(0ox?7$Uv*S0#X5k}`)YOOLvCKlDU|&$UD+*1dRJ)E~7| zfARb@2>rIa?dhVns#K!b;~V6&ol;vK*5ef|-NnTM>EdJ!=2QPWbKB{ATklUh-(mRv zU6+TN742)t98r#k&p;{!n zi*7Od-wjX}HF|SPmhic}@a^OMAbg?X%Pn8ySSo?Lcw>VTz!=NY6n@hi5_S#2WSX%g z$ugEg&?r|E2v~3iYU{WO82g7M8Dnsa7eZ891nZ1kx@Z=Lr51I8G43jDoP{}jfZ`?Q z5ZxJEOY6*@3M}=wGMb`VON9{}W??XJnN;5YKE@lX)tUXDrP)vaC(~&<`2QSyga81f zF*(VB{ONKs>Js13SgBQ$o5U<;y)Mu$jjWtMd-i2uC3mhim_x;-fUXw!mknx*0v3Dd zo8}f#0HsZ!1`FgCqrl zL}lA;?;oBX=_F=U8(MIOrtej9*{;$56?=Up3+x6sp7dpp6OkOchuX?U*RJhV8BJRG m;BV;0vftB{7H#(J9FiNKA0CF!>%hRk3HSs@YSrTa3;+Pnb-L^T literal 0 HcmV?d00001 diff --git a/database/fixtures/packages/src/hello-api/composer.json b/database/fixtures/packages/src/hello-api/composer.json new file mode 100644 index 0000000..3306cf3 --- /dev/null +++ b/database/fixtures/packages/src/hello-api/composer.json @@ -0,0 +1,15 @@ +{ + "name": "registry-demo/hello-api", + "description": "Registry fixture: free API package used by registry:seed --seed-release.", + "type": "library", + "license": "AGPL-3.0-or-later", + "keywords": ["fleetbase-extension"], + "require": { + "php": ">=8.0" + }, + "autoload": { + "psr-4": { + "RegistryDemo\\Hello\\": "src/" + } + } +} diff --git a/database/fixtures/packages/src/hello-api/src/Hello.php b/database/fixtures/packages/src/hello-api/src/Hello.php new file mode 100644 index 0000000..33b14ce --- /dev/null +++ b/database/fixtures/packages/src/hello-api/src/Hello.php @@ -0,0 +1,11 @@ += 18" + } +} diff --git a/database/fixtures/packages/src/pro-api/composer.json b/database/fixtures/packages/src/pro-api/composer.json new file mode 100644 index 0000000..0a4fe24 --- /dev/null +++ b/database/fixtures/packages/src/pro-api/composer.json @@ -0,0 +1,15 @@ +{ + "name": "registry-demo/pro-api", + "description": "Registry fixture: paid API package used by registry:seed --seed-release.", + "type": "library", + "license": "AGPL-3.0-or-later", + "keywords": ["fleetbase-extension"], + "require": { + "php": ">=8.0" + }, + "autoload": { + "psr-4": { + "RegistryDemo\\Pro\\": "src/" + } + } +} diff --git a/database/fixtures/packages/src/pro-api/src/Pro.php b/database/fixtures/packages/src/pro-api/src/Pro.php new file mode 100644 index 0000000..9ff3115 --- /dev/null +++ b/database/fixtures/packages/src/pro-api/src/Pro.php @@ -0,0 +1,11 @@ += 18" + } +} diff --git a/database/migrations/2026_10_22_000001_add_package_checksums_to_files_table.php b/database/migrations/2026_10_22_000001_add_package_checksums_to_files_table.php new file mode 100644 index 0000000..9e71c5d --- /dev/null +++ b/database/migrations/2026_10_22_000001_add_package_checksums_to_files_table.php @@ -0,0 +1,28 @@ +char('shasum', 40)->nullable()->after('checksum'); + $table->string('integrity', 128)->nullable()->after('shasum'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn(['shasum', 'integrity']); + }); + } +}; diff --git a/tests/Feature/Packages/ReleaseStorageTest.php b/tests/Feature/Packages/ReleaseStorageTest.php new file mode 100644 index 0000000..1489142 --- /dev/null +++ b/tests/Feature/Packages/ReleaseStorageTest.php @@ -0,0 +1,76 @@ + 'packages']); + Storage::fake('packages'); +}); + +it('stores a tarball under its prefix with all three digests and the manifest', function () { + $release = Release::factory()->create(); + $source = fixturePackage('hello-engine-1.0.0.tgz'); + + $file = app(ReleaseStorage::class)->store($source, File::TYPE_ENGINE_TARBALL, $release); + + expect($file->path)->toBe('tarballs/'.$file->uuid.'/hello-engine-1.0.0.tgz') + ->and($file->disk)->toBe('packages') + ->and($file->size)->toBe(filesize($source)) + ->and($file->checksum)->toBe(hash_file('sha256', $source)) + ->and($file->shasum)->toBe(sha1_file($source)) + ->and($file->integrity)->toBe('sha512-'.base64_encode(hash_file('sha512', $source, true))) + ->and($file->mime)->toBe('application/gzip') + ->and($file->subject_uuid)->toBe($release->uuid) + ->and($file->manifest()['name'])->toBe('@registry-demo/hello-engine') + ->and($file->public_id)->toStartWith('file_'); + + Storage::disk('packages')->assertExists($file->path); + expect(app(ReleaseStorage::class)->verify($file))->toBeTrue(); +}); + +it('uses a prefix per file type', function (string $type, string $prefix) { + $file = app(ReleaseStorage::class)->storeContents('bytes', $type, 'x.bin'); + + expect($file->path)->toStartWith($prefix.'/'); +})->with([ + [File::TYPE_ENGINE_TARBALL, 'tarballs'], + [File::TYPE_API_TARBALL, 'tarballs'], + [File::TYPE_ENGINE_DIST, 'dist'], + [File::TYPE_ICON, 'icons'], + [File::TYPE_SCREENSHOT, 'screenshots'], +]); + +it('keeps the composer.json of an API tarball', function () { + $file = app(ReleaseStorage::class)->store(fixturePackage('hello-api-1.0.0.tar.gz'), File::TYPE_API_TARBALL); + + expect($file->manifest()['name'])->toBe('registry-demo/hello-api') + ->and($file->meta)->toHaveKey('composer_json'); +}); + +it('sanitizes filenames and rejects unknown types', function () { + $file = app(ReleaseStorage::class)->storeContents('x', File::TYPE_ICON, '../../etc/pass wd.png'); + expect($file->name)->toBe('pass-wd.png'); + + app(ReleaseStorage::class)->storeContents('x', 'nonsense', 'x'); +})->throws(InvalidArgumentException::class); + +it('detects a corrupted object', function () { + $file = app(ReleaseStorage::class)->storeContents('original', File::TYPE_ENGINE_DIST, 'dist.tgz'); + Storage::disk('packages')->put($file->path, 'tampered'); + + expect(app(ReleaseStorage::class)->verify($file))->toBeFalse(); +}); + +it('deletes the object with the row', function () { + $storage = app(ReleaseStorage::class); + $file = $storage->storeContents('x', File::TYPE_SCREENSHOT, 's.png'); + + $storage->delete($file); + + Storage::disk('packages')->assertMissing($file->path); + expect(File::find($file->uuid))->toBeNull() + ->and(File::withTrashed()->find($file->uuid))->not->toBeNull(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 48d34a3..f620b40 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,3 +22,41 @@ function actingAsAccount(Account $account, array $scopes = [Token::SCOPE_ALL]): return $token; } + +/** + * Write a gzipped ustar archive of `path => contents` and return its path. Paths longer than + * 100 bytes go through a GNU long-name entry, as npm and GNU tar write them. + */ +function makeTarball(array $files, bool $gzip = true): string +{ + $block = fn (string $data) => $data.str_repeat("\0", (512 - strlen($data) % 512) % 512); + $header = function (string $name, int $size, string $type = '0'): string { + $h = str_pad(substr($name, 0, 100), 100, "\0") + .str_pad('0000644', 8, "\0").str_pad('0000000', 8, "\0").str_pad('0000000', 8, "\0") + .str_pad(decoct($size), 11, '0', STR_PAD_LEFT)."\0" + .str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0" + .' '.$type.str_repeat("\0", 100)."ustar\0".'00'.str_repeat("\0", 32 + 32 + 8 + 8 + 155 + 12); + $sum = array_sum(array_map('ord', str_split($h))); + + return substr_replace($h, str_pad(decoct($sum), 6, '0', STR_PAD_LEFT)."\0 ", 148, 8); + }; + + $tar = ''; + foreach ($files as $name => $contents) { + if (strlen($name) > 100) { + $tar .= $header('././@LongLink', strlen($name) + 1, 'L').$block($name."\0"); + } + $tar .= $header($name, strlen($contents)).$block($contents); + } + $tar .= str_repeat("\0", 1024); + + $path = tempnam(sys_get_temp_dir(), 'tar'); + file_put_contents($path, $gzip ? gzencode($tar) : $tar); + + return $path; +} + +function fixturePackage(string $file): string +{ + return base_path('database/fixtures/packages/'.$file); +} diff --git a/tests/Unit/Support/TarballTest.php b/tests/Unit/Support/TarballTest.php new file mode 100644 index 0000000..2b91168 --- /dev/null +++ b/tests/Unit/Support/TarballTest.php @@ -0,0 +1,40 @@ +toBe('@registry-demo/hello-engine') + ->and($manifest['version'])->toBe('1.0.0'); +}); + +it('reads composer.json from a single top-level folder', function () { + $manifest = Tarball::manifest(fixturePackage('hello-api-1.0.0.tar.gz'), 'composer.json'); + + expect($manifest['name'])->toBe('registry-demo/hello-api'); +}); + +it('prefers the shallowest manifest and ignores nested ones', function () { + $path = makeTarball([ + 'package/node_modules/dep/package.json' => '{"name":"dep"}', + 'package/package.json' => '{"name":"root"}', + ]); + + expect(Tarball::manifest($path, 'package.json'))->toBe(['name' => 'root']); + + $nestedOnly = makeTarball(['a/b/package.json' => '{"name":"deep"}']); + expect(Tarball::manifest($nestedOnly, 'package.json'))->toBeNull(); +}); + +it('handles uncompressed archives and GNU long names', function () { + $long = 'package/'.str_repeat('nested-directory/', 8).'file.txt'; + $path = makeTarball(['composer.json' => '{"name":"a/b"}', $long => 'x'], gzip: false); + + expect(Tarball::manifest($path, 'composer.json'))->toBe(['name' => 'a/b']) + ->and(Tarball::names($path))->toBe(['composer.json', $long]); +}); + +it('returns null for invalid json', function () { + expect(Tarball::manifest(makeTarball(['package/package.json' => '{nope']), 'package.json'))->toBeNull(); +}); From c19ca6391fe8c0dc393af87a4cc89476869c230b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:40:19 +0800 Subject: [PATCH 08/16] Add npm packument and tarball endpoints GET /npm/{name} builds the packument from published, non-yanked releases that carry an engine tarball: versions from the stored package.json with authoritative name, version and dist (tarball, shasum, integrity), dist-tags by SemVer precedence (latest = highest stable, beta = highest beta, latest falls back to beta when nothing is stable), time, and a content ETag with 304 support. Scoped names resolve whether npm encodes the slash or not. GET /npm/{name}/-/{file}.tgz streams the tarball; yanked versions stay downloadable for lockfiles. npm writes (publish, unpublish, dist-tag) get 405 pointing at flb publish. Package routes return npm's error string beside the Fleetbase envelope. --- .env.example | 3 + .../Controllers/Packages/NpmController.php | 87 +++++++++ .../Packages/PackageController.php | 66 +++++++ app/Models/ExtensionPackage.php | 58 ++++++ app/Providers/AppServiceProvider.php | 15 ++ app/Services/Packages/NpmPackument.php | 126 +++++++++++++ app/Support/Semver.php | 125 +++++++++++++ bootstrap/app.php | 17 +- config/registry.php | 7 + routes/api.php | 23 ++- tests/Feature/Packages/NpmTest.php | 166 ++++++++++++++++++ tests/Feature/Packages/ReleaseStorageTest.php | 5 - tests/Pest.php | 53 ++++++ tests/Unit/Support/SemverTest.php | 29 +++ 14 files changed, 770 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/Packages/NpmController.php create mode 100644 app/Http/Controllers/Packages/PackageController.php create mode 100644 app/Services/Packages/NpmPackument.php create mode 100644 app/Support/Semver.php create mode 100644 tests/Feature/Packages/NpmTest.php create mode 100644 tests/Unit/Support/SemverTest.php diff --git a/.env.example b/.env.example index 71887f1..e47cce3 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=false # Registry REGISTRY_VERSION=2.0.0-dev REGISTRY_CATALOG_CACHE_SECONDS=60 +# Base URL written into npm/Composer metadata; empty = the request's scheme and host. +REGISTRY_PUBLIC_URL= +REGISTRY_PACKAGE_METADATA_CACHE_SECONDS=60 REGISTRY_STAFF_ORGANIZATION=fleetbase HASH_VERIFY=true diff --git a/app/Http/Controllers/Packages/NpmController.php b/app/Http/Controllers/Packages/NpmController.php new file mode 100644 index 0000000..2625085 --- /dev/null +++ b/app/Http/Controllers/Packages/NpmController.php @@ -0,0 +1,87 @@ +resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + $document = $this->packument->build($resolved); + + abort_if((array) $document['versions'] === [], 404, 'Package not found.'); + + return $this->metadataResponse($document, $request, ! $resolved->extension->isPaid()); + } + + /** + * GET /npm/{name}/-/{basename}-{version}.tgz + */ + public function tarball(Request $request, string $package, string $file): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + + $basename = str_contains($resolved->name, '/') ? substr($resolved->name, strpos($resolved->name, '/') + 1) : $resolved->name; + abort_unless(str_starts_with($file, $basename.'-') && str_ends_with($file, '.tgz'), 404, 'Tarball not found.'); + $version = substr($file, strlen($basename) + 1, -strlen('.tgz')); + + $tarball = $resolved->downloadableRelease($version)?->engineTarball; + abort_if($tarball === null, 404, 'Tarball not found.'); + + return $this->storage->download($tarball, $request, array_merge( + ['Content-Type' => 'application/octet-stream'], + $this->archiveCacheHeaders(! $resolved->extension->isPaid()), + )); + } + + /** + * PUT/POST/DELETE/PATCH under /npm: publish, unpublish, dist-tag, deprecate, adduser. + */ + public function readOnly(): JsonResponse + { + return response()->packageError(self::READ_ONLY_MESSAGE, 405, ['Allow' => 'GET, HEAD']); + } + + /** + * GET /npm/-/ping (`npm ping`) + */ + public function ping(): JsonResponse + { + return response()->json(new \stdClass); + } + + /** + * GET /npm/-/whoami (`npm whoami` with a registry token configured) + */ + public function whoami(Request $request): JsonResponse + { + $account = auth('token')->user(); + if (! $account instanceof Account) { + return response()->packageError('Unauthenticated.', 401); + } + + return response()->json(['username' => $account->username ?? $account->email]); + } +} diff --git a/app/Http/Controllers/Packages/PackageController.php b/app/Http/Controllers/Packages/PackageController.php new file mode 100644 index 0000000..beeca96 --- /dev/null +++ b/app/Http/Controllers/Packages/PackageController.php @@ -0,0 +1,66 @@ +attributes->get(self::PACKAGE_ATTRIBUTE) ?? ExtensionPackage::resolve($kind, $name); + + abort_if($package === null || $package->kind !== $kind, 404, 'Package not found.'); + + return $package; + } + + protected function metadataResponse(array $document, Request $request, bool $public): Response + { + $json = json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $response = new Response($json, 200, ['Content-Type' => 'application/json']); + $response->setEtag(sha1($json)); + $this->cacheFor($response, $public, config('registry.package_metadata_cache_seconds', 60)); + $response->isNotModified($request); + + return $response; + } + + /** + * Free packages may sit in shared caches; paid ones are private and revalidated every + * time so a revoked licence takes effect on the next request. + */ + protected function cacheFor(SymfonyResponse $response, bool $public, int $maxAge): SymfonyResponse + { + if ($public) { + $response->setPublic(); + $response->setMaxAge($maxAge); + } else { + $response->setPrivate(); + $response->headers->addCacheControlDirective('no-cache'); + $response->setVary('Authorization', false); + } + + return $response; + } + + protected function archiveCacheHeaders(bool $public): array + { + return $public + ? ['Cache-Control' => 'public, max-age=31536000, immutable'] + : ['Cache-Control' => 'private, no-cache', 'Vary' => 'Authorization']; + } +} diff --git a/app/Models/ExtensionPackage.php b/app/Models/ExtensionPackage.php index 7c27a5d..d7b0a38 100644 --- a/app/Models/ExtensionPackage.php +++ b/app/Models/ExtensionPackage.php @@ -2,7 +2,10 @@ namespace App\Models; +use App\Support\Semver; use App\Traits\HasUuid; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -28,4 +31,59 @@ public function extension(): BelongsTo { return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); } + + /** + * The package a client asked for, if its listing is servable: not deleted and not + * suspended. Names are matched lowercase, as npm and Composer require them. + */ + public static function resolve(string $kind, string $name): ?static + { + return static::query() + ->where('kind', $kind) + ->where('name', strtolower($name)) + ->whereHas('extension', fn (Builder $q) => $q->where('status', '!=', Extension::STATUS_SUSPENDED)) + ->with('extension.price') + ->first(); + } + + /** + * Release column and relation holding this package's archive. + */ + public function fileRelation(): string + { + return $this->kind === self::KIND_NPM ? 'engineTarball' : 'apiTarball'; + } + + /** + * Published releases that carry this package's archive and a valid SemVer version, + * ascending by precedence. Yanked releases are excluded. + * + * @return Collection + */ + public function servableReleases(): Collection + { + $relation = $this->fileRelation(); + + return $this->extension->releases() + ->published() + ->whereNotNull($relation === 'engineTarball' ? 'engine_tarball_file_uuid' : 'api_tarball_file_uuid') + ->with($relation) + ->get() + ->filter(fn (Release $release) => $release->{$relation} !== null && Semver::valid($release->version)) + ->sort(fn (Release $a, Release $b) => Semver::compare($a->version, $b->version)) + ->values(); + } + + /** + * A release whose archive may be downloaded. Yanked releases stay downloadable so + * existing lockfiles keep installing; they are only hidden from metadata. + */ + public function downloadableRelease(string $version): ?Release + { + return $this->extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->where('version', $version) + ->with($this->fileRelation()) + ->first(); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cc5b733..8777d81 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Response; +use Illuminate\Support\Facades\URL; use Illuminate\Support\MessageBag; use Illuminate\Support\ServiceProvider; @@ -31,6 +32,12 @@ public function boot(): void $this->registerAuthGuard(); $this->registerResponseMacros(); $this->registerRateLimiters(); + + // Package metadata embeds absolute download URLs; pin them behind proxies. + if ($publicUrl = config('registry.public_url')) { + URL::forceRootUrl($publicUrl); + URL::forceScheme(parse_url($publicUrl, PHP_URL_SCHEME) ?: 'https'); + } } /** @@ -53,6 +60,11 @@ protected function registerResponseMacros(): void return Response::json(array_merge(['errors' => is_array($error) ? array_values($error) : [$error]], $data), $statusCode); }); + + // Package routes add npm's `error` string (shown by the npm CLI) beside the envelope. + Response::macro('packageError', function (string $message, int $statusCode, array $headers = []): JsonResponse { + return Response::json(['errors' => [$message], 'error' => $message], $statusCode, $headers); + }); } protected function registerRateLimiters(): void @@ -65,5 +77,8 @@ protected function registerRateLimiters(): void RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); + + // npm and Composer fetch metadata and archives in bursts during an install. + RateLimiter::for('packages', fn (Request $request) => Limit::perMinute(1200)->by($request->bearerToken() ? sha1($request->bearerToken()) : $request->ip())); } } diff --git a/app/Services/Packages/NpmPackument.php b/app/Services/Packages/NpmPackument.php new file mode 100644 index 0000000..b00f647 --- /dev/null +++ b/app/Services/Packages/NpmPackument.php @@ -0,0 +1,126 @@ +extension; + $releases = $package->servableReleases(); + $name = $package->name; + + $versions = []; + $time = []; + foreach ($releases as $release) { + $versions[$release->version] = $this->version($name, $release); + $time[$release->version] = $this->timestamp($release->published_at ?? $release->created_at); + } + + $publishedAt = $releases->map(fn (Release $r) => $r->published_at ?? $r->created_at)->filter(); + $modifiedAt = $releases->map(fn (Release $r) => $r->updated_at)->push($extension->updated_at)->filter(); + + $document = [ + '_id' => $name, + 'name' => $name, + 'description' => $extension->subtitle ?? data_get(end($versions) ?: [], 'description'), + 'dist-tags' => (object) $this->distTags($releases), + 'versions' => (object) $versions, + 'time' => (object) array_merge(array_filter([ + 'created' => $this->timestamp($publishedAt->min()), + 'modified' => $this->timestamp($modifiedAt->max()), + ]), $time), + ]; + + if ($extension->website_url) { + $document['homepage'] = $extension->website_url; + } + if ($extension->description) { + $document['readme'] = $extension->description; + } + + return $document; + } + + /** + * `latest` = highest stable-channel release; `beta` = highest beta-channel release. + * With no stable release yet, `latest` falls back to the highest beta so a bare + * `npm install ` still resolves. + * + * @param Collection $releases + */ + public function distTags(Collection $releases): array + { + $stable = Semver::max($releases->where('channel', Release::CHANNEL_STABLE)->pluck('version')); + $beta = Semver::max($releases->where('channel', Release::CHANNEL_BETA)->pluck('version')); + + return array_filter(['latest' => $stable ?? $beta, 'beta' => $beta]); + } + + protected function version(string $name, Release $release): array + { + $file = $release->engineTarball; + $manifest = $file->manifest() ?? []; + + foreach (array_keys($manifest) as $key) { + if (str_starts_with((string) $key, '_') || in_array($key, self::STRIPPED_KEYS, true)) { + unset($manifest[$key]); + } + } + foreach (self::OBJECT_KEYS as $key) { + if (array_key_exists($key, $manifest) && $manifest[$key] === []) { + $manifest[$key] = new \stdClass; + } + } + + return array_merge($manifest, [ + 'name' => $name, + 'version' => $release->version, + '_id' => $name.'@'.$release->version, + 'dependencies' => $manifest['dependencies'] ?? new \stdClass, + 'dist' => [ + 'tarball' => self::tarballUrl($name, $release->version), + 'shasum' => $file->shasum, + 'integrity' => $file->integrity, + ], + ]); + } + + protected function timestamp(?CarbonInterface $at): ?string + { + return $at?->copy()->utc()->format('Y-m-d\TH:i:s.v\Z'); + } +} diff --git a/app/Support/Semver.php b/app/Support/Semver.php new file mode 100644 index 0000000..2d8f5be --- /dev/null +++ b/app/Support/Semver.php @@ -0,0 +1,125 @@ +}|null + */ + public static function parse(string $version): ?array + { + if (! preg_match(self::PATTERN, trim($version), $m)) { + return null; + } + + return [ + 'major' => (int) $m[1], + 'minor' => (int) $m[2], + 'patch' => (int) $m[3], + 'prerelease' => isset($m[4]) && $m[4] !== '' ? explode('.', $m[4]) : [], + ]; + } + + public static function valid(string $version): bool + { + return self::parse($version) !== null; + } + + public static function isComposerCompatible(string $version): bool + { + return preg_match(self::COMPOSER_PATTERN, $version) === 1; + } + + public static function isPrerelease(string $version): bool + { + return (self::parse($version)['prerelease'] ?? []) !== []; + } + + /** + * -1, 0 or 1 by SemVer precedence. Invalid versions sort below valid ones. + */ + public static function compare(string $a, string $b): int + { + $pa = self::parse($a); + $pb = self::parse($b); + + if ($pa === null || $pb === null) { + return ($pa !== null) <=> ($pb !== null) ?: strcmp($a, $b) <=> 0; + } + + foreach (['major', 'minor', 'patch'] as $part) { + if ($pa[$part] !== $pb[$part]) { + return $pa[$part] <=> $pb[$part]; + } + } + + // A version without a prerelease has higher precedence than one with. + if ($pa['prerelease'] === [] || $pb['prerelease'] === []) { + return ($pa['prerelease'] === []) <=> ($pb['prerelease'] === []); + } + + $count = max(count($pa['prerelease']), count($pb['prerelease'])); + for ($i = 0; $i < $count; $i++) { + $x = $pa['prerelease'][$i] ?? null; + $y = $pb['prerelease'][$i] ?? null; + if ($x === null || $y === null) { + return $x === null ? -1 : 1; + } + + $xNumeric = ctype_digit($x); + $yNumeric = ctype_digit($y); + $cmp = match (true) { + $xNumeric && $yNumeric => (int) $x <=> (int) $y, + $xNumeric => -1, + $yNumeric => 1, + default => strcmp($x, $y) <=> 0, + }; + if ($cmp !== 0) { + return $cmp; + } + } + + return 0; + } + + /** + * Sort ascending by precedence. + * + * @param list $versions + * @return list + */ + public static function sort(array $versions): array + { + usort($versions, [self::class, 'compare']); + + return $versions; + } + + /** + * @param iterable $versions + */ + public static function max(iterable $versions): ?string + { + $max = null; + foreach ($versions as $version) { + if ($max === null || self::compare($version, $max) > 0) { + $max = $version; + } + } + + return $max; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 3591d36..f991f21 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -26,7 +26,8 @@ ]); }) ->withExceptions(function (Exceptions $exceptions): void { - $isApi = fn (Request $request) => $request->is('v1/*') || $request->is('v1') || $request->expectsJson(); + $isPackage = fn (Request $request) => $request->is('npm', 'npm/*', 'composer', 'composer/*'); + $isApi = fn (Request $request) => $request->is('v1/*') || $request->is('v1') || $isPackage($request) || $request->expectsJson(); $exceptions->shouldRenderJsonWhen(fn (Request $request) => $isApi($request)); @@ -43,13 +44,23 @@ } }); - $exceptions->render(function (ModelNotFoundException|NotFoundHttpException $e, Request $request) use ($isApi) { + $exceptions->render(function (ModelNotFoundException|NotFoundHttpException $e, Request $request) use ($isApi, $isPackage) { + if ($isPackage($request)) { + return response()->packageError($e instanceof NotFoundHttpException && $e->getMessage() !== '' ? $e->getMessage() : 'Not found.', 404); + } + if ($isApi($request)) { return response()->error('Resource not found.', 404); } }); - $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isApi) { + $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isApi, $isPackage) { + if ($isPackage($request)) { + $message = $e->getMessage() ?: (Response::$statusTexts[$e->getStatusCode()] ?? 'Request failed.'); + + return response()->packageError($message, $e->getStatusCode(), $e->getHeaders()); + } + if ($isApi($request)) { $message = $e->getMessage() ?: (Response::$statusTexts[$e->getStatusCode()] ?? 'Request failed.'); diff --git a/config/registry.php b/config/registry.php index a533c56..2ed0c84 100644 --- a/config/registry.php +++ b/config/registry.php @@ -10,6 +10,13 @@ 'version' => env('REGISTRY_VERSION', '2.0.0-dev'), + // Absolute base URL written into package metadata (npm dist.tarball, Composer dist.url). + // Empty means the request's own scheme and host. + 'public_url' => env('REGISTRY_PUBLIC_URL'), + + // Cache lifetime for npm packuments and Composer metadata of free packages, in seconds. + 'package_metadata_cache_seconds' => (int) env('REGISTRY_PACKAGE_METADATA_CACHE_SECONDS', 60), + // Cache lifetime for public catalog responses, in seconds. 'catalog_cache_seconds' => (int) env('REGISTRY_CATALOG_CACHE_SECONDS', 60), diff --git a/routes/api.php b/routes/api.php index 6dadc40..370e995 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Account\TokenController; use App\Http\Controllers\Auth\AuthController; use App\Http\Controllers\Catalog\CatalogController; +use App\Http\Controllers\Packages\NpmController; use Illuminate\Support\Facades\Route; /* @@ -13,8 +14,9 @@ | | Public catalog: no auth. | Account routes: Authorization: Bearer flbp_… (personal token). -| Package (npm, Composer), instance, publisher, admin and Stripe routes -| are added in later workstreams. +| Package routes (/npm, /composer) sit outside /v1 because npm and Composer +| clients address them as registry roots. +| Instance, publisher, admin and Stripe routes are added in later workstreams. | */ @@ -42,3 +44,20 @@ Route::delete('account/tokens/{token}', [TokenController::class, 'destroy'])->name('account.tokens.destroy'); }); }); + +Route::prefix('npm')->name('npm.')->middleware('throttle:packages')->group(function () { + Route::get('-/ping', [NpmController::class, 'ping'])->name('ping'); + Route::get('-/whoami', [NpmController::class, 'whoami'])->name('whoami'); + + Route::get('{package}/-/{file}', [NpmController::class, 'tarball']) + ->where(['package' => NpmController::NAME_PATTERN, 'file' => '[A-Za-z0-9._~+-]+\\.tgz']) + ->name('tarball'); + + Route::get('{package}', [NpmController::class, 'packument']) + ->where('package', NpmController::NAME_PATTERN) + ->name('packument'); + + Route::match(['PUT', 'POST', 'DELETE', 'PATCH'], '{any}', [NpmController::class, 'readOnly']) + ->where('any', '.*') + ->name('read-only'); +}); diff --git a/tests/Feature/Packages/NpmTest.php b/tests/Feature/Packages/NpmTest.php new file mode 100644 index 0000000..d2a6ed0 --- /dev/null +++ b/tests/Feature/Packages/NpmTest.php @@ -0,0 +1,166 @@ + 'Plan better routes']); + makeRelease($package, '1.0.0', publishedAt: '2026-01-01 10:00:00', manifest: [ + 'description' => 'Engine', + 'dependencies' => ['ember-concurrency' => '^3.0.0'], + 'peerDependencies' => [], + '_private' => 'dropped', + 'readme' => 'dropped', + ]); + + $response = $this->get('/npm/@acme%2froute-optimizer-engine')->assertOk() + ->assertHeader('Content-Type', 'application/json') + ->assertJsonPath('_id', '@acme/route-optimizer-engine') + ->assertJsonPath('name', '@acme/route-optimizer-engine') + ->assertJsonPath('description', 'Plan better routes') + ->assertJsonPath('dist-tags.latest', '1.0.0') + ->assertJsonPath('time.created', '2026-01-01T10:00:00.000Z'); + + $version = $response->json('versions')['1.0.0']; + $file = Release::query()->where('version', '1.0.0')->first()->engineTarball; + expect($version['name'])->toBe('@acme/route-optimizer-engine') + ->and($version['version'])->toBe('1.0.0') + ->and($version['_id'])->toBe('@acme/route-optimizer-engine@1.0.0') + ->and($version['description'])->toBe('Engine') + ->and($version['dependencies'])->toBe(['ember-concurrency' => '^3.0.0']) + ->and($version['dist']['tarball'])->toBe(url('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz')) + ->and($response->json('time')['1.0.0'])->toBe('2026-01-01T10:00:00.000Z') + ->and($version['dist']['shasum'])->toBe($file->shasum) + ->and($version['dist']['integrity'])->toBe($file->integrity) + ->and($version)->not->toHaveKeys(['_private', 'readme']); + + // Empty objects stay objects for npm. + expect($response->getContent())->toContain('"peerDependencies":{}'); + + // Unencoded and upper-case encoded forms resolve the same package. + $this->get('/npm/@acme/route-optimizer-engine')->assertOk(); + $this->get('/npm/@acme%2Froute-optimizer-engine')->assertOk(); +}); + +it('builds versions from published, non-yanked releases and orders dist-tags by semver', function () { + $package = npmPackage(); + makeRelease($package, '1.2.0'); + makeRelease($package, '1.10.0', publishedAt: now()->subYear()->toDateTimeString()); + makeRelease($package, '2.0.0', status: Release::STATUS_YANKED); + makeRelease($package, '3.0.0', status: Release::STATUS_IN_REVIEW); + makeRelease($package, '2.0.0-beta.2', Release::CHANNEL_BETA); + makeRelease($package, '2.0.0-beta.10', Release::CHANNEL_BETA); + + $response = $this->get('/npm/@acme%2froute-optimizer-engine')->assertOk(); + + expect(array_keys($response->json('versions')))->toBe(['1.2.0', '1.10.0', '2.0.0-beta.2', '2.0.0-beta.10']) + ->and($response->json('dist-tags'))->toBe(['latest' => '1.10.0', 'beta' => '2.0.0-beta.10']); +}); + +it('falls back to the highest beta for latest when nothing stable is published', function () { + $package = npmPackage(); + makeRelease($package, '0.1.0-beta.1', Release::CHANNEL_BETA); + + $this->get('/npm/@acme%2froute-optimizer-engine')->assertOk() + ->assertJsonPath('dist-tags', ['latest' => '0.1.0-beta.1', 'beta' => '0.1.0-beta.1']); +}); + +it('answers If-None-Match with 304 and changes the ETag when a release is published', function () { + $package = npmPackage(); + makeRelease($package, '1.0.0'); + + $first = $this->get('/npm/@acme%2froute-optimizer-engine')->assertOk(); + $etag = $first->headers->get('ETag'); + expect($etag)->not->toBeEmpty() + ->and($first->headers->get('Cache-Control'))->toContain('public'); + + $this->withHeader('If-None-Match', $etag)->get('/npm/@acme%2froute-optimizer-engine') + ->assertStatus(304)->assertContent(''); + + makeRelease($package, '1.1.0'); + $this->withHeader('If-None-Match', $etag)->get('/npm/@acme%2froute-optimizer-engine') + ->assertOk()->assertJsonPath('dist-tags.latest', '1.1.0'); +}); + +it('streams the tarball with its checksum ETag', function () { + $package = npmPackage(); + $release = makeRelease($package, '1.0.0'); + $file = $release->engineTarball; + + $response = $this->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz')->assertOk() + ->assertHeader('Content-Type', 'application/octet-stream') + ->assertHeader('Content-Length', (string) $file->size) + ->assertHeader('ETag', '"'.$file->checksum.'"'); + + expect(sha1($response->streamedContent()))->toBe($file->shasum) + ->and($response->headers->get('Cache-Control'))->toContain('immutable'); + + $this->withHeader('If-None-Match', '"'.$file->checksum.'"') + ->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz') + ->assertStatus(304); +}); + +it('keeps yanked tarballs downloadable for existing lockfiles', function () { + $package = npmPackage(); + makeRelease($package, '1.0.0', status: Release::STATUS_YANKED); + + $this->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz')->assertOk(); + $this->get('/npm/@acme%2froute-optimizer-engine')->assertNotFound(); +}); + +it('returns npm-readable 404s', function () { + $package = npmPackage(); + makeRelease($package, '1.0.0', status: Release::STATUS_IN_REVIEW); + + $this->get('/npm/@acme%2fmissing')->assertNotFound()->assertJsonPath('error', 'Package not found.'); + $this->get('/npm/@acme%2froute-optimizer-engine')->assertNotFound(); + $this->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz')->assertNotFound(); + $this->get('/npm/@acme/route-optimizer-engine/-/other-1.0.0.tgz')->assertNotFound()->assertJsonPath('error', 'Tarball not found.'); + $this->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-9.9.9.tgz')->assertNotFound(); +}); + +it('hides packages of suspended listings', function () { + $package = npmPackage(); + makeRelease($package, '1.0.0'); + $package->extension->forceFill(['status' => Extension::STATUS_SUSPENDED])->save(); + + $this->get('/npm/@acme%2froute-optimizer-engine')->assertNotFound(); + $this->get('/npm/@acme/route-optimizer-engine/-/route-optimizer-engine-1.0.0.tgz')->assertNotFound(); +}); + +it('refuses npm publish and other writes with a pointer to flb publish', function (string $method, string $uri) { + $this->json($method, $uri, ['name' => '@acme/x']) + ->assertStatus(405) + ->assertHeader('Allow', 'GET, HEAD') + ->assertJsonPath('error', fn (string $message) => str_contains($message, 'flb publish')); +})->with([ + ['PUT', '/npm/@acme%2froute-optimizer-engine'], + ['PUT', '/npm/unscoped-engine'], + ['DELETE', '/npm/@acme%2froute-optimizer-engine/-rev/1-abc'], + ['PUT', '/npm/-/package/@acme%2fx/dist-tags/latest'], +]); + +it('answers npm ping and whoami', function () { + $this->get('/npm/-/ping')->assertOk()->assertExactJson([]); + $this->get('/npm/-/whoami')->assertUnauthorized(); + + $account = Account::factory()->create(['username' => 'ron']); + actingAsAccount($account, ['install']); + $this->get('/npm/-/whoami')->assertOk()->assertJsonPath('username', 'ron'); +}); + +it('serves unscoped package names', function () { + $package = npmPackage(name: 'legacy-engine'); + makeRelease($package, '0.4.0'); + + $versions = $this->get('/npm/legacy-engine')->assertOk()->json('versions'); + expect($versions['0.4.0']['dist']['tarball'])->toBe(url('/npm/legacy-engine/-/legacy-engine-0.4.0.tgz')); + $this->get('/npm/legacy-engine/-/legacy-engine-0.4.0.tgz')->assertOk(); +}); diff --git a/tests/Feature/Packages/ReleaseStorageTest.php b/tests/Feature/Packages/ReleaseStorageTest.php index 1489142..a85df23 100644 --- a/tests/Feature/Packages/ReleaseStorageTest.php +++ b/tests/Feature/Packages/ReleaseStorageTest.php @@ -5,11 +5,6 @@ use App\Services\ReleaseStorage; use Illuminate\Support\Facades\Storage; -beforeEach(function () { - config(['registry.storage.disk' => 'packages']); - Storage::fake('packages'); -}); - it('stores a tarball under its prefix with all three digests and the manifest', function () { $release = Release::factory()->create(); $source = fixturePackage('hello-engine-1.0.0.tgz'); diff --git a/tests/Pest.php b/tests/Pest.php index f620b40..1e7896e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,8 +1,14 @@ extend(TestCase::class) @@ -11,6 +17,12 @@ pest()->extend(TestCase::class)->in('Unit'); +// Package tests run against a fake registry disk. +pest()->beforeEach(function () { + config(['registry.storage.disk' => 'packages']); + Storage::fake('packages'); +})->in('Feature/Packages'); + /** * Issue a token for the account and authenticate subsequent requests with it. */ @@ -60,3 +72,44 @@ function fixturePackage(string $file): string { return base_path('database/fixtures/packages/'.$file); } + +/** + * A published listing owning one package of the given kind. + */ +function makePackage(string $kind, string $name, bool $paid = false, array $extension = []): ExtensionPackage +{ + $factory = Extension::factory()->published()->state($extension); + $factory = $paid ? $factory->paid() : $factory->free(); + + return $factory->create()->packages()->create(['kind' => $kind, 'name' => $name])->load('extension.price'); +} + +/** + * A release of the package's extension whose archive holds a generated manifest. A second + * call for the same version attaches the other kind's archive to the existing release. + */ +function makeRelease(ExtensionPackage $package, string $version, string $channel = Release::CHANNEL_STABLE, string $status = Release::STATUS_PUBLISHED, array $manifest = [], ?string $publishedAt = null): Release +{ + $npm = $package->kind === ExtensionPackage::KIND_NPM; + $manifest = array_merge(['name' => $package->name], $npm ? ['version' => $version] : [], $manifest); + $archive = makeTarball(['package/'.($npm ? 'package.json' : 'composer.json') => json_encode($manifest)]); + + $release = Release::query()->firstOrCreate( + ['extension_uuid' => $package->extension_uuid, 'version' => $version], + Release::factory()->raw([ + 'extension_uuid' => $package->extension_uuid, + 'version' => $version, + 'channel' => $channel, + 'status' => $status, + 'published_at' => $publishedAt ?? now(), + 'yanked_at' => $status === Release::STATUS_YANKED ? now() : null, + ]), + ); + + $file = app(ReleaseStorage::class)->store($archive, $npm ? File::TYPE_ENGINE_TARBALL : File::TYPE_API_TARBALL, $release, [ + 'name' => $npm ? basename($package->name).'-'.$version.'.tgz' : str_replace('/', '-', $package->name).'-'.$version.'.tar', + ]); + $release->forceFill([$npm ? 'engine_tarball_file_uuid' : 'api_tarball_file_uuid' => $file->uuid])->save(); + + return $release; +} diff --git a/tests/Unit/Support/SemverTest.php b/tests/Unit/Support/SemverTest.php new file mode 100644 index 0000000..1a7d5fc --- /dev/null +++ b/tests/Unit/Support/SemverTest.php @@ -0,0 +1,29 @@ +toBe($ordered) + ->and(Semver::max($shuffled))->toBe('2.0.0'); +}); + +it('validates versions and detects prereleases', function () { + expect(Semver::valid('1.2.3'))->toBeTrue() + ->and(Semver::valid('1.2.3-beta.1+build.5'))->toBeTrue() + ->and(Semver::valid('1.2'))->toBeFalse() + ->and(Semver::valid('01.2.3'))->toBeFalse() + ->and(Semver::isPrerelease('1.2.3-rc.1'))->toBeTrue() + ->and(Semver::isPrerelease('1.2.3'))->toBeFalse() + ->and(Semver::compare('1.0.0+build.1', '1.0.0+build.2'))->toBe(0); +}); + +it('knows which versions Composer accepts', function () { + expect(Semver::isComposerCompatible('1.2.0'))->toBeTrue() + ->and(Semver::isComposerCompatible('1.2.0-beta.1'))->toBeTrue() + ->and(Semver::isComposerCompatible('2.0.0-RC2'))->toBeTrue() + ->and(Semver::isComposerCompatible('1.2.0-next.1'))->toBeFalse(); +}); From 8deaa61a0ad4d00efc3d215f1e826dc795afb857 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:41:53 +0800 Subject: [PATCH 09/16] Add the instance API: registration codes, register, me, updates, installs Instance tokens (flbi_) go through TokenGuard with a kind check and the new auth:instance guard. Installs claim licence seats, creating an unlimited free licence on first install of a free extension. Updates compare installed releases with the newest published release whose compat ranges accept the instance's console and API versions (composer/semver). Also adds source_uuid columns for the importer. --- app/Actions/ActivateLicence.php | 101 ++++++++ app/Actions/DeactivateLicence.php | 23 ++ app/Auth/TokenGuard.php | 48 +++- app/Exceptions/LicenceRequiredException.php | 17 ++ .../Account/InstanceCodeController.php | 73 ++++++ .../Instance/InstallController.php | 210 +++++++++++++++ .../Instance/InstanceController.php | 149 +++++++++++ .../Account/CreateInstanceCodeRequest.php | 20 ++ .../Instance/RegisterInstanceRequest.php | 27 ++ .../Instance/ReportInstallRequest.php | 29 +++ .../Resources/ExtensionReferenceResource.php | 20 ++ app/Http/Resources/InstallResource.php | 21 ++ app/Http/Resources/InstanceResource.php | 28 ++ .../Resources/LicenceActivationResource.php | 17 ++ app/Http/Resources/LicenceResource.php | 28 ++ app/Models/Instance.php | 55 +++- app/Models/InstanceRegistrationCode.php | 118 +++++++++ app/Models/Licence.php | 23 ++ app/Providers/AppServiceProvider.php | 10 +- app/Support/Compat.php | 98 +++++++ composer.json | 1 + composer.lock | 79 +++++- config/auth.php | 12 +- ...eate_instance_registration_codes_table.php | 30 +++ ..._make_licence_max_activations_nullable.php | 25 ++ ...003_add_source_uuid_to_imported_tables.php | 37 +++ routes/api.php | 21 +- .../Feature/Instance/InstanceInstallsTest.php | 239 ++++++++++++++++++ .../Instance/InstanceMeAndUpdatesTest.php | 131 ++++++++++ .../Instance/InstanceRegistrationTest.php | 146 +++++++++++ tests/Pest.php | 13 + tests/Unit/CompatTest.php | 29 +++ 32 files changed, 1865 insertions(+), 13 deletions(-) create mode 100644 app/Actions/ActivateLicence.php create mode 100644 app/Actions/DeactivateLicence.php create mode 100644 app/Exceptions/LicenceRequiredException.php create mode 100644 app/Http/Controllers/Account/InstanceCodeController.php create mode 100644 app/Http/Controllers/Instance/InstallController.php create mode 100644 app/Http/Controllers/Instance/InstanceController.php create mode 100644 app/Http/Requests/Account/CreateInstanceCodeRequest.php create mode 100644 app/Http/Requests/Instance/RegisterInstanceRequest.php create mode 100644 app/Http/Requests/Instance/ReportInstallRequest.php create mode 100644 app/Http/Resources/ExtensionReferenceResource.php create mode 100644 app/Http/Resources/InstallResource.php create mode 100644 app/Http/Resources/InstanceResource.php create mode 100644 app/Http/Resources/LicenceActivationResource.php create mode 100644 app/Http/Resources/LicenceResource.php create mode 100644 app/Models/InstanceRegistrationCode.php create mode 100644 app/Support/Compat.php create mode 100644 database/migrations/2026_10_22_000001_create_instance_registration_codes_table.php create mode 100644 database/migrations/2026_10_22_000002_make_licence_max_activations_nullable.php create mode 100644 database/migrations/2026_10_22_000003_add_source_uuid_to_imported_tables.php create mode 100644 tests/Feature/Instance/InstanceInstallsTest.php create mode 100644 tests/Feature/Instance/InstanceMeAndUpdatesTest.php create mode 100644 tests/Feature/Instance/InstanceRegistrationTest.php create mode 100644 tests/Unit/CompatTest.php diff --git a/app/Actions/ActivateLicence.php b/app/Actions/ActivateLicence.php new file mode 100644 index 0000000..9c9d719 --- /dev/null +++ b/app/Actions/ActivateLicence.php @@ -0,0 +1,101 @@ +active() + ->where('extension_uuid', $extension->uuid) + ->where('organization_uuid', $instance->organization_uuid) + ->orderByRaw('case when kind = ? then 0 else 1 end', [Licence::KIND_PAID]) + ->orderBy('created_at') + ->lockForUpdate() + ->get(); + + $existing = LicenceActivation::query() + ->whereIn('licence_uuid', $licences->pluck('uuid')) + ->where('instance_uuid', $instance->uuid) + ->whereNull('deactivated_at') + ->first(); + + if ($existing) { + if ($release && $existing->release_uuid !== $release->uuid) { + $existing->forceFill(['release_uuid' => $release->uuid])->save(); + } + + return $existing->setRelation('licence', $licences->firstWhere('uuid', $existing->licence_uuid)); + } + + if ($licences->isEmpty()) { + $licences = collect([$this->freeLicence($instance, $extension)]); + } + + /** @var Licence|null $licence */ + $licence = $licences->first(fn (Licence $licence) => $licence->hasFreeSeat()); + if (! $licence) { + $seats = (int) $licences->sum('max_activations'); + + throw new LicenceRequiredException("All {$seats} seats for '{$extension->slug}' are in use. Uninstall it from another instance first."); + } + + $activation = LicenceActivation::create([ + 'licence_uuid' => $licence->uuid, + 'instance_uuid' => $instance->uuid, + 'release_uuid' => $release?->uuid, + 'activated_at' => now(), + ]); + + return $activation->setRelation('licence', $licence); + }); + } + + /** + * Free extensions get one unlimited licence per organization on first install. + * Paid extensions, and free ones whose licence staff revoked, are refused. + */ + protected function freeLicence(Instance $instance, Extension $extension): Licence + { + $extension->loadMissing('price'); + + if ($extension->isPaid()) { + throw new LicenceRequiredException("'{$extension->slug}' is a paid extension and your organization does not hold an active licence for it."); + } + + $revoked = Licence::query() + ->where('extension_uuid', $extension->uuid) + ->where('organization_uuid', $instance->organization_uuid) + ->where('status', Licence::STATUS_REVOKED) + ->exists(); + + if ($revoked) { + throw new LicenceRequiredException("Your organization's licence for '{$extension->slug}' has been revoked."); + } + + return Licence::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $instance->organization_uuid, + 'kind' => Licence::KIND_FREE, + 'max_activations' => null, + 'status' => Licence::STATUS_ACTIVE, + ]); + } +} diff --git a/app/Actions/DeactivateLicence.php b/app/Actions/DeactivateLicence.php new file mode 100644 index 0000000..07071de --- /dev/null +++ b/app/Actions/DeactivateLicence.php @@ -0,0 +1,23 @@ +where('instance_uuid', $instance->uuid) + ->whereNull('deactivated_at') + ->whereHas('licence', fn ($q) => $q->where('extension_uuid', $extension->uuid)) + ->update(['deactivated_at' => now(), 'updated_at' => now()]); + } +} diff --git a/app/Auth/TokenGuard.php b/app/Auth/TokenGuard.php index 93d279a..97cb2fd 100644 --- a/app/Auth/TokenGuard.php +++ b/app/Auth/TokenGuard.php @@ -3,23 +3,43 @@ namespace App\Auth; use App\Models\Account; +use App\Models\Instance; use App\Models\Token; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Request; /** - * Resolves the account behind an `Authorization: Bearer flbp_…` header. + * Resolves the caller behind an `Authorization: Bearer …` header. * - * Registered as the `registry-token` guard driver through `Auth::viaRequest`. - * The token is SHA-256 hashed and matched against `tokens.token_hash`; expired, - * revoked, or foreign-prefix tokens resolve to nobody. Each successful use stamps - * `last_used_at` and `last_used_ip` on the token row. + * One guard driver per token kind, registered through `Auth::viaRequest`: + * `registry-token` accepts personal tokens (`flbp_`) and resolves an Account; + * `registry-instance-token` accepts instance tokens (`flbi_`) and resolves an Instance. + * The prefix is the kind check: a token of another kind resolves to nobody, so an + * instance token never reaches account routes and vice versa. + * + * Tokens are SHA-256 hashed and matched against the stored hash; expired, revoked or + * foreign-prefix tokens resolve to nobody. Each successful use stamps last-used data. */ class TokenGuard { - public function __invoke(Request $request): ?Account + public const KIND_PERSONAL = 'personal'; + + public const KIND_INSTANCE = 'instance'; + + public function __construct(protected string $kind = self::KIND_PERSONAL) {} + + public function __invoke(Request $request): ?Authenticatable { $plain = (string) $request->bearerToken(); + return match ($this->kind) { + self::KIND_INSTANCE => $this->resolveInstance($plain, $request), + default => $this->resolveAccount($plain, $request), + }; + } + + protected function resolveAccount(string $plain, Request $request): ?Account + { if ($plain === '' || ! str_starts_with($plain, Token::PREFIX_PERSONAL)) { return null; } @@ -39,4 +59,20 @@ public function __invoke(Request $request): ?Account return $account->withCurrentToken($token); } + + protected function resolveInstance(string $plain, Request $request): ?Instance + { + if ($plain === '' || ! str_starts_with($plain, Token::PREFIX_INSTANCE)) { + return null; + } + + $instance = Instance::findByToken($plain); + if (! $instance) { + return null; + } + + $instance->touchLastSeen($request->ip()); + + return $instance; + } } diff --git a/app/Exceptions/LicenceRequiredException.php b/app/Exceptions/LicenceRequiredException.php new file mode 100644 index 0000000..09bb4ce --- /dev/null +++ b/app/Exceptions/LicenceRequiredException.php @@ -0,0 +1,17 @@ +user(); + $token = $account->currentToken(); + $identifier = $request->input('organization'); + + if ($identifier === null && $token?->organization_uuid) { + $identifier = $token->organization_uuid; + } + + $organization = $identifier === null + ? $account->personalOrganization() + : $account->organizations() + ->where(fn ($q) => $q->where('organizations.uuid', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.slug', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + return response()->error('You are not a member of that organization.', 422); + } + + if ($token?->organization_uuid && $token->organization_uuid !== $organization->uuid) { + return response()->error('This token is limited to another organization.', 403); + } + + if (! in_array($this->roleIn($account, $organization), self::ROLES, true)) { + return response()->error('Only organization owners and admins can register instances.', 403); + } + + $code = InstanceRegistrationCode::mint($organization, $account, $request->ip()); + Event::record('instance_code.created', $organization, $account, ['code' => $code->uuid], $request->ip()); + + return response()->json([ + 'code' => $code->plainTextCode, + 'organization' => $organization->public_id, + 'expires_at' => $code->expires_at, + 'expires_in' => InstanceRegistrationCode::LIFETIME_MINUTES * 60, + ], 201); + } + + protected function roleIn(Account $account, Organization $organization): ?string + { + return Membership::query() + ->where('account_uuid', $account->uuid) + ->where('organization_uuid', $organization->uuid) + ->value('role'); + } +} diff --git a/app/Http/Controllers/Instance/InstallController.php b/app/Http/Controllers/Instance/InstallController.php new file mode 100644 index 0000000..ffea8ed --- /dev/null +++ b/app/Http/Controllers/Instance/InstallController.php @@ -0,0 +1,210 @@ +user(); + $status = $request->input('status'); + + $extension = $this->resolveExtension($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + $release = $extension->releases() + ->where(fn ($q) => $request->filled('release') + ? $q->where('public_id', $request->input('release'))->orWhere('uuid', $request->input('release')) + : $q->where('version', ltrim((string) $request->input('version'), 'vV'))) + ->first(); + + if (! $release) { + return response()->error('Release not found for this extension.', 404); + } + + if ($status === Install::STATUS_INSTALLING && ($error = $this->installRefusal($instance, $extension, $release))) { + return response()->error($error, 422); + } + + if (! in_array($release->status, [Release::STATUS_PUBLISHED, Release::STATUS_YANKED], true)) { + return response()->error('Only published releases can be installed.', 422); + } + + $activation = $status === Install::STATUS_FAILED ? null : $activate($instance, $extension, $release); + + [$install, $created] = DB::transaction(function () use ($instance, $extension, $release, $status, $request) { + $open = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLING) + ->orderByDesc('id') + ->first(); + + if ($status === Install::STATUS_INSTALLING && $open) { + $open->forceFill(['status' => Install::STATUS_FAILED, 'error' => 'Superseded by a newer install attempt.', 'finished_at' => now()])->save(); + $open = null; + } + + $install = $open ?? new Install([ + 'instance_uuid' => $instance->uuid, + 'extension_uuid' => $extension->uuid, + 'started_at' => now(), + ]); + $created = ! $install->exists; + + $install->fill([ + 'release_uuid' => $release->uuid, + 'status' => $status, + 'installed_by' => $request->input('installed_by', $install->installed_by), + 'error' => $status === Install::STATUS_FAILED ? $request->input('error') : null, + 'finished_at' => $status === Install::STATUS_INSTALLING ? null : now(), + ])->save(); + + if ($status === Install::STATUS_INSTALLED) { + Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->whereKeyNot($install->getKey()) + ->update(['status' => Install::STATUS_UNINSTALLED, 'finished_at' => now(), 'updated_at' => now()]); + } + + return [$install, $created]; + }); + + if ($status === Install::STATUS_FAILED && ! $this->hasInstalled($instance, $extension)) { + $deactivate($instance, $extension); + } + + Event::record('install.'.$status, $install, $instance, ['extension' => $extension->public_id, 'version' => $release->version], $request->ip()); + + return response()->json([ + 'install' => (new InstallResource($install->load(['extension', 'release'])))->resolve($request), + 'activation' => $activation ? (new LicenceActivationResource($activation->load('release')))->resolve($request) : null, + ], $created ? 201 : 200); + } + + /** + * DELETE /v1/instances/me/installs/{extension} — report an uninstall and release + * the instance's licence seat. + */ + public function destroy(Request $request, string $extension, DeactivateLicence $deactivate): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + + $model = $this->resolveExtension($extension); + if (! $model) { + return response()->error('Extension not found.', 404); + } + + $installs = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $model->uuid) + ->whereIn('status', [Install::STATUS_INSTALLED, Install::STATUS_INSTALLING]) + ->orderByDesc('id') + ->get(); + + if ($installs->isEmpty()) { + return response()->error('This extension is not installed on this instance.', 404); + } + + Install::query()->whereKey($installs->modelKeys())->update([ + 'status' => Install::STATUS_UNINSTALLED, + 'finished_at' => now(), + 'updated_at' => now(), + ]); + $deactivated = $deactivate($instance, $model); + + $install = $installs->first()->refresh()->load(['extension', 'release']); + Event::record('install.uninstalled', $install, $instance, ['extension' => $model->public_id], $request->ip()); + + return response()->json([ + 'install' => (new InstallResource($install))->resolve($request), + 'deactivated' => $deactivated, + ]); + } + + /** + * Extensions are addressed by slug, public id, uuid, or one of their npm or + * Composer package names (what an instance knows from its installed packages). + */ + protected function resolveExtension(string $identifier): ?Extension + { + $identifier = urldecode($identifier); + + $extension = Extension::query() + ->where(fn ($q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)) + ->when(Str::isUuid($identifier), fn ($q) => $q->orWhere('uuid', $identifier)) + ->first(); + + if ($extension) { + return $extension; + } + + $package = ExtensionPackage::query()->where('name', $identifier)->first(); + + return $package ? Extension::find($package->extension_uuid) : null; + } + + /** + * Why a new install may not start, or null when it may. + */ + protected function installRefusal(Instance $instance, Extension $extension, Release $release): ?string + { + if ($extension->status !== Extension::STATUS_PUBLISHED) { + return 'This extension is not published.'; + } + + if (! $release->isPublished()) { + return 'Only published releases can be installed.'; + } + + if ($extension->hosting !== Extension::HOSTING_BOTH && $extension->hosting !== $instance->kind) { + return "This extension is only available for {$extension->hosting} instances."; + } + + return null; + } + + protected function hasInstalled(Instance $instance, Extension $extension): bool + { + return Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->exists(); + } +} diff --git a/app/Http/Controllers/Instance/InstanceController.php b/app/Http/Controllers/Instance/InstanceController.php new file mode 100644 index 0000000..5b74b3f --- /dev/null +++ b/app/Http/Controllers/Instance/InstanceController.php @@ -0,0 +1,149 @@ +input('code')); + if (! $code) { + return response()->error('The registration code is invalid, expired or already used.', 401); + } + + $organization = Organization::find($code->organization_uuid); + if (! $organization) { + return response()->error('The organization for this registration code no longer exists.', 422); + } + + [$instance, $plain] = DB::transaction(function () use ($request, $code, $organization) { + $instance = Instance::create([ + 'organization_uuid' => $organization->uuid, + 'registered_by_account_uuid' => $code->account_uuid, + 'name' => $request->input('name'), + 'host' => $request->input('host'), + 'kind' => $request->input('kind'), + 'console_version' => $request->input('console_version'), + 'api_version' => $request->input('api_version'), + 'status' => Instance::STATUS_ACTIVE, + 'last_seen_at' => now(), + ]); + + $plain = $instance->issueToken(); + $code->forceFill(['instance_uuid' => $instance->uuid])->save(); + + return [$instance, $plain]; + }); + + Event::record('instance.registered', $instance, $code->account, ['code' => $code->uuid], $request->ip()); + + return response()->json([ + 'token' => $plain, + 'token_type' => 'Bearer', + 'instance' => (new InstanceResource($instance->load('organization')))->resolve($request), + ], 201); + } + + /** + * GET /v1/instances/me — the instance, its organization and the organization's + * licences with seat usage and whether this instance holds a seat. + */ + public function me(Request $request): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + + $licences = Licence::query() + ->where('organization_uuid', $instance->organization_uuid) + ->with('extension') + ->withCount('activeActivations') + ->addSelect(['activated_here' => LicenceActivation::query() + ->selectRaw('count(*)') + ->whereColumn('licence_activations.licence_uuid', 'licences.uuid') + ->where('licence_activations.instance_uuid', $instance->uuid) + ->whereNull('licence_activations.deactivated_at'), + ]) + ->orderByDesc('created_at') + ->get(); + + return response()->json([ + 'instance' => (new InstanceResource($instance->load('organization')))->resolve($request), + 'licences' => LicenceResource::collection($licences)->resolve($request), + ]); + } + + /** + * GET /v1/instances/me/updates?channel=stable|beta — every extension installed on + * the instance with its installed release and the newest published release whose + * `compat` ranges accept the instance's console and API versions. + */ + public function updates(Request $request): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + $channels = $request->input('channel') === Release::CHANNEL_BETA + ? [Release::CHANNEL_STABLE, Release::CHANNEL_BETA] + : [Release::CHANNEL_STABLE]; + + $installs = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->with(['extension', 'release']) + ->orderBy('created_at') + ->get() + ->filter(fn (Install $install) => $install->extension !== null); + + $published = Release::query() + ->published() + ->whereIn('extension_uuid', $installs->pluck('extension_uuid')->unique()) + ->whereIn('channel', $channels) + ->get() + ->groupBy('extension_uuid'); + + $data = $installs->map(function (Install $install) use ($instance, $published, $request) { + $compatible = ($published->get($install->extension_uuid) ?? collect()) + ->filter(fn (Release $release) => Compat::releaseIsCompatible($release, $instance->console_version, $instance->api_version)); + $latest = Compat::latest($compatible); + + return [ + 'extension' => (new ExtensionReferenceResource($install->extension))->resolve($request), + 'installed' => $install->release ? (new ReleaseResource($install->release))->resolve($request) : null, + 'latest' => $latest ? (new ReleaseResource($latest))->resolve($request) : null, + 'update_available' => $latest !== null && Compat::isNewer($latest->version, $install->release?->version), + ]; + })->values(); + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'console_version' => $instance->console_version, + 'api_version' => $instance->api_version, + 'channels' => $channels, + 'updates_available' => $data->where('update_available', true)->count(), + ], + ]); + } +} diff --git a/app/Http/Requests/Account/CreateInstanceCodeRequest.php b/app/Http/Requests/Account/CreateInstanceCodeRequest.php new file mode 100644 index 0000000..87b84c9 --- /dev/null +++ b/app/Http/Requests/Account/CreateInstanceCodeRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Requests/Instance/RegisterInstanceRequest.php b/app/Http/Requests/Instance/RegisterInstanceRequest.php new file mode 100644 index 0000000..54ac6b5 --- /dev/null +++ b/app/Http/Requests/Instance/RegisterInstanceRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:64'], + 'name' => ['required', 'string', 'max:191'], + 'host' => ['nullable', 'string', 'max:255'], + 'kind' => ['required', 'string', Rule::in(Instance::KINDS)], + 'console_version' => ['nullable', 'string', 'max:64'], + 'api_version' => ['nullable', 'string', 'max:64'], + ]; + } +} diff --git a/app/Http/Requests/Instance/ReportInstallRequest.php b/app/Http/Requests/Instance/ReportInstallRequest.php new file mode 100644 index 0000000..78bfd0d --- /dev/null +++ b/app/Http/Requests/Instance/ReportInstallRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:191'], + 'version' => ['required_without:release', 'nullable', 'string', 'max:64'], + 'release' => ['required_without:version', 'nullable', 'string', 'max:191'], + 'status' => ['required', 'string', Rule::in(self::STATUSES)], + 'error' => ['nullable', 'string', 'max:10000'], + 'installed_by' => ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Resources/ExtensionReferenceResource.php b/app/Http/Resources/ExtensionReferenceResource.php new file mode 100644 index 0000000..4894381 --- /dev/null +++ b/app/Http/Resources/ExtensionReferenceResource.php @@ -0,0 +1,20 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'slug' => $this->slug, + 'name' => $this->name, + ]; + } +} diff --git a/app/Http/Resources/InstallResource.php b/app/Http/Resources/InstallResource.php new file mode 100644 index 0000000..06002ed --- /dev/null +++ b/app/Http/Resources/InstallResource.php @@ -0,0 +1,21 @@ + $this->uuid, + 'extension' => $this->whenLoaded('extension', fn () => (new ExtensionReferenceResource($this->extension))->resolve($request)), + 'release' => $this->whenLoaded('release', fn () => $this->release ? (new ReleaseResource($this->release))->resolve($request) : null), + 'status' => $this->status, + 'installed_by' => $this->installed_by, + 'error' => $this->error, + 'started_at' => $this->started_at, + 'finished_at' => $this->finished_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/InstanceResource.php b/app/Http/Resources/InstanceResource.php new file mode 100644 index 0000000..3842418 --- /dev/null +++ b/app/Http/Resources/InstanceResource.php @@ -0,0 +1,28 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'name' => $this->name, + 'host' => $this->host, + 'kind' => $this->kind, + 'console_version' => $this->console_version, + 'api_version' => $this->api_version, + 'status' => $this->status, + 'organization' => $this->whenLoaded('organization', fn () => (new OrganizationResource($this->organization))->resolve($request)), + 'last_seen_at' => $this->last_seen_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/LicenceActivationResource.php b/app/Http/Resources/LicenceActivationResource.php new file mode 100644 index 0000000..23edfce --- /dev/null +++ b/app/Http/Resources/LicenceActivationResource.php @@ -0,0 +1,17 @@ + $this->uuid, + 'licence' => $this->whenLoaded('licence', fn () => (new LicenceResource($this->licence))->resolve($request)), + 'release' => $this->whenLoaded('release', fn () => $this->release?->version), + 'activated_at' => $this->activated_at, + 'deactivated_at' => $this->deactivated_at, + ]; + } +} diff --git a/app/Http/Resources/LicenceResource.php b/app/Http/Resources/LicenceResource.php new file mode 100644 index 0000000..da05a19 --- /dev/null +++ b/app/Http/Resources/LicenceResource.php @@ -0,0 +1,28 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'kind' => $this->kind, + 'status' => $this->status, + 'max_activations' => $this->max_activations, + 'seats_used' => $this->whenCounted('activeActivations', fn () => (int) $this->active_activations_count), + 'activated_here' => $this->when(array_key_exists('activated_here', $this->resource->getAttributes()), fn () => (bool) $this->resource->getAttribute('activated_here')), + 'expires_at' => $this->expires_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php index da044ab..0be5547 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -5,16 +5,23 @@ use App\Traits\HasPublicId; use App\Traits\HasUuid; use Database\Factories\InstanceFactory; +use Illuminate\Auth\Authenticatable; +use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; /** * A registered Fleetbase instance (cloud tenant or self-hosted install). + * + * Authenticates with an instance token (`flbi_` + 40 random characters) whose SHA-256 + * hash lives in `token_hash`; the plaintext is returned once at registration. */ -class Instance extends Model +class Instance extends Model implements AuthenticatableContract { + use Authenticatable; use HasFactory; use HasPublicId; use HasUuid; @@ -26,6 +33,10 @@ class Instance extends Model public const KINDS = [self::KIND_CLOUD, self::KIND_SELF_HOSTED]; + public const STATUS_ACTIVE = 'active'; + + public const STATUS_REVOKED = 'revoked'; + protected $table = 'instances'; protected string $publicIdType = 'inst'; @@ -63,4 +74,46 @@ public function activations(): HasMany { return $this->hasMany(LicenceActivation::class, 'instance_uuid', 'uuid'); } + + /** + * Replace the instance token and return the new plaintext (shown once). + */ + public function issueToken(): string + { + $plain = Token::PREFIX_INSTANCE.Str::random(40); + + $this->forceFill(['token_hash' => Token::hash($plain)])->save(); + + return $plain; + } + + /** + * Resolve a plaintext instance token to its active instance, or null. + */ + public static function findByToken(string $plain): ?static + { + if (! str_starts_with($plain, Token::PREFIX_INSTANCE)) { + return null; + } + + return static::query() + ->where('token_hash', Token::hash($plain)) + ->where('status', self::STATUS_ACTIVE) + ->first(); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function touchLastSeen(?string $ip = null): void + { + $meta = $this->meta ?? []; + if ($ip !== null) { + $meta['last_seen_ip'] = $ip; + } + + $this->forceFill(['last_seen_at' => now(), 'meta' => $meta])->saveQuietly(); + } } diff --git a/app/Models/InstanceRegistrationCode.php b/app/Models/InstanceRegistrationCode.php new file mode 100644 index 0000000..1e3ed64 --- /dev/null +++ b/app/Models/InstanceRegistrationCode.php @@ -0,0 +1,118 @@ + 'datetime', 'used_at' => 'datetime']; + + /** Plaintext, only populated on the instance returned by `mint()`. */ + public ?string $plainTextCode = null; + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + /** + * Mint a code (`XXXX-XXXX-XXXX-XXXX`, 80 bits) for an organization. + */ + public static function mint(Organization $organization, Account $account, ?string $ip = null): static + { + $plain = static::generatePlain(); + + $code = static::create([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'code_hash' => static::hash($plain), + 'expires_at' => now()->addMinutes(static::LIFETIME_MINUTES), + 'created_ip' => $ip, + ]); + + $code->plainTextCode = $plain; + + return $code; + } + + public static function generatePlain(): string + { + $chars = ''; + for ($i = 0; $i < 16; $i++) { + $chars .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; + } + + return implode('-', str_split($chars, 4)); + } + + /** + * Codes are compared case-insensitively and without separators or whitespace. + */ + public static function normalize(string $plain): string + { + $chars = strtoupper((string) preg_replace('/[^0-9A-Za-z]/', '', $plain)); + + return implode('-', str_split($chars, 4)); + } + + public static function hash(string $plain): string + { + return hash('sha256', static::normalize($plain)); + } + + public function scopeUsable(Builder $query): Builder + { + return $query->whereNull('used_at')->where('expires_at', '>', now()); + } + + /** + * Atomically mark the code behind `$plain` used and return it, or null when the + * code is unknown, expired or already used. Safe against concurrent redemption. + */ + public static function redeem(string $plain): ?static + { + if (trim($plain) === '') { + return null; + } + + $code = static::query()->usable()->where('code_hash', static::hash($plain))->first(); + if (! $code) { + return null; + } + + $claimed = static::query()->usable()->whereKey($code->getKey())->update(['used_at' => now(), 'updated_at' => now()]); + + return $claimed === 1 ? $code->refresh() : null; + } +} diff --git a/app/Models/Licence.php b/app/Models/Licence.php index 1c90387..9780e3e 100644 --- a/app/Models/Licence.php +++ b/app/Models/Licence.php @@ -67,4 +67,27 @@ public function isActive(): bool { return $this->status === self::STATUS_ACTIVE && ($this->expires_at === null || $this->expires_at->isFuture()); } + + /** + * Null `max_activations` means unlimited seats (automatic free licences). + */ + public function isUnlimited(): bool + { + return $this->max_activations === null; + } + + public function activeActivations(): HasMany + { + return $this->activations()->whereNull('deactivated_at'); + } + + public function seatsUsed(): int + { + return $this->activeActivations()->count(); + } + + public function hasFreeSeat(): bool + { + return $this->isUnlimited() || $this->seatsUsed() < $this->max_activations; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cc5b733..4587c7d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,11 +34,13 @@ public function boot(): void } /** - * `auth:token` resolves personal tokens (flbp_…) to accounts. + * `auth:token` resolves personal tokens (flbp_…) to accounts; + * `auth:instance` resolves instance tokens (flbi_…) to instances. */ protected function registerAuthGuard(): void { - Auth::viaRequest('registry-token', new TokenGuard); + Auth::viaRequest('registry-token', new TokenGuard(TokenGuard::KIND_PERSONAL)); + Auth::viaRequest('registry-instance-token', new TokenGuard(TokenGuard::KIND_INSTANCE)); } /** @@ -64,6 +66,10 @@ protected function registerRateLimiters(): void RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + RateLimiter::for('instances', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + RateLimiter::for('instance-register', fn (Request $request) => Limit::perMinute(10)->by($request->ip())); + RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); } } diff --git a/app/Support/Compat.php b/app/Support/Compat.php new file mode 100644 index 0000000..e336f10 --- /dev/null +++ b/app/Support/Compat.php @@ -0,0 +1,98 @@ +", "api": ""}` using npm/Composer range + * syntax (`>=0.5.0`, `^1.6`, `>=1.0 <2.0`, `1.x || 2.x`), evaluated with composer/semver. + * Rules: a side with no range accepts any version; an instance that does not report a + * version (or reports one that is not semver) is not constrained on that side; a range + * that does not parse never matches, so a broken manifest cannot be offered as an update. + */ +class Compat +{ + public static function satisfies(?string $version, ?string $range): bool + { + $range = trim((string) $range); + if ($range === '' || $range === '*') { + return true; + } + + try { + $constraint = (new VersionParser)->parseConstraints($range); + } catch (\UnexpectedValueException) { + return false; + } + + $version = static::normalizeVersion($version); + if ($version === null) { + return true; + } + + $parser = new VersionParser; + + return $constraint->matches(new Constraint('==', $parser->normalize($version))); + } + + public static function releaseIsCompatible(Release $release, ?string $consoleVersion, ?string $apiVersion): bool + { + $compat = $release->compat ?? []; + + return static::satisfies($consoleVersion, data_get($compat, 'console')) + && static::satisfies($apiVersion, data_get($compat, 'api')); + } + + /** + * Highest-versioned release in the collection; unparseable versions sort last. + */ + public static function latest(Collection $releases): ?Release + { + return $releases + ->filter(fn (Release $release) => static::normalizeVersion($release->version) !== null) + ->sort(fn (Release $a, Release $b) => static::compare($b->version, $a->version)) + ->first(); + } + + /** + * -1, 0 or 1 like the spaceship operator. + */ + public static function compare(string $a, string $b): int + { + if (Comparator::equalTo($a, $b)) { + return 0; + } + + return Comparator::greaterThan($a, $b) ? 1 : -1; + } + + public static function isNewer(?string $candidate, ?string $current): bool + { + if ($candidate === null || static::normalizeVersion($candidate) === null) { + return false; + } + + if ($current === null || static::normalizeVersion($current) === null) { + return true; + } + + return static::compare($candidate, $current) > 0; + } + + /** + * The version without a leading `v`, or null when it is not a semver version. + */ + public static function normalizeVersion(?string $version): ?string + { + $version = ltrim(trim((string) $version), 'vV'); + + return preg_match('/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/', $version) === 1 ? $version : null; + } +} diff --git a/composer.json b/composer.json index 3037dda..7f113ec 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "license": "AGPL-3.0-or-later", "require": { "php": "^8.2", + "composer/semver": "^3.4", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "league/flysystem-aws-s3-v3": "^3.0" diff --git a/composer.lock b/composer.lock index d123d0f..0a13a54 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "558bcc907a4d4424a4ad268b6fb88d18", + "content-hash": "0a45620eacbd3a7f1df98442083a574b", "packages": [ { "name": "aws/aws-crt-php", @@ -291,6 +291,83 @@ ], "time": "2026-09-06T14:11:38+00:00" }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", diff --git a/config/auth.php b/config/auth.php index b4154d1..bc54bb5 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,6 +1,7 @@ 'registry-token', 'provider' => 'accounts', ], + 'instance' => [ + 'driver' => 'registry-instance-token', + 'provider' => 'instances', + ], ], 'providers' => [ @@ -31,6 +37,10 @@ 'driver' => 'eloquent', 'model' => Account::class, ], + 'instances' => [ + 'driver' => 'eloquent', + 'model' => Instance::class, + ], ], 'passwords' => [ diff --git a/database/migrations/2026_10_22_000001_create_instance_registration_codes_table.php b/database/migrations/2026_10_22_000001_create_instance_registration_codes_table.php new file mode 100644 index 0000000..da07d03 --- /dev/null +++ b/database/migrations/2026_10_22_000001_create_instance_registration_codes_table.php @@ -0,0 +1,30 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('organization_uuid')->index(); + $table->uuid('account_uuid')->index(); + $table->string('code_hash', 64)->unique(); + $table->timestamp('expires_at')->index(); + $table->timestamp('used_at')->nullable(); + $table->uuid('instance_uuid')->nullable()->index(); + $table->string('created_ip', 45)->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('instance_registration_codes'); + } +}; diff --git a/database/migrations/2026_10_22_000002_make_licence_max_activations_nullable.php b/database/migrations/2026_10_22_000002_make_licence_max_activations_nullable.php new file mode 100644 index 0000000..dc14ad2 --- /dev/null +++ b/database/migrations/2026_10_22_000002_make_licence_max_activations_nullable.php @@ -0,0 +1,25 @@ +unsignedInteger('max_activations')->nullable()->default(5)->change(); + }); + } + + public function down(): void + { + Schema::table('licences', function (Blueprint $table) { + $table->unsignedInteger('max_activations')->nullable(false)->default(5)->change(); + }); + } +}; diff --git a/database/migrations/2026_10_22_000003_add_source_uuid_to_imported_tables.php b/database/migrations/2026_10_22_000003_add_source_uuid_to_imported_tables.php new file mode 100644 index 0000000..bf595e2 --- /dev/null +++ b/database/migrations/2026_10_22_000003_add_source_uuid_to_imported_tables.php @@ -0,0 +1,37 @@ +string('source_uuid', 191)->nullable()->index(); + }); + } + } + + public function down(): void + { + foreach (self::TABLES as $name) { + Schema::table($name, function (Blueprint $table) use ($name) { + $table->dropIndex($name.'_source_uuid_index'); + $table->dropColumn('source_uuid'); + }); + } + } +}; diff --git a/routes/api.php b/routes/api.php index 6dadc40..cf7a7cb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,12 @@ name('account.tokens.index'); Route::post('account/tokens', [TokenController::class, 'store'])->name('account.tokens.store'); Route::delete('account/tokens/{token}', [TokenController::class, 'destroy'])->name('account.tokens.destroy'); + Route::post('account/instance-codes', [InstanceCodeController::class, 'store'])->middleware('scope:instance:register')->name('account.instance-codes.store'); + }); + + Route::prefix('instances')->name('instances.')->group(function () { + Route::post('register', [InstanceController::class, 'register'])->middleware('throttle:instance-register')->name('register'); + + Route::middleware(['auth:instance', 'throttle:instances'])->group(function () { + Route::get('me', [InstanceController::class, 'me'])->name('me'); + Route::get('me/updates', [InstanceController::class, 'updates'])->name('me.updates'); + Route::post('me/installs', [InstallController::class, 'store'])->name('me.installs.store'); + Route::delete('me/installs/{extension}', [InstallController::class, 'destroy'])->where('extension', '.+')->name('me.installs.destroy'); + }); }); }); diff --git a/tests/Feature/Instance/InstanceInstallsTest.php b/tests/Feature/Instance/InstanceInstallsTest.php new file mode 100644 index 0000000..9073e8c --- /dev/null +++ b/tests/Feature/Instance/InstanceInstallsTest.php @@ -0,0 +1,239 @@ +published()->withPackages()->state($state); + $factory = $configure ? $configure($factory) : $factory->free(); + + return $factory->create(); +} + +function releaseFor(Extension $extension, string $version, array $state = []): Release +{ + return Release::factory()->published()->create(array_merge([ + 'extension_uuid' => $extension->uuid, + 'version' => $version, + 'compat' => [], + ], $state)); +} + +function paidLicence(Extension $extension, Instance $instance, int $seats = 5): Licence +{ + $purchase = Purchase::create([ + 'extension_uuid' => $extension->uuid, + 'buyer_organization_uuid' => $instance->organization_uuid, + 'amount_total' => 4900, + 'currency' => 'USD', + 'status' => Purchase::STATUS_PAID, + ]); + + return Licence::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $instance->organization_uuid, + 'purchase_uuid' => $purchase->uuid, + 'kind' => Licence::KIND_PAID, + 'max_activations' => $seats, + 'status' => Licence::STATUS_ACTIVE, + ]); +} + +it('creates an unlimited free licence and an activation on first install', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + releaseFor($extension, '1.0.0'); + actingAsInstance($instance); + + $response = $this->postJson('/v1/instances/me/installs', [ + 'extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing', 'installed_by' => 'console', + ])->assertCreated() + ->assertJsonPath('install.status', 'installing') + ->assertJsonPath('install.release.version', '1.0.0') + ->assertJsonPath('install.extension.slug', $extension->slug) + ->assertJsonPath('activation.licence.kind', 'free') + ->assertJsonPath('activation.licence.max_activations', null) + ->assertJsonPath('activation.release', '1.0.0'); + + $licence = Licence::sole(); + expect($licence->organization_uuid)->toBe($instance->organization_uuid) + ->and($licence->kind)->toBe(Licence::KIND_FREE) + ->and($licence->max_activations)->toBeNull() + ->and($licence->purchase_uuid)->toBeNull(); + + $this->postJson('/v1/instances/me/installs', [ + 'extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed', + ])->assertOk() + ->assertJsonPath('install.id', $response->json('install.id')) + ->assertJsonPath('install.status', 'installed') + ->assertJsonPath('activation.id', $response->json('activation.id')); + + expect(Install::sole()->finished_at)->not->toBeNull() + ->and(Licence::count())->toBe(1) + ->and(LicenceActivation::whereNull('deactivated_at')->count())->toBe(1); + + // Another instance in the same organization reuses the free licence. + $sibling = Instance::factory()->create(['organization_uuid' => $instance->organization_uuid]); + actingAsInstance($sibling); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed']) + ->assertCreated()->assertJsonPath('activation.licence.id', $licence->public_id); + expect(Licence::count())->toBe(1); +}); + +it('resolves extensions by package name and releases by public id', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + $release = releaseFor($extension, '2.1.0'); + actingAsInstance($instance); + + $npm = $extension->packages()->where('kind', 'npm')->value('name'); + + $this->postJson('/v1/instances/me/installs', ['extension' => $npm, 'release' => $release->public_id, 'status' => 'installed']) + ->assertCreated()->assertJsonPath('install.release.version', '2.1.0'); + + $this->postJson('/v1/instances/me/installs', ['extension' => 'nope', 'version' => '1.0.0', 'status' => 'installed'])->assertStatus(404); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '9.9.9', 'status' => 'installed'])->assertStatus(404); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'status' => 'done'])->assertStatus(422); +}); + +it('refuses paid extensions without a licence and enforces seats', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension([], fn ($f) => $f->paid(4900)); + releaseFor($extension, '1.0.0'); + actingAsInstance($instance); + + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing']) + ->assertStatus(403) + ->assertJsonPath('errors.0', "'{$extension->slug}' is a paid extension and your organization does not hold an active licence for it."); + expect(Install::count())->toBe(0)->and(Licence::count())->toBe(0); + + $licence = paidLicence($extension, $instance, seats: 1); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed']) + ->assertCreated()->assertJsonPath('activation.licence.id', $licence->public_id)->assertJsonPath('activation.licence.kind', 'paid'); + + $second = Instance::factory()->create(['organization_uuid' => $instance->organization_uuid]); + actingAsInstance($second); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing']) + ->assertStatus(403)->assertJsonPath('errors.0', "All 1 seats for '{$extension->slug}' are in use. Uninstall it from another instance first."); + + // Uninstalling on the first instance frees the seat. + actingAsInstance($instance); + $this->deleteJson('/v1/instances/me/installs/'.$extension->slug)->assertOk()->assertJsonPath('deactivated', 1); + + actingAsInstance($second); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing'])->assertCreated(); + + // A revoked licence counts as none. + $licence->forceFill(['status' => Licence::STATUS_REVOKED])->save(); + $third = Instance::factory()->create(['organization_uuid' => $instance->organization_uuid]); + actingAsInstance($third); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing'])->assertStatus(403); +}); + +it('refuses new installs of unpublished extensions, unpublished releases and other hosting kinds', function () { + $instance = Instance::factory()->create(['kind' => Instance::KIND_SELF_HOSTED]); + actingAsInstance($instance); + + $cloudOnly = publishedExtension(['hosting' => Extension::HOSTING_CLOUD]); + releaseFor($cloudOnly, '1.0.0'); + $this->postJson('/v1/instances/me/installs', ['extension' => $cloudOnly->slug, 'version' => '1.0.0', 'status' => 'installing']) + ->assertStatus(422)->assertJsonPath('errors.0', 'This extension is only available for cloud instances.'); + + $draft = publishedExtension(['status' => Extension::STATUS_DRAFT]); + releaseFor($draft, '1.0.0'); + $this->postJson('/v1/instances/me/installs', ['extension' => $draft->slug, 'version' => '1.0.0', 'status' => 'installing'])->assertStatus(422); + + $extension = publishedExtension(); + releaseFor($extension, '1.0.0', ['status' => Release::STATUS_YANKED]); + releaseFor($extension, '1.1.0', ['status' => Release::STATUS_APPROVED, 'published_at' => null]); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installing'])->assertStatus(422); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.1.0', 'status' => 'installed'])->assertStatus(422); + + // A yanked release an instance already has may still be reported as installed. + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed'])->assertCreated(); +}); + +it('supersedes the previous installed row on upgrade so the instance counts once', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + releaseFor($extension, '1.0.0'); + releaseFor($extension, '1.1.0'); + actingAsInstance($instance); + + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed'])->assertCreated(); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.1.0', 'status' => 'installing'])->assertCreated(); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.1.0', 'status' => 'installed'])->assertOk(); + + expect(Install::where('status', 'installed')->count())->toBe(1) + ->and(Install::where('status', 'installed')->first()->release->version)->toBe('1.1.0') + ->and(Install::where('status', 'uninstalled')->count())->toBe(1) + ->and(LicenceActivation::whereNull('deactivated_at')->sole()->release->version)->toBe('1.1.0'); + + $this->getJson('/v1/catalog/extensions?q='.urlencode($extension->name))->assertOk()->assertJsonPath('data.0.installs_count', 1); +}); + +it('keeps the seat when an upgrade fails and releases it when a first install fails', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + releaseFor($extension, '1.0.0'); + releaseFor($extension, '1.1.0'); + actingAsInstance($instance); + + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed']); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.1.0', 'status' => 'installing']); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.1.0', 'status' => 'failed', 'error' => 'composer require failed']) + ->assertOk()->assertJsonPath('install.status', 'failed')->assertJsonPath('install.error', 'composer require failed')->assertJsonPath('activation', null); + + expect(Install::where('status', 'installed')->sole()->release->version)->toBe('1.0.0') + ->and(LicenceActivation::whereNull('deactivated_at')->count())->toBe(1); + + $other = publishedExtension(); + releaseFor($other, '1.0.0'); + $this->postJson('/v1/instances/me/installs', ['extension' => $other->slug, 'version' => '1.0.0', 'status' => 'installing']); + $this->postJson('/v1/instances/me/installs', ['extension' => $other->slug, 'version' => '1.0.0', 'status' => 'failed', 'error' => 'boom']); + + $otherLicence = Licence::where('extension_uuid', $other->uuid)->sole(); + expect($otherLicence->activeActivations()->count())->toBe(0); +}); + +it('reports uninstalls and deactivates the seat', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + releaseFor($extension, '1.0.0'); + actingAsInstance($instance); + + $this->deleteJson('/v1/instances/me/installs/'.$extension->slug)->assertStatus(404); + + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed']); + + $composer = $extension->packages()->where('kind', 'composer')->value('name'); + $this->deleteJson('/v1/instances/me/installs/'.$composer) + ->assertOk()->assertJsonPath('install.status', 'uninstalled')->assertJsonPath('deactivated', 1); + + expect(Install::sole()->status)->toBe('uninstalled') + ->and(LicenceActivation::sole()->deactivated_at)->not->toBeNull() + ->and(Licence::sole()->status)->toBe('active'); + + // Reinstalling opens a new activation on the same free licence. + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed'])->assertCreated(); + expect(LicenceActivation::count())->toBe(2)->and(Licence::count())->toBe(1); +}); + +it('accepts scoped npm package names in the uninstall path', function () { + $instance = Instance::factory()->create(); + $extension = publishedExtension(); + releaseFor($extension, '1.0.0'); + actingAsInstance($instance); + $this->postJson('/v1/instances/me/installs', ['extension' => $extension->slug, 'version' => '1.0.0', 'status' => 'installed']); + + $npm = $extension->packages()->where('kind', 'npm')->value('name'); + expect($npm)->toStartWith('@'); + + $this->deleteJson('/v1/instances/me/installs/'.$npm)->assertOk()->assertJsonPath('install.status', 'uninstalled'); +}); diff --git a/tests/Feature/Instance/InstanceMeAndUpdatesTest.php b/tests/Feature/Instance/InstanceMeAndUpdatesTest.php new file mode 100644 index 0000000..099f70c --- /dev/null +++ b/tests/Feature/Instance/InstanceMeAndUpdatesTest.php @@ -0,0 +1,131 @@ +create([ + 'instance_uuid' => $instance->uuid, + 'extension_uuid' => $extension->uuid, + 'release_uuid' => $release?->uuid, + 'status' => $status, + ]); +} + +function release(Extension $extension, string $version, array $compat = [], array $state = []): Release +{ + return Release::factory()->published()->create(array_merge([ + 'extension_uuid' => $extension->uuid, 'version' => $version, 'compat' => $compat, + ], $state)); +} + +it('returns the instance, its organization and licences with seat usage', function () { + $instance = Instance::factory()->create(); + $sibling = Instance::factory()->create(['organization_uuid' => $instance->organization_uuid]); + $extension = Extension::factory()->published()->create(); + $other = Extension::factory()->published()->create(); + + $licence = Licence::create([ + 'extension_uuid' => $extension->uuid, 'organization_uuid' => $instance->organization_uuid, + 'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE, + ]); + LicenceActivation::create(['licence_uuid' => $licence->uuid, 'instance_uuid' => $instance->uuid, 'activated_at' => now()]); + LicenceActivation::create(['licence_uuid' => $licence->uuid, 'instance_uuid' => $sibling->uuid, 'activated_at' => now()]); + LicenceActivation::create(['licence_uuid' => $licence->uuid, 'instance_uuid' => $sibling->uuid, 'activated_at' => now()->subDay(), 'deactivated_at' => now()]); + + Licence::create([ + 'extension_uuid' => $other->uuid, 'organization_uuid' => $instance->organization_uuid, + 'kind' => Licence::KIND_FREE, 'max_activations' => null, 'status' => Licence::STATUS_ACTIVE, + ]); + Licence::create([ + 'extension_uuid' => $other->uuid, 'organization_uuid' => Instance::factory()->create()->organization_uuid, + 'kind' => Licence::KIND_FREE, 'max_activations' => null, 'status' => Licence::STATUS_ACTIVE, + ]); + + actingAsInstance($instance); + $response = $this->getJson('/v1/instances/me')->assertOk() + ->assertJsonPath('instance.id', $instance->public_id) + ->assertJsonPath('instance.organization.id', $instance->organization->public_id) + ->assertJsonCount(2, 'licences'); + + $licences = collect($response->json('licences'))->keyBy('extension.slug'); + expect($licences[$extension->slug])->toMatchArray(['kind' => 'paid', 'max_activations' => 5, 'seats_used' => 2, 'activated_here' => true]) + ->and($licences[$other->slug])->toMatchArray(['kind' => 'free', 'max_activations' => null, 'seats_used' => 0, 'activated_here' => false]); + + expect($instance->fresh()->last_seen_at)->not->toBeNull(); + expect(json_encode($response->json()))->not->toContain('token'); +}); + +it('lists installed releases against the newest compatible published release', function () { + $instance = Instance::factory()->create(['console_version' => '0.7.2', 'api_version' => '1.6.10']); + actingAsInstance($instance); + + // Newer compatible release available; a newer incompatible one and a beta are skipped. + $fleet = Extension::factory()->published()->create(['slug' => 'fleet-insights']); + $installed = release($fleet, '1.0.0', ['console' => '>=0.6.0']); + release($fleet, '1.2.0', ['console' => '>=0.7.0', 'api' => '^1.6']); + release($fleet, '1.10.0', ['console' => '>=0.8.0']); + release($fleet, '1.11.0-beta.1', [], ['channel' => Release::CHANNEL_BETA]); + release($fleet, '1.9.0', [], ['status' => Release::STATUS_YANKED]); + installOn($instance, $fleet, $installed); + + // Already on the latest. + $routes = Extension::factory()->published()->create(['slug' => 'route-planner']); + $current = release($routes, '2.0.0'); + installOn($instance, $routes, $current); + + // Nothing compatible at all. + $legacy = Extension::factory()->published()->create(['slug' => 'legacy-sync']); + $old = release($legacy, '0.1.0', ['api' => '<1.0']); + installOn($instance, $legacy, $old); + + // Not reported: uninstalled and failed rows, and other instances' installs. + $gone = Extension::factory()->published()->create(['slug' => 'gone']); + installOn($instance, $gone, release($gone, '1.0.0'), Install::STATUS_UNINSTALLED); + installOn(Instance::factory()->create(), $routes, $current); + + $response = $this->getJson('/v1/instances/me/updates')->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('meta.updates_available', 1) + ->assertJsonPath('meta.console_version', '0.7.2'); + + $rows = collect($response->json('data'))->keyBy('extension.slug'); + + expect($rows['fleet-insights']['installed']['version'])->toBe('1.0.0') + ->and($rows['fleet-insights']['latest']['version'])->toBe('1.2.0') + ->and($rows['fleet-insights']['update_available'])->toBeTrue() + ->and($rows['route-planner']['latest']['version'])->toBe('2.0.0') + ->and($rows['route-planner']['update_available'])->toBeFalse() + ->and($rows['legacy-sync']['installed']['version'])->toBe('0.1.0') + ->and($rows['legacy-sync']['latest'])->toBeNull() + ->and($rows['legacy-sync']['update_available'])->toBeFalse(); + + $beta = collect($this->getJson('/v1/instances/me/updates?channel=beta')->assertOk()->json('data'))->keyBy('extension.slug'); + expect($beta['fleet-insights']['latest']['version'])->toBe('1.11.0-beta.1'); +}); + +it('does not constrain on versions the instance does not report', function () { + $instance = Instance::factory()->create(['console_version' => null, 'api_version' => 'dev-main']); + actingAsInstance($instance); + + $extension = Extension::factory()->published()->create(); + installOn($instance, $extension, release($extension, '1.0.0')); + release($extension, '1.1.0', ['console' => '>=99.0', 'api' => '>=99.0']); + release($extension, '1.2.0', ['console' => 'not a range']); + + $this->getJson('/v1/instances/me/updates')->assertOk() + ->assertJsonPath('data.0.latest.version', '1.1.0') + ->assertJsonPath('data.0.update_available', true); +}); + +it('requires an instance token', function () { + $this->getJson('/v1/instances/me')->assertStatus(401); + $this->getJson('/v1/instances/me/updates')->assertStatus(401); + $this->postJson('/v1/instances/me/installs', [])->assertStatus(401); + $this->deleteJson('/v1/instances/me/installs/x')->assertStatus(401); +}); diff --git a/tests/Feature/Instance/InstanceRegistrationTest.php b/tests/Feature/Instance/InstanceRegistrationTest.php new file mode 100644 index 0000000..d5e0028 --- /dev/null +++ b/tests/Feature/Instance/InstanceRegistrationTest.php @@ -0,0 +1,146 @@ +create(); + $organization = Organization::factory()->state(['is_personal' => $personal])->create(); + $account->organizations()->attach($organization->uuid, ['role' => $role]); + + return [$account, $organization]; +} + +function registrationPayload(string $code, array $overrides = []): array +{ + return array_merge([ + 'code' => $code, + 'name' => 'Acme production', + 'host' => 'fleet.acme.test', + 'kind' => 'self_hosted', + 'console_version' => '0.7.2', + 'api_version' => '1.6.10', + ], $overrides); +} + +it('mints a 15-minute registration code for the personal organization', function () { + [$account, $organization] = accountWithOrganization(); + actingAsAccount($account, [Token::SCOPE_INSTANCE_REGISTER]); + + $response = $this->postJson('/v1/account/instance-codes')->assertCreated() + ->assertJsonPath('organization', $organization->public_id) + ->assertJsonPath('expires_in', 900); + + $plain = $response->json('code'); + expect($plain)->toMatch('/^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/'); + + $code = InstanceRegistrationCode::first(); + expect($code->code_hash)->toBe(hash('sha256', $plain)) + ->and($code->code_hash)->not->toContain($plain) + ->and($code->expires_at->diffInMinutes(now(), true))->toBeGreaterThan(14.9)->toBeLessThanOrEqual(15); +}); + +it('requires the instance:register scope and an owner or admin role', function () { + [$account, $organization] = accountWithOrganization(); + + actingAsAccount($account, [Token::SCOPE_READ]); + $this->postJson('/v1/account/instance-codes')->assertStatus(403); + + $this->withHeader('Authorization', 'Bearer nope')->postJson('/v1/account/instance-codes')->assertStatus(401); + + [$member, $team] = accountWithOrganization(Membership::ROLE_PUBLISHER, false); + actingAsAccount($member, [Token::SCOPE_INSTANCE_REGISTER]); + $this->postJson('/v1/account/instance-codes', ['organization' => $team->slug]) + ->assertStatus(403)->assertJsonPath('errors.0', 'Only organization owners and admins can register instances.'); + + $this->postJson('/v1/account/instance-codes', ['organization' => Organization::factory()->create()->slug]) + ->assertStatus(422); + + [$admin, $adminTeam] = accountWithOrganization(Membership::ROLE_ADMIN, false); + actingAsAccount($admin, ['*']); + $this->postJson('/v1/account/instance-codes', ['organization' => $adminTeam->public_id]) + ->assertCreated()->assertJsonPath('organization', $adminTeam->public_id); +}); + +it('defaults to and enforces the organization a token is bound to', function () { + [$account, $personal] = accountWithOrganization(); + $team = Organization::factory()->create(); + $account->organizations()->attach($team->uuid, ['role' => Membership::ROLE_OWNER]); + + $token = Token::issue($account, 'team', [Token::SCOPE_INSTANCE_REGISTER], null, $team->uuid); + $this->withHeader('Authorization', 'Bearer '.$token->plainTextToken); + + $this->postJson('/v1/account/instance-codes')->assertCreated()->assertJsonPath('organization', $team->public_id); + $this->postJson('/v1/account/instance-codes', ['organization' => $personal->slug])->assertStatus(403); +}); + +it('registers an instance with a code and returns the instance token once', function () { + [$account, $organization] = accountWithOrganization(); + $code = InstanceRegistrationCode::mint($organization, $account); + + $response = $this->postJson('/v1/instances/register', registrationPayload(strtolower($code->plainTextCode))) + ->assertCreated() + ->assertJsonPath('token_type', 'Bearer') + ->assertJsonPath('instance.name', 'Acme production') + ->assertJsonPath('instance.kind', 'self_hosted') + ->assertJsonPath('instance.console_version', '0.7.2') + ->assertJsonPath('instance.organization.id', $organization->public_id); + + $plain = $response->json('token'); + expect($plain)->toStartWith('flbi_')->toHaveLength(45); + + $instance = Instance::first(); + expect($instance->token_hash)->toBe(hash('sha256', $plain)) + ->and($instance->registered_by_account_uuid)->toBe($account->uuid) + ->and($instance->public_id)->toStartWith('inst_') + ->and($code->fresh()->used_at)->not->toBeNull() + ->and($code->fresh()->instance_uuid)->toBe($instance->uuid) + ->and(json_encode($response->json('instance')))->not->toContain($plain); + + $this->withHeader('Authorization', 'Bearer '.$plain)->getJson('/v1/instances/me') + ->assertOk()->assertJsonPath('instance.id', $instance->public_id); +}); + +it('refuses used, expired and unknown codes', function () { + [$account, $organization] = accountWithOrganization(); + $code = InstanceRegistrationCode::mint($organization, $account); + + $this->postJson('/v1/instances/register', registrationPayload($code->plainTextCode))->assertCreated(); + $this->postJson('/v1/instances/register', registrationPayload($code->plainTextCode)) + ->assertStatus(401)->assertJsonPath('errors.0', 'The registration code is invalid, expired or already used.'); + + $expired = InstanceRegistrationCode::mint($organization, $account); + $this->travel(16)->minutes(); + $this->postJson('/v1/instances/register', registrationPayload($expired->plainTextCode))->assertStatus(401); + + $this->postJson('/v1/instances/register', registrationPayload('AAAA-BBBB-CCCC-DDDD'))->assertStatus(401); + expect(Instance::count())->toBe(1); +}); + +it('validates the registration body without burning the code', function () { + [$account, $organization] = accountWithOrganization(); + $code = InstanceRegistrationCode::mint($organization, $account); + + $response = $this->postJson('/v1/instances/register', registrationPayload($code->plainTextCode, ['kind' => 'desktop', 'name' => ''])) + ->assertStatus(422); + expect($response->json('fields'))->toHaveKeys(['kind', 'name']); + expect($code->fresh()->used_at)->toBeNull(); +}); + +it('keeps instance and personal tokens apart', function () { + $account = Account::factory()->create(); + $personal = Token::issue($account, 'cli'); + $instance = Instance::factory()->create(); + $instanceToken = $instance->issueToken(); + + $this->withHeader('Authorization', 'Bearer '.$personal->plainTextToken)->getJson('/v1/instances/me')->assertStatus(401); + $this->withHeader('Authorization', 'Bearer '.$instanceToken)->getJson('/v1/account')->assertStatus(401); + + $instance->forceFill(['status' => Instance::STATUS_REVOKED])->save(); + $this->withHeader('Authorization', 'Bearer '.$instanceToken)->getJson('/v1/instances/me')->assertStatus(401); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 48d34a3..53ba9fd 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,7 @@ issueToken(); + + test()->withHeader('Authorization', 'Bearer '.$plain); + + return $plain; +} diff --git a/tests/Unit/CompatTest.php b/tests/Unit/CompatTest.php new file mode 100644 index 0000000..962ee52 --- /dev/null +++ b/tests/Unit/CompatTest.php @@ -0,0 +1,29 @@ +toBe($expected); +})->with([ + 'no range' => ['0.7.2', null, true], + 'star' => ['0.7.2', '*', true], + 'gte' => ['0.7.2', '>=0.7.0', true], + 'gte fails' => ['0.6.9', '>=0.7.0', false], + 'caret' => ['1.6.10', '^1.6', true], + 'caret major' => ['2.0.0', '^1.6', false], + 'and' => ['1.5.0', '>=1.0 <2.0', true], + 'or' => ['2.3.0', '1.x || 2.x', true], + 'tilde' => ['0.7.9', '~0.7.2', true], + 'leading v' => ['v0.7.2', '>=0.7', true], + 'unknown version' => [null, '>=99', true], + 'non-semver version' => ['dev-main', '>=99', true], + 'broken range' => ['1.0.0', 'not a range', false], +]); + +it('orders versions by semver precedence', function () { + expect(Compat::isNewer('1.10.0', '1.9.0'))->toBeTrue() + ->and(Compat::isNewer('1.0.0', '1.0.0-beta.1'))->toBeTrue() + ->and(Compat::isNewer('1.0.0', '1.0.0'))->toBeFalse() + ->and(Compat::isNewer('garbage', '1.0.0'))->toBeFalse() + ->and(Compat::isNewer('1.0.0', null))->toBeTrue(); +}); From e8105dc76d1815c73ea80241208facc7e4e76390 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:42:08 +0800 Subject: [PATCH 10/16] Add the Composer v2 repository GET /composer/packages.json returns metadata-url and available-packages (every Composer package with a published release). GET /composer/p2/{vendor}/{package}.json lists every published release newest first in the composer/2.0 minified format, built from the stored composer.json (root-only keys dropped) with dist {type: tar, url, shasum, reference}; ~dev.json is always empty. GET /composer/dist/{vendor}/{package}/{version}.tar streams the API archive. Versions Composer cannot parse are left out of p2. --- .../Packages/ComposerController.php | 59 +++++++ app/Services/Packages/ComposerMetadata.php | 142 ++++++++++++++++ routes/api.php | 13 ++ tests/Feature/Packages/ComposerTest.php | 156 ++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 app/Http/Controllers/Packages/ComposerController.php create mode 100644 app/Services/Packages/ComposerMetadata.php create mode 100644 tests/Feature/Packages/ComposerTest.php diff --git a/app/Http/Controllers/Packages/ComposerController.php b/app/Http/Controllers/Packages/ComposerController.php new file mode 100644 index 0000000..4030a56 --- /dev/null +++ b/app/Http/Controllers/Packages/ComposerController.php @@ -0,0 +1,59 @@ +metadataResponse($this->metadata->root(), $request, true); + } + + /** + * GET /composer/p2/{vendor}/{package}.json and {package}~dev.json + */ + public function versions(Request $request, string $vendor, string $package): Response + { + $dev = str_ends_with($package, '~dev'); + $name = $vendor.'/'.($dev ? substr($package, 0, -4) : $package); + + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_COMPOSER, $name); + $document = $this->metadata->versions($resolved, $dev); + + abort_if(! $dev && $document['packages'][$resolved->name] === [], 404, 'Package not found.'); + + return $this->metadataResponse($document, $request, ! $resolved->extension->isPaid()); + } + + /** + * GET /composer/dist/{vendor}/{package}/{version}.tar + */ + public function dist(Request $request, string $vendor, string $package, string $version): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_COMPOSER, $vendor.'/'.$package); + + $archive = $resolved->downloadableRelease($version)?->apiTarball; + abort_if($archive === null, 404, 'Archive not found.'); + + return $this->storage->download($archive, $request, $this->archiveCacheHeaders(! $resolved->extension->isPaid())); + } +} diff --git a/app/Services/Packages/ComposerMetadata.php b/app/Services/Packages/ComposerMetadata.php new file mode 100644 index 0000000..da62371 --- /dev/null +++ b/app/Services/Packages/ComposerMetadata.php @@ -0,0 +1,142 @@ +where('kind', ExtensionPackage::KIND_COMPOSER) + ->whereHas('extension', fn (Builder $q) => $q + ->where('status', '!=', Extension::STATUS_SUSPENDED) + ->whereHas('releases', fn (Builder $r) => $r->published()->whereNotNull('api_tarball_file_uuid'))) + ->orderBy('name') + ->pluck('name') + ->all(); + + return [ + 'packages' => new \stdClass, + 'metadata-url' => self::metadataUrlTemplate(), + 'available-packages' => $names, + ]; + } + + /** + * GET /composer/p2/{vendor}/{package}.json (newest first, minified) and the `~dev` + * variant, which is always empty: the registry has no branch versions. + */ + public function versions(ExtensionPackage $package, bool $dev = false): array + { + $versions = $dev ? [] : $package->servableReleases() + ->filter(fn (Release $release) => Semver::isComposerCompatible($release->version)) + ->reverse() + ->map(fn (Release $release) => $this->version($package, $release)) + ->values() + ->all(); + + return [ + 'packages' => [$package->name => self::minify($versions)], + 'minified' => self::MINIFIED, + ]; + } + + protected function version(ExtensionPackage $package, Release $release): array + { + $file = $release->apiTarball; + $manifest = array_intersect_key($file->manifest() ?? [], array_flip(self::KEYS)); + + foreach (self::OBJECT_KEYS as $key) { + if (array_key_exists($key, $manifest) && $manifest[$key] === []) { + $manifest[$key] = new \stdClass; + } + } + + return array_merge(['type' => 'library'], $manifest, [ + 'name' => $package->name, + 'version' => $release->version, + 'time' => ($release->published_at ?? $release->created_at)?->copy()->utc()->toAtomString(), + 'dist' => [ + 'type' => 'tar', + 'url' => self::distUrl($package->name, $release->version), + 'shasum' => $file->shasum, + 'reference' => $file->checksum, + ], + ]); + } + + /** + * Composer\MetadataMinifier\MetadataMinifier::minify: each entry after the first holds + * only the keys that changed from the previous one, with `__unset` for removed keys. + */ + public static function minify(array $versions): array + { + $minified = []; + $previous = null; + + foreach ($versions as $version) { + if ($previous === null) { + $minified[] = $previous = $version; + + continue; + } + + $entry = []; + foreach ($version as $key => $value) { + if (! array_key_exists($key, $previous) || $previous[$key] !== $value) { + $entry[$key] = $value; + $previous[$key] = $value; + } + } + foreach (array_keys($previous) as $key) { + if (! array_key_exists($key, $version)) { + $entry[$key] = '__unset'; + unset($previous[$key]); + } + } + + $minified[] = $entry; + } + + return $minified; + } +} diff --git a/routes/api.php b/routes/api.php index 370e995..2c9db23 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Account\TokenController; use App\Http\Controllers\Auth\AuthController; use App\Http\Controllers\Catalog\CatalogController; +use App\Http\Controllers\Packages\ComposerController; use App\Http\Controllers\Packages\NpmController; use Illuminate\Support\Facades\Route; @@ -61,3 +62,15 @@ ->where('any', '.*') ->name('read-only'); }); + +Route::prefix('composer')->name('composer.')->middleware('throttle:packages')->group(function () { + Route::get('packages.json', [ComposerController::class, 'packages'])->name('packages'); + + Route::get('p2/{vendor}/{package}.json', [ComposerController::class, 'versions']) + ->where(['vendor' => ComposerController::VENDOR_PATTERN, 'package' => ComposerController::PACKAGE_PATTERN.'(?:~dev)?']) + ->name('versions'); + + Route::get('dist/{vendor}/{package}/{version}.tar', [ComposerController::class, 'dist']) + ->where(['vendor' => ComposerController::VENDOR_PATTERN, 'package' => ComposerController::PACKAGE_PATTERN, 'version' => '[A-Za-z0-9.+_-]+']) + ->name('dist'); +}); diff --git a/tests/Feature/Packages/ComposerTest.php b/tests/Feature/Packages/ComposerTest.php new file mode 100644 index 0000000..f8f086c --- /dev/null +++ b/tests/Feature/Packages/ComposerTest.php @@ -0,0 +1,156 @@ +get('/composer/packages.json')->assertOk() + ->assertJsonPath('available-packages', ['acme/billing-api', 'acme/route-optimizer-api']) + ->assertJsonPath('metadata-url', url('/composer/p2').'/%package%.json'); + + expect($response->getContent())->toContain('"packages":{}'); + expect($response->headers->get('ETag'))->not->toBeEmpty(); +}); + +it('lists every published release newest first in the minified v2 format', function () { + $package = composerPackage(); + makeRelease($package, '1.0.0', publishedAt: '2026-01-01 00:00:00', manifest: [ + 'description' => 'Route optimizer API', + 'type' => 'fleetbase-extension', + 'require' => ['php' => '^8.2'], + 'autoload' => ['psr-4' => ['Acme\\RouteOptimizer\\' => 'server/src/']], + 'repositories' => [['type' => 'vcs', 'url' => 'dropped']], + 'config' => ['dropped' => true], + ]); + makeRelease($package, '1.1.0', publishedAt: '2026-02-01 00:00:00', manifest: [ + 'description' => 'Route optimizer API', + 'type' => 'fleetbase-extension', + 'require' => ['php' => '^8.2', 'fleetbase/core-api' => '^1.6'], + 'autoload' => ['psr-4' => ['Acme\\RouteOptimizer\\' => 'server/src/']], + ]); + makeRelease($package, '1.2.0-beta.1', Release::CHANNEL_BETA, manifest: ['description' => 'Beta']); + makeRelease($package, '0.9.0', status: Release::STATUS_YANKED); + + $response = $this->get('/composer/p2/acme/route-optimizer-api.json')->assertOk() + ->assertJsonPath('minified', 'composer/2.0'); + + $versions = $response->json('packages')['acme/route-optimizer-api']; + expect(array_column($versions, 'version'))->toBe(['1.2.0-beta.1', '1.1.0', '1.0.0']); + + // Expand the way Composer does and check the full 1.0.0 entry. + $expanded = composerExpand($versions); + $v100 = $expanded[2]; + $release = Release::query()->where('version', '1.0.0')->first(); + expect($v100['name'])->toBe('acme/route-optimizer-api') + ->and($v100['type'])->toBe('fleetbase-extension') + ->and($v100['require'])->toBe(['php' => '^8.2']) + ->and($v100['time'])->toBe('2026-01-01T00:00:00+00:00') + ->and($v100['dist'])->toBe([ + 'type' => 'tar', + 'url' => url('/composer/dist/acme/route-optimizer-api/1.0.0.tar'), + 'shasum' => $release->apiTarball->shasum, + 'reference' => $release->apiTarball->checksum, + ]) + ->and($v100)->not->toHaveKeys(['repositories', 'config']); + + // The beta entry has no `require`; the minifier records that as __unset on the next one. + expect($expanded[0])->not->toHaveKey('require') + ->and($expanded[1]['require'])->toBe(['php' => '^8.2', 'fleetbase/core-api' => '^1.6']); +}); + +it('serves an empty ~dev list and 404s unknown or unreleased packages', function () { + makeRelease(composerPackage(), '1.0.0'); + composerPackage(name: 'acme/unreleased-api'); + + $this->get('/composer/p2/acme/route-optimizer-api~dev.json')->assertOk() + ->assertExactJson(['packages' => ['acme/route-optimizer-api' => []], 'minified' => 'composer/2.0']); + $this->get('/composer/p2/acme/unreleased-api.json')->assertNotFound(); + $this->get('/composer/p2/acme/nope.json')->assertNotFound()->assertJsonPath('error', 'Package not found.'); +}); + +it('streams the dist archive with sha1 matching dist.shasum', function () { + $release = makeRelease(composerPackage(), '1.0.0'); + + $response = $this->get('/composer/dist/acme/route-optimizer-api/1.0.0.tar')->assertOk() + ->assertHeader('ETag', '"'.$release->apiTarball->checksum.'"'); + + expect(sha1($response->streamedContent()))->toBe($release->apiTarball->shasum); + + $this->get('/composer/dist/acme/route-optimizer-api/2.0.0.tar')->assertNotFound(); +}); + +it('answers If-None-Match on p2 metadata', function () { + makeRelease(composerPackage(), '1.0.0'); + + $etag = $this->get('/composer/p2/acme/route-optimizer-api.json')->headers->get('ETag'); + $this->withHeader('If-None-Match', $etag)->get('/composer/p2/acme/route-optimizer-api.json')->assertStatus(304); +}); + +it('skips versions Composer cannot parse', function () { + $package = composerPackage(); + makeRelease($package, '1.0.0'); + makeRelease($package, '1.1.0-next.1', Release::CHANNEL_BETA); + + $versions = $this->get('/composer/p2/acme/route-optimizer-api.json')->json('packages')['acme/route-optimizer-api']; + expect(array_column($versions, 'version'))->toBe(['1.0.0']); +}); + +it('hides suspended listings from packages.json', function () { + $package = composerPackage(); + makeRelease($package, '1.0.0'); + $package->extension->forceFill(['status' => Extension::STATUS_SUSPENDED])->save(); + + $this->get('/composer/packages.json')->assertJsonPath('available-packages', []); + $this->get('/composer/p2/acme/route-optimizer-api.json')->assertNotFound(); +}); + +it('minifies like Composer', function () { + $versions = [ + ['name' => 'a/b', 'version' => '2.0.0', 'require' => ['php' => '^8.2'], 'extra' => ['x' => 1]], + ['name' => 'a/b', 'version' => '1.0.0', 'require' => ['php' => '^8.2']], + ]; + + expect(ComposerMetadata::minify($versions))->toBe([ + $versions[0], + ['version' => '1.0.0', 'extra' => '__unset'], + ])->and(composerExpand(ComposerMetadata::minify($versions)))->toBe($versions); +}); + +/** + * Composer\MetadataMinifier\MetadataMinifier::expand. + */ +function composerExpand(array $versions): array +{ + $expanded = []; + $expandedVersion = null; + foreach ($versions as $versionData) { + if (! $expandedVersion) { + $expandedVersion = $versionData; + $expanded[] = $expandedVersion; + + continue; + } + foreach ($versionData as $key => $val) { + if ($val === '__unset') { + unset($expandedVersion[$key]); + } else { + $expandedVersion[$key] = $val; + } + } + $expanded[] = $expandedVersion; + } + + return $expanded; +} From 9ab9018c02a0d708e1cb81ee87246692d1e712d2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:46:49 +0800 Subject: [PATCH 11/16] Add the licence gate for package routes and registry:seed --seed-release licence:npm and licence:composer guard the packument, tarball, p2 and dist routes. Free packages pass through. Paid packages need a bearer token: a personal token with the install scope (its pinned organization, or every organization of the account) or an instance token (instances.token_hash). Access is granted when one of those organizations owns the extension or holds an active, unexpired licence; 401 with WWW-Authenticate without a usable token, 403 otherwise. Paid metadata and archives are Cache-Control private and vary on Authorization. registry:seed seeds categories; with --seed-release it fabricates a published free and a paid extension from the fixture tarballs, a buyer organization holding a licence and an outsider without one, and prints install-scoped tokens for both (--json for scripts). tests/Conformance/packages.sh drives real npm and Composer clients against a running service with that seed. --- app/Console/Commands/SeedRegistry.php | 230 ++++++++++++++++++ app/Http/Middleware/EnsurePackageLicence.php | 64 +++++ app/Models/Instance.php | 2 + app/Services/LicenceGate.php | 114 +++++++++ bootstrap/app.php | 2 + routes/api.php | 4 + tests/Conformance/packages.sh | 80 ++++++ tests/Feature/Packages/LicenceGateTest.php | 160 ++++++++++++ .../Packages/SeedReleaseCommandTest.php | 50 ++++ 9 files changed, 706 insertions(+) create mode 100644 app/Console/Commands/SeedRegistry.php create mode 100644 app/Http/Middleware/EnsurePackageLicence.php create mode 100644 app/Services/LicenceGate.php create mode 100755 tests/Conformance/packages.sh create mode 100644 tests/Feature/Packages/LicenceGateTest.php create mode 100644 tests/Feature/Packages/SeedReleaseCommandTest.php diff --git a/app/Console/Commands/SeedRegistry.php b/app/Console/Commands/SeedRegistry.php new file mode 100644 index 0000000..8851a7e --- /dev/null +++ b/app/Console/Commands/SeedRegistry.php @@ -0,0 +1,230 @@ + [name, price in cents or null for free]. */ + public const FIXTURES = [ + 'hello' => ['Registry Demo Hello', null], + 'pro' => ['Registry Demo Pro', 4900], + ]; + + public function handle(ReleaseStorage $storage): int + { + if ($this->laravel->isProduction() && ! $this->option('force')) { + $this->error('Refusing to seed in production without --force.'); + + return self::FAILURE; + } + + $this->callSilently('db:seed', ['--class' => DatabaseSeeder::class, '--force' => true]); + + if (! $this->option('seed-release')) { + $this->option('json') ? $this->line(json_encode(['seeded' => ['categories']])) : $this->info('Seeded categories.'); + + return self::SUCCESS; + } + + $result = DB::transaction(fn () => $this->seedReleases($storage)); + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->info('Seeded demo releases.'); + $this->table(['Extension', 'Pricing', 'npm', 'Composer', 'Version'], array_map(fn ($e) => [ + $e['name'], $e['paid'] ? 'paid' : 'free', $e['npm'], $e['composer'], $e['version'], + ], $result['extensions'])); + $this->newLine(); + $this->line('Tokens (shown once, install scope):'); + $this->line(" licensed {$result['tokens']['licensed']} (organization {$result['organizations']['buyer']} holds a licence for the paid extension)"); + $this->line(" unlicensed {$result['tokens']['unlicensed']} (organization {$result['organizations']['outsider']} holds none)"); + + return self::SUCCESS; + } + + protected function seedReleases(ReleaseStorage $storage): array + { + $publisherAccount = $this->account('publisher@registry-demo.test', 'Registry Demo Publisher', 'registry-demo-publisher'); + $publisher = $this->organization(self::PUBLISHER, 'Registry Demo', $publisherAccount); + $category = Category::query()->orderBy('sort')->first(); + + $extensions = []; + foreach (self::FIXTURES as $key => [$name, $amount]) { + $extension = Extension::withTrashed()->updateOrCreate(['slug' => self::PUBLISHER.'-'.$key], [ + 'organization_uuid' => $publisher->uuid, + 'category_uuid' => $category?->uuid, + 'created_by_account_uuid' => $publisherAccount->uuid, + 'name' => $name, + 'subtitle' => "Fixture extension seeded by registry:seed ({$key}).", + 'description' => 'Demo listing used to exercise the npm and Composer endpoints.', + 'hosting' => Extension::HOSTING_BOTH, + 'status' => Extension::STATUS_PUBLISHED, + 'published_at' => now(), + 'fa_icon' => 'puzzle-piece', + 'tags' => ['demo'], + 'languages' => ['en'], + ]); + $extension->restore(); + + Price::query()->updateOrCreate(['extension_uuid' => $extension->uuid], $amount === null + ? ['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => 'USD'] + : ['model' => Price::MODEL_ONE_TIME, 'amount' => $amount, 'currency' => 'USD']); + + $npm = ExtensionPackage::query()->firstOrCreate( + ['kind' => ExtensionPackage::KIND_NPM, 'name' => '@'.self::PUBLISHER.'/'.$key.'-engine'], + ['extension_uuid' => $extension->uuid], + ); + $composer = ExtensionPackage::query()->firstOrCreate( + ['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => self::PUBLISHER.'/'.$key.'-api'], + ['extension_uuid' => $extension->uuid], + ); + + $release = $this->release($storage, $extension, $key, '1.0.0', $publisherAccount); + + $extensions[] = [ + 'name' => $extension->name, + 'slug' => $extension->slug, + 'paid' => $amount !== null, + 'npm' => $npm->name, + 'composer' => $composer->name, + 'version' => $release->version, + ]; + } + + $paid = Extension::query()->where('slug', self::PUBLISHER.'-pro')->firstOrFail(); + + $buyerAccount = $this->account('buyer@registry-demo.test', 'Registry Demo Buyer', 'registry-demo-buyer'); + $buyer = $this->organization('registry-demo-buyer', 'Registry Demo Buyer', $buyerAccount); + Licence::withTrashed()->updateOrCreate( + ['extension_uuid' => $paid->uuid, 'organization_uuid' => $buyer->uuid], + ['kind' => Licence::KIND_PAID, 'status' => Licence::STATUS_ACTIVE, 'expires_at' => null], + )->restore(); + + $outsiderAccount = $this->account('outsider@registry-demo.test', 'Registry Demo Outsider', 'registry-demo-outsider'); + $outsider = $this->organization('registry-demo-outsider', 'Registry Demo Outsider', $outsiderAccount); + + return [ + 'extensions' => $extensions, + 'organizations' => ['publisher' => $publisher->slug, 'buyer' => $buyer->slug, 'outsider' => $outsider->slug], + 'tokens' => [ + 'licensed' => $this->reissueToken($buyerAccount), + 'unlicensed' => $this->reissueToken($outsiderAccount), + ], + ]; + } + + protected function release(ReleaseStorage $storage, Extension $extension, string $key, string $version, Account $creator): Release + { + $release = Release::withTrashed()->firstOrNew(['extension_uuid' => $extension->uuid, 'version' => $version]); + $release->fill([ + 'channel' => Release::CHANNEL_STABLE, + 'status' => Release::STATUS_PUBLISHED, + 'created_by_account_uuid' => $creator->uuid, + 'manifest' => ['id' => $extension->slug, 'name' => $extension->name, 'version' => $version], + 'compat' => ['console' => '>=0.7.0', 'api' => '>=1.6.0'], + 'notes' => 'Seeded by registry:seed --seed-release.', + 'published_at' => $release->published_at ?? now(), + ]); + $release->deleted_at = null; + $release->save(); + + $files = [ + 'engine_tarball_file_uuid' => [File::TYPE_ENGINE_TARBALL, "{$key}-engine-{$version}.tgz"], + 'api_tarball_file_uuid' => [File::TYPE_API_TARBALL, "{$key}-api-{$version}.tar.gz"], + ]; + foreach ($files as $column => [$type, $fixture]) { + $existing = $release->{$column} ? File::find($release->{$column}) : null; + if ($existing && $storage->exists($existing)) { + continue; + } + + $file = $storage->store(base_path('database/fixtures/packages/'.$fixture), $type, $release, [ + 'uploader_account_uuid' => $creator->uuid, + ]); + $release->{$column} = $file->uuid; + } + + $release->size_bytes = File::query()->whereIn('uuid', [$release->engine_tarball_file_uuid, $release->api_tarball_file_uuid])->sum('size'); + $release->checksum = 'sha256:'.File::find($release->engine_tarball_file_uuid)?->checksum; + $release->save(); + + return $release; + } + + protected function account(string $email, string $name, string $username): Account + { + $account = Account::withTrashed()->firstOrNew(['email' => $email]); + $account->fill([ + 'name' => $name, + 'username' => $username, + 'status' => Account::STATUS_ACTIVE, + 'email_verified_at' => $account->email_verified_at ?? now(), + ]); + if (! $account->exists) { + $account->password = Str::random(40); + } + $account->deleted_at = null; + $account->save(); + + return $account; + } + + protected function organization(string $slug, string $name, Account $owner): Organization + { + $organization = Organization::withTrashed()->updateOrCreate(['slug' => $slug], ['name' => $name, 'verified_at' => now()]); + $organization->restore(); + + if (! $owner->organizations()->whereKey($organization->uuid)->exists()) { + $owner->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_OWNER]); + } + + return $organization; + } + + protected function reissueToken(Account $account): string + { + $account->tokens()->where('name', self::TOKEN_NAME)->whereNull('revoked_at')->get()->each->revoke(); + + return Token::issue($account, self::TOKEN_NAME, [Token::SCOPE_INSTALL, Token::SCOPE_READ], now()->addDays(30))->plainTextToken; + } +} diff --git a/app/Http/Middleware/EnsurePackageLicence.php b/app/Http/Middleware/EnsurePackageLicence.php new file mode 100644 index 0000000..66fc5bf --- /dev/null +++ b/app/Http/Middleware/EnsurePackageLicence.php @@ -0,0 +1,64 @@ +packageName($request, $kind); + $package = $name !== null ? ExtensionPackage::resolve($kind, $name) : null; + + if ($package === null) { + return $next($request); + } + + $request->attributes->set(PackageController::PACKAGE_ATTRIBUTE, $package); + + if (! $package->extension->isPaid()) { + return $next($request); + } + + $decision = $this->gate->check($request, $package->extension); + if ($decision['status'] === LicenceGate::STATUS_ALLOWED) { + return $next($request); + } + + $headers = ['Cache-Control' => 'private, no-store']; + if ($decision['status'] === LicenceGate::STATUS_UNAUTHENTICATED) { + $headers['WWW-Authenticate'] = 'Bearer realm="Fleetbase Registry"'; + } + + return response()->packageError($decision['message'], $decision['status'], $headers); + } + + protected function packageName(Request $request, string $kind): ?string + { + $route = $request->route(); + + return match ($kind) { + ExtensionPackage::KIND_NPM => $route?->parameter('package'), + ExtensionPackage::KIND_COMPOSER => $route?->parameter('vendor') && $route?->parameter('package') + ? $route->parameter('vendor').'/'.preg_replace('/~dev$/', '', $route->parameter('package')) + : null, + default => throw new \InvalidArgumentException("Unknown package kind [{$kind}]."), + }; + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php index da044ab..5fb150d 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -26,6 +26,8 @@ class Instance extends Model public const KINDS = [self::KIND_CLOUD, self::KIND_SELF_HOSTED]; + public const STATUS_ACTIVE = 'active'; + protected $table = 'instances'; protected string $publicIdType = 'inst'; diff --git a/app/Services/LicenceGate.php b/app/Services/LicenceGate.php new file mode 100644 index 0000000..d53b097 --- /dev/null +++ b/app/Services/LicenceGate.php @@ -0,0 +1,114 @@ +bearerToken(); + if ($plain === '') { + return $this->deny(self::STATUS_UNAUTHENTICATED, "{$extension->name} is a paid extension. Send a registry token with the install scope as `Authorization: Bearer `."); + } + + $credential = $this->resolve($plain, $request); + if ($credential === null) { + return $this->deny(self::STATUS_UNAUTHENTICATED, 'The registry token is invalid, expired or revoked.'); + } + + if ($credential instanceof Token && ! $credential->can(Token::SCOPE_INSTALL)) { + return $this->deny(self::STATUS_FORBIDDEN, "This token does not have the 'install' scope."); + } + + $organizations = $this->organizationsFor($credential); + if (in_array($extension->organization_uuid, $organizations, true) || $this->hasActiveLicence($extension, $organizations)) { + return ['status' => self::STATUS_ALLOWED, 'message' => null]; + } + + return $this->deny(self::STATUS_FORBIDDEN, "Your organization does not hold an active licence for {$extension->name}."); + } + + /** + * @param list $organizationUuids + */ + public function hasActiveLicence(Extension $extension, array $organizationUuids): bool + { + if ($organizationUuids === []) { + return false; + } + + return Licence::query()->active() + ->where('extension_uuid', $extension->uuid) + ->whereIn('organization_uuid', $organizationUuids) + ->exists(); + } + + protected function resolve(string $plain, Request $request): Token|Instance|null + { + if (str_starts_with($plain, Token::PREFIX_PERSONAL)) { + $token = Token::findActive($plain); + if (! $token || ! $token->account instanceof Account || ! $token->account->isActive()) { + return null; + } + $token->touchUsage($request->ip()); + + return $token; + } + + if (str_starts_with($plain, Token::PREFIX_INSTANCE)) { + return Instance::query() + ->where('token_hash', Token::hash($plain)) + ->where('status', Instance::STATUS_ACTIVE) + ->first(); + } + + return null; + } + + /** + * @return list + */ + protected function organizationsFor(Token|Instance $credential): array + { + if ($credential instanceof Instance) { + return [$credential->organization_uuid]; + } + + if ($credential->organization_uuid) { + return [$credential->organization_uuid]; + } + + return $credential->account->memberships()->pluck('organization_uuid')->all(); + } + + protected function deny(int $status, string $message): array + { + return ['status' => $status, 'message' => $message]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index f991f21..bb4a4ae 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'scope' => EnsureTokenScope::class, + 'licence' => EnsurePackageLicence::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/routes/api.php b/routes/api.php index 2c9db23..c86981c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -51,10 +51,12 @@ Route::get('-/whoami', [NpmController::class, 'whoami'])->name('whoami'); Route::get('{package}/-/{file}', [NpmController::class, 'tarball']) + ->middleware('licence:npm') ->where(['package' => NpmController::NAME_PATTERN, 'file' => '[A-Za-z0-9._~+-]+\\.tgz']) ->name('tarball'); Route::get('{package}', [NpmController::class, 'packument']) + ->middleware('licence:npm') ->where('package', NpmController::NAME_PATTERN) ->name('packument'); @@ -67,10 +69,12 @@ Route::get('packages.json', [ComposerController::class, 'packages'])->name('packages'); Route::get('p2/{vendor}/{package}.json', [ComposerController::class, 'versions']) + ->middleware('licence:composer') ->where(['vendor' => ComposerController::VENDOR_PATTERN, 'package' => ComposerController::PACKAGE_PATTERN.'(?:~dev)?']) ->name('versions'); Route::get('dist/{vendor}/{package}/{version}.tar', [ComposerController::class, 'dist']) + ->middleware('licence:composer') ->where(['vendor' => ComposerController::VENDOR_PATTERN, 'package' => ComposerController::PACKAGE_PATTERN, 'version' => '[A-Za-z0-9.+_-]+']) ->name('dist'); }); diff --git a/tests/Conformance/packages.sh b/tests/Conformance/packages.sh new file mode 100755 index 0000000..4cd1b69 --- /dev/null +++ b/tests/Conformance/packages.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Package protocol conformance: drive real npm and Composer clients against a running +# Registry Service seeded with `php artisan registry:seed --seed-release`. +# +# php artisan serve --port=8000 & +# tests/Conformance/packages.sh http://127.0.0.1:8000 +# +# Checks the free packages install with no credentials, the paid ones answer 401 without a +# token and 403 with an unlicensed one, and install with a licensed token. Nothing touches +# npmjs.org or packagist.org. +set -euo pipefail + +BASE_URL="${1:-http://127.0.0.1:8000}" +BASE_URL="${BASE_URL%/}" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +AUTHORITY="${BASE_URL#*://}" # host[:port] +export npm_config_cache="$WORK/npm-cache" npm_config_audit=false npm_config_fund=false npm_config_update_notifier=false +export COMPOSER_HOME="$WORK/composer-home" COMPOSER_CACHE_DIR="$WORK/composer-cache" COMPOSER_NO_INTERACTION=1 + +pass() { printf ' \033[32mok\033[0m %s\n' "$1"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; exit 1; } +expect_fail() { # description, expected output fragment, command... + local description="$1" fragment="$2"; shift 2 + local output + if output="$("$@" 2>&1)"; then fail "$description (command succeeded)"; fi + grep -q -- "$fragment" <<<"$output" || { echo "$output"; fail "$description (expected '$fragment')"; } + pass "$description" +} + +echo "Seeding demo releases" +SEED="$(cd "$ROOT" && php artisan registry:seed --seed-release --json)" +LICENSED="$(php -r 'echo json_decode($argv[1], true)["tokens"]["licensed"];' "$SEED")" +UNLICENSED="$(php -r 'echo json_decode($argv[1], true)["tokens"]["unlicensed"];' "$SEED")" + +echo "npm ($(npm --version))" +mkdir -p "$WORK/npm" && cd "$WORK/npm" +npm init -y >/dev/null +echo "@registry-demo:registry=$BASE_URL/npm/" > .npmrc + +[ "$(npm view @registry-demo/hello-engine version)" = "1.0.0" ] && pass "npm view resolves the free packument" || fail "npm view" +[ "$(npm view @registry-demo/hello-engine dist-tags.latest)" = "1.0.0" ] && pass "dist-tags.latest" || fail "dist-tags.latest" +npm pack @registry-demo/hello-engine >/dev/null 2>&1 && [ -f registry-demo-hello-engine-1.0.0.tgz ] && pass "npm pack downloads and verifies the tarball" || fail "npm pack" +npm install @registry-demo/hello-engine >/dev/null && [ -f node_modules/@registry-demo/hello-engine/index.js ] && pass "npm install (free)" || fail "npm install (free)" +grep -q '"integrity": "sha512-' package-lock.json && pass "lockfile records sha512 integrity" || fail "lockfile integrity" + +expect_fail "paid packument without a token is 401" "401" npm view @registry-demo/pro-engine version +echo "//$AUTHORITY/npm/:_authToken=$UNLICENSED" >> .npmrc +expect_fail "paid packument with an unlicensed token is 403" "403" npm view @registry-demo/pro-engine version +expect_fail "npm publish is refused with a pointer to flb publish" "flb publish" npm publish "$ROOT/database/fixtures/packages/hello-engine-1.0.0.tgz" --registry "$BASE_URL/npm/" +sed -i.bak "s#_authToken=.*#_authToken=$LICENSED#" .npmrc +npm install @registry-demo/pro-engine >/dev/null && [ -f node_modules/@registry-demo/pro-engine/index.js ] && pass "npm install (paid, licensed token)" || fail "npm install (paid)" + +echo "Composer ($(composer --version 2>/dev/null | head -n 1))" +mkdir -p "$WORK/composer" && cd "$WORK/composer" +cat > composer.json < '/npm/@acme%2fpro-engine', + 'npm tarball' => '/npm/@acme/pro-engine/-/pro-engine-1.0.0.tgz', + 'composer p2' => '/composer/p2/acme/pro-api.json', + 'composer p2 ~dev' => '/composer/p2/acme/pro-api~dev.json', + 'composer dist' => '/composer/dist/acme/pro-api/1.0.0.tar', +]; + +/** + * A paid extension with an npm and a Composer package, both released at 1.0.0. + */ +function paidExtension(): Extension +{ + $npm = makePackage(ExtensionPackage::KIND_NPM, '@acme/pro-engine', paid: true); + $extension = $npm->extension; + $composer = $extension->packages()->create(['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => 'acme/pro-api']); + makeRelease($npm, '1.0.0'); + makeRelease($composer, '1.0.0'); + + return $extension; +} + +function grantLicence(Extension $extension, Organization $organization, array $attributes = []): Licence +{ + return Licence::create(array_merge([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $organization->uuid, + 'kind' => Licence::KIND_PAID, + 'status' => Licence::STATUS_ACTIVE, + ], $attributes)); +} + +function buyer(): array +{ + $account = Account::factory()->create(); + $organization = Organization::factory()->create(); + $account->organizations()->attach($organization->uuid, ['role' => 'owner']); + + return [$account, $organization]; +} + +it('leaves free packages open without a token, even with a bad one', function () { + $npm = makePackage(ExtensionPackage::KIND_NPM, '@acme/free-engine'); + $composer = $npm->extension->packages()->create(['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => 'acme/free-api']); + makeRelease($npm, '1.0.0'); + makeRelease($composer, '1.0.0'); + + $this->get('/npm/@acme%2ffree-engine')->assertOk(); + $this->get('/npm/@acme/free-engine/-/free-engine-1.0.0.tgz')->assertOk(); + $this->get('/composer/p2/acme/free-api.json')->assertOk(); + $this->get('/composer/dist/acme/free-api/1.0.0.tar')->assertOk(); + + $this->withHeader('Authorization', 'Bearer flbp_bogus')->get('/npm/@acme%2ffree-engine')->assertOk(); +}); + +it('answers 401 for paid packages without a usable token', function (string $uri) { + paidExtension(); + + $this->get($uri)->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer realm="Fleetbase Registry"') + ->assertJsonPath('error', fn (string $m) => str_contains($m, 'paid extension')); + + $this->withHeader('Authorization', 'Bearer flbp_not-a-real-token')->get($uri)->assertUnauthorized(); + + [$account] = buyer(); + $revoked = Token::issue($account, 'old', [Token::SCOPE_INSTALL])->revoke(); + $this->withHeader('Authorization', 'Bearer '.$revoked->plainTextToken)->get($uri)->assertUnauthorized(); +})->with(PAID_ROUTES); + +it('answers 403 for a token without a licence or without the install scope', function (string $uri) { + paidExtension(); + [$account] = buyer(); + + actingAsAccount($account, [Token::SCOPE_INSTALL]); + $this->get($uri)->assertForbidden()->assertJsonPath('error', fn (string $m) => str_contains($m, 'does not hold an active licence')); + + actingAsAccount($account, [Token::SCOPE_READ, Token::SCOPE_PUBLISH]); + $this->get($uri)->assertForbidden()->assertJsonPath('error', "This token does not have the 'install' scope."); +})->with(PAID_ROUTES); + +it('serves paid packages to an organization with an active licence', function (string $uri) { + $extension = paidExtension(); + [$account, $organization] = buyer(); + grantLicence($extension, $organization); + + $token = actingAsAccount($account, [Token::SCOPE_INSTALL]); + $response = $this->get($uri)->assertOk(); + + expect($response->headers->get('Cache-Control'))->toContain('private') + ->and($token->fresh()->last_used_at)->not->toBeNull(); +})->with(PAID_ROUTES); + +it('ignores expired and revoked licences', function () { + $extension = paidExtension(); + [$account, $organization] = buyer(); + $licence = grantLicence($extension, $organization, ['expires_at' => now()->subDay()]); + actingAsAccount($account, [Token::SCOPE_INSTALL]); + + $this->get('/npm/@acme%2fpro-engine')->assertForbidden(); + + $licence->forceFill(['expires_at' => null, 'status' => Licence::STATUS_REVOKED])->save(); + $this->get('/npm/@acme%2fpro-engine')->assertForbidden(); + + $licence->forceFill(['status' => Licence::STATUS_ACTIVE])->save(); + $this->get('/npm/@acme%2fpro-engine')->assertOk(); +}); + +it('limits a token pinned to an organization to that organization', function () { + $extension = paidExtension(); + [$account, $licensed] = buyer(); + grantLicence($extension, $licensed); + $other = Organization::factory()->create(); + $account->organizations()->attach($other->uuid, ['role' => 'member']); + + $pinned = Token::issue($account, 'other org', [Token::SCOPE_INSTALL], organizationUuid: $other->uuid); + $this->withHeader('Authorization', 'Bearer '.$pinned->plainTextToken)->get('/npm/@acme%2fpro-engine')->assertForbidden(); + + $unpinned = Token::issue($account, 'any org', [Token::SCOPE_INSTALL]); + $this->withHeader('Authorization', 'Bearer '.$unpinned->plainTextToken)->get('/npm/@acme%2fpro-engine')->assertOk(); +}); + +it('lets the publishing organization install its own paid extension', function () { + $extension = paidExtension(); + $publisher = Account::factory()->create(); + $publisher->organizations()->attach($extension->organization_uuid, ['role' => 'publisher']); + + actingAsAccount($publisher, [Token::SCOPE_INSTALL]); + $this->get('/composer/dist/acme/pro-api/1.0.0.tar')->assertOk(); +}); + +it('accepts an instance token of a licensed organization', function () { + $extension = paidExtension(); + $organization = Organization::factory()->create(); + grantLicence($extension, $organization); + + $plain = Token::PREFIX_INSTANCE.Str::random(40); + $instance = Instance::factory()->create(['organization_uuid' => $organization->uuid, 'token_hash' => Token::hash($plain)]); + + $this->withHeader('Authorization', 'Bearer '.$plain)->get('/npm/@acme%2fpro-engine')->assertOk(); + + $instance->forceFill(['status' => 'revoked'])->save(); + $this->withHeader('Authorization', 'Bearer '.$plain)->get('/npm/@acme%2fpro-engine')->assertUnauthorized(); +}); + +it('keeps paid packages listed in packages.json', function () { + paidExtension(); + + $this->get('/composer/packages.json')->assertOk()->assertJsonPath('available-packages', ['acme/pro-api']); +}); diff --git a/tests/Feature/Packages/SeedReleaseCommandTest.php b/tests/Feature/Packages/SeedReleaseCommandTest.php new file mode 100644 index 0000000..e25202b --- /dev/null +++ b/tests/Feature/Packages/SeedReleaseCommandTest.php @@ -0,0 +1,50 @@ + true, '--json' => true]))->toBe(0); + + return json_decode(Artisan::output(), true); +} + +it('fabricates a free and a paid release that the package endpoints serve', function () { + $seed = seedReleases(); + + expect(collect($seed['extensions'])->pluck('paid', 'npm')->all()) + ->toBe(['@registry-demo/hello-engine' => false, '@registry-demo/pro-engine' => true]); + + $this->get('/npm/@registry-demo%2fhello-engine')->assertOk()->assertJsonPath('dist-tags.latest', '1.0.0'); + $this->get('/npm/@registry-demo/hello-engine/-/hello-engine-1.0.0.tgz')->assertOk(); + $this->get('/composer/packages.json')->assertJsonPath('available-packages', ['registry-demo/hello-api', 'registry-demo/pro-api']); + $this->get('/composer/dist/registry-demo/hello-api/1.0.0.tar')->assertOk(); + + $this->get('/npm/@registry-demo%2fpro-engine')->assertUnauthorized(); + $this->withHeader('Authorization', 'Bearer '.$seed['tokens']['unlicensed'])->get('/composer/p2/registry-demo/pro-api.json')->assertForbidden(); + $this->withHeader('Authorization', 'Bearer '.$seed['tokens']['licensed'])->get('/composer/p2/registry-demo/pro-api.json')->assertOk(); + $this->withHeader('Authorization', 'Bearer '.$seed['tokens']['licensed'])->get('/npm/@registry-demo/pro-engine/-/pro-engine-1.0.0.tgz')->assertOk(); +}); + +it('is safe to re-run and reissues the tokens', function () { + $first = seedReleases(); + $second = seedReleases(); + + expect(Release::count())->toBe(2) + ->and(File::count())->toBe(4) + ->and($second['tokens']['licensed'])->not->toBe($first['tokens']['licensed']); + + $this->withHeader('Authorization', 'Bearer '.$first['tokens']['licensed'])->get('/npm/@registry-demo%2fpro-engine')->assertUnauthorized(); + $this->withHeader('Authorization', 'Bearer '.$second['tokens']['licensed'])->get('/npm/@registry-demo%2fpro-engine')->assertOk(); +}); + +it('seeds only reference data without the flag and refuses production without --force', function () { + $this->artisan('registry:seed')->assertSuccessful(); + expect(Release::count())->toBe(0)->and(Category::count())->toBe(41); + + app()->detectEnvironment(fn () => 'production'); + $this->artisan('registry:seed', ['--seed-release' => true])->assertFailed(); +}); From af96f0819ae721bfe27739f5c85959161a595933 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:48:09 +0800 Subject: [PATCH 12/16] Protect the @fleetbase/* and fleetbase/* package namespaces Organizations gain an is_staff flag (not mass assignable; set with registry:staff). extension_packages names in a reserved namespace (registry.reserved_scopes, matched on the npm scope or Composer vendor, case-insensitively) can only be saved for an extension whose organization is staff; creation, renames and moves to another listing are checked and fail with a 422 on the name field. Package names are stored lowercase. The unused REGISTRY_STAFF_ORGANIZATION setting is removed in favour of the flag. --- .env.docker | 1 - .env.example | 1 - .../Commands/ManageStaffOrganization.php | 39 ++++++++++ .../ReservedPackageNamespaceException.php | 19 +++++ app/Models/ExtensionPackage.php | 50 ++++++++++++ app/Models/Organization.php | 7 ++ config/registry.php | 6 +- database/factories/OrganizationFactory.php | 5 ++ ...02_add_is_staff_to_organizations_table.php | 27 +++++++ .../Feature/Packages/PackageNamespaceTest.php | 78 +++++++++++++++++++ 10 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 app/Console/Commands/ManageStaffOrganization.php create mode 100644 app/Exceptions/ReservedPackageNamespaceException.php create mode 100644 database/migrations/2026_10_22_000002_add_is_staff_to_organizations_table.php create mode 100644 tests/Feature/Packages/PackageNamespaceTest.php diff --git a/.env.docker b/.env.docker index 4e0caa3..49c4e1e 100644 --- a/.env.docker +++ b/.env.docker @@ -10,4 +10,3 @@ LOG_LEVEL=debug BCRYPT_ROUNDS=12 REGISTRY_VERSION=2.0.0-dev REGISTRY_CATALOG_CACHE_SECONDS=60 -REGISTRY_STAFF_ORGANIZATION=fleetbase diff --git a/.env.example b/.env.example index e47cce3..5e37cf1 100644 --- a/.env.example +++ b/.env.example @@ -68,7 +68,6 @@ REGISTRY_CATALOG_CACHE_SECONDS=60 # Base URL written into npm/Composer metadata; empty = the request's scheme and host. REGISTRY_PUBLIC_URL= REGISTRY_PACKAGE_METADATA_CACHE_SECONDS=60 -REGISTRY_STAFF_ORGANIZATION=fleetbase HASH_VERIFY=true # Sentry (optional) diff --git a/app/Console/Commands/ManageStaffOrganization.php b/app/Console/Commands/ManageStaffOrganization.php new file mode 100644 index 0000000..e0feb21 --- /dev/null +++ b/app/Console/Commands/ManageStaffOrganization.php @@ -0,0 +1,39 @@ +argument('organization'); + $organization = Organization::query()->where('slug', $identifier)->orWhere('public_id', $identifier)->first(); + + if ($organization === null) { + $this->error("No organization matches [{$identifier}]."); + + return self::FAILURE; + } + + $organization->forceFill(['is_staff' => ! $this->option('revoke')])->save(); + + $this->info($organization->is_staff + ? "{$organization->name} ({$organization->slug}) is now a staff organization." + : "{$organization->name} ({$organization->slug}) is no longer a staff organization."); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/ReservedPackageNamespaceException.php b/app/Exceptions/ReservedPackageNamespaceException.php new file mode 100644 index 0000000..42e72a1 --- /dev/null +++ b/app/Exceptions/ReservedPackageNamespaceException.php @@ -0,0 +1,19 @@ + "The '{$namespace}' package namespace is reserved for Fleetbase; {$name} cannot be registered by this organization.", + ]); + } +} diff --git a/app/Models/ExtensionPackage.php b/app/Models/ExtensionPackage.php index d7b0a38..3e19491 100644 --- a/app/Models/ExtensionPackage.php +++ b/app/Models/ExtensionPackage.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Exceptions\ReservedPackageNamespaceException; use App\Support\Semver; use App\Traits\HasUuid; use Illuminate\Database\Eloquent\Builder; @@ -27,11 +28,60 @@ class ExtensionPackage extends Model protected $hidden = ['id']; + protected static function booted(): void + { + static::saving(function (ExtensionPackage $package) { + $package->name = strtolower(trim((string) $package->name)); + + if ($package->isDirty(['name', 'kind', 'extension_uuid'])) { + $package->assertNamespaceAllowed(); + } + }); + } + public function extension(): BelongsTo { return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); } + /** + * The scope or vendor: `fleetbase` for `@fleetbase/x` and `fleetbase/x`; null for an + * unscoped npm name. + */ + public static function namespaceOf(string $kind, string $name): ?string + { + $name = strtolower($name); + + if ($kind === self::KIND_NPM) { + return preg_match('#^@([^/]+)/#', $name, $m) ? $m[1] : null; + } + + return str_contains($name, '/') ? strstr($name, '/', true) : null; + } + + public static function isReservedNamespace(?string $namespace): bool + { + return $namespace !== null && in_array($namespace, array_map('strtolower', config('registry.reserved_scopes', [])), true); + } + + /** + * Reserved namespaces may only be owned by an extension of a staff organization. + * + * @throws ReservedPackageNamespaceException + */ + public function assertNamespaceAllowed(): void + { + $namespace = static::namespaceOf((string) $this->kind, (string) $this->name); + if (! static::isReservedNamespace($namespace)) { + return; + } + + $owner = Extension::withTrashed()->find($this->extension_uuid)?->organization()->first(); + if ($owner?->is_staff !== true) { + throw ReservedPackageNamespaceException::for($this->name, $this->kind === self::KIND_NPM ? '@'.$namespace : $namespace); + } + } + /** * The package a client asked for, if its listing is servable: not deleted and not * suspended. Names are matched lowercase, as npm and Composer require them. diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 5689bc6..5f0b80b 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -31,8 +31,10 @@ class Organization extends Model protected $hidden = ['id', 'stripe_connect_id']; + // `is_staff` is deliberately not fillable; set it with `registry:staff`. protected $casts = [ 'is_personal' => 'boolean', + 'is_staff' => 'boolean', 'verified_at' => 'datetime', 'meta' => 'array', ]; @@ -70,6 +72,11 @@ public function licences(): HasMany return $this->hasMany(Licence::class, 'organization_uuid', 'uuid'); } + public function isStaff(): bool + { + return $this->is_staff === true; + } + public function isVerified(): bool { return $this->verified_at !== null; diff --git a/config/registry.php b/config/registry.php index 2ed0c84..58e9f23 100644 --- a/config/registry.php +++ b/config/registry.php @@ -20,12 +20,10 @@ // Cache lifetime for public catalog responses, in seconds. 'catalog_cache_seconds' => (int) env('REGISTRY_CATALOG_CACHE_SECONDS', 60), - // Package namespaces reserved for the Fleetbase staff organization. + // Package namespaces (npm scope / Composer vendor) only an organization flagged + // `is_staff` may own. Grant the flag with `php artisan registry:staff `. 'reserved_scopes' => ['fleetbase'], - // Slug of the staff organization that may publish reserved scopes and review releases. - 'staff_organization' => env('REGISTRY_STAFF_ORGANIZATION', 'fleetbase'), - // Maximum uploaded bundle size in bytes (used by release checks in Workstream C). 'max_bundle_bytes' => (int) env('REGISTRY_MAX_BUNDLE_BYTES', 50 * 1024 * 1024), diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index f6cbb1a..9b259f0 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -31,6 +31,11 @@ public function verified(): static return $this->state(fn () => ['verified_at' => now()]); } + public function staff(): static + { + return $this->state(fn () => ['is_staff' => true]); + } + public function personal(): static { return $this->state(fn () => ['is_personal' => true]); diff --git a/database/migrations/2026_10_22_000002_add_is_staff_to_organizations_table.php b/database/migrations/2026_10_22_000002_add_is_staff_to_organizations_table.php new file mode 100644 index 0000000..964e5e1 --- /dev/null +++ b/database/migrations/2026_10_22_000002_add_is_staff_to_organizations_table.php @@ -0,0 +1,27 @@ +boolean('is_staff')->default(false)->after('is_personal')->index(); + }); + } + + public function down(): void + { + Schema::table('organizations', function (Blueprint $table) { + $table->dropIndex(['is_staff']); + $table->dropColumn('is_staff'); + }); + } +}; diff --git a/tests/Feature/Packages/PackageNamespaceTest.php b/tests/Feature/Packages/PackageNamespaceTest.php new file mode 100644 index 0000000..dfbcc61 --- /dev/null +++ b/tests/Feature/Packages/PackageNamespaceTest.php @@ -0,0 +1,78 @@ +create(); + + expect(fn () => $extension->packages()->create(['kind' => $kind, 'name' => $name])) + ->toThrow(ReservedPackageNamespaceException::class); + + expect(ExtensionPackage::count())->toBe(0); +})->with([ + 'npm scope' => [ExtensionPackage::KIND_NPM, '@fleetbase/fleetops-engine'], + 'npm scope, mixed case' => [ExtensionPackage::KIND_NPM, '@FleetBase/fleetops-engine'], + 'composer vendor' => [ExtensionPackage::KIND_COMPOSER, 'fleetbase/fleetops-api'], +]); + +it('lets the staff organization own reserved namespaces', function () { + $extension = Extension::factory()->for(Organization::factory()->staff())->create(); + + $npm = $extension->packages()->create(['kind' => ExtensionPackage::KIND_NPM, 'name' => '@Fleetbase/FleetOps-Engine']); + $extension->packages()->create(['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => 'fleetbase/fleetops-api']); + + expect($npm->name)->toBe('@fleetbase/fleetops-engine') + ->and(ExtensionPackage::count())->toBe(2); +}); + +it('leaves other namespaces and lookalikes alone', function (string $kind, string $name) { + $extension = Extension::factory()->create(); + + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + + expect(ExtensionPackage::count())->toBe(1); +})->with([ + [ExtensionPackage::KIND_NPM, '@acme/fleetbase-engine'], + [ExtensionPackage::KIND_NPM, '@fleetbase-community/engine'], + [ExtensionPackage::KIND_NPM, 'fleetbase-unscoped-engine'], + [ExtensionPackage::KIND_COMPOSER, 'fleetbaseplus/api'], +]); + +it('also guards renames and moves to another listing', function () { + $staffExtension = Extension::factory()->for(Organization::factory()->staff())->create(); + $package = $staffExtension->packages()->create(['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => 'fleetbase/storefront-api']); + $other = Extension::factory()->create(); + + expect(fn () => $package->update(['extension_uuid' => $other->uuid]))->toThrow(ReservedPackageNamespaceException::class); + + $mine = $other->packages()->create(['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => 'acme/storefront-api']); + expect(fn () => $mine->update(['name' => 'fleetbase/storefront-pro-api']))->toThrow(ReservedPackageNamespaceException::class); + expect($mine->fresh()->name)->toBe('acme/storefront-api'); +}); + +it('renders as a 422 on the name field', function () { + $extension = Extension::factory()->create(); + Route::post('/v1/_namespace-test', fn () => $extension->packages()->create(['kind' => 'npm', 'name' => '@fleetbase/x'])); + + $this->postJson('/v1/_namespace-test')->assertStatus(422) + ->assertJsonPath('fields.name.0', fn (string $m) => str_contains($m, "'@fleetbase' package namespace is reserved")); +}); + +it('keeps is_staff out of mass assignment and sets it with registry:staff', function () { + $organization = Organization::factory()->create(['slug' => 'fleetbase']); + + expect(fn () => Organization::create(['slug' => 'x', 'name' => 'X', 'is_staff' => true])) + ->toThrow(MassAssignmentException::class); + + $this->artisan('registry:staff', ['organization' => 'fleetbase'])->assertSuccessful(); + expect($organization->fresh()->isStaff())->toBeTrue(); + + $this->artisan('registry:staff', ['organization' => $organization->public_id, '--revoke' => true])->assertSuccessful(); + expect($organization->fresh()->isStaff())->toBeFalse(); + + $this->artisan('registry:staff', ['organization' => 'nope'])->assertFailed(); +}); From 66f5e20e3f89cc1fd081b2904ed42370a5124bea Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:49:59 +0800 Subject: [PATCH 13/16] Run npm and Composer conformance in CI and document the package endpoints The package-conformance job starts the service on SQLite with the local disk, seeds the fixture releases and runs tests/Conformance/packages.sh: npm view, pack and install, composer require with secure-http off, 401 and 403 for the paid package, and a licensed install with each client. --- .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++++ README.md | 73 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8ece8e..ee5e5ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,64 @@ jobs: - name: Run tests run: vendor/bin/pest --ci --colors=always + package-conformance: + name: npm and Composer conformance + runs-on: ubuntu-latest + needs: tests + # .env.example already runs on SQLite (database/database.sqlite) with the local disk; + # `artisan serve` reads .env rather than the job environment. + env: + PHP_CLI_SERVER_WORKERS: 4 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, intl, bcmath, zip, pdo_sqlite, sqlite3, phar, zlib + coverage: none + tools: composer:v2 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-8.3-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}-8.3- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare the service + run: | + cp .env.example .env + php artisan key:generate --ansi + touch database/database.sqlite + php artisan migrate --force + + - name: Start the service + run: | + php artisan serve --host=127.0.0.1 --port=8000 > storage/logs/serve.log 2>&1 & + for i in $(seq 1 30); do curl -sf http://127.0.0.1:8000/up > /dev/null && exit 0; sleep 1; done + cat storage/logs/serve.log; exit 1 + + - name: Run npm and Composer against seeded releases + run: tests/Conformance/packages.sh http://127.0.0.1:8000 + + - name: Service logs + if: failure() + run: | + cat storage/logs/serve.log || true + cat storage/logs/laravel.log || true + docker: name: Docker image builds runs-on: ubuntu-latest diff --git a/README.md b/README.md index 327266f..fbdcad3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Phase 1, Workstreams A and B: | Account auth: register, email verification code, login, logout, personal tokens | done | | Public catalog API (`/v1/catalog/*`) with 60 s cache | done | | Docker image, compose stack (app, queue, MySQL, MinIO, Mailpit), CI | done | -| npm packument and tarball endpoints, Composer v2 repository | next | +| Package store (`ReleaseStorage`), npm packument and tarball endpoints, Composer v2 repository, licence gate, `@fleetbase` namespace protection | done | | Releases upload, checks, review; instance API; Stripe | later workstreams | ## Stack @@ -60,6 +60,18 @@ Run the tests: composer test ``` +Seed a published free and a paid extension from the fixture tarballs in +`database/fixtures/packages`, with install tokens for a licensed and an unlicensed +organization, then drive real npm and Composer clients against them: + +```bash +php artisan registry:seed --seed-release # add --json for scripts +php artisan serve & +tests/Conformance/packages.sh http://127.0.0.1:8000 +``` + +CI runs the same script in the `package-conformance` job. + ## API overview All routes live under `/v1`. Errors use the Fleetbase envelope `{"errors": ["..."]}`; @@ -101,21 +113,76 @@ match either side), `category=[,…]`, `publisher=`, `tag= [--revoke]`). + ## Repository layout ``` app/ Auth/TokenGuard.php bearer-token guard (auth:token) Casts/Money.php minor-unit money cast + Console/Commands/ registry:seed, registry:staff + Http/Controllers/Packages/ npm and Composer endpoints Http/Filter/ request-driven query filters (ExtensionFilter) + Http/Middleware/ scope:…, licence:npm|composer Http/Resources/ Fleetbase-style JSON resources Models/ one model per table; Model.php is the base - Support/ Http and Utils helpers + Services/ ReleaseStorage, LicenceGate, Packages/ (packument, Composer metadata) + Support/ Http, Utils, Semver, Tarball helpers +database/fixtures/packages/ fixture tarballs for registry:seed --seed-release database/migrations/ the data model database/seeders/ CategorySeeder (41 categories from registry-bridge) docker/ compose helpers legacy/ the Verdaccio registry (v1) -tests/Feature/{Auth,Catalog} Pest feature tests (SQLite in memory) +tests/Feature/{Auth,Catalog,Packages} Pest feature tests (SQLite in memory) +tests/Conformance/packages.sh real npm and Composer clients against a running service ``` ## Deployment From 51d85fa7916427f57ad8faa4e81bf4bb13f07cae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 13:57:44 +0800 Subject: [PATCH 14/16] Add registry:import for the cloud registry-bridge tables Reads a MySQL DSN, a sqlite:// file or a .sql/.sql.gz dump (loaded into a temporary SQLite file, no MySQL server needed) and maps registry extensions, bundles, purchases, installs, developer accounts, cloud registry users, companies and categories per the plan's mapping rules. Rows are keyed by source_uuid so re-runs update in place. --dry-run runs the same mapping in a rolled-back transaction and prints counts plus an anomaly report. Bundle archives, icons and screenshots are copied with SHA-256 checksums; releases without a retrievable tarball import as yanked. Documents the instance API and the importer in the README. --- .env.example | 7 + README.md | 105 +- app/Console/Commands/ImportRegistry.php | 105 ++ app/Import/ImportReport.php | 87 ++ app/Import/IncompleteStatement.php | 8 + app/Import/RegistryImporter.php | 1290 +++++++++++++++++++ app/Import/SourceDatabase.php | 107 ++ app/Import/SqlDumpLoader.php | 340 +++++ app/Models/File.php | 3 + config/filesystems.php | 13 + config/registry.php | 5 + tests/Feature/Import/RegistryImportTest.php | 347 +++++ tests/Fixtures/import/cloud-registry.sql | 339 +++++ tests/Unit/SqlDumpLoaderTest.php | 59 + 14 files changed, 2810 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/ImportRegistry.php create mode 100644 app/Import/ImportReport.php create mode 100644 app/Import/IncompleteStatement.php create mode 100644 app/Import/RegistryImporter.php create mode 100644 app/Import/SourceDatabase.php create mode 100644 app/Import/SqlDumpLoader.php create mode 100644 tests/Feature/Import/RegistryImportTest.php create mode 100644 tests/Fixtures/import/cloud-registry.sql create mode 100644 tests/Unit/SqlDumpLoaderTest.php diff --git a/.env.example b/.env.example index 71887f1..9cbfd78 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,13 @@ AWS_ENDPOINT= AWS_URL= AWS_USE_PATH_STYLE_ENDPOINT=false +# registry:import reads cloud bundle and icon objects from this disk (an s3 disk by default). +REGISTRY_IMPORT_SOURCE_DISK=import_source +IMPORT_SOURCE_AWS_BUCKET= +# IMPORT_SOURCE_AWS_ACCESS_KEY_ID= +# IMPORT_SOURCE_AWS_SECRET_ACCESS_KEY= +# IMPORT_SOURCE_AWS_DEFAULT_REGION= + # Registry REGISTRY_VERSION=2.0.0-dev REGISTRY_CATALOG_CACHE_SECONDS=60 diff --git a/README.md b/README.md index 327266f..92182a6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ there until the publishing cutover. ## Status -Phase 1, Workstreams A and B: +Phase 1, Workstreams A to C: | Area | State | | --- | --- | @@ -19,8 +19,10 @@ Phase 1, Workstreams A and B: | Account auth: register, email verification code, login, logout, personal tokens | done | | Public catalog API (`/v1/catalog/*`) with 60 s cache | done | | Docker image, compose stack (app, queue, MySQL, MinIO, Mailpit), CI | done | +| Instance API: registration codes, register, me, updates, installs, licence seats | done | +| `registry:import` from the cloud registry-bridge tables (dry run, anomaly report, tarball copy) | done | | npm packument and tarball endpoints, Composer v2 repository | next | -| Releases upload, checks, review; instance API; Stripe | later workstreams | +| Releases upload, checks, review; Stripe | later workstreams | ## Stack @@ -79,6 +81,7 @@ validation failures add a `fields` map. Resources are returned unwrapped; paged | `GET /v1/account/tokens` | The caller's tokens, masked (`flbp_…a1b2`) | | `POST /v1/account/tokens` | name, scopes (`read`, `publish`, `install`, `instance:register`, `*`), expires_in_days (max 365), optional organization. Plaintext returned once | | `DELETE /v1/account/tokens/{id}` | Revoke | +| `POST /v1/account/instance-codes` | Optional organization (slug or id). Mints a single-use instance registration code (`XXXX-XXXX-XXXX-XXXX`) valid 15 minutes. Needs the `instance:register` scope and the owner or admin role; defaults to the token's organization, else the personal one | Tokens are `flbp_` + 40 random characters, stored as SHA-256, expiring, scoped and revocable. Send them as `Authorization: Bearer flbp_…`. Passwords are bcrypt hashes checked with @@ -101,21 +104,113 @@ match either side), `category=[,…]`, `publisher=`, `tag=", "api": ""}`, + npm/Composer range syntax, evaluated with composer/semver). A side with no range accepts any + version; an instance that does not report a version, or reports one that is not semver, + is not constrained on that side; a range that does not parse never matches. + +## Importing from Fleetbase Cloud + +`registry:import` copies the cloud registry-bridge tables into the service, following the +plan's mapping rules. It reads `registry_extensions`, `registry_extension_bundles`, +`registry_extension_purchases`, `registry_extension_installs`, `registry_users`, +`registry_developer_accounts`, `users`, `companies`, `files` and `categories`. + +```bash +# Rehearse: counts and anomalies, then roll back. Nothing is written and no file is copied. +php artisan registry:import --from=/backups/fleetbase-cloud.sql.gz --dry-run + +# Import, keeping the full anomaly list for the publisher follow-up. +php artisan registry:import --from=mysql://reader:secret@cloud-replica:3306/fleetbase --report=import-report.json +``` + +| Option | Meaning | +| --- | --- | +| `--from` | `mysql://user:pass@host:3306/db` (or `mariadb://`), `sqlite:///path/file.sqlite`, or a path to a `.sql` / `.sql.gz` dump. Dumps are loaded into a temporary SQLite file without a MySQL server; mysqldump's extended inserts and one-tuple-per-line dumps both work, and statements for other tables are skipped | +| `--dry-run` | Runs the whole mapping in a transaction that is rolled back, so the counts and anomalies are exactly what a real run would produce | +| `--source-disk` | Filesystem disk holding the cloud `files` objects. Default `REGISTRY_IMPORT_SOURCE_DISK` (`import_source`, an S3 disk configured by `IMPORT_SOURCE_AWS_*`) | +| `--report` | Also writes the full report, every anomaly included, as JSON | +| `--examples` | Anomaly examples printed per type (default 10) | + +What it writes: + +| Cloud | Service | Rule | +| --- | --- | --- | +| `registry_developer_accounts` | `accounts`, personal `organizations`, `memberships` (owner) | Status kept (`active`, `suspended`, `pending_verification`); bcrypt hash copied | +| `companies` | `organizations` | One per company the registry tables reference (publishers, registry users, buyers, installers); `stripe_connect_id` copied | +| `registry_users` (cloud) + `users` | `accounts`, `memberships` | One account per user (registry_users has one row per token); owner if the user owns the company, else publisher; password hash copied; tokens not migrated | +| `categories` | `categories` | Matched to seeded categories by `internal_id`, then slug; others created | +| `registry_extensions` | `extensions`, `prices`, `extension_packages` | Package names from the current bundle's `package.json` and `composer.json`, then older bundles, then the Phase 0 `package_name` column; listings with no bundle and no package name become drafts; `self_managed` maps to hosting `self_hosted`; icon and screenshots copied | +| `registry_extension_bundles` | `releases` | Version from `extension.json`; `bundle_number` kept in `manifest`; status pending → uploaded, awaiting_review → in_review, approved, published, rejected; `compat` from `extension.json` when present. The bundle archive is copied to `tarballs/imported/…` as a `source_bundle` file with a SHA-256 checksum; a bundle whose file is missing imports as `yanked` | +| `registry_extension_purchases` | `purchases`, `licences` | One paid licence per purchase with 5 seats; a soft-deleted purchase revokes its licence | +| `registry_extension_installs` | `instances`, `installs`, `licence_activations` | One synthetic cloud instance per company (no token); soft-deleted installs become `uninstalled`; live installs claim a seat on the paid licence or an automatic free licence | + +Idempotency: every imported row stores the uuid of its source row in `source_uuid`, and +re-runs match on it and update in place; an unchanged source produces only "unchanged" +counts. Slugs, usernames and paid seat counts are set on first import and then left alone. +Tarballs already copied with a checksum are not copied again; a release imported as yanked +is restored when its tarball becomes retrievable. An account whose email already belongs to +another account (created in the service, or imported from the other source table) is mapped +onto it and never overwritten. + +The anomaly report lists: extensions without package names or bundles, bundles whose +tarball is missing (with the reason), duplicate package names (the first live, published +listing keeps the name), users without emails, accounts merged by email, bundles without a +version or repeating a version, rows that reference missing companies, users or extensions, +installs with no usable licence, renamed slugs, unknown statuses, subscription pricing, and +icons that could not be copied. + ## Repository layout ``` app/ - Auth/TokenGuard.php bearer-token guard (auth:token) + Actions/ ActivateLicence, DeactivateLicence, RegisterAccount + Auth/TokenGuard.php bearer-token guard (auth:token, auth:instance) Casts/Money.php minor-unit money cast + Console/Commands/ registry:import + Import/ importer: SQL dump loader, source connection, mapping, report Http/Filter/ request-driven query filters (ExtensionFilter) Http/Resources/ Fleetbase-style JSON resources Models/ one model per table; Model.php is the base - Support/ Http and Utils helpers + Support/ Http, Utils and Compat (semver ranges) helpers database/migrations/ the data model database/seeders/ CategorySeeder (41 categories from registry-bridge) docker/ compose helpers legacy/ the Verdaccio registry (v1) -tests/Feature/{Auth,Catalog} Pest feature tests (SQLite in memory) +tests/Feature/{Auth,Catalog,Instance,Import} Pest feature tests (SQLite in memory) +tests/Fixtures/import/ mysqldump fixture of the cloud registry tables ``` ## Deployment diff --git a/app/Console/Commands/ImportRegistry.php b/app/Console/Commands/ImportRegistry.php new file mode 100644 index 0000000..c4a58b3 --- /dev/null +++ b/app/Console/Commands/ImportRegistry.php @@ -0,0 +1,105 @@ +option('from'); + if ($from === '') { + $this->error('--from is required: a mysql:// or sqlite:// DSN, or a path to a SQL dump.'); + + return self::INVALID; + } + + $dryRun = (bool) $this->option('dry-run'); + $sourceDisk = $this->option('source-disk') ?: config('registry.import.source_disk'); + $targetDisk = config('registry.storage.disk'); + $source = new SourceDatabase($from); + + $this->line(sprintf('%s from %s', $dryRun ? 'Dry run' : 'Importing', $source->describe())); + $this->line("Files: {$sourceDisk} → {$targetDisk}"); + + try { + $connection = $source->open(); + $report = (new RegistryImporter($connection, $sourceDisk, $targetDisk, $dryRun))->run(); + } catch (\Throwable $e) { + $this->error('Import failed: '.$e->getMessage()); + + return self::FAILURE; + } finally { + $source->close(); + } + + $this->printReport($report, max(0, (int) $this->option('examples'))); + + if (! $dryRun) { + Event::record('registry.imported', null, null, [ + 'from' => $source->describe(), + 'counts' => $report->counts, + 'anomalies' => array_map('count', $report->anomalies), + ], '127.0.0.1'); + } + + if ($path = $this->option('report')) { + file_put_contents($path, json_encode($report->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $this->line("Full report written to {$path}"); + } + + $this->newLine(); + $this->line($dryRun ? 'Dry run: nothing was written.' : 'Import complete.'); + + return self::SUCCESS; + } + + protected function printReport(ImportReport $report, int $examples): void + { + $this->newLine(); + $this->line('Source rows read'); + $this->table(['Table', 'Rows'], collect($report->source)->map(fn ($n, $t) => [$t, $n])->values()->all()); + + $this->line(''.($report->dryRun ? 'Would write' : 'Written').''); + $outcomes = [ImportReport::CREATED, ImportReport::UPDATED, ImportReport::UNCHANGED, ImportReport::SKIPPED]; + $this->table( + array_merge(['Table'], array_map('ucfirst', $outcomes)), + collect($report->counts)->sortKeys()->map(fn ($counts, $table) => array_merge([$table], array_map(fn ($o) => $counts[$o] ?? 0, $outcomes)))->values()->all() + ); + + $this->line('Anomalies ('.$report->anomalyCount().')'); + if ($report->anomalyCount() === 0) { + $this->line(' none'); + + return; + } + + foreach ($report->anomalies as $type => $items) { + $this->newLine(); + $this->line(sprintf(' %s: %d', ImportReport::ANOMALIES[$type] ?? $type, count($items))); + foreach (array_slice($items, 0, $examples) as $item) { + $this->line(' - '.collect($item)->map(fn ($v, $k) => $k.'='.(is_scalar($v) || $v === null ? var_export($v, true) : json_encode($v)))->implode(', ')); + } + if (count($items) > $examples) { + $this->line(' … '.(count($items) - $examples).' more (use --report for all)'); + } + } + } +} diff --git a/app/Import/ImportReport.php b/app/Import/ImportReport.php new file mode 100644 index 0000000..7f12276 --- /dev/null +++ b/app/Import/ImportReport.php @@ -0,0 +1,87 @@ + rows read per source table */ + public array $source = []; + + /** @var array> target table => outcome => count */ + public array $counts = []; + + /** @var array>> anomaly type => items */ + public array $anomalies = []; + + /** @var array anomaly type => human description */ + public const ANOMALIES = [ + 'extension_without_packages' => 'Extensions without package names or bundles', + 'tarball_missing' => 'Bundles whose tarball path is missing (imported as yanked)', + 'duplicate_package_name' => 'Duplicate package names (first listing keeps the name)', + 'user_without_email' => 'Users without emails (not imported)', + 'merged_account' => 'Accounts merged by email across cloud users and developer accounts', + 'missing_reference' => 'Rows that reference a missing company, user, extension or developer account', + 'bundle_without_version' => 'Bundles without a version (not imported)', + 'duplicate_version' => 'Bundles that repeat a version of the same extension (older ones not imported)', + 'slug_renamed' => 'Extension or organization slugs changed to stay unique', + 'unknown_status' => 'Unknown statuses (mapped to a safe default)', + 'subscription' => 'Subscription pricing or purchases (imported as one-time)', + 'install_without_licence' => 'Installs with no usable licence (install imported, no activation)', + 'duplicate_install' => 'Several live installs of one extension by one company (older ones marked uninstalled)', + 'duplicate_purchase' => 'Repeat purchases of one extension by one buyer (each imported with its own licence)', + 'icon_missing' => 'Icons or screenshots whose file could not be retrieved', + 'username_taken' => 'Usernames already taken by another account (left empty)', + 'conflict' => 'Rows that conflict with records created in the service (not imported)', + ]; + + public function __construct(public readonly bool $dryRun = false) {} + + public function count(string $table, string $outcome, int $by = 1): void + { + $this->counts[$table][$outcome] = ($this->counts[$table][$outcome] ?? 0) + $by; + } + + public function anomaly(string $type, array $item): void + { + $this->anomalies[$type][] = $item; + } + + public function anomalyCount(?string $type = null): int + { + if ($type !== null) { + return count($this->anomalies[$type] ?? []); + } + + return array_sum(array_map('count', $this->anomalies)); + } + + public function total(string $table, string $outcome): int + { + return $this->counts[$table][$outcome] ?? 0; + } + + public function toArray(): array + { + return [ + 'dry_run' => $this->dryRun, + 'source' => $this->source, + 'counts' => $this->counts, + 'anomalies' => collect($this->anomalies)->map(fn ($items, $type) => [ + 'description' => self::ANOMALIES[$type] ?? $type, + 'count' => count($items), + 'items' => $items, + ])->all(), + ]; + } +} diff --git a/app/Import/IncompleteStatement.php b/app/Import/IncompleteStatement.php new file mode 100644 index 0000000..d126625 --- /dev/null +++ b/app/Import/IncompleteStatement.php @@ -0,0 +1,8 @@ + */ + protected Collection $extensions; + + /** @var Collection */ + protected Collection $bundles; + + /** @var Collection */ + protected Collection $purchases; + + /** @var Collection */ + protected Collection $installs; + + /** @var Collection */ + protected Collection $registryUsers; + + /** @var Collection */ + protected Collection $developers; + + /** @var Collection */ + protected Collection $companies; + + /** @var Collection */ + protected Collection $users; + + /** @var Collection */ + protected Collection $files; + + /** @var Collection> extension uuid => screenshot file rows */ + protected Collection $screenshots; + + /** @var Collection */ + protected Collection $categories; + + /** @var array source category uuid => category uuid */ + protected array $categoryMap = []; + + /** @var array company uuid => organization */ + protected array $companyOrganizations = []; + + /** @var array developer account uuid => personal organization */ + protected array $developerOrganizations = []; + + /** @var array developer account uuid => account */ + protected array $developerAccounts = []; + + /** @var array user uuid => account */ + protected array $userAccounts = []; + + /** @var array source extension uuid => extension */ + protected array $extensionMap = []; + + /** @var array source bundle uuid => release */ + protected array $releaseMap = []; + + /** @var array company uuid => synthetic instance */ + protected array $instances = []; + + public function __construct( + protected Connection $source, + protected string $sourceDisk, + protected string $targetDisk, + protected bool $dryRun = false, + ) { + $this->report = new ImportReport($dryRun); + } + + public function run(): ImportReport + { + $this->loadSource(); + + DB::beginTransaction(); + + try { + $this->importCategories(); + $this->importDeveloperAccounts(); + $this->importCompanies(); + $this->importCloudUsers(); + $this->importExtensions(); + $this->importReleases(); + $this->importPurchases(); + $this->importInstalls(); + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; + } + + $this->dryRun ? DB::rollBack() : DB::commit(); + + return $this->report; + } + + // --------------------------------------------------------------------- + // Source + // --------------------------------------------------------------------- + + protected function loadSource(): void + { + $this->extensions = $this->rows('registry_extensions'); + $this->bundles = $this->rows('registry_extension_bundles'); + $this->purchases = $this->rows('registry_extension_purchases'); + $this->installs = $this->rows('registry_extension_installs'); + $this->registryUsers = $this->rows('registry_users'); + $this->developers = $this->rows('registry_developer_accounts'); + + $companyUuids = collect() + ->merge($this->extensions->pluck('company_uuid')) + ->merge($this->extensions->filter(fn ($e) => ($e->publisher_type ?? 'cloud') !== 'developer')->pluck('publisher_uuid')) + ->merge($this->registryUsers->pluck('company_uuid')) + ->merge($this->purchases->pluck('company_uuid')) + ->merge($this->purchases->filter(fn ($p) => $this->purchaserKind($p) === 'company')->pluck('purchaser_uuid')) + ->merge($this->installs->pluck('company_uuid')); + $this->companies = $this->rowsWhereIn('companies', 'uuid', $companyUuids); + + $userUuids = collect() + ->merge($this->registryUsers->pluck('user_uuid')) + ->merge($this->extensions->pluck('created_by_uuid')) + ->merge($this->bundles->pluck('created_by_uuid')); + $this->users = $this->rowsWhereIn('users', 'uuid', $userUuids); + + $this->files = $this->rowsWhereIn('files', 'uuid', collect() + ->merge($this->extensions->pluck('icon_uuid')) + ->merge($this->bundles->pluck('bundle_uuid'))); + + $this->screenshots = $this->hasTable('files') && $this->extensions->isNotEmpty() + ? $this->rowsWhereIn('files', 'subject_uuid', $this->extensions->keys(), false) + ->filter(fn ($f) => ($f->type ?? null) === 'extension_screenshot') + ->groupBy('subject_uuid') + : collect(); + + $this->categories = $this->rowsWhereIn('categories', 'uuid', $this->extensions->pluck('category_uuid')); + } + + protected function hasTable(string $table): bool + { + return $this->source->getSchemaBuilder()->hasTable($table); + } + + /** + * @return Collection + */ + protected function rows(string $table): Collection + { + if (! $this->hasTable($table)) { + $this->report->source[$table] = 0; + + return collect(); + } + + $rows = $this->source->table($table)->orderBy('id')->get()->keyBy('uuid'); + $this->report->source[$table] = $rows->count(); + + return $rows; + } + + /** + * @return Collection + */ + protected function rowsWhereIn(string $table, string $column, Collection $values, bool $keyByUuid = true): Collection + { + $values = $values->filter()->unique()->values(); + if (! $this->hasTable($table) || $values->isEmpty()) { + $this->report->source[$table] = $this->report->source[$table] ?? 0; + + return collect(); + } + + $rows = collect(); + foreach ($values->chunk(500) as $chunk) { + $rows = $rows->merge($this->source->table($table)->whereIn($column, $chunk->all())->get()); + } + + $this->report->source[$table] = ($this->report->source[$table] ?? 0) + $rows->count(); + + return $keyByUuid ? $rows->keyBy('uuid') : $rows; + } + + // --------------------------------------------------------------------- + // Categories + // --------------------------------------------------------------------- + + protected function importCategories(): void + { + foreach ($this->categories as $row) { + $category = Category::withTrashed()->where('source_uuid', $row->uuid)->first() + ?? ($row->internal_id ? Category::withTrashed()->where('internal_id', $row->internal_id)->first() : null) + ?? ($row->slug ? Category::withTrashed()->where('slug', $row->slug)->first() : null); + + if ($category) { + // Seeded categories keep their own names; only the link to the source is recorded. + $this->save($category, ['source_uuid' => $row->uuid], 'categories'); + } else { + $category = $this->save(new Category, [ + 'source_uuid' => $row->uuid, + 'slug' => $this->uniqueSlug(Category::class, $row->slug ?: $row->name ?: 'category'), + 'name' => $row->name ?: 'Uncategorised', + 'internal_id' => $row->internal_id, + 'description' => $row->description ?? null, + 'icon' => $row->icon ?? null, + 'tags' => $this->json($row->tags ?? null), + ], 'categories'); + } + + $this->categoryMap[$row->uuid] = $category->uuid; + } + } + + // --------------------------------------------------------------------- + // Accounts, organizations, memberships + // --------------------------------------------------------------------- + + /** + * `registry_developer_accounts` → account + personal organization (owner). + */ + protected function importDeveloperAccounts(): void + { + foreach ($this->developers as $row) { + if (blank($row->email ?? null)) { + $this->report->anomaly('user_without_email', ['source' => 'registry_developer_accounts', 'uuid' => $row->uuid, 'username' => $row->username ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + $status = match ($row->status ?? null) { + 'active' => Account::STATUS_ACTIVE, + 'suspended' => Account::STATUS_SUSPENDED, + 'pending_verification' => Account::STATUS_PENDING_VERIFICATION, + default => null, + }; + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_developer_accounts', 'uuid' => $row->uuid, 'status' => $row->status ?? null, 'mapped_to' => Account::STATUS_PENDING_VERIFICATION]); + $status = Account::STATUS_PENDING_VERIFICATION; + } + + $account = $this->importAccount($row->uuid, 'registry_developer_accounts', [ + 'email' => Str::lower(trim($row->email)), + 'name' => $row->name ?? null, + 'username' => $row->username ?? null, + 'avatar_url' => $row->avatar_url ?? null, + 'email_verified_at' => $this->date($row->email_verified_at ?? null), + 'status' => $status, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter([ + 'source' => 'registry_developer_accounts', + 'github_username' => $row->github_username ?? null, + ]), + ], $row->password ?? null); + + $this->developerAccounts[$row->uuid] = $account; + + $organization = Organization::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Organization; + $organization = $this->save($organization, [ + 'source_uuid' => $row->uuid, + 'slug' => $organization->slug ?? $this->uniqueSlug(Organization::class, $row->username ?: Str::before($row->email, '@')), + 'name' => $row->name ?: $row->username, + 'website' => $row->website ?? null, + 'bio' => $row->bio ?? null, + 'avatar_url' => $row->avatar_url ?? null, + 'is_personal' => true, + 'created_at' => $this->date($row->created_at ?? null), + 'meta' => ['source' => 'registry_developer_accounts'], + ], 'organizations'); + + $this->developerOrganizations[$row->uuid] = $organization; + $this->membership($organization, $account, Membership::ROLE_OWNER, $row->uuid); + } + } + + /** + * Every company the registry tables reference → organization. Companies are the + * publishers, buyers and installers of the cloud registry. + */ + protected function importCompanies(): void + { + foreach ($this->companies as $row) { + $organization = Organization::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Organization; + $name = $row->name ?: 'Company '.($row->public_id ?? Str::limit($row->uuid, 8, '')); + + $this->companyOrganizations[$row->uuid] = $this->save($organization, [ + 'source_uuid' => $row->uuid, + 'slug' => $organization->slug ?? $this->uniqueSlug(Organization::class, $row->slug ?: $name), + 'name' => $name, + 'website' => $row->website_url ?? null, + 'bio' => $row->description ?? null, + 'is_personal' => false, + 'stripe_connect_id' => $row->stripe_connect_id ?? null, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter(['source' => 'companies', 'public_id' => $row->public_id ?? null]), + ], 'organizations'); + } + } + + /** + * Cloud `registry_users` (one row per token) + `users` → one account per user and a + * membership in the company's organization: owner for the company owner, else publisher. + */ + protected function importCloudUsers(): void + { + $pairs = $this->registryUsers + ->filter(fn ($r) => ($r->account_type ?? 'cloud') !== 'developer') + ->unique(fn ($r) => ($r->company_uuid ?? '').'|'.($r->user_uuid ?? '')); + + foreach ($pairs as $row) { + $user = $this->users->get($row->user_uuid ?? ''); + $organization = $this->companyOrganizations[$row->company_uuid ?? ''] ?? null; + + if (! $user) { + $this->report->anomaly('missing_reference', ['table' => 'registry_users', 'uuid' => $row->uuid, 'missing' => 'users', 'reference' => $row->user_uuid ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + if (blank($user->email ?? null)) { + $this->report->anomaly('user_without_email', ['source' => 'users', 'uuid' => $user->uuid, 'name' => $user->name ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + $account = $this->userAccounts[$user->uuid] ??= $this->importAccount($user->uuid, 'users', [ + 'email' => Str::lower(trim($user->email)), + 'name' => $user->name ?? null, + 'username' => $user->username ?? null, + 'email_verified_at' => $this->date($user->email_verified_at ?? null), + 'status' => Account::STATUS_ACTIVE, + 'timezone' => $user->timezone ?? null, + 'created_at' => $this->date($user->created_at ?? null), + 'meta' => array_filter(['source' => 'users', 'public_id' => $user->public_id ?? null]), + ], $user->password ?? null); + + if (! $organization) { + $this->report->anomaly('missing_reference', ['table' => 'registry_users', 'uuid' => $row->uuid, 'missing' => 'companies', 'reference' => $row->company_uuid ?? null]); + + continue; + } + + $owner = ($this->companies->get($row->company_uuid)->owner_uuid ?? null) === $user->uuid; + $this->membership($organization, $account, $owner ? Membership::ROLE_OWNER : Membership::ROLE_PUBLISHER, $row->uuid); + } + } + + /** + * Find or create the account for a source person, matched on `source_uuid`. A person + * whose email already belongs to another account (created in the service, or imported + * from the other source table) is mapped onto that account without changing it. + * Password hashes are copied verbatim (bcrypt, as core-api). + */ + protected function importAccount(string $sourceUuid, string $sourceTable, array $attributes, ?string $passwordHash): Account + { + $account = Account::withTrashed()->where('source_uuid', $sourceUuid)->first(); + + if (! $account && ($existing = Account::withTrashed()->where('email', $attributes['email'])->first())) { + // Same email as an account created in the service or imported from another + // source: map onto it and leave it untouched. + $this->report->anomaly('merged_account', [ + 'email' => $attributes['email'], 'source' => $sourceTable, 'uuid' => $sourceUuid, + 'into' => $existing->source_uuid === null ? 'service account' : 'imported account', + ]); + $this->report->count('accounts', ImportReport::UNCHANGED); + + return $existing; + } + + $account ??= new Account; + + // Usernames are set once; a taken username is left empty rather than renamed. + $username = $attributes['username'] ?? null; + unset($attributes['username']); + if ($account->exists) { + $username = $account->username; + } elseif ($username !== null && Account::withTrashed()->where('username', $username)->exists()) { + $this->report->anomaly('username_taken', ['username' => $username, 'source' => $sourceTable, 'uuid' => $sourceUuid]); + $username = null; + } + + $account->forceFill(array_merge($attributes, ['source_uuid' => $sourceUuid, 'username' => $username])); + + // Bypass the `hashed` cast: the hash is copied as-is and its cost may exceed BCRYPT_ROUNDS. + $account->setRawAttributes(array_merge($account->getAttributes(), ['password' => $passwordHash ?: null])); + + return $this->persist($account, 'accounts'); + } + + protected function membership(Organization $organization, Account $account, string $role, string $sourceUuid): void + { + $membership = Membership::query() + ->where('organization_uuid', $organization->uuid) + ->where('account_uuid', $account->uuid) + ->first(); + + if ($membership && $membership->source_uuid === null) { + // Created in the service; leave it alone. + $this->report->count('memberships', ImportReport::UNCHANGED); + + return; + } + + $membership ??= new Membership; + $membership->forceFill([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'role' => $role, + 'source_uuid' => $sourceUuid, + ]); + + $this->persist($membership, 'memberships'); + } + + // --------------------------------------------------------------------- + // Extensions, prices, packages, icons + // --------------------------------------------------------------------- + + protected function importExtensions(): void + { + $claimed = [ExtensionPackage::KIND_NPM => [], ExtensionPackage::KIND_COMPOSER => []]; + + // Live and published listings first, so they win package names and slugs. + $ordered = $this->extensions->sortBy(fn ($e) => [ + $this->date($e->deleted_at ?? null) ? 1 : 0, + ($e->status ?? null) === 'published' ? 0 : 1, + (string) ($e->created_at ?? ''), + (int) ($e->id ?? 0), + ]); + + foreach ($ordered as $row) { + $organization = $this->publisherOrganization($row); + if (! $organization) { + $this->report->anomaly('missing_reference', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'name' => $row->name, 'missing' => 'publisher', 'reference' => $row->publisher_uuid ?? $row->company_uuid ?? null]); + $this->report->count('extensions', ImportReport::SKIPPED); + + continue; + } + + $extensionBundles = $this->bundles->where('extension_uuid', $row->uuid); + $packages = $this->packageNames($row, $extensionBundles); + + $status = $this->mapExtensionStatus($row); + if ($extensionBundles->isEmpty() && $packages === []) { + $status = Extension::STATUS_DRAFT; + } + if ($packages === []) { + $this->report->anomaly('extension_without_packages', [ + 'uuid' => $row->uuid, 'name' => $row->name, 'bundles' => $extensionBundles->count(), 'imported_status' => $status, + ]); + } + + if ($this->bool($row->subscription_required ?? false)) { + $this->report->anomaly('subscription', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'name' => $row->name]); + } + + $extension = Extension::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Extension; + $slug = $extension->slug; + if ($slug === null) { + $base = Str::slug($row->slug ?: $row->name) ?: 'extension'; + $slug = $this->uniqueSlug(Extension::class, $base); + if ($slug !== $base) { + $this->report->anomaly('slug_renamed', ['table' => 'extensions', 'uuid' => $row->uuid, 'from' => $base, 'to' => $slug]); + } + } + + $extension = $this->save($extension, [ + 'source_uuid' => $row->uuid, + 'organization_uuid' => $organization->uuid, + 'category_uuid' => $this->categoryMap[$row->category_uuid ?? ''] ?? null, + 'created_by_account_uuid' => ($this->userAccounts[$row->created_by_uuid ?? ''] ?? null)?->uuid, + 'slug' => $slug, + 'name' => $row->name, + 'subtitle' => $row->subtitle ?? null, + 'description' => $row->description ?? null, + 'promotional_text' => $row->promotional_text ?? null, + 'tags' => $this->json($row->tags ?? null), + 'languages' => $this->json($row->languages ?? null), + 'hosting' => $this->bool($row->self_managed ?? false) ? Extension::HOSTING_SELF_HOSTED : Extension::HOSTING_BOTH, + 'status' => $status, + 'website_url' => $row->website_url ?? null, + 'repo_url' => $row->repo_url ?? null, + 'support_url' => $row->support_url ?? null, + 'privacy_url' => $row->privacy_policy_url ?? null, + 'tos_url' => $row->tos_url ?? null, + 'copyright' => $row->copyright ?? null, + 'fa_icon' => $row->fa_icon ?? null, + 'published_at' => $this->date($row->published_at ?? null), + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter([ + 'source' => array_filter([ + 'public_id' => $row->public_id ?? null, + 'status' => $row->status ?? null, + 'core_extension' => $this->bool($row->core_extension ?? false) ?: null, + 'primary_language' => $row->primary_language ?? null, + ]), + ]), + ], 'extensions'); + + $this->extensionMap[$row->uuid] = $extension; + + $this->importPrice($row, $extension); + $this->importPackages($row, $extension, $packages, $claimed); + $this->importIcon($row, $extension); + $this->importScreenshots($row, $extension); + } + } + + protected function publisherOrganization(object $row): ?Organization + { + if (($row->publisher_type ?? 'cloud') === 'developer') { + return $this->developerOrganizations[$row->publisher_uuid ?? ''] ?? null; + } + + return $this->companyOrganizations[($row->publisher_uuid ?? null) ?: ($row->company_uuid ?? '')] ?? null; + } + + /** + * npm and Composer names from the current bundle's package.json / composer.json, then + * from the newest bundle that has them, then from the Phase 0 `package_name` column. + * + * @return array kind => name + */ + protected function packageNames(object $row, Collection $bundles): array + { + $candidates = collect([$bundles->get($row->current_bundle_uuid ?? '')]) + ->merge($bundles->sortByDesc(fn ($b) => [(string) ($b->created_at ?? ''), (int) ($b->id ?? 0)])) + ->filter(); + + $names = []; + foreach ($candidates as $bundle) { + $meta = $this->json($bundle->meta ?? null) ?? []; + $names[ExtensionPackage::KIND_NPM] ??= $this->bundleFile($meta, 'package.json', 'name') ?: null; + $names[ExtensionPackage::KIND_COMPOSER] ??= $this->bundleFile($meta, 'composer.json', 'name') ?: null; + } + + $legacy = trim((string) ($row->package_name ?? '')); + if ($legacy !== '') { + $kind = str_starts_with($legacy, '@') || ! str_contains($legacy, '/') ? ExtensionPackage::KIND_NPM : ExtensionPackage::KIND_COMPOSER; + $names[$kind] ??= $legacy; + } + + return array_filter($names); + } + + protected function mapExtensionStatus(object $row): string + { + $status = match ($row->status ?? null) { + 'pending' => Extension::STATUS_DRAFT, + 'awaiting_review', 'in_review' => Extension::STATUS_IN_REVIEW, + 'approved' => Extension::STATUS_APPROVED, + 'rejected' => Extension::STATUS_REJECTED, + 'published' => Extension::STATUS_PUBLISHED, + 'unpublished' => Extension::STATUS_UNPUBLISHED, + default => null, + }; + + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'status' => $row->status ?? null, 'mapped_to' => Extension::STATUS_DRAFT]); + } + + return $status ?? Extension::STATUS_DRAFT; + } + + protected function importPrice(object $row, Extension $extension): void + { + $amount = (int) ($row->price ?? 0); + $paid = $this->bool($row->payment_required ?? false) && $amount > 0; + $price = Price::query()->where('extension_uuid', $extension->uuid)->first() ?? new Price; + + $this->save($price, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'model' => $paid ? Price::MODEL_ONE_TIME : Price::MODEL_FREE, + 'amount' => $paid ? $amount : 0, + 'currency' => Str::upper($row->currency ?? '') ?: 'USD', + 'sale_amount' => ($row->sale_price ?? null) !== null ? (int) $row->sale_price : null, + 'on_sale' => $this->bool($row->on_sale ?? false), + 'stripe_product_id' => $row->stripe_product_id ?? null, + ], 'prices'); + } + + /** + * @param array $packages + * @param array> $claimed + */ + protected function importPackages(object $row, Extension $extension, array $packages, array &$claimed): void + { + foreach ($packages as $kind => $name) { + $owner = $claimed[$kind][$name] ?? null; + $existing = ExtensionPackage::query()->where('kind', $kind)->where('name', $name)->first(); + + if (($owner !== null && $owner !== $extension->uuid) || ($existing && $existing->extension_uuid !== $extension->uuid)) { + $this->report->anomaly('duplicate_package_name', [ + 'kind' => $kind, 'name' => $name, 'extension' => $row->name, 'uuid' => $row->uuid, + 'kept_by' => Extension::withTrashed()->find($owner ?? $existing?->extension_uuid)?->slug, + ]); + $this->report->count('extension_packages', ImportReport::SKIPPED); + + continue; + } + + $claimed[$kind][$name] = $extension->uuid; + + $this->save($existing ?? new ExtensionPackage, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'kind' => $kind, + 'name' => $name, + ], 'extension_packages'); + } + } + + protected function importIcon(object $row, Extension $extension): void + { + if (blank($row->icon_uuid ?? null)) { + return; + } + + $file = $this->copyFile($this->files->get($row->icon_uuid), File::TYPE_ICON, 'icons', $extension); + if (! $file) { + $this->report->anomaly('icon_missing', ['extension' => $row->name, 'uuid' => $row->uuid, 'file' => $row->icon_uuid]); + + return; + } + + if ($extension->icon_file_uuid !== $file->uuid) { + $extension->forceFill(['icon_file_uuid' => $file->uuid])->save(); + } + } + + protected function importScreenshots(object $row, Extension $extension): void + { + foreach ($this->screenshots->get($row->uuid, collect()) as $screenshot) { + if (! $this->copyFile($screenshot, File::TYPE_SCREENSHOT, 'screenshots', $extension)) { + $this->report->anomaly('icon_missing', ['extension' => $row->name, 'uuid' => $row->uuid, 'file' => $screenshot->uuid]); + } + } + } + + // --------------------------------------------------------------------- + // Releases and tarballs + // --------------------------------------------------------------------- + + protected function importReleases(): void + { + foreach ($this->bundles->groupBy('extension_uuid') as $extensionUuid => $bundles) { + $extension = $this->extensionMap[$extensionUuid] ?? null; + $sourceExtension = $this->extensions->get($extensionUuid); + + if (! $extension) { + foreach ($bundles as $bundle) { + if (! $sourceExtension) { + $this->report->anomaly('missing_reference', ['table' => 'registry_extension_bundles', 'uuid' => $bundle->uuid, 'missing' => 'registry_extensions', 'reference' => $extensionUuid]); + } + $this->report->count('releases', ImportReport::SKIPPED); + } + + continue; + } + + // The current bundle, then the newest, keep a version that appears twice. + $ordered = $bundles->sortBy(fn ($b) => [ + $b->uuid === ($sourceExtension->current_bundle_uuid ?? null) ? 0 : 1, + -strtotime((string) ($b->created_at ?? '1970-01-01')), + -(int) ($b->id ?? 0), + ]); + + $seen = []; + foreach ($ordered as $bundle) { + $meta = $this->json($bundle->meta ?? null) ?? []; + $version = ltrim((string) ($this->bundleFile($meta, 'extension.json', 'version') ?: ($bundle->version ?? null) ?: $this->bundleFile($meta, 'package.json', 'version') ?: ''), 'vV'); + + if ($version === '') { + $this->report->anomaly('bundle_without_version', ['extension' => $extension->slug, 'bundle' => $bundle->bundle_id ?? $bundle->uuid]); + $this->report->count('releases', ImportReport::SKIPPED); + + continue; + } + + if (isset($seen[$version])) { + $this->report->anomaly('duplicate_version', ['extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid, 'kept' => $seen[$version]]); + $this->report->count('releases', ImportReport::SKIPPED); + + continue; + } + $seen[$version] = $bundle->bundle_id ?? $bundle->uuid; + + $this->importRelease($bundle, $meta, $version, $extension, $sourceExtension); + } + } + } + + protected function importRelease(object $bundle, array $meta, string $version, Extension $extension, object $sourceExtension): void + { + $release = Release::withTrashed()->where('source_uuid', $bundle->uuid)->first(); + + $conflict = Release::withTrashed() + ->where('extension_uuid', $extension->uuid) + ->where('version', $version) + ->when($release, fn ($q) => $q->whereKeyNot($release->getKey())) + ->first(); + if ($conflict) { + $this->report->anomaly('conflict', ['table' => 'releases', 'extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid]); + $this->report->count('releases', ImportReport::SKIPPED); + + return; + } + + $release ??= new Release; + $status = $this->mapReleaseStatus($bundle); + $isCurrent = $bundle->uuid === ($sourceExtension->current_bundle_uuid ?? null); + + $publishedAt = null; + if ($status === Release::STATUS_PUBLISHED) { + $publishedAt = ($isCurrent ? $this->date($sourceExtension->published_at ?? null) : null) + ?? $this->date($bundle->updated_at ?? null) + ?? $this->date($bundle->created_at ?? null); + } + + // Save first so the tarball file can point at the release as its subject. + $attributes = [ + 'source_uuid' => $bundle->uuid, + 'extension_uuid' => $extension->uuid, + 'created_by_account_uuid' => ($this->userAccounts[$bundle->created_by_uuid ?? ''] ?? null)?->uuid, + 'version' => $version, + 'channel' => Compat::normalizeVersion($version) !== null && str_contains($version, '-') ? Release::CHANNEL_BETA : Release::CHANNEL_STABLE, + 'manifest' => array_merge($this->bundleFile($meta, 'extension.json') ?: [], array_filter(['bundle_number' => $bundle->bundle_number ?? null])), + 'compat' => $this->compat($meta), + 'published_at' => $publishedAt, + 'created_at' => $this->date($bundle->created_at ?? null), + 'deleted_at' => $this->date($bundle->deleted_at ?? null), + ]; + + $tarball = $this->files->get($bundle->bundle_uuid ?? ''); + $reason = match (true) { + blank($bundle->bundle_uuid ?? null) => 'bundle has no file', + $tarball === null => 'file row not found', + blank($tarball->path ?? null) => 'file has no path', + ! $this->sourceExists($tarball->path) => 'object not found on source disk', + default => null, + }; + + $release->forceFill($attributes); + if (! $release->exists) { + $release->forceFill(['status' => $status])->save(); + } + + $file = $reason === null ? $this->copyFile($tarball, File::TYPE_SOURCE_BUNDLE, 'tarballs', $release) : null; + if ($reason === null && ! $file) { + $reason = 'copy failed'; + } + + $releaseMeta = array_filter([ + 'source' => array_filter([ + 'bundle_id' => $bundle->bundle_id ?? null, + 'public_id' => $bundle->public_id ?? null, + 'bundle_number' => $bundle->bundle_number ?? null, + 'status' => $bundle->status ?? null, + ]), + 'package_json' => $this->bundleFile($meta, 'package.json'), + 'composer_json' => $this->bundleFile($meta, 'composer.json'), + 'source_bundle_file_uuid' => $file?->uuid, + 'import_yank_reason' => $reason, + ]); + + if ($reason !== null) { + $this->report->anomaly('tarball_missing', [ + 'extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid, + 'path' => $tarball->path ?? null, 'reason' => $reason, + ]); + } + + $release->forceFill([ + 'status' => $reason !== null ? Release::STATUS_YANKED : $status, + 'yanked_at' => $reason !== null ? ($release->yanked_at ?? now()) : null, + 'size_bytes' => $file?->size ?? (int) ($tarball->file_size ?? 0), + 'checksum' => $file?->checksum, + 'meta' => $releaseMeta, + ]); + + $this->releaseMap[$bundle->uuid] = $this->persist($release, 'releases', $release->wasRecentlyCreated); + } + + protected function mapReleaseStatus(object $bundle): string + { + $status = match ($bundle->status ?? null) { + 'pending' => Release::STATUS_UPLOADED, + 'awaiting_review', 'in_review' => Release::STATUS_IN_REVIEW, + 'approved' => Release::STATUS_APPROVED, + 'published' => Release::STATUS_PUBLISHED, + 'rejected' => Release::STATUS_REJECTED, + default => null, + }; + + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_extension_bundles', 'uuid' => $bundle->uuid, 'status' => $bundle->status ?? null, 'mapped_to' => Release::STATUS_UPLOADED]); + } + + return $status ?? Release::STATUS_UPLOADED; + } + + /** + * `compat` from the manifest when it declares console or API ranges. + */ + protected function compat(array $meta): ?array + { + $compat = $this->bundleFile($meta, 'extension.json', 'compat') ?? $this->bundleFile($meta, 'extension.json', 'fleetbase'); + if (! is_array($compat)) { + return null; + } + + $compat = array_filter(['console' => $compat['console'] ?? null, 'api' => $compat['api'] ?? null]); + + return $compat ?: null; + } + + // --------------------------------------------------------------------- + // Purchases and licences + // --------------------------------------------------------------------- + + protected function importPurchases(): void + { + $seen = []; + + foreach ($this->purchases as $row) { + $extension = $this->extensionMap[$row->extension_uuid ?? ''] ?? null; + $buyer = $this->buyerOrganization($row); + + if (! $extension || ! $buyer) { + $this->report->anomaly('missing_reference', [ + 'table' => 'registry_extension_purchases', 'uuid' => $row->uuid, + 'missing' => $extension ? 'buyer' : 'extension', + 'reference' => $extension ? (($row->purchaser_uuid ?? null) ?: ($row->company_uuid ?? null)) : ($row->extension_uuid ?? null), + ]); + $this->report->count('purchases', ImportReport::SKIPPED); + + continue; + } + + if ($this->bool($row->is_subcription ?? false)) { + $this->report->anomaly('subscription', ['table' => 'registry_extension_purchases', 'uuid' => $row->uuid, 'extension' => $extension->slug]); + } + + $key = $extension->uuid.'|'.$buyer->uuid; + if (isset($seen[$key])) { + $this->report->anomaly('duplicate_purchase', ['uuid' => $row->uuid, 'extension' => $extension->slug, 'buyer' => $buyer->slug]); + } + $seen[$key] = true; + + $sessionId = $row->stripe_checkout_session_id ?? null; + if ($sessionId && Purchase::withTrashed()->where('stripe_checkout_session_id', $sessionId)->where(fn ($q) => $q->whereNull('source_uuid')->orWhere('source_uuid', '!=', $row->uuid))->exists()) { + $this->report->anomaly('conflict', ['table' => 'purchases', 'uuid' => $row->uuid, 'stripe_checkout_session_id' => $sessionId]); + $sessionId = null; + } + + $extension->loadMissing('price'); + $amount = ($row->locked_price ?? null) !== null ? (int) $row->locked_price : (int) ($extension->price?->amount ?? 0); + $currency = $extension->price?->currency ?? 'USD'; + $deletedAt = $this->date($row->deleted_at ?? null); + + $purchase = $this->save(Purchase::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Purchase, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'buyer_organization_uuid' => $buyer->uuid, + 'price_snapshot' => ['model' => Price::MODEL_ONE_TIME, 'amount' => $amount, 'currency' => $currency, 'source' => 'import'], + 'amount_total' => $amount, + 'fee_amount' => 0, + 'net_amount' => 0, + 'currency' => $currency, + 'stripe_checkout_session_id' => $sessionId, + 'stripe_payment_intent_id' => $row->stripe_payment_intent_id ?? null, + 'status' => Purchase::STATUS_PAID, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $deletedAt, + 'meta' => ['source' => 'registry_extension_purchases', 'fees_unknown' => true], + ], 'purchases'); + + $licence = Licence::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Licence; + $this->save($licence, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $buyer->uuid, + 'purchase_uuid' => $purchase->uuid, + 'kind' => Licence::KIND_PAID, + // Seat counts are set once; staff may raise them in the service later. + 'max_activations' => $licence->exists ? $licence->max_activations : self::DEFAULT_PAID_SEATS, + 'status' => $deletedAt ? Licence::STATUS_REVOKED : Licence::STATUS_ACTIVE, + 'created_at' => $this->date($row->created_at ?? null), + ], 'licences'); + } + } + + protected function buyerOrganization(object $row): ?Organization + { + return match ($this->purchaserKind($row)) { + 'developer' => $this->developerOrganizations[$row->purchaser_uuid ?? ''] ?? null, + 'company' => $this->companyOrganizations[$row->purchaser_uuid ?? ''] ?? null, + default => $this->companyOrganizations[$row->company_uuid ?? ''] ?? null, + }; + } + + protected function purchaserKind(object $row): ?string + { + $type = (string) ($row->purchaser_type ?? ''); + if ($type === '' || blank($row->purchaser_uuid ?? null)) { + return null; + } + + return str_contains($type, 'Developer') ? 'developer' : 'company'; + } + + // --------------------------------------------------------------------- + // Instances, installs, activations + // --------------------------------------------------------------------- + + /** + * One synthetic cloud instance per company; soft-deleted installs become + * `uninstalled` rows. Live installs claim a licence seat on the company's + * instance (the paid licence from the purchase, or an automatic free licence). + */ + protected function importInstalls(): void + { + $activate = app(ActivateLicence::class); + $deactivate = app(DeactivateLicence::class); + + $groups = $this->installs->groupBy(fn ($i) => ($i->company_uuid ?? '').'|'.($i->extension_uuid ?? '')); + + foreach ($groups as $rows) { + $first = $rows->first(); + $extension = $this->extensionMap[$first->extension_uuid ?? ''] ?? null; + $instance = $this->syntheticInstance($first->company_uuid ?? null); + + if (! $extension || ! $instance) { + foreach ($rows as $row) { + $this->report->anomaly('missing_reference', [ + 'table' => 'registry_extension_installs', 'uuid' => $row->uuid, + 'missing' => $extension ? 'companies' : 'extension', + 'reference' => $extension ? ($row->company_uuid ?? null) : ($row->extension_uuid ?? null), + ]); + $this->report->count('installs', ImportReport::SKIPPED); + } + + continue; + } + + $release = $this->installedRelease($first->extension_uuid, $extension); + $live = $rows->filter(fn ($r) => ! $this->date($r->deleted_at ?? null)) + ->sortByDesc(fn ($r) => [(string) ($r->created_at ?? ''), (int) ($r->id ?? 0)]); + $current = $live->first(); + + if ($live->count() > 1) { + $this->report->anomaly('duplicate_install', ['extension' => $extension->slug, 'company' => $first->company_uuid, 'live_rows' => $live->count()]); + } + + $currentInstall = null; + foreach ($rows as $row) { + $installed = $current !== null && $row->uuid === $current->uuid; + $createdAt = $this->date($row->created_at ?? null); + + $install = $this->save(Install::query()->where('source_uuid', $row->uuid)->first() ?? new Install, [ + 'source_uuid' => $row->uuid, + 'instance_uuid' => $instance->uuid, + 'extension_uuid' => $extension->uuid, + 'release_uuid' => $release?->uuid, + 'status' => $installed ? Install::STATUS_INSTALLED : Install::STATUS_UNINSTALLED, + 'installed_by' => 'cloud-import', + 'started_at' => $createdAt, + 'finished_at' => $installed ? $createdAt : ($this->date($row->deleted_at ?? null) ?? $this->date($row->updated_at ?? null) ?? $createdAt), + 'created_at' => $createdAt, + ], 'installs'); + + if ($installed) { + $currentInstall = $install; + } + } + + if (! $currentInstall) { + $closed = $deactivate($instance, $extension); + if ($closed > 0) { + $this->report->count('licence_activations', ImportReport::UPDATED, $closed); + } + + continue; + } + + $licencesBefore = Licence::query()->where('extension_uuid', $extension->uuid)->where('organization_uuid', $instance->organization_uuid)->count(); + + try { + $activation = $activate($instance, $extension, $release); + } catch (LicenceRequiredException $e) { + $this->report->anomaly('install_without_licence', ['extension' => $extension->slug, 'company' => $first->company_uuid, 'reason' => $e->getMessage()]); + + continue; + } + + if (Licence::query()->where('extension_uuid', $extension->uuid)->where('organization_uuid', $instance->organization_uuid)->count() > $licencesBefore) { + $this->report->count('licences', ImportReport::CREATED); + } + + if ($activation->wasRecentlyCreated) { + $activation->forceFill(['source_uuid' => $current->uuid])->save(); + $this->report->count('licence_activations', ImportReport::CREATED); + } else { + $this->report->count('licence_activations', ImportReport::UNCHANGED); + } + } + } + + protected function syntheticInstance(?string $companyUuid): ?Instance + { + if ($companyUuid === null || $companyUuid === '') { + return null; + } + + if (isset($this->instances[$companyUuid])) { + return $this->instances[$companyUuid]; + } + + $organization = $this->companyOrganizations[$companyUuid] ?? null; + if (! $organization) { + return null; + } + + $company = $this->companies->get($companyUuid); + $instance = Instance::withTrashed()->where('source_uuid', $companyUuid)->where('kind', Instance::KIND_CLOUD)->first() ?? new Instance; + + return $this->instances[$companyUuid] = $this->save($instance, [ + 'source_uuid' => $companyUuid, + 'organization_uuid' => $organization->uuid, + 'name' => ($company->name ?? $organization->name).' (Fleetbase Cloud)', + 'kind' => Instance::KIND_CLOUD, + 'status' => Instance::STATUS_ACTIVE, + 'meta' => array_filter(['synthetic' => true, 'source' => 'companies', 'company_public_id' => $company->public_id ?? null]), + ], 'instances'); + } + + /** + * The release an imported install points at: the extension's current bundle, else + * its newest published release. + */ + protected function installedRelease(string $sourceExtensionUuid, Extension $extension): ?Release + { + $current = $this->extensions->get($sourceExtensionUuid)->current_bundle_uuid ?? null; + if ($current && isset($this->releaseMap[$current])) { + return $this->releaseMap[$current]; + } + + return Compat::latest(collect($this->releaseMap)->filter(fn (Release $r) => $r->extension_uuid === $extension->uuid && $r->isPublished())); + } + + // --------------------------------------------------------------------- + // Files + // --------------------------------------------------------------------- + + protected function sourceExists(string $path): bool + { + try { + return Storage::disk($this->sourceDisk)->exists($path); + } catch (\Throwable) { + return false; + } + } + + /** + * Copy a cloud `files` object into service storage with a SHA-256 checksum. Re-runs + * skip objects already copied with a checksum. A dry run records the file row (rolled + * back) but copies nothing. + */ + protected function copyFile(?object $source, string $type, string $prefix, EloquentModel $subject): ?File + { + if (! $source || blank($source->path ?? null) || ! $this->sourceExists($source->path)) { + return null; + } + + $file = File::withTrashed()->where('source_uuid', $source->uuid)->first() ?? new File; + $target = trim(config("registry.storage.prefixes.{$prefix}", $prefix), '/').'/imported/'.$source->uuid.'/'.basename($source->path); + $attributes = [ + 'source_uuid' => $source->uuid, + 'subject_type' => $subject->getMorphClass(), + 'subject_uuid' => $subject->getKey(), + 'type' => $type, + 'disk' => $this->targetDisk, + 'path' => $target, + 'name' => basename($source->path), + 'original_filename' => $source->original_filename ?? basename($source->path), + 'mime' => $source->content_type ?? null, + ]; + + $copied = $file->exists && $file->checksum && $file->disk === $this->targetDisk && $file->path === $target + && Storage::disk($this->targetDisk)->exists($target); + + if ($copied || $this->dryRun) { + $attributes['size'] = $copied ? $file->size : (int) ($source->file_size ?? 0); + + return $this->save($file, $attributes, 'files'); + } + + [$size, $checksum] = $this->streamCopy($source->path, $target); + + return $this->save($file, array_merge($attributes, ['size' => $size, 'checksum' => 'sha256:'.$checksum]), 'files'); + } + + /** + * @return array{0: int, 1: string} size in bytes and hex SHA-256 + */ + protected function streamCopy(string $from, string $to): array + { + $in = Storage::disk($this->sourceDisk)->readStream($from); + $buffer = tmpfile(); + + try { + stream_copy_to_stream($in, $buffer); + $path = stream_get_meta_data($buffer)['uri']; + $checksum = hash_file('sha256', $path); + $size = filesize($path); + + rewind($buffer); + Storage::disk($this->targetDisk)->writeStream($to, $buffer); + + return [(int) $size, $checksum]; + } finally { + is_resource($in) && fclose($in); + is_resource($buffer) && fclose($buffer); + } + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** + * Fill and persist a model, counting the outcome under `$table`. + * + * @template T of EloquentModel + * + * @param T $model + * @return T + */ + protected function save(EloquentModel $model, array $attributes, string $table): EloquentModel + { + $model->forceFill($attributes); + + return $this->persist($model, $table); + } + + /** + * @template T of EloquentModel + * + * @param T $model + * @return T + */ + protected function persist(EloquentModel $model, string $table, bool $created = false): EloquentModel + { + $outcome = match (true) { + $created || ! $model->exists => ImportReport::CREATED, + $model->isDirty() => ImportReport::UPDATED, + default => ImportReport::UNCHANGED, + }; + + if ($model->isDirty() || ! $model->exists) { + $model->save(); + } + + $this->report->count($table, $outcome); + + return $model; + } + + /** + * A slug unique among the model's rows (including soft-deleted ones). + * + * @param class-string $model + */ + protected function uniqueSlug(string $model, string $base): string + { + $base = Str::slug($base) ?: Str::lower(Str::random(8)); + $candidate = $base; + $n = 2; + + $query = fn () => in_array(SoftDeletes::class, class_uses_recursive($model), true) ? $model::withTrashed() : $model::query(); + while ($query()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.$n++; + } + + if ($candidate !== $base && $model === Organization::class) { + $this->report->anomaly('slug_renamed', ['table' => 'organizations', 'from' => $base, 'to' => $candidate]); + } + + return $candidate; + } + + /** + * A value from one of the files a bundle's `meta` holds, keyed by file name + * (`extension.json`, `package.json`, `composer.json`). Not `data_get`: the dots in + * those keys are part of the name. + */ + protected function bundleFile(array $meta, string $file, ?string $path = null): mixed + { + $contents = $meta[$file] ?? null; + + return $path === null ? $contents : data_get($contents, $path); + } + + protected function bool(mixed $value): bool + { + return in_array($value, [true, 1, '1', 'true'], true); + } + + protected function json(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (is_object($value)) { + return json_decode(json_encode($value), true); + } + if (! is_string($value) || trim($value) === '') { + return null; + } + + $decoded = json_decode($value, true); + + // Double-encoded JSON (a JSON string holding JSON) appears in older rows. + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + return is_array($decoded) ? $decoded : null; + } + + protected function date(mixed $value): ?Carbon + { + if ($value === null || $value === '' || str_starts_with((string) $value, '0000-00-00')) { + return null; + } + + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Import/SourceDatabase.php b/app/Import/SourceDatabase.php new file mode 100644 index 0000000..6cf622c --- /dev/null +++ b/app/Import/SourceDatabase.php @@ -0,0 +1,107 @@ + rows loaded per table, when the source is a dump */ + public array $dumpRowCounts = []; + + public function __construct(public readonly string $from) {} + + public function open(): Connection + { + $config = $this->connectionConfig(); + + Config::set('database.connections.'.self::CONNECTION, $config); + DB::purge(self::CONNECTION); + + return DB::connection(self::CONNECTION); + } + + public function describe(): string + { + if ($this->isDsn()) { + return (string) preg_replace('#//([^:/@]+):[^@]*@#', '//$1:***@', $this->from); + } + + return $this->from; + } + + public function close(): void + { + DB::purge(self::CONNECTION); + + if ($this->temporaryFile && is_file($this->temporaryFile)) { + @unlink($this->temporaryFile); + } + $this->temporaryFile = null; + } + + protected function isDsn(): bool + { + return (bool) preg_match('#^(mysql|mariadb|sqlite)://#i', $this->from); + } + + protected function connectionConfig(): array + { + if (preg_match('#^sqlite://(.+)$#i', $this->from, $m)) { + $path = $m[1]; + if (! is_file($path)) { + throw new RuntimeException("SQLite source not found: {$path}"); + } + + return ['driver' => 'sqlite', 'database' => $path, 'prefix' => '', 'foreign_key_constraints' => false]; + } + + if (preg_match('#^(mysql|mariadb)://#i', $this->from)) { + $parts = parse_url($this->from); + if ($parts === false || empty($parts['host']) || empty($parts['path']) || trim($parts['path'], '/') === '') { + throw new RuntimeException('Expected a DSN like mysql://user:pass@host:3306/database.'); + } + parse_str($parts['query'] ?? '', $query); + + return [ + 'driver' => strtolower($parts['scheme']), + 'host' => $parts['host'], + 'port' => $parts['port'] ?? 3306, + 'database' => trim($parts['path'], '/'), + 'username' => isset($parts['user']) ? urldecode($parts['user']) : 'root', + 'password' => isset($parts['pass']) ? urldecode($parts['pass']) : '', + 'charset' => $query['charset'] ?? 'utf8mb4', + 'collation' => $query['collation'] ?? 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => false, + ]; + } + + $this->temporaryFile = tempnam(sys_get_temp_dir(), 'registry-import-').'.sqlite'; + $this->dumpRowCounts = (new SqlDumpLoader(self::TABLES))->load($this->from, $this->temporaryFile); + + return ['driver' => 'sqlite', 'database' => $this->temporaryFile, 'prefix' => '', 'foreign_key_constraints' => false]; + } +} diff --git a/app/Import/SqlDumpLoader.php b/app/Import/SqlDumpLoader.php new file mode 100644 index 0000000..65b66dd --- /dev/null +++ b/app/Import/SqlDumpLoader.php @@ -0,0 +1,340 @@ +> */ + protected array $columns = []; + + /** @var array */ + protected array $rowCounts = []; + + protected PDO $pdo; + + /** + * @param list $tables tables to keep + */ + public function __construct(protected array $tables) {} + + /** + * @return array rows loaded per table + */ + public function load(string $dumpPath, string $sqlitePath): array + { + if (! is_file($dumpPath) || ! is_readable($dumpPath)) { + throw new RuntimeException("SQL dump not found or not readable: {$dumpPath}"); + } + + $handle = fopen(str_ends_with($dumpPath, '.gz') ? 'compress.zlib://'.$dumpPath : $dumpPath, 'rb'); + if ($handle === false) { + throw new RuntimeException("Could not open SQL dump: {$dumpPath}"); + } + + $this->pdo = new PDO('sqlite:'.$sqlitePath, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $this->pdo->exec('PRAGMA journal_mode = OFF'); + $this->pdo->exec('PRAGMA synchronous = OFF'); + $this->pdo->beginTransaction(); + + try { + while (($line = fgets($handle)) !== false) { + if (preg_match('/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([^`"\s(]+)[`"]?/i', $line, $match)) { + $statement = $this->readUntilTerminated($handle, $line); + if ($this->wanted($match[1])) { + $this->defineTable($match[1], $this->parseCreateColumns($statement), true); + } + + continue; + } + + if (preg_match('/^(?:INSERT|REPLACE)(?:\s+IGNORE)?\s+INTO\s+[`"]?([^`"\s(]+)[`"]?/i', $line, $match) && $this->wanted($match[1])) { + $this->loadInsert($handle, $match[1], $line); + } + } + + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + + throw $e; + } finally { + fclose($handle); + } + + return $this->rowCounts; + } + + protected function wanted(string $table): bool + { + return in_array($table, $this->tables, true); + } + + /** + * @param resource $handle + */ + protected function readUntilTerminated($handle, string $statement): string + { + $line = $statement; + while (! str_ends_with(rtrim($line), ';')) { + $line = fgets($handle); + if ($line === false) { + break; + } + $statement .= $line; + } + + return $statement; + } + + /** + * @return list + */ + protected function parseCreateColumns(string $statement): array + { + $columns = []; + foreach (preg_split('/\R/', $statement) as $line) { + if (preg_match('/^\s*[`"]((?:[^`"]|``)+)[`"]\s+\w/', $line, $match)) { + $columns[] = str_replace('``', '`', $match[1]); + } + } + + if ($columns === []) { + throw new RuntimeException('Could not read the columns of a CREATE TABLE statement in the dump.'); + } + + return $columns; + } + + /** + * @param list $columns + */ + protected function defineTable(string $table, array $columns, bool $replace): void + { + if (isset($this->columns[$table]) && ! $replace) { + return; + } + + $quoted = implode(', ', array_map(fn ($c) => $this->quote($c).' TEXT', $columns)); + $this->pdo->exec('DROP TABLE IF EXISTS '.$this->quote($table)); + $this->pdo->exec('CREATE TABLE '.$this->quote($table)." ({$quoted})"); + + $this->columns[$table] = $columns; + $this->rowCounts[$table] = 0; + } + + /** + * @param resource $handle + */ + protected function loadInsert($handle, string $table, string $statement): void + { + while (true) { + try { + [$columns, $rows] = $this->parseInsert($statement); + break; + } catch (IncompleteStatement) { + $next = fgets($handle); + if ($next === false) { + throw new RuntimeException("The dump ends inside an INSERT statement for `{$table}`."); + } + $statement .= $next; + } + } + + if (! isset($this->columns[$table])) { + if ($columns === null) { + throw new RuntimeException("The dump has rows for `{$table}` but no CREATE TABLE statement or column list for it."); + } + $this->defineTable($table, $columns, false); + } + + $tableColumns = $this->columns[$table]; + $insertColumns = $columns ?? $tableColumns; + $placeholders = implode(', ', array_fill(0, count($insertColumns), '?')); + $insert = $this->pdo->prepare( + 'INSERT INTO '.$this->quote($table).' ('.implode(', ', array_map(fn ($c) => $this->quote($c), $insertColumns)).") VALUES ({$placeholders})" + ); + + foreach ($rows as $row) { + if (count($row) !== count($insertColumns)) { + throw new RuntimeException("A row for `{$table}` has ".count($row).' values but '.count($insertColumns).' columns.'); + } + $insert->execute($row); + $this->rowCounts[$table]++; + } + } + + /** + * Parse one INSERT statement into its column list (null when implicit) and rows. + * + * @return array{0: list|null, 1: list>} + * + * @throws IncompleteStatement when the statement continues on the next line + */ + public function parseInsert(string $sql): array + { + if (! preg_match('/^(?:INSERT|REPLACE)(?:\s+IGNORE)?\s+INTO\s+[`"]?[^`"\s(]+[`"]?\s*(?:\(([^)]*)\))?\s*VALUES\s*/i', $sql, $match)) { + if (! preg_match('/\bVALUES\b/i', $sql)) { + throw new IncompleteStatement; + } + + throw new RuntimeException('Unrecognised INSERT statement in the dump.'); + } + + $columns = isset($match[1]) && $match[1] !== '' + ? array_map(fn ($c) => trim($c, " \t\n\r`\""), explode(',', $match[1])) + : null; + + $i = strlen($match[0]); + $len = strlen($sql); + $rows = []; + + while (true) { + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] !== '(') { + throw new RuntimeException('Expected "(" in an INSERT statement at offset '.$i.'.'); + } + $i++; + + $row = []; + while (true) { + $i = $this->skipWhitespace($sql, $i, $len); + [$value, $i] = $this->parseValue($sql, $i, $len); + $row[] = $value; + + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] === ',') { + $i++; + + continue; + } + if ($sql[$i] === ')') { + $i++; + break; + } + + throw new RuntimeException('Unexpected "'.$sql[$i].'" in an INSERT statement at offset '.$i.'.'); + } + $rows[] = $row; + + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] === ',') { + $i++; + + continue; + } + if ($sql[$i] === ';') { + return [$columns, $rows]; + } + + throw new RuntimeException('Unexpected "'.$sql[$i].'" after a row in an INSERT statement at offset '.$i.'.'); + } + } + + protected function skipWhitespace(string $sql, int $i, int $len): int + { + $i += strspn($sql, " \t\r\n", $i); + if ($i >= $len) { + throw new IncompleteStatement; + } + + return $i; + } + + /** + * @return array{0: string|null, 1: int} + */ + protected function parseValue(string $sql, int $i, int $len): array + { + $char = $sql[$i]; + + if ($char === "'" || $char === '"') { + return $this->parseString($sql, $i, $len); + } + + // Charset introducers: _binary '…', _utf8mb4'…' + if ($char === '_' && preg_match('/\G_[a-z0-9]+\s*(?=[\'"])/Ai', $sql, $m, 0, $i)) { + return $this->parseString($sql, $i + strlen($m[0]), $len); + } + + $token = substr($sql, $i, strcspn($sql, ",) \t\r\n", $i)); + if ($i + strlen($token) >= $len) { + throw new IncompleteStatement; + } + $next = $i + strlen($token); + + if (strcasecmp($token, 'NULL') === 0) { + return [null, $next]; + } + if (strcasecmp($token, 'TRUE') === 0 || strcasecmp($token, 'FALSE') === 0) { + return [strcasecmp($token, 'TRUE') === 0 ? '1' : '0', $next]; + } + if (preg_match('/^0x([0-9a-f]*)$/i', $token, $m)) { + return [hex2bin(strlen($m[1]) % 2 ? '0'.$m[1] : $m[1]), $next]; + } + if (preg_match("/^b'([01]*)'$/i", $token, $m)) { + return [(string) bindec($m[1] ?: '0'), $next]; + } + + return [$token, $next]; + } + + /** + * @return array{0: string, 1: int} + */ + protected function parseString(string $sql, int $i, int $len): array + { + static $escapes = ['0' => "\0", "'" => "'", '"' => '"', 'b' => "\x08", 'n' => "\n", 'r' => "\r", 't' => "\t", 'Z' => "\x1A", '\\' => '\\', '%' => '\\%', '_' => '\\_']; + + $quote = $sql[$i]; + $i++; + $value = ''; + + while (true) { + $chunk = strcspn($sql, $quote.'\\', $i); + $value .= substr($sql, $i, $chunk); + $i += $chunk; + + if ($i >= $len) { + throw new IncompleteStatement; + } + + if ($sql[$i] === '\\') { + if ($i + 1 >= $len) { + throw new IncompleteStatement; + } + $value .= $escapes[$sql[$i + 1]] ?? $sql[$i + 1]; + $i += 2; + + continue; + } + + // Doubled quote inside the string. + if ($i + 1 < $len && $sql[$i + 1] === $quote) { + $value .= $quote; + $i += 2; + + continue; + } + + return [$value, $i + 1]; + } + } + + protected function quote(string $identifier): string + { + return '"'.str_replace('"', '""', $identifier).'"'; + } +} diff --git a/app/Models/File.php b/app/Models/File.php index 99d78d6..6150afe 100644 --- a/app/Models/File.php +++ b/app/Models/File.php @@ -27,6 +27,9 @@ class File extends Model public const TYPE_SCREENSHOT = 'screenshot'; + /** A cloud registry-bridge bundle archive copied by `registry:import`. */ + public const TYPE_SOURCE_BUNDLE = 'source_bundle'; + protected $table = 'files'; protected string $publicIdType = 'file'; diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..fd94504 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -60,6 +60,19 @@ 'report' => false, ], + // Read-only view of the cloud API's file bucket, used by `registry:import`. + 'import_source' => [ + 'driver' => 's3', + 'key' => env('IMPORT_SOURCE_AWS_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')), + 'secret' => env('IMPORT_SOURCE_AWS_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')), + 'region' => env('IMPORT_SOURCE_AWS_DEFAULT_REGION', env('AWS_DEFAULT_REGION')), + 'bucket' => env('IMPORT_SOURCE_AWS_BUCKET'), + 'endpoint' => env('IMPORT_SOURCE_AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('IMPORT_SOURCE_AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + ], /* diff --git a/config/registry.php b/config/registry.php index a533c56..4be1e52 100644 --- a/config/registry.php +++ b/config/registry.php @@ -22,6 +22,11 @@ // Maximum uploaded bundle size in bytes (used by release checks in Workstream C). 'max_bundle_bytes' => (int) env('REGISTRY_MAX_BUNDLE_BYTES', 50 * 1024 * 1024), + 'import' => [ + // Disk holding the cloud `files` objects (bundles, icons) that `registry:import` copies. + 'source_disk' => env('REGISTRY_IMPORT_SOURCE_DISK', 'import_source'), + ], + 'storage' => [ // Disk that holds tarballs, prebuilt dist archives, icons and screenshots. 'disk' => env('REGISTRY_STORAGE_DISK', 's3'), diff --git a/tests/Feature/Import/RegistryImportTest.php b/tests/Feature/Import/RegistryImportTest.php new file mode 100644 index 0000000..5632a8e --- /dev/null +++ b/tests/Feature/Import/RegistryImportTest.php @@ -0,0 +1,347 @@ +seed(CategorySeeder::class); + + config(['registry.storage.disk' => 'registry', 'registry.import.source_disk' => 'import_source']); + Storage::fake('registry'); + $this->source = Storage::fake('import_source'); + + $this->source->put('uploads/c1/icons/fleet-insights.png', 'PNG!'); + $this->source->put('uploads/c1/screens/dashboard.png', 'SHOT!'); + $this->source->put('uploads/c1/extensions/fleet-insights-1.0.0.tar.gz', 'fleet-insights 1.0.0 bundle'); + $this->source->put('uploads/c1/extensions/fleet-insights-1.1.0.tar.gz', 'fleet-insights 1.1.0 bundle'); + // route-planner-0.3.0.tar.gz is deliberately absent. +}); + +function runImport(array $options = []): array +{ + $report = tempnam(sys_get_temp_dir(), 'import-report-'); + + test()->artisan('registry:import', array_merge(['--from' => FIXTURE_DUMP, '--report' => $report], $options))->assertSuccessful(); + + $data = json_decode(file_get_contents($report), true); + unlink($report); + + return $data; +} + +function anomalyItems(array $report, string $type): array +{ + return $report['anomalies'][$type]['items'] ?? []; +} + +it('dry-runs: prints counts and anomalies and writes nothing', function () { + $categoriesBefore = Category::count(); + + $this->artisan('registry:import', ['--from' => FIXTURE_DUMP, '--dry-run' => true]) + ->expectsOutputToContain('Dry run from') + ->expectsOutputToContain('Would write') + ->expectsOutputToContain('Extensions without package names or bundles: 1') + ->expectsOutputToContain('Bundles whose tarball path is missing (imported as yanked): 2') + ->expectsOutputToContain('Duplicate package names (first listing keeps the name): 1') + ->expectsOutputToContain('Users without emails (not imported): 1') + ->expectsOutputToContain('Dry run: nothing was written.') + ->assertSuccessful(); + + expect(Extension::withTrashed()->count())->toBe(0) + ->and(Account::count())->toBe(0) + ->and(Organization::count())->toBe(0) + ->and(Release::withTrashed()->count())->toBe(0) + ->and(File::withTrashed()->count())->toBe(0) + ->and(Category::count())->toBe($categoriesBefore) + ->and(Category::whereNotNull('source_uuid')->count())->toBe(0) + ->and(Storage::disk('registry')->allFiles())->toBe([]) + ->and(Event::where('kind', 'registry.imported')->exists())->toBeFalse(); +}); + +it('reports the same counts in a dry run as a real run', function () { + $dry = runImport(['--dry-run' => true]); + $real = runImport(); + + expect($dry['dry_run'])->toBeTrue() + ->and($dry['counts'])->toBe($real['counts']) + ->and(array_map(fn ($a) => $a['count'], $dry['anomalies']))->toBe(array_map(fn ($a) => $a['count'], $real['anomalies'])); +}); + +it('maps the cloud tables onto the service data model', function () { + $report = runImport(); + + expect($report['source'])->toMatchArray([ + 'registry_extensions' => 5, 'registry_extension_bundles' => 6, 'registry_extension_purchases' => 2, + 'registry_extension_installs' => 4, 'registry_users' => 5, 'registry_developer_accounts' => 3, + 'companies' => 3, 'users' => 3, 'categories' => 2, + ]); + + expect($report['counts'])->toMatchArray([ + 'organizations' => ['created' => 6], + 'accounts' => ['created' => 4, 'unchanged' => 1, 'skipped' => 1], + 'memberships' => ['created' => 5], + 'extensions' => ['created' => 5], + 'prices' => ['created' => 5], + 'extension_packages' => ['created' => 5, 'skipped' => 1], + 'releases' => ['created' => 4, 'skipped' => 2], + 'purchases' => ['created' => 2], + 'instances' => ['created' => 3], + 'installs' => ['created' => 4], + 'licence_activations' => ['created' => 2], + ]); + expect($report['counts']['licences']['created'])->toBe(3); + + // Organizations: a personal one per developer account, one per referenced company. + $acme = Organization::where('source_uuid', 'c0000000-0000-4000-8000-000000000001')->sole(); + expect($acme->slug)->toBe('acme-logistics') + ->and($acme->stripe_connect_id)->toBe('acct_1Acme') + ->and($acme->bio)->toBe("Acme's logistics arm") + ->and($acme->is_personal)->toBeFalse() + ->and(Organization::where('source_uuid', 'c0000000-0000-4000-8000-000000000002')->value('slug'))->toBe('beta-freight') + ->and(Organization::where('is_personal', true)->pluck('slug')->sort()->values()->all())->toBe(['alice-dev', 'devon', 'penny']); + + // Accounts: developer accounts and cloud registry users; Alice's two identities merge by email. + $devon = Account::where('email', 'devon@indie.test')->sole(); + expect($devon->status)->toBe(Account::STATUS_ACTIVE) + ->and($devon->username)->toBe('devon') + ->and($devon->meta['github_username'])->toBe('devon-gh') + ->and(Account::where('email', 'penny@indie.test')->value('status'))->toBe(Account::STATUS_PENDING_VERIFICATION); + + $alice = Account::where('email', 'alice@acme.test')->sole(); + expect($alice->source_uuid)->toBe('d0000000-0000-4000-8000-000000000003') + ->and(Membership::where('account_uuid', $alice->uuid)->where('organization_uuid', $acme->uuid)->value('role'))->toBe(Membership::ROLE_OWNER); + + // A registry user who is not the company owner joins as publisher. + $carol = Account::where('email', 'carol@beta.test')->sole(); + expect(Membership::where('account_uuid', $carol->uuid)->value('role'))->toBe(Membership::ROLE_PUBLISHER) + ->and(Account::where('name', 'Bob NoEmail')->exists())->toBeFalse(); + + // Extensions, prices and package names. + $fleet = Extension::where('source_uuid', 'e0000000-0000-4000-8000-000000000001')->sole(); + expect($fleet->slug)->toBe('fleet-insights') + ->and($fleet->organization_uuid)->toBe($acme->uuid) + ->and($fleet->status)->toBe(Extension::STATUS_PUBLISHED) + ->and($fleet->hosting)->toBe(Extension::HOSTING_BOTH) + ->and($fleet->description)->toBe("Dashboards and \"insights\" for fleet operators.\r\nSecond line.") + ->and($fleet->copyright)->toBe('© 2024 Acme') + ->and($fleet->tags)->toBe(['analytics', 'fleet']) + ->and($fleet->privacy_url)->toBe('https://acme.test/privacy') + ->and($fleet->category->slug)->toBe('telematics') + ->and($fleet->created_by_account_uuid)->toBe($alice->uuid) + ->and($fleet->published_at->toDateTimeString())->toBe('2024-05-01 10:00:00'); + + expect($fleet->price->only(['model', 'amount', 'currency', 'sale_amount', 'on_sale'])) + ->toBe(['model' => Price::MODEL_ONE_TIME, 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 3900, 'on_sale' => false]); + + expect($fleet->packages()->pluck('name', 'kind')->all()) + ->toBe(['npm' => '@acme/fleet-insights-engine', 'composer' => 'acme/fleet-insights-api']); + + $route = Extension::where('slug', 'route-planner')->sole(); + expect($route->organization->slug)->toBe('devon') + ->and($route->hosting)->toBe(Extension::HOSTING_SELF_HOSTED) + ->and($route->price->model)->toBe(Price::MODEL_FREE) + ->and($route->category->slug)->toBe('custom-tools') + ->and(Category::where('slug', 'custom-tools')->value('source_uuid'))->toBe('b0000000-0000-4000-8000-000000000002') + ->and(Category::where('slug', 'telematics')->value('source_uuid'))->toBe('b0000000-0000-4000-8000-000000000001') + ->and(Category::where('slug', 'telematics')->value('description'))->not->toBe('Cloud copy of telematics'); + + expect(Extension::where('slug', 'draft-thing')->value('status'))->toBe(Extension::STATUS_DRAFT) + ->and(Extension::where('slug', 'copycat')->value('status'))->toBe(Extension::STATUS_IN_REVIEW) + ->and(Extension::where('slug', 'copycat')->sole()->packages()->count())->toBe(0); + + $old = Extension::withTrashed()->where('slug', 'old-widget')->sole(); + expect($old->trashed())->toBeTrue() + ->and($old->status)->toBe(Extension::STATUS_UNPUBLISHED) + ->and($old->packages()->pluck('name', 'kind')->all())->toBe(['npm' => '@acme/old-widget-engine']); + + // Releases: version from extension.json, statuses mapped, the newest bundle wins a repeated version. + $releases = Release::where('extension_uuid', $fleet->uuid)->orderBy('version')->get(); + expect($releases->pluck('version')->all())->toBe(['1.0.0', '1.1.0']) + ->and($releases->pluck('status')->unique()->all())->toBe([Release::STATUS_PUBLISHED]) + ->and($releases[1]->compat)->toBe(['console' => '>=0.7.0', 'api' => '^1.6']) + ->and($releases[1]->manifest['bundle_number'])->toBe('2') + ->and($releases[1]->meta['source']['bundle_id'])->toBe('EXTBNDLBBBBBBB') + ->and($releases[1]->published_at->toDateTimeString())->toBe('2024-05-01 10:00:00') + ->and($releases[0]->published_at->toDateTimeString())->toBe('2024-04-02 00:00:00'); + + // Purchases and licences: one licence per purchase, 5 seats; a deleted purchase revokes it. + $paid = Purchase::where('stripe_checkout_session_id', 'cs_test_1')->sole(); + expect((int) $paid->amount_total)->toBe(4900) + ->and($paid->buyer->slug)->toBe('beta-freight') + ->and($paid->licence->only(['kind', 'max_activations', 'status']))->toBe(['kind' => 'paid', 'max_activations' => 5, 'status' => 'active']); + + $refunded = Purchase::withTrashed()->where('stripe_checkout_session_id', 'cs_test_2')->sole(); + expect($refunded->trashed())->toBeTrue() + ->and((int) $refunded->amount_total)->toBe(4900) + ->and(Licence::where('purchase_uuid', $refunded->uuid)->value('status'))->toBe(Licence::STATUS_REVOKED); + + // Installs: one synthetic cloud instance per company, deleted installs uninstalled. + $gammaInstance = Instance::where('source_uuid', 'c0000000-0000-4000-8000-000000000003')->sole(); + expect($gammaInstance->kind)->toBe(Instance::KIND_CLOUD) + ->and($gammaInstance->token_hash)->toBeNull() + ->and($gammaInstance->meta['synthetic'])->toBeTrue(); + + $routeInstalls = Install::with('release')->where('extension_uuid', $route->uuid)->orderBy('started_at')->get(); + expect($routeInstalls->pluck('status')->all())->toBe(['uninstalled', 'installed']) + ->and($routeInstalls[1]->release->version)->toBe('0.3.0'); + + // Live installs hold a seat: the paid licence for Beta, an automatic free licence for Gamma. + $betaInstance = Instance::where('source_uuid', 'c0000000-0000-4000-8000-000000000002')->sole(); + expect(LicenceActivation::where('instance_uuid', $betaInstance->uuid)->whereNull('deactivated_at')->sole()->licence_uuid)->toBe($paid->licence->uuid); + + $free = Licence::where('extension_uuid', $route->uuid)->sole(); + expect($free->kind)->toBe(Licence::KIND_FREE) + ->and($free->max_activations)->toBeNull() + ->and($free->organization_uuid)->toBe($gammaInstance->organization_uuid) + ->and($free->activeActivations()->sole()->instance_uuid)->toBe($gammaInstance->uuid); + + // Acme installed its own paid extension without a purchase: install kept, no seat. + $acmeInstance = Instance::where('source_uuid', 'c0000000-0000-4000-8000-000000000001')->sole(); + expect(Install::where('instance_uuid', $acmeInstance->uuid)->value('status'))->toBe('installed') + ->and(LicenceActivation::where('instance_uuid', $acmeInstance->uuid)->count())->toBe(0); + + expect(Event::where('kind', 'registry.imported')->sole()->payload['counts']['extensions']['created'])->toBe(5); + + // The catalog counts installs from the imported rows. + $this->getJson('/v1/catalog/extensions?q=Fleet')->assertOk()->assertJsonPath('data.0.installs_count', 2); +}); + +it('lists every anomaly class the plan asks for', function () { + $report = runImport(['--dry-run' => true]); + + expect(collect(anomalyItems($report, 'extension_without_packages'))->pluck('name')->all())->toBe(['Draft Thing']) + ->and(collect(anomalyItems($report, 'tarball_missing'))->pluck('reason', 'bundle')->all())->toBe([ + 'EXTBNDLDDDDDDD' => 'object not found on source disk', + 'EXTBNDLFFFFFFF' => 'file row not found', + ]) + ->and(anomalyItems($report, 'duplicate_package_name'))->toHaveCount(1) + ->and(anomalyItems($report, 'duplicate_package_name')[0])->toMatchArray(['name' => '@acme/fleet-insights-engine', 'extension' => 'Copycat', 'kept_by' => 'fleet-insights']) + ->and(anomalyItems($report, 'user_without_email')[0])->toMatchArray(['source' => 'users', 'name' => 'Bob NoEmail']) + ->and(anomalyItems($report, 'merged_account')[0])->toMatchArray(['email' => 'alice@acme.test', 'source' => 'users']) + ->and(anomalyItems($report, 'duplicate_version')[0])->toMatchArray(['extension' => 'fleet-insights', 'version' => '1.0.0', 'bundle' => 'EXTBNDLCCCCCCC']) + ->and(anomalyItems($report, 'bundle_without_version')[0])->toMatchArray(['extension' => 'route-planner', 'bundle' => 'EXTBNDLEEEEEEE']) + ->and(anomalyItems($report, 'install_without_licence')[0])->toMatchArray(['extension' => 'fleet-insights', 'company' => 'c0000000-0000-4000-8000-000000000001']); +}); + +it('copies tarballs into service storage with checksums and yanks releases without one', function () { + runImport(); + + $release = Release::where('version', '1.1.0')->sole(); + $file = File::find($release->meta['source_bundle_file_uuid']); + + expect($file->type)->toBe(File::TYPE_SOURCE_BUNDLE) + ->and($file->disk)->toBe('registry') + ->and($file->subject_uuid)->toBe($release->uuid) + ->and($file->path)->toBe('tarballs/imported/f0000000-0000-4000-8000-000000000012/fleet-insights-1.1.0.tar.gz') + ->and(Storage::disk('registry')->get($file->path))->toBe('fleet-insights 1.1.0 bundle') + ->and($file->checksum)->toBe('sha256:'.hash('sha256', 'fleet-insights 1.1.0 bundle')) + ->and($release->checksum)->toBe($file->checksum) + ->and($release->size_bytes)->toBe(strlen('fleet-insights 1.1.0 bundle')); + + $yanked = Release::whereIn('version', ['0.3.0'])->orWhere(fn ($q) => $q->where('version', '1.0.0')->whereHas('extension', fn ($e) => $e->where('slug', 'copycat')))->get(); + expect($yanked)->toHaveCount(2) + ->and($yanked->pluck('status')->unique()->all())->toBe([Release::STATUS_YANKED]) + ->and($yanked->every(fn ($r) => $r->yanked_at !== null && $r->checksum === null))->toBeTrue() + ->and($yanked->pluck('meta.import_yank_reason')->sort()->values()->all())->toBe(['file row not found', 'object not found on source disk']) + ->and($yanked->firstWhere('version', '0.3.0')->size_bytes)->toBe(9001); + + $fleet = Extension::where('slug', 'fleet-insights')->sole(); + expect(Storage::disk('registry')->get($fleet->icon->path))->toBe('PNG!') + ->and($fleet->icon->type)->toBe(File::TYPE_ICON) + ->and($fleet->screenshots()->sole()->checksum)->toBe('sha256:'.hash('sha256', 'SHOT!')); +}); + +it('re-runs idempotently', function () { + runImport(); + $snapshot = fn () => [ + Organization::withTrashed()->count(), Account::withTrashed()->count(), Membership::count(), + Extension::withTrashed()->count(), ExtensionPackage::count(), Price::count(), Release::withTrashed()->count(), + File::withTrashed()->count(), Purchase::withTrashed()->count(), Licence::withTrashed()->count(), + Instance::withTrashed()->count(), Install::count(), LicenceActivation::count(), Category::count(), + ]; + $before = $snapshot(); + $yankedAt = Release::where('version', '0.3.0')->sole()->yanked_at->toDateTimeString(); + + $second = runImport(); + + expect($snapshot())->toBe($before); + foreach ($second['counts'] as $table => $counts) { + expect($counts['created'] ?? 0)->toBe(0, "{$table} created rows on the second run") + ->and($counts['updated'] ?? 0)->toBe(0, "{$table} updated rows on the second run"); + } + expect(Release::where('version', '0.3.0')->sole()->yanked_at->toDateTimeString())->toBe($yankedAt); +}); + +it('picks up a tarball that becomes retrievable on a later run', function () { + runImport(); + expect(Release::where('version', '0.3.0')->value('status'))->toBe(Release::STATUS_YANKED); + + $this->source->put('uploads/developers/route-planner-0.3.0.tar.gz', 'route planner bundle'); + runImport(); + + $release = Release::where('version', '0.3.0')->sole(); + expect($release->status)->toBe(Release::STATUS_PUBLISHED) + ->and($release->yanked_at)->toBeNull() + ->and($release->checksum)->toBe('sha256:'.hash('sha256', 'route planner bundle')); +}); + +it('lets imported users log in with their cloud password', function () { + runImport(); + + $this->postJson('/v1/auth/login', ['email' => 'devon@indie.test', 'password' => 'secret-password']) + ->assertOk()->assertJsonStructure(['token']); + $this->postJson('/v1/auth/login', ['email' => 'carol@beta.test', 'password' => 'secret-password'])->assertOk(); + $this->postJson('/v1/auth/login', ['email' => 'carol@beta.test', 'password' => 'wrong'])->assertStatus(401); +}); + +it('never overwrites an account created in the service', function () { + $existing = Account::factory()->create(['email' => 'devon@indie.test', 'name' => 'Devon (service)', 'password' => 'service-password']); + + $report = runImport(); + + $existing->refresh(); + expect($existing->name)->toBe('Devon (service)') + ->and($existing->source_uuid)->toBeNull() + ->and(collect(anomalyItems($report, 'merged_account'))->pluck('into', 'email')->get('devon@indie.test'))->toBe('service account') + ->and(Extension::where('slug', 'route-planner')->sole()->organization->slug)->toBe('devon'); + + $this->postJson('/v1/auth/login', ['email' => 'devon@indie.test', 'password' => 'service-password'])->assertOk(); +}); + +it('reads from a sqlite:// source', function () { + $sqlite = tempnam(sys_get_temp_dir(), 'cloud-').'.sqlite'; + (new SqlDumpLoader(SourceDatabase::TABLES))->load(FIXTURE_DUMP, $sqlite); + + try { + $report = runImport(['--from' => 'sqlite://'.$sqlite, '--dry-run' => true]); + } finally { + @unlink($sqlite); + } + + expect($report['counts']['extensions']['created'])->toBe(5); +}); + +it('fails cleanly without a source or with an unreadable one', function () { + $this->artisan('registry:import')->expectsOutputToContain('--from is required')->assertExitCode(2); + $this->artisan('registry:import', ['--from' => '/nope/missing.sql'])->expectsOutputToContain('SQL dump not found')->assertFailed(); + $this->artisan('registry:import', ['--from' => 'mysql://user@/'])->expectsOutputToContain('Expected a DSN like')->assertFailed(); +}); diff --git a/tests/Fixtures/import/cloud-registry.sql b/tests/Fixtures/import/cloud-registry.sql new file mode 100644 index 0000000..1b348d1 --- /dev/null +++ b/tests/Fixtures/import/cloud-registry.sql @@ -0,0 +1,339 @@ +-- MySQL dump 10.13 Distrib 8.0.36, for Linux (x86_64) +-- +-- Host: fleetbase-cloud Database: fleetbase +-- ------------------------------------------------------ +-- Server version 8.0.35 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET NAMES utf8mb4 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; + +-- +-- Table structure for table `companies` +-- + +DROP TABLE IF EXISTS `companies`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `companies` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_connect_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner_uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `website_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `slug` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `companies_public_id_unique` (`public_id`), + KEY `companies_uuid_index` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +LOCK TABLES `companies` WRITE; +/*!40000 ALTER TABLE `companies` DISABLE KEYS */; +INSERT INTO `companies` VALUES (1,'c0000000-0000-4000-8000-000000000001','company_acme1','acct_1Acme','a0000000-0000-4000-8000-000000000001','Acme Logistics','https://acme.test','Acme\'s logistics arm','acme-logistics','2024-01-01 00:00:00','2024-01-01 00:00:00',NULL),(2,'c0000000-0000-4000-8000-000000000002','company_beta2',NULL,NULL,'Beta Freight',NULL,NULL,NULL,'2024-01-02 00:00:00','2024-01-02 00:00:00',NULL),(3,'c0000000-0000-4000-8000-000000000003','company_gamma',NULL,NULL,'Gamma Couriers',NULL,NULL,'gamma-couriers','2024-01-03 00:00:00','2024-01-03 00:00:00',NULL); +/*!40000 ALTER TABLE `companies` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `orders` (not read by the importer) +-- + +DROP TABLE IF EXISTS `orders`; +CREATE TABLE `orders` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(191) DEFAULT NULL, + `notes` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +LOCK TABLES `orders` WRITE; +INSERT INTO `orders` VALUES (1,'o1','INSERT INTO `registry_extensions` VALUES (\'not a real row\');'),(2,'o2',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `company_uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `username` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `password` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `timezone` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email_verified_at` datetime DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `users` WRITE; +INSERT INTO `users` VALUES (1,'a0000000-0000-4000-8000-000000000001','user_alice','c0000000-0000-4000-8000-000000000001','alice','Alice@Acme.test','$2y$10$ryH8ewFSnZo6gek/TqYuLuVvtmBXBYt9zz33gnbM0VGR1LgOulzvm','Alice Admin','Asia/Singapore','active','2024-01-01 00:00:00','2024-01-01 00:00:00','2024-01-01 00:00:00',NULL),(2,'a0000000-0000-4000-8000-000000000002','user_bob','c0000000-0000-4000-8000-000000000001',NULL,NULL,NULL,'Bob NoEmail',NULL,'active',NULL,'2024-01-01 00:00:00','2024-01-01 00:00:00',NULL),(3,'a0000000-0000-4000-8000-000000000003','user_carol','c0000000-0000-4000-8000-000000000002','carol','carol@beta.test','$2y$10$ryH8ewFSnZo6gek/TqYuLuVvtmBXBYt9zz33gnbM0VGR1LgOulzvm','Carol Buyer','UTC','active','2024-01-02 00:00:00','2024-01-02 00:00:00','2024-01-02 00:00:00',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `files` +-- + +DROP TABLE IF EXISTS `files`; +CREATE TABLE `files` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `company_uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `subject_uuid` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `disk` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `path` longtext COLLATE utf8mb4_unicode_ci, + `subject_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `bucket` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `original_filename` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `content_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `file_size` int DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `files` WRITE; +INSERT INTO `files` VALUES (1,'f0000000-0000-4000-8000-000000000001','file_icon1','c0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','s3','uploads/c1/icons/fleet-insights.png','Fleetbase\\RegistryBridge\\Models\\RegistryExtension','flb-prod','fleet-insights.png','extension_icon','image/png',4,'2024-04-01 00:00:00','2024-04-01 00:00:00',NULL),(2,'f0000000-0000-4000-8000-000000000011','file_b1','c0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','s3','uploads/c1/extensions/fleet-insights-1.0.0.tar.gz','Fleetbase\\RegistryBridge\\Models\\RegistryExtension','flb-prod','fleet-insights-1.0.0.tar.gz','extension_bundle','application/gzip',21,'2024-04-01 00:00:00','2024-04-01 00:00:00',NULL),(3,'f0000000-0000-4000-8000-000000000012','file_b2','c0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','s3','uploads/c1/extensions/fleet-insights-1.1.0.tar.gz','Fleetbase\\RegistryBridge\\Models\\RegistryExtension','flb-prod','fleet-insights-1.1.0.tar.gz','extension_bundle','application/gzip',21,'2024-05-01 00:00:00','2024-05-01 00:00:00',NULL),(4,'f0000000-0000-4000-8000-000000000013','file_b3',NULL,'e0000000-0000-4000-8000-000000000002','s3','uploads/developers/route-planner-0.3.0.tar.gz','Fleetbase\\RegistryBridge\\Models\\RegistryExtension','flb-prod','route-planner-0.3.0.tar.gz','extension_bundle','application/gzip',9001,'2024-06-01 00:00:00','2024-06-01 00:00:00',NULL),(5,'f0000000-0000-4000-8000-000000000021','file_shot1','c0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','s3','uploads/c1/screens/dashboard.png','Fleetbase\\RegistryBridge\\Models\\RegistryExtension','flb-prod','dashboard.png','extension_screenshot','image/png',5,'2024-04-01 00:00:00','2024-04-01 00:00:00',NULL),(6,'f0000000-0000-4000-8000-000000000099','file_other','c0000000-0000-4000-8000-000000000002',NULL,'s3','uploads/c2/avatar.png',NULL,'flb-prod','avatar.png','user_avatar','image/png',3,'2024-04-01 00:00:00','2024-04-01 00:00:00',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `categories` +-- + +DROP TABLE IF EXISTS `categories`; +CREATE TABLE `categories` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `internal_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tags` json DEFAULT NULL, + `icon` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `for` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `slug` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `categories` WRITE; +INSERT INTO `categories` VALUES (1,'b0000000-0000-4000-8000-000000000001','category_tele','Telematics','FLBTELEMATICS','Cloud copy of telematics','[\"tracking\"]','satellite-dish','extension_category','telematics','2024-01-01 00:00:00','2024-01-01 00:00:00'),(2,'b0000000-0000-4000-8000-000000000002','category_cust','Custom Tools',NULL,'Tools made by the community','[\"tools\", \"community\"]','toolbox','extension_category','custom-tools','2024-01-01 00:00:00','2024-01-01 00:00:00'); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_developer_accounts` +-- + +DROP TABLE IF EXISTS `registry_developer_accounts`; +CREATE TABLE `registry_developer_accounts` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `username` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `avatar_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `github_username` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `website` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `bio` text COLLATE utf8mb4_unicode_ci, + `email_verified_at` timestamp NULL DEFAULT NULL, + `verification_token` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` enum('active','suspended','pending_verification') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending_verification', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `registry_developer_accounts_uuid_unique` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_developer_accounts` WRITE; +INSERT INTO `registry_developer_accounts` VALUES (1,'d0000000-0000-4000-8000-000000000001','devon','devon@indie.test','$2y$10$ryH8ewFSnZo6gek/TqYuLuVvtmBXBYt9zz33gnbM0VGR1LgOulzvm','Devon Indie',NULL,'devon-gh','https://devon.test','Builds routing tools.\nMostly at night.','2026-02-16 00:00:00',NULL,'active','2026-02-16 00:00:00','2026-02-16 00:00:00',NULL),(2,'d0000000-0000-4000-8000-000000000002','penny','penny@indie.test','$2y$10$ryH8ewFSnZo6gek/TqYuLuVvtmBXBYt9zz33gnbM0VGR1LgOulzvm',NULL,NULL,NULL,NULL,NULL,NULL,'tok123','pending_verification','2026-02-17 00:00:00','2026-02-17 00:00:00',NULL),(3,'d0000000-0000-4000-8000-000000000003','alice-dev','alice@acme.test','$2y$10$ryH8ewFSnZo6gek/TqYuLuVvtmBXBYt9zz33gnbM0VGR1LgOulzvm','Alice (dev)',NULL,NULL,NULL,NULL,'2026-02-18 00:00:00',NULL,'active','2026-02-18 00:00:00','2026-02-18 00:00:00',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_users` +-- + +DROP TABLE IF EXISTS `registry_users`; +CREATE TABLE `registry_users` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `account_type` enum('cloud','developer') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'cloud', + `developer_account_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `company_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `token` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `registry_token` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `scope` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `expires_at` timestamp NULL DEFAULT NULL, + `last_used_at` timestamp NULL DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `meta` json DEFAULT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_users` WRITE; +INSERT INTO `registry_users` VALUES (1,'90000000-0000-4000-8000-000000000001','cloud',NULL,'c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000001','ru_1','plain-token-1','npm-token-1',NULL,NULL,NULL,NULL,NULL,0,'2024-03-19 00:00:00','2024-03-19 00:00:00',NULL),(2,'90000000-0000-4000-8000-000000000002','cloud',NULL,'c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000001','ru_2','plain-token-2',NULL,NULL,NULL,NULL,NULL,NULL,1,'2024-03-20 00:00:00','2024-03-20 00:00:00',NULL),(3,'90000000-0000-4000-8000-000000000003','cloud',NULL,'c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000002','ru_3','plain-token-3',NULL,NULL,NULL,NULL,NULL,NULL,0,'2024-03-21 00:00:00','2024-03-21 00:00:00',NULL),(4,'90000000-0000-4000-8000-000000000004','developer','d0000000-0000-4000-8000-000000000001',NULL,NULL,'ru_4','plain-token-4',NULL,NULL,NULL,NULL,NULL,NULL,0,'2026-02-16 00:00:00','2026-02-16 00:00:00',NULL),(5,'90000000-0000-4000-8000-000000000005','cloud',NULL,'c0000000-0000-4000-8000-000000000002','a0000000-0000-4000-8000-000000000003','ru_5','plain-token-5',NULL,NULL,NULL,NULL,NULL,NULL,0,'2024-07-01 00:00:00','2024-07-01 00:00:00',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_extensions` +-- + +DROP TABLE IF EXISTS `registry_extensions`; +CREATE TABLE `registry_extensions` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `company_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `publisher_type` enum('cloud','developer') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'cloud', + `publisher_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `registry_user_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `current_bundle_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `next_bundle_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `icon_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `category_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `public_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_product_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subtitle` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `self_managed` tinyint(1) NOT NULL DEFAULT '0', + `payment_required` tinyint(1) NOT NULL DEFAULT '0', + `price` int DEFAULT NULL, + `sale_price` int DEFAULT NULL, + `on_sale` tinyint(1) NOT NULL DEFAULT '0', + `subscription_required` tinyint(1) NOT NULL DEFAULT '0', + `currency` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `slug` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `version` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fa_icon` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` mediumtext COLLATE utf8mb4_unicode_ci, + `promotional_text` mediumtext COLLATE utf8mb4_unicode_ci, + `website_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `repo_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `support_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `privacy_policy_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tos_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `copyright` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `primary_language` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tags` json DEFAULT NULL, + `languages` json DEFAULT NULL, + `meta` json DEFAULT NULL, + `core_extension` tinyint(1) NOT NULL DEFAULT '0', + `status` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending', + `package_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `published_at` timestamp NULL DEFAULT NULL, + `accepted_at` timestamp NULL DEFAULT NULL, + `rejected_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_extensions` WRITE; +INSERT INTO `registry_extensions` VALUES (1,'e0000000-0000-4000-8000-000000000001','c0000000-0000-4000-8000-000000000001','cloud','c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000001','90000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000002',NULL,'f0000000-0000-4000-8000-000000000001','b0000000-0000-4000-8000-000000000001','extension_fleet','prod_Fleet','Fleet Insights','Analytics for your fleet',0,1,4900,3900,0,0,'usd','fleet-insights','1.1.0','chart-line','Dashboards and \"insights\" for fleet operators.\r\nSecond line.','Now with 50% more charts','https://acme.test/fleet-insights',NULL,'https://acme.test/support','https://acme.test/privacy',NULL,'© 2024 Acme','en','[\"analytics\", \"fleet\"]','[\"en\", \"zh\"]',NULL,0,'published',NULL,'2024-05-01 10:00:00','2024-05-01 09:00:00',NULL,'2024-03-21 00:00:00','2024-05-01 10:00:00',NULL),(2,'e0000000-0000-4000-8000-000000000002',NULL,'developer','d0000000-0000-4000-8000-000000000001',NULL,'90000000-0000-4000-8000-000000000004','30000000-0000-4000-8000-000000000003',NULL,NULL,'b0000000-0000-4000-8000-000000000002','extension_route','prod_Route','Route Planner','Plan routes offline',1,0,NULL,NULL,0,0,'USD','route-planner','0.3.0','route',NULL,NULL,NULL,'https://github.com/devon/route-planner',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,'published',NULL,'2026-03-01 00:00:00',NULL,NULL,'2026-02-20 00:00:00','2026-03-01 00:00:00',NULL),(3,'e0000000-0000-4000-8000-000000000003','c0000000-0000-4000-8000-000000000002','cloud','c0000000-0000-4000-8000-000000000002',NULL,NULL,NULL,NULL,NULL,NULL,'extension_draft',NULL,'Draft Thing',NULL,0,0,NULL,NULL,0,0,'USD','draft-thing',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,'pending',NULL,NULL,NULL,NULL,'2024-08-01 00:00:00','2024-08-01 00:00:00',NULL),(4,'e0000000-0000-4000-8000-000000000004','c0000000-0000-4000-8000-000000000003','cloud','c0000000-0000-4000-8000-000000000003',NULL,NULL,'30000000-0000-4000-8000-000000000004',NULL,NULL,NULL,'extension_copy',NULL,'Copycat',NULL,0,0,NULL,NULL,0,0,'USD','copycat',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,'awaiting_review',NULL,NULL,NULL,NULL,'2024-09-01 00:00:00','2024-09-01 00:00:00',NULL),(5,'e0000000-0000-4000-8000-000000000005','c0000000-0000-4000-8000-000000000001','cloud','c0000000-0000-4000-8000-000000000001',NULL,NULL,NULL,NULL,NULL,NULL,'extension_old',NULL,'Old Widget',NULL,0,0,NULL,NULL,0,0,'USD','old-widget',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,'unpublished','@acme/old-widget-engine',NULL,NULL,NULL,'2024-02-01 00:00:00','2024-02-01 00:00:00','2024-10-01 00:00:00'); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_extension_bundles` +-- + +DROP TABLE IF EXISTS `registry_extension_bundles`; +CREATE TABLE `registry_extension_bundles` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `public_id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `bundle_id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `company_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `extension_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `bundle_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `bundle_number` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending', + `meta` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_extension_bundles` WRITE; +INSERT INTO `registry_extension_bundles` VALUES (1,'30000000-0000-4000-8000-000000000001','bundle_pub_1','EXTBNDLAAAAAAA','c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','f0000000-0000-4000-8000-000000000011','1','1.0.0','published','{\"extension.json\": {\"name\": \"Fleet Insights\", \"version\": \"1.0.0\"}, \"package.json\": {\"name\": \"@acme/fleet-insights-engine\", \"version\": \"1.0.0\"}, \"composer.json\": {\"name\": \"acme/fleet-insights-api\"}}','2024-04-01 00:00:00','2024-04-02 00:00:00',NULL),(2,'30000000-0000-4000-8000-000000000002','bundle_pub_2','EXTBNDLBBBBBBB','c0000000-0000-4000-8000-000000000001','a0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001','f0000000-0000-4000-8000-000000000012','2','1.1.0','published','{\"extension.json\": {\"name\": \"Fleet Insights\", \"version\": \"1.1.0\", \"compat\": {\"console\": \">=0.7.0\", \"api\": \"^1.6\"}}, \"package.json\": {\"name\": \"@acme/fleet-insights-engine\", \"version\": \"1.1.0\"}, \"composer.json\": {\"name\": \"acme/fleet-insights-api\"}}','2024-05-01 00:00:00','2024-05-01 10:00:00',NULL),(3,'30000000-0000-4000-8000-000000000005','bundle_pub_5','EXTBNDLCCCCCCC','c0000000-0000-4000-8000-000000000001',NULL,'e0000000-0000-4000-8000-000000000001',NULL,'0','v1.0.0','rejected',NULL,'2024-03-01 00:00:00','2024-03-01 00:00:00',NULL),(4,'30000000-0000-4000-8000-000000000003','bundle_pub_3','EXTBNDLDDDDDDD',NULL,NULL,'e0000000-0000-4000-8000-000000000002','f0000000-0000-4000-8000-000000000013','1','0.3.0','published','{\"extension.json\": {\"version\": \"0.3.0\"}, \"package.json\": {\"name\": \"@devon/route-planner-engine\"}, \"composer.json\": {\"name\": \"devon/route-planner-api\"}}','2026-02-25 00:00:00','2026-03-01 00:00:00',NULL),(5,'30000000-0000-4000-8000-000000000006','bundle_pub_6','EXTBNDLEEEEEEE',NULL,NULL,'e0000000-0000-4000-8000-000000000002',NULL,'2',NULL,'pending',NULL,'2026-03-05 00:00:00','2026-03-05 00:00:00',NULL),(6,'30000000-0000-4000-8000-000000000004','bundle_pub_4','EXTBNDLFFFFFFF','c0000000-0000-4000-8000-000000000003',NULL,'e0000000-0000-4000-8000-000000000004','f0000000-0000-4000-8000-000000000014','1','1.0.0','pending','{\"package.json\": {\"name\": \"@acme/fleet-insights-engine\", \"version\": \"1.0.0\"}}','2024-09-01 00:00:00','2024-09-01 00:00:00',NULL); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_extension_purchases` +-- + +DROP TABLE IF EXISTS `registry_extension_purchases`; +CREATE TABLE `registry_extension_purchases` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `purchaser_uuid` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `purchaser_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `company_uuid` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `extension_uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `stripe_checkout_session_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_payment_intent_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_subcription` tinyint(1) NOT NULL DEFAULT '0', + `locked_price` int DEFAULT NULL, + `subscription_billing_period` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `subscription_model` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `meta` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_extension_purchases` WRITE; +INSERT INTO `registry_extension_purchases` VALUES (1,'50000000-0000-4000-8000-000000000001','c0000000-0000-4000-8000-000000000002','Fleetbase\\Models\\Company','c0000000-0000-4000-8000-000000000002','e0000000-0000-4000-8000-000000000001','cs_test_1','pi_test_1',0,4900,NULL,NULL,NULL,'2024-07-02 00:00:00','2024-07-02 00:00:00',NULL),(2,'50000000-0000-4000-8000-000000000002','c0000000-0000-4000-8000-000000000003','Fleetbase\\Models\\Company','c0000000-0000-4000-8000-000000000003','e0000000-0000-4000-8000-000000000001','cs_test_2','pi_test_2',0,NULL,NULL,NULL,NULL,'2024-07-03 00:00:00','2024-07-10 00:00:00','2024-07-10 00:00:00'); +UNLOCK TABLES; + +-- +-- Table structure for table `registry_extension_installs` +-- + +DROP TABLE IF EXISTS `registry_extension_installs`; +CREATE TABLE `registry_extension_installs` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `company_uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `extension_uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `meta` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +LOCK TABLES `registry_extension_installs` WRITE; +INSERT INTO `registry_extension_installs` VALUES (1,'60000000-0000-4000-8000-000000000001','c0000000-0000-4000-8000-000000000002','e0000000-0000-4000-8000-000000000001',NULL,'2024-07-02 01:00:00','2024-07-02 01:00:00',NULL),(2,'60000000-0000-4000-8000-000000000002','c0000000-0000-4000-8000-000000000003','e0000000-0000-4000-8000-000000000002',NULL,'2026-03-10 00:00:00','2026-03-10 00:00:00',NULL),(3,'60000000-0000-4000-8000-000000000003','c0000000-0000-4000-8000-000000000003','e0000000-0000-4000-8000-000000000002',NULL,'2026-03-02 00:00:00','2026-03-05 00:00:00','2026-03-05 00:00:00'),(4,'60000000-0000-4000-8000-000000000004','c0000000-0000-4000-8000-000000000001','e0000000-0000-4000-8000-000000000001',NULL,'2024-05-02 00:00:00','2024-05-02 00:00:00',NULL); +UNLOCK TABLES; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; + +-- Dump completed on 2026-10-20 3:00:01 diff --git a/tests/Unit/SqlDumpLoaderTest.php b/tests/Unit/SqlDumpLoaderTest.php new file mode 100644 index 0000000..f6fa0d6 --- /dev/null +++ b/tests/Unit/SqlDumpLoaderTest.php @@ -0,0 +1,59 @@ +parseInsert( + "INSERT INTO `t` VALUES (1,'it\\'s','a\\\\b\\nc',NULL,-2.5,'{\\\"k\\\": \\\"v\\\"}',_binary 'bin',0x4142,'semi;colon','paren)'),(2,'','x''y',TRUE,1e3,'',_utf8mb4'u',0x,'',NULL);" + ); + + expect($columns)->toBeNull() + ->and($rows)->toBe([ + ['1', "it's", "a\\b\nc", null, '-2.5', '{"k": "v"}', 'bin', 'AB', 'semi;colon', 'paren)'], + ['2', '', "x'y", '1', '1e3', '', 'u', '', '', null], + ]); +}); + +it('reads explicit column lists and asks for more input when a statement is cut', function () { + $loader = new SqlDumpLoader([]); + + [$columns, $rows] = $loader->parseInsert("INSERT IGNORE INTO `t` (`id`, `name`) VALUES\n(1, 'a'),\n(2, 'b');"); + expect($columns)->toBe(['id', 'name'])->and($rows)->toBe([['1', 'a'], ['2', 'b']]); + + expect(fn () => $loader->parseInsert("INSERT INTO `t` (`id`, `name`) VALUES\n(1, 'a'),\n"))->toThrow(IncompleteStatement::class); + expect(fn () => $loader->parseInsert("INSERT INTO `t` VALUES (1,'unterminated"))->toThrow(IncompleteStatement::class); +}); + +it('loads only the wanted tables from a gzipped dump with one tuple per line', function () { + $dir = sys_get_temp_dir().'/dump-test-'.uniqid(); + mkdir($dir); + $dump = $dir.'/dump.sql.gz'; + $sqlite = $dir.'/out.sqlite'; + + file_put_contents($dump, gzencode(implode("\n", [ + 'CREATE TABLE `keep` (', + ' `id` int NOT NULL,', + ' `note` text,', + ' PRIMARY KEY (`id`)', + ') ENGINE=InnoDB;', + 'CREATE TABLE `skip` (`id` int);', + 'INSERT INTO `skip` VALUES (1);', + 'INSERT INTO `keep` (`id`, `note`) VALUES', + "(1, 'first'),", + "(2, 'multi\\nline');", + 'INSERT INTO `keep` VALUES (3,NULL);', + '', + ]))); + + $counts = (new SqlDumpLoader(['keep']))->load($dump, $sqlite); + $rows = (new PDO('sqlite:'.$sqlite))->query('select id, note from keep order by id')->fetchAll(PDO::FETCH_NUM); + $tables = (new PDO('sqlite:'.$sqlite))->query("select name from sqlite_master where type = 'table'")->fetchAll(PDO::FETCH_COLUMN); + + expect($counts)->toBe(['keep' => 3]) + ->and($rows)->toBe([['1', 'first'], ['2', "multi\nline"], ['3', null]]) + ->and($tables)->toBe(['keep']); + + array_map('unlink', glob($dir.'/*')); + rmdir($dir); +}); From 457b8fbee79009fb83c585b9dd07b9abbdd670ce Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 16:43:12 +0800 Subject: [PATCH 15/16] Fold Compat::compare and normalizeVersion into Semver Semver is now the one version comparator: normalize() pads loose versions (v1.2 -> 1.2.0), compare() normalizes before applying SemVer precedence, and isNewer() moves over from Compat. Compat keeps range evaluation and gains validRange() for the release checks. --- .../Instance/InstanceController.php | 3 +- app/Import/RegistryImporter.php | 3 +- app/Support/Compat.php | 50 +++++++------------ app/Support/Semver.php | 44 ++++++++++++++-- tests/Unit/CompatTest.php | 11 ++-- tests/Unit/Support/SemverTest.php | 21 ++++++++ 6 files changed, 86 insertions(+), 46 deletions(-) diff --git a/app/Http/Controllers/Instance/InstanceController.php b/app/Http/Controllers/Instance/InstanceController.php index 5b74b3f..5efa1c6 100644 --- a/app/Http/Controllers/Instance/InstanceController.php +++ b/app/Http/Controllers/Instance/InstanceController.php @@ -17,6 +17,7 @@ use App\Models\Organization; use App\Models\Release; use App\Support\Compat; +use App\Support\Semver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -132,7 +133,7 @@ public function updates(Request $request): JsonResponse 'extension' => (new ExtensionReferenceResource($install->extension))->resolve($request), 'installed' => $install->release ? (new ReleaseResource($install->release))->resolve($request) : null, 'latest' => $latest ? (new ReleaseResource($latest))->resolve($request) : null, - 'update_available' => $latest !== null && Compat::isNewer($latest->version, $install->release?->version), + 'update_available' => $latest !== null && Semver::isNewer($latest->version, $install->release?->version), ]; })->values(); diff --git a/app/Import/RegistryImporter.php b/app/Import/RegistryImporter.php index c2fb26f..893502f 100644 --- a/app/Import/RegistryImporter.php +++ b/app/Import/RegistryImporter.php @@ -20,6 +20,7 @@ use App\Models\Purchase; use App\Models\Release; use App\Support\Compat; +use App\Support\Semver; use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Database\Eloquent\SoftDeletes; @@ -770,7 +771,7 @@ protected function importRelease(object $bundle, array $meta, string $version, E 'extension_uuid' => $extension->uuid, 'created_by_account_uuid' => ($this->userAccounts[$bundle->created_by_uuid ?? ''] ?? null)?->uuid, 'version' => $version, - 'channel' => Compat::normalizeVersion($version) !== null && str_contains($version, '-') ? Release::CHANNEL_BETA : Release::CHANNEL_STABLE, + 'channel' => Semver::normalize($version) !== null && str_contains($version, '-') ? Release::CHANNEL_BETA : Release::CHANNEL_STABLE, 'manifest' => array_merge($this->bundleFile($meta, 'extension.json') ?: [], array_filter(['bundle_number' => $bundle->bundle_number ?? null])), 'compat' => $this->compat($meta), 'published_at' => $publishedAt, diff --git a/app/Support/Compat.php b/app/Support/Compat.php index e336f10..ab900b9 100644 --- a/app/Support/Compat.php +++ b/app/Support/Compat.php @@ -3,7 +3,6 @@ namespace App\Support; use App\Models\Release; -use Composer\Semver\Comparator; use Composer\Semver\Constraint\Constraint; use Composer\Semver\VersionParser; use Illuminate\Support\Collection; @@ -16,6 +15,7 @@ * Rules: a side with no range accepts any version; an instance that does not report a * version (or reports one that is not semver) is not constrained on that side; a range * that does not parse never matches, so a broken manifest cannot be offered as an update. + * Version ordering is `Semver`'s; this class only evaluates ranges. */ class Compat { @@ -32,7 +32,7 @@ public static function satisfies(?string $version, ?string $range): bool return false; } - $version = static::normalizeVersion($version); + $version = Semver::normalize($version); if ($version === null) { return true; } @@ -51,48 +51,32 @@ public static function releaseIsCompatible(Release $release, ?string $consoleVer } /** - * Highest-versioned release in the collection; unparseable versions sort last. + * Whether a range parses with composer/semver. Empty means "any version". */ - public static function latest(Collection $releases): ?Release - { - return $releases - ->filter(fn (Release $release) => static::normalizeVersion($release->version) !== null) - ->sort(fn (Release $a, Release $b) => static::compare($b->version, $a->version)) - ->first(); - } - - /** - * -1, 0 or 1 like the spaceship operator. - */ - public static function compare(string $a, string $b): int + public static function validRange(?string $range): bool { - if (Comparator::equalTo($a, $b)) { - return 0; + $range = trim((string) $range); + if ($range === '' || $range === '*') { + return true; } - return Comparator::greaterThan($a, $b) ? 1 : -1; - } - - public static function isNewer(?string $candidate, ?string $current): bool - { - if ($candidate === null || static::normalizeVersion($candidate) === null) { - return false; - } + try { + (new VersionParser)->parseConstraints($range); - if ($current === null || static::normalizeVersion($current) === null) { return true; + } catch (\UnexpectedValueException) { + return false; } - - return static::compare($candidate, $current) > 0; } /** - * The version without a leading `v`, or null when it is not a semver version. + * Highest-versioned release in the collection; unparseable versions are left out. */ - public static function normalizeVersion(?string $version): ?string + public static function latest(Collection $releases): ?Release { - $version = ltrim(trim((string) $version), 'vV'); - - return preg_match('/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/', $version) === 1 ? $version : null; + return $releases + ->filter(fn (Release $release) => Semver::normalize($release->version) !== null) + ->sort(fn (Release $a, Release $b) => Semver::compare($b->version, $a->version)) + ->first(); } } diff --git a/app/Support/Semver.php b/app/Support/Semver.php index 2d8f5be..373d879 100644 --- a/app/Support/Semver.php +++ b/app/Support/Semver.php @@ -3,8 +3,9 @@ namespace App\Support; /** - * Semantic Versioning 2.0.0 parsing and precedence, enough to order releases and pick - * dist-tags. Ranges and constraints are out of scope. + * Semantic Versioning 2.0.0 parsing and precedence: the one version comparator in the + * service. Orders releases, picks dist-tags, decides updates and enforces that a new + * release is greater than the last one on its channel. Ranges live in `Compat`. */ class Semver { @@ -38,6 +39,21 @@ public static function valid(string $version): bool return self::parse($version) !== null; } + /** + * The version as strict SemVer without a leading `v`, padding a missing minor or patch + * with zeros (`v1.2` → `1.2.0`), or null when it is not a version at all (`dev-main`). + */ + public static function normalize(?string $version): ?string + { + $version = ltrim(trim((string) $version), 'vV'); + + if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?((?:-|\+).*)?$/', $version, $m) === 1 && ($m[3] ?? '') === '') { + $version = $m[1].'.'.(($m[2] ?? '') !== '' ? $m[2] : '0').'.0'.($m[4] ?? ''); + } + + return self::parse($version) !== null ? $version : null; + } + public static function isComposerCompatible(string $version): bool { return preg_match(self::COMPOSER_PATTERN, $version) === 1; @@ -49,12 +65,13 @@ public static function isPrerelease(string $version): bool } /** - * -1, 0 or 1 by SemVer precedence. Invalid versions sort below valid ones. + * -1, 0 or 1 by SemVer precedence, after `normalize()`. Build metadata is ignored. + * Invalid versions sort below valid ones. */ public static function compare(string $a, string $b): int { - $pa = self::parse($a); - $pb = self::parse($b); + $pa = self::parse(self::normalize($a) ?? $a); + $pb = self::parse(self::normalize($b) ?? $b); if ($pa === null || $pb === null) { return ($pa !== null) <=> ($pb !== null) ?: strcmp($a, $b) <=> 0; @@ -95,6 +112,23 @@ public static function compare(string $a, string $b): int return 0; } + /** + * True when the candidate is a version and has higher precedence than the current one; + * anything beats no (or an unparseable) current version. + */ + public static function isNewer(?string $candidate, ?string $current): bool + { + if ($candidate === null || self::normalize($candidate) === null) { + return false; + } + + if ($current === null || self::normalize($current) === null) { + return true; + } + + return self::compare($candidate, $current) > 0; + } + /** * Sort ascending by precedence. * diff --git a/tests/Unit/CompatTest.php b/tests/Unit/CompatTest.php index 962ee52..6abf9bf 100644 --- a/tests/Unit/CompatTest.php +++ b/tests/Unit/CompatTest.php @@ -20,10 +20,9 @@ 'broken range' => ['1.0.0', 'not a range', false], ]); -it('orders versions by semver precedence', function () { - expect(Compat::isNewer('1.10.0', '1.9.0'))->toBeTrue() - ->and(Compat::isNewer('1.0.0', '1.0.0-beta.1'))->toBeTrue() - ->and(Compat::isNewer('1.0.0', '1.0.0'))->toBeFalse() - ->and(Compat::isNewer('garbage', '1.0.0'))->toBeFalse() - ->and(Compat::isNewer('1.0.0', null))->toBeTrue(); +it('checks that ranges parse', function () { + expect(Compat::validRange('>=0.7 <1.0'))->toBeTrue() + ->and(Compat::validRange('^1.6 || ^2.0'))->toBeTrue() + ->and(Compat::validRange(null))->toBeTrue() + ->and(Compat::validRange('not a range'))->toBeFalse(); }); diff --git a/tests/Unit/Support/SemverTest.php b/tests/Unit/Support/SemverTest.php index 1a7d5fc..d9c32f8 100644 --- a/tests/Unit/Support/SemverTest.php +++ b/tests/Unit/Support/SemverTest.php @@ -27,3 +27,24 @@ ->and(Semver::isComposerCompatible('2.0.0-RC2'))->toBeTrue() ->and(Semver::isComposerCompatible('1.2.0-next.1'))->toBeFalse(); }); + +it('normalizes loose versions and compares them with one comparator', function () { + expect(Semver::normalize('v1.2'))->toBe('1.2.0') + ->and(Semver::normalize('V2'))->toBe('2.0.0') + ->and(Semver::normalize('1.2-beta.1'))->toBe('1.2.0-beta.1') + ->and(Semver::normalize('1.2.3+build.5'))->toBe('1.2.3+build.5') + ->and(Semver::normalize('dev-main'))->toBeNull() + ->and(Semver::normalize('1.2.3.4'))->toBeNull() + ->and(Semver::normalize(null))->toBeNull() + ->and(Semver::compare('v1.2', '1.2.0'))->toBe(0) + ->and(Semver::compare('1.10', '1.9.9'))->toBe(1); +}); + +it('decides whether a version is newer', function () { + expect(Semver::isNewer('1.10.0', '1.9.0'))->toBeTrue() + ->and(Semver::isNewer('1.0.0', '1.0.0-beta.1'))->toBeTrue() + ->and(Semver::isNewer('1.0.0', '1.0.0'))->toBeFalse() + ->and(Semver::isNewer('garbage', '1.0.0'))->toBeFalse() + ->and(Semver::isNewer('1.0.0', null))->toBeTrue() + ->and(Semver::isNewer('1.0.0', 'dev-main'))->toBeTrue(); +}); From c366ecf83a8b1f8a2537ee6fb13cc8ee2367f819 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 16:43:16 +0800 Subject: [PATCH 16/16] Add the releases pipeline (Phase 2 Workstream E) Publisher API (publish scope, owner/admin/publisher role): create listings idempotently on package names with the namespace rules, edit listing fields, upload releases (manifest, engine, api, optional prebuilt dist), submit, withdraw, yank. RunReleaseChecks inspects each archive once and records release_checks for the manifest (extension.json JSON Schema shared with registry-bridge), compat ranges, package names against extension_packages, version order per channel, size limits, archive safety (traversal, links, devices, forbidden types, native binaries), secrets (built-in gitleaks-style rules, plus gitleaks when installed) and the build-engine-release dist manifest. Release state machine with a reviews row per transition; review policy D6 (human review for first releases and package name, pricing or permissions changes, auto-approve and publish otherwise); staff admin API for the queue, claim, approve, reject with reason and items, publish and suspend. Publishing sets published_at, lists draft extensions, flushes the catalog cache through a generation key, and emails the uploader. --- .env.example | 10 + Dockerfile | 3 + README.md | 122 ++++- .../Controllers/Admin/ReviewController.php | 114 +++++ .../Controllers/Catalog/CatalogController.php | 8 +- .../Publisher/ExtensionController.php | 228 +++++++++ .../Publisher/PublisherController.php | 117 +++++ .../Publisher/ReleaseController.php | 99 ++++ app/Http/Middleware/EnsureStaff.php | 25 + .../Requests/Admin/ReviewDecisionRequest.php | 43 ++ .../Admin/SuspendExtensionRequest.php | 18 + .../Publisher/CreateExtensionRequest.php | 50 ++ app/Http/Requests/Publisher/ListingRules.php | 44 ++ .../Publisher/UpdateExtensionRequest.php | 20 + .../Publisher/UploadReleaseRequest.php | 93 ++++ .../Resources/PublisherExtensionResource.php | 55 +++ app/Http/Resources/ReleaseCheckResource.php | 22 + app/Http/Resources/ReleaseDetailResource.php | 71 +++ app/Http/Resources/ReviewResource.php | 23 + app/Jobs/RunReleaseChecks.php | 50 ++ app/Mail/ReleaseDecisionMail.php | 61 +++ app/Models/Account.php | 20 + app/Models/Membership.php | 3 + app/Models/Release.php | 64 ++- app/Models/ReleaseCheck.php | 13 +- app/Models/Review.php | 24 +- app/Models/Token.php | 5 +- app/Providers/AppServiceProvider.php | 5 + app/Releases/Archives/ArchiveInspector.php | 135 ++++++ app/Releases/Archives/ArchiveInventory.php | 61 +++ app/Releases/Archives/ReleaseArchives.php | 89 ++++ app/Releases/Checks/ArchiveCheck.php | 83 ++++ app/Releases/Checks/Check.php | 11 + app/Releases/Checks/CheckContext.php | 58 +++ app/Releases/Checks/CheckResult.php | 57 +++ app/Releases/Checks/CompatCheck.php | 41 ++ app/Releases/Checks/DistCheck.php | 137 ++++++ app/Releases/Checks/ManifestCheck.php | 45 ++ app/Releases/Checks/PackageNamesCheck.php | 84 ++++ app/Releases/Checks/SecretsScanCheck.php | 90 ++++ app/Releases/Checks/SizeCheck.php | 58 +++ app/Releases/Checks/VersionCheck.php | 64 +++ app/Releases/ExtensionManifest.php | 79 +++ app/Releases/InvalidReleaseTransition.php | 16 + app/Releases/ListingReadiness.php | 58 +++ app/Releases/PackageNamespaces.php | 55 +++ app/Releases/ReleaseNotReady.php | 20 + app/Releases/ReleasePipeline.php | 454 ++++++++++++++++++ app/Releases/ReleaseStateMachine.php | 114 +++++ app/Releases/ReviewPolicy.php | 160 ++++++ app/Releases/Secrets/Gitleaks.php | 77 +++ app/Releases/Secrets/SecretRules.php | 148 ++++++ app/Services/ReleaseStorage.php | 7 +- app/Support/CatalogCache.php | 33 ++ app/Support/Tarball.php | 57 ++- bootstrap/app.php | 2 + composer.json | 3 +- composer.lock | 192 +++++++- config/registry.php | 29 +- database/factories/ExtensionFactory.php | 14 + ...dd_transition_columns_to_reviews_table.php | 31 ++ docker-compose.yml | 7 +- resources/schemas/extension.schema.json | 119 +++++ .../views/mail/release-decision.blade.php | 38 ++ routes/api.php | 35 +- .../Releases/PublisherExtensionsTest.php | 177 +++++++ tests/Feature/Releases/ReleaseChecksTest.php | 245 ++++++++++ tests/Feature/Releases/ReleaseUploadTest.php | 141 ++++++ tests/Feature/Releases/ReviewWorkflowTest.php | 320 ++++++++++++ tests/Helpers/releases.php | 176 +++++++ tests/Pest.php | 26 +- tests/Unit/Releases/ArchiveInspectorTest.php | 64 +++ tests/Unit/Releases/ExtensionManifestTest.php | 28 ++ tests/Unit/Releases/SecretRulesTest.php | 58 +++ 74 files changed, 5360 insertions(+), 46 deletions(-) create mode 100644 app/Http/Controllers/Admin/ReviewController.php create mode 100644 app/Http/Controllers/Publisher/ExtensionController.php create mode 100644 app/Http/Controllers/Publisher/PublisherController.php create mode 100644 app/Http/Controllers/Publisher/ReleaseController.php create mode 100644 app/Http/Middleware/EnsureStaff.php create mode 100644 app/Http/Requests/Admin/ReviewDecisionRequest.php create mode 100644 app/Http/Requests/Admin/SuspendExtensionRequest.php create mode 100644 app/Http/Requests/Publisher/CreateExtensionRequest.php create mode 100644 app/Http/Requests/Publisher/ListingRules.php create mode 100644 app/Http/Requests/Publisher/UpdateExtensionRequest.php create mode 100644 app/Http/Requests/Publisher/UploadReleaseRequest.php create mode 100644 app/Http/Resources/PublisherExtensionResource.php create mode 100644 app/Http/Resources/ReleaseCheckResource.php create mode 100644 app/Http/Resources/ReleaseDetailResource.php create mode 100644 app/Http/Resources/ReviewResource.php create mode 100644 app/Jobs/RunReleaseChecks.php create mode 100644 app/Mail/ReleaseDecisionMail.php create mode 100644 app/Releases/Archives/ArchiveInspector.php create mode 100644 app/Releases/Archives/ArchiveInventory.php create mode 100644 app/Releases/Archives/ReleaseArchives.php create mode 100644 app/Releases/Checks/ArchiveCheck.php create mode 100644 app/Releases/Checks/Check.php create mode 100644 app/Releases/Checks/CheckContext.php create mode 100644 app/Releases/Checks/CheckResult.php create mode 100644 app/Releases/Checks/CompatCheck.php create mode 100644 app/Releases/Checks/DistCheck.php create mode 100644 app/Releases/Checks/ManifestCheck.php create mode 100644 app/Releases/Checks/PackageNamesCheck.php create mode 100644 app/Releases/Checks/SecretsScanCheck.php create mode 100644 app/Releases/Checks/SizeCheck.php create mode 100644 app/Releases/Checks/VersionCheck.php create mode 100644 app/Releases/ExtensionManifest.php create mode 100644 app/Releases/InvalidReleaseTransition.php create mode 100644 app/Releases/ListingReadiness.php create mode 100644 app/Releases/PackageNamespaces.php create mode 100644 app/Releases/ReleaseNotReady.php create mode 100644 app/Releases/ReleasePipeline.php create mode 100644 app/Releases/ReleaseStateMachine.php create mode 100644 app/Releases/ReviewPolicy.php create mode 100644 app/Releases/Secrets/Gitleaks.php create mode 100644 app/Releases/Secrets/SecretRules.php create mode 100644 app/Support/CatalogCache.php create mode 100644 database/migrations/2026_10_22_000004_add_transition_columns_to_reviews_table.php create mode 100644 resources/schemas/extension.schema.json create mode 100644 resources/views/mail/release-decision.blade.php create mode 100644 tests/Feature/Releases/PublisherExtensionsTest.php create mode 100644 tests/Feature/Releases/ReleaseChecksTest.php create mode 100644 tests/Feature/Releases/ReleaseUploadTest.php create mode 100644 tests/Feature/Releases/ReviewWorkflowTest.php create mode 100644 tests/Helpers/releases.php create mode 100644 tests/Unit/Releases/ArchiveInspectorTest.php create mode 100644 tests/Unit/Releases/ExtensionManifestTest.php create mode 100644 tests/Unit/Releases/SecretRulesTest.php diff --git a/.env.example b/.env.example index bc12f2f..53f3bc1 100644 --- a/.env.example +++ b/.env.example @@ -77,5 +77,15 @@ REGISTRY_PUBLIC_URL= REGISTRY_PACKAGE_METADATA_CACHE_SECONDS=60 HASH_VERIFY=true +# Release pipeline limits and the secrets scanner (auto | builtin | off). +# REGISTRY_MAX_BUNDLE_BYTES=52428800 +# REGISTRY_MAX_DIST_BYTES=104857600 +# REGISTRY_MAX_UNPACKED_BYTES=314572800 +# REGISTRY_MAX_FILE_BYTES=26214400 +# REGISTRY_MAX_ARCHIVE_ENTRIES=20000 +REGISTRY_SECRETS_SCANNER=auto +# REGISTRY_GITLEAKS_BINARY=gitleaks +# REGISTRY_CHECKS_QUEUE= + # Sentry (optional) SENTRY_LARAVEL_DSN= diff --git a/Dockerfile b/Dockerfile index d713e08..6e82b10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,10 @@ RUN --mount=type=cache,target=/tmp/cache \ # ---- Runtime --------------------------------------------------------------- FROM serversideup/php:8.3-fpm-nginx-alpine AS base +# Release uploads: two 50 MB archives plus a 100 MB prebuilt dist (config/registry.php). ENV PHP_OPCACHE_ENABLE=1 \ + PHP_UPLOAD_MAX_FILE_SIZE=110M \ + PHP_POST_MAX_SIZE=220M \ AUTORUN_ENABLED=false \ AUTORUN_LARAVEL_MIGRATION=false \ NGINX_WEBROOT=/var/www/html/public \ diff --git a/README.md b/README.md index a0c3d10..ca3c537 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ there until the publishing cutover. ## Status -Phase 1, Workstreams A to C: +Phase 1, Workstreams A to C, and Phase 2 Workstream E: | Area | State | | --- | --- | @@ -22,7 +22,8 @@ Phase 1, Workstreams A to C: | Package store (`ReleaseStorage`), npm packument and tarball endpoints, Composer v2 repository, licence gate, `@fleetbase` namespace protection | done | | Instance API: registration codes, register, me, updates, installs, licence seats | done | | `registry:import` from the cloud registry-bridge tables (dry run, anomaly report, tarball copy) | done | -| Releases upload, checks, review; Stripe | later workstreams | +| Releases pipeline: publisher API, upload, queued checks, state machine, D6 review policy, staff review API, publishing | done | +| Publisher website (Workstream F), CLI v2 (G), Stripe (Phase 4) | later workstreams | ## Stack @@ -56,6 +57,9 @@ docker compose up --build | Mailpit (verification emails) | http://localhost:8025 | | MySQL | localhost:3307 (registry / registry) | +Release checks run on the queue. `docker compose` starts a worker; with `php artisan serve` +run `php artisan queue:work` beside it (or set `QUEUE_CONNECTION=sync`). + Run the tests: ```bash @@ -91,7 +95,7 @@ validation failures add a `fields` map. Resources are returned unwrapped; paged | `POST /v1/auth/logout` | Revokes the token used for the request | | `GET /v1/account` | The caller's account with organizations and roles | | `GET /v1/account/tokens` | The caller's tokens, masked (`flbp_…a1b2`) | -| `POST /v1/account/tokens` | name, scopes (`read`, `publish`, `install`, `instance:register`, `*`), expires_in_days (max 365), optional organization. Plaintext returned once | +| `POST /v1/account/tokens` | name, scopes (`read`, `publish`, `install`, `instance:register`, `review`, `*`), expires_in_days (max 365), optional organization. Plaintext returned once | | `DELETE /v1/account/tokens/{id}` | Revoke | | `POST /v1/account/instance-codes` | Optional organization (slug or id). Mints a single-use instance registration code (`XXXX-XXXX-XXXX-XXXX`) valid 15 minutes. Needs the `instance:register` scope and the owner or admin role; defaults to the token's organization, else the personal one | @@ -165,6 +169,107 @@ URLs written into metadata use the public host. to extensions of an organization flagged `is_staff` (`php artisan registry:staff [--revoke]`). +### Publisher API (personal token, `publish` scope) + +The caller needs the owner, admin or publisher role in the listing's organization; a token +pinned to an organization reaches only that organization. Other publishers' listings and +releases answer 404. + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/publisher/extensions` | The caller's listings, with package names, price and `readiness` | +| `POST /v1/publisher/extensions` | name, `packages: {npm, composer}` (one or both), optional slug, organization and listing fields. Creates a draft listing with a free price (201). Idempotent on package names: names that already belong to one of the caller's listings return it (200) and add any package kind it lacks; a different name for a kind it has, names spread over two listings, or names of another publisher answer 409 | +| `GET /v1/publisher/extensions/{slug}` | Listing with its releases | +| `PATCH /v1/publisher/extensions/{slug}` | Listing fields (name, subtitle, description, promotional_text, category, tags, languages, hosting, website/repo/support/privacy/tos URLs, copyright, fa_icon). No review | +| `GET /v1/publisher/extensions/{slug}/releases` | Every release with its checks | +| `POST /v1/publisher/extensions/{slug}/releases` | Multipart: `manifest` (extension.json), `engine` (npm tarball, gzipped) and/or `api` (Composer tar or tar.gz), optional `dist` (the `/` folder from ember-core's `fleetbase-build-engine-release`, tarred), optional `channel` (`stable`, `beta`; default from the manifest), `notes`, `submit` (submit as soon as the checks pass). Returns the release (201) | +| `GET /v1/publisher/releases/{id}` | Files, checks, submission state, review policy outcome, the review timeline with reasons and items | +| `POST /v1/publisher/releases/{id}/submit` | 200 once submitted (or published, see D6); 202 while checks are still running, then it is submitted when they pass; 422 with reasons when checks failed or the listing is not ready | +| `POST /v1/publisher/releases/{id}/withdraw` | Out of review, back to `uploaded`. Optional reason | +| `POST /v1/publisher/releases/{id}/yank` | Published → yanked: gone from metadata and the catalog's latest version; the archives stay downloadable for lockfiles. Optional reason | + +Upload rules: the manifest must be a JSON object with a SemVer `version` (no `v`) and the +request needs an engine or an api archive (422 otherwise). The archives are limited by +`REGISTRY_MAX_BUNDLE_BYTES` (50 MB each) and `REGISTRY_MAX_DIST_BYTES` (100 MB). Uploading a +version whose release was never submitted, failed its checks or was rejected replaces it +(files, checks and timeline deleted; the timeline is kept in `events`); any other existing +version answers 409. + +**Namespaces.** `@fleetbase/*` and `fleetbase/*` are for staff organizations. A scope or vendor +that another organization's packages use is refused. Otherwise a new scope must be the +organization's slug, one it already publishes under, or one staff granted in +`organizations.meta.namespaces`. Unscoped npm names are allowed. + +**Listing readiness** (the bridge's `isExtensionReadyForSubmission`, server-side): before a +listing's first release can be submitted it needs a name over 3 characters, subtitle, +description over 12 characters, promotional text, a tag, copyright, website, support and +privacy URLs, an icon (`fa_icon` counts) and a category. Published listings are not +re-checked. + +### Release checks + +`RunReleaseChecks` (queue `REGISTRY_CHECKS_QUEUE`, default queue otherwise) copies the stored +archives locally, reads each once without extracting it, and writes one `release_checks` row +per check with `passed` and `output` (`summary`, `errors`, `warnings`, details): + +| Check | Fails when | +| --- | --- | +| `manifest` | extension.json does not validate against `resources/schemas/extension.schema.json` (draft 2020-12, shared with registry-bridge); `engine`/`api` declared without the archive or the reverse; version or channel differ from the release | +| `compat` | `compat.console` or `compat.api` does not parse as a composer/semver range (missing ranges are a warning) | +| `package_names` | the `package.json` / `composer.json` name is not the listing's `extension_packages` name of that kind, or disagrees with the manifest; the engine's `package.json` version is not the release version | +| `version` | not greater than every published or yanked release on the same channel; not installable by Composer (`-next.1` and the like) when there is an api archive | +| `size` | an archive over its limit, more than `REGISTRY_MAX_ARCHIVE_ENTRIES` entries, unpacked size over `REGISTRY_MAX_UNPACKED_BYTES`, a file over `REGISTRY_MAX_FILE_BYTES` | +| `archive` | not a readable tar (header checksums are verified); engine not gzipped; absolute paths, `..`, drive letters or backslashes; symlinks, hard links, devices, FIFOs; a forbidden extension (`registry.releases.forbidden_extensions`); a native executable by magic number (ELF, Mach-O, PE) | +| `secrets_scan` | the built-in gitleaks-style ruleset (`App\Releases\Secrets\SecretRules`: AWS, GitHub, GitLab, Slack, Stripe, private keys, Google, npm, SendGrid, Twilio, Mailgun, OpenAI, Anthropic, Fleetbase tokens; `.env`, SSH keys, key stores, `.npmrc`/`auth.json` with literal tokens) finds something. With `REGISTRY_SECRETS_SCANNER=auto` gitleaks also runs when its binary is on the path (`REGISTRY_GITLEAKS_BINARY`); without it the check warns that only the built-in rules ran. `off` skips the check with a warning. Matches are redacted; `gitleaks:allow` on a line suppresses it | +| `dist` | only with a `dist` upload: no `manifest.json`, no `builtAgainst` version for `@fleetbase/ember-ui` or `@fleetbase/ember-core`, name or version not the engine's, no `runtime` entry, a listed file missing or with a different sha256. Skipped (passed) without a dist | + +The job records `release.meta.package_names` and `release.meta.dist` (`built_against`) for the +review policy and the installer. If the job itself fails the release goes to `failed_checks`. +It may run for up to 900 s, so the worker's queue needs `retry_after` above that +(`DB_QUEUE_RETRY_AFTER=960` in the compose stack) and `queue:work --timeout=900`. The worker +needs the same storage disk and cache store as the API, because an auto-approved release is +published from the job. + +### Release states and review (D6) + +``` +uploaded ──check──▶ checking ──pass_checks──▶ uploaded (ready to submit) + └──fail_checks──▶ failed_checks +uploaded | rejected ──submit──▶ submitted ──claim──▶ in_review +submitted | in_review ──approve──▶ approved ──publish──▶ published ──yank──▶ yanked +submitted | in_review | approved ──reject──▶ rejected +submitted | in_review | approved ──withdraw──▶ uploaded +``` + +Every transition writes a `reviews` row (`action`, `from_status`, `to_status`, `actor` +`publisher`/`staff`/`system`, reviewer, `reason`, `items`) in the same transaction, with the +release row locked. Other moves answer 409. + +On submit, review policy D6 decides. A human review is required for an extension's first +release and when, compared with the last published (or yanked) release, the package names, +the price (model, amount, currency; snapshotted on each release at publish) or the manifest's +`permissions` changed, or the listing is not published. The reasons are stored in +`release.meta.review_policy` and returned as `review.reasons`. Otherwise the release is +approved by `system` and published straight away. + +Publishing sets `published_at`, snapshots the price, publishes a draft listing (status and +`published_at`), flushes the catalog cache (`App\Support\CatalogCache` bumps a generation key, +so it works on every cache store), and emails the uploader. npm dist-tags and Composer +metadata are built from published releases, so they follow at once. Rejections and failed +checks are emailed too. + +### Admin API (personal token, `review` scope, member of an `is_staff` organization) + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/admin/releases?status=&extension=&limit=` | The review queue, oldest first. Default statuses `submitted`, `in_review`, `approved` | +| `GET /v1/admin/releases/{id}` | A release with checks, policy reasons and timeline | +| `POST /v1/admin/releases/{id}/claim` | submitted → in_review | +| `POST /v1/admin/releases/{id}/approve` | Optional `reason`, `items`; publishes unless `publish: false` | +| `POST /v1/admin/releases/{id}/reject` | Required `reason` and `items: [{code, passed, note?}]` (at least one) | +| `POST /v1/admin/releases/{id}/publish` | An approved release | +| `POST /v1/admin/extensions/{slug}/suspend` | Required `reason`. The listing leaves the catalog and the package endpoints; uploads and publishing stop | + ### Instance API (instance token) An organization owner or admin mints a registration code, pastes it into the instance (console @@ -262,19 +367,26 @@ app/ Casts/Money.php minor-unit money cast Console/Commands/ registry:seed, registry:staff, registry:import Http/Controllers/Packages/ npm and Composer endpoints + Http/Controllers/Publisher/ publisher extensions and releases + Http/Controllers/Admin/ staff review API + Jobs/RunReleaseChecks.php queued release checks + Releases/ pipeline, state machine, review policy (D6), listing readiness, + namespace rules, manifest schema, checks, archive inspection, secrets Import/ importer: SQL dump loader, source connection, mapping, report Http/Filter/ request-driven query filters (ExtensionFilter) Http/Middleware/ scope:…, licence:npm|composer Http/Resources/ Fleetbase-style JSON resources Models/ one model per table; Model.php is the base Services/ ReleaseStorage, LicenceGate, Packages/ (packument, Composer metadata) - Support/ Http, Utils, Semver, Tarball and Compat (semver ranges) helpers + Support/ Http, Utils, Semver (the one version comparator), Compat (ranges), + Tarball, CatalogCache database/fixtures/packages/ fixture tarballs for registry:seed --seed-release database/migrations/ the data model database/seeders/ CategorySeeder (41 categories from registry-bridge) docker/ compose helpers legacy/ the Verdaccio registry (v1) -tests/Feature/{Auth,Catalog,Packages,Instance,Import} Pest feature tests (SQLite in memory) +resources/schemas/ extension.json JSON Schema (copy of registry-bridge's) +tests/Feature/{Auth,Catalog,Packages,Instance,Import,Releases} Pest feature tests (SQLite in memory) tests/Conformance/packages.sh real npm and Composer clients against a running service tests/Fixtures/import/ mysqldump fixture of the cloud registry tables ``` diff --git a/app/Http/Controllers/Admin/ReviewController.php b/app/Http/Controllers/Admin/ReviewController.php new file mode 100644 index 0000000..40d4582 --- /dev/null +++ b/app/Http/Controllers/Admin/ReviewController.php @@ -0,0 +1,114 @@ +validate([ + 'status' => ['nullable', 'string'], + 'extension' => ['nullable', 'string', 'max:191'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $statuses = $request->filled('status') ? explode(',', $request->input('status')) : Release::QUEUE_STATUSES; + $request->merge(['statuses' => $statuses])->validate(['statuses.*' => [Rule::in(Release::STATUSES)]]); + + $releases = Release::query() + ->with(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks']) + ->whereIn('status', $statuses) + ->when($request->filled('extension'), fn (Builder $q) => $q->whereHas('extension', fn (Builder $q) => $q->where('slug', $request->input('extension'))->orWhere('public_id', $request->input('extension')))) + ->orderBy('updated_at') + ->orderBy('id') + ->paginate((int) $request->input('limit', 25)) + ->withQueryString(); + + return ReleaseDetailResource::collection($releases); + } + + public function show(string $release): ReleaseDetailResource + { + return new ReleaseDetailResource(PublisherController::releaseDetail(Release::findByIdOrFail($release))); + } + + /** + * POST /v1/admin/releases/{id}/claim — submitted → in_review. + */ + public function claim(Request $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->claim(Release::findByIdOrFail($release), $request->user()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/approve — optional reason and items; publishes unless + * `publish` is false. + */ + public function approve(ReviewDecisionRequest $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->approve( + Release::findByIdOrFail($release), + $request->user(), + $request->validated('reason'), + $request->items(), + $request->boolean('publish', true), + ); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/reject — required reason and items. + */ + public function reject(ReviewDecisionRequest $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->reject(Release::findByIdOrFail($release), $request->user(), $request->validated('reason'), $request->items()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/publish — an approved release. + */ + public function publish(Request $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->publish(Release::findByIdOrFail($release), $request->user()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/extensions/{slug}/suspend — required reason. + */ + public function suspend(SuspendExtensionRequest $request, string $slug): PublisherExtensionResource + { + $extension = Extension::query()->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug))->firstOrFail(); + $extension = $this->pipeline->suspend($extension, $request->user(), $request->validated('reason')); + + return new PublisherExtensionResource($extension->load(['organization', 'category', 'packages', 'price'])); + } +} diff --git a/app/Http/Controllers/Catalog/CatalogController.php b/app/Http/Controllers/Catalog/CatalogController.php index c458bc1..a32006b 100644 --- a/app/Http/Controllers/Catalog/CatalogController.php +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -14,6 +14,7 @@ use App\Models\Install; use App\Models\Organization; use App\Models\Release; +use App\Support\CatalogCache; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\JsonResponse; @@ -142,13 +143,12 @@ protected function listingScopes(Builder|Relation $query) } /** - * Serve from cache for 60 seconds. The closure must return array data. + * Serve from cache for 60 seconds. The closure must return array data. Publishing + * flushes the cache (`CatalogCache::flush()`). */ protected function cached(Request $request, \Closure $build): JsonResponse { - $key = 'catalog:'.sha1($request->fullUrl()); - - $data = Cache::remember($key, self::CACHE_SECONDS, $build); + $data = Cache::remember(CatalogCache::key($request), self::CACHE_SECONDS, $build); return response()->json($data) ->header('Cache-Control', 'public, max-age='.self::CACHE_SECONDS.', s-maxage='.self::CACHE_SECONDS) diff --git a/app/Http/Controllers/Publisher/ExtensionController.php b/app/Http/Controllers/Publisher/ExtensionController.php new file mode 100644 index 0000000..992e750 --- /dev/null +++ b/app/Http/Controllers/Publisher/ExtensionController.php @@ -0,0 +1,228 @@ +with(self::DETAIL) + ->whereIn('organization_uuid', $this->publishableOrganizations($request)) + ->orderBy('name') + ->get(); + + return response()->json(PublisherExtensionResource::collection($extensions)->resolve($request)); + } + + /** + * GET /v1/publisher/extensions/{slug} + */ + public function show(Request $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, self::DETAIL); + $extension->load(['releases' => fn ($q) => $q->orderByDesc('created_at')->orderByDesc('id')]); + + return new PublisherExtensionResource($extension); + } + + /** + * POST /v1/publisher/extensions — create a listing, idempotent on package names: when the + * names already belong to one of the caller's listings, that listing is returned (200) + * and any package kind it lacks is added. Names owned by another publisher answer 409. + */ + public function store(CreateExtensionRequest $request): JsonResponse + { + $account = $this->account($request); + $packages = $request->packages(); + + $existing = $this->listingOwning($packages); + if ($existing !== null) { + if (! $this->canPublishFor($request, $existing->organization_uuid)) { + throw new ConflictHttpException('These package names are registered to another publisher\'s extension.'); + } + + if ($request->filled('organization')) { + $organization = $this->targetOrganization($request, $request->input('organization')); + if ($organization->uuid !== $existing->organization_uuid) { + throw new ConflictHttpException("These package names belong to {$existing->slug}, which another of your organizations owns."); + } + } + + $this->attachPackages($existing, $existing->organization()->firstOrFail(), $packages); + + return (new PublisherExtensionResource($existing->load(self::DETAIL)))->response()->setStatusCode(200); + } + + $organization = $this->targetOrganization($request, $request->input('organization')); + $slug = $request->input('slug') ?? Str::slug($request->input('name')); + + $bySlug = Extension::withTrashed()->where('slug', $slug)->first(); + if ($bySlug !== null) { + if ($bySlug->trashed() || $bySlug->organization_uuid !== $organization->uuid) { + throw ValidationException::withMessages(['slug' => "The slug {$slug} is taken."]); + } + + // A listing of this organization with the slug and none of these names: claim them. + $this->attachPackages($bySlug, $organization, $packages); + + return (new PublisherExtensionResource($bySlug->load(self::DETAIL)))->response()->setStatusCode(200); + } + + $this->assertClaimable($organization, $packages); + + $extension = DB::transaction(function () use ($request, $account, $organization, $slug, $packages) { + $extension = Extension::create(array_merge($request->listingAttributes(), [ + 'organization_uuid' => $organization->uuid, + 'created_by_account_uuid' => $account->uuid, + 'category_uuid' => $this->categoryUuid($request->input('category')), + 'slug' => $slug, + 'status' => Extension::STATUS_DRAFT, + 'hosting' => $request->input('hosting', Extension::HOSTING_BOTH), + ])); + $extension->price()->create(['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => 'USD']); + foreach ($packages as $kind => $name) { + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + } + + return $extension; + }); + + Event::record('extension.created', $extension, $account, ['packages' => $packages]); + + return (new PublisherExtensionResource($extension->load(self::DETAIL)))->response()->setStatusCode(201); + } + + /** + * PATCH /v1/publisher/extensions/{slug} — listing copy, links, category, tags, hosting. + * Listing edits never need review. + */ + public function update(UpdateExtensionRequest $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug); + $attributes = $request->listingAttributes(); + if ($request->has('category')) { + $attributes['category_uuid'] = $this->categoryUuid($request->input('category')); + } + + $extension->fill($attributes)->save(); + Event::record('extension.updated', $extension, $this->account($request), ['fields' => array_keys($attributes)]); + + if ($extension->status === Extension::STATUS_PUBLISHED) { + CatalogCache::flush(); + } + + return new PublisherExtensionResource($extension->load(self::DETAIL)); + } + + /** + * The one listing that owns any of the names, or null. Names spread over two listings + * answer 409. + */ + protected function listingOwning(array $packages): ?Extension + { + $owners = ExtensionPackage::query() + ->where(function (Builder $q) use ($packages) { + foreach ($packages as $kind => $name) { + $q->orWhere(fn (Builder $q) => $q->where('kind', $kind)->where('name', $name)); + } + }) + ->pluck('extension_uuid') + ->unique(); + + if ($owners->count() > 1) { + throw new ConflictHttpException('These package names belong to different extensions.'); + } + + if ($owners->isEmpty()) { + return null; + } + + $extension = Extension::withTrashed()->find($owners->first()); + if ($extension === null || $extension->trashed()) { + throw new ConflictHttpException('These package names belong to a deleted extension; contact Fleetbase staff.'); + } + + return $extension; + } + + /** + * Add package kinds the listing does not have yet. A different name for a kind the + * listing already has is a rename, which this endpoint does not do. + */ + protected function attachPackages(Extension $extension, Organization $organization, array $packages): void + { + $current = $extension->packages()->pluck('name', 'kind'); + $new = []; + + foreach ($packages as $kind => $name) { + if (! $current->has($kind)) { + $new[$kind] = $name; + } elseif ($current->get($kind) !== $name) { + throw new ConflictHttpException("{$extension->slug} already has the {$kind} package {$current->get($kind)}; package names cannot be changed here."); + } + } + + $this->assertClaimable($organization, $new); + foreach ($new as $kind => $name) { + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + } + } + + protected function assertClaimable(Organization $organization, array $packages): void + { + $errors = []; + foreach ($packages as $kind => $name) { + if ($messages = $this->namespaces->errors($organization, $kind, $name)) { + $errors['packages.'.$kind] = $messages; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + protected function categoryUuid(?string $identifier): ?string + { + if ($identifier === null || $identifier === '') { + return null; + } + + $category = Category::query() + ->where(fn (Builder $q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)->orWhere('uuid', $identifier)) + ->first(); + + if ($category === null) { + throw ValidationException::withMessages(['category' => "Unknown category {$identifier}."]); + } + + return $category->uuid; + } +} diff --git a/app/Http/Controllers/Publisher/PublisherController.php b/app/Http/Controllers/Publisher/PublisherController.php new file mode 100644 index 0000000..245346b --- /dev/null +++ b/app/Http/Controllers/Publisher/PublisherController.php @@ -0,0 +1,117 @@ +user(); + } + + /** + * Organizations the caller may publish for, honouring the token's organization pin. + * + * @return list organization uuids + */ + protected function publishableOrganizations(Request $request): array + { + $account = $this->account($request); + $pinned = $account->currentToken()?->organization_uuid; + + return Membership::query() + ->where('account_uuid', $account->uuid) + ->whereIn('role', Membership::PUBLISHING_ROLES) + ->when($pinned, fn (Builder $q) => $q->where('organization_uuid', $pinned)) + ->pluck('organization_uuid') + ->all(); + } + + /** + * The organization a new listing is created in: `organization` from the body, else the + * token's organization, else the caller's personal organization. + */ + protected function targetOrganization(Request $request, ?string $identifier): Organization + { + $account = $this->account($request); + $token = $account->currentToken(); + $identifier ??= $token?->organization_uuid; + + $organization = $identifier === null + ? $account->personalOrganization() + : $account->organizations() + ->where(fn ($q) => $q->where('organizations.uuid', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.slug', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + throw new UnprocessableEntityHttpException('You are not a member of that organization.'); + } + + if ($token?->organization_uuid && $token->organization_uuid !== $organization->uuid) { + throw new AccessDeniedHttpException('This token is limited to another organization.'); + } + + if (! in_array($account->roleIn($organization), Membership::PUBLISHING_ROLES, true)) { + throw new AccessDeniedHttpException('Only organization owners, admins and publishers can publish extensions.'); + } + + return $organization; + } + + protected function findExtension(Request $request, string $slug, array $with = []): Extension + { + $extension = Extension::query() + ->with($with) + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->first(); + + if ($extension === null || ! $this->canPublishFor($request, $extension->organization_uuid)) { + throw new NotFoundHttpException('Extension not found.'); + } + + return $extension; + } + + protected function findRelease(Request $request, string $id): Release + { + $release = Release::findById($id, ['extension']); + + if ($release === null || $release->extension === null || ! $this->canPublishFor($request, $release->extension->organization_uuid)) { + throw new NotFoundHttpException('Release not found.'); + } + + return $release; + } + + protected function canPublishFor(Request $request, string $organizationUuid): bool + { + return in_array($organizationUuid, $this->publishableOrganizations($request), true); + } + + /** + * Relations the release detail resource reads. + */ + public static function releaseDetail(Release $release): Release + { + return $release->load(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks', 'reviews.reviewer']); + } +} diff --git a/app/Http/Controllers/Publisher/ReleaseController.php b/app/Http/Controllers/Publisher/ReleaseController.php new file mode 100644 index 0000000..13318be --- /dev/null +++ b/app/Http/Controllers/Publisher/ReleaseController.php @@ -0,0 +1,99 @@ +findExtension($request, $slug); + $releases = $extension->releases() + ->with(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(); + + return response()->json(ReleaseDetailResource::collection($releases)->resolve($request)); + } + + /** + * POST /v1/publisher/extensions/{slug}/releases — multipart `manifest`, `engine`, `api`, + * optional `dist`, `channel`, `notes`, `submit`. Stores the files and queues the checks; + * returns the release (201) with whatever checks have already run. + */ + public function store(UploadReleaseRequest $request, string $slug): JsonResponse + { + $extension = $this->findExtension($request, $slug); + if ($extension->status === Extension::STATUS_SUSPENDED) { + throw new AccessDeniedHttpException('This extension is suspended.'); + } + + $release = $this->pipeline->upload( + $extension, + $this->account($request), + $request->manifest(), + $request->channel(), + $request->input('notes'), + $request->uploads(), + $request->boolean('submit'), + ); + + return (new ReleaseDetailResource(self::releaseDetail($release->fresh())))->response()->setStatusCode(201); + } + + /** + * GET /v1/publisher/releases/{id} — checks, submission state, review status and reasons. + */ + public function show(Request $request, string $release): ReleaseDetailResource + { + return new ReleaseDetailResource(self::releaseDetail($this->findRelease($request, $release))); + } + + /** + * POST /v1/publisher/releases/{id}/submit — 200 once submitted (or published under D6), + * 202 while checks are still running (submitted when they pass). + */ + public function submit(Request $request, string $release): JsonResponse + { + $release = $this->findRelease($request, $release); + $pending = $this->pipeline->checksPending($release); + $release = $this->pipeline->submit($release, $this->account($request)); + + return (new ReleaseDetailResource(self::releaseDetail($release)))->response()->setStatusCode($pending ? 202 : 200); + } + + /** + * POST /v1/publisher/releases/{id}/withdraw + */ + public function withdraw(Request $request, string $release): ReleaseDetailResource + { + $request->validate(['reason' => ['nullable', 'string', 'max:2000']]); + $release = $this->pipeline->withdraw($this->findRelease($request, $release), $this->account($request), $request->input('reason')); + + return new ReleaseDetailResource(self::releaseDetail($release)); + } + + /** + * POST /v1/publisher/releases/{id}/yank + */ + public function yank(Request $request, string $release): ReleaseDetailResource + { + $request->validate(['reason' => ['nullable', 'string', 'max:2000']]); + $release = $this->pipeline->yank($this->findRelease($request, $release), $this->account($request), $request->input('reason')); + + return new ReleaseDetailResource(self::releaseDetail($release)); + } +} diff --git a/app/Http/Middleware/EnsureStaff.php b/app/Http/Middleware/EnsureStaff.php new file mode 100644 index 0000000..718b61d --- /dev/null +++ b/app/Http/Middleware/EnsureStaff.php @@ -0,0 +1,25 @@ +user(); + + if (! $account instanceof Account || ! $account->isStaff()) { + return response()->error('Only Fleetbase staff can do this.', 403); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Admin/ReviewDecisionRequest.php b/app/Http/Requests/Admin/ReviewDecisionRequest.php new file mode 100644 index 0000000..1f29904 --- /dev/null +++ b/app/Http/Requests/Admin/ReviewDecisionRequest.php @@ -0,0 +1,43 @@ +routeIs('v1.admin.releases.reject'); + + return [ + 'reason' => [$rejecting ? 'required' : 'nullable', 'string', 'min:3', 'max:5000'], + 'items' => [$rejecting ? 'required' : 'nullable', 'array', $rejecting ? 'min:1' : 'min:0', 'max:50'], + 'items.*' => ['array:code,passed,note'], + 'items.*.code' => ['required', 'string', 'max:32'], + 'items.*.passed' => ['required', 'boolean'], + 'items.*.note' => ['nullable', 'string', 'max:2000'], + 'publish' => ['nullable', 'boolean'], + ]; + } + + public function items(): ?array + { + $items = $this->validated('items'); + + return $items === null ? null : array_map(fn (array $item) => [ + 'code' => $item['code'], + 'passed' => (bool) $item['passed'], + 'note' => $item['note'] ?? null, + ], array_values($items)); + } +} diff --git a/app/Http/Requests/Admin/SuspendExtensionRequest.php b/app/Http/Requests/Admin/SuspendExtensionRequest.php new file mode 100644 index 0000000..fa549b9 --- /dev/null +++ b/app/Http/Requests/Admin/SuspendExtensionRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'min:3', 'max:5000']]; + } +} diff --git a/app/Http/Requests/Publisher/CreateExtensionRequest.php b/app/Http/Requests/Publisher/CreateExtensionRequest.php new file mode 100644 index 0000000..b35a82d --- /dev/null +++ b/app/Http/Requests/Publisher/CreateExtensionRequest.php @@ -0,0 +1,50 @@ +input('packages'); + if (is_array($packages)) { + $this->merge(['packages' => array_map(fn ($name) => is_string($name) ? strtolower(trim($name)) : $name, $packages)]); + } + } + + public function rules(): array + { + return array_merge([ + 'name' => ['required', 'string', 'min:2', 'max:120'], + 'slug' => ['nullable', 'string', 'min:2', 'max:100', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'organization' => ['nullable', 'string', 'max:191'], + 'packages' => ['required', 'array'], + 'packages.npm' => ['nullable', 'required_without:packages.composer', 'string', 'max:214', 'regex:'.self::NPM_NAME], + 'packages.composer' => ['nullable', 'required_without:packages.npm', 'string', 'max:214', 'regex:'.self::COMPOSER_NAME], + ], $this->listingRules()); + } + + /** + * @return array kind => name + */ + public function packages(): array + { + return array_filter([ + 'npm' => $this->validated('packages.npm'), + 'composer' => $this->validated('packages.composer'), + ]); + } +} diff --git a/app/Http/Requests/Publisher/ListingRules.php b/app/Http/Requests/Publisher/ListingRules.php new file mode 100644 index 0000000..b3f642d --- /dev/null +++ b/app/Http/Requests/Publisher/ListingRules.php @@ -0,0 +1,44 @@ + ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:20000'], + 'promotional_text' => ['nullable', 'string', 'max:1000'], + 'category' => ['nullable', 'string', 'max:191'], + 'tags' => ['nullable', 'array', 'max:20'], + 'tags.*' => ['string', 'min:1', 'max:64'], + 'languages' => ['nullable', 'array', 'max:50'], + 'languages.*' => ['string', 'max:16'], + 'hosting' => ['nullable', Rule::in(Extension::HOSTINGS)], + 'website_url' => ['nullable', 'url:http,https', 'max:255'], + 'repo_url' => ['nullable', 'url:http,https', 'max:255'], + 'support_url' => ['nullable', 'url:http,https', 'max:255'], + 'privacy_url' => ['nullable', 'url:http,https', 'max:255'], + 'tos_url' => ['nullable', 'url:http,https', 'max:255'], + 'copyright' => ['nullable', 'string', 'max:255'], + 'fa_icon' => ['nullable', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'], + ]; + } + + /** + * The listing columns present in the request (category resolved by the controller). + */ + public function listingAttributes(): array + { + return collect($this->validated()) + ->only(['name', 'subtitle', 'description', 'promotional_text', 'tags', 'languages', 'hosting', 'website_url', 'repo_url', 'support_url', 'privacy_url', 'tos_url', 'copyright', 'fa_icon']) + ->all(); + } +} diff --git a/app/Http/Requests/Publisher/UpdateExtensionRequest.php b/app/Http/Requests/Publisher/UpdateExtensionRequest.php new file mode 100644 index 0000000..4bf4cd6 --- /dev/null +++ b/app/Http/Requests/Publisher/UpdateExtensionRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'string', 'min:2', 'max:120']], $this->listingRules()); + } +} diff --git a/app/Http/Requests/Publisher/UploadReleaseRequest.php b/app/Http/Requests/Publisher/UploadReleaseRequest.php new file mode 100644 index 0000000..e61eb7a --- /dev/null +++ b/app/Http/Requests/Publisher/UploadReleaseRequest.php @@ -0,0 +1,93 @@ + ['required', 'file', 'max:256'], + 'engine' => ['nullable', 'required_without:api', 'file', 'max:'.$bundleKb], + 'api' => ['nullable', 'required_without:engine', 'file', 'max:'.$bundleKb], + 'dist' => ['nullable', 'file', 'max:'.$distKb], + 'channel' => ['nullable', Rule::in(Release::CHANNELS)], + 'notes' => ['nullable', 'string', 'max:20000'], + 'submit' => ['nullable', 'boolean'], + ]; + } + + public function after(): array + { + return [function (Validator $validator) { + if ($validator->errors()->has('manifest')) { + return; + } + + $manifest = ExtensionManifest::decode((string) file_get_contents($this->file('manifest')->getRealPath())); + if ($manifest === null) { + $validator->errors()->add('manifest', 'The manifest must be a JSON object (extension.json).'); + + return; + } + + $version = $manifest['version'] ?? null; + if (! is_string($version) || ! Semver::valid($version) || str_starts_with($version, 'v')) { + $validator->errors()->add('manifest', 'The manifest needs a SemVer `version` (for example 1.2.0 or 1.3.0-beta.1).'); + + return; + } + + $channel = $this->input('channel'); + $declared = $manifest['channel'] ?? null; + if ($channel !== null && is_string($declared) && $declared !== $channel) { + $validator->errors()->add('channel', "The channel `{$channel}` does not match the manifest's `{$declared}`."); + } + + $this->decodedManifest = $manifest; + }]; + } + + public function manifest(): array + { + return $this->decodedManifest ?? []; + } + + public function channel(): string + { + $declared = $this->decodedManifest['channel'] ?? null; + + return $this->input('channel') ?? (in_array($declared, Release::CHANNELS, true) ? $declared : Release::CHANNEL_STABLE); + } + + public function uploads(): array + { + return array_filter([ + 'engine' => $this->file('engine'), + 'api' => $this->file('api'), + 'dist' => $this->file('dist'), + ]); + } +} diff --git a/app/Http/Resources/PublisherExtensionResource.php b/app/Http/Resources/PublisherExtensionResource.php new file mode 100644 index 0000000..fa90be3 --- /dev/null +++ b/app/Http/Resources/PublisherExtensionResource.php @@ -0,0 +1,55 @@ +errors($this->resource); + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'slug' => $this->slug, + 'name' => $this->name, + 'subtitle' => $this->subtitle, + 'description' => $this->description, + 'promotional_text' => $this->promotional_text, + 'status' => $this->status, + 'hosting' => $this->hosting, + 'organization' => $this->whenLoaded('organization', fn () => ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name]), + 'category' => $this->whenLoaded('category', fn () => $this->category ? ['id' => $this->category->public_id, 'slug' => $this->category->slug, 'name' => $this->category->name] : null), + 'tags' => $this->tags ?? [], + 'languages' => $this->languages ?? [], + 'website_url' => $this->website_url, + 'repo_url' => $this->repo_url, + 'support_url' => $this->support_url, + 'privacy_url' => $this->privacy_url, + 'tos_url' => $this->tos_url, + 'copyright' => $this->copyright, + 'fa_icon' => $this->fa_icon, + 'packages' => $this->whenLoaded('packages', fn () => $this->packages->map(fn ($package) => ['kind' => $package->kind, 'name' => $package->name])->values()->all()), + 'price' => $this->whenLoaded('price', fn () => (new PriceResource($this->price))->resolve($request)), + 'readiness' => ['ready' => $missing === [], 'missing' => $missing], + 'releases' => $this->whenLoaded('releases', fn () => $this->releases->map(fn ($release) => [ + 'id' => $release->public_id, + 'version' => $release->version, + 'channel' => $release->channel, + 'status' => $release->status, + 'published_at' => $release->published_at, + 'created_at' => $release->created_at, + ])->values()->all()), + 'suspension' => $this->meta['suspension'] ?? null, + 'published_at' => $this->published_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseCheckResource.php b/app/Http/Resources/ReleaseCheckResource.php new file mode 100644 index 0000000..687b001 --- /dev/null +++ b/app/Http/Resources/ReleaseCheckResource.php @@ -0,0 +1,22 @@ +output ?? []; + + return [ + 'check' => $this->check, + 'passed' => (bool) $this->passed, + 'skipped' => (bool) ($output['skipped'] ?? false), + 'summary' => $output['summary'] ?? null, + 'errors' => $output['errors'] ?? [], + 'warnings' => $output['warnings'] ?? [], + 'output' => $output, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseDetailResource.php b/app/Http/Resources/ReleaseDetailResource.php new file mode 100644 index 0000000..308f864 --- /dev/null +++ b/app/Http/Resources/ReleaseDetailResource.php @@ -0,0 +1,71 @@ + $file === null ? null : [ + 'id' => $file->public_id, + 'name' => $file->name, + 'original_filename' => $file->original_filename, + 'size' => (int) $file->size, + 'sha256' => $file->checksum, + 'integrity' => $file->integrity, + ]; + + $checks = $this->relationLoaded('checks') ? $this->checks : null; + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => [ + 'id' => $this->extension->public_id, + 'slug' => $this->extension->slug, + 'name' => $this->extension->name, + 'status' => $this->extension->status, + 'organization' => $this->extension->relationLoaded('organization') && $this->extension->organization + ? ['id' => $this->extension->organization->public_id, 'slug' => $this->extension->organization->slug, 'name' => $this->extension->organization->name] + : null, + ]), + 'version' => $this->version, + 'channel' => $this->channel, + 'status' => $this->status, + 'compat' => $this->compat ?? [], + 'notes' => $this->notes, + 'size_bytes' => (int) $this->size_bytes, + 'checksum' => $this->checksum, + 'files' => [ + 'engine' => $file($this->engineTarball), + 'api' => $file($this->apiTarball), + 'dist' => $file($this->engineDist), + ], + 'checks_passed' => $checks === null || $this->metaValue('checks_completed_at') === null ? null : $this->checksPassed(), + 'checks' => $checks === null ? [] : ReleaseCheckResource::collection($checks->sortBy(fn ($check) => array_search($check->check, ReleaseCheck::CHECKS, true))->values())->resolve($request), + 'submission' => [ + 'requested' => (bool) $this->metaValue('submission.requested', false), + 'submitted_at' => $this->metaValue('submission.submitted_at'), + 'errors' => $this->metaValue('submission.errors', []), + ], + 'review' => [ + 'required' => $this->metaValue('review_policy.required'), + 'reasons' => (object) $this->metaValue('review_policy.reasons', []), + ], + 'dist' => $this->metaValue('dist'), + 'reviews' => $this->whenLoaded('reviews', fn () => ReviewResource::collection($this->reviews)->resolve($request), []), + 'manifest' => $this->manifest ?? [], + 'published_at' => $this->published_at, + 'yanked_at' => $this->yanked_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ReviewResource.php b/app/Http/Resources/ReviewResource.php new file mode 100644 index 0000000..c5e6595 --- /dev/null +++ b/app/Http/Resources/ReviewResource.php @@ -0,0 +1,23 @@ + $this->action, + 'from_status' => $this->from_status, + 'to_status' => $this->to_status, + 'actor' => $this->actor, + 'by' => $this->whenLoaded('reviewer', fn () => $this->reviewer ? ['id' => $this->reviewer->public_id, 'name' => $this->reviewer->name] : null), + 'reason' => $this->reason, + 'items' => $this->items ?? [], + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Jobs/RunReleaseChecks.php b/app/Jobs/RunReleaseChecks.php new file mode 100644 index 0000000..cd6e54e --- /dev/null +++ b/app/Jobs/RunReleaseChecks.php @@ -0,0 +1,50 @@ +onQueue(config('registry.releases.checks_queue')); + } + + public function handle(ReleasePipeline $pipeline): void + { + $release = Release::query()->whereKey($this->release->getKey())->first(); + + if ($release === null || ! $pipeline->checksPending($release)) { + return; + } + + $pipeline->runChecks($release); + } + + public function failed(?\Throwable $exception): void + { + $release = Release::query()->whereKey($this->release->getKey())->first(); + + if ($release !== null) { + app(ReleasePipeline::class)->failChecks($release, 'The checks could not run. Upload the release again; if this keeps happening, contact Fleetbase staff.'); + } + } +} diff --git a/app/Mail/ReleaseDecisionMail.php b/app/Mail/ReleaseDecisionMail.php new file mode 100644 index 0000000..d4a46c4 --- /dev/null +++ b/app/Mail/ReleaseDecisionMail.php @@ -0,0 +1,61 @@ +release->loadMissing('extension'); + $subject = match ($this->decision) { + self::PUBLISHED => 'is published', + self::REJECTED => 'was not approved', + default => 'failed its checks', + }; + + return new Envelope(subject: $this->release->extension->name.' '.$this->release->version.' '.$subject); + } + + public function content(): Content + { + $this->release->loadMissing(['extension', 'createdBy', 'checks']); + $review = $this->release->reviews()->reorder()->latest('id')->first(); + + return new Content( + markdown: 'mail.release-decision', + with: [ + 'name' => $this->release->createdBy?->name, + 'extension' => $this->release->extension->name, + 'version' => $this->release->version, + 'decision' => $this->decision, + 'reason' => $review?->action === Review::ACTION_REJECT ? $review->reason : null, + 'items' => $review?->action === Review::ACTION_REJECT ? ($review->items ?? []) : [], + 'failedChecks' => $this->release->checks->where('passed', false)->map(fn ($check) => $check->check.': '.($check->output['summary'] ?? ''))->values()->all(), + 'releaseId' => $this->release->public_id, + ], + ); + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 6759e74..cdc845a 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -116,6 +116,26 @@ public function tokenCan(string $scope): bool return $this->currentToken?->can($scope) ?? false; } + /** + * The account's role in the organization, or null when it is not a member. + */ + public function roleIn(Organization|string $organization): ?string + { + return Membership::query() + ->where('account_uuid', $this->uuid) + ->where('organization_uuid', $organization instanceof Organization ? $organization->uuid : $organization) + ->value('role'); + } + + /** + * Member of an organization flagged `is_staff` (Fleetbase staff: release review and + * moderation). + */ + public function isStaff(): bool + { + return $this->organizations()->where('is_staff', true)->exists(); + } + /** * The account's personal organization (created at registration). */ diff --git a/app/Models/Membership.php b/app/Models/Membership.php index ac34893..e63b8af 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -25,6 +25,9 @@ class Membership extends Pivot public const ROLES = [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_PUBLISHER, self::ROLE_BILLING, self::ROLE_MEMBER]; + /** Roles that may create listings and upload, submit, withdraw and yank releases. */ + public const PUBLISHING_ROLES = [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_PUBLISHER]; + protected $table = 'memberships'; protected $primaryKey = 'uuid'; diff --git a/app/Models/Release.php b/app/Models/Release.php index a195e71..328a417 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -50,6 +50,17 @@ class Release extends Model self::STATUS_IN_REVIEW, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_PUBLISHED, self::STATUS_YANKED, ]; + /** Releases that have been out (published, possibly yanked since). */ + public const RELEASED_STATUSES = [self::STATUS_PUBLISHED, self::STATUS_YANKED]; + + /** Waiting for staff: the review queue. */ + public const QUEUE_STATUSES = [self::STATUS_SUBMITTED, self::STATUS_IN_REVIEW, self::STATUS_APPROVED]; + + /** + * Never submitted, failed or rejected: uploading the same version again replaces it. + */ + public const REPLACEABLE_STATUSES = [self::STATUS_UPLOADED, self::STATUS_FAILED_CHECKS, self::STATUS_REJECTED]; + protected $table = 'releases'; protected string $publicIdType = 'rel'; @@ -108,7 +119,7 @@ public function checks(): HasMany public function reviews(): HasMany { - return $this->hasMany(Review::class, 'release_uuid', 'uuid')->orderBy('created_at'); + return $this->hasMany(Review::class, 'release_uuid', 'uuid')->orderBy('created_at')->orderBy('id'); } public function scopePublished(Builder $query): Builder @@ -126,6 +137,51 @@ public function isYanked(): bool return $this->status === self::STATUS_YANKED; } + public function scopeReleased(Builder $query): Builder + { + return $query->whereIn('status', self::RELEASED_STATUSES); + } + + public function isReplaceable(): bool + { + return in_array($this->status, self::REPLACEABLE_STATUSES, true); + } + + public function metaValue(string $key, mixed $default = null): mixed + { + return data_get($this->meta ?? [], $key, $default); + } + + /** + * Merge keys into `meta` (dot notation allowed) without saving. + */ + public function mergeMeta(array $values): static + { + $meta = $this->meta ?? []; + foreach ($values as $key => $value) { + data_set($meta, $key, $value); + } + $this->meta = $meta; + + return $this; + } + + /** + * The uploaded files by role: `engine`, `api`, `dist`. + * + * @return array + */ + public function archives(): array + { + $this->loadMissing(['engineTarball', 'apiTarball', 'engineDist']); + + return array_filter([ + 'engine' => $this->engineTarball, + 'api' => $this->apiTarball, + 'dist' => $this->engineDist, + ]); + } + /** * All automated checks passed (and at least one exists). */ @@ -138,15 +194,15 @@ public function checksPassed(): bool /** * Rule from the data model: publishable when every check passes and the latest - * review action is `approve`. Auto-publish policy (D6) is applied by the caller. + * transition is an approval (by staff, or by the D6 auto-approval). */ public function isPublishable(): bool { - if (! $this->checksPassed()) { + if ($this->status !== self::STATUS_APPROVED || ! $this->checksPassed()) { return false; } - $latestReview = $this->reviews()->latest('created_at')->first(); + $latestReview = $this->reviews()->reorder()->latest('id')->first(); return $latestReview?->action === Review::ACTION_APPROVE; } diff --git a/app/Models/ReleaseCheck.php b/app/Models/ReleaseCheck.php index 3bb5d83..0854b86 100644 --- a/app/Models/ReleaseCheck.php +++ b/app/Models/ReleaseCheck.php @@ -19,11 +19,20 @@ class ReleaseCheck extends Model public const CHECK_SIZE = 'size'; + public const CHECK_VERSION = 'version'; + + /** Entry types, path traversal, symlinks and forbidden file types. */ + public const CHECK_ARCHIVE = 'archive'; + + /** The prebuilt engine release (`dist`) manifest from ember-core's `build-engine-release`. */ + public const CHECK_DIST = 'dist'; + + /** Engine build smoke test; reserved for Phase 3. */ public const CHECK_BUILD = 'build'; public const CHECKS = [ - self::CHECK_MANIFEST, self::CHECK_PACKAGE_NAMES, self::CHECK_SECRETS_SCAN, - self::CHECK_COMPAT, self::CHECK_SIZE, self::CHECK_BUILD, + self::CHECK_MANIFEST, self::CHECK_COMPAT, self::CHECK_PACKAGE_NAMES, self::CHECK_VERSION, + self::CHECK_SIZE, self::CHECK_ARCHIVE, self::CHECK_SECRETS_SCAN, self::CHECK_DIST, self::CHECK_BUILD, ]; protected $table = 'release_checks'; diff --git a/app/Models/Review.php b/app/Models/Review.php index 411abbe..5401c07 100644 --- a/app/Models/Review.php +++ b/app/Models/Review.php @@ -6,14 +6,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * Review timeline entry for a release; the history and the rejection reasons. + * Review timeline entry for a release: one row per status transition (see + * `App\Releases\ReleaseStateMachine`), carrying the history and the rejection reasons. */ class Review extends Model { use HasUuid; + public const ACTION_UPLOAD = 'upload'; + + public const ACTION_CHECK = 'check'; + + public const ACTION_PASS_CHECKS = 'pass_checks'; + + public const ACTION_FAIL_CHECKS = 'fail_checks'; + public const ACTION_SUBMIT = 'submit'; + public const ACTION_CLAIM = 'claim'; + public const ACTION_APPROVE = 'approve'; public const ACTION_REJECT = 'reject'; @@ -25,13 +36,20 @@ class Review extends Model public const ACTION_YANK = 'yank'; public const ACTIONS = [ - self::ACTION_SUBMIT, self::ACTION_APPROVE, self::ACTION_REJECT, + self::ACTION_UPLOAD, self::ACTION_CHECK, self::ACTION_PASS_CHECKS, self::ACTION_FAIL_CHECKS, + self::ACTION_SUBMIT, self::ACTION_CLAIM, self::ACTION_APPROVE, self::ACTION_REJECT, self::ACTION_WITHDRAW, self::ACTION_PUBLISH, self::ACTION_YANK, ]; + public const ACTOR_PUBLISHER = 'publisher'; + + public const ACTOR_STAFF = 'staff'; + + public const ACTOR_SYSTEM = 'system'; + protected $table = 'reviews'; - protected $fillable = ['release_uuid', 'reviewer_account_uuid', 'action', 'reason', 'items']; + protected $fillable = ['release_uuid', 'reviewer_account_uuid', 'actor', 'action', 'from_status', 'to_status', 'reason', 'items']; protected $hidden = ['id']; diff --git a/app/Models/Token.php b/app/Models/Token.php index b9aee84..9757744 100644 --- a/app/Models/Token.php +++ b/app/Models/Token.php @@ -33,7 +33,10 @@ class Token extends Model public const SCOPE_INSTANCE_REGISTER = 'instance:register'; - public const SCOPES = [self::SCOPE_ALL, self::SCOPE_READ, self::SCOPE_PUBLISH, self::SCOPE_INSTALL, self::SCOPE_INSTANCE_REGISTER]; + /** Staff review and moderation (`/v1/admin/*`); also needs a staff organization membership. */ + public const SCOPE_REVIEW = 'review'; + + public const SCOPES = [self::SCOPE_ALL, self::SCOPE_READ, self::SCOPE_PUBLISH, self::SCOPE_INSTALL, self::SCOPE_INSTANCE_REGISTER, self::SCOPE_REVIEW]; public const MAX_LIFETIME_DAYS = 365; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9fcd134..91f3a6d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -82,6 +82,11 @@ protected function registerRateLimiters(): void RateLimiter::for('instance-register', fn (Request $request) => Limit::perMinute(10)->by($request->ip())); + RateLimiter::for('publish', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + // Release uploads carry up to two 50 MB archives and a dist. + RateLimiter::for('upload', fn (Request $request) => Limit::perHour(60)->by($request->user()?->uuid ?: $request->ip())); + RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); // npm and Composer fetch metadata and archives in bursts during an install. diff --git a/app/Releases/Archives/ArchiveInspector.php b/app/Releases/Archives/ArchiveInspector.php new file mode 100644 index 0000000..4b6693f --- /dev/null +++ b/app/Releases/Archives/ArchiveInspector.php @@ -0,0 +1,135 @@ + 'ELF executable', + "\xfe\xed\xfa\xce" => 'Mach-O executable', + "\xfe\xed\xfa\xcf" => 'Mach-O executable', + "\xce\xfa\xed\xfe" => 'Mach-O executable', + "\xcf\xfa\xed\xfe" => 'Mach-O executable', + "\xca\xfe\xba\xbe" => 'Mach-O universal binary or Java class', + 'MZ' => 'Windows (PE) executable', + ]; + + public function __construct(protected ?SecretRules $secrets = null) {} + + public static function isRegularFile(string $type): bool + { + return $type === '0' || $type === '7'; + } + + public function inspect(string $path, string $label, ?string $mirrorDir = null): ArchiveInventory + { + $head = (string) @file_get_contents($path, false, null, 0, 2); + $inventory = new ArchiveInventory($label, $path, (int) @filesize($path), $head === "\x1f\x8b"); + + $maxEntries = (int) config('registry.releases.max_entries', 20000); + $maxUnpacked = (int) config('registry.releases.max_unpacked_bytes', 300 * 1024 * 1024); + $maxFile = (int) config('registry.releases.max_file_bytes', 25 * 1024 * 1024); + $current = null; + + try { + Tarball::walk( + $path, + function (string $name, string $type, int $size, string $raw = '', string $link = '') use ($inventory, $maxEntries, $maxUnpacked, $maxFile, &$current) { + if ($inventory->entryCount() >= $maxEntries || $inventory->unpackedBytes + $size > $maxUnpacked) { + $inventory->truncated = true; + $inventory->unpackedBytes += $size; + + return null; + } + + $read = self::isRegularFile($type) && $size <= $maxFile; + $inventory->entries[] = [ + 'name' => $name, 'raw' => $raw !== '' ? $raw : $name, 'type' => $type, 'size' => $size, + 'link' => $link, 'sha256' => null, 'executable' => null, 'read' => $read, + ]; + $inventory->unpackedBytes += $size; + $current = array_key_last($inventory->entries); + + if ($this->secrets !== null && self::isRegularFile($type) && ! $read) { + array_push($inventory->secrets, ...$this->secrets->scan($name, null)); + } + + return $read; + }, + function (string $name, string $contents) use ($inventory, $mirrorDir, &$current) { + $entry = &$inventory->entries[$current]; + $entry['sha256'] = hash('sha256', $contents); + $entry['executable'] = self::executableKind($contents); + + if (in_array(basename($name), self::KEEP_FILES, true) && strlen($contents) <= self::KEEP_MAX_BYTES) { + $inventory->kept[$name] = $contents; + } + + if ($this->secrets !== null) { + array_push($inventory->secrets, ...$this->secrets->scan($name, $contents)); + } + + if ($mirrorDir !== null && self::isSafePath($entry['raw'])) { + $target = rtrim($mirrorDir, '/').'/'.$name; + @mkdir(dirname($target), 0700, true); + file_put_contents($target, $contents); + } + }, + $maxFile, + ); + } catch (\Throwable $e) { + $inventory->error = $e->getMessage(); + } + + return $inventory; + } + + /** + * A relative path with no `..` segment, drive letter or backslash. + */ + public static function isSafePath(string $raw): bool + { + if ($raw === '' || str_starts_with($raw, '/') || str_contains($raw, '\\') || preg_match('/^[A-Za-z]:/', $raw) === 1) { + return false; + } + + return ! in_array('..', explode('/', $raw), true); + } + + public static function executableKind(string $contents): ?string + { + foreach (self::EXECUTABLE_MAGIC as $magic => $kind) { + if (str_starts_with($contents, $magic)) { + // "MZ" alone is too short to trust; require the PE header offset to point at "PE\0\0". + if ($magic === 'MZ') { + $offset = strlen($contents) >= 64 ? unpack('V', substr($contents, 60, 4))[1] : null; + if ($offset === null || substr($contents, $offset, 4) !== "PE\0\0") { + continue; + } + } + + return $kind; + } + } + + return null; + } +} diff --git a/app/Releases/Archives/ArchiveInventory.php b/app/Releases/Archives/ArchiveInventory.php new file mode 100644 index 0000000..3196484 --- /dev/null +++ b/app/Releases/Archives/ArchiveInventory.php @@ -0,0 +1,61 @@ + */ + public array $entries = []; + + public int $unpackedBytes = 0; + + /** Stopped early: too many entries or too many unpacked bytes. */ + public bool $truncated = false; + + /** Set when the file is not a readable tar / tar.gz archive. */ + public ?string $error = null; + + /** @var list */ + public array $secrets = []; + + /** @var array JSON files kept by name (see ArchiveInspector::KEEP_FILES) */ + public array $kept = []; + + public function __construct( + public readonly string $label, + public readonly string $path, + public readonly int $compressedBytes, + public readonly bool $gzipped, + ) {} + + public function entryCount(): int + { + return count($this->entries); + } + + /** + * Regular file entries. + * + * @return list + */ + public function files(): array + { + return array_values(array_filter($this->entries, fn (array $entry) => ArchiveInspector::isRegularFile($entry['type']))); + } + + public function file(string $name): ?array + { + foreach ($this->entries as $entry) { + if ($entry['name'] === $name && ArchiveInspector::isRegularFile($entry['type'])) { + return $entry; + } + } + + return null; + } +} diff --git a/app/Releases/Archives/ReleaseArchives.php b/app/Releases/Archives/ReleaseArchives.php new file mode 100644 index 0000000..ad95f3a --- /dev/null +++ b/app/Releases/Archives/ReleaseArchives.php @@ -0,0 +1,89 @@ +/