Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ce8ad3
ci: point the Postman contract at the v0.7.53 release
roncodes Aug 7, 2026
82b4aa7
ci: unpin the contract workflow now that it tracks latest
roncodes Aug 8, 2026
f8b527b
ci(postman): test this branch's API code, not the published package
roncodes Aug 10, 2026
e06d182
chore(release): v0.4.19
roncodes Aug 10, 2026
1e88971
Merge pull request #86 from fleetbase/ci/postman-contract-v0.7.53
roncodes Aug 10, 2026
370d0c1
Merge pull request #87 from fleetbase/ci/contract-overlay-branch-source
roncodes Aug 10, 2026
6f4b97a
fix(auth): scope the verification bypass to designated review accounts
roncodes Aug 10, 2026
c7c2ba2
fix(checkout): verify the customer against the authenticated session
roncodes Aug 10, 2026
14b913c
fix(api): GET /storefront/v1/carts crashed on an argument order
roncodes Aug 11, 2026
0573b61
fix(api): an unknown store location answered 500 instead of 404
roncodes Aug 11, 2026
a533a1c
fix(api): a malformed Apple identity token answered 500
roncodes Aug 11, 2026
b23c029
fix(api): creating a review without a subject answered 500
roncodes Aug 11, 2026
fe41cad
fix(api): SMS login answered 500 when the store has no Twilio credent…
roncodes Aug 11, 2026
5e18dc8
fix(api): account closure and phone verification leaked provider errors
roncodes Aug 11, 2026
cefc1ca
feat: harden marketplace API workflows
roncodes Aug 11, 2026
908fbd1
fix: preserve store marketplace contracts
roncodes Aug 11, 2026
bc3a773
fix(api): apply the review-account bypass to phone verification
roncodes Aug 12, 2026
69dcf9c
ci(contract): run the API contract once per commit
roncodes Aug 12, 2026
2b1da5e
Revert "ci(contract): run the API contract once per commit"
roncodes Aug 12, 2026
2149439
fix(checkout): accept the documented service_quote spelling in valida…
roncodes Aug 13, 2026
44c0581
fix(session): clear the previous storefront scope when the key type c…
roncodes Aug 13, 2026
58f956c
fix(products): let the public API create a product that anything can …
roncodes Aug 13, 2026
d93ac9a
fix(cart): stop mutating carts that have already been checked out
roncodes Aug 13, 2026
fd9e66f
fix(customers): resolve the verification identity from the payload
roncodes Aug 13, 2026
bc11657
fix(tests): repair the two breaks the last three commits introduced
roncodes Aug 13, 2026
9391bd6
fix(checkout): return the checkout id, not just the token
roncodes Aug 13, 2026
25a1de1
test(customers): cover the four null-identity guards
roncodes Aug 14, 2026
0f341bb
chore: update RELEASE.md
roncodes Aug 17, 2026
5fff527
Merge branch 'main' into dev-v0.4.19
roncodes Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .github/workflows/postman.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
name: API Contract (Postman)

# Boots a full Fleetbase stack (published image) and runs the Storefront API
# Postman collection against the live API. Delegates to the reusable workflow in
# Boots a full Fleetbase stack (published image) and runs the Storefront API Postman
# collection against the live API. Delegates to the reusable workflow in
# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN
# (inherited); no-ops until POSTMAN_API_KEY is set.
# TODO: change @dev-v0.7.53 to @main once that branch is merged.
#
# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main
# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and
# there is no ref here to remember to bump. Each run records the image digest it actually
# resolved in its job summary, so a result stays traceable. To reproduce an older run:
#
# with:
# fleetbase-ref: v0.7.53
# api-image: fleetbase/fleetbase-api:v0.7.53

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
contract:
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main
with:
collections: "Fleetbase Storefront API"
build-from-source: false
# Without this the run tests the version of fleetbase/storefront-api baked into the
# published image, not the branch under review. The workflow checks this
# repository out at the commit under test and swaps it into the container.
overlay-package: fleetbase/storefront-api
secrets: inherit
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ The public customer-facing API is mounted under `storefront/v1` and protected by
- Customer registration, login, SMS code verification, social login endpoints, device registration, saved places, customer orders, phone verification, Stripe customer helpers, and account closure flows.
- Order pickup completion and receipt generation.

### Marketplace network contract

A public `network_...` key uses accepted `network_stores` membership as its marketplace authorization boundary, including member stores owned by another company. Marketplace responses expose only public Storefront resources; they do not expose member customers, internal API keys, or gateway credentials.

- `GET storefront/v1/stores` supports search, online/tag/category/ID filters, rating/age/popularity/trending/nearest sorts, distance filtering in meters, and limit/offset pagination.
- Categories and published products can be scoped to a member store. Product responses include public merchant context for marketplace search and cards.
- Cart line items preserve both merchant and store-location identity. Product and location substitution across stores is rejected.
- Checkout revalidates active membership, store availability, published products, location ownership, the network multi-cart policy, and one-cart currency before payment begins.
- Network delivery quotes resolve one origin per represented store and reject missing, foreign, or mismatched locations with `422`.
- When `Customer-Token` is present, it is authoritative; a conflicting checkout customer ID returns `403`.

### Internal Console API

The protected internal API is mounted under `storefront/int/v1`. It powers the Fleetbase Console and includes:
Expand Down Expand Up @@ -204,6 +215,15 @@ composer test:unit
composer test
```

Generate and summarize the full backend Clover baseline with Xdebug or PCOV:

```bash
XDEBUG_MODE=coverage composer test:coverage:clover
composer coverage:summary
```

Marketplace changes on `dev-v0.4.19` must retain 100% backend statement coverage. Focused success and failure-path contracts should be added before regenerating the baseline.

## Configuration

Storefront configuration is provided through the Laravel package config files and environment variables:
Expand Down
42 changes: 37 additions & 5 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
> v0.4.18 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title"
> v0.4.19 ~ "Marketplace storefronts with safer checkout and customer verification"

---
## Highlights

RELEASE_NOTES_PLACEHOLDER
Storefront networks can now power a multi-merchant marketplace. Network clients can discover member stores, locations, categories, products, tags, reviews, and payment gateways while the API keeps every result inside the active marketplace.

Describe what changed in this release. The first line above must name the version
being released, and both placeholder markers must be gone, or the release workflow
refuses to tag.
Carts and checkout now validate merchant membership, store locations, product availability, online status, currency, and the network's multi-store policy. Delivery quotes preserve one origin per merchant, and cart responses include merchant details without querying each line separately.

---
## Security and Reliability

- Authenticated checkout now treats the `Customer-Token` identity as authoritative and rejects a conflicting customer ID with `403`.
- The app-review verification bypass is disabled by default and only works for explicitly allowlisted email addresses or phone numbers.
- Switching between store and network keys clears the previous storefront scope, preventing filters from leaking between requests.
- Store, category, product, location, and review lookups are constrained to the active storefront context.
- Invalid cart, location, review, Apple sign-in, and SMS-provider states now return controlled API errors instead of internal exceptions.

---
## API and Checkout Changes

- Network store discovery supports search, category and tag filters, online state, ratings, popularity, trending activity, age, nearest distance, and maximum distance.
- Product creation now defaults to `published`, matching the console, and marketplace reads only return published, available products.
- Checkout accepts both `serviceQuote` and the documented `service_quote` field.
- Cash, card, and payment-intent checkout responses now include the `checkout` public ID alongside the existing token so clients can query checkout status.
- A cart that has already produced an order can no longer be mutated; retrieving its old ID creates a fresh cart.
- SMS login and account-closure requests fall back to email when SMS is unavailable, and responses identify the delivery method.

---
## Upgrade Steps

No database migration is required.

Installations that use a fixed code for app-store review accounts must now configure both values; the previous `999000` default no longer works:

```dotenv
STOREFRONT_BYPASS_VERIFICATION_CODE=<a secret, rotated code>
STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100
```

Release the matching Storefront SDK and API specification before distributing marketplace-enabled app builds. Publish the corresponding documentation with the backend release.

---
## Need help?

- [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions)
- [Discord](https://discord.gg/HnTqQ6zAVn)
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/storefront-api",
"version": "0.4.18",
"version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
Expand Down
2 changes: 1 addition & 1 deletion extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Storefront",
"version": "0.4.18",
"version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"repository": "https://github.com/fleetbase/storefront",
"license": "AGPL-3.0-or-later",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/storefront-engine",
"version": "0.4.18",
"version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"fleetbase": {
"route": "storefront",
Expand Down
19 changes: 18 additions & 1 deletion server/config/storefront.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,24 @@
|--------------------------------------------------------------------------
*/
'storefront_app' => [
'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE', '999000')
/*
| App store reviewers cannot receive our SMS or email, so a fixed verification
| code has to keep working in production for them. Both values are required for
| a bypass to be possible, and neither has a default — an unconfigured install
| has no bypass at all.
|
| The code alone is NOT sufficient: it is only accepted for an identity listed
| in review_accounts. Previously any identity was accepted, so knowing the code
| was enough to authenticate as any customer.
|
| STOREFRONT_BYPASS_VERIFICATION_CODE=<a secret, rotated code>
| STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100
*/
'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE'),
'review_accounts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('STOREFRONT_REVIEW_ACCOUNTS', ''))
))),
],

/*
Expand Down
1 change: 1 addition & 0 deletions server/src/Http/Controllers/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function findNetwork(string $id)
{
$id = trim($id);
$isPublicId = Str::startsWith($id, ['storefront_network_', 'network_']);
$network = null;

if ($isPublicId) {
$network = Network::where('public_id', $id)->first();
Expand Down
27 changes: 23 additions & 4 deletions server/src/Http/Controllers/v1/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,39 @@

class CartController extends Controller
{
protected function retrieveCart(?string $uniqueId, bool $create = false): Cart
/**
* The flag used to be passed through as `$create`, but Cart::retrieve()'s second
* parameter is `$excludeCheckedout`. Every mutating action below took the `false`
* default and so happily added to, updated, emptied or deleted a cart that had
* already produced an order, while the GET action passed `true` and did not. The
* name made that read as intentional. Let Cart::retrieve() keep its own default so
* a checked-out cart is off limits everywhere.
*/
protected function retrieveCart(?string $uniqueId): Cart
{
return Cart::retrieve($uniqueId, $create);
return Cart::retrieve($uniqueId);
}

/**
* Retrieve or create a cart using a unique identifier. If no unique identifier is provided
* one will be created.
*
* The injected Request is declared FIRST on purpose. Laravel resolves method
* dependencies by splicing class-typed parameters in at their own index and filling
* the remainder from the route parameters, in order. With the optional $uniqueId
* first, GET /storefront/v1/carts (the route with no {uniqueId}) had nothing to put
* at index 0, so the Request landed at index 1 and the call arrived with a hole:
*
* ArgumentCountError: Too few arguments to function retrieve(), 1 passed
*
* The route WITH an id worked, which is why this looked like a cart problem rather
* than a signature one. Five requests in the Storefront collection failed behind it.
*
* @return \Illuminate\Http\Response
*/
public function retrieve(?string $uniqueId = null, Request $request)
public function retrieve(Request $request, ?string $uniqueId = null)
{
$cart = $this->retrieveCart($uniqueId, true);
$cart = $this->retrieveCart($uniqueId);

return new StorefrontCart($cart);
}
Expand Down
35 changes: 27 additions & 8 deletions server/src/Http/Controllers/v1/CategoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ public function query(Request $request)
})->toArray();

// get all products in these categories
$products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get();
$products = Product::whereIn('category_uuid', $categoryIds)
->where('store_uuid', session('storefront_store'))
->where('is_available', 1)
->where('status', 'published')
->with(['addonCategories', 'variants', 'files'])
->get();

$results = $results->map(function ($category) use ($products) {
$category->products = $products->where('category_uuid', $category->uuid)->mapInto(ProductResource::class)->values();
Expand All @@ -60,7 +65,6 @@ public function query(Request $request)
if (session('storefront_network')) {
if ($request->filled('store')) {
$store = Store::where([
'company_uuid' => session('company'),
'public_id' => $request->input('store'),
])->whereHas('networks', function ($q) {
$q->where('network_uuid', session('storefront_network'));
Expand Down Expand Up @@ -91,7 +95,12 @@ public function query(Request $request)
})->toArray();

// get all products in these categories
$products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get();
$products = Product::whereIn('category_uuid', $categoryIds)
->where('store_uuid', $store->uuid)
->where('is_available', 1)
->where('status', 'published')
->with(['addonCategories', 'variants', 'files'])
->get();

$results = $results->map(function ($category) use ($products) {
// $category->products = Product::where('category_uuid', $category->uuid)->get()->mapInto(ProductResource::class);
Expand Down Expand Up @@ -120,11 +129,21 @@ public function query(Request $request)

// if we want to get categories with stores
if ($request->has('with_stores')) {
$results = $results->map(function ($category) {
$category->stores = Store::whereHas('networks', function ($q) use ($category) {
$q->where('network_uuid', session('storefront_network'));
$q->where('category_uuid', $category->uuid);
})->get();
$categoryIds = $results->pluck('uuid');
$stores = Store::whereHas('networks', function ($q) use ($categoryIds) {
$q->where('network_uuid', session('storefront_network'));
$q->whereIn('category_uuid', $categoryIds);
})->with(['networks' => function ($q) use ($categoryIds) {
$q->where('network_uuid', session('storefront_network'));
$q->whereIn('category_uuid', $categoryIds);
}])->get();

$results = $results->map(function ($category) use ($stores) {
$category->stores = $stores->filter(function ($store) use ($category) {
return $store->networks->contains(function ($network) use ($category) {
return $network->pivot?->category_uuid === $category->uuid;
});
})->values();

return $category;
});
Expand Down
Loading
Loading