Skip to content

Serve Composer routes for reason-dev packages - #5

Merged
justinmaurerdotdev merged 11 commits into
masterfrom
feature/composer-routes
Oct 8, 2026
Merged

justinmaurerdotdev merged 11 commits into
masterfrom
feature/composer-routes

Conversation

@justinmaurerdotdev

Copy link
Copy Markdown
Member

Summary

packages.reason.com now also works as a read-only Composer repository for reason-dev/* packages. It serves /packages.json, /p2/reason-dev/<slug>.json and /dist/reason-dev/<slug>/<slug>-<version>.zip, all behind SIMPLE_UPDATE_KEY sent as a Bearer header. The existing update-checker route that legacy WordPress sites use is unchanged.

Why

No Asana task.

vnext sites install Reason plugins with Composer, so they need a Composer repository. wp-site-template (docs/plugin-distribution.md) and reason-edge-delivery already document this branch as the canonical distribution mechanism (see docs/plans/2026-10-06-composer-routes-review.md). The longer it sits unmerged, the more those downstream docs can drift from what is deployed.

Changes

  • includes/Wpup/ComposerRepository.php adds a router with no side effects. It takes a path and headers and returns a response, or null for paths it doesn't own. It only reads S3.
    • It serves only the reason-dev vendor, and never serves meta/.
    • It reads the key only from Authorization: Bearer (the scheme name is case-insensitive) and never from ?key=.
    • If the key is empty, it returns 503, so a stage with no key is never an open repository.
    • It rejects bad slugs, bad versions, path traversal and dist filenames that don't match their slug with 404, before making any S3 call.
    • A download redirects (302) to a presigned S3 URL that expires after 15 minutes.
    • An S3 error other than not-found (for example access denied) returns 500 with a log line, not 404.
  • includes/Wpup/ComposerResponse.php adds the response value object.
  • includes/composer-routing.php and index.php try the Composer routes first. Otherwise Wpup_Request would read p2, dist and so on as plugin slugs. Every other request falls through to Wpup_LambdaS3UpdateServer exactly as before. index.php keeps calling Wpup_LambdaS3UpdateServer::guessServerUrl().
  • loader.php loads the two new classes.
  • serverless.yml:
    • Keeps tests, docs and IDE folders out of the Lambda package.
    • Corrects the SIMPLE_UPDATE_KEY comment: set the key with aws ssm put-parameter at the exact path the deploy reads, because bref secret:create doesn't document where it writes.
  • tests/check.php, tests/composer-repository-test.php and tests/index-routing-test.php are plain-PHP tests. They use the AWS SDK's MockHandler, so they need no S3 access.
  • .github/workflows/tests.yml runs the tests on PHP 8.4 for every pull request and every push to master.
  • tests/acceptance/composer-smoke.sh is an end-to-end smoke test for a deployed stage. It uploads a throwaway reason-dev/wpup-smoke package, runs composer install with Bearer auth, checks the lockfile, and checks that a key sent in the query string is refused.
  • docs/runbook.md covers the routes, the append-only storage rule (and its one exception, the smoke package), and how to rotate the key.
  • docs/plans/ holds the implementation plan and the downstream review notes.

Also in this PR: two commits that were on local master but never pushed. a9a456c is a composer update. 555d9c7 builds an absolute download_url (without it, WordPress fails updates with "A valid URL was not provided"). This branch builds on 555d9c7.

Alternatives considered

  • Accept the key in ?key= on Composer routes, as the update-checker route does. Rejected: a key in a URL ends up in composer.lock files and access logs.
  • Leave the Composer routes open when no key is set, as the update-checker route is. Rejected: a stage with a missing key would quietly become a public package repository. Failing closed with 503 is safer.
  • Add the Composer routes inside Wpup_LambdaS3UpdateServer. Rejected: it would change the class that legacy sites depend on. A separate router that returns null for paths it doesn't own leaves that class untouched.

Testing

  • php tests/composer-repository-test.php: 37 checks, ALL PASSED (local PHP 8.5.11).
  • php tests/index-routing-test.php: 5 checks, ALL PASSED.
  • CI runs both on PHP 8.4, the Lambda runtime.
  • curl https://packages.reason.com/packages.json returns 400 today. Production is still running master, which reads packages.json as a plugin slug.

Not run as part of this PR:

  • The plan's dev-stage acceptance steps (Task 3, steps 2–4): deploy dev, run composer-smoke.sh, confirm the update-checker download_url is absolute. I couldn't confirm whether they ran, so treat them as outstanding.
  • The production deploy and check (Task 4, steps 2–3).
  • Negative controls were not re-run here.

Not in this PR

  • Deploy order: run the dev smoke test (plan Task 3) before the production deploy (plan Task 4). After the production deploy, /packages.json should return 503 until the key is enforced, or 401 once it is.
  • Before enforcing SIMPLE_UPDATE_KEY in production: the plugin fleet pins a wp-update-checker that predates key support. Re-lock and release the fleet first, and run the plan's Task 0 pre-flight check on each legacy site. If a site's first-loaded checker predates key support, it sends no key and loses updates for every Reason plugin.
  • Follow-ups from the review notes:
    • Write down the p2 "publish contract" in the runbook: package name, type: wordpress-plugin, extra.installer-name, a require list taken from the plugin's own composer.json, and dist zips with no vendor/. Do this before plan 2 builds the publish pipeline.
    • Mark scripts/rebuild-composer-index.sh in the runbook as "lands with plan 2". It doesn't exist in reason-wp-repo-actions yet.
    • Accept a second key (SIMPLE_UPDATE_KEY_PREVIOUS) during rotation, so rotating the key doesn't break the fleet for a window.
  • The publish side (writing p2/, dist/ and meta/) is plan 2, in reason-wp-repo-actions.

🤖 Generated with Claude Code

justinmaurerdotdev and others added 10 commits September 22, 2026 16:36
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…otation

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ify the runbook

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cQuwZuNAe6vwnnnMGbTfU

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it introduces a new authenticated routing layer (Bearer-key auth, S3-backed Composer metadata/dist responses with presigned URLs) ahead of the existing update server, a human look would still be worthwhile.

What was reviewed: the Bearer-token check and fail-closed 503/401 behavior in Wpup_ComposerRepository::isAuthorized/handle; slug/version/path-traversal validation before any S3 call; the reserved-prefix routing split between composer-routing.php and the legacy Wpup_LambdaS3UpdateServer path; and S3 error handling in serveP2/serveDist (404 vs 500 mapping, with createPresignedRequest left outside the try/catch that wraps headObject).

Extended reasoning...

The diff adds a new Composer v2 repository (ComposerRepository/ComposerResponse), wires it in ahead of the existing plugin-update routing, reworks guessServerUrl() for absolute download URLs, and adds CI plus unit/acceptance tests; it is a large (~1700 line) change touching authentication (Bearer SIMPLE_UPDATE_KEY), S3 access, and presigned URL generation for premium plugin distribution. No findings were produced by the bug hunt (exit reason dry_streak, i.e. it ran dry rather than being cut off), and three candidate issues (legacy plugin slugs colliding with reserved Composer path segments, and an unwrapped createPresignedRequest call in serveDist) were already investigated and ruled out/flagged in the supplied analysis. Given the security-sensitive auth/S3 surface and overall size of the change, a human review is still warranted even though automated review found nothing to block on.

The checkboxes were never ticked as work landed, so the plan read as unstarted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@justinmaurerdotdev
justinmaurerdotdev merged commit d299815 into master Oct 8, 2026
1 check passed
@justinmaurerdotdev
justinmaurerdotdev deleted the feature/composer-routes branch October 8, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants