From a9a456cbaea397790470f70bad07118ec4538b9c Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Tue, 22 Sep 2026 16:36:37 -0500 Subject: [PATCH 01/11] composer update --- composer.lock | 391 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 256 insertions(+), 135 deletions(-) diff --git a/composer.lock b/composer.lock index cdf3a2c..dfb1831 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.383.0", + "version": "3.396.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "50be4b674a9969c0e0aba765dee534cb5e61d309" + "reference": "0c289e1083c336efaf2ccd78a0520ed5c49af758" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/50be4b674a9969c0e0aba765dee534cb5e61d309", - "reference": "50be4b674a9969c0e0aba765dee534cb5e61d309", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0c289e1083c336efaf2ccd78a0520ed5c49af758", + "reference": "0c289e1083c336efaf2ccd78a0520ed5c49af758", "shasum": "" }, "require": { @@ -79,10 +79,10 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.4.5", - "mtdowling/jmespath.php": "^2.8.0", + "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" @@ -153,28 +153,27 @@ "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.383.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.396.0" }, - "time": "2026-05-28T18:17:51+00:00" + "time": "2026-09-22T18:06:28+00:00" }, { "name": "bref/bref", - "version": "3.0.5", + "version": "3.0.10", "source": { "type": "git", "url": "https://github.com/brefphp/bref.git", - "reference": "87b441fd428111be584100da40992e683f2cccd0" + "reference": "0e96d98fa4039f302318b233f9df41537eb3cc93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brefphp/bref/zipball/87b441fd428111be584100da40992e683f2cccd0", - "reference": "87b441fd428111be584100da40992e683f2cccd0", + "url": "https://api.github.com/repos/brefphp/bref/zipball/0e96d98fa4039f302318b233f9df41537eb3cc93", + "reference": "0e96d98fa4039f302318b233f9df41537eb3cc93", "shasum": "" }, "require": { "crwlr/query-string": "^1.0.3", "ext-curl": "*", - "ext-json": "*", "hollodotme/fast-cgi-client": "^3.0.1", "nyholm/psr7": "^1.4.1", "php": ">=8.2", @@ -182,7 +181,7 @@ "psr/http-message": "^1.0|^2.0", "psr/http-server-handler": "^1.0", "riverline/multipart-parser": "^2.1.2", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/process": "^5.4|^6.4|^7.0|^8.0" }, "require-dev": { "async-aws/core": "^1.0", @@ -194,8 +193,8 @@ "guzzlehttp/guzzle": "^7.5", "phpstan/phpstan": "^2", "phpunit/phpunit": "^10.1", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/console": "^5.4|^6.4|^7.0|^8.0", + "symfony/yaml": "^5.4|^6.4|^7.0|^8.0" }, "bin": [ "src/bref-local" @@ -221,7 +220,7 @@ ], "support": { "issues": "https://github.com/brefphp/bref/issues", - "source": "https://github.com/brefphp/bref/tree/3.0.5" + "source": "https://github.com/brefphp/bref/tree/3.0.10" }, "funding": [ { @@ -233,7 +232,7 @@ "type": "github" } ], - "time": "2026-05-12T16:07:33+00:00" + "time": "2026-09-21T16:26:57+00:00" }, { "name": "crwlr/query-string", @@ -294,25 +293,27 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.5", + "version": "8.2.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148" + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", - "php": "^7.2.5 || ^8.0", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { "psr/http-client-implementation": "1.0" @@ -320,10 +321,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "phpunit/phpunit": "^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -339,9 +340,6 @@ } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" } @@ -401,7 +399,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.5" + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" }, "funding": [ { @@ -417,28 +415,28 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:53:46+00:00" + "time": "2026-09-06T13:55:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.4.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "phpunit/phpunit": "^9.6.34" }, "type": "library", "extra": { @@ -484,7 +482,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "source": "https://github.com/guzzle/promises/tree/3.0.2" }, "funding": [ { @@ -500,37 +498,39 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2026-08-24T10:00:26+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.3", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.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" + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -601,7 +601,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.3" + "source": "https://github.com/guzzle/psr7/tree/3.1.0" }, "funding": [ { @@ -617,7 +617,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:48:20+00:00" + "time": "2026-08-24T11:02:13+00:00" }, { "name": "hollodotme/fast-cgi-client", @@ -671,16 +671,16 @@ }, { "name": "mtdowling/jmespath.php", - "version": "2.8.0", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", - "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", "shasum": "" }, "require": { @@ -689,7 +689,7 @@ }, "require-dev": { "composer/xdebug-handler": "^3.0.3", - "phpunit/phpunit": "^8.5.33" + "phpunit/phpunit": "^8.5.52" }, "bin": [ "bin/jp.php" @@ -697,7 +697,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.8-dev" + "dev-master": "2.9-dev" } }, "autoload": { @@ -731,9 +731,9 @@ ], "support": { "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" }, - "time": "2024-09-04T18:46:31+00:00" + "time": "2026-07-06T18:56:19+00:00" }, { "name": "nyholm/psr7", @@ -1082,50 +1082,6 @@ }, "time": "2023-04-10T20:06:20+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": "riverline/multipart-parser", "version": "2.2.2", @@ -1184,16 +1140,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1231,7 +1187,7 @@ "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.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1251,24 +1207,25 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v8.0.11", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -1301,7 +1258,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -1321,7 +1278,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:39:47+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1408,16 +1365,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -1469,7 +1426,171 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "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-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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.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-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "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\\Php82\\": "" + }, + "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.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -1489,24 +1610,24 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/process", - "version": "v8.0.13", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -1534,7 +1655,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.13" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -1554,7 +1675,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-09-02T12:40:29+00:00" } ], "packages-dev": [], From 555d9c70ab12f402598db4e5cef84b1f91f20028 Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 00:46:37 -0500 Subject: [PATCH 02/11] Build an absolute server URL so download_url isn't relative Under Bref/API Gateway SCRIPT_NAME isn't set, so the parent guessServerUrl() fell back to "/" and the metadata's download_url became relative. WordPress rejects that with "Download failed. A valid URL was not provided." - Build the URL from the Host header (or SERVER_NAME) and the domain root, honoring X-Forwarded-Proto since API Gateway terminates TLS. - Call the Lambda subclass's guessServerUrl() from index.php; it was calling the base class, so the override never ran. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/Wpup/LambdaS3UpdateServer.php | 29 +++++++++++++++++++------- index.php | 2 +- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/includes/Wpup/LambdaS3UpdateServer.php b/includes/Wpup/LambdaS3UpdateServer.php index 1b56058..6b3d9b0 100644 --- a/includes/Wpup/LambdaS3UpdateServer.php +++ b/includes/Wpup/LambdaS3UpdateServer.php @@ -272,20 +272,35 @@ protected function filterLogInfo($columns, $request = null) { } /** - * Guess the Server Url based on the current request. + * Guess the absolute Server Url based on the current request. * - * Defaults to the current URL minus the query and "index.php". + * The parent implementation needs both HTTP_HOST and SCRIPT_NAME and falls back to a + * bare "/" otherwise. Under Bref/API Gateway SCRIPT_NAME isn't reliably set, and a + * relative "/" makes the metadata's download_url relative, which WordPress rejects + * ("A valid URL was not provided"). The function always serves from the domain root, + * so only the scheme and host are needed. * * @static * * @return string Url */ public static function guessServerUrl() { - $serverUrl = parent::guessServerUrl(); - //Make sure there's a trailing slash. - if ( substr($serverUrl, -1) !== '/' ) { - $serverUrl .= '/'; + $host = ''; + if ( !empty($_SERVER['HTTP_HOST']) ) { + $host = strval($_SERVER['HTTP_HOST']); + } elseif ( !empty($_SERVER['SERVER_NAME']) ) { + $host = strval($_SERVER['SERVER_NAME']); } - return $serverUrl; + if ( $host === '' ) { + return parent::guessServerUrl(); + } + + // API Gateway terminates TLS and reports the original scheme in X-Forwarded-Proto. + $isSsl = self::isSsl(); + if ( isset($_SERVER['HTTP_X_FORWARDED_PROTO']) ) { + $isSsl = strtolower(strval($_SERVER['HTTP_X_FORWARDED_PROTO'])) === 'https'; + } + + return ($isSsl ? 'https' : 'http') . '://' . $host . '/'; } } diff --git a/index.php b/index.php index 1966681..42b7572 100644 --- a/index.php +++ b/index.php @@ -14,7 +14,7 @@ $simpleUpdateKey = getenv('SIMPLE_UPDATE_KEY') ?: ''; $server = new Wpup_LambdaS3UpdateServer( - Wpup_UpdateServer::guessServerUrl(), + Wpup_LambdaS3UpdateServer::guessServerUrl(), $s3Client, $bucketName, $prefix, From f358c627b140241a30554e3c1e46e780cedf2ef5 Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:17:04 -0500 Subject: [PATCH 03/11] Add implementation plan for Composer routes Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/plans/2026-09-24-composer-routes.md | 824 +++++++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/plans/2026-09-24-composer-routes.md diff --git a/docs/plans/2026-09-24-composer-routes.md b/docs/plans/2026-09-24-composer-routes.md new file mode 100644 index 0000000..9b92e92 --- /dev/null +++ b/docs/plans/2026-09-24-composer-routes.md @@ -0,0 +1,824 @@ +# Composer routes for packages.reason.com — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** packages.reason.com answers Composer's `/packages.json`, `/p2/…` and `/dist/…` requests behind the existing `SIMPLE_UPDATE_KEY` gate, without changing the update-checker route. + +**Architecture:** A new, side-effect-free router class (`Wpup_ComposerRepository`) takes a request path and headers and returns a response object, or `null` for paths it doesn't own. `index.php` asks it first and only falls through to the existing `Wpup_LambdaS3UpdateServer` when it returns `null`. The router only reads S3; everything it serves is written by the publish pipeline (plan 2). + +**Tech Stack:** PHP 8.4 on AWS Lambda via Bref (`serverless.yml`), `aws/aws-sdk-php` v3 (`S3Client`, `MockHandler` for tests), plain-PHP test scripts. + +**Spec:** `~/.claude/plans/vnext-package-distribution/spec.md` — sections "wp-update-server", "Storage layout", "Invariants" 2–3. (The spec and its sibling plans are kept outside all repos on purpose; this is Plan 1 of that set, adapted into this repo.) + +**Provenance:** adapted on 2026-09-24 from `~/.claude/plans/vnext-package-distribution/1-update-server.md` and checked against `master` at `555d9c7`. Changes from that draft: `index.php` keeps `Wpup_LambdaS3UpdateServer::guessServerUrl()` (the absolute-URL fix in `555d9c7`); deploys use Bref Cloud (`bref deploy --env`); tests and docs are kept out of the Lambda package; the production rollout accounts for the key not being enforced yet; the runbook lives in `docs/`. The draft's router code and tests were run against `555d9c7` unchanged and passed. + +**Repo:** `~/dev/reason-update-server/wp-update-server` (`ReasonFoundation/wp-update-server`, default branch `master`). Work on a branch `feature/composer-routes`. + +## Global Constraints + +- Package vendor is exactly `reason-dev`; only `reason-dev/*` paths are served. +- Reserved first path segments: `packages.json`, `p2`, `dist`, `meta`. `meta/` is never served. +- Key accepted **only** as `Authorization: Bearer ` on Composer routes (case-insensitive scheme). Never read from the query string there. +- If `SIMPLE_UPDATE_KEY` is empty, Composer routes return `503` (fail closed). The update-checker route keeps its existing "empty key disables the gate" behavior. +- Presigned download URLs expire in `+15 minutes` (same as `generatePresignedUrl()`). +- Slug pattern: `[a-z0-9][a-z0-9._-]*`. Version pattern: `[0-9][0-9A-Za-z.+-]*`. +- No change to `Wpup_LambdaS3UpdateServer`, `Wpup_Request` or the update-checker route's responses. +- Must keep running on `php-84-fpm`; don't use syntax newer than PHP 8.4. +- `index.php` must keep building the server URL with `Wpup_LambdaS3UpdateServer::guessServerUrl()`. The parent `Wpup_UpdateServer::guessServerUrl()` produces a relative `download_url` under API Gateway, which WordPress rejects ("A valid URL was not provided"). +- Deploys go through Bref Cloud: `bref deploy --env `. Stage `prod` is production; use `dev` for testing. +- `SIMPLE_UPDATE_KEY` is read at deploy time from SSM `/wp-update-server//SIMPLE_UPDATE_KEY` (see `serverless.yml`); an absent parameter means an empty key. + +## Review Focus + +1. **Composer route requested with the key in `?key=` instead of a header** → `401`. A key accepted from the query string would encourage keys in URLs, which end up in lockfiles and logs. +2. **`SIMPLE_UPDATE_KEY` unset in a stage** → Composer routes `503`, never an open repository. (Task 1 test "empty key".) +3. **A dist filename that doesn't start with its own slug** (`dist/reason-dev/acme/other-1.0.zip`) → `404`, with no S3 call. +4. **`index.php` rewired for Composer, update-checker `download_url` quietly turns relative again** → WordPress refuses every update. (Task 3 Step 4 checks the dev stage's `download_url` starts with `https://`.) +5. **An S3 error other than not-found** (for example `403` from a misconfigured role) → surfaces as `500` with a log line, not a misleading `404`. (Task 1 test "S3 access denied".) + +--- + +### Task 1: Composer router (`Wpup_ComposerRepository`) with tests + +**Files:** +- Create: `includes/Wpup/ComposerResponse.php` +- Create: `includes/Wpup/ComposerRepository.php` +- Modify: `loader.php` (add two `require_once` lines) +- Create: `tests/check.php` +- Create: `tests/composer-repository-test.php` +- Create: `.github/workflows/tests.yml` +- Modify: `serverless.yml` (keep `tests/`, `docs/`, `.github/` out of the Lambda package) + +**Interfaces:** +- Produces: + - `Wpup_ComposerResponse::__construct(int $status, array $headers = [], string $body = '')`, public props `status`, `headers`, `body`; `static json(int $status, array $data): Wpup_ComposerResponse`; `static error(int $status, string $message): Wpup_ComposerResponse`; `send(): void`. + - `Wpup_ComposerRepository::__construct(Aws\S3\S3Client $s3, string $bucket, string $simpleUpdateKey)`. + - `Wpup_ComposerRepository::handle(string $path, Wpup_Headers $headers): ?Wpup_ComposerResponse` — `null` means "not a Composer path". + - `Wpup_ComposerRepository::isComposerPath(string $path): bool` (static). + +- [ ] **Step 1: Add the shared test helper** + +`tests/check.php` (same helper the update checker uses): + +```php + 'us-east-1', + 'version' => 'latest', + 'handler' => $mock, + 'credentials' => array('key' => 'AKIDTEST', 'secret' => 'SECRETTEST'), + )); + return new Wpup_ComposerRepository($s3, 'test-bucket', $key); +} + +function bearer($key) { + return new Wpup_Headers(array('Authorization' => 'Bearer ' . $key)); +} + +function s3_error($status, $code) { + return function (CommandInterface $cmd) use ($status, $code) { + return new S3Exception($code, $cmd, array('response' => new Response($status), 'code' => $code)); + }; +} + +// --- paths the router does not own --- +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('not composer: root', $repo->handle('/', bearer(KEY)) === null); +check('not composer: plugin slug', $repo->handle('/my-plugin/', bearer(KEY)) === null); +check('not composer: index.php', $repo->handle('/index.php', bearer(KEY)) === null); +check('not composer: slug that merely starts with p2', $repo->handle('/p2x/', bearer(KEY)) === null); +check('isComposerPath: dist', Wpup_ComposerRepository::isComposerPath('/dist/reason-dev/a/a-1.0.zip')); + +// --- fail closed when the key is not configured --- +$mock = new MockHandler(); +$res = make_repo('', $mock)->handle('/packages.json', bearer('anything')); +check('empty key: 503', $res->status === 503); + +// --- authorization --- +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +$res = $repo->handle('/packages.json', new Wpup_Headers(array())); +check('no header: 401', $res->status === 401); +check('no header: WWW-Authenticate set', isset($res->headers['WWW-Authenticate'])); +check('wrong key: 401', $repo->handle('/packages.json', bearer('nope'))->status === 401); +check('basic scheme: 401', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'Basic ' . base64_encode('x:' . KEY))))->status === 401); +check('lowercase bearer scheme: 200', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'bearer ' . KEY)))->status === 200); + +// --- packages.json --- +$res = $repo->handle('/packages.json', bearer(KEY)); +$root = json_decode($res->body, true); +check('packages.json: 200', $res->status === 200); +check('packages.json: json content type', $res->headers['Content-Type'] === 'application/json'); +check('packages.json: metadata-url', $root['metadata-url'] === '/p2/%package%.json'); +check('packages.json: patterns', $root['available-package-patterns'] === array('reason-dev/*')); + +// --- p2 --- +$mock = new MockHandler(); +$mock->append(new Result(array('Body' => Utils::streamFor('{"packages":{"reason-dev/acme-widget":[]}}')))); +$repo = make_repo(KEY, $mock); +$res = $repo->handle('/p2/reason-dev/acme-widget.json', bearer(KEY)); +check('p2: 200', $res->status === 200); +check('p2: body passed through', $res->body === '{"packages":{"reason-dev/acme-widget":[]}}'); +check('p2: bucket', $mock->getLastCommand()['Bucket'] === 'test-bucket'); +check('p2: key', $mock->getLastCommand()['Key'] === 'p2/reason-dev/acme-widget.json'); + +$mock = new MockHandler(); +$mock->append(s3_error(404, 'NoSuchKey')); +check('p2 missing: 404', make_repo(KEY, $mock)->handle('/p2/reason-dev/nope.json', bearer(KEY))->status === 404); + +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('p2 ~dev: 404', $repo->handle('/p2/reason-dev/acme-widget~dev.json', bearer(KEY))->status === 404); +check('p2 other vendor: 404', $repo->handle('/p2/wpengine/acf.json', bearer(KEY))->status === 404); +check('p2 traversal: 404', $repo->handle('/p2/reason-dev/../x.json', bearer(KEY))->status === 404); +check('p2 bad requests made no S3 calls', count($mock) === 0 && $mock->getLastCommand() === null); + +// --- dist --- +$mock = new MockHandler(); +$mock->append(new Result(array())); // HeadObject succeeds +$res = make_repo(KEY, $mock)->handle('/dist/reason-dev/acme-widget/acme-widget-1.4.2.zip', bearer(KEY)); +check('dist: 302', $res->status === 302); +check('dist: presigned', strpos($res->headers['Location'], 'X-Amz-Signature=') !== false); +check('dist: correct object', strpos($res->headers['Location'], '/dist/reason-dev/acme-widget/acme-widget-1.4.2.zip') !== false); +check('dist: key not leaked', strpos($res->headers['Location'], KEY) === false); +check('dist: head checked the object', $mock->getLastCommand()->getName() === 'HeadObject'); + +$mock = new MockHandler(); +$mock->append(new Result(array())); +$res = make_repo(KEY, $mock)->handle('/dist/reason-dev/foo-2/foo-2-1.0.0.1.zip', bearer(KEY)); +check('dist: slug containing a digit segment and four-part version', $res->status === 302); + +$mock = new MockHandler(); +$mock->append(s3_error(404, 'NotFound')); +check('dist missing: 404', make_repo(KEY, $mock)->handle('/dist/reason-dev/acme-widget/acme-widget-9.9.9.zip', bearer(KEY))->status === 404); + +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('dist slug mismatch: 404', $repo->handle('/dist/reason-dev/acme-widget/other-1.0.zip', bearer(KEY))->status === 404); +check('dist no version: 404', $repo->handle('/dist/reason-dev/acme-widget/acme-widget.zip', bearer(KEY))->status === 404); +check('meta is never served', $repo->handle('/meta/reason-dev/acme-widget/1.4.2.json', bearer(KEY))->status === 404); +check('bare reserved segment: 404', $repo->handle('/p2/', bearer(KEY))->status === 404); +check('bad dist requests made no S3 calls', $mock->getLastCommand() === null); + +// --- S3 errors other than not-found --- +$mock = new MockHandler(); +$mock->append(s3_error(403, 'AccessDenied')); +check('S3 access denied: 500', make_repo(KEY, $mock)->handle('/p2/reason-dev/acme-widget.json', bearer(KEY))->status === 500); + +finish_tests(); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `php tests/composer-repository-test.php` +Expected: fatal error `Class "Wpup_ComposerRepository" not found`. + +- [ ] **Step 4: Write the response class** + +`includes/Wpup/ComposerResponse.php`: + +```php + */ + public $headers; + /** @var string */ + public $body; + + public function __construct($status, array $headers = array(), $body = '') { + $this->status = (int)$status; + $this->headers = $headers; + $this->body = (string)$body; + } + + public static function json($status, array $data) { + return new self( + $status, + array('Content-Type' => 'application/json', 'Cache-Control' => 'no-cache'), + json_encode($data, JSON_UNESCAPED_SLASHES) + ); + } + + public static function error($status, $message) { + return self::json($status, array('error' => $message)); + } + + public function send() { + http_response_code($this->status); + foreach ( $this->headers as $name => $value ) { + header($name . ': ' . $value); + } + echo $this->body; + } +} +``` + +- [ ] **Step 5: Write the router** + +`includes/Wpup/ComposerRepository.php`: + +```php +.json p2/reason-dev/.json + * /dist/reason-dev//-.zip 302 to a presigned URL + * + * Every route requires SIMPLE_UPDATE_KEY as "Authorization: Bearer " and + * fails closed (503) when the key isn't configured: premium plugin code sits + * behind these routes. The key is never accepted from the query string here, + * because Composer records download URLs in composer.lock. + */ +class Wpup_ComposerRepository { + const VENDOR = 'reason-dev'; + const SLUG_PATTERN = '[a-z0-9][a-z0-9._-]*'; + const VERSION_PATTERN = '[0-9][0-9A-Za-z.+-]*'; + const PRESIGN_TTL = '+15 minutes'; + + /** First path segments this router owns. They can never be plugin slugs. */ + const RESERVED = array('packages.json', 'p2', 'dist', 'meta'); + + /** @var S3Client */ + protected $s3; + /** @var string */ + protected $bucket; + /** @var string */ + protected $key; + + public function __construct(S3Client $s3, $bucket, $simpleUpdateKey) { + $this->s3 = $s3; + $this->bucket = (string)$bucket; + $this->key = (string)$simpleUpdateKey; + } + + public static function isComposerPath($path) { + $parts = explode('/', ltrim((string)$path, '/'), 2); + return in_array($parts[0], self::RESERVED, true); + } + + /** + * @param string $path Request path, e.g. "/p2/reason-dev/acme.json". + * @param Wpup_Headers $headers + * @return Wpup_ComposerResponse|null Null when the path is not a Composer route. + */ + public function handle($path, Wpup_Headers $headers) { + if ( !self::isComposerPath($path) ) { + return null; + } + $path = ltrim((string)$path, '/'); + + if ( $this->key === '' ) { + return Wpup_ComposerResponse::error(503, 'The Composer repository is disabled: SIMPLE_UPDATE_KEY is not set.'); + } + if ( !$this->isAuthorized($headers) ) { + $response = Wpup_ComposerResponse::error(401, 'Invalid or missing update key.'); + $response->headers['WWW-Authenticate'] = 'Bearer realm="packages.reason.com"'; + return $response; + } + + if ( $path === 'packages.json' ) { + return Wpup_ComposerResponse::json(200, array( + 'metadata-url' => '/p2/%package%.json', + 'available-package-patterns' => array(self::VENDOR . '/*'), + )); + } + + if ( preg_match('@^p2/' . self::VENDOR . '/(' . self::SLUG_PATTERN . ')\.json$@', $path, $m) ) { + return $this->serveP2($m[1]); + } + + if ( preg_match('@^dist/' . self::VENDOR . '/(' . self::SLUG_PATTERN . ')/([^/]+)\.zip$@', $path, $m) ) { + $slug = $m[1]; + $prefix = $slug . '-'; + if ( + strpos($m[2], $prefix) === 0 + && preg_match('@^' . self::VERSION_PATTERN . '$@', substr($m[2], strlen($prefix))) + ) { + return $this->serveDist($path); + } + } + + return Wpup_ComposerResponse::error(404, 'Not found.'); + } + + protected function isAuthorized(Wpup_Headers $headers) { + $header = $headers->get('Authorization', ''); + if ( !is_string($header) || stripos($header, 'Bearer ') !== 0 ) { + return false; + } + return hash_equals($this->key, trim(substr($header, 7))); + } + + protected function serveP2($slug) { + try { + $result = $this->s3->getObject(array( + 'Bucket' => $this->bucket, + 'Key' => 'p2/' . self::VENDOR . '/' . $slug . '.json', + )); + } catch ( S3Exception $e ) { + return $this->s3Failure($e); + } + return new Wpup_ComposerResponse( + 200, + array('Content-Type' => 'application/json', 'Cache-Control' => 'no-cache'), + (string)$result['Body'] + ); + } + + protected function serveDist($key) { + try { + $this->s3->headObject(array('Bucket' => $this->bucket, 'Key' => $key)); + } catch ( S3Exception $e ) { + return $this->s3Failure($e); + } + $command = $this->s3->getCommand('GetObject', array('Bucket' => $this->bucket, 'Key' => $key)); + $url = (string)$this->s3->createPresignedRequest($command, self::PRESIGN_TTL)->getUri(); + return new Wpup_ComposerResponse(302, array('Location' => $url, 'Cache-Control' => 'no-store')); + } + + protected function s3Failure(S3Exception $e) { + if ( $e->getStatusCode() === 404 ) { + return Wpup_ComposerResponse::error(404, 'Not found.'); + } + error_log('[composer] S3 error ' . $e->getStatusCode() . ' ' . $e->getAwsErrorCode()); + return Wpup_ComposerResponse::error(500, 'Storage error.'); + } +} +``` + +Add to `loader.php`, after the `LambdaS3UpdateServer.php` line: + +```php +require_once __DIR__ . '/includes/Wpup/ComposerResponse.php'; +require_once __DIR__ . '/includes/Wpup/ComposerRepository.php'; +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `composer install && php tests/composer-repository-test.php` +Expected: every line `PASS`, final line `ALL PASSED`, exit code 0. + +- [ ] **Step 7: Run the tests in CI** + +`.github/workflows/tests.yml`: + +```yaml +--- +name: Tests + +"on": + push: + branches: [master] + pull_request: + +jobs: + php: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - run: composer install --no-interaction --no-progress + - name: Run test scripts + run: | + status=0 + for t in tests/*-test.php; do + echo "== $t" + php "$t" || status=1 + done + exit $status +``` + +- [ ] **Step 8: Keep tests and docs out of the Lambda package** + +Bref zips the project directory as-is, so without this the new `tests/` and `docs/` folders ship to Lambda. In `serverless.yml`, add a top-level `package` block directly above the `functions:` key: + +```yaml +package: + patterns: + - '!tests/**' + - '!docs/**' + - '!.github/**' + - '!.idea/**' +``` + +Run: `grep -n "^package:" -A5 serverless.yml` +Expected: the block is present at the top level (same indentation as `functions:`). + +- [ ] **Step 9: Commit** + +```bash +git checkout -b feature/composer-routes +git add includes/Wpup/ComposerResponse.php includes/Wpup/ComposerRepository.php loader.php tests/ .github/workflows/tests.yml serverless.yml +git commit -m "Add a read-only Composer repository router for reason-dev packages" +``` + +--- + +### Task 2: Route Composer paths in `index.php` + +**Files:** +- Modify: `index.php` +- Test: `tests/index-routing-test.php` + +**Interfaces:** +- Consumes: `Wpup_ComposerRepository::handle()`, `Wpup_ComposerResponse::send()` (Task 1). +- Produces: `wpup_route_composer(Wpup_ComposerRepository $repo, string $requestUri, array $headers): ?Wpup_ComposerResponse` in new file `includes/composer-routing.php` — the testable part of `index.php`. + +- [ ] **Step 1: Write the failing test** + +`tests/index-routing-test.php`: + +```php + 'us-east-1', + 'version' => 'latest', + 'handler' => new MockHandler(), + 'credentials' => array('key' => 'AKIDTEST', 'secret' => 'SECRETTEST'), +)); +$repo = new Wpup_ComposerRepository($s3, 'test-bucket', 'k'); + +check('update-checker path falls through', wpup_route_composer($repo, '/acme-widget/?action=get_metadata', array()) === null); +check('query string is stripped before matching', wpup_route_composer($repo, '/packages.json?foo=1', array('Authorization' => 'Bearer k'))->status === 200); +check('query key does not authorize', wpup_route_composer($repo, '/packages.json?key=k', array())->status === 401); +check('header authorizes', wpup_route_composer($repo, '/packages.json', array('Authorization' => 'Bearer k'))->status === 200); +check('reserved path never falls through', wpup_route_composer($repo, '/meta/', array('Authorization' => 'Bearer k')) !== null); + +finish_tests(); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `php tests/index-routing-test.php` +Expected: failure opening `includes/composer-routing.php`. + +- [ ] **Step 3: Implement** + +`includes/composer-routing.php`: + +```php + value. + * @return Wpup_ComposerResponse|null + */ +function wpup_route_composer(Wpup_ComposerRepository $repo, $requestUri, array $headers) { + $path = parse_url((string)$requestUri, PHP_URL_PATH); + if ( !is_string($path) ) { + $path = '/'; + } + return $repo->handle($path, new Wpup_Headers($headers)); +} +``` + +Replace `index.php` with: + +```php + 'latest', + 'region' => getenv('AWS_REGION') ?: 'us-east-1', +)); + +$bucketName = getenv('S3_BUCKET'); +$prefix = getenv('S3_PREFIX') ?: ''; +$simpleUpdateKey = getenv('SIMPLE_UPDATE_KEY') ?: ''; + +// Composer routes first: Wpup_Request would otherwise read "p2", "dist", ... as a plugin slug. +$composerResponse = wpup_route_composer( + new Wpup_ComposerRepository($s3Client, $bucketName, $simpleUpdateKey), + isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/', + Wpup_Headers::parseCurrent() +); +if ($composerResponse !== null) { + error_log(sprintf('[composer] %d %s', $composerResponse->status, parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))); + $composerResponse->send(); + exit; +} + +$server = new Wpup_LambdaS3UpdateServer( + // Keep the Lambda override: the parent's version yields a relative download_url under API Gateway. + Wpup_LambdaS3UpdateServer::guessServerUrl(), + $s3Client, + $bucketName, + $prefix, + $simpleUpdateKey +); +$server->handleRequest(); +``` + +- [ ] **Step 4: Run all tests** + +Run: `for t in tests/*-test.php; do php "$t" || echo "FAILED: $t"; done` +Expected: both scripts end `ALL PASSED`, no `FAILED:` lines. + +Run: `php -l index.php && grep -c "Wpup_LambdaS3UpdateServer::guessServerUrl()" index.php` +Expected: `No syntax errors detected`, then `1`. + +- [ ] **Step 5: Commit** + +```bash +git add index.php includes/composer-routing.php tests/index-routing-test.php +git commit -m "Serve Composer routes before the update-checker route" +``` + +--- + +### Task 3: Acceptance on a `dev` stage + +**Files:** +- Create: `tests/acceptance/composer-smoke.sh` + +**Interfaces:** +- Consumes: deployed Composer routes (Tasks 1–2). +- Produces: `tests/acceptance/composer-smoke.sh ` — reused after the production deploy and by plan 2's canary. + +- [ ] **Step 1: Write the smoke script** + +`tests/acceptance/composer-smoke.sh`: + +```bash +#!/usr/bin/env bash +# End-to-end check of the Composer routes against a deployed stage. +# Uploads a throwaway package reason-dev/wpup-smoke at a unique version, +# installs it with Composer using Bearer auth, and checks the lockfile. +# +# tests/acceptance/composer-smoke.sh https://.execute-api.us-east-1.amazonaws.com +set -euo pipefail + +BASE_URL="${1%/}"; BUCKET="$2"; KEY="$3" +HOST="$(printf '%s' "$BASE_URL" | sed -E 's#^https?://([^/]+).*#\1#')" +VERSION="0.0.$(date +%s)" +SLUG="wpup-smoke" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/pkg/$SLUG" +printf ' "$WORK/pkg/$SLUG/$SLUG.php" +(cd "$WORK/pkg" && zip -qr "$WORK/$SLUG-$VERSION.zip" "$SLUG") +SHA1="$(shasum -a 1 "$WORK/$SLUG-$VERSION.zip" | cut -d' ' -f1)" +DIST_KEY="dist/reason-dev/$SLUG/$SLUG-$VERSION.zip" + +cat > "$WORK/p2.json" </dev/null +aws s3api put-object --bucket "$BUCKET" --key "p2/reason-dev/$SLUG.json" --body "$WORK/p2.json" --content-type application/json >/dev/null + +echo "== unauthenticated request is refused" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json")" +[ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } + +echo "== query-string key is refused" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json?key=$KEY")" +[ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } + +echo "== composer install with Bearer auth" +mkdir "$WORK/site" +cat > "$WORK/site/composer.json" <" "" "$DEV_KEY"` +Expected: final line `PASS: Composer routes work end to end (wpup-smoke 0.0.)`. + +- [ ] **Step 4: Confirm the update-checker route is unchanged on dev** + +Upload any existing plugin zip to `packages//` in the dev bucket, then: + +Run: `curl -s -H "Authorization: Bearer $DEV_KEY" "//?action=get_metadata" | jq '{name, version, download_url}'` +Expected: the same JSON shape production returns today, and `download_url` is an **absolute** URL starting with `https:///` that contains `action=download` and `key=`. + +Run: `curl -sI "$(curl -s -H "Authorization: Bearer $DEV_KEY" "//?action=get_metadata" | jq -r .download_url)" | head -1` +Expected: `HTTP/2 302` (a redirect to a presigned S3 URL). + +Afterwards, restore the dev environment for local work with `composer install` (the deploy step installed without dev packages). + +- [ ] **Step 5: Commit** + +```bash +git add tests/acceptance/composer-smoke.sh +git commit -m "Add a Composer end-to-end smoke test for deployed stages" +``` + +--- + +### Task 4: Runbook, production deploy, and merge + +**Files:** +- Create: `docs/runbook.md` + +- [ ] **Step 1: Write the runbook** + +`docs/runbook.md`: + +```markdown +# packages.reason.com runbook + +## What this serves + +* Update-checker route (legacy WordPress sites): `//?action=get_metadata` + and `?action=download`. Key via `Authorization: Bearer` or `?key=`. +* Composer routes (vnext sites): `/packages.json`, `/p2/reason-dev/.json`, + `/dist/reason-dev//-.zip`. Key via `Authorization: Bearer` + only. Answer 503 if `SIMPLE_UPDATE_KEY` is unset. + +This server only reads the bucket. Everything under `packages/`, `dist/`, +`meta/` and `p2/` is written by `reason-wp-repo-actions`. + +## Rules + +* **Append-only.** Never overwrite or delete objects under `packages/`, `dist/`, + `meta/` or `p2/` by hand, and never add an S3 lifecycle rule that expires + them. Sites' `composer.lock` files download exact URLs forever; deleting a + version breaks every site that locked it, including their rollbacks. +* **Rebuilding an index:** `scripts/rebuild-composer-index.sh` in + `reason-wp-repo-actions` regenerates `p2/` from `meta/`. Use it instead of + editing a p2 file. + +## Rotating SIMPLE_UPDATE_KEY + +One key serves the whole fleet. Rotation touches, in this order: + +1. Every legacy site's `REASON_PACKAGES_UPDATES_KEY` constant. The server + accepts only one key at a time, so schedule a short window: sites send + the old key until their constant changes. +2. Every vnext site repo's `COMPOSER_AUTH_JSON` secret + (`{"bearer":{"packages.reason.com":""}}`). +3. The org secret `REASON_PACKAGES_UPDATES_KEY` used by plugin CI. +4. SSM `/wp-update-server/prod/SIMPLE_UPDATE_KEY`, then redeploy. + +## Test stage + +`dev` stage: SSM `/wp-update-server/dev/SIMPLE_UPDATE_KEY`, stack +`wp-update-server-dev`. Smoke test: +`tests/acceptance/composer-smoke.sh `. +``` + +- [ ] **Step 2: Deploy production** + +```bash +composer install --no-dev --optimize-autoloader +bref deploy --env prod +composer install +``` + +- [ ] **Step 3: Check production, according to whether the key is enforced yet** + +Run: `curl -s -o /dev/null -w '%{http_code}\n' https://packages.reason.com/packages.json` + +- **`503`: the key isn't set in production yet.** This is expected while the plugin fleet still bundles a checker without key support (see Task 0). It proves the routes fail closed. Also confirm the update-checker route still works for sites: + `curl -s "https://packages.reason.com//?action=get_metadata" | jq '{version, download_url}'` + Expected: a version, and an absolute `download_url`. **Stop here for this plan.** The full production smoke test (the next bullet) runs after the key is enforced, as the first step of the key-enforcement release. +- **`401`: the key is set.** Run the full smoke test: + ```bash + PROD_BUCKET="$(aws cloudformation describe-stack-resources --stack-name wp-update-server-prod \ + --logical-resource-id WPUpdateBucket --query 'StackResources[0].PhysicalResourceId' --output text)" + tests/acceptance/composer-smoke.sh https://packages.reason.com "$PROD_BUCKET" "" + ``` + Expected: `PASS: …`. The smoke package `reason-dev/wpup-smoke` stays in the bucket (append-only); that's intended and harmless. + +- [ ] **Step 4: Commit, push, open a pull request** + +```bash +git add docs/runbook.md +git commit -m "Add a runbook covering Composer routes, append-only storage and key rotation" +git push -u origin feature/composer-routes +``` + +Open a PR into `master`; merge after review. + +--- + +### Task 0 (not part of this branch): pre-flight before enforcing the key in production + +Not a code task: a pre-flight check so legacy sites don't lose updates when `SIMPLE_UPDATE_KEY` is set. + +On a legacy site, the first plugin to load its bundled `wp-update-checker` supplies it for every plugin. If that copy predates key support, the site sends no key and gets `401` for every Reason plugin. + +- [ ] **Step 1: On each legacy site (reason.com, reason.org, staging), check the loaded copy** + +```bash +wp eval 'echo function_exists("ReasonDev\\PluginUpdateChecker\\ReasonPackages\\filter_http_request_args") ? "key support: yes\n" : "key support: NO\n"; echo defined("REASON_PACKAGES_UPDATES_KEY") ? "key defined: yes\n" : "key defined: NO\n";' +``` + +Expected: `key support: yes` and `key defined: yes` on every site. Any `NO` blocks enforcing the key until the site's plugins are updated or the constant is added. From be89e1e8406967edda08c5d2dcba2a26fc5af2a1 Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:21:15 -0500 Subject: [PATCH 04/11] Add a read-only Composer repository router for reason-dev packages Co-Authored-By: Claude Haiku 4.5 --- .github/workflows/tests.yml | 26 ++++++ includes/Wpup/ComposerRepository.php | 132 +++++++++++++++++++++++++++ includes/Wpup/ComposerResponse.php | 39 ++++++++ loader.php | 2 + serverless.yml | 7 ++ tests/check.php | 18 ++++ tests/composer-repository-test.php | 129 ++++++++++++++++++++++++++ 7 files changed, 353 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 includes/Wpup/ComposerRepository.php create mode 100644 includes/Wpup/ComposerResponse.php create mode 100644 tests/check.php create mode 100644 tests/composer-repository-test.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d5fff9d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +--- +name: Tests + +"on": + push: + branches: [master] + pull_request: + +jobs: + php: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - run: composer install --no-interaction --no-progress + - name: Run test scripts + run: | + status=0 + for t in tests/*-test.php; do + echo "== $t" + php "$t" || status=1 + done + exit $status diff --git a/includes/Wpup/ComposerRepository.php b/includes/Wpup/ComposerRepository.php new file mode 100644 index 0000000..fcea9fd --- /dev/null +++ b/includes/Wpup/ComposerRepository.php @@ -0,0 +1,132 @@ +.json p2/reason-dev/.json + * /dist/reason-dev//-.zip 302 to a presigned URL + * + * Every route requires SIMPLE_UPDATE_KEY as "Authorization: Bearer " and + * fails closed (503) when the key isn't configured: premium plugin code sits + * behind these routes. The key is never accepted from the query string here, + * because Composer records download URLs in composer.lock. + */ +class Wpup_ComposerRepository { + const VENDOR = 'reason-dev'; + const SLUG_PATTERN = '[a-z0-9][a-z0-9._-]*'; + const VERSION_PATTERN = '[0-9][0-9A-Za-z.+-]*'; + const PRESIGN_TTL = '+15 minutes'; + + /** First path segments this router owns. They can never be plugin slugs. */ + const RESERVED = array('packages.json', 'p2', 'dist', 'meta'); + + /** @var S3Client */ + protected $s3; + /** @var string */ + protected $bucket; + /** @var string */ + protected $key; + + public function __construct(S3Client $s3, $bucket, $simpleUpdateKey) { + $this->s3 = $s3; + $this->bucket = (string)$bucket; + $this->key = (string)$simpleUpdateKey; + } + + public static function isComposerPath($path) { + $parts = explode('/', ltrim((string)$path, '/'), 2); + return in_array($parts[0], self::RESERVED, true); + } + + /** + * @param string $path Request path, e.g. "/p2/reason-dev/acme.json". + * @param Wpup_Headers $headers + * @return Wpup_ComposerResponse|null Null when the path is not a Composer route. + */ + public function handle($path, Wpup_Headers $headers) { + if ( !self::isComposerPath($path) ) { + return null; + } + $path = ltrim((string)$path, '/'); + + if ( $this->key === '' ) { + return Wpup_ComposerResponse::error(503, 'The Composer repository is disabled: SIMPLE_UPDATE_KEY is not set.'); + } + if ( !$this->isAuthorized($headers) ) { + $response = Wpup_ComposerResponse::error(401, 'Invalid or missing update key.'); + $response->headers['WWW-Authenticate'] = 'Bearer realm="packages.reason.com"'; + return $response; + } + + if ( $path === 'packages.json' ) { + return Wpup_ComposerResponse::json(200, array( + 'metadata-url' => '/p2/%package%.json', + 'available-package-patterns' => array(self::VENDOR . '/*'), + )); + } + + if ( preg_match('@^p2/' . self::VENDOR . '/(' . self::SLUG_PATTERN . ')\.json$@', $path, $m) ) { + return $this->serveP2($m[1]); + } + + if ( preg_match('@^dist/' . self::VENDOR . '/(' . self::SLUG_PATTERN . ')/([^/]+)\.zip$@', $path, $m) ) { + $slug = $m[1]; + $prefix = $slug . '-'; + if ( + strpos($m[2], $prefix) === 0 + && preg_match('@^' . self::VERSION_PATTERN . '$@', substr($m[2], strlen($prefix))) + ) { + return $this->serveDist($path); + } + } + + return Wpup_ComposerResponse::error(404, 'Not found.'); + } + + protected function isAuthorized(Wpup_Headers $headers) { + $header = $headers->get('Authorization', ''); + if ( !is_string($header) || stripos($header, 'Bearer ') !== 0 ) { + return false; + } + return hash_equals($this->key, trim(substr($header, 7))); + } + + protected function serveP2($slug) { + try { + $result = $this->s3->getObject(array( + 'Bucket' => $this->bucket, + 'Key' => 'p2/' . self::VENDOR . '/' . $slug . '.json', + )); + } catch ( S3Exception $e ) { + return $this->s3Failure($e); + } + return new Wpup_ComposerResponse( + 200, + array('Content-Type' => 'application/json', 'Cache-Control' => 'no-cache'), + (string)$result['Body'] + ); + } + + protected function serveDist($key) { + try { + $this->s3->headObject(array('Bucket' => $this->bucket, 'Key' => $key)); + } catch ( S3Exception $e ) { + return $this->s3Failure($e); + } + $command = $this->s3->getCommand('GetObject', array('Bucket' => $this->bucket, 'Key' => $key)); + $url = (string)$this->s3->createPresignedRequest($command, self::PRESIGN_TTL)->getUri(); + return new Wpup_ComposerResponse(302, array('Location' => $url, 'Cache-Control' => 'no-store')); + } + + protected function s3Failure(S3Exception $e) { + if ( $e->getStatusCode() === 404 ) { + return Wpup_ComposerResponse::error(404, 'Not found.'); + } + error_log('[composer] S3 error ' . $e->getStatusCode() . ' ' . $e->getAwsErrorCode()); + return Wpup_ComposerResponse::error(500, 'Storage error.'); + } +} diff --git a/includes/Wpup/ComposerResponse.php b/includes/Wpup/ComposerResponse.php new file mode 100644 index 0000000..1a777f2 --- /dev/null +++ b/includes/Wpup/ComposerResponse.php @@ -0,0 +1,39 @@ + */ + public $headers; + /** @var string */ + public $body; + + public function __construct($status, array $headers = array(), $body = '') { + $this->status = (int)$status; + $this->headers = $headers; + $this->body = (string)$body; + } + + public static function json($status, array $data) { + return new self( + $status, + array('Content-Type' => 'application/json', 'Cache-Control' => 'no-cache'), + json_encode($data, JSON_UNESCAPED_SLASHES) + ); + } + + public static function error($status, $message) { + return self::json($status, array('error' => $message)); + } + + public function send() { + http_response_code($this->status); + foreach ( $this->headers as $name => $value ) { + header($name . ': ' . $value); + } + echo $this->body; + } +} diff --git a/loader.php b/loader.php index d1ae959..f483298 100644 --- a/loader.php +++ b/loader.php @@ -9,6 +9,8 @@ require_once __DIR__ . '/includes/Wpup/FileCache.php'; require_once __DIR__ . '/includes/Wpup/UpdateServer.php'; require_once __DIR__ . '/includes/Wpup/LambdaS3UpdateServer.php'; +require_once __DIR__ . '/includes/Wpup/ComposerResponse.php'; +require_once __DIR__ . '/includes/Wpup/ComposerRepository.php'; if ( !class_exists('WshWordPressPackageParser') ) { require_once __DIR__ . '/includes/extension-meta/extension-meta.php'; diff --git a/serverless.yml b/serverless.yml index 0629470..85eef81 100644 --- a/serverless.yml +++ b/serverless.yml @@ -18,6 +18,13 @@ provider: - !GetAtt WPUpdateBucket.Arn - !Sub '${WPUpdateBucket.Arn}/*' +package: + patterns: + - '!tests/**' + - '!docs/**' + - '!.github/**' + - '!.idea/**' + functions: web: # `index.php` is the entrypoint of your application diff --git a/tests/check.php b/tests/check.php new file mode 100644 index 0000000..e3eb299 --- /dev/null +++ b/tests/check.php @@ -0,0 +1,18 @@ + 'us-east-1', + 'version' => 'latest', + 'handler' => $mock, + 'credentials' => array('key' => 'AKIDTEST', 'secret' => 'SECRETTEST'), + )); + return new Wpup_ComposerRepository($s3, 'test-bucket', $key); +} + +function bearer($key) { + return new Wpup_Headers(array('Authorization' => 'Bearer ' . $key)); +} + +function s3_error($status, $code) { + return function (CommandInterface $cmd) use ($status, $code) { + return new S3Exception($code, $cmd, array('response' => new Response($status), 'code' => $code)); + }; +} + +// --- paths the router does not own --- +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('not composer: root', $repo->handle('/', bearer(KEY)) === null); +check('not composer: plugin slug', $repo->handle('/my-plugin/', bearer(KEY)) === null); +check('not composer: index.php', $repo->handle('/index.php', bearer(KEY)) === null); +check('not composer: slug that merely starts with p2', $repo->handle('/p2x/', bearer(KEY)) === null); +check('isComposerPath: dist', Wpup_ComposerRepository::isComposerPath('/dist/reason-dev/a/a-1.0.zip')); + +// --- fail closed when the key is not configured --- +$mock = new MockHandler(); +$res = make_repo('', $mock)->handle('/packages.json', bearer('anything')); +check('empty key: 503', $res->status === 503); + +// --- authorization --- +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +$res = $repo->handle('/packages.json', new Wpup_Headers(array())); +check('no header: 401', $res->status === 401); +check('no header: WWW-Authenticate set', isset($res->headers['WWW-Authenticate'])); +check('wrong key: 401', $repo->handle('/packages.json', bearer('nope'))->status === 401); +check('basic scheme: 401', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'Basic ' . base64_encode('x:' . KEY))))->status === 401); +check('lowercase bearer scheme: 200', $repo->handle('/packages.json', new Wpup_Headers(array('Authorization' => 'bearer ' . KEY)))->status === 200); + +// --- packages.json --- +$res = $repo->handle('/packages.json', bearer(KEY)); +$root = json_decode($res->body, true); +check('packages.json: 200', $res->status === 200); +check('packages.json: json content type', $res->headers['Content-Type'] === 'application/json'); +check('packages.json: metadata-url', $root['metadata-url'] === '/p2/%package%.json'); +check('packages.json: patterns', $root['available-package-patterns'] === array('reason-dev/*')); + +// --- p2 --- +$mock = new MockHandler(); +$mock->append(new Result(array('Body' => Utils::streamFor('{"packages":{"reason-dev/acme-widget":[]}}')))); +$repo = make_repo(KEY, $mock); +$res = $repo->handle('/p2/reason-dev/acme-widget.json', bearer(KEY)); +check('p2: 200', $res->status === 200); +check('p2: body passed through', $res->body === '{"packages":{"reason-dev/acme-widget":[]}}'); +check('p2: bucket', $mock->getLastCommand()['Bucket'] === 'test-bucket'); +check('p2: key', $mock->getLastCommand()['Key'] === 'p2/reason-dev/acme-widget.json'); + +$mock = new MockHandler(); +$mock->append(s3_error(404, 'NoSuchKey')); +check('p2 missing: 404', make_repo(KEY, $mock)->handle('/p2/reason-dev/nope.json', bearer(KEY))->status === 404); + +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('p2 ~dev: 404', $repo->handle('/p2/reason-dev/acme-widget~dev.json', bearer(KEY))->status === 404); +check('p2 other vendor: 404', $repo->handle('/p2/wpengine/acf.json', bearer(KEY))->status === 404); +check('p2 traversal: 404', $repo->handle('/p2/reason-dev/../x.json', bearer(KEY))->status === 404); +check('p2 bad requests made no S3 calls', count($mock) === 0 && $mock->getLastCommand() === null); + +// --- dist --- +$mock = new MockHandler(); +$mock->append(new Result(array())); // HeadObject succeeds +$res = make_repo(KEY, $mock)->handle('/dist/reason-dev/acme-widget/acme-widget-1.4.2.zip', bearer(KEY)); +check('dist: 302', $res->status === 302); +check('dist: presigned', strpos($res->headers['Location'], 'X-Amz-Signature=') !== false); +check('dist: correct object', strpos($res->headers['Location'], '/dist/reason-dev/acme-widget/acme-widget-1.4.2.zip') !== false); +check('dist: key not leaked', strpos($res->headers['Location'], KEY) === false); +check('dist: head checked the object', $mock->getLastCommand()->getName() === 'HeadObject'); + +$mock = new MockHandler(); +$mock->append(new Result(array())); +$res = make_repo(KEY, $mock)->handle('/dist/reason-dev/foo-2/foo-2-1.0.0.1.zip', bearer(KEY)); +check('dist: slug containing a digit segment and four-part version', $res->status === 302); + +$mock = new MockHandler(); +$mock->append(s3_error(404, 'NotFound')); +check('dist missing: 404', make_repo(KEY, $mock)->handle('/dist/reason-dev/acme-widget/acme-widget-9.9.9.zip', bearer(KEY))->status === 404); + +$mock = new MockHandler(); +$repo = make_repo(KEY, $mock); +check('dist slug mismatch: 404', $repo->handle('/dist/reason-dev/acme-widget/other-1.0.zip', bearer(KEY))->status === 404); +check('dist no version: 404', $repo->handle('/dist/reason-dev/acme-widget/acme-widget.zip', bearer(KEY))->status === 404); +check('meta is never served', $repo->handle('/meta/reason-dev/acme-widget/1.4.2.json', bearer(KEY))->status === 404); +check('bare reserved segment: 404', $repo->handle('/p2/', bearer(KEY))->status === 404); +check('bad dist requests made no S3 calls', $mock->getLastCommand() === null); + +// --- S3 errors other than not-found --- +$mock = new MockHandler(); +$mock->append(s3_error(403, 'AccessDenied')); +check('S3 access denied: 500', make_repo(KEY, $mock)->handle('/p2/reason-dev/acme-widget.json', bearer(KEY))->status === 500); + +finish_tests(); From b4a3e1c72451f295abea254de6c7822c35d5f283 Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:26:29 -0500 Subject: [PATCH 05/11] Serve Composer routes before the update-checker route Co-Authored-By: Claude Haiku 4.5 --- includes/composer-routing.php | 16 ++++++++++++++++ index.php | 16 +++++++++++++++- tests/index-routing-test.php | 29 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 includes/composer-routing.php create mode 100644 tests/index-routing-test.php diff --git a/includes/composer-routing.php b/includes/composer-routing.php new file mode 100644 index 0000000..0f8d896 --- /dev/null +++ b/includes/composer-routing.php @@ -0,0 +1,16 @@ + value. + * @return Wpup_ComposerResponse|null + */ +function wpup_route_composer(Wpup_ComposerRepository $repo, $requestUri, array $headers) { + $path = parse_url((string)$requestUri, PHP_URL_PATH); + if ( !is_string($path) ) { + $path = '/'; + } + return $repo->handle($path, new Wpup_Headers($headers)); +} diff --git a/index.php b/index.php index 42b7572..3a93b9c 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,7 @@ status, parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))); + $composerResponse->send(); + exit; +} + $server = new Wpup_LambdaS3UpdateServer( + // Keep the Lambda override: the parent's version yields a relative download_url under API Gateway. Wpup_LambdaS3UpdateServer::guessServerUrl(), $s3Client, $bucketName, $prefix, $simpleUpdateKey ); -$server->handleRequest(); \ No newline at end of file +$server->handleRequest(); diff --git a/tests/index-routing-test.php b/tests/index-routing-test.php new file mode 100644 index 0000000..fa1486e --- /dev/null +++ b/tests/index-routing-test.php @@ -0,0 +1,29 @@ + 'us-east-1', + 'version' => 'latest', + 'handler' => new MockHandler(), + 'credentials' => array('key' => 'AKIDTEST', 'secret' => 'SECRETTEST'), +)); +$repo = new Wpup_ComposerRepository($s3, 'test-bucket', 'k'); + +check('update-checker path falls through', wpup_route_composer($repo, '/acme-widget/?action=get_metadata', array()) === null); +check('query string is stripped before matching', wpup_route_composer($repo, '/packages.json?foo=1', array('Authorization' => 'Bearer k'))->status === 200); +check('query key does not authorize', wpup_route_composer($repo, '/packages.json?key=k', array())->status === 401); +check('header authorizes', wpup_route_composer($repo, '/packages.json', array('Authorization' => 'Bearer k'))->status === 200); +check('reserved path never falls through', wpup_route_composer($repo, '/meta/', array('Authorization' => 'Bearer k')) !== null); + +finish_tests(); From 591dc4f73240a048ecf00686ce35157581a27974 Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:30:10 -0500 Subject: [PATCH 06/11] Add a Composer end-to-end smoke test for deployed stages Co-Authored-By: Claude Haiku 4.5 --- tests/acceptance/composer-smoke.sh | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100755 tests/acceptance/composer-smoke.sh diff --git a/tests/acceptance/composer-smoke.sh b/tests/acceptance/composer-smoke.sh new file mode 100755 index 0000000..a018f2a --- /dev/null +++ b/tests/acceptance/composer-smoke.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# End-to-end check of the Composer routes against a deployed stage. +# Uploads a throwaway package reason-dev/wpup-smoke at a unique version, +# installs it with Composer using Bearer auth, and checks the lockfile. +# +# tests/acceptance/composer-smoke.sh https://.execute-api.us-east-1.amazonaws.com +set -euo pipefail + +BASE_URL="${1%/}"; BUCKET="$2"; KEY="$3" +HOST="$(printf '%s' "$BASE_URL" | sed -E 's#^https?://([^/]+).*#\1#')" +VERSION="0.0.$(date +%s)" +SLUG="wpup-smoke" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/pkg/$SLUG" +printf ' "$WORK/pkg/$SLUG/$SLUG.php" +(cd "$WORK/pkg" && zip -qr "$WORK/$SLUG-$VERSION.zip" "$SLUG") +SHA1="$(shasum -a 1 "$WORK/$SLUG-$VERSION.zip" | cut -d' ' -f1)" +DIST_KEY="dist/reason-dev/$SLUG/$SLUG-$VERSION.zip" + +cat > "$WORK/p2.json" </dev/null +aws s3api put-object --bucket "$BUCKET" --key "p2/reason-dev/$SLUG.json" --body "$WORK/p2.json" --content-type application/json >/dev/null + +echo "== unauthenticated request is refused" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json")" +[ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } + +echo "== query-string key is refused" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json?key=$KEY")" +[ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } + +echo "== composer install with Bearer auth" +mkdir "$WORK/site" +cat > "$WORK/site/composer.json" < Date: Thu, 24 Sep 2026 02:30:10 -0500 Subject: [PATCH 07/11] Add a runbook covering Composer routes, append-only storage and key rotation Co-Authored-By: Claude Haiku 4.5 --- docs/runbook.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/runbook.md diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..5559249 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,40 @@ +# packages.reason.com runbook + +## What this serves + +* Update-checker route (legacy WordPress sites): `//?action=get_metadata` + and `?action=download`. Key via `Authorization: Bearer` or `?key=`. +* Composer routes (vnext sites): `/packages.json`, `/p2/reason-dev/.json`, + `/dist/reason-dev//-.zip`. Key via `Authorization: Bearer` + only. Answer 503 if `SIMPLE_UPDATE_KEY` is unset. + +This server only reads the bucket. Everything under `packages/`, `dist/`, +`meta/` and `p2/` is written by `reason-wp-repo-actions`. + +## Rules + +* **Append-only.** Never overwrite or delete objects under `packages/`, `dist/`, + `meta/` or `p2/` by hand, and never add an S3 lifecycle rule that expires + them. Sites' `composer.lock` files download exact URLs forever; deleting a + version breaks every site that locked it, including their rollbacks. +* **Rebuilding an index:** `scripts/rebuild-composer-index.sh` in + `reason-wp-repo-actions` regenerates `p2/` from `meta/`. Use it instead of + editing a p2 file. + +## Rotating SIMPLE_UPDATE_KEY + +One key serves the whole fleet. Rotation touches, in this order: + +1. Every legacy site's `REASON_PACKAGES_UPDATES_KEY` constant. The server + accepts only one key at a time, so schedule a short window: sites send + the old key until their constant changes. +2. Every vnext site repo's `COMPOSER_AUTH_JSON` secret + (`{"bearer":{"packages.reason.com":""}}`). +3. The org secret `REASON_PACKAGES_UPDATES_KEY` used by plugin CI. +4. SSM `/wp-update-server/prod/SIMPLE_UPDATE_KEY`, then redeploy. + +## Test stage + +`dev` stage: SSM `/wp-update-server/dev/SIMPLE_UPDATE_KEY`, stack +`wp-update-server-dev`. Smoke test: +`tests/acceptance/composer-smoke.sh `. From a9a8c86872e3fcde208f424e2f35b58bbd60be1c Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:35:39 -0500 Subject: [PATCH 08/11] Document the smoke package as the one p2 exception Co-Authored-By: Claude Haiku 4.5 --- docs/runbook.md | 10 ++++++++++ tests/acceptance/composer-smoke.sh | 1 + 2 files changed, 11 insertions(+) diff --git a/docs/runbook.md b/docs/runbook.md index 5559249..c22a6fe 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -17,6 +17,12 @@ This server only reads the bucket. Everything under `packages/`, `dist/`, `meta/` or `p2/` by hand, and never add an S3 lifecycle rule that expires them. Sites' `composer.lock` files download exact URLs forever; deleting a version breaks every site that locked it, including their rollbacks. +* **The one exception: `reason-dev/wpup-smoke`.** It is a throwaway test + package written only by `tests/acceptance/composer-smoke.sh`. Each run + uploads a new zip under `dist/` (append-only) and replaces + `p2/reason-dev/wpup-smoke.json` with just that run's version. No site + ever requires it, and it has no `meta/` sidecar, so the index rebuild + script leaves it alone. * **Rebuilding an index:** `scripts/rebuild-composer-index.sh` in `reason-wp-repo-actions` regenerates `p2/` from `meta/`. Use it instead of editing a p2 file. @@ -38,3 +44,7 @@ One key serves the whole fleet. Rotation touches, in this order: `dev` stage: SSM `/wp-update-server/dev/SIMPLE_UPDATE_KEY`, stack `wp-update-server-dev`. Smoke test: `tests/acceptance/composer-smoke.sh `. + +The smoke test's "query-string key is refused" check sends the key in a +URL (`?key=`) on purpose, to prove it is rejected. Treat that key as +possibly present in API Gateway access logs for that request. diff --git a/tests/acceptance/composer-smoke.sh b/tests/acceptance/composer-smoke.sh index a018f2a..18393db 100755 --- a/tests/acceptance/composer-smoke.sh +++ b/tests/acceptance/composer-smoke.sh @@ -25,6 +25,7 @@ cat > "$WORK/p2.json" </dev/null +# Replaces the smoke package's index on purpose (see docs/runbook.md, "The one exception"). aws s3api put-object --bucket "$BUCKET" --key "p2/reason-dev/$SLUG.json" --body "$WORK/p2.json" --content-type application/json >/dev/null echo "== unauthenticated request is refused" From 5929908dd3e685140f05817dc7c5df0b3975c92a Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 24 Sep 2026 02:42:00 -0500 Subject: [PATCH 09/11] Correct the key comment, tighten packaging and the smoke script, clarify the runbook Co-Authored-By: Claude Sonnet 5 --- docs/runbook.md | 15 +++++++++++++-- serverless.yml | 13 +++++++++---- tests/acceptance/composer-smoke.sh | 6 ++++-- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/runbook.md b/docs/runbook.md index c22a6fe..bd2f2b4 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -3,7 +3,9 @@ ## What this serves * Update-checker route (legacy WordPress sites): `//?action=get_metadata` - and `?action=download`. Key via `Authorization: Bearer` or `?key=`. + and `?action=download`. Key via `Authorization: Bearer` or `?key=`. With + `SIMPLE_UPDATE_KEY` unset, this route is ungated — which is production's + state until the key is enforced. * Composer routes (vnext sites): `/packages.json`, `/p2/reason-dev/.json`, `/dist/reason-dev//-.zip`. Key via `Authorization: Bearer` only. Answer 503 if `SIMPLE_UPDATE_KEY` is unset. @@ -16,7 +18,10 @@ This server only reads the bucket. Everything under `packages/`, `dist/`, * **Append-only.** Never overwrite or delete objects under `packages/`, `dist/`, `meta/` or `p2/` by hand, and never add an S3 lifecycle rule that expires them. Sites' `composer.lock` files download exact URLs forever; deleting a - version breaks every site that locked it, including their rollbacks. + version breaks every site that locked it, including their rollbacks. The + legacy publish workflow (`publish-plugin.yml`) still replaces zips under + `packages/` as it always has; this rule doesn't change that, since it + forbids manual edits and Composer never reads `packages/`. * **The one exception: `reason-dev/wpup-smoke`.** It is a throwaway test package written only by `tests/acceptance/composer-smoke.sh`. Each run uploads a new zip under `dist/` (append-only) and replaces @@ -39,6 +44,12 @@ One key serves the whole fleet. Rotation touches, in this order: 3. The org secret `REASON_PACKAGES_UPDATES_KEY` used by plugin CI. 4. SSM `/wp-update-server/prod/SIMPLE_UPDATE_KEY`, then redeploy. +Step 1 only applies once legacy sites are actually sending the key; until +then they send none. Whatever order these steps run in, there's a brief +window where either legacy sites or Composer builds fail against the key +in place at that moment. Accepting two keys at once during rotation would +remove that window; that's possible future work, not implemented here. + ## Test stage `dev` stage: SSM `/wp-update-server/dev/SIMPLE_UPDATE_KEY`, stack diff --git a/serverless.yml b/serverless.yml index 85eef81..2eae31d 100644 --- a/serverless.yml +++ b/serverless.yml @@ -24,6 +24,9 @@ package: - '!docs/**' - '!.github/**' - '!.idea/**' + - '!.superpowers/**' + - '!.junie/**' + - '!test-s3-logic.php' functions: web: @@ -34,10 +37,12 @@ functions: environment: S3_BUCKET: !Ref WPUpdateBucket S3_PREFIX: ${env:S3_PREFIX, 'packages/'} - # Shared secret that gates the `get_metadata` and `download` actions. Leave empty/unset to disable the gate. - # Create/rotate per stage with the Bref CLI: - # vendor/bin/bref secret:create --name SIMPLE_UPDATE_KEY --value '' --stage prod - # This stores a SecureString at /${self:service}/${stage}/SIMPLE_UPDATE_KEY in SSM Parameter Store. + # Shared secret that gates both the update-checker route (`get_metadata`/`download`) + # and the Composer routes (`/packages.json`, `/p2/...`, `/dist/...`). When empty or + # unset, the update-checker route is ungated, but the Composer routes return 503 + # (they fail closed). Read at deploy time from SSM Parameter Store at + # /${self:service}//SIMPLE_UPDATE_KEY. Set/rotate it with: + # aws ssm put-parameter --region us-east-1 --name /wp-update-server//SIMPLE_UPDATE_KEY --type SecureString --value '' SIMPLE_UPDATE_KEY: ${ssm:/${self:service}/${self:provider.stage}/SIMPLE_UPDATE_KEY, ''} events: - httpApi: '*' diff --git a/tests/acceptance/composer-smoke.sh b/tests/acceptance/composer-smoke.sh index 18393db..e5b91b8 100755 --- a/tests/acceptance/composer-smoke.sh +++ b/tests/acceptance/composer-smoke.sh @@ -7,6 +7,8 @@ set -euo pipefail BASE_URL="${1%/}"; BUCKET="$2"; KEY="$3" +# This script writes permanent objects to dist/, so refuse to run it against a production bucket. +case "$BUCKET" in *-prod-*) echo "Refusing to run against a production bucket ($BUCKET); use a dev stage." >&2; exit 1;; esac HOST="$(printf '%s' "$BASE_URL" | sed -E 's#^https?://([^/]+).*#\1#')" VERSION="0.0.$(date +%s)" SLUG="wpup-smoke" @@ -33,7 +35,7 @@ code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json")" [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } echo "== query-string key is refused" -code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/packages.json?key=$KEY")" +code="$(curl -s -o /dev/null -w '%{http_code}' --get --data-urlencode "key=$KEY" "$BASE_URL/packages.json")" [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code"; exit 1; } echo "== composer install with Bearer auth" @@ -45,6 +47,6 @@ JSON COMPOSER_AUTH="{\"bearer\":{\"$HOST\":\"$KEY\"}}" composer install --working-dir "$WORK/site" --no-interaction --no-progress [ -f "$WORK/site/vendor/reason-dev/$SLUG/$SLUG.php" ] || { echo "FAIL: package files not installed at vendor/reason-dev/$SLUG/"; exit 1; } -if grep -q "$KEY" "$WORK/site/composer.lock"; then echo "FAIL: key found in composer.lock"; exit 1; fi +if grep -qF -- "$KEY" "$WORK/site/composer.lock"; then echo "FAIL: key found in composer.lock"; exit 1; fi echo "PASS: Composer routes work end to end ($SLUG $VERSION)" From 421bde1c806197cb5af2de413e353e94bde377e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:45:28 +0000 Subject: [PATCH 10/11] Add downstream review notes for the composer routes Review from the consumer side (wp-site-template, reason-edge-delivery), which now treat this branch as the canonical distribution mechanism. Recommendations: document the p2 publish contract before plan 2 implements it (name/type/installer-name/require derived from each plugin's composer.json; dist zips without vendor/), mark the not-yet-existing rebuild-composer-index.sh runbook pointer as pending, promote dual-key rotation to a follow-up, and open the PR this branch's plan calls for. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011cQuwZuNAe6vwnnnMGbTfU --- .../2026-10-06-composer-routes-review.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/plans/2026-10-06-composer-routes-review.md diff --git a/docs/plans/2026-10-06-composer-routes-review.md b/docs/plans/2026-10-06-composer-routes-review.md new file mode 100644 index 0000000..bbfc551 --- /dev/null +++ b/docs/plans/2026-10-06-composer-routes-review.md @@ -0,0 +1,57 @@ +# Review notes — composer routes (2026-10-06) + +Downstream review of `feature/composer-routes` at `5929908`, from the +consumer side: `wp-site-template` (`docs/plugin-distribution.md`) and +`reason-edge-delivery` (PLAN §0.4) now treat this branch as the canonical +distribution mechanism, and their docs were aligned to it on this date — +`reason-dev/*` vendor, Bearer-only `COMPOSER_AUTH_JSON`, append-only store. + +Both test suites pass locally at this commit. The router design, fail-closed +auth, the no-S3-calls-on-bad-input guarantees, and the append-only doctrine +all hold up under review. Recommendations, in priority order: + +## 1. Write down the p2 publish contract before plan 2 implements it + +Today the only specification of what a `p2/reason-dev/.json` version +entry contains is the smoke test's heredoc. Downstream correctness depends on +fields this server never inspects: + +- `name` = `reason-dev/` (slug = the plugin's directory/text domain); +- `type: wordpress-plugin` and `extra.installer-name` (so composer/installers + lands the plugin in the right directory on sites); +- the package's **`require` list, derived from the plugin's own + `composer.json`** — reason-edge-delivery hard-requires `aws/aws-sdk-php` + and its plan assumes the requirement propagates so the *site's* composer + resolves and dedupes the SDK; +- the **dist zip shape**: plugin source + `composer.json`, **no `vendor/`**. + The bundled-vendor zip belongs only under `packages/` for the + update-checker route — a vendored zip served through `dist/` would install + a second SDK copy into every consuming site. + +Recommendation: a short "publish contract" section in `docs/runbook.md` (or a +spec file plan 2 must implement against), so the pipeline work can't quietly +invent a different shape than the one the smoke test and the consumers assume. + +## 2. The runbook points at a script that doesn't exist yet + +`scripts/rebuild-composer-index.sh` in `reason-wp-repo-actions` is named as +the recovery tool for `p2/`, but that repo's `main` currently has no +`scripts/` directory and no composer-side writers at all (only the legacy +`packages/` upload). Worth a "(lands with plan 2)" marker so an operator +mid-incident doesn't hunt for a tool that isn't there. + +## 3. Dual-key rotation: promote from "possible future work" to a follow-up + +Accepting `SIMPLE_UPDATE_KEY` plus a `SIMPLE_UPDATE_KEY_PREVIOUS` during +rotation removes the fleet-wide breakage window the runbook currently +documents as accepted. It is a few lines in `isAuthorized()` (and the +update-checker equivalent once the key is enforced there), and the rotation +procedure gets simpler rather than more complex. + +## 4. Open the PR + +This branch's own plan ends with a PR into `master`; it has not been opened. +Downstream work is now aligned to this branch (reason-edge-delivery PR #1 is +renaming to the `reason-dev` vendor; wp-site-template documents the +bearer-shaped secret), so the remaining risk is drift the longer it sits +unmerged. From 67e40381439b20b968e8f4c65e602583ea99cfed Mon Sep 17 00:00:00 2001 From: Justin Maurer Date: Thu, 8 Oct 2026 16:28:59 -0500 Subject: [PATCH 11/11] Mark composer-routes plan progress through the dev smoke test The checkboxes were never ticked as work landed, so the plan read as unstarted. Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-24-composer-routes.md | 48 ++++++++++++++---------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-09-24-composer-routes.md b/docs/plans/2026-09-24-composer-routes.md index 9b92e92..206826d 100644 --- a/docs/plans/2026-09-24-composer-routes.md +++ b/docs/plans/2026-09-24-composer-routes.md @@ -56,7 +56,7 @@ - `Wpup_ComposerRepository::handle(string $path, Wpup_Headers $headers): ?Wpup_ComposerResponse` — `null` means "not a Composer path". - `Wpup_ComposerRepository::isComposerPath(string $path): bool` (static). -- [ ] **Step 1: Add the shared test helper** +- [x] **Step 1: Add the shared test helper** `tests/check.php` (same helper the update checker uses): @@ -81,7 +81,7 @@ function finish_tests() { } ``` -- [ ] **Step 2: Write the failing test** +- [x] **Step 2: Write the failing test** `tests/composer-repository-test.php`: @@ -217,12 +217,12 @@ check('S3 access denied: 500', make_repo(KEY, $mock)->handle('/p2/reason-dev/acm finish_tests(); ``` -- [ ] **Step 3: Run it to verify it fails** +- [x] **Step 3: Run it to verify it fails** Run: `php tests/composer-repository-test.php` Expected: fatal error `Class "Wpup_ComposerRepository" not found`. -- [ ] **Step 4: Write the response class** +- [x] **Step 4: Write the response class** `includes/Wpup/ComposerResponse.php`: @@ -268,7 +268,7 @@ class Wpup_ComposerResponse { } ``` -- [ ] **Step 5: Write the router** +- [x] **Step 5: Write the router** `includes/Wpup/ComposerRepository.php`: @@ -414,12 +414,12 @@ require_once __DIR__ . '/includes/Wpup/ComposerResponse.php'; require_once __DIR__ . '/includes/Wpup/ComposerRepository.php'; ``` -- [ ] **Step 6: Run the tests to verify they pass** +- [x] **Step 6: Run the tests to verify they pass** Run: `composer install && php tests/composer-repository-test.php` Expected: every line `PASS`, final line `ALL PASSED`, exit code 0. -- [ ] **Step 7: Run the tests in CI** +- [x] **Step 7: Run the tests in CI** `.github/workflows/tests.yml`: @@ -452,7 +452,7 @@ jobs: exit $status ``` -- [ ] **Step 8: Keep tests and docs out of the Lambda package** +- [x] **Step 8: Keep tests and docs out of the Lambda package** Bref zips the project directory as-is, so without this the new `tests/` and `docs/` folders ship to Lambda. In `serverless.yml`, add a top-level `package` block directly above the `functions:` key: @@ -468,7 +468,7 @@ package: Run: `grep -n "^package:" -A5 serverless.yml` Expected: the block is present at the top level (same indentation as `functions:`). -- [ ] **Step 9: Commit** +- [x] **Step 9: Commit** ```bash git checkout -b feature/composer-routes @@ -488,7 +488,7 @@ git commit -m "Add a read-only Composer repository router for reason-dev package - Consumes: `Wpup_ComposerRepository::handle()`, `Wpup_ComposerResponse::send()` (Task 1). - Produces: `wpup_route_composer(Wpup_ComposerRepository $repo, string $requestUri, array $headers): ?Wpup_ComposerResponse` in new file `includes/composer-routing.php` — the testable part of `index.php`. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** `tests/index-routing-test.php`: @@ -524,12 +524,12 @@ check('reserved path never falls through', wpup_route_composer($repo, '/meta/', finish_tests(); ``` -- [ ] **Step 2: Run it to verify it fails** +- [x] **Step 2: Run it to verify it fails** Run: `php tests/index-routing-test.php` Expected: failure opening `includes/composer-routing.php`. -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** `includes/composer-routing.php`: @@ -594,7 +594,7 @@ $server = new Wpup_LambdaS3UpdateServer( $server->handleRequest(); ``` -- [ ] **Step 4: Run all tests** +- [x] **Step 4: Run all tests** Run: `for t in tests/*-test.php; do php "$t" || echo "FAILED: $t"; done` Expected: both scripts end `ALL PASSED`, no `FAILED:` lines. @@ -602,7 +602,7 @@ Expected: both scripts end `ALL PASSED`, no `FAILED:` lines. Run: `php -l index.php && grep -c "Wpup_LambdaS3UpdateServer::guessServerUrl()" index.php` Expected: `No syntax errors detected`, then `1`. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add index.php includes/composer-routing.php tests/index-routing-test.php @@ -620,7 +620,9 @@ git commit -m "Serve Composer routes before the update-checker route" - Consumes: deployed Composer routes (Tasks 1–2). - Produces: `tests/acceptance/composer-smoke.sh ` — reused after the production deploy and by plan 2's canary. -- [ ] **Step 1: Write the smoke script** +- [x] **Step 1: Write the smoke script** + +> **Note:** the committed script (`5929908`) differs from the draft below. It refuses to run against a `*-prod-*` bucket, URL-encodes the query-string key, and uses `grep -F` for the lockfile check. The file in the repo is authoritative. `tests/acceptance/composer-smoke.sh`: @@ -678,7 +680,9 @@ echo "PASS: Composer routes work end to end ($SLUG $VERSION)" `chmod +x tests/acceptance/composer-smoke.sh` -- [ ] **Step 2: Deploy a `dev` stage** +- [x] **Step 2: Deploy a `dev` stage** + +> **Done 2026-10-08, differently from below:** the dev key was set with `bref secret:create SIMPLE_UPDATE_KEY --env dev` (Bref Cloud), not `aws ssm put-parameter`. Dev is at `https://97u2hk4ec7.execute-api.us-east-1.amazonaws.com`, and `/packages.json` answers `401` without a key, so the deployed function has a non-empty key. Step 3 confirms it is the key that was set. Create the dev key at the exact SSM path `serverless.yml` reads, then deploy the `dev` environment with Bref Cloud. Use `aws ssm put-parameter` rather than `bref secret:create`: Bref doesn't document the path its command writes to, and a mismatch silently yields an empty key, which disables the gate. @@ -696,7 +700,9 @@ aws cloudformation describe-stack-resources --stack-name wp-update-server-dev \ --logical-resource-id WPUpdateBucket --query 'StackResources[0].PhysicalResourceId' --output text ``` -- [ ] **Step 3: Run the smoke test against dev** +- [x] **Step 3: Run the smoke test against dev** + +> **Passed 2026-10-08:** `PASS: Composer routes work end to end (wpup-smoke 0.0.1791494471)`. This also confirms that the key set with `bref secret:create` is the key the deployed function enforces. Run: `tests/acceptance/composer-smoke.sh "" "" "$DEV_KEY"` Expected: final line `PASS: Composer routes work end to end (wpup-smoke 0.0.)`. @@ -713,7 +719,7 @@ Expected: `HTTP/2 302` (a redirect to a presigned S3 URL). Afterwards, restore the dev environment for local work with `composer install` (the deploy step installed without dev packages). -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add tests/acceptance/composer-smoke.sh @@ -727,7 +733,7 @@ git commit -m "Add a Composer end-to-end smoke test for deployed stages" **Files:** - Create: `docs/runbook.md` -- [ ] **Step 1: Write the runbook** +- [x] **Step 1: Write the runbook** `docs/runbook.md`: @@ -797,7 +803,7 @@ Run: `curl -s -o /dev/null -w '%{http_code}\n' https://packages.reason.com/packa ``` Expected: `PASS: …`. The smoke package `reason-dev/wpup-smoke` stays in the bucket (append-only); that's intended and harmless. -- [ ] **Step 4: Commit, push, open a pull request** +- [x] **Step 4: Commit, push, open a pull request** ```bash git add docs/runbook.md @@ -807,6 +813,8 @@ git push -u origin feature/composer-routes Open a PR into `master`; merge after review. +> **Status 2026-10-08:** PR #5 is open (https://github.com/ReasonFoundation/wp-update-server/pull/5). It is not merged yet, and Steps 2–3 (production deploy and check) haven't been done. + --- ### Task 0 (not part of this branch): pre-flight before enforcing the key in production