From 0d7115017d30b1e200da151fd03ca9cac926c826 Mon Sep 17 00:00:00 2001 From: DanMat Date: Thu, 3 Sep 2026 14:17:09 -0400 Subject: [PATCH] feat: cart-count summary endpoint for content-page badges (ADR 0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a public GET /ext/shop/cart/summary → {count} (no-store, JSON), so a theme can show the cart count on a page-CACHED content page without baking a per-visitor count into the shared cache. Read-only (StorefrontCart::summary, never mints); the count is a pure function of the caller's own opaque HttpOnly nb_cart cookie (no id param → no IDOR; no cookie → 0). Count only — no lines, token, or PII. Being JSON + no-store keeps it out of the page cache (core caches only 200 text/html). Reviewed via both skills, security-green. Tests: count-only JSON + no-store never cached; no cart → 0; reading never mints. Co-Authored-By: Claude Opus 4.8 --- src/StorefrontCart.php | 17 +++++++++++++++++ src/StorefrontPlugin.php | 4 ++++ tests/StorefrontCartTest.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php index 5dbd9ac..74960af 100644 --- a/src/StorefrontCart.php +++ b/src/StorefrontCart.php @@ -226,6 +226,23 @@ public function summary(Request $request): ?array return $this->summaryOf($port->contents($meta['token'])); } + /** + * GET `/ext/shop/cart/summary` — the visitor's own cart **count** as JSON, so a + * theme can fill a header badge on a page-cached CONTENT page WITHOUT baking a + * per-visitor count into the shared cache. Read-only (never mints); the answer + * is a pure function of the caller's own opaque HttpOnly `nb_cart` cookie (no + * id parameter → no IDOR; no cookie → 0). Returns ONLY `{count}` — no lines, no + * token, no PII. `no-store` + being JSON keep it out of the page cache (core + * caches only 200 text/html), so it can never leak across visitors. + */ + public function summaryResponse(Request $request): Response + { + $summary = $this->summary($request); + $count = $summary === null ? 0 : $summary['count']; + return Response::json(['count' => $count]) + ->withHeader('Cache-Control', 'no-store, private'); + } + // --- helpers --------------------------------------------------------- /** diff --git a/src/StorefrontPlugin.php b/src/StorefrontPlugin.php index 5eecbc2..cc9c6c2 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -61,6 +61,10 @@ public function register(PluginContext $context): void // The cart + checkout mutations — public POST actions (ADR 0017), // CSRF-guarded, that redirect (POST-redirect-GET) to a private page. + // The visitor's own cart count as JSON (no-store, count-only) — lets a theme + // fill a header badge on a page-cached content page without baking a + // per-visitor count into the shared cache (ADR 0017). + $context->routes()->get('shop', '/cart/summary', static fn (Request $r, array $p): Response => $cart->summaryResponse($r)); $context->routes()->post('shop', '/cart/add', static fn (Request $r, array $p): Response => $cart->add($r)); $context->routes()->post('shop', '/cart/update', static fn (Request $r, array $p): Response => $cart->update($r)); $context->routes()->post('shop', '/checkout', static fn (Request $r, array $p): Response => $cart->checkout($r)); diff --git a/tests/StorefrontCartTest.php b/tests/StorefrontCartTest.php index 4cc4606..7a9c141 100644 --- a/tests/StorefrontCartTest.php +++ b/tests/StorefrontCartTest.php @@ -256,6 +256,34 @@ public function categories(): array self::assertSame('1.80', $order['lines'][0]['line_total']); } + public function test_the_cart_summary_endpoint_is_count_only_json_and_never_cached(): void + { + $token = $this->port->seed('sec'); + $this->port->stubContents = ['lines' => [ + ['sku_code' => 'apple', 'qty' => 2, 'name' => 'Apple', 'unit' => null, 'unit_price' => '1.00', 'line_total' => '2.00', 'availability' => 'in_stock'], + ['sku_code' => 'milk', 'qty' => 3, 'name' => 'Milk', 'unit' => null, 'unit_price' => '1.00', 'line_total' => '3.00', 'availability' => 'in_stock'], + ], 'total' => '5.00', 'count' => 2]; + + $res = $this->cart->summaryResponse($this->request('GET', [], $token)); + + self::assertSame(200, $res->status); + self::assertStringContainsString('application/json', (string) $res->header('Content-Type')); + self::assertStringContainsString('no-store', (string) $res->header('Cache-Control'), 'never entered the page cache'); + self::assertStringContainsString('"count":5', $res->body, 'the count is Σ line qty'); + // Count ONLY — no lines, SKUs, total, or any cart contents leak. + self::assertStringNotContainsString('apple', $res->body); + self::assertStringNotContainsString('total', $res->body); + self::assertStringNotContainsString('line', $res->body); + self::assertSame([], $this->port->minted, 'reading the summary never mints a cart'); + } + + public function test_the_cart_summary_endpoint_is_zero_without_a_cart(): void + { + $res = $this->cart->summaryResponse($this->request('GET')); + self::assertStringContainsString('"count":0', $res->body); + self::assertStringContainsString('no-store', (string) $res->header('Cache-Control')); + } + public function test_the_confirmation_falls_back_to_ref_only_without_commerce(): void { // No order-read port wired → order is null, the page still renders.