v1.1.0
Security, atomicity, REST compatibility, and deterministic behavior. Released on the main branch as Composer tag v1.1.0.
Upgrade summary
{
"require": {
"better-route/better-route": "^1.1"
}
}
1.1.0 is a hardening release across route intent, CORS, write safety, and the WooCommerce layer. Everything below is verified by the test suite (156 tests), PHPStan, and a live WordPress 7.0.1 / WooCommerce 10.9.4 HPOS install; CI now also runs PHP 8.3 and 8.4.
Like 1.0.0, this release contains intentional fail-safe behavior changes — the biggest one being deny-by-default for every HTTP method. Read the Behavior change checklist before upgrading.
Router
- Deny-by-default for every method. Raw
Routerroutes without an explicit permission callback now fail with403regardless of HTTP method —GETandOPTIONSincluded (write methods have denied since v0.4.0). Declare intent with->permission(...),->protectedByMiddleware(...), or->publicRoute()on every route. See Router. group()unwinds safely after exceptions — a throwing callback no longer corrupts the prefix/middleware stack for later registrations.- Handler resolution hardened. Static
[Controller::class, 'method']callables run without instantiation; union-typed context parameters resolve; nonexistent classes/methods and constructor-dependent handler classes fail with clearInvalidArgumentExceptions; handlers may require at most two parameters. - Registration fails loudly.
WordPressRestDispatcherthrows whenregister()runs outsiderest_api_initor when WordPress core rejects a route.
CORS
- WordPress CORS bridge.
CorsMiddlewarenow registers matched routes withWordPressCorsBridgeatRouter::register()time. The bridge answers allowed/denied preflight onrest_pre_dispatch(before the route or its permission callback runs) and replaces WordPress coreAccess-Control-*headers onrest_pre_serve_request, so the configured allowlist is authoritative. ExplicitRouter::options()preflight routes are no longer required. See CORS. - Configuration validated at construction. Origins must be valid serialized origins; method and header names must be valid HTTP tokens;
maxAgeSecondsmust be non-negative — closing response-header injection through configuration.
Write safety
- Atomic idempotency uses reservation leases.
WpdbAtomicIdempotencyStoreissues an unforgeable per-reservation lease token (LeaseAwareAtomicIdempotencyStoreInterface), creates/migrates its table viainstallSchema(), and stores responses throughStoredResponseCodec— data-only serialization that never deserializes arbitrary classes. Request fingerprints are deep-canonical (Support\Canonicalizer), keys are bounded, and failed requests keep their reservation until TTL expiry by default, preventing an uncertain side effect from executing twice.ArrayAtomicIdempotencyStoreis for tests only. See Atomic idempotency. - Optimistic locking gets a critical section. Version resolution and the write now run inside a per-resource MySQL advisory lock (
WpdbOptimisticLockCriticalSection; pluggable viaOptimisticLockCriticalSectionInterface/CallbackOptimisticLockCriticalSection). Writers outside this protocol can still race — use the same lock or a storage-level conditionalUPDATEfor external writers. See Optimistic locking.
Identity, auth, and rate limiting
- Native WordPress identity scopes shared state. A logged-in WP user now scopes cache, idempotency, and rate-limit keys even without an auth middleware attribute (
Support\RequestIdentity); HMAC identities populate the shared auth context. - JWT max-lifetime requires both
iatandexp. WithmaxLifetimeSecondsconfigured, tokens missing either claim are rejected (previously a missingiatskipped the lifetime check). - JWKS refresh hardened. Unknown-key refreshes are throttled, and a failed fetch preserves the last-known-good key set.
- Rate limiting cannot silently degrade.
WpObjectCacheRateLimiterthrows at construction without a persistent external object cache and atomicwp_cache_incr()(and refuses to reset an existing counter — no bypass on eviction).TransientRateLimiterserializes its read-modify-write with a MySQL advisory lock in the default WordPress configuration.429responses includeRetry-Afterand rate-limit headers.
Resources
- CPT reads fail closed on missing/private/password-protected visibility data.
- Truthful visibility pagination. A custom
cptVisibilityPolicy()callback is evaluated item by item; the Resource layer scans all matching repository pages before slicing sototal/pagination stay correct. For large datasets, express visibility as a query-level filter instead. Invariant WP query arguments can no longer be overridden through filters. See CPT resource. - Table resources: SQL
NULLwrites are real SQL nulls, default ordering uses the primary key, and list ordering has an ID tie-breaker for deterministic pagination.
WooCommerce
- Order writes are transactional and fully validated. Payloads are validated before persistence (typed scalars, existing
customer_id, line-item and address fields reject unknown keys), and create/update run insidewc_transaction_query. See Orders. - Strict product/customer/coupon validation, aligned OpenAPI input schemas (
priceremoved fromWooProductInput; newWooCustomerCreateInput/WooCouponCreateInput), and an ID tie-breaker on all list ordering. - Coupon code writes are uniqueness-locked. Updates enforce code uniqueness (
409 coupon_exists) under a per-code MySQL advisory lock; a lock timeout returns409 coupon_write_in_progress. - Durable idempotency by default. Without an explicit store the Woo registrar installs and uses the lease-aware wpdb atomic store (idempotency now also covers customer/coupon writes, not just orders/products). A schema/install failure is reported instead of degrading to a request-local store; a versioned option (
better_route_atomic_idempotency_schema_version) prevents repeated schema checks. See Configuration. 'actions' => []disables a resource. An empty action list now registers no routes for that resource (previously it silently fell back to the full set); invalid action values throw.- WordPress global REST params accepted.
_fields,_locale,_embed,_envelope,_jsonpno longer trip the unknown-parameter check.
OpenAPI
- Parameters derive from route
args. Executable routeargsare exported automatically as path/query parameters; explicitmeta.parametersentries override derived entries with the same name and location. OPTIONSoperations are documented as204; custommeta.responsesreplace the defaults.- Resource create/update responses are
{ "data": ... }envelopes — strict component sets should provide<Resource>Responsealongside<Resource>,<Resource>Input, and<Resource>ListResponse. See OpenAPI.
Errors and observability
- Response/error headers are validated against header injection;
WP_Errordetails are allowlisted before entering the error envelope. - Audit and metric sink failures are best-effort and never change the API response;
PrometheusMetricSink/InMemoryMetricSinkare documented as process/request-local collectors.
Files added
src/Middleware/Cors/WordPressCorsBridge.php— preflight + authoritative CORS headers via WP filters.src/Middleware/WordPressRouteMiddlewareInterface.php— middleware notified of matched routes atregister()time.src/Middleware/Write/LeaseAwareAtomicIdempotencyStoreInterface.php,src/Middleware/Write/StoredResponseCodec.php— reservation leases + data-only response storage.src/Middleware/Write/OptimisticLockCriticalSectionInterface.php,src/Middleware/Write/WpdbOptimisticLockCriticalSection.php,src/Middleware/Write/CallbackOptimisticLockCriticalSection.php— optimistic-lock critical sections.src/Support/Canonicalizer.php,src/Support/RequestIdentity.php,src/Support/RestRequestParameters.php— canonical fingerprints, native-identity scoping, WP global REST params.tests/WooRouteRegistrarTest.php,tests/PassthroughOptimisticLockCriticalSection.php— new coverage (156 tests total).
Behavior change checklist
1.1.0 is safe to adopt, but these consumer-visible changes are intentional:
| Change | Action |
|---|---|
Every raw Router route denies by default (GET/OPTIONS included) | Add ->publicRoute(), ->permission(...), or ->protectedByMiddleware(...) to every route — previously-public GET routes without intent now return 403 |
| Preflight is answered by the WordPress CORS bridge | Remove explicit Router::options() preflight routes, or add explicit intent to the ones you keep |
| WP core CORS headers are replaced on matched routes | The CorsPolicy allowlist is authoritative — verify it lists every origin you serve |
register() outside rest_api_init throws | Wrap Router::register() in an add_action('rest_api_init', ...) callback |
| Failed idempotent requests stay reserved until TTL | Retrying a failed write with the same key is refused until the reservation expires — issue a new key to retry deliberately |
ArrayAtomicIdempotencyStore is tests-only | Use the wpdb store (or your own) in production |
WpObjectCacheRateLimiter throws without a persistent object cache | Use TransientRateLimiter on default hosting |
JWT max-lifetime requires iat and exp | Tokens missing iat are rejected when maxLifetimeSeconds is set — ensure the issuer emits both claims |
Woo 'actions' => [] disables the resource | Omit the key for the full route set; empty array now means "no routes" |
| Woo idempotency store is durable by default | The wpdb store's table is installed automatically; install failures surface as errors instead of silent request-local fallback |
Unknown nested keys in Woo order/customer payloads return 400 | Strip unrecognized line-item/address fields from clients |
Coupon-code update conflicts return 409 | Handle coupon_exists / coupon_write_in_progress on coupon writes |
Compared to 1.0.0
- 1.0.0 hardened the identity-boundary defaults and fixed WooCommerce semantics.
- 1.1.0 closes the remaining default-permissiveness gaps (public-by-default
GET/OPTIONS, WP core CORS headers, racy rate-limit fallbacks, request-local Woo idempotency) and makes concurrent writes atomic end to end (reservation leases, optimistic-lock critical sections, transactional Woo order writes, uniqueness-locked coupon codes).