Skip to content

(P1: 3) [Security/Infra] Enforce origin posture, harden DB integrity constraints, and finalize shop observability safety (no console, correlation IDs)#179

Merged
ViktorSvertoka merged 8 commits into
developfrom
lso/feat/shop
Jan 22, 2026
Merged

(P1: 3) [Security/Infra] Enforce origin posture, harden DB integrity constraints, and finalize shop observability safety (no console, correlation IDs)#179
ViktorSvertoka merged 8 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 22, 2026

Description

This PR hardens the Shop platform’s safety rails across three areas: origin/caller posture, database integrity, and observability. It documents and enforces the expected request origins for browser-exposed endpoints, aligns Drizzle schema/snapshots to match real Postgres constraints to prevent drift, and completes structured logging coverage across the Shop API surface (including correlation IDs and explicit error codes) while removing console.* usage.


Related Issue

Issue: #<issue_number>


Changes

  • Origin posture (P1): documented the allow/deny assumptions and applied targeted origin enforcement where browser context is possible (admin + checkout), while keeping webhooks/internal explicitly non-browser.
  • DB integrity (P0/P1): aligned Drizzle schema + snapshots with the actual Postgres constraints to prevent drift across environments; normalized FK deletion policy for stripe_events.order_id → orders.id to be consistently ON DELETE CASCADE with an idempotent guard.
  • Observability safety (P0): expanded structured logging façade coverage across Shop routes; ensured requestId + base metadata + explicit code fields and added correlation identifiers where applicable (e.g., orderId, attemptId, stripeEventId, providerRef), and purged remaining console.*.

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Screenshots (if applicable)

N/A (backend/security/infra changes)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • Documentation

    • Added an Origin Posture security guide describing origin/CSRF policies.
  • New Features

    • Admin UI: responsive orders/products views and a product delete action in admin.
  • Bug Fixes

    • Hardened API error handling, origin/CSRF enforcement, webhook and checkout robustness, and refund resilience.
    • Improved rate-limit and client IP handling (Cloudflare header trust made configurable).
  • Chores

    • Added environment options for APP_ORIGIN, APP_ADDITIONAL_ORIGINS and TRUST_CF_CONNECTING_IP; DB cascade for stripe events.
    • Expanded logging and request tracing.
  • Tests

    • Added origin-posture tests and updated tests to include Origin headers and related setup.

✏️ Tip: You can customize this high-level summary in your review settings.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 22, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit de6793a
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/6971aa09538f150008c35c0e
😎 Deploy Preview https://deploy-preview-179--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 22, 2026

📝 Walkthrough

Walkthrough

Adds origin-posture enforcement (browser same-origin and non-browser guards), CSRF gating, request-scoped tracing, standardized no-store JSON responses, expanded structured logging, tests and test-helper updates, and a DB migration to add ON DELETE CASCADE for stripe_events.order_id.

Changes

Cohort / File(s) Summary
Environment & Gitignore
frontend/.env.example, frontend/.gitignore
New env vars APP_ORIGIN, APP_ADDITIONAL_ORIGINS, TRUST_CF_CONNECTING_IP documented; .claude ignore preserved and select docs unignored.
Origin & Security Utilities
frontend/lib/security/origin.ts
New helpers: normalizeOrigin, getAllowedOrigins, guardBrowserSameOrigin, guardNonBrowserOnly, and standardized 403 no-store error responses.
Rate-limit / Client IP
frontend/lib/security/rate-limit.ts
Client IP resolution now respects TRUST_FORWARDED_HEADERS and TRUST_CF_CONNECTING_IP; Cloudflare header only trusted when enabled.
Admin API Routes
frontend/app/api/shop/admin/... (orders, products, reconcile-stale, refunds)
Added origin guards, CSRF gating, requestId/startedAt tracing, noStoreJson helper, exported runtime='nodejs' and richer structured logging/error mapping across GET/PATCH/DELETE/refund/reconcile flows.
Shop & Checkout Routes
frontend/app/api/shop/checkout/route.ts, .../cart/rehydrate/route.ts, .../catalog/route.ts, .../orders/[id]/route.ts
Introduced origin checks, request tracing, no-store JSON responses, idempotency handling, extended payload validation and detailed logging/codes.
Webhooks
frontend/app/api/shop/webhooks/stripe/route.ts
Added guardNonBrowserOnly, noStoreJson usage, OPTIONS handler, enriched stripe metadata extraction and structured logging/responses.
Internal Janitor Job
frontend/app/api/shop/internal/orders/restock-stale/route.ts
Added non-browser guard, request tracing, run/job metrics logging, detailed payload validation and rate-limit logging for janitor runs.
DB Migration & Drizzle metadata
frontend/db/schema/shop.ts, frontend/drizzle/0005_modern_bromley.sql, frontend/drizzle/meta/...
Added ON DELETE CASCADE to stripe_events.order_id, migration DO block, and new Drizzle snapshot/journal files.
Docs
frontend/docs/security/origin-posture.md
New document detailing origin posture, allowed origins env vars and enforcement policy for browser vs non-browser endpoints.
Tests & Helpers
frontend/lib/tests/*, frontend/lib/tests/helpers/makeCheckoutReq.ts
Many tests updated to include Origin headers or use NextRequest; new origin-posture tests added; rate-limit/ENV lifecycle setup added; test helper signatures extended.
UI / Admin Components
frontend/app/[locale]/shop/admin/..., frontend/components/shop/admin/admin-product-delete-button.tsx
Responsive admin UI changes and new client component AdminProductDeleteButton (CSRF-aware delete flow).
Product mutations & errors
frontend/lib/services/products/mutations/*, frontend/lib/errors/products.ts, frontend/lib/services/products/admin/queries.ts
Atomic delete via SQL WITH returning, added ProductNotFoundError and replaced generic throws with specific error types; defensive checks for concurrent deletes/updates.
Misc / Project structure
frontend/project-structure.txt, many added/moved files under app & components
Large reorganization/additions: nested API routes, new components, tests and scaffolding; many new files added.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant NextAPI as Next.js API
    participant Guard as Origin Guard
    participant CSRF as CSRF Checker
    participant Handler as Route Handler
    participant DB as Database
    participant Resp as Response

    Browser->>NextAPI: POST /admin/... (Origin header)
    NextAPI->>Guard: guardBrowserSameOrigin(request)
    Guard->>Guard: validate Origin vs allowlist
    alt Origin blocked
        Guard->>Resp: 403 ORIGIN_NOT_ALLOWED (no-store)
        Resp->>Browser: 403 no-store
    else Origin allowed
        NextAPI->>CSRF: requireAdminCsrf(request)
        alt CSRF invalid
            CSRF->>Resp: 403 CSRF_REJECTED (no-store)
            Resp->>Browser: 403 no-store
        else CSRF valid
            NextAPI->>Handler: invoke handler (with requestId, start time)
            Handler->>DB: perform DB operations
            DB->>Handler: result / error
            Handler->>Resp: 200/4xx/5xx via noStoreJson
            Resp->>Browser: response (no-store)
        end
    end
Loading
sequenceDiagram
    participant ExternalJob
    participant NextAPI as Next.js API
    participant Guard as Non-Browser Guard
    participant Handler as Job Handler
    participant DB as Database
    participant Resp as Response

    ExternalJob->>NextAPI: POST /internal/restock-stale (no browser headers)
    NextAPI->>Guard: guardNonBrowserOnly(request)
    Guard->>Guard: check Origin / Sec-Fetch-Site absence
    alt Browser context detected
        Guard->>Resp: 403 BROWSER_CONTEXT_NOT_ALLOWED (no-store)
        Resp->>ExternalJob: 403 no-store
    else Verified non-browser
        NextAPI->>Handler: validate payload & generate requestId
        alt Invalid payload
            Handler->>Resp: 400 INVALID_PAYLOAD (no-store)
            Resp->>ExternalJob: 400 no-store
        else Valid
            Handler->>DB: run restock job
            DB->>Handler: results
            Handler->>Resp: 200 result (no-store)
            Resp->>ExternalJob: 200 no-store
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐇 I guard the origin, twitching my nose,
I hop with requestIds in tidy rows.
No-store on the meadow, CSRF kept tight,
Logs hum like crickets through day and night.
Hooray — safe hops and clean deletes tonight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title comprehensively covers the main changes: origin posture enforcement, database constraint hardening, and observability improvements (correlation IDs, logging).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@frontend/.env.example`:
- Around line 2-6: The dotenv example file has APP_ORIGIN and
APP_ADDITIONAL_ORIGINS out of order causing dotenv-linter warnings; reorder the
Core/Environment block so keys follow the linter's expected sequence (place
APP_URL and NEXT_PUBLIC_SITE_URL before APP_ENV if required by your linter
conventions or move APP_ORIGIN and APP_ADDITIONAL_ORIGINS to the proper
position) — update the APP_ENV, APP_URL, NEXT_PUBLIC_SITE_URL, APP_ORIGIN,
APP_ADDITIONAL_ORIGINS keys in .env.example to match the dotenv-linter expected
ordering.

In `@frontend/app/api/shop/catalog/route.ts`:
- Around line 30-34: The normalizeLocale function treats uppercase/mixed inputs
as non-UK and falls back to 'en'; to fix, normalize the input to lowercase
before checks: in normalizeLocale, compute the raw string by trimming and then
calling toLowerCase() on the input (e.g., const raw = typeof input === 'string'
? input.trim().toLowerCase() : ''); then keep the existing checks (raw === 'uk'
|| raw.startsWith('uk-')) to return 'uk' and return 'en' otherwise.

In `@frontend/app/api/shop/internal/orders/restock-stale/route.ts`:
- Around line 282-289: The current log uses a hardcoded 'UNAUTHORIZED' even
though requireInternalJanitorAuth(request) may return different statuses
(401/403/503); change the block that handles authRes to derive the actual HTTP
status from authRes (e.g., authRes.status || authRes.statusCode) and set that
value as the code field passed to logWarn (use a fallback like String(status) or
'UNAUTHORIZED' if missing), keeping the same variables (authRes, baseMeta,
logWarn) and returning authRes as before.

In `@frontend/lib/security/rate-limit.ts`:
- Around line 110-125: The .env.example is missing the TRUST_CF_CONNECTING_IP
flag which is referenced by getClientIpFromHeaders; add a line
TRUST_CF_CONNECTING_IP=0 to .env.example next to the existing
TRUST_FORWARDED_HEADERS entry and include a short security note explaining that
enabling it trusts Cloudflare's cf-connecting-ip header (only set for Cloudflare
fronted deployments) and that it should remain 0 in untrusted environments to
avoid IP spoofing.
🧹 Nitpick comments (15)
frontend/lib/security/origin.ts (1)

5-14: Consider setting no-store in origin error responses.
Centralizing Cache-Control here avoids relying on each caller.

♻️ Suggested tweak
 function buildErrorResponse(code: string, message: string) {
-  return NextResponse.json(
+  const res = NextResponse.json(
     {
       error: {
         code,
         message,
       },
     },
     { status: 403 }
   );
+  res.headers.set('Cache-Control', 'no-store');
+  return res;
 }
frontend/app/api/shop/admin/orders/[id]/refund/route.ts (1)

131-169: Avoid double‑logging known errors as error‑level.
OrderNotFoundError and InvalidPayloadError are already handled; logging them as errors first can inflate error metrics. Consider logging errors only for unexpected cases.

♻️ Suggested adjustment
-    logError('admin_orders_refund_failed', error, {
-      ...baseMeta,
-      orderId: orderIdForLog,
-      code: 'ADMIN_REFUND_FAILED',
-      durationMs: Date.now() - startedAtMs,
-    });
-
     if (error instanceof OrderNotFoundError) {
       logWarn('admin_orders_refund_not_found', {
         ...baseMeta,
         code: error.code,
         orderId: orderIdForLog,
         durationMs: Date.now() - startedAtMs,
       });

       return noStoreJson(
         { error: error.message, code: error.code },
         { status: 404 }
       );
     }

     if (error instanceof InvalidPayloadError) {
       logWarn('admin_orders_refund_invalid_payload', {
         ...baseMeta,
         code: error.code,
         orderId: orderIdForLog,
         durationMs: Date.now() - startedAtMs,
       });

       return noStoreJson(
         { error: error.message, code: error.code },
         { status: 400 }
       );
     }

+    logError('admin_orders_refund_failed', error, {
+      ...baseMeta,
+      orderId: orderIdForLog,
+      code: 'ADMIN_REFUND_FAILED',
+      durationMs: Date.now() - startedAtMs,
+    });
     return noStoreJson(
       { error: 'Unable to refund order', code: 'INTERNAL_ERROR' },
       { status: 500 }
     );
frontend/app/api/shop/admin/orders/reconcile-stale/route.ts (1)

9-9: Use info-level logging for successful reconcile
A normal success path should not emit warnings; consider logInfo to keep warn signal meaningful.

♻️ Suggested change
-import { logError, logWarn } from '@/lib/logging';
+import { logError, logInfo, logWarn } from '@/lib/logging';
...
-    logWarn('admin_reconcile_stale_succeeded', {
+    logInfo('admin_reconcile_stale_succeeded', {
       ...baseMeta,
       code: 'OK',
       processed,
       olderThanMinutes: DEFAULT_STALE_MINUTES,
       durationMs: Date.now() - startedAtMs,
     });

Also applies to: 98-104

frontend/app/api/shop/webhooks/stripe/route.ts (3)

575-591: Consider extracting charge ID resolution into a helper function.

The deeply nested ternary for bestEffortRefundChargeId is functionally correct but hard to read. Since this pattern (extracting ID from string or object) appears multiple times in this file, a small helper would improve maintainability.

♻️ Suggested refactor
function extractStripeId(value: unknown): string | null {
  if (typeof value === 'string' && value.trim().length > 0) return value.trim();
  if (value && typeof value === 'object' && 'id' in value && typeof (value as any).id === 'string') {
    return (value as any).id;
  }
  return null;
}

// Usage:
const bestEffortRefundChargeId = refundObject ? extractStripeId((refundObject as any).charge) : null;

438-443: Redundant Cache-Control header setting.

The rateLimitResponse() helper already sets Cache-Control: no-store (see frontend/lib/security/rate-limit.ts lines 263-264). The additional res.headers.set('Cache-Control', 'no-store') on line 442 is redundant.

♻️ Suggested removal
       const res = rateLimitResponse({
         retryAfterSeconds: decision.retryAfterSeconds,
         details: { scope: 'stripe_webhook', reason: 'missing_signature' },
       });
-      res.headers.set('Cache-Control', 'no-store');
       return res;

413-418: Minor formatting inconsistency.

Line 417 has unusual indentation ( return res;) compared to the rest of the file. This appears to be a typo.

♻️ Suggested fix
     const res = noStoreJson(
       { error: 'invalid_payload', code: 'INVALID_PAYLOAD' },
       { status: 400 }
     );
-       return res;
+    return res;
frontend/drizzle/0005_modern_bromley.sql (1)

9-16: Consider wrapping in explicit transaction for safety.

The DROP CONSTRAINT and ADD CONSTRAINT are separate statements. If the migration runner doesn't wrap this in a transaction and the ADD CONSTRAINT fails (e.g., orphaned order_id references), the constraint will be permanently lost. Drizzle typically runs migrations in transactions, but an explicit block adds defense-in-depth.

♻️ Explicit transaction wrapper
 DO $$
 BEGIN
   IF EXISTS (
     SELECT 1
     FROM pg_constraint c
     WHERE c.conname = 'stripe_events_order_id_orders_id_fk'
       AND c.conrelid = 'public.stripe_events'::regclass
       AND pg_get_constraintdef(c.oid) NOT ILIKE '%ON DELETE CASCADE%'
   ) THEN
+    -- Wrap in savepoint for safety within DO block
     ALTER TABLE public.stripe_events
       DROP CONSTRAINT stripe_events_order_id_orders_id_fk;

     ALTER TABLE public.stripe_events
       ADD CONSTRAINT stripe_events_order_id_orders_id_fk
       FOREIGN KEY (order_id) REFERENCES public.orders(id)
       ON DELETE CASCADE;
   END IF;
 END $$;

Note: DO blocks are already atomic in PostgreSQL, so this is actually safe. The entire block runs as a single transaction.

frontend/lib/tests/origin-posture.test.ts (1)

30-37: Consider adding test for APP_ADDITIONAL_ORIGINS.

The tests stub APP_ADDITIONAL_ORIGINS as empty. Consider adding a test case that verifies requests from an additional origin are also allowed, to ensure the multi-origin allowlist logic works correctly.

📝 Suggested additional test
it('guardBrowserSameOrigin allows POST with additional allowed Origin', () => {
  vi.stubEnv('APP_ADDITIONAL_ORIGINS', 'https://staging.example.com,https://preview.example.com');
  const req = makeReq({
    method: 'POST',
    headers: { origin: 'https://staging.example.com' },
  });
  const res = guardBrowserSameOrigin(req);
  expect(res).toBeNull();
});
frontend/docs/security/origin-posture.md (1)

44-46: Consider adding links to implementation files.

Adding references to the actual guard implementations (frontend/lib/security/origin.ts) and example route handlers would help developers quickly navigate to the code.

📝 Suggested addition
## Implementation references
- Guard utilities: `frontend/lib/security/origin.ts`
- Tests: `frontend/lib/tests/origin-posture.test.ts`
- Example browser-exposed route: `frontend/app/api/shop/checkout/route.ts`
- Example non-browser route: `frontend/app/api/shop/webhooks/stripe/route.ts`
frontend/app/api/shop/admin/products/[id]/status/route.ts (1)

125-149: Avoid error-level logging for known 404s.

PRODUCT_NOT_FOUND is an expected outcome; logging it as an error adds noise before the warn + 404 response. Consider handling it before the generic error log.

🔧 Proposed reorder
-    logError('admin_product_status_failed', error, {
-      ...baseMeta,
-      code: 'ADMIN_PRODUCT_STATUS_FAILED',
-      productId: productIdForLog,
-      durationMs: Date.now() - startedAtMs,
-    });
-
     if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') {
       logWarn('admin_product_status_not_found', {
         ...baseMeta,
         code: 'PRODUCT_NOT_FOUND',
         productId: productIdForLog,
         durationMs: Date.now() - startedAtMs,
       });

       return noStoreJson(
         { error: 'Product not found', code: 'PRODUCT_NOT_FOUND' },
         { status: 404 }
       );
     }
+
+    logError('admin_product_status_failed', error, {
+      ...baseMeta,
+      code: 'ADMIN_PRODUCT_STATUS_FAILED',
+      productId: productIdForLog,
+      durationMs: Date.now() - startedAtMs,
+    });
frontend/app/api/shop/checkout/route.ts (1)

63-129: Add no-store headers to checkout responses (including origin-blocked).

Checkout responses can include sensitive payment/order metadata; explicitly setting Cache-Control: no-store keeps them out of intermediary caches and aligns with other shop endpoints.

🔧 Proposed update
 function errorResponse(
   code: string,
   message: string,
   status: number,
   details?: unknown
 ) {
-  return NextResponse.json(
+  const res = NextResponse.json(
     {
       code,
       message,
       ...(details === undefined ? {} : { details }),
     },
     { status }
   );
+  res.headers.set('Cache-Control', 'no-store');
+  return res;
 }
@@
 function buildCheckoutResponse({
   order,
   itemCount,
   clientSecret,
   status,
 }: {
   order: CheckoutOrderShape;
   itemCount: number;
   clientSecret: string | null;
   status: number;
 }) {
-  return NextResponse.json(
+  const res = NextResponse.json(
     {
       success: true,
       order: {
         id: order.id,
         currency: order.currency,
         totalAmount: order.totalAmount,
         itemCount,
         paymentStatus: order.paymentStatus,
         paymentProvider: order.paymentProvider,
         paymentIntentId: order.paymentIntentId,
         clientSecret,
       },
       orderId: order.id,
       paymentStatus: order.paymentStatus,
       paymentProvider: order.paymentProvider,
       paymentIntentId: order.paymentIntentId,
       clientSecret,
     },
     { status }
   );
+  res.headers.set('Cache-Control', 'no-store');
+  return res;
 }
@@
   if (blocked) {
     logWarn('checkout_origin_blocked', {
       ...baseMeta,
       code: 'ORIGIN_BLOCKED',
     });
-
-    return blocked;
+    blocked.headers.set('Cache-Control', 'no-store');
+    return blocked;
   }

Also applies to: 167-175

frontend/app/api/shop/admin/orders/[id]/route.ts (1)

37-48: Origin guard is a no-op for GET requests.

According to guardBrowserSameOrigin implementation, it returns null for GET and HEAD methods (safe requests don't require origin validation). This block will never execute for this GET handler, making it dead code.

Consider removing this guard for GET, or clarify if there's an intent to change guardBrowserSameOrigin behavior in the future.irs.

frontend/app/api/shop/admin/products/[id]/route.ts (3)

115-126: Origin guard is a no-op for GET requests.

Same as the orders route - guardBrowserSameOrigin returns null for GET/HEAD methods, making this block unreachable.


326-420: Duplicate sale rule validation is redundant.

Sale rules are validated twice:

  1. Lines 326-370: getSaleViolationFromFormData(formData) validates raw form data
  2. Lines 395-420: findSaleRuleViolation(parsed.data) validates after parseAdminProductForm

If parseAdminProductForm doesn't transform the sale/price data, the second check will always pass if the first did. Consider removing the first validation to simplify the code, or document why both are needed (e.g., if prices can be modified during parsing).


374-376: Type assertion could be cleaner.

The complex cast ((parsed.error as any)?.issues?.length as number | undefined) ?? 0 suggests the error type from parseAdminProductForm lacks proper typing. Consider adding a typed error structure or using Zod's typed error format.

Comment thread frontend/.env.example Outdated
Comment on lines +2 to +6
APP_ENV=
APP_URL=
NEXT_PUBLIC_SITE_URL=
APP_ORIGIN=https://example.test
APP_ADDITIONAL_ORIGINS=https://admin.example.test
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Align key order with dotenv-linter to avoid CI warnings.

The linter flags APP_ORIGIN and APP_ADDITIONAL_ORIGINS ordering. Consider reordering the Core/Environment block.

🧹 Proposed reorder
-APP_ENV=
-APP_URL=
-NEXT_PUBLIC_SITE_URL=
-APP_ORIGIN=https://example.test
-APP_ADDITIONAL_ORIGINS=https://admin.example.test
+APP_ADDITIONAL_ORIGINS=https://admin.example.test
+APP_ENV=
+APP_ORIGIN=https://example.test
+APP_URL=
+NEXT_PUBLIC_SITE_URL=
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
APP_ENV=
APP_URL=
NEXT_PUBLIC_SITE_URL=
APP_ORIGIN=https://example.test
APP_ADDITIONAL_ORIGINS=https://admin.example.test
APP_ADDITIONAL_ORIGINS=https://admin.example.test
APP_ENV=
APP_ORIGIN=https://example.test
APP_URL=
NEXT_PUBLIC_SITE_URL=
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 5-5: [UnorderedKey] The APP_ORIGIN key should go before the APP_URL key

(UnorderedKey)


[warning] 6-6: [UnorderedKey] The APP_ADDITIONAL_ORIGINS key should go before the APP_ENV key

(UnorderedKey)

🤖 Prompt for AI Agents
In `@frontend/.env.example` around lines 2 - 6, The dotenv example file has
APP_ORIGIN and APP_ADDITIONAL_ORIGINS out of order causing dotenv-linter
warnings; reorder the Core/Environment block so keys follow the linter's
expected sequence (place APP_URL and NEXT_PUBLIC_SITE_URL before APP_ENV if
required by your linter conventions or move APP_ORIGIN and
APP_ADDITIONAL_ORIGINS to the proper position) — update the APP_ENV, APP_URL,
NEXT_PUBLIC_SITE_URL, APP_ORIGIN, APP_ADDITIONAL_ORIGINS keys in .env.example to
match the dotenv-linter expected ordering.

Comment thread frontend/app/api/shop/catalog/route.ts
Comment thread frontend/app/api/shop/internal/orders/restock-stale/route.ts
Comment thread frontend/lib/security/rate-limit.ts
…mobile cards UI; tighten env/docs, locale normalization, cache-control, and logging semantics
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@frontend/app/api/shop/admin/orders/`[id]/route.ts:
- Around line 49-59: The CSRF check is being applied unconditionally in the
admin order GET handler via requireAdminCsrf (and its csrfRes handling/logWarn
with code 'CSRF_REJECTED'), which is unnecessary for safe/read-only GETs; update
the logic in route.ts so that you only call requireAdminCsrf (and execute the
csrfRes.headers.set('Cache-Control', 'no-store') / return) for non-GET methods
(e.g., when request.method !== 'GET') or remove the CSRF check path entirely for
GET requests while keeping it for POST/PUT/PATCH/DELETE, leaving logWarn and the
existing metadata in place for rejected state-changing requests.
🧹 Nitpick comments (11)
frontend/app/api/shop/admin/products/[id]/status/route.ts (2)

19-23: Consider extracting noStoreJson to a shared utility.

Per the AI summary, this pattern is used across multiple shop routes. Extracting it to a shared module (e.g., @/lib/responses) would reduce duplication and ensure consistent behavior.


125-137: String-based error check is fragile.

Using error.message === 'PRODUCT_NOT_FOUND' for error identification is brittle—if the message changes in toggleProductStatus, this check silently fails and falls through to the 500 handler.

Consider creating a custom error class (similar to AdminUnauthorizedError) for type-safe error handling:

♻️ Suggested refactor
// In a shared errors module (e.g., `@/lib/errors/products.ts`)
export class ProductNotFoundError extends Error {
  code = 'PRODUCT_NOT_FOUND' as const;
  constructor(productId: string) {
    super(`Product not found: ${productId}`);
    this.name = 'ProductNotFoundError';
  }
}

Then update the check:

-    if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') {
+    if (error instanceof ProductNotFoundError) {
frontend/lib/services/products/mutations/delete.ts (1)

2-46: Prefer returning imagePublicId from the delete to avoid stale cleanup and an extra round‑trip.

The current pre-read can become stale if the product is updated between the select and delete. Returning imagePublicId from the delete makes the cleanup consistent with the deleted row and removes the extra query.

♻️ Proposed refactor
-import { eq, sql } from 'drizzle-orm';
+import { sql } from 'drizzle-orm';
@@
-  const [existing] = await db
-    .select({ id: products.id, imagePublicId: products.imagePublicId })
-    .from(products)
-    .where(eq(products.id, id))
-    .limit(1);
-
-  if (!existing) {
-    throw new Error('PRODUCT_NOT_FOUND');
-  }
-
   // Atomic delete: prices first, then product, all-or-nothing.
   const result = await db.execute(sql`
     WITH del_prices AS (
       DELETE FROM ${productPrices}
       WHERE ${productPrices.productId} = ${id}
     ),
     del_product AS (
       DELETE FROM ${products}
       WHERE ${products.id} = ${id}
-      RETURNING ${products.id} AS id
+      RETURNING ${products.id} AS id, ${products.imagePublicId} AS imagePublicId
     )
-    SELECT id FROM del_product;
+    SELECT id, imagePublicId FROM del_product;
   `);
 
-  const rows =
-    (result as unknown as { rows?: Array<{ id: string }> }).rows ?? [];
-  if (rows.length === 0) {
+  const rows =
+    (result as unknown as {
+      rows?: Array<{ id: string; imagePublicId: string | null }>;
+    }).rows ?? [];
+  const [deleted] = rows;
+  if (!deleted) {
     // concurrent delete edge-case
     throw new Error('PRODUCT_NOT_FOUND');
   }
 
-  if (existing.imagePublicId) {
+  if (deleted.imagePublicId) {
     try {
-      await destroyProductImage(existing.imagePublicId);
+      await destroyProductImage(deleted.imagePublicId);
     } catch (error) {
       logError('Failed to cleanup product image after delete', error);
     }
   }
frontend/app/[locale]/shop/admin/orders/page.tsx (1)

61-292: Reduce duplication between mobile and desktop render paths.

Both views recompute the same currency/total formatting; a small view‑model extraction avoids drift and simplifies future changes.

♻️ Example refactor
   const hasNext = all.length > PAGE_SIZE;
   const items = all.slice(0, PAGE_SIZE);
+  const viewModels = items.map(order => {
+    const currency = orderCurrency(order, locale);
+    const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount);
+    const totalFormatted =
+      totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale);
+    return { order, totalFormatted };
+  });
@@
-            {items.map(order => {
-              const currency = orderCurrency(order, locale);
-              const totalMinor = pickMinor(
-                order?.totalAmountMinor,
-                order?.totalAmount
-              );
-              const totalFormatted =
-                totalMinor === null
-                  ? '-'
-                  : formatMoney(totalMinor, currency, locale);
+            {viewModels.map(({ order, totalFormatted }) => {
               return (
@@
-                  return (
+                  return (
                     <tr key={order.id} className="hover:bg-muted/50">
@@
-                            {totalFormatted}
+                            {totalFormatted}
                           </td>
frontend/lib/security/origin.ts (1)

22-24: Consider validating URL format in normalizeOrigin.

The function only trims and removes trailing slashes, but doesn't validate that the input is a valid origin URL. Malformed origins could pass through.

💡 Optional: Add basic URL validation
export function normalizeOrigin(input: string): string {
  const trimmed = input.trim().replace(/\/+$/, '');
  // Optional: validate it looks like a valid origin
  try {
    const url = new URL(trimmed);
    return url.origin;
  } catch {
    return trimmed; // fallback to original behavior
  }
}

Using URL.origin ensures consistent normalization (e.g., handling default ports).

frontend/app/api/shop/webhooks/stripe/route.ts (1)

63-69: OPTIONS handler should also reject browser context.

The OPTIONS handler doesn't use guardNonBrowserOnly, which means browsers could send preflight requests. While this returns 405 anyway, for consistency with the documented posture (non-browser endpoints reject browser context), consider adding the guard.

💡 Optional: Add origin guard to OPTIONS
-export function OPTIONS() {
-  const res = noStoreJson(
+export function OPTIONS(request: NextRequest) {
+  const blocked = guardNonBrowserOnly(request);
+  if (blocked) {
+    blocked.headers.set('Cache-Control', 'no-store');
+    return blocked;
+  }
+
+  return noStoreJson(
     { error: 'METHOD_NOT_ALLOWED', code: 'METHOD_NOT_ALLOWED' },
     { status: 405, headers: { Allow: 'POST' } }
   );
-  return res;
 }
frontend/app/[locale]/shop/admin/products/page.tsx (1)

54-68: Consider using schema references instead of raw table names in SQL.

The isInUse query uses hardcoded table names (order_items, inventory_moves) rather than the exported schema objects. Using orderItems and inventoryMoves from @/db/schema would provide a single source of truth and prevent drift if table names change.

💡 Suggested approach

Update the query to use schema references:

+import { orderItems, inventoryMoves } from '@/db/schema';
+
      isInUse: sql<boolean>`
        (
          exists (
            select 1
-           from order_items oi
+           from ${orderItems} oi
            where oi.product_id = ${products.id}
          )
          OR
          exists (
            select 1
-           from inventory_moves im
+           from ${inventoryMoves} im
            where im.product_id = ${products.id}
          )
        )
      `,
frontend/app/api/shop/admin/orders/reconcile-stale/route.ts (1)

52-65: Clarify the purpose of the dual origin checks or consolidate if redundant.

The isSameOrigin check on line 57 performs similar origin validation to guardBrowserSameOrigin called on line 33. If guardBrowserSameOrigin already restricts requests to allowed origins, the subsequent isSameOrigin check may be redundant in most configurations. Consider documenting why both are necessary (e.g., different validation semantics for CSRF defense), or if safe to do so, consolidate into a single check to reduce complexity. Note that other admin routes use requireAdminCsrf for full CSRF token verification instead of this manual pattern.

frontend/app/api/shop/checkout/route.ts (1)

227-231: Consider documenting the truncation rationale for idempotencyKeyShort.

The key is truncated to 32 characters for logging. While this is reasonable to avoid log bloat, adding a brief comment explaining this would clarify intent for future maintainers.

frontend/app/api/shop/admin/products/[id]/route.ts (2)

30-36: Consider extracting noStoreJson to a shared utility.

This helper is duplicated across multiple route files (checkout, admin routes). Centralizing it in a shared location (e.g., @/lib/api/responses) would reduce duplication and ensure consistent behavior.


454-464: Type assertion on parsed.data could be avoided.

The cast parsed.data as UpdateProductInput suggests a potential type mismatch between parseAdminProductForm output and updateProduct input. If types align, the assertion shouldn't be needed.

Comment thread frontend/app/api/shop/admin/orders/[id]/route.ts Outdated
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