chore(release): v0.4.19 - #88
Conversation
The contract job pinned the reusable workflow to @dev-v0.7.53, a pre-release branch. That branch is now merged (fleetbase/fleetbase#575) and v0.7.53 is tagged, with fleetbase/fleetbase-api:v0.7.53 published to Docker Hub. - pins the reusable workflow to @v0.7.53 instead of the dev branch, so runs are reproducible rather than tracking a branch that can move or be deleted - passes fleetbase-ref: v0.7.53 explicitly. The reusable workflow still defaults that input to dev-v0.7.53, so without this the job would boot the stack from the pre-release branch while testing against the released image. Passing it makes the booted source and the published image the same commit. Bump both refs together at each release. Contract runs on this repo were previously failing before they reached Postman — the installer step died building the console image, because console/package.json and console/pnpm-lock.yaml were briefly out of sync on the release branch and console/Dockerfile installs with --frozen-lockfile. That is fixed in v0.7.53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fleetbase/fleetbase#578 changed the reusable workflow to default fleetbase-ref to main and to test against fleetbase/fleetbase-api:latest, so there is no longer a per-release ref to bump here. Drops the explicit fleetbase-ref and moves the workflow reference from @v0.7.53 to @main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With build-from-source: false the stack boots the published API image, and this package is a composer dependency baked into it — so a PR here booted the released version and ran the collections against that. Its own API changes were never exercised; the check was green on code that was not under review. overlay-package makes the reusable workflow check this repository out at the commit under test and swap it into the running container, dumping the autoloader (the image is built with --optimize-autoloader, so a frozen classmap would otherwise hide classes added or moved on the branch), clearing caches and running migrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version bump via `flb version-bump --patch`, opening the release branch for this cycle. Follows the standard Fleetbase flow of release branch -> main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #88 +/- ##
============================================
Coverage 100.00% 100.00%
- Complexity 1640 1742 +102
============================================
Files 135 135
Lines 7366 7699 +333
============================================
+ Hits 7366 7699 +333
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ci: fix the Postman contract and track the latest release automatically
ci(postman): test this branch's API code, and unpin the contract workflow
STOREFRONT_BYPASS_VERIFICATION_CODE defaulted to 999000 and was compared against the submitted code alone, so every install shipped a live bypass that authenticated ANY customer to anyone who knew six digits. Scopes it to explicitly designated accounts. A code is only honoured for an identity listed in the new review_accounts config, so app store reviewers keep working in production while a leaked code authenticates nobody. STOREFRONT_BYPASS_VERIFICATION_CODE=<a secret, rotated code> STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100 Both values are required and the 999000 default is removed, so an unconfigured install has no bypass at all. Comparison is constant-time via hash_equals, and each accepted bypass is logged with its identity so use is auditable. Applies to both call sites: verifyCode and confirmAccountClosure. BREAKING: an install relying on the 999000 default must now set both STOREFRONT_BYPASS_VERIFICATION_CODE and STOREFRONT_REVIEW_ACCOUNTS, or app review logins will stop working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authorization gap in checkout — not addressed by this branchFlagging for follow-up. Found while scoping the verification-bypass fix; it is a separate and larger change, so it is deliberately not in this release branch.
// CheckoutController.php:169
$customerId = $request->input('customer');
...
// CheckoutController.php:188
$customer = Customer::findFromCustomerId($customerId);Storefront requests carry two independent credentials — a Impact: anyone holding a storefront key — which is client-side by nature, it ships in the storefront app — can check out as an arbitrary customer by passing their Suggested fix: resolve the customer from $authenticated = Storefront::getCustomerFromToken();
if ($authenticated && $customerId && $authenticated->public_id !== $customerId) {
return response()->apiError('Customer does not match the authenticated session.', 403);
}
$customer = $authenticated ?: Customer::findFromCustomerId($customerId);Guest checkout appears to be a legitimate flow here, which is why this needs a decision rather than a blind patch: if a caller may check out with no Related hardening in this branch: the verification bypass is now restricted to designated review accounts, so a leaked code no longer authenticates arbitrary customers. |
Checkout took the customer from the request body and trusted it:
$customerId = $request->input('customer');
$customer = Customer::findFromCustomerId($customerId);
A storefront key is client-side by nature — it ships inside the storefront app —
and customer public ids appear in ordinary API responses, so anyone holding a key
could check out as an arbitrary customer by passing their id. Neither credential
the request carries was consulted when resolving who the checkout was for.
When a Customer-Token is present it now wins, and a body parameter naming a
different customer is refused with 403 rather than silently honoured. Applied at
all three sites: beforeCheckout, createStripeSetupIntentForCustomer and
updateStripePaymentIntent.
Guest checkout is deliberately preserved. With no token the body parameter is
still used, because a guest has none to present — so this closes the escalation
without removing the anonymous flow.
The comparison normalises the prefix first. A storefront customer is stored as a
Contact and findFromCustomerId() rewrites customer_ to contact_ before looking
it up, so comparing the raw values would never match a caller's own record and
would have 403'd every authenticated checkout.
Verified against the committed logic: a caller's own customer_ id resolves
against their contact_ record, a contact_ id passed directly resolves, someone
else's id is refused, an absent body parameter falls back to the token, and both
guest paths still resolve from the body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Implemented on this branch in A Customer-Token now takes precedence at all three checkout sites ( One subtlety worth noting for review: the comparison normalises the prefix before matching. A storefront customer is stored as a |
CartController::retrieve() declared its optional $uniqueId BEFORE the injected
Request. Laravel resolves method dependencies by splicing class-typed parameters
in at their own index and filling the remainder from the route parameters, in
order — so for the route with no {uniqueId} there was nothing to place at index 0,
the Request landed at index 1, and the call arrived with a hole:
ArgumentCountError: Too few arguments to function retrieve(), 1 passed
GET /storefront/v1/carts/{uniqueId} worked, because index 0 was filled. That is
why this read as a cart problem rather than a signature one. Five requests in the
Storefront collection failed behind it — the whole cart chain depends on
retrieving a cart first.
The unit tests never caught it because every call site passed both arguments
explicitly, matching the broken signature rather than what the router produces.
The no-identifier test now calls retrieve() with the Request alone, which is
exactly the failing shape.
Suite passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StoreController::location() used both lookups unguarded — Store::first() and
StoreLocation::first() — and handed the result straight to StoreLocationResource.
An id that resolved nothing therefore threw inside the resource:
GET /storefront/v1/locations/{id}
500 Call to a member function first() on null (22 KB of HTML)
That is what a client sees for any stale or mistyped location id, and what the
contract run hit. Both cases now answer 404 with a JSON error, matching how the
rest of the controller reports a missing store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loginWithApple() verifies the token inside a try block whose catch returns
response()->apiError($e->getMessage(), 500). The JWT parser THROWS on a malformed
token rather than returning false, so any client sending a truncated, corrupted or
otherwise unparseable identityToken got:
500 {"error":"The JWT string must have two dots"}
which is both the wrong status for client input and a leak of the parser's own
message.
Parsing is now guarded separately. A token that cannot be parsed is the same
rejection as one that parses but does not verify, so it returns the existing
400 "Apple ID authentication is not valid." and logs the parser message rather
than returning it.
loginWithGoogle already answered 400 for equally malformed input — this brings
Apple in line with it.
The existing test asserted the 500, pinning the defect; the same test asserts
Google's 400 three lines further down. That assertion is corrected and now also
checks the message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewController::create() resolves the subject with
Utils::resolveSubject($request->input('subject')), whose parameter is a
non-nullable string. A request that omitted `subject` therefore threw
Utils::resolveSubject(): Argument #1 ($publicId) must be of type string,
null given
as a 500 — before the controller's own `if (!$subject)` guard could run. That
guard was unreachable for the commonest way to get the request wrong.
`subject` is now required in CreateReviewRequest, so a missing one is a 422 and
the existing guard handles the present-but-unresolvable case it was written for.
Surfaced by the contract run once the collection stopped sending an empty
`content` and the request got far enough to reach this line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ials loginWithPhone() called VerificationCode::generateSmsVerificationFor() unguarded. The Twilio SDK THROWS when no credentials are configured — ConfigurationException, "Credentials are required to create a Client" — so POST /storefront/v1/customers/login-with-sms answered 500 with an HTML stack trace for any store that has simply not set up SMS. That is a configuration state, not a server error. The SMS attempt is now guarded and falls back to email, mirroring FleetOps' DriverController::loginWithPhone, which has handled this since it was written. A store without Twilio can still authenticate customers who have an email address. The response reports `method` so the client knows which channel carried the code; both branches were previously indistinguishable. Note the fallback leaves two verification_codes rows — generateSmsVerificationFor persists the code before attempting delivery, so the failed SMS attempt leaves its row behind. Both are valid for the subject and purpose, so either verifies. FleetOps behaves identically. Asserted rather than changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more paths where an unconfigured SMS provider surfaced as an API error carrying the Twilio SDK's own message, "Credentials are required to create a Client". startAccountClosure() chose its channel with `if ($user->phone) ... elseif ($user->email)`, so a customer WITH a phone never reached the email branch: the SMS throw went straight to the catch and returned the SDK message. The SMS attempt is now guarded on its own and falls back to email, matching loginWithPhone. That fallback breaks a coupling worth making explicit. confirmAccountClosure looks the code up by `meta->identity`, computed as `$user->phone ?? $user->email`. Previously SMS filed it under the phone and email under the email, which agreed only because the same precedence chose the channel. Both branches now file it under that identity, so an emailed code is still findable for a customer who has a phone. requestPhoneVerification() has no email fallback — verifying a phone number by email would not verify anything — so it returns "Unable to send phone verification code." rather than the provider's message. Both paths report to Sentry when bound. Also restores `mail.manager` in the email-fallback test added in fe41cad. The file runs in one process, and leaving the working mailer bound made a later test's deliberately-failing email delivery succeed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bypass exists so App Store review can complete flows that would otherwise
need a live SMS provider. It was wired into verifyCode and confirmAccountClosure
but never into phone verification, so that flow still required Twilio outright —
requestPhoneVerification returned an error and verifyPhoneNumber could not be
reached at all.
Both ends now honour it, with the same allowlist and constant-time code check as
every other call site:
requestPhoneVerification a review account needs no message delivered, so the
send is skipped and the response reports
method=bypass
verifyPhoneNumber accepts the configured code for a listed account. The
bypass leaves no code row to read the phone back from,
so it comes from the request — which is what the
caller is asking to verify
A code that is not the bypass is still rejected for the same account, which the
test asserts alongside the happy path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pull request from a branch in this repo fires both `push` and `pull_request` for the same commit, so the contract ran twice — two stack boots, two seeds, two full collection runs for one change. Keying the concurrency group on the COMMIT rather than the ref collapses them: the pull_request event reports the head SHA and the push event reports the same commit as github.sha, so both land in one group and the older is cancelled. Deduped rather than dropping a trigger, so a direct push to a release branch is still verified instead of relying on someone remembering to dispatch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cancel-in-progress dedupes by cancelling, which surfaces cancelled runs on the pull request. That reads as something having gone wrong, and is a worse trade than the duplicate run it removes. Reverted to the previous triggers. The duplicate run on same-repo pull requests is back, and is worth solving properly — most likely by narrowing the triggers so only one event fires, rather than starting a run and killing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion CheckoutController reads the quote with or(['serviceQuote', 'service_quote']), so both spellings work downstream. InitializeCheckoutRequest validated only the camelCase one, so a snake_case request was rejected with "The service quote field is required." before the controller ever ran — even with a valid quote id present in the query string. That is the spelling our own API reference documents and the one the contract collection sends, so /checkouts/before was unreachable for delivery checkouts via the documented payload. Either spelling now satisfies the requirement; sending neither on a delivery checkout still fails as before, and pickup checkouts still require neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hanges
setKey() only ever added keys. Storefront sessions are cookie-backed, so a client
that calls a network endpoint and then a store endpoint reuses the same session
and ended up with BOTH storefront_store and storefront_network set.
Every query that branches on those two applies them additively:
->when(session('storefront_store'), ...->where('store_uuid', ...))
->when(session('storefront_network'), ...whereHas('store.networks')
->where('is_available', 1)
->where('status', 'published'))
so a store-scoped request silently picked up the stricter network filter as well.
Cart::findProduct then returned null for a product that plainly belongs to the
store, and the cart answered "Invalid product provided to cart!" — which is what
the API contract run hits: List Network Stores (network key) runs before the Cart
folder (store key), and products created through the API have status NULL, so the
leaked network branch excluded them.
ProductController::find and query carry the same both-branches shape and were
mis-scoped the same way.
Each request re-runs this middleware with its own key, and setKey() is the only
writer of these session keys, so clearing the opposing scope is safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
API contract findings — two fixes on this branchBoth of these came out of the Storefront API contract run and are pushed here. Flagging them for your review because the second one changes session behaviour. 1.
|
Unfixed, needs a decision: a product created via the public API can never be
|
Unfixed, minor:
|
…read
`published` is the status the rest of the module reads — Product::PUBLISHED, the
network branch of Cart::findProduct(), ProductController::find/query,
CategoryController, StoreController, and CheckoutController's cart validation all
filter on it, and the console controller writes it.
CreateProductRequest allowed only draft, active and archived, and `status` is a
nullable column with no default, so a product created through the public API
landed as NULL and could never satisfy any of them:
- a network storefront never listed it
- CheckoutController:166 looks cart items up with
->where('is_available', 1)->where('status', 'published'), so it was dropped at
capture time for store-scoped merchants too
Allow `published` and default to it on create, matching the console. UpdateProductRequest
is an empty subclass, so the one rule edit covers both. An explicit status is still
honoured.
No migration needed: existing rows are already `published` (console) or NULL/draft
(API), and nothing that reads `published` gets stricter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
retrieveCart() named its second parameter `$create` and passed it straight into Cart::retrieve(), whose second parameter is `$excludeCheckedout`. The GET action passed true; add, update, remove, empty and delete all took the false default and so operated on carts that had already produced an order. The name made that read as deliberate. Drop the passthrough and let Cart::retrieve() apply its own `true` default everywhere. Behaviour change worth noting in the release: a cart id whose checkout produced an order is no longer found, so Cart::retrieve() falls through to newCart() and the client gets a fresh empty cart rather than editing a historical one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create() read `identity` and nothing else. A body of {name, email, code} — which is
what the documented flow produces after verifying an email — left it null,
static::phone() turned that into the literal '+', and the lookup on meta->identity
could never match. A correctly issued code came back "Invalid verification code
provided!".
Fall back to the payload's own email, then phone, before giving up.
static::phone() now returns null instead of a bare '+' when there is nothing to
format. That '+' was also being written into contacts.phone and users.phone for
every customer created without a phone number.
Returning null makes `where('phone', $x)` compile to `phone IS NULL`, which would
match an arbitrary phone-less user, so the four lookups that could receive it are
guarded: login, loginWithPhone, verifyCode and requestPhoneVerification now bail
with their existing error rather than searching on null.
Also guarded verifyPhoneNumber's $verificationCode->meta['phone'] subscript — a row
written by anything other than requestPhoneVerification turned a recoverable 400
into a 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PHP CI came back red on the previous push. Two causes, both mine:
- the new create-customer test inserted a `uuid` column into the throwaway
verification_codes table it builds, which has no such column
- Product::PUBLISHED does not resolve under the unit-test harness — reproducible
on a whole-suite run, not just an isolated file — so ProductController::create
now writes the status literally, as the rest of that controller already does
Full suite verified locally under PHP 8.2: 0 failures.
Worth recording for anyone debugging this suite: the pest runner emits no output at
all here, in CI or locally, and reports only an exit code. `--log-junit` is the way
to see which tests failed and why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /checkouts/status matches on public_id AND token:
Checkout::where('public_id', $checkoutId)->where('token', $token)->first()
but the chkt_* public id and the checkout_* token are different values, and only
initializeQPayCheckout was returning the id. The cash, card and payment-intent-update
paths returned the token alone, so a client following the documented flow had no way
to learn its own checkout's id and the status endpoint was unreachable for them.
Add `checkout` to the three responses that omitted it, mirroring what the QPay path
already does. Purely additive, so no existing client breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov flagged the branch at 99.94% project / 99.14% patch. All four uncovered
statements were guards added with the static::phone() change, and each one protects a
lookup that would otherwise search on null:
loginWithPhone where('phone', null) -> `phone IS NULL`
verifyCode phone = $x OR email = $x -> both IS NULL
requestPhoneVerification findExistingUserByPhone(string) -> TypeError on null
verifyPhoneNumber $verificationCode->meta['phone'] -> 500 instead of 400
Each is now exercised through the controller, asserting the error it returns rather
than merely executing the line.
Two of them need care that is worth recording:
* verifyCode's case binds its request instead of only passing it. static::phone()
falls back to request()->input('phone'), so an earlier bound request carrying a
phone supplied an identity the call never sent, and the guard stayed unreached. It
runs last in that test so the rebind affects nothing after it.
* verifyPhoneNumber's case seeds a storefront_verify_phone row with an empty meta,
which is the only way to reach a matched code that carries no phone.
Local: 100.00% statements (7160/7160), whole suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Release scope
This is the Storefront
v0.4.19release branch. It includes the patch version bump, contract-workflow updates, API reliability fixes already accumulated ondev-v0.4.19, and the restored marketplace backend required by the refactored Storefront App.Marketplace API
Customer-Tokenidentity as authoritative and reject mismatched customer IDs with403Security and compatibility
The implementation fails closed across network membership and tenant boundaries. Foreign store, category, product, review, and location IDs are not exposed. Existing single-store API behavior remains supported while network keys gain explicit marketplace behavior. Store keys retain access to their own newly created products for retrieval and cart workflows; marketplace keys only expose published, available products from current member stores.
Validation
git diff --check: cleanThe repository PHPStan configuration currently points at a missing
srcdirectory. An explicit changed-path run reaches the existing framework/model-magic baseline rather than producing a usable package gate; this PR does not weaken static-analysis configuration.Related PRs and rollout
Accept the API and SDK contracts before releasing the app. Documentation and API examples should land with the corresponding backend release.
Review focus
Human review is requested for public API compatibility, network isolation, authenticated customer identity, multi-merchant payment behavior, delivery quote origins, review-subject scoping, and release sequencing.