diff --git a/frontend/.env.example b/frontend/.env.example index 0eb502ab..c1ebeb15 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,7 @@ # --- Core / Environment +APP_ADDITIONAL_ORIGINS=https://admin.example.test APP_ENV= +APP_ORIGIN=https://example.test APP_URL= NEXT_PUBLIC_SITE_URL= @@ -83,6 +85,11 @@ STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60 STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30 STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60 +# SECURITY: If true, trust Cloudflare's cf-connecting-ip header for rate limiting. +# Enable ONLY when traffic is fronted by Cloudflare (header is set by Cloudflare at the edge). +# Default: false (0). Keep 0 in untrusted environments to avoid IP spoofing. +TRUST_CF_CONNECTING_IP=0 + # SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting. # Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers. # Default: false (empty/0/false). diff --git a/frontend/.gitignore b/frontend/.gitignore index ca291aa3..e7c6bf8f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -46,4 +46,8 @@ next-env.d.ts # Documentation (only for development) CLAUDE.md docs/ -.claude \ No newline at end of file +.claude + +!docs/ +!docs/security/ +!docs/security/origin-posture.md diff --git a/frontend/app/[locale]/shop/admin/orders/page.tsx b/frontend/app/[locale]/shop/admin/orders/page.tsx index 33366ef0..40efe166 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -61,6 +61,23 @@ export default async function AdminOrdersPage({ 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); + + return { + id: order.id, + createdAt: formatDate(order.createdAt, locale), + paymentStatus: order.paymentStatus, + totalFormatted: + totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale), + itemCount: order.itemCount, + paymentProvider: order.paymentProvider ?? '-', + viewHref: `/shop/admin/orders/${order.id}`, + viewAriaLabel: `View order ${order.id}`, + }; + }); + return ( <> @@ -69,7 +86,7 @@ export default async function AdminOrdersPage({ className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8" aria-labelledby="admin-orders-title" > -
+

-
-
- - - - - - - - - - - - - - +
+
+
+ {vm.createdAt} +
+
+ + {vm.paymentStatus} + +
+
+ +
+ {vm.totalFormatted} +
+
+ +
+
+
Items
+
{vm.itemCount}
+
-
- {items.length === 0 ? ( +
+
Provider
+
+ {vm.paymentProvider} +
+
+ +
+
Order ID
+
+ {vm.id} +
+
+ + +
+ + View + +
+ + ))} + + )} + + + {/* Desktop table */} +
+
+
Orders list
- Created - - Status - + {/* Mobile cards */} +
+ {viewModels.length === 0 ? ( +
+ No orders yet. +
+ ) : ( +
    + {viewModels.map(vm => ( +
  • - Total -
- Items - - Provider - - Order ID - - Actions -
+ + + - + + + + + + + - ) : ( - items.map(order => { - const currency = orderCurrency(order, locale); - const totalMinor = pickMinor( - order?.totalAmountMinor, - order?.totalAmount - ); - const totalFormatted = - totalMinor === null - ? '-' - : formatMoney(totalMinor, currency, locale); - - return ( - - + + + {viewModels.length === 0 ? ( + + + + ) : ( + viewModels.map(vm => ( + + - - - - - - ); - }) - )} - -
Orders list
- No orders yet. - + Created + + Status + + Total + + Items + + Provider + + Order ID + + Actions +
- {formatDate(order.createdAt, locale)} +
+ No orders yet. +
+ {vm.createdAt} + - {order.paymentStatus} + {vm.paymentStatus} - {totalFormatted} + + {vm.totalFormatted} - {order.itemCount} + + {vm.itemCount} - {order.paymentProvider} + + + {vm.paymentProvider} - {order.id} + {vm.id} + View
+ )) + )} + + +
+
`( + exists ( + select 1 + from ${orderItems} oi + where oi.product_id = ${products.id} + ) + OR + exists ( + select 1 + from ${inventoryMoves} im + where im.product_id = ${products.id} + ) +)`; const all = await db .select({ @@ -51,6 +69,7 @@ export default async function AdminProductsPage({ isFeatured: products.isFeatured, createdAt: products.createdAt, priceMinor: productPrices.priceMinor, + isInUse: isInUseSql, }) .from(products) .leftJoin( @@ -60,14 +79,15 @@ export default async function AdminProductsPage({ eq(productPrices.currency, displayCurrency) ) ) - // stable sort with tie-breaker .orderBy(desc(products.createdAt), desc(products.id)) .limit(PAGE_SIZE + 1) .offset(offset); const hasNext = all.length > PAGE_SIZE; const rows = all.slice(0, PAGE_SIZE); + const csrfTokenStatus = issueCsrfToken('admin:products:status'); + const csrfTokenDelete = issueCsrfToken('admin:products:delete'); return ( <> @@ -77,7 +97,7 @@ export default async function AdminProductsPage({ className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8" aria-labelledby="admin-products-title" > -
+

New product

-
-
- - - - - - - - - - - - - - - - - - - - +
+ {/* Mobile cards */} +
+ {rows.length === 0 ? ( +
+ No products yet. +
+ ) : ( +
    {rows.map(row => { const priceMinor = row.priceMinor; + const badge = + row.badge == null || row.badge === 'NONE' ? '-' : row.badge; return ( -
- - + - +
+
+
Category
+
+ {row.category ?? '-'} +
+
-
- - +
+
Badge
+
{badge}
+
- +
+
Active
+
+ {row.isActive ? 'Yes' : 'No'} +
+
- +
+
Featured
+
+ {row.isFeatured ? 'Yes' : 'No'} +
+
- +
+
Created
+
+ {formatDate(row.createdAt, locale)} +
+
+ - +
+ + View + -
- + )} + + ); })} + + )} + + + {/* Desktop table */} +
+
+
Products list
- Title - - Slug - - Price - - Category - - Type - - Stock - - Badge - - Active - - Featured - - Created - - Actions -
-
- {row.title} +
  • +
    +
    +
    + {row.title} +
    +
    + {row.slug} +
    -
  • -
    - {row.slug} +
    + {priceMinor == null + ? '-' + : formatMoney(priceMinor, displayCurrency, locale)}
    -
    - {priceMinor === null - ? '-' - : formatMoney(priceMinor, displayCurrency, locale)} - -
    - {row.category ?? '-'} +
    +
    Type
    +
    + {row.type ?? '-'} +
    -
    -
    - {row.type ?? '-'} +
    +
    Stock
    +
    {row.stock}
    -
    - {row.stock} - - {row.badge == null || row.badge === 'NONE' - ? '-' - : row.badge} - - - {row.isActive ? 'Yes' : 'No'} - - - - {row.isFeatured ? 'Yes' : 'No'} - - - {formatDate(row.createdAt, locale)} - -
    - - View - + + Edit + - - Edit - + - -
    -
    + - {rows.length === 0 ? ( + - + Title + + + + + + + + + + + - ) : null} - -
    Products list
    - No products yet. - + Slug + + Price + + Category + + Type + + Stock + + Badge + + Active + + Featured + + Created + + Actions +
    + + + + {rows.map(row => { + const priceMinor = row.priceMinor; + + return ( + + +
    + {row.title} +
    + + + +
    + {row.slug} +
    + + + + {priceMinor == null + ? '-' + : formatMoney(priceMinor, displayCurrency, locale)} + + + +
    + {row.category ?? '-'} +
    + + + +
    + {row.type ?? '-'} +
    + + + + {row.stock} + + + + {row.badge == null || row.badge === 'NONE' + ? '-' + : row.badge} + + + + + {row.isActive ? 'Yes' : 'No'} + + + + + + {row.isFeatured ? 'Yes' : 'No'} + + + + + {formatDate(row.createdAt, locale)} + + + +
    + + View + + + + Edit + + + + + {row.isInUse ? null : ( + + )} +
    + + + ); + })} + + {rows.length === 0 ? ( + + + No products yet. + + + ) : null} + + +
    +
    } ) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_orders_refund_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + let orderIdForLog: string | null = null; try { await requireAdminApi(request); const csrfRes = requireAdminCsrf(request, 'admin:orders:refund'); - if (csrfRes) return csrfRes; + if (csrfRes) { + logWarn('admin_orders_refund_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } const rawParams = await context.params; const parsed = orderIdParamSchema.safeParse(rawParams); if (!parsed.success) { - return NextResponse.json( + logWarn('admin_orders_refund_invalid_order_id', { + ...baseMeta, + code: 'INVALID_ORDER_ID', + issuesCount: parsed.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Invalid order id', code: 'INVALID_ORDER_ID' }, { status: 400 } ); } - // app/api/shop/admin/orders/[id]/refund/route.ts - const order = await refundOrder(parsed.data.id, { requestedBy: 'admin' }); + orderIdForLog = parsed.data.id; + const order = await refundOrder(orderIdForLog, { requestedBy: 'admin' }); const orderSummary = orderSummarySchema.parse(order); - return NextResponse.json({ + return noStoreJson({ success: true, order: { ...orderSummary, - createdAt: orderSummary.createdAt.toISOString(), + createdAt: + orderSummary.createdAt instanceof Date + ? orderSummary.createdAt.toISOString() + : String(orderSummary.createdAt), }, }); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: 'ADMIN_API_DISABLED' }, { status: 403 }); + logWarn('admin_orders_refund_admin_api_disabled', { + ...baseMeta, + code: 'ADMIN_API_DISABLED', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_orders_refund_unauthorized', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); - } + logWarn('admin_orders_refund_forbidden', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); - logError('Refund order failed', error); + return noStoreJson({ code: error.code }, { status: 403 }); + } if (error instanceof OrderNotFoundError) { - return NextResponse.json( + 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) { - return NextResponse.json( + 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 } ); } - return NextResponse.json( + 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 } ); diff --git a/frontend/app/api/shop/admin/orders/[id]/route.ts b/frontend/app/api/shop/admin/orders/[id]/route.ts index 29c823a6..82b37feb 100644 --- a/frontend/app/api/shop/admin/orders/[id]/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/route.ts @@ -1,56 +1,143 @@ -import { NextRequest, NextResponse } from "next/server" +import crypto from 'node:crypto'; +import { NextRequest, NextResponse } from 'next/server'; import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, -} from "@/lib/auth/admin" +} from '@/lib/auth/admin'; -import { getAdminOrderDetail } from "@/db/queries/shop/admin-orders" -import { logError } from "@/lib/logging" -import { orderIdParamSchema } from "@/lib/validation/shop" +import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders'; + +import { logError, logWarn } from '@/lib/logging'; + +import { orderIdParamSchema } from '@/lib/validation/shop'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + // Origin posture: same-origin enforcement is applied to mutating methods; + // GET is intentionally unguarded. + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let orderIdForLog: string | null = null; -export async function GET(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { - await requireAdminApi(_request) + await requireAdminApi(request); + + // CSRF is enforced only for state-changing admin routes. + // This endpoint is read-only (GET), so we intentionally do not require CSRF. - const rawParams = await context.params - const parsed = orderIdParamSchema.safeParse(rawParams) + const rawParams = await context.params; + const parsed = orderIdParamSchema.safeParse(rawParams); if (!parsed.success) { - return NextResponse.json({ error: "Invalid order id", code: "INVALID_ORDER_ID" }, { status: 400 }) + logWarn('admin_order_detail_invalid_order_id', { + ...baseMeta, + code: 'INVALID_ORDER_ID', + issuesCount: parsed.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'Invalid order id', code: 'INVALID_ORDER_ID' }, + { status: 400 } + ); } - const order = await getAdminOrderDetail(parsed.data.id) + orderIdForLog = parsed.data.id; + + const order = await getAdminOrderDetail(orderIdForLog); + if (!order) { - return NextResponse.json({ error: "Order not found", code: "ORDER_NOT_FOUND" }, { status: 404 }) + logWarn('admin_order_detail_not_found', { + ...baseMeta, + code: 'ORDER_NOT_FOUND', + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'Order not found', code: 'ORDER_NOT_FOUND' }, + { status: 404 } + ); } - return NextResponse.json( + return noStoreJson( { success: true, order: { ...order, createdAt: order.createdAt.toISOString(), updatedAt: order.updatedAt.toISOString(), - restockedAt: order.restockedAt ? order.restockedAt.toISOString() : null, + restockedAt: order.restockedAt + ? order.restockedAt.toISOString() + : null, }, }, { status: 200 } - ) + ); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }) + logWarn('admin_order_detail_admin_api_disabled', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }) + logWarn('admin_order_detail_unauthorized', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }) + logWarn('admin_order_detail_forbidden', { + ...baseMeta, + code: error.code, + orderId: orderIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError("Admin order detail failed", error) - return NextResponse.json({ error: "internal_error", code: "INTERNAL_ERROR" }, { status: 500 }) + logError('admin_order_detail_failed', error, { + ...baseMeta, + orderId: orderIdForLog, + code: 'ADMIN_ORDER_DETAIL_FAILED', + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); } } diff --git a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts index 92fa5e5a..7c19087d 100644 --- a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts +++ b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts @@ -1,43 +1,89 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; - import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; - -import { logError } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; import { restockStalePendingOrders } from '@/lib/services/orders'; import { CSRF_FORM_FIELD, isSameOrigin, verifyCsrfToken, } from '@/lib/security/csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} const DEFAULT_STALE_MINUTES = 60; export async function POST(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + // NOTE: We intentionally keep TWO origin checks: + // 1) guardBrowserSameOrigin(): generic unsafe-request Origin allowlist gate + // (APP_ORIGIN/APP_ADDITIONAL_ORIGINS), fail-fast before auth/body parsing. + // 2) isSameOrigin(): CSRF-specific strict same-origin assertion, so CSRF origin mismatch + // is logged/coded separately. + // These checks are not equivalent and serve different error semantics (policy vs CSRF). + + const blocked = guardBrowserSameOrigin(request); + + if (blocked) { + logWarn('admin_reconcile_stale_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + try { // 1) Kill-switch + auth FIRST (no body parsing) await requireAdminApi(request); // 2) CSRF checks second (browser-only semantics) if (!isSameOrigin(request)) { - return NextResponse.json( - { code: 'CSRF_ORIGIN_MISMATCH' }, - { status: 403 } - ); + logWarn('admin_reconcile_stale_csrf_origin_mismatch', { + ...baseMeta, + code: 'CSRF_ORIGIN_MISMATCH', + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson({ code: 'CSRF_ORIGIN_MISMATCH' }, { status: 403 }); } let form: FormData; try { form = await request.formData(); - } catch { - return NextResponse.json( - { code: 'INVALID_REQUEST_BODY' }, - { status: 400 } - ); + } catch (error) { + logWarn('admin_reconcile_stale_invalid_body', { + ...baseMeta, + code: 'INVALID_REQUEST_BODY', + reason: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson({ code: 'INVALID_REQUEST_BODY' }, { status: 400 }); } const token = form.get(CSRF_FORM_FIELD); @@ -45,26 +91,61 @@ export async function POST(request: NextRequest) { typeof token !== 'string' || !verifyCsrfToken(token, 'admin:orders:reconcile-stale') ) { - return NextResponse.json({ code: 'CSRF_INVALID' }, { status: 403 }); + logWarn('admin_reconcile_stale_csrf_invalid', { + ...baseMeta, + code: 'CSRF_INVALID', + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson({ code: 'CSRF_INVALID' }, { status: 403 }); } const processed = await restockStalePendingOrders({ olderThanMinutes: DEFAULT_STALE_MINUTES, }); + logInfo('admin_reconcile_stale_succeeded', { + ...baseMeta, + code: 'OK', + processed, + olderThanMinutes: DEFAULT_STALE_MINUTES, + durationMs: Date.now() - startedAtMs, + }); - return NextResponse.json({ processed }); + return noStoreJson({ processed }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: 'ADMIN_API_DISABLED' }, { status: 403 }); + logWarn('admin_reconcile_stale_admin_api_disabled', { + ...baseMeta, + code: 'ADMIN_API_DISABLED', + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'ADMIN_API_DISABLED' }, { status: 403 }); } if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_reconcile_stale_unauthorized', { + ...baseMeta, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_reconcile_stale_forbidden', { + ...baseMeta, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError('Failed to reconcile stale orders', error); - return NextResponse.json({ error: 'internal_error' }, { status: 500 }); + logError('admin_reconcile_stale_failed', error, { + ...baseMeta, + code: 'ADMIN_RECONCILE_STALE_FAILED', + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); } } diff --git a/frontend/app/api/shop/admin/orders/route.ts b/frontend/app/api/shop/admin/orders/route.ts index f89c6eba..9b3d79f8 100644 --- a/frontend/app/api/shop/admin/orders/route.ts +++ b/frontend/app/api/shop/admin/orders/route.ts @@ -1,41 +1,99 @@ -import { NextRequest, NextResponse } from "next/server" -import { z } from "zod" +import crypto from 'node:crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, -} from "@/lib/auth/admin" +} from '@/lib/auth/admin'; -import { getAdminOrdersPage } from "@/db/queries/shop/admin-orders" -import { logError } from "@/lib/logging" +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; + +import { getAdminOrdersPage } from '@/db/queries/shop/admin-orders'; + +import { logError, logWarn } from '@/lib/logging'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} const querySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0), -}) +}); export async function GET(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_orders_list_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + try { - await requireAdminApi(request) + await requireAdminApi(request); + + const csrfRes = requireAdminCsrf(request, 'admin:orders:list'); + if (csrfRes) { + logWarn('admin_orders_list_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } - const url = new URL(request.url) const parsedQuery = querySchema.safeParse({ - limit: url.searchParams.get("limit") ?? undefined, - offset: url.searchParams.get("offset") ?? undefined, - }) + limit: request.nextUrl.searchParams.get('limit') ?? undefined, + offset: request.nextUrl.searchParams.get('offset') ?? undefined, + }); if (!parsedQuery.success) { - return NextResponse.json( - { error: "Invalid query", code: "INVALID_QUERY", details: parsedQuery.error.format() }, + logWarn('admin_orders_list_invalid_query', { + ...baseMeta, + code: 'INVALID_QUERY', + issuesCount: parsedQuery.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid query', + code: 'INVALID_QUERY', + details: parsedQuery.error.format(), + }, { status: 400 } - ) + ); } - const { items, total } = await getAdminOrdersPage(parsedQuery.data) + const { items, total } = await getAdminOrdersPage(parsedQuery.data); - return NextResponse.json( + return noStoreJson( { success: true, total, @@ -45,19 +103,44 @@ export async function GET(request: NextRequest) { })), }, { status: 200 } - ) + ); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }) + logWarn('admin_orders_list_admin_api_disabled', { + ...baseMeta, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }) + logWarn('admin_orders_list_unauthorized', { + ...baseMeta, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }) + logWarn('admin_orders_list_forbidden', { + ...baseMeta, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError("Admin orders list failed", error) - return NextResponse.json({ error: "internal_error", code: "INTERNAL_ERROR" }, { status: 500 }) + logError('admin_orders_list_failed', error, { + ...baseMeta, + code: 'ADMIN_ORDERS_LIST_FAILED', + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); } } diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index 78472581..a3dd66d9 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -1,28 +1,42 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; - +import { sql } from 'drizzle-orm'; import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; + import { InvalidPayloadError, SlugConflictError, PriceConfigError, } from '@/lib/services/errors'; + import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; -import { logError } from '@/lib/logging'; +import { logError, logWarn } from '@/lib/logging'; +import { db } from '@/db'; import { deleteProduct, getAdminProductByIdWithPrices, updateProduct, } from '@/lib/services/products'; +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + const productIdParamSchema = z.object({ id: z.string().uuid() }); + type SaleRuleViolation = { currency: string; field: 'originalPriceMinor'; @@ -34,29 +48,33 @@ type InvalidPricesJsonError = { field: 'prices'; }; -function isInvalidPricesJsonError( - value: SaleRuleViolation | InvalidPricesJsonError | null -): value is InvalidPricesJsonError { - if (!value || typeof value !== 'object') return false; - return (value as Record).code === 'INVALID_PRICES_JSON'; -} +function findSaleRuleViolation(input: unknown): SaleRuleViolation | null { + if (typeof input !== 'object' || input === null) return null; -function findSaleRuleViolation(input: any): SaleRuleViolation | null { - const badge = input?.badge; - if (badge !== 'SALE') return null; + const rec = input as Record; + if (rec.badge !== 'SALE') return null; + + const pricesUnknown = rec.prices; + const prices = Array.isArray(pricesUnknown) ? pricesUnknown : []; + + for (const rowUnknown of prices) { + if (typeof rowUnknown !== 'object' || rowUnknown === null) continue; + + const row = rowUnknown as Record; + + const currency = String(row.currency ?? ''); + const priceMinor = Number(row.priceMinor); + const originalPriceMinorRaw = row.originalPriceMinor; - const prices = Array.isArray(input?.prices) ? input.prices : []; - for (const row of prices) { - const currency = String(row?.currency ?? ''); - const priceMinor = Number(row?.priceMinor); const originalPriceMinor = - row?.originalPriceMinor == null ? null : Number(row.originalPriceMinor); + originalPriceMinorRaw == null ? null : Number(originalPriceMinorRaw); if (!currency || !Number.isFinite(priceMinor)) continue; if (originalPriceMinor == null) { return { currency, field: 'originalPriceMinor', rule: 'required' }; } + if ( !Number.isFinite(originalPriceMinor) || originalPriceMinor <= priceMinor @@ -72,9 +90,20 @@ function findSaleRuleViolation(input: any): SaleRuleViolation | null { return null; } -function getSaleViolationFromFormData( +function getIssuesCount(err: unknown): number { + if (err instanceof z.ZodError) return err.issues.length; + + if (typeof err === 'object' && err !== null) { + const issues = (err as Record).issues; + if (Array.isArray(issues)) return issues.length; + } + + return 0; +} + +function getInvalidPricesJsonErrorFromFormData( formData: FormData -): SaleRuleViolation | InvalidPricesJsonError | null { +): InvalidPricesJsonError | null { const badge = String(formData.get('badge') ?? ''); if (badge !== 'SALE') return null; @@ -82,59 +111,218 @@ function getSaleViolationFromFormData( if (typeof pricesRaw !== 'string' || !pricesRaw.trim()) return null; try { - const prices = JSON.parse(pricesRaw); - return findSaleRuleViolation({ badge, prices }); + JSON.parse(pricesRaw); + return null; } catch { return { code: 'INVALID_PRICES_JSON', field: 'prices' }; } } +function getPgMeta(err: unknown): { + code?: string; + constraint?: string; + detail?: string; +} { + const seen = new Set(); + let cur: unknown = err; + + for (let i = 0; i < 8; i++) { + if (cur == null || typeof cur !== 'object') break; + if (seen.has(cur)) break; + seen.add(cur); + + const rec = cur as Record; + const code = typeof rec.code === 'string' ? rec.code : undefined; + const constraint = + typeof rec.constraint === 'string' ? rec.constraint : undefined; + const detail = typeof rec.detail === 'string' ? rec.detail : undefined; + + if (code || constraint || detail) return { code, constraint, detail }; + + cur = rec.cause; + } + + return {}; +} + +async function getProductDeleteBlockerConstraint( + productId: string +): Promise { + const result = await db.execute(sql` + SELECT + CASE + WHEN EXISTS ( + SELECT 1 FROM order_items oi + WHERE oi.product_id = ${productId} + ) THEN 'order_items_product_id_products_id_fk' + WHEN EXISTS ( + SELECT 1 FROM inventory_moves im + WHERE im.product_id = ${productId} + ) THEN 'inventory_moves_product_id_products_id_fk' + ELSE NULL + END AS constraint; + `); + + const rows = + (result as unknown as { rows?: Array<{ constraint: string | null }> }) + .rows ?? []; + + return rows[0]?.constraint ?? null; +} + export async function GET( request: NextRequest, context: { params: Promise<{ id: string }> } ): Promise { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + // Origin posture: same-origin enforcement is applied to mutating methods; + // GET is intentionally unguarded. + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let productIdForLog: string | null = null; + try { await requireAdminApi(request); + + const csrfRes = requireAdminCsrf(request, 'admin:products:read'); + if (csrfRes) { + logWarn('admin_product_detail_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } + const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); if (!parsedParams.success) { - return NextResponse.json( - { error: 'Invalid product id', details: parsedParams.error.format() }, + logWarn('admin_product_detail_invalid_product_id', { + ...baseMeta, + code: 'INVALID_PRODUCT_ID', + issuesCount: getIssuesCount(parsedParams.error), + + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product id', + code: 'INVALID_PRODUCT_ID', + details: parsedParams.error.format(), + }, { status: 400 } ); } - const product = await getAdminProductByIdWithPrices(parsedParams.data.id); - return NextResponse.json({ product }); + productIdForLog = parsedParams.data.id; + + const product = await getAdminProductByIdWithPrices(productIdForLog); + + return noStoreJson({ product }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_detail_admin_api_disabled', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_product_detail_unauthorized', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_detail_forbidden', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError('Failed to load admin product', error); - if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') { - return NextResponse.json({ error: 'Product not found' }, { status: 404 }); + logWarn('admin_product_detail_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 } + ); } - return NextResponse.json( - { error: 'Failed to load product' }, + logError('admin_product_detail_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_DETAIL_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, { status: 500 } ); } } +type UpdateProductInput = Parameters[1]; + export async function PATCH( request: NextRequest, context: { params: Promise<{ id: string }> } ): Promise { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_product_update_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let productIdForLog: string | null = null; + try { await requireAdminApi(request); @@ -142,25 +330,69 @@ export async function PATCH( const parsedParams = productIdParamSchema.safeParse(rawParams); if (!parsedParams.success) { - return NextResponse.json( - { error: 'Invalid product id', details: parsedParams.error.format() }, + logWarn('admin_product_update_invalid_product_id', { + ...baseMeta, + code: 'INVALID_PRODUCT_ID', + issuesCount: getIssuesCount(parsedParams.error), + + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product id', + code: 'INVALID_PRODUCT_ID', + details: parsedParams.error.format(), + }, + { status: 400 } + ); + } + + productIdForLog = parsedParams.data.id; + + let formData: FormData; + try { + formData = await request.formData(); + } catch (err) { + logWarn('admin_product_update_invalid_body', { + ...baseMeta, + code: 'INVALID_REQUEST_BODY', + productId: productIdForLog, + reason: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'Invalid request body', code: 'INVALID_REQUEST_BODY' }, { status: 400 } ); } - const formData = await request.formData(); const csrfRes = requireAdminCsrf( request, 'admin:products:update', formData ); - if (csrfRes) return csrfRes; + if (csrfRes) { + logWarn('admin_product_update_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } - // PATCH inside PATCH() right after: const formData = await request.formData(); - const saleViolationFromForm = getSaleViolationFromFormData(formData); + if (getInvalidPricesJsonErrorFromFormData(formData)) { + logWarn('admin_product_update_invalid_prices_json', { + ...baseMeta, + code: 'INVALID_PRICES_JSON', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); - if (isInvalidPricesJsonError(saleViolationFromForm)) { - return NextResponse.json( + return noStoreJson( { error: 'Invalid prices JSON', code: 'INVALID_PRICES_JSON', @@ -170,39 +402,45 @@ export async function PATCH( ); } - if (saleViolationFromForm) { - const message = - saleViolationFromForm.rule === 'required' - ? 'SALE badge requires original price for each provided currency.' - : 'SALE badge requires original price to be greater than price.'; + const parsed = parseAdminProductForm(formData, { mode: 'update' }); + if (!parsed.ok) { + const issuesCount = getIssuesCount(parsed.error); + + logWarn('admin_product_update_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + productId: productIdForLog, + issuesCount, + durationMs: Date.now() - startedAtMs, + }); - return NextResponse.json( + return noStoreJson( { - error: message, - code: 'SALE_ORIGINAL_REQUIRED', - field: 'prices', - details: saleViolationFromForm, + error: 'Invalid product data', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), }, { status: 400 } ); } - - // 1) Parse/validate base fields via existing parser - const parsed = parseAdminProductForm(formData, { mode: 'update' }); - if (!parsed.ok) { - return NextResponse.json( - { error: 'Invalid product data', details: parsed.error.format() }, - { status: 400 } - ); - } - const saleViolation = findSaleRuleViolation(parsed.data as any); + // SALE invariant is validated on parsed/normalized payload (single source of truth). + const saleViolation = findSaleRuleViolation(parsed.data); if (saleViolation) { const message = saleViolation.rule === 'required' ? 'SALE badge requires original price for each provided currency.' : 'SALE badge requires original price to be greater than price.'; - return NextResponse.json( + logWarn('admin_product_update_sale_rule_violation', { + ...baseMeta, + code: 'SALE_ORIGINAL_REQUIRED', + productId: productIdForLog, + currency: saleViolation.currency, + rule: saleViolation.rule, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: message, code: 'SALE_ORIGINAL_REQUIRED', @@ -213,27 +451,32 @@ export async function PATCH( ); } - // 3) Update product try { const imageFile = formData.get('image'); - const updated = await updateProduct(parsedParams.data.id, { - ...(parsed.data as any), + const updated = await updateProduct(productIdForLog, { + ...(parsed.data as UpdateProductInput), image: imageFile instanceof File && imageFile.size > 0 ? imageFile : undefined, }); - return NextResponse.json({ success: true, product: updated }); + return noStoreJson({ success: true, product: updated }, { status: 200 }); } catch (error) { - logError('Failed to update product', error); - if (error instanceof PriceConfigError) { - return NextResponse.json( + logWarn('admin_product_update_price_config_error', { + ...baseMeta, + code: error.code, // PRICE_CONFIG_ERROR + productId: productIdForLog, + currency: error.currency, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: error.message, - code: error.code, // PRICE_CONFIG_ERROR + code: error.code, productId: error.productId, currency: error.currency, field: 'prices', @@ -243,28 +486,55 @@ export async function PATCH( } if (error instanceof InvalidPayloadError) { - const anyErr = error as any; - return NextResponse.json( + const rec = error as unknown as Record; + const code = + typeof rec.code === 'string' ? rec.code : 'INVALID_PAYLOAD'; + const field = typeof rec.field === 'string' ? rec.field : undefined; + const details = rec.details; + + logWarn('admin_product_update_invalid_payload_error', { + ...baseMeta, + code, + productId: productIdForLog, + field, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: error.message || 'Invalid product data', - code: anyErr.code, - field: anyErr.field, - details: anyErr.details, + code, + field, + details, }, { status: 400 } ); } if (error instanceof SlugConflictError) { - return NextResponse.json( + logWarn('admin_product_update_slug_conflict', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Slug already exists.', code: error.code, field: 'slug' }, { status: 409 } ); } if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') { - return NextResponse.json( - { error: 'Product not found' }, + logWarn('admin_product_update_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 } ); } @@ -273,7 +543,14 @@ export async function PATCH( error instanceof Error && error.message === 'Failed to upload image to Cloudinary' ) { - return NextResponse.json( + logWarn('admin_product_update_image_upload_failed', { + ...baseMeta, + code: 'IMAGE_UPLOAD_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', @@ -283,25 +560,58 @@ export async function PATCH( ); } - return NextResponse.json( - { error: 'Failed to update product' }, + logError('admin_product_update_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_UPDATE_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'Failed to update product', code: 'INTERNAL_ERROR' }, { status: 500 } ); } } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_update_admin_api_disabled', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_product_update_unauthorized', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_update_forbidden', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError('Admin PATCH /products/:id failed (outer)', error); - return NextResponse.json( - { error: 'Failed to process request' }, + logError('admin_product_update_outer_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_UPDATE_OUTER_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, { status: 500 } ); } @@ -311,42 +621,174 @@ export async function DELETE( request: NextRequest, context: { params: Promise<{ id: string }> } ): Promise { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_product_delete_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let productIdForLog: string | null = null; + try { await requireAdminApi(request); + + // DELETE can’t reliably carry FormData; CSRF is validated via header/cookie path inside requireAdminCsrf. const csrfRes = requireAdminCsrf(request, 'admin:products:delete'); - if (csrfRes) return csrfRes; + if (csrfRes) { + logWarn('admin_product_delete_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); if (!parsedParams.success) { - return NextResponse.json( - { error: 'Invalid product id', details: parsedParams.error.format() }, + logWarn('admin_product_delete_invalid_product_id', { + ...baseMeta, + code: 'INVALID_PRODUCT_ID', + issuesCount: getIssuesCount(parsedParams.error), + + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product id', + code: 'INVALID_PRODUCT_ID', + details: parsedParams.error.format(), + }, { status: 400 } ); } - await deleteProduct(parsedParams.data.id); - return NextResponse.json({ success: true }, { status: 200 }); + productIdForLog = parsedParams.data.id; + // Fail fast: do not attempt DELETE if product is referenced by orders. + const blockerConstraint = await getProductDeleteBlockerConstraint( + productIdForLog + ); + if (blockerConstraint) { + logWarn('admin_product_delete_in_use', { + ...baseMeta, + code: 'PRODUCT_IN_USE', + productId: productIdForLog, + constraint: blockerConstraint, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: + 'Product cannot be deleted because it is referenced by other records.', + code: 'PRODUCT_IN_USE', + constraint: blockerConstraint, + }, + { status: 409 } + ); + } + + await deleteProduct(productIdForLog); + + return noStoreJson({ success: true }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_delete_admin_api_disabled', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } + if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_product_delete_unauthorized', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } + if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_delete_forbidden', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError('Failed to delete product', error); - if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') { - return NextResponse.json({ error: 'Product not found' }, { status: 404 }); + logWarn('admin_product_delete_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 } + ); + } + const { code: pgCode, constraint } = getPgMeta(error); + + // Postgres: 23503 = foreign_key_violation + if (pgCode === '23503') { + logWarn('admin_product_delete_in_use', { + ...baseMeta, + code: 'PRODUCT_IN_USE', + productId: productIdForLog, + constraint, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: + 'Product cannot be deleted because it is referenced by other records.', + code: 'PRODUCT_IN_USE', + constraint, + }, + { status: 409 } + ); } - return NextResponse.json( - { error: 'Failed to delete product' }, + logError('admin_product_delete_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_DELETE_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, { status: 500 } ); } diff --git a/frontend/app/api/shop/admin/products/[id]/status/route.ts b/frontend/app/api/shop/admin/products/[id]/status/route.ts index 867b41be..4d7ccd9e 100644 --- a/frontend/app/api/shop/admin/products/[id]/status/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/status/route.ts @@ -1,5 +1,7 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; + import { AdminApiDisabledError, AdminForbiddenError, @@ -7,25 +9,76 @@ import { requireAdminApi, } from '@/lib/auth/admin'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; -import { logError } from '@/lib/logging'; +import { logError, logWarn } from '@/lib/logging'; +import { ProductNotFoundError } from '@/lib/errors/products'; import { toggleProductStatus } from '@/lib/services/products'; +export const runtime = 'nodejs'; + const productIdParamSchema = z.object({ id: z.string().uuid() }); +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} export async function PATCH( request: NextRequest, context: { params: Promise<{ id: string }> } ) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_product_status_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let productIdForLog: string | null = null; + try { await requireAdminApi(request); const csrfRes = requireAdminCsrf(request, 'admin:products:status'); - if (csrfRes) return csrfRes; + if (csrfRes) { + logWarn('admin_product_status_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } const rawParams = await context.params; const parsedParams = productIdParamSchema.safeParse(rawParams); + if (!parsedParams.success) { - return NextResponse.json( + logWarn('admin_product_status_invalid_product_id', { + ...baseMeta, + code: 'INVALID_PRODUCT_ID', + issuesCount: parsedParams.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Invalid product id', code: 'INVALID_PRODUCT_ID', @@ -35,27 +88,65 @@ export async function PATCH( ); } - const productId = parsedParams.data.id; + productIdForLog = parsedParams.data.id; + + const updated = await toggleProductStatus(productIdForLog); + + // no success log (avoid log noise); rely on response + metrics - const updated = await toggleProductStatus(productId); - return NextResponse.json({ success: true, product: updated }); + return noStoreJson({ success: true, product: updated }, { status: 200 }); } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_status_admin_api_disabled', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_product_status_unauthorized', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_status_forbidden', { + ...baseMeta, + code: error.code, + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - logError('Failed to update product status', error); - if (error instanceof Error && error.message === 'PRODUCT_NOT_FOUND') { - return NextResponse.json({ error: 'Product not found' }, { status: 404 }); + if (error instanceof ProductNotFoundError) { + 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 } + ); } - return NextResponse.json( - { error: 'Failed to update product status' }, + + logError('admin_product_status_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_STATUS_FAILED', + productId: productIdForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, { status: 500 } ); } diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 18499eeb..5cd78a01 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { @@ -7,12 +8,20 @@ import { requireAdminApi, } from '@/lib/auth/admin'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; -import { logError } from '@/lib/logging'; +import { logError, logWarn } from '@/lib/logging'; import { InvalidPayloadError, SlugConflictError } from '@/lib/services/errors'; import { createProduct } from '@/lib/services/products'; +export const runtime = 'nodejs'; +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + type SaleRuleViolation = { currency: string; field: 'originalPriceMinor'; @@ -79,20 +88,84 @@ function getSaleViolationFromFormData( } export async function POST(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_product_create_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let slugForLog: string | null = null; + try { await requireAdminApi(request); - const formData = await request.formData(); + let formData: FormData; + try { + formData = await request.formData(); + } catch (error) { + logWarn('admin_product_create_invalid_body', { + ...baseMeta, + code: 'INVALID_REQUEST_BODY', + reason: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'Invalid request body', code: 'INVALID_REQUEST_BODY' }, + { status: 400 } + ); + } + + const rawSlug = formData.get('slug'); + slugForLog = + typeof rawSlug === 'string' && rawSlug.trim().length > 0 + ? rawSlug.trim() + : null; + const csrfRes = requireAdminCsrf( request, 'admin:products:create', formData ); - if (csrfRes) return csrfRes; + if (csrfRes) { + logWarn('admin_product_create_csrf_rejected', { + ...baseMeta, + code: 'CSRF_REJECTED', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + csrfRes.headers.set('Cache-Control', 'no-store'); + return csrfRes; + } const imageFile = formData.get('image'); if (!(imageFile instanceof File) || imageFile.size === 0) { - return NextResponse.json( + logWarn('admin_product_create_image_required', { + ...baseMeta, + code: 'IMAGE_REQUIRED', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Image file is required', code: 'IMAGE_REQUIRED', @@ -101,9 +174,17 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + const saleViolationFromForm = getSaleViolationFromFormData(formData); if (isInvalidPricesJsonError(saleViolationFromForm)) { - return NextResponse.json( + logWarn('admin_product_create_invalid_prices_json', { + ...baseMeta, + code: 'INVALID_PRICES_JSON', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: 'Invalid prices JSON', code: 'INVALID_PRICES_JSON', @@ -119,7 +200,16 @@ export async function POST(request: NextRequest) { ? 'SALE badge requires original price for each provided currency.' : 'SALE badge requires original price to be greater than price.'; - return NextResponse.json( + logWarn('admin_product_create_sale_rule_violation', { + ...baseMeta, + code: 'SALE_ORIGINAL_REQUIRED', + slug: slugForLog, + currency: saleViolationFromForm.currency, + rule: saleViolationFromForm.rule, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: message, code: 'SALE_ORIGINAL_REQUIRED', @@ -129,14 +219,31 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + const parsed = parseAdminProductForm(formData, { mode: 'create' }); if (!parsed.ok) { - return NextResponse.json( - { error: 'Invalid product data', details: parsed.error.format() }, + const issuesCount = + ((parsed.error as any)?.issues?.length as number | undefined) ?? 0; + + logWarn('admin_product_create_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + slug: slugForLog, + issuesCount, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product data', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, { status: 400 } ); } + const saleViolation = findSaleRuleViolation(parsed.data as any); if (saleViolation) { const message = @@ -144,7 +251,22 @@ export async function POST(request: NextRequest) { ? 'SALE badge requires original price for each provided currency.' : 'SALE badge requires original price to be greater than price.'; - return NextResponse.json( + const parsedSlug = + typeof (parsed.data as any)?.slug === 'string' && + (parsed.data as any).slug.trim().length > 0 + ? (parsed.data as any).slug.trim() + : null; + + logWarn('admin_product_create_sale_rule_violation', { + ...baseMeta, + code: 'SALE_ORIGINAL_REQUIRED', + slug: parsedSlug ?? slugForLog, + currency: saleViolation.currency, + rule: saleViolation.rule, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { error: message, code: 'SALE_ORIGINAL_REQUIRED', @@ -160,7 +282,7 @@ export async function POST(request: NextRequest) { ...parsed.data, image: imageFile, }); - return NextResponse.json( + return noStoreJson( { success: true, product: inserted, @@ -168,11 +290,48 @@ export async function POST(request: NextRequest) { { status: 201 } ); } catch (error) { - logError('Failed to create product', error); + const parsedSlug = + typeof (parsed.data as any)?.slug === 'string' && + (parsed.data as any).slug.trim().length > 0 + ? (parsed.data as any).slug.trim() + : null; + + const errCode = + error instanceof InvalidPayloadError + ? (error as any).code ?? 'INVALID_PAYLOAD' + : error instanceof SlugConflictError + ? error.code + : error instanceof Error && + error.message === 'Failed to upload image to Cloudinary' + ? 'IMAGE_UPLOAD_FAILED' + : 'ADMIN_PRODUCT_CREATE_FAILED'; + + const isExpected = + error instanceof InvalidPayloadError || + error instanceof SlugConflictError || + (error instanceof Error && + error.message === 'Failed to upload image to Cloudinary'); + + if (isExpected) { + logWarn('admin_product_create_failed', { + ...baseMeta, + code: errCode, + slug: parsedSlug ?? slugForLog, + reason: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startedAtMs, + }); + } else { + logError('admin_product_create_failed', error, { + ...baseMeta, + code: errCode, + slug: parsedSlug ?? slugForLog, + durationMs: Date.now() - startedAtMs, + }); + } if (error instanceof InvalidPayloadError) { const anyErr = error as any; - return NextResponse.json( + return noStoreJson( { error: error.message || 'Invalid product data', code: anyErr.code, @@ -184,7 +343,7 @@ export async function POST(request: NextRequest) { } if (error instanceof SlugConflictError) { - return NextResponse.json( + return noStoreJson( { error: 'Slug already exists.', code: error.code, field: 'slug' }, { status: 409 } ); @@ -194,7 +353,7 @@ export async function POST(request: NextRequest) { error instanceof Error && error.message === 'Failed to upload image to Cloudinary' ) { - return NextResponse.json( + return noStoreJson( { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', @@ -204,21 +363,50 @@ export async function POST(request: NextRequest) { ); } - return NextResponse.json( - { error: 'Failed to create product' }, + return noStoreJson( + { error: 'Failed to create product', code: 'INTERNAL_ERROR' }, { status: 500 } ); } } catch (error) { if (error instanceof AdminApiDisabledError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_create_admin_api_disabled', { + ...baseMeta, + code: error.code, + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } if (error instanceof AdminUnauthorizedError) { - return NextResponse.json({ code: error.code }, { status: 401 }); + logWarn('admin_product_create_unauthorized', { + ...baseMeta, + code: error.code, + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 401 }); } if (error instanceof AdminForbiddenError) { - return NextResponse.json({ code: error.code }, { status: 403 }); + logWarn('admin_product_create_forbidden', { + ...baseMeta, + code: error.code, + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: error.code }, { status: 403 }); } - throw error; + + logError('admin_product_create_outer_failed', error, { + ...baseMeta, + code: 'ADMIN_PRODUCT_CREATE_OUTER_FAILED', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); } } diff --git a/frontend/app/api/shop/cart/rehydrate/route.ts b/frontend/app/api/shop/cart/rehydrate/route.ts index 6470c300..45484f3c 100644 --- a/frontend/app/api/shop/cart/rehydrate/route.ts +++ b/frontend/app/api/shop/cart/rehydrate/route.ts @@ -1,12 +1,11 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; - import { MoneyValueError } from '@/db/queries/shop/orders'; import { resolveLocaleAndCurrency } from '@/lib/shop/request-locale'; - import { rehydrateCartItems } from '@/lib/services/products'; import { cartRehydratePayloadSchema } from '@/lib/validation/shop'; import { InvalidPayloadError, PriceConfigError } from '@/lib/services/errors'; -import { logError } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; function normalizeCartPayload(body: unknown) { if (!body || typeof body !== 'object') return body; @@ -20,8 +19,14 @@ function normalizeCartPayload(body: unknown) { if (!item || typeof item !== 'object') return item; const { quantity, ...itemRest } = item as { quantity?: unknown }; const normalizedQuantity = - typeof quantity === 'string' && quantity.trim().length > 0 - ? Number(quantity) + typeof quantity === 'string' + ? (() => { + const t = quantity.trim(); + if (!t) return quantity; + if (!/^\d+$/.test(t)) return quantity; + const n = Number.parseInt(t, 10); + return Number.isSafeInteger(n) ? n : quantity; + })() : quantity; return { ...itemRest, quantity: normalizedQuantity }; @@ -42,11 +47,34 @@ function jsonError( } export async function POST(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + const { currency } = resolveLocaleAndCurrency(request); + + const meta = { + ...baseMeta, + currency, + }; + let body: unknown; try { body = await request.json(); - } catch { + } catch (error) { + logWarn('cart_rehydrate_payload_parse_failed', { + ...meta, + code: 'INVALID_PAYLOAD', + reason: error instanceof Error ? error.message : String(error), + }); + return jsonError(400, 'INVALID_PAYLOAD', 'Unable to process cart data.'); } @@ -54,35 +82,72 @@ export async function POST(request: NextRequest) { const parsedPayload = cartRehydratePayloadSchema.safeParse(normalizedBody); if (!parsedPayload.success) { + logWarn('cart_rehydrate_invalid_payload', { + ...meta, + code: 'INVALID_PAYLOAD', + issuesCount: parsedPayload.error.issues?.length ?? 0, + }); + return jsonError(400, 'INVALID_PAYLOAD', 'Invalid cart payload', { issues: parsedPayload.error.format(), }); } - const { currency } = resolveLocaleAndCurrency(request); - try { const { items } = parsedPayload.data; const parsedResult = await rehydrateCartItems(items, currency); + + // Success signal (avoid noise on empty carts) + if (Array.isArray(items) && items.length > 0) { + logInfo('cart_rehydrate_completed', { + ...meta, + code: 'OK', + itemsCount: items.length, + durationMs: Date.now() - startedAtMs, + }); + } + return NextResponse.json(parsedResult); } catch (error) { - logError('cart_rehydrate_failed', error); - - // Missing price for locale currency is a CONTRACT error, not a 422. + // Missing price for locale currency is a CONTRACT error (4xx), but must be traceable. if (error instanceof PriceConfigError) { + logWarn('cart_rehydrate_price_config_error', { + ...meta, + code: error.code, + productId: error.productId, + currency: error.currency, + }); + return jsonError(400, error.code, error.message, { productId: error.productId, currency: error.currency, }); } - // DB misconfiguration / invalid stored money: treat as 500 (server fault), - // but keep stable code for diagnostics. + // Client/business rejection (4xx) must be traceable as warn (not error). + if (error instanceof InvalidPayloadError) { + logWarn('cart_rehydrate_rejected', { + ...meta, + code: error.code, + }); + + return jsonError(400, error.code, error.message); + } + + // DB misconfiguration / invalid stored money => 500 with stable code. if (error instanceof MoneyValueError) { + logError('cart_rehydrate_price_data_error', error, { + ...meta, + code: 'PRICE_DATA_ERROR', + productId: error.productId, + field: error.field, + rawValue: error.rawValue, + }); + return jsonError( 500, - 'PRICE_CONFIG_ERROR', - 'Invalid price configuration for one or more products.', + 'PRICE_DATA_ERROR', + 'Invalid stored price data for one or more products.', { productId: error.productId, field: error.field, @@ -91,9 +156,10 @@ export async function POST(request: NextRequest) { ); } - if (error instanceof InvalidPayloadError) { - return jsonError(400, error.code, error.message); - } + logError('cart_rehydrate_failed', error, { + ...meta, + code: 'CART_REHYDRATE_FAILED', + }); return jsonError(500, 'INTERNAL_ERROR', 'Unable to rehydrate cart.'); } diff --git a/frontend/app/api/shop/catalog/route.ts b/frontend/app/api/shop/catalog/route.ts index 5d47fb42..17ef1cbb 100644 --- a/frontend/app/api/shop/catalog/route.ts +++ b/frontend/app/api/shop/catalog/route.ts @@ -1,13 +1,21 @@ +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { getCatalogProducts } from '@/lib/shop/data'; import { catalogQuerySchema } from '@/lib/validation/shop'; import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; -import { logWarn } from '@/lib/logging'; +import { logError, logWarn } from '@/lib/logging'; +export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const revalidate = 0; +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + type RawSearchParams = { category?: string; type?: string; @@ -19,19 +27,40 @@ type RawSearchParams = { locale?: string; }; -export async function GET(req: NextRequest) { - const url = new URL(req.url); - const raw = Object.fromEntries(url.searchParams.entries()) as RawSearchParams; +function normalizeLocale(input: unknown): 'en' | 'uk' { + const raw = typeof input === 'string' ? input.trim().toLowerCase() : ''; + if (raw === 'uk' || raw.startsWith('uk-')) return 'uk'; + return 'en'; +} + +export async function GET(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const raw = Object.fromEntries( + request.nextUrl.searchParams.entries() + ) as RawSearchParams; const { locale, ...rest } = raw; - const effectiveLocale = locale ?? 'en'; + const effectiveLocale = normalizeLocale(locale); const parsed = catalogQuerySchema.safeParse(rest); if (!parsed.success) { - logWarn('[shop.catalog] invalid query params; using defaults', { - query: rest, - issues: parsed.error.flatten(), + logWarn('shop_catalog_invalid_query', { + ...baseMeta, + code: 'INVALID_QUERY', + locale: effectiveLocale, + issuesCount: parsed.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, }); } @@ -39,9 +68,20 @@ export async function GET(req: NextRequest) { ? parsed.data : { page: 1, limit: CATALOG_PAGE_SIZE }; - const catalog = await getCatalogProducts(filters, effectiveLocale); + try { + const catalog = await getCatalogProducts(filters, effectiveLocale); + return noStoreJson(catalog, { status: 200 }); + } catch (error) { + logError('shop_catalog_failed', error, { + ...baseMeta, + code: 'SHOP_CATALOG_FAILED', + locale: effectiveLocale, + durationMs: Date.now() - startedAtMs, + }); - return NextResponse.json(catalog, { - headers: { 'Cache-Control': 'no-store' }, - }); + return noStoreJson( + { error: 'internal_error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } } diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index c7dffe4e..37b06916 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'node:crypto'; import { enforceRateLimit, getRateLimitSubject, @@ -10,6 +11,7 @@ import { logError, logWarn } from '@/lib/logging'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { IdempotencyConflictError } from '@/lib/services/errors'; import { MoneyValueError } from '@/db/queries/shop/orders'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { InsufficientStockError, InvalidPayloadError, @@ -64,7 +66,7 @@ function errorResponse( status: number, details?: unknown ) { - return NextResponse.json( + const res = NextResponse.json( { code, message, @@ -72,6 +74,9 @@ function errorResponse( }, { status } ); + + res.headers.set('Cache-Control', 'no-store'); + return res; } function getIdempotencyKey(request: NextRequest) { @@ -104,7 +109,7 @@ function buildCheckoutResponse({ clientSecret: string | null; status: number; }) { - return NextResponse.json( + const res = NextResponse.json( { success: true, order: { @@ -125,6 +130,9 @@ function buildCheckoutResponse({ }, { status } ); + + res.headers.set('Cache-Control', 'no-store'); + return res; } function getSessionUserId(user: unknown): string | null { @@ -153,14 +161,34 @@ async function readJsonBody(request: NextRequest): Promise { } export async function POST(request: NextRequest) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('checkout_origin_blocked', { ...baseMeta, code: 'ORIGIN_BLOCKED' }); + + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + let body: unknown; try { body = await readJsonBody(request); } catch (error) { - logWarn('Failed to parse cart payload', { + logWarn('checkout_payload_parse_failed', { + ...baseMeta, + code: 'INVALID_PAYLOAD', reason: error instanceof Error ? error.message : String(error), }); + return errorResponse( 'INVALID_PAYLOAD', 'Unable to process cart data.', @@ -171,6 +199,11 @@ export async function POST(request: NextRequest) { const idempotencyKey = getIdempotencyKey(request); if (idempotencyKey === null) { + logWarn('checkout_missing_idempotency_key', { + ...baseMeta, + code: 'MISSING_IDEMPOTENCY_KEY', + }); + return errorResponse( 'MISSING_IDEMPOTENCY_KEY', 'Idempotency-Key header is required.', @@ -179,6 +212,11 @@ export async function POST(request: NextRequest) { } if (idempotencyKey instanceof Error) { + logWarn('checkout_invalid_idempotency_key', { + ...baseMeta, + code: 'INVALID_IDEMPOTENCY_KEY', + }); + return errorResponse( 'INVALID_IDEMPOTENCY_KEY', 'Idempotency key must be 16-128 chars and contain only A-Z a-z 0-9 _ -.', @@ -186,13 +224,24 @@ export async function POST(request: NextRequest) { idempotencyKey.format?.() ); } + // For observability: shorten to avoid oversized structured logs. + // Never used as an idempotency key; the full header value remains canonical. + const idempotencyKeyShort = idempotencyKey.slice(0, 32); + + const meta = { + ...baseMeta, + idempotencyKey: idempotencyKeyShort, + }; const parsedPayload = checkoutPayloadSchema.safeParse(body); if (!parsedPayload.success) { - logWarn('Invalid checkout payload', { + logWarn('checkout_invalid_payload', { + ...meta, + code: 'INVALID_PAYLOAD', issuesCount: parsedPayload.error.issues?.length ?? 0, }); + return errorResponse( 'INVALID_PAYLOAD', 'Invalid checkout payload', @@ -209,21 +258,40 @@ export async function POST(request: NextRequest) { try { currentUser = await getCurrentUser(); } catch (error) { - logError('Failed to resolve current user', error); + logError('checkout_auth_user_resolve_failed', error, { + ...meta, + code: 'AUTH_USER_RESOLVE_FAILED', + }); + currentUser = null; } const sessionUserId = getSessionUserId(currentUser); + const authMeta = { + ...meta, + sessionUserId, + }; if (userId) { if (!sessionUserId) { + logWarn('checkout_user_id_not_allowed', { + ...authMeta, + code: 'USER_ID_NOT_ALLOWED', + }); + return errorResponse( 'USER_ID_NOT_ALLOWED', 'userId is not allowed for guest checkout.', 400 ); } + if (userId !== sessionUserId) { + logWarn('checkout_user_mismatch', { + ...authMeta, + code: 'USER_MISMATCH', + }); + return errorResponse( 'USER_MISMATCH', 'Authenticated user does not match payload userId.', @@ -256,6 +324,12 @@ export async function POST(request: NextRequest) { }); if (!decision.ok) { + logWarn('checkout_rate_limited', { + ...authMeta, + code: 'RATE_LIMITED', + retryAfterSeconds: decision.retryAfterSeconds, + }); + return rateLimitResponse({ retryAfterSeconds: decision.retryAfterSeconds, details: { scope: 'checkout' }, @@ -271,6 +345,13 @@ export async function POST(request: NextRequest) { }); const { order } = result; + const orderMeta = { + ...authMeta, + orderId: order.id, + paymentProvider: order.paymentProvider, + paymentStatus: order.paymentStatus, + paymentIntentId: order.paymentIntentId ?? null, + }; const paymentsEnabled = isPaymentsEnabled(); @@ -279,6 +360,11 @@ export async function POST(request: NextRequest) { order.paymentProvider === 'none' && order.paymentStatus === 'failed' ) { + logWarn('checkout_failed', { + ...orderMeta, + code: 'CHECKOUT_FAILED', + }); + return errorResponse( 'CHECKOUT_FAILED', 'Order could not be completed.', @@ -293,6 +379,11 @@ export async function POST(request: NextRequest) { order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid' ) { + logWarn('checkout_payments_disabled_requires_payment', { + ...orderMeta, + code: 'PAYMENTS_DISABLED', + }); + return errorResponse( 'PAYMENTS_DISABLED', 'Payments are disabled. This order requires payment and cannot be processed.', @@ -307,13 +398,14 @@ export async function POST(request: NextRequest) { order.paymentIntentId ) { logError( - `Payments disabled but order is not paid/none. orderId=${ - order.id - } provider=${order.paymentProvider} status=${ - order.paymentStatus - } intent=${order.paymentIntentId ?? 'null'}`, - new Error('ORDER_STATE_INVALID') + 'checkout_order_state_invalid', + new Error('ORDER_STATE_INVALID'), + { + ...orderMeta, + code: 'ORDER_STATE_INVALID', + } ); + return errorResponse( 'ORDER_STATE_INVALID', 'Order state is invalid for payments disabled.', @@ -356,11 +448,18 @@ export async function POST(request: NextRequest) { try { await restockOrder(order.id, { reason: 'failed' }); } catch (restockError) { - logError( - 'Restoring stock after attempts exhausted failed', - restockError - ); + logError('checkout_restock_failed', restockError, { + ...orderMeta, + code: 'RESTOCK_FAILED', + reason: 'attempts_exhausted', + }); } + logWarn('checkout_payment_attempts_exhausted', { + ...orderMeta, + code: 'PAYMENT_ATTEMPTS_EXHAUSTED', + provider: error.provider, + }); + return errorResponse( 'PAYMENT_ATTEMPTS_EXHAUSTED', 'Payment attempts exhausted for this order.', @@ -371,6 +470,12 @@ export async function POST(request: NextRequest) { // Post-create/state conflict must be 409 (not 502) if (error instanceof InvalidPayloadError) { + logWarn('checkout_conflict', { + ...orderMeta, + code: 'CHECKOUT_CONFLICT', + reason: 'payment_init_state_conflict', + }); + return errorResponse( 'CHECKOUT_CONFLICT', 'Order state conflict while initializing payment. Retry with the same Idempotency-Key.', @@ -380,13 +485,22 @@ export async function POST(request: NextRequest) { } if (error instanceof OrderStateInvalidError) { + logError('checkout_order_state_invalid', error, { + ...orderMeta, + code: error.code, + }); + return errorResponse(error.code, error.message, 500, { orderId: error.orderId, ...(error.details ? { details: error.details } : {}), }); } - logError('Checkout payment initialization failed', error); + logError('checkout_payment_init_failed', error, { + ...orderMeta, + code: 'STRIPE_ERROR', + }); + return errorResponse( 'STRIPE_ERROR', 'Unable to initiate payment.', @@ -453,6 +567,12 @@ export async function POST(request: NextRequest) { } catch (error) { // Conflict => 409 and DO NOT restock (leave reserved; retry/janitor) if (error instanceof InvalidPayloadError) { + logWarn('checkout_conflict', { + ...orderMeta, + code: 'CHECKOUT_CONFLICT', + reason: 'payment_init_state_conflict', + }); + return errorResponse( 'CHECKOUT_CONFLICT', 'Order state conflict while initializing payment. Retry with the same Idempotency-Key.', @@ -465,11 +585,18 @@ export async function POST(request: NextRequest) { try { await restockOrder(order.id, { reason: 'failed' }); } catch (restockError) { - logError( - 'Restoring stock after attempts exhausted failed', - restockError - ); + logError('checkout_restock_failed', restockError, { + ...orderMeta, + code: 'RESTOCK_FAILED', + reason: 'attempts_exhausted', + }); } + logWarn('checkout_payment_attempts_exhausted', { + ...orderMeta, + code: 'PAYMENT_ATTEMPTS_EXHAUSTED', + provider: error.provider, + }); + return errorResponse( 'PAYMENT_ATTEMPTS_EXHAUSTED', 'Payment attempts exhausted for this order.', @@ -478,17 +605,26 @@ export async function POST(request: NextRequest) { ); } - logError('Checkout payment initialization failed', error); + logError('checkout_payment_init_failed', error, { + ...orderMeta, + code: 'STRIPE_ERROR', + }); try { await restockOrder(order.id, { reason: 'failed' }); } catch (restockError) { - logError( - 'Restoring stock after payment init failure failed', - restockError - ); + logError('checkout_restock_failed', restockError, { + ...orderMeta, + code: 'RESTOCK_FAILED', + reason: 'payment_init_failure', + }); } if (error instanceof OrderStateInvalidError) { + logError('checkout_order_state_invalid', error, { + ...orderMeta, + code: error.code, + }); + return errorResponse(error.code, error.message, 500, { orderId: error.orderId, ...(error.details ? { details: error.details } : {}), @@ -497,13 +633,23 @@ export async function POST(request: NextRequest) { return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); } } catch (error) { + const errorOrderId = + typeof (error as any)?.orderId === 'string' + ? (error as any).orderId + : null; + if (isExpectedBusinessError(error)) { - logWarn('Checkout rejected', { + logWarn('checkout_business_rejected', { + ...authMeta, code: getErrorCode(error) ?? 'UNKNOWN', - path: request.nextUrl.pathname, + orderId: errorOrderId, }); } else { - logError('Checkout failed', error); + logError('checkout_failed', error, { + ...authMeta, + code: getErrorCode(error) ?? 'INTERNAL_ERROR', + orderId: errorOrderId, + }); } if (error instanceof InvalidPayloadError) { diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index e37855ba..d8ae44f3 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -1,6 +1,5 @@ -//app\api\shop\internal\orders\restock-stale\route.ts +import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; -import crypto from 'crypto'; import { sql } from 'drizzle-orm'; import { db } from '@/db'; import { @@ -8,9 +7,9 @@ import { restockStaleNoPaymentOrders, restockStuckReservingOrders, } from '@/lib/services/orders'; - import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; -import { logError } from '@/lib/logging'; +import { logError, logInfo, logWarn } from '@/lib/logging'; +import { guardNonBrowserOnly } from '@/lib/security/origin'; export const runtime = 'nodejs'; @@ -261,18 +260,60 @@ async function acquireJobSlot(params: { } export async function POST(request: NextRequest) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + jobName: 'restock-stale', + }; + + const blocked = guardNonBrowserOnly(request); + if (blocked) { + logWarn('internal_janitor_origin_blocked', { + ...baseMeta, + code: 'ORIGIN_BLOCKED', + }); + return blocked; + } + const authRes = requireInternalJanitorAuth(request); - if (authRes) return authRes; + if (authRes) { + const status = + (authRes as any).status ?? (authRes as any).statusCode ?? 401; + + logWarn('internal_janitor_auth_rejected', { + ...baseMeta, + code: String(status), + status, + }); + + return authRes; + } let body: unknown = null; try { body = await request.json(); - } catch { + } catch (error) { + logWarn('internal_janitor_payload_parse_failed', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + reason: error instanceof Error ? error.message : String(error), + }); // ignore invalid/missing json body; query params may be used instead } const batchSizeParsed = parseBatchSize(request, body); if (typeof batchSizeParsed === 'object' && 'error' in batchSizeParsed) { + logWarn('internal_janitor_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + field: 'batchSize', + reason: batchSizeParsed.error, + }); + return NextResponse.json( { success: false, @@ -285,6 +326,13 @@ export async function POST(request: NextRequest) { const olderThanParsed = parseOlderThanPolicy(request, body); if (typeof olderThanParsed === 'object' && 'error' in olderThanParsed) { + logWarn('internal_janitor_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + field: 'olderThanMinutes', + reason: olderThanParsed.error, + }); + return NextResponse.json( { success: false, @@ -297,6 +345,13 @@ export async function POST(request: NextRequest) { const maxRuntimeParsed = parseMaxRuntimeMs(request, body); if (typeof maxRuntimeParsed === 'object' && 'error' in maxRuntimeParsed) { + logWarn('internal_janitor_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + field: 'maxRuntimeMs', + reason: maxRuntimeParsed.error, + }); + return NextResponse.json( { success: false, @@ -315,6 +370,13 @@ export async function POST(request: NextRequest) { typeof requestedMinIntervalParsed === 'object' && 'error' in requestedMinIntervalParsed ) { + logWarn('internal_janitor_invalid_payload', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + field: 'minIntervalSeconds', + reason: requestedMinIntervalParsed.error, + }); + return NextResponse.json( { success: false, @@ -342,7 +404,8 @@ export async function POST(request: NextRequest) { // DB-backed limiter: cross-instance, atomic const runId = crypto.randomUUID(); - const jobName = 'restock-stale'; + const jobName = baseMeta.jobName; + const workerId = `janitor:${runId}`; const gate = await acquireJobSlot({ jobName, @@ -357,6 +420,14 @@ export async function POST(request: NextRequest) { Math.ceil((gate.nextAllowedAt.getTime() - Date.now()) / 1000) ) : Math.max(1, minIntervalSeconds); + logWarn('internal_janitor_rate_limited', { + ...baseMeta, + code: 'RATE_LIMITED', + runId, + workerId, + retryAfterSeconds, + minIntervalSeconds, + }); const res = NextResponse.json( { @@ -370,11 +441,9 @@ export async function POST(request: NextRequest) { res.headers.set('Cache-Control', 'no-store'); return res; } - - const workerId = `janitor:${runId}`; - try { - const deadlineMs = Date.now() + maxRuntimeMs; + const startedAtMs = Date.now(); + const deadlineMs = startedAtMs + maxRuntimeMs; // 1) stuck reserving (timeout on inventory reservation phase) const remaining0 = Math.max(0, deadlineMs - Date.now()); @@ -407,6 +476,25 @@ export async function POST(request: NextRequest) { processedStalePending + processedOrphanNoPayment; + logInfo('internal_janitor_run_completed', { + ...baseMeta, + code: 'OK', + runId, + jobName, + workerId, + processed, + processedByCategory: { + stuckReserving: processedStuckReserving, + stalePending: processedStalePending, + orphanNoPayment: processedOrphanNoPayment, + }, + batchSize: policy.batchSize, + appliedPolicy: policy, + maxRuntimeMs, + minIntervalSeconds, + runtimeMs: Date.now() - startedAtMs, + }); + return NextResponse.json({ success: true, runId, @@ -423,7 +511,14 @@ export async function POST(request: NextRequest) { minIntervalSeconds, }); } catch (e) { - logError('restock_stale_failed', e, { runId }); + logError('internal_janitor_restock_stale_failed', e, { + ...baseMeta, + code: 'JANITOR_RESTOCK_STALE_FAILED', + runId, + jobName, + workerId, + }); + return NextResponse.json( { success: false, code: 'INTERNAL_ERROR' }, { status: 500 } diff --git a/frontend/app/api/shop/orders/[id]/route.ts b/frontend/app/api/shop/orders/[id]/route.ts index 4d707e89..d86b6e1d 100644 --- a/frontend/app/api/shop/orders/[id]/route.ts +++ b/frontend/app/api/shop/orders/[id]/route.ts @@ -1,13 +1,12 @@ import 'server-only'; - +import crypto from 'node:crypto'; +import { orderIdParamSchema } from '@/lib/validation/shop'; +import { logError, logWarn } from '@/lib/logging'; import { NextRequest, NextResponse } from 'next/server'; import { and, eq } from 'drizzle-orm'; - import { db } from '@/db'; import { orderItems, orders } from '@/db/schema'; import { getCurrentUser } from '@/lib/auth'; -import { orderIdParamSchema } from '@/lib/validation/shop'; -import { logError } from '@/lib/logging'; export const dynamic = 'force-dynamic'; @@ -16,7 +15,7 @@ function noStoreJson(body: unknown, init?: { status?: number }) { res.headers.set('Cache-Control', 'no-store'); return res; } -type OrderCurrency = (typeof orders.$inferSelect)["currency"]; +type OrderCurrency = (typeof orders.$inferSelect)['currency']; type OrderDetailResponse = { id: string; userId: string | null; @@ -47,7 +46,6 @@ type OrderDetailResponse = { }>; }; - function toOrderItem( item: { id: string | null; @@ -84,12 +82,31 @@ function toOrderItem( } export async function GET( - _request: NextRequest, + request: NextRequest, context: { params: Promise<{ id: string }> } ) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let orderIdForLog: string | null = null; + try { const user = await getCurrentUser(); if (!user) { + logWarn('public_order_detail_unauthorized', { + ...baseMeta, + code: 'UNAUTHORIZED', + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( { code: 'UNAUTHORIZED', error: 'Authentication required' }, { status: 401 } @@ -99,17 +116,26 @@ export async function GET( const rawParams = await context.params; const parsed = orderIdParamSchema.safeParse(rawParams); if (!parsed.success) { + logWarn('public_order_detail_invalid_order_id', { + ...baseMeta, + code: 'INVALID_ORDER_ID', + issuesCount: parsed.error.issues?.length ?? 0, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson( { code: 'INVALID_ORDER_ID', error: 'Invalid order id' }, { status: 400 } ); } + orderIdForLog = parsed.data.id; + const isAdmin = user.role === 'admin'; const whereClause = isAdmin - ? eq(orders.id, parsed.data.id) - : and(eq(orders.id, parsed.data.id), eq(orders.userId, user.id)); + ? eq(orders.id, orderIdForLog) + : and(eq(orders.id, orderIdForLog), eq(orders.userId, user.id)); const rows = await db .select({ @@ -140,10 +166,17 @@ export async function GET( }) .from(orders) .leftJoin(orderItems, eq(orderItems.orderId, orders.id)) - .where(whereClause) - + .where(whereClause); if (rows.length === 0) { + logWarn('public_order_detail_not_found', { + ...baseMeta, + code: 'ORDER_NOT_FOUND', + orderId: orderIdForLog, + isAdmin, + durationMs: Date.now() - startedAtMs, + }); + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, { status: 404 }); } @@ -163,7 +196,14 @@ export async function GET( return noStoreJson({ success: true, order: response }, { status: 200 }); } catch (error) { - logError('Public order detail failed', error); + logError('public_order_detail_failed', error, { + ...baseMeta, + orderId: orderIdForLog, + code: 'PUBLIC_ORDER_DETAIL_FAILED', + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( { code: 'INTERNAL_ERROR', error: 'internal_error' }, { status: 500 } diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 9d17db77..43e2fdfc 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -19,6 +19,7 @@ import { rateLimitResponse, } from '@/lib/security/rate-limit'; import { resolveStripeWebhookRateLimit } from '@/lib/security/stripe-webhook-rate-limit'; +import { guardNonBrowserOnly } from '@/lib/security/origin'; const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const; @@ -33,8 +34,20 @@ const STRIPE_WEBHOOK_INSTANCE_ID = const STRIPE_EVENT_CLAIM_TTL_MS = 10 * 60 * 1000; // 10 minutes const STRIPE_EVENT_RETRY_AFTER_SECONDS = 10; +function noStoreJson( + body: unknown, + init?: { status?: number; headers?: HeadersInit } +) { + const res = NextResponse.json(body, { + status: init?.status ?? 200, + headers: init?.headers, + }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + function busyRetry() { - return NextResponse.json( + const res = noStoreJson( { code: 'WEBHOOK_CLAIMED', retryAfterSeconds: STRIPE_EVENT_RETRY_AFTER_SECONDS, @@ -44,7 +57,22 @@ function busyRetry() { headers: { 'Retry-After': String(STRIPE_EVENT_RETRY_AFTER_SECONDS) }, } ); + return res; +} + +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' } } + ); } + async function tryClaimStripeEvent( eventId: string ): Promise<'claimed' | 'already_processed' | 'busy'> { @@ -125,6 +153,8 @@ function upsertRefundIntoMeta(params: { } function warnRefundFullnessUndetermined(payload: { + requestId?: string; + route?: string; eventId: string; eventType: string; chargeId: string | null; @@ -139,25 +169,67 @@ function warnRefundFullnessUndetermined(payload: { refundId?: string | null; refundAmount?: number | null; }) { - logWarn('stripe_webhook_refund_fullness_undetermined', payload); + logWarn('stripe_webhook_refund_fullness_undetermined', { + ...payload, + // keep consistent correlation fields with stripe_webhook + stripeEventId: payload.eventId, + instanceId: STRIPE_WEBHOOK_INSTANCE_ID, + provider: 'stripe', + code: 'REFUND_FULLNESS_UNDETERMINED', + }); } function logWebhookEvent(payload: { + requestId?: string; + stripeEventId?: string; orderId?: string; paymentIntentId?: string | null; paymentStatus?: string | null; eventType: string; + chargeId?: string | null; + refundId?: string | null; }) { - const { orderId, paymentIntentId, paymentStatus, eventType } = payload; + const { + requestId, + stripeEventId, + orderId, + paymentIntentId, + paymentStatus, + eventType, + chargeId, + refundId, + } = payload; + + const providerRef = refundId ?? chargeId ?? paymentIntentId ?? null; + + const providerRefType = + refundId != null + ? 'refund' + : chargeId != null + ? 'charge' + : paymentIntentId != null + ? 'payment_intent' + : null; logInfo('stripe_webhook', { + requestId, + stripeEventId, provider: 'stripe', + instanceId: STRIPE_WEBHOOK_INSTANCE_ID, + orderId, paymentIntentId, + chargeId: chargeId ?? null, + refundId: refundId ?? null, + + providerRef, + providerRefType, + paymentStatus, eventType, }); } + type PaymentIntentWithCharges = Stripe.PaymentIntent & { charges?: { data?: Stripe.Charge[] }; }; @@ -180,6 +252,21 @@ function getLatestChargeId( return null; } +function extractStripeId(value: unknown): string | null { + if (typeof value === 'string') { + const s = value.trim(); + return s.length > 0 ? s : null; + } + if (value && typeof value === 'object' && 'id' in value) { + const id = (value as any).id; + if (typeof id === 'string') { + const s = id.trim(); + return s.length > 0 ? s : null; + } + } + return null; +} + function resolvePaymentMethod( paymentIntent?: Stripe.PaymentIntent, charge?: Stripe.Charge @@ -307,13 +394,47 @@ function shouldRestockFromWebhook(order: { } export async function POST(request: NextRequest) { + const startedAtMs = Date.now(); + + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + provider: 'stripe', + instanceId: STRIPE_WEBHOOK_INSTANCE_ID, + }; + + const meta = (extra: Record = {}) => ({ + ...baseMeta, + ...extra, + durationMs: Date.now() - startedAtMs, + }); + + const blocked = guardNonBrowserOnly(request); + if (blocked) { + logWarn('stripe_webhook_origin_blocked', meta({ code: 'ORIGIN_BLOCKED' })); + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + let rawBody: string; try { rawBody = await request.text(); } catch (error) { - logError('Failed to read Stripe webhook body', error); - return NextResponse.json({ error: 'invalid_payload' }, { status: 400 }); + logError( + 'stripe_webhook_body_read_failed', + error, + meta({ code: 'INVALID_PAYLOAD' }) + ); + const res = noStoreJson( + { error: 'invalid_payload', code: 'INVALID_PAYLOAD' }, + { status: 400 } + ); + return res; } const signature = request.headers.get('stripe-signature'); @@ -327,17 +448,27 @@ export async function POST(request: NextRequest) { }); if (!decision.ok) { - return rateLimitResponse({ + logWarn('stripe_webhook_rate_limited', { + ...meta(), + code: 'RATE_LIMITED', + reason: 'missing_signature', + retryAfterSeconds: decision.retryAfterSeconds, + }); + + const res = rateLimitResponse({ retryAfterSeconds: decision.retryAfterSeconds, details: { scope: 'stripe_webhook', reason: 'missing_signature' }, }); + return res; } logError( - 'Stripe webhook missing signature header', - new Error('MISSING_STRIPE_SIGNATURE') + 'stripe_webhook_missing_signature', + new Error('MISSING_STRIPE_SIGNATURE'), + meta({ code: 'MISSING_SIGNATURE' }) ); - return NextResponse.json({ code: 'INVALID_SIGNATURE' }, { status: 400 }); + + return noStoreJson({ code: 'INVALID_SIGNATURE' }, { status: 400 }); } let event: Stripe.Event; @@ -345,8 +476,12 @@ export async function POST(request: NextRequest) { event = verifyWebhookSignature({ rawBody, signatureHeader: signature }); } catch (error) { if (error instanceof Error && error.message === 'STRIPE_WEBHOOK_DISABLED') { - logError('Stripe webhook disabled or misconfigured', error); - return NextResponse.json({ code: 'WEBHOOK_DISABLED' }, { status: 500 }); + logError('stripe_webhook_disabled_or_misconfigured', error, { + ...meta(), + code: 'WEBHOOK_DISABLED', + }); + + return noStoreJson({ code: 'WEBHOOK_DISABLED' }, { status: 500 }); } if ( @@ -362,20 +497,47 @@ export async function POST(request: NextRequest) { }); if (!decision.ok) { - return rateLimitResponse({ + logWarn('stripe_webhook_rate_limited', { + ...meta(), + code: 'RATE_LIMITED', + reason: 'invalid_signature', + retryAfterSeconds: decision.retryAfterSeconds, + }); + + const res = rateLimitResponse({ retryAfterSeconds: decision.retryAfterSeconds, details: { scope: 'stripe_webhook', reason: 'invalid_signature' }, }); + return res; } - logError('Stripe webhook signature verification failed', error); - return NextResponse.json({ code: 'INVALID_SIGNATURE' }, { status: 400 }); + logError('stripe_webhook_signature_verification_failed', error, { + ...meta(), + code: 'INVALID_SIGNATURE', + }); + + return noStoreJson({ code: 'INVALID_SIGNATURE' }, { status: 400 }); } - throw error; + logError( + 'stripe_webhook_signature_verification_unexpected_error', + error, + meta({ code: 'SIGNATURE_VERIFICATION_FAILED' }) + ); + + const res = noStoreJson( + { error: 'internal_error', code: 'SIGNATURE_VERIFICATION_FAILED' }, + { status: 500 } + ); + return res; } const eventType = event.type; + const stripeEventId = event.id; + const eventMeta = (extra: Record = {}) => + meta({ stripeEventId, eventType, ...extra }); + + const warnBase = { requestId, route: baseMeta.route }; const rawObject = event.data.object; let paymentIntent: Stripe.PaymentIntent | undefined; @@ -428,6 +590,16 @@ export async function POST(request: NextRequest) { ((refundObject as any)?.status as string | null | undefined) ?? null; + const bestEffortRefundChargeId: string | null = extractStripeId( + (refundObject as any)?.charge + ); + + const bestEffortChargeId: string | null = paymentIntent + ? getLatestChargeId(paymentIntent) + : charge?.id ?? bestEffortRefundChargeId ?? null; + + const bestEffortRefundId: string | null = refundObject?.id ?? null; + const rawMetadata = rawObject && typeof rawObject === 'object' && 'metadata' in rawObject ? (rawObject as { metadata?: Stripe.Metadata }).metadata @@ -445,8 +617,16 @@ export async function POST(request: NextRequest) { : undefined; if (!paymentIntentId) { - logWebhookEvent({ eventType, paymentIntentId, paymentStatus }); - return NextResponse.json({ received: true }, { status: 200 }); + logWebhookEvent({ + eventType, + paymentIntentId, + paymentStatus, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, + requestId, + stripeEventId, + }); + return noStoreJson({ received: true }, { status: 200 }); } try { @@ -464,13 +644,15 @@ export async function POST(request: NextRequest) { if (updated.length === 0) { logWarn('stripe_webhook_ack_claim_lost', { + ...eventMeta(), eventId: event.id, - eventType, + code: 'WEBHOOK_CLAIM_LOST', }); + return busyRetry(); } - return NextResponse.json({ received: true }, { status: 200 }); + return noStoreJson({ received: true }, { status: 200 }); }; // 1) Insert event idempotently (no transactions) const inserted = await db @@ -496,10 +678,11 @@ export async function POST(request: NextRequest) { if (existing?.processedAt) { logInfo('stripe_webhook_duplicate_event', { + ...eventMeta(), eventId: event.id, - eventType, }); - return NextResponse.json({ received: true }, { status: 200 }); + + return noStoreJson({ received: true }, { status: 200 }); } // processedAt is NULL => previous attempt failed; reprocess } @@ -507,16 +690,17 @@ export async function POST(request: NextRequest) { const claimState = await tryClaimStripeEvent(event.id); if (claimState === 'already_processed') { logInfo('stripe_webhook_duplicate_event', { + ...eventMeta(), eventId: event.id, - eventType, reason: 'already_processed', }); - return NextResponse.json({ received: true }, { status: 200 }); + + return noStoreJson({ received: true }, { status: 200 }); } if (claimState === 'busy') { logInfo('stripe_webhook_claimed_by_other_instance', { + ...eventMeta(), eventId: event.id, - eventType, }); return busyRetry(); } @@ -536,14 +720,27 @@ export async function POST(request: NextRequest) { resolvedOrderId = candidates[0].id; } else { logWarn('stripe_webhook_missing_order_id', { + ...eventMeta(), + code: 'MISSING_ORDER_ID', paymentIntentId, - eventType, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, reason: candidates.length === 0 ? 'no_order_for_payment_intent' : 'multiple_orders_for_payment_intent', }); - logWebhookEvent({ eventType, paymentIntentId, paymentStatus }); + + logWebhookEvent({ + requestId, + stripeEventId, + eventType, + paymentIntentId, + paymentStatus, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, + }); + return ack(); } } @@ -574,18 +771,30 @@ export async function POST(request: NextRequest) { .limit(1); if (!order) { - logWebhookEvent({ eventType, paymentIntentId, paymentStatus }); + logWebhookEvent({ + eventType, + paymentIntentId, + paymentStatus, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, + requestId, + stripeEventId, + }); return ack(); } if (order.paymentIntentId && order.paymentIntentId !== paymentIntentId) { logInfo('stripe_webhook_payment_intent_mismatch', { + ...eventMeta(), + code: 'PAYMENT_INTENT_MISMATCH', orderId: order.id, paymentIntentId, orderPaymentIntentId: order.paymentIntentId, - eventType, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, reason: 'payment_intent_mismatch', }); + return ack(); } @@ -609,6 +818,7 @@ export async function POST(request: NextRequest) { : 'currency_mismatch'; const chargeForIntent = getLatestCharge(paymentIntent as any); + const latestChargeId = getLatestChargeId(paymentIntent); const createdAtIso = new Date().toISOString(); const deltaMeta = buildPspMetadata({ eventType, @@ -649,14 +859,16 @@ export async function POST(request: NextRequest) { .where(eq(orders.id, order.id)); logWarn('stripe_webhook_mismatch', { + ...eventMeta(), + code: 'PSP_MISMATCH', orderId: order.id, paymentIntentId, - eventType, stripeAmount, orderAmountMinor, stripeCurrency, orderCurrency: order.currency, reason: mismatchReason, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, }); await markStripeAttemptFinal({ @@ -706,8 +918,11 @@ export async function POST(request: NextRequest) { pspMetadata: nextMeta, }, extraWhere: and( - eq(orders.stockRestored, false), - ne(orders.inventoryStatus, 'released'), + or(isNull(orders.stockRestored), eq(orders.stockRestored, false)), + or( + isNull(orders.inventoryStatus), + ne(orders.inventoryStatus, 'released') + ), // avoid churn when already consistent or(ne(orders.paymentStatus, 'paid'), ne(orders.status, 'PAID')), // explicit safety gates (redundant with matrix, but keep) @@ -721,20 +936,30 @@ export async function POST(request: NextRequest) { }); logWebhookEvent({ + requestId, + stripeEventId, orderId: order.id, paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, }); + return ack(); } if (eventType === 'payment_intent.payment_failed') { + const chargeForIntent = getLatestCharge(paymentIntent as any); + const latestChargeId = getLatestChargeId(paymentIntent); + if (order.paymentStatus === 'paid') { logWebhookEvent({ orderId: order.id, paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } @@ -744,11 +969,13 @@ export async function POST(request: NextRequest) { paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } - const chargeForIntent = getLatestCharge(paymentIntent as any); const failureReason = paymentIntent?.last_payment_error?.decline_code ?? paymentIntent?.last_payment_error?.code ?? @@ -778,7 +1005,7 @@ export async function POST(request: NextRequest) { note: eventType, set: { updatedAt: now, - pspChargeId: chargeForIntent?.id ?? null, + pspChargeId: latestChargeId ?? chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent @@ -807,17 +1034,26 @@ export async function POST(request: NextRequest) { paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } if (eventType === 'payment_intent.canceled') { + const chargeForIntent = getLatestCharge(paymentIntent as any); + const latestChargeId = getLatestChargeId(paymentIntent); + if (order.paymentStatus === 'paid') { logWebhookEvent({ orderId: order.id, paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } @@ -827,10 +1063,12 @@ export async function POST(request: NextRequest) { paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } - const chargeForIntent = getLatestCharge(paymentIntent as any); const cancellationReason = paymentIntent?.cancellation_reason ?? paymentIntent?.status ?? @@ -858,7 +1096,7 @@ export async function POST(request: NextRequest) { note: eventType, set: { updatedAt: now, - pspChargeId: chargeForIntent?.id ?? null, + pspChargeId: latestChargeId ?? chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent @@ -882,6 +1120,9 @@ export async function POST(request: NextRequest) { paymentIntentId, paymentStatus, eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + requestId, + stripeEventId, }); return ack(); } @@ -931,6 +1172,7 @@ export async function POST(request: NextRequest) { if (!list || list.length === 0) { warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, eventType, chargeId: @@ -960,6 +1202,7 @@ export async function POST(request: NextRequest) { if (!sawNumericAmount && currentAmt == null) { warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, eventType, chargeId: @@ -991,6 +1234,7 @@ export async function POST(request: NextRequest) { : null; warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, eventType, chargeId: @@ -1045,7 +1289,9 @@ export async function POST(request: NextRequest) { if (!list || list.length === 0) { warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, + eventType, chargeId: ((effectiveCharge as any)?.id as string | undefined) ?? @@ -1076,6 +1322,7 @@ export async function POST(request: NextRequest) { if (!sawNumericAmount && currentAmt == null) { warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, eventType, chargeId: @@ -1108,6 +1355,7 @@ export async function POST(request: NextRequest) { : null; warnRefundFullnessUndetermined({ + ...warnBase, eventId: event.id, eventType, chargeId: @@ -1181,11 +1429,16 @@ export async function POST(request: NextRequest) { .where(eq(orders.id, order.id)); logWebhookEvent({ + requestId, + stripeEventId, orderId: order.id, paymentIntentId, paymentStatus, eventType, + refundId: refund?.id ?? null, + chargeId: charge?.id ?? refundChargeId ?? null, }); + return ack(); } @@ -1230,32 +1483,48 @@ export async function POST(request: NextRequest) { } logWebhookEvent({ + requestId, + stripeEventId, orderId: order.id, paymentIntentId, paymentStatus, eventType, + refundId: refund?.id ?? null, + chargeId: charge?.id ?? refundChargeId ?? null, }); + return ack(); } // default ack logWebhookEvent({ + requestId, + stripeEventId, orderId: order.id, paymentIntentId, paymentStatus, eventType, + chargeId: charge?.id ?? null, + refundId: refundObject?.id ?? null, }); + return ack(); } catch (error) { if (isRefundFullnessUndeterminedError(error)) { // Do NOT ack() -> keep processedAt NULL so Stripe retries. - return NextResponse.json( + return noStoreJson( { code: REFUND_FULLNESS_UNDETERMINED }, { status: 500 } ); } - logError('Stripe webhook processing failed', error); + logError('stripe_webhook_processing_failed', error, { + ...eventMeta(), + code: 'WEBHOOK_PROCESSING_FAILED', + paymentIntentId, + orderId: orderId ?? null, + }); + // P0.8: release claim early so Stripe retries can be claimed immediately. try { await db @@ -1271,6 +1540,6 @@ export async function POST(request: NextRequest) { } catch { // best-effort } - return NextResponse.json({ error: 'internal_error' }, { status: 500 }); + return noStoreJson({ error: 'internal_error' }, { status: 500 }); } } diff --git a/frontend/components/shop/admin/admin-product-delete-button.tsx b/frontend/components/shop/admin/admin-product-delete-button.tsx new file mode 100644 index 00000000..23b43240 --- /dev/null +++ b/frontend/components/shop/admin/admin-product-delete-button.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useId, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +interface AdminProductDeleteButtonProps { + id: string; + title: string; + csrfToken: string; +} + +export function AdminProductDeleteButton({ + id, + title, + csrfToken, +}: AdminProductDeleteButtonProps) { + const router = useRouter(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const errorId = useId(); + + const onDelete = async () => { + setError(null); + + if (!csrfToken) { + setError('Security token missing. Refresh the page.'); + return; + } + + const ok = window.confirm( + `Delete product "${title}"? This cannot be undone.` + ); + if (!ok) return; + + setIsLoading(true); + + try { + const response = await fetch(`/api/shop/admin/products/${id}`, { + method: 'DELETE', + headers: { + 'x-csrf-token': csrfToken, + }, + }); + + if (!response.ok) { + let code: string | undefined; + try { + const body = await response.json(); + code = typeof body?.code === 'string' ? body.code : undefined; + } catch { + // ignore + } + + if ( + response.status === 403 && + (code === 'CSRF_MISSING' || code === 'CSRF_INVALID') + ) { + setError('Security token expired. Refresh the page and retry.'); + return; + } + + if (response.status === 404) { + setError('Product not found (already deleted).'); + router.refresh(); + return; + } + if (response.status === 409 && code === 'PRODUCT_IN_USE') { + setError( + 'Cannot delete: this product is referenced by other records.' + ); + return; + } + + setError('Failed to delete product'); + return; + } + + // refresh server component data + router.refresh(); + } catch { + setError('Failed to delete product'); + } finally { + setIsLoading(false); + } + }; + + return ( +
    + + + {error ? ( +

    + {error} +

    + ) : null} +
    + ); +} diff --git a/frontend/db/schema/shop.ts b/frontend/db/schema/shop.ts index 315f6715..d74fe19d 100644 --- a/frontend/db/schema/shop.ts +++ b/frontend/db/schema/shop.ts @@ -266,7 +266,7 @@ export const stripeEvents = pgTable( provider: text('provider').notNull().default('stripe'), eventId: text('event_id').notNull(), paymentIntentId: text('payment_intent_id'), - orderId: uuid('order_id').references(() => orders.id), + orderId: uuid('order_id').references(() => orders.id, { onDelete: 'cascade' }), eventType: text('event_type').notNull(), paymentStatus: text('payment_status'), claimedAt: timestamp('claimed_at', { withTimezone: true }), diff --git a/frontend/docs/security/origin-posture.md b/frontend/docs/security/origin-posture.md new file mode 100644 index 00000000..f64b5a11 --- /dev/null +++ b/frontend/docs/security/origin-posture.md @@ -0,0 +1,81 @@ +# Origin Posture (Shop APIs) + +## Why this exists + +CORS, CSRF, and origin checks solve different problems: + +- **CORS** is a browser-enforced policy that controls which origins can read + responses. It does **not** prevent requests from being sent, and it is not an + auth mechanism. +- **CSRF protections** (tokens, SameSite cookies) prevent attackers from abusing + a victim's browser session on state-changing requests. +- **Origin / Fetch Metadata** checks let the server detect browser context and + block cross-origin browser requests before they reach application logic. + +This application does **not** support cross-origin browser calls for admin or +checkout flows. We explicitly enforce this posture at the application layer (no +Cloudflare/WAF assumed). + +## Our posture (fail-closed) + +**No cross-origin browser usage.** + +- **Browser-exposed endpoints** (admin + checkout) only allow **same-origin** + browser requests. +- **Non-browser endpoints** (internal/cron + webhooks) reject **browser-like** + requests entirely. + +### Browser-exposed endpoints (same-origin only) + +For unsafe methods (`POST`, `PATCH`, `PUT`, `DELETE`): + +- Require an `Origin` header. +- The `Origin` must match the allowlist built from `APP_ORIGIN` and + `APP_ADDITIONAL_ORIGINS`. +- If either condition fails, return `403 ORIGIN_NOT_ALLOWED`. + +CSRF checks remain in place and are still required. + +### Non-browser endpoints (reject browser context) + +For internal and webhook routes: + +- Reject requests that include an `Origin` header **or** `Sec-Fetch-Site` that + is not `none`. +- Return `403 BROWSER_CONTEXT_NOT_ALLOWED`. +- Canonical auth remains: + - Internal endpoints rely on internal tokens. + - Stripe webhooks rely on Stripe signature verification. + +### Browser policy headers + +We do not add `Access-Control-Allow-*` headers. Cross-origin browser access is +not a supported integration pattern for these APIs. + +## Environment variables + +- `APP_ORIGIN` (required in production): the primary allowed origin. +- `APP_ADDITIONAL_ORIGINS` (optional): comma-separated list of extra allowed + origins. +- In non-production environments, `http://localhost:3000` is added if missing. + +## Enforcement location + +All enforcement is implemented in **Next.js route handlers** in +`frontend/app/api/**/route.ts`. No Cloudflare/WAF or external middleware is +assumed. + +## Implementation references + +### Core implementation + +- `frontend/lib/security/origin.ts` + +### Tests + +- `frontend/lib/tests/origin-posture.test.ts` + +### Example routes + +- `frontend/app/api/shop/checkout/route.ts` (browser-exposed, same-origin) +- `frontend/app/api/shop/webhooks/stripe/route.ts` (non-browser only) diff --git a/frontend/drizzle/0005_modern_bromley.sql b/frontend/drizzle/0005_modern_bromley.sql new file mode 100644 index 00000000..23135890 --- /dev/null +++ b/frontend/drizzle/0005_modern_bromley.sql @@ -0,0 +1,19 @@ +-- Note: Postgres DO blocks execute atomically as a single statement. +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 + 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 $$; diff --git a/frontend/drizzle/meta/0005_snapshot.json b/frontend/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..7e8abb4f --- /dev/null +++ b/frontend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2805 @@ +{ + "id": "71daad5e-a037-48d6-b75c-7f8b49fd518f", + "prevId": "666d4e8b-79a6-409d-81d9-cef200363124", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_translations": { + "name": "category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_translations_category_id_categories_id_fk": { + "name": "category_translations_category_id_categories_id_fk", + "tableFrom": "category_translations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_translations_category_id_locale_pk": { + "name": "category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_translations": { + "name": "question_translations", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_blocks": { + "name": "answer_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "question_translations_question_id_questions_id_fk": { + "name": "question_translations_question_id_questions_id_fk", + "tableFrom": "question_translations", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "question_translations_question_id_locale_pk": { + "name": "question_translations_question_id_locale_pk", + "columns": [ + "question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "questions_category_sort_order_idx": { + "name": "questions_category_sort_order_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answer_translations": { + "name": "quiz_answer_translations", + "schema": "", + "columns": { + "quiz_answer_id": { + "name": "quiz_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk": { + "name": "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_answer_translations", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_answer_translations_quiz_answer_id_locale_pk": { + "name": "quiz_answer_translations_quiz_answer_id_locale_pk", + "columns": [ + "quiz_answer_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answers": { + "name": "quiz_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "quiz_answers_question_display_order_idx": { + "name": "quiz_answers_question_display_order_idx", + "columns": [ + { + "expression": "quiz_question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt_answers": { + "name": "quiz_attempt_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_answer_id": { + "name": "selected_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempt_answers_attempt_idx": { + "name": "quiz_attempt_answers_attempt_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk": { + "name": "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "integrity_score": { + "name": "integrity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100 + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempts_user_id_idx": { + "name": "quiz_attempts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_user_completed_at_idx": { + "name": "quiz_attempts_user_completed_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_percentage_completed_at_idx": { + "name": "quiz_attempts_quiz_percentage_completed_at_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "percentage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_integrity_score_idx": { + "name": "quiz_attempts_quiz_integrity_score_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integrity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question_content": { + "name": "quiz_question_content", + "schema": "", + "columns": { + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_content_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_question_content_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_question_content", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_question_content_quiz_question_id_locale_pk": { + "name": "quiz_question_content_quiz_question_id_locale_pk", + "columns": [ + "quiz_question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_questions": { + "name": "quiz_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_question_id": { + "name": "source_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_questions_quiz_display_order_idx": { + "name": "quiz_questions_quiz_display_order_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_translations": { + "name": "quiz_translations", + "schema": "", + "columns": { + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_translations_quiz_id_quizzes_id_fk": { + "name": "quiz_translations_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_translations", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_translations_quiz_id_locale_pk": { + "name": "quiz_translations_quiz_id_locale_pk", + "columns": [ + "quiz_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quizzes": { + "name": "quizzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_count": { + "name": "questions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quizzes_slug_idx": { + "name": "quizzes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_provider_id_unique": { + "name": "users_provider_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_transactions": { + "name": "point_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'quiz'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "point_transactions_user_id_idx": { + "name": "point_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_rate_limits_updated_at_idx": { + "name": "api_rate_limits_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_rate_limits_count_non_negative": { + "name": "api_rate_limits_count_non_negative", + "value": "\"api_rate_limits\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.internal_job_state": { + "name": "internal_job_state", + "schema": "", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_moves": { + "name": "inventory_moves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "move_key": { + "name": "move_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_move_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_moves_move_key_uq": { + "name": "inventory_moves_move_key_uq", + "columns": [ + { + "expression": "move_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_moves_quantity_gt_0": { + "name": "inventory_moves_quantity_gt_0", + "value": "\"inventory_moves\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_size": { + "name": "selected_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selected_color": { + "name": "selected_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "line_total": { + "name": "line_total", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "product_title": { + "name": "product_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_slug": { + "name": "product_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_items_order_id_idx": { + "name": "order_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_items_order_variant_uq": { + "name": "order_items_order_variant_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_size", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_color", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_items_quantity_positive": { + "name": "order_items_quantity_positive", + "value": "\"order_items\".\"quantity\" > 0" + }, + "order_items_unit_price_minor_non_negative": { + "name": "order_items_unit_price_minor_non_negative", + "value": "\"order_items\".\"unit_price_minor\" >= 0" + }, + "order_items_line_total_minor_non_negative": { + "name": "order_items_line_total_minor_non_negative", + "value": "\"order_items\".\"line_total_minor\" >= 0" + }, + "order_items_line_total_consistent": { + "name": "order_items_line_total_consistent", + "value": "\"order_items\".\"line_total_minor\" = \"order_items\".\"unit_price_minor\" * \"order_items\".\"quantity\"" + }, + "order_items_unit_price_mirror_consistent": { + "name": "order_items_unit_price_mirror_consistent", + "value": "\"order_items\".\"unit_price\" = (\"order_items\".\"unit_price_minor\"::numeric / 100)" + }, + "order_items_line_total_mirror_consistent": { + "name": "order_items_line_total_mirror_consistent", + "value": "\"order_items\".\"line_total\" = (\"order_items\".\"line_total_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_amount_minor": { + "name": "total_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "payment_status": { + "name": "payment_status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_charge_id": { + "name": "psp_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_payment_method": { + "name": "psp_payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_status_reason": { + "name": "psp_status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_metadata": { + "name": "psp_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CREATED'" + }, + "inventory_status": { + "name": "inventory_status", + "type": "inventory_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_request_hash": { + "name": "idempotency_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stock_restored": { + "name": "stock_restored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restocked_at": { + "name": "restocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sweep_claimed_at": { + "name": "sweep_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_claim_expires_at": { + "name": "sweep_claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_run_id": { + "name": "sweep_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sweep_claimed_by": { + "name": "sweep_claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orders_sweep_claim_expires_idx": { + "name": "orders_sweep_claim_expires_idx", + "columns": [ + { + "expression": "sweep_claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "orders_payment_provider_valid": { + "name": "orders_payment_provider_valid", + "value": "\"orders\".\"payment_provider\" in ('stripe', 'none')" + }, + "orders_total_amount_minor_non_negative": { + "name": "orders_total_amount_minor_non_negative", + "value": "\"orders\".\"total_amount_minor\" >= 0" + }, + "orders_payment_intent_id_null_when_none": { + "name": "orders_payment_intent_id_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_intent_id\" IS NULL" + }, + "orders_psp_fields_null_when_none": { + "name": "orders_psp_fields_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR (\n \"orders\".\"psp_charge_id\" IS NULL AND\n \"orders\".\"psp_payment_method\" IS NULL AND\n \"orders\".\"psp_status_reason\" IS NULL\n )" + }, + "orders_total_amount_mirror_consistent": { + "name": "orders_total_amount_mirror_consistent", + "value": "\"orders\".\"total_amount\" = (\"orders\".\"total_amount_minor\"::numeric / 100)" + }, + "orders_payment_status_valid_when_none": { + "name": "orders_payment_status_valid_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_status\" in ('paid','failed')" + } + }, + "isRLSEnabled": false + }, + "public.payment_attempts": { + "name": "payment_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_attempts_order_provider_attempt_unique": { + "name": "payment_attempts_order_provider_attempt_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_pi_unique": { + "name": "payment_attempts_provider_pi_unique", + "columns": [ + { + "expression": "provider_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_status_idx": { + "name": "payment_attempts_order_provider_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_active_unique": { + "name": "payment_attempts_order_provider_active_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_attempts\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payment_attempts_provider_check": { + "name": "payment_attempts_provider_check", + "value": "\"payment_attempts\".\"provider\" in ('stripe')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.product_prices": { + "name": "product_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_price_minor": { + "name": "original_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_prices_product_id_idx": { + "name": "product_prices_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_prices_product_currency_uq": { + "name": "product_prices_product_currency_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_prices_price_positive": { + "name": "product_prices_price_positive", + "value": "\"product_prices\".\"price_minor\" > 0" + }, + "product_prices_original_price_valid": { + "name": "product_prices_original_price_valid", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price_minor\" > \"product_prices\".\"price_minor\"" + }, + "product_prices_price_mirror_consistent": { + "name": "product_prices_price_mirror_consistent", + "value": "\"product_prices\".\"price\" = (\"product_prices\".\"price_minor\"::numeric / 100)" + }, + "product_prices_original_price_null_coupled": { + "name": "product_prices_original_price_null_coupled", + "value": "(\"product_prices\".\"original_price_minor\" is null) = (\"product_prices\".\"original_price\" is null)" + }, + "product_prices_original_price_mirror_consistent": { + "name": "product_prices_original_price_mirror_consistent", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price\" = (\"product_prices\".\"original_price_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sizes": { + "name": "sizes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "badge": { + "name": "badge", + "type": "product_badge", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NONE'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "products_stock_non_negative": { + "name": "products_stock_non_negative", + "value": "\"products\".\"stock\" >= 0" + }, + "products_currency_usd_only": { + "name": "products_currency_usd_only", + "value": "\"products\".\"currency\" = 'USD'" + }, + "products_price_positive": { + "name": "products_price_positive", + "value": "\"products\".\"price\" > 0" + }, + "products_original_price_valid": { + "name": "products_original_price_valid", + "value": "\"products\".\"original_price\" is null or \"products\".\"original_price\" > \"products\".\"price\"" + } + }, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_event_id_idx": { + "name": "stripe_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_id_idx": { + "name": "email_verification_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "active_sessions_last_activity_idx": { + "name": "active_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.inventory_move_type": { + "name": "inventory_move_type", + "schema": "public", + "values": [ + "reserve", + "release" + ] + }, + "public.inventory_status": { + "name": "inventory_status", + "schema": "public", + "values": [ + "none", + "reserving", + "reserved", + "release_pending", + "released", + "failed" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "CREATED", + "INVENTORY_RESERVED", + "INVENTORY_FAILED", + "PAID", + "CANCELED" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "requires_payment", + "paid", + "failed", + "refunded" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index 3ccfd4e8..fc375b9f 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1768707328896, "tag": "0004_add_api_rate_limits", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1768782782399, + "tag": "0005_modern_bromley", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/lib/errors/products.ts b/frontend/lib/errors/products.ts new file mode 100644 index 00000000..822e24d5 --- /dev/null +++ b/frontend/lib/errors/products.ts @@ -0,0 +1,10 @@ +// frontend/lib/errors/products.ts + +export class ProductNotFoundError extends Error { + readonly code = 'PRODUCT_NOT_FOUND' as const; + + constructor(productId: string) { + super(`Product not found: ${productId}`); + this.name = 'ProductNotFoundError'; + } +} diff --git a/frontend/lib/security/origin.ts b/frontend/lib/security/origin.ts new file mode 100644 index 00000000..00f66581 --- /dev/null +++ b/frontend/lib/security/origin.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const LOCALHOST_ORIGIN = 'http://localhost:3000'; + +function buildErrorResponse(code: string, message: string) { + const res = NextResponse.json( + { + error: { + code, + message, + }, + }, + { status: 403 } + ); + + // Ensure security errors are never cached by intermediaries. + res.headers.set('Cache-Control', 'no-store'); + + return res; +} + +export function normalizeOrigin(input: string): string { + const trimmed = input.trim().replace(/\/+$/, ''); + + try { + // If the input is a valid URL (incl. scheme), normalize to canonical origin. + return new URL(trimmed).origin; + } catch { + // Backward-compatible fallback for values like "example.com" (no scheme). + return trimmed; + } +} + +export function getAllowedOrigins(): string[] { + const allowed = new Set(); + + const appOrigin = (process.env.APP_ORIGIN ?? '').trim(); + if (appOrigin) { + allowed.add(normalizeOrigin(appOrigin)); + } + + const additionalRaw = (process.env.APP_ADDITIONAL_ORIGINS ?? '').trim(); + if (additionalRaw) { + for (const entry of additionalRaw.split(',')) { + const candidate = entry.trim(); + if (!candidate) continue; + allowed.add(normalizeOrigin(candidate)); + } + } + + if (process.env.NODE_ENV !== 'production') { + const normalizedLocalhost = normalizeOrigin(LOCALHOST_ORIGIN); + if (!allowed.has(normalizedLocalhost)) { + allowed.add(normalizedLocalhost); + } + } + + return Array.from(allowed.values()); +} + +export function guardBrowserSameOrigin(req: NextRequest): NextResponse | null { + const method = req.method.toUpperCase(); + if (method === 'GET' || method === 'HEAD') return null; + + const origin = req.headers.get('origin'); + if (!origin) { + return buildErrorResponse( + 'ORIGIN_NOT_ALLOWED', + 'Origin header is required for unsafe requests.' + ); + } + + const normalizedOrigin = normalizeOrigin(origin); + const allowedOrigins = getAllowedOrigins(); + const isAllowed = allowedOrigins.includes(normalizedOrigin); + + if (!isAllowed) { + return buildErrorResponse( + 'ORIGIN_NOT_ALLOWED', + 'Origin is not allowed for this endpoint.' + ); + } + + return null; +} + +export function guardNonBrowserOnly(req: NextRequest): NextResponse | null { + const origin = req.headers.get('origin'); + if (origin) { + return buildErrorResponse( + 'BROWSER_CONTEXT_NOT_ALLOWED', + 'Browser context is not allowed for this endpoint.' + ); + } + + const fetchSite = req.headers.get('sec-fetch-site'); + if (fetchSite && fetchSite !== 'none') { + return buildErrorResponse( + 'BROWSER_CONTEXT_NOT_ALLOWED', + 'Browser context is not allowed for this endpoint.' + ); + } + + return null; +} diff --git a/frontend/lib/security/rate-limit.ts b/frontend/lib/security/rate-limit.ts index 2ba6f655..cc5e5ab2 100644 --- a/frontend/lib/security/rate-limit.ts +++ b/frontend/lib/security/rate-limit.ts @@ -108,13 +108,19 @@ function envBool(name: string, fallback: boolean): boolean { } export function getClientIpFromHeaders(headers: Headers): string | null { - // Always allow Cloudflare canonical header (highest priority). - const cf = (headers.get('cf-connecting-ip') ?? '').trim(); - if (cf && isIP(cf)) return cf; + const trustForwarded = envBool( + 'TRUST_FORWARDED_HEADERS', + process.env.NODE_ENV !== 'production' + ); + const trustCf = envBool('TRUST_CF_CONNECTING_IP', false); - const trustForwarded = envBool('TRUST_FORWARDED_HEADERS', false); + // Allow Cloudflare canonical header (highest priority) only when explicitly trusted. + if (trustCf) { + const cf = (headers.get('cf-connecting-ip') ?? '').trim(); + if (cf && isIP(cf)) return cf; + } - // Trusted boundary: if we don't trust forwarded headers and CF is missing, + // Trusted boundary: if we don't trust forwarded headers, // do NOT fall back to spoofable headers. if (!trustForwarded) return null; diff --git a/frontend/lib/services/products.ts b/frontend/lib/services/products.ts index 7269c7ec..17fa2c8d 100644 --- a/frontend/lib/services/products.ts +++ b/frontend/lib/services/products.ts @@ -15,4 +15,7 @@ export { export { rehydrateCartItems } from './products/cart/rehydrate'; -export type { AdminProductPriceRow, AdminProductsFilter } from './products/types'; +export type { + AdminProductPriceRow, + AdminProductsFilter, +} from './products/types'; diff --git a/frontend/lib/services/products/admin/queries.ts b/frontend/lib/services/products/admin/queries.ts index 0d76a851..a16d4ed7 100644 --- a/frontend/lib/services/products/admin/queries.ts +++ b/frontend/lib/services/products/admin/queries.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { products, productPrices } from '@/db/schema'; import type { CurrencyCode } from '@/lib/shop/currency'; import type { DbProduct } from '@/lib/types/shop'; +import { ProductNotFoundError } from '@/lib/errors/products'; import { assertMoneyMinorInt } from '../prices'; import { mapRowToProduct } from '../mapping'; @@ -17,7 +18,7 @@ export async function getAdminProductById(id: string): Promise { .limit(1); if (!row) { - throw new Error('PRODUCT_NOT_FOUND'); + throw new ProductNotFoundError(id); } return mapRowToProduct(row); diff --git a/frontend/lib/services/products/mutations/delete.ts b/frontend/lib/services/products/mutations/delete.ts index 4d157536..928fe85a 100644 --- a/frontend/lib/services/products/mutations/delete.ts +++ b/frontend/lib/services/products/mutations/delete.ts @@ -1,29 +1,46 @@ -// frontend/lib/services/products/mutations/delete.ts -import { eq } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; import { destroyProductImage } from '@/lib/cloudinary'; import { db } from '@/db'; -import { products } from '@/db/schema'; +import { products, productPrices } from '@/db/schema'; import { logError } from '@/lib/logging'; +import { ProductNotFoundError } from '@/lib/errors/products'; export async function deleteProduct(id: string): Promise { - const [existing] = await db - .select() - .from(products) - .where(eq(products.id, id)) - .limit(1); + // Atomic delete: prices first, then product, all-or-nothing. + // Return imagePublicId from the deleted row to avoid stale pre-reads. + 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, ${products.imagePublicId} AS imagePublicId + ) + SELECT id, imagePublicId FROM del_product; + `); - if (!existing) { - throw new Error('PRODUCT_NOT_FOUND'); - } + const rows = + ( + result as unknown as { + rows?: Array<{ id: string; imagePublicId: string | null }>; + } + ).rows ?? []; + + const [deleted] = rows; - await db.delete(products).where(eq(products.id, id)); + if (!deleted) { + // not found or concurrent delete edge-case + throw new ProductNotFoundError(id); + } - 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); } } -} \ No newline at end of file +} diff --git a/frontend/lib/services/products/mutations/toggle.ts b/frontend/lib/services/products/mutations/toggle.ts index 21ef9861..84bc3a7b 100644 --- a/frontend/lib/services/products/mutations/toggle.ts +++ b/frontend/lib/services/products/mutations/toggle.ts @@ -6,6 +6,7 @@ import { products } from '@/db/schema'; import type { DbProduct } from '@/lib/types/shop'; import { mapRowToProduct } from '../mapping'; +import { ProductNotFoundError } from '@/lib/errors/products'; export async function toggleProductStatus(id: string): Promise { const [current] = await db @@ -15,7 +16,7 @@ export async function toggleProductStatus(id: string): Promise { .limit(1); if (!current) { - throw new Error('PRODUCT_NOT_FOUND'); + throw new ProductNotFoundError(id); } const [updated] = await db @@ -24,5 +25,10 @@ export async function toggleProductStatus(id: string): Promise { .where(eq(products.id, id)) .returning(); + if (!updated) { + // concurrent delete between SELECT and UPDATE + throw new ProductNotFoundError(id); + } + return mapRowToProduct(updated); } diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index a32f19dd..b3c16e99 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -9,9 +9,10 @@ import { logError } from '@/lib/logging'; import { toDbMoney } from '@/lib/shop/money'; import type { CurrencyCode } from '@/lib/shop/currency'; import type { DbProduct, ProductUpdateInput } from '@/lib/types/shop'; - import { SlugConflictError } from '../../errors'; +import { ProductNotFoundError } from '@/lib/errors/products'; import { mapRowToProduct } from '../mapping'; + import { normalizeSlug } from '../slug'; import { assertMoneyMinorInt, @@ -33,7 +34,7 @@ export async function updateProduct( .limit(1); if (!existing) { - throw new Error('PRODUCT_NOT_FOUND'); + throw new ProductNotFoundError(id); } const slug = await normalizeSlug( @@ -192,7 +193,7 @@ export async function updateProduct( .returning(); if (!row) { - throw new Error('PRODUCT_NOT_FOUND'); + throw new ProductNotFoundError(id); } if (uploaded && existing.imagePublicId) { diff --git a/frontend/lib/tests/admin-api-killswitch.test.ts b/frontend/lib/tests/admin-api-killswitch.test.ts index d75be523..2dd39ea0 100644 --- a/frontend/lib/tests/admin-api-killswitch.test.ts +++ b/frontend/lib/tests/admin-api-killswitch.test.ts @@ -76,12 +76,13 @@ const cases: RouteCase[] = [ function makeReq(path: string, method: string) { const url = `${BASE_URL}${path}`; + const origin = process.env.APP_ORIGIN ?? 'http://localhost:3000'; // Intentionally invalid JSON payload to ensure kill-switch guard runs // BEFORE any req.json()/formData() parsing. const init: RequestInit = { method, - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', origin }, }; if (method !== 'GET' && method !== 'HEAD') { @@ -148,6 +149,7 @@ describe('P0-7.1 Admin API kill-switch coverage (production)', () => { vi.stubEnv('NODE_ENV', 'production'); // Treat anything except 'true' as disabled; empty string is explicitly disabled. vi.stubEnv('ENABLE_ADMIN_API', ''); + vi.stubEnv('APP_ORIGIN', 'https://admin.example.test'); }); afterEach(() => { diff --git a/frontend/lib/tests/admin-csrf-contract.test.ts b/frontend/lib/tests/admin-csrf-contract.test.ts index 543fdbe3..99965180 100644 --- a/frontend/lib/tests/admin-csrf-contract.test.ts +++ b/frontend/lib/tests/admin-csrf-contract.test.ts @@ -32,6 +32,7 @@ describe('P0-SEC: admin CSRF required for mutating endpoints', () => { const req = new NextRequest( new Request('http://localhost/api/shop/admin/products/x/status', { method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, }) ); diff --git a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts b/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts index b5d73cc8..95c4105e 100644 --- a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts +++ b/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; import { PriceConfigError } from '@/lib/services/errors'; vi.mock('@/lib/auth/admin', () => ({ @@ -41,7 +42,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -function makeReq(): any { +function makeReq(): NextRequest { const fd = new FormData(); // Make payload realistic enough to pass parseAdminProductForm in PATCH @@ -60,7 +61,16 @@ function makeReq(): any { ]) ); - return { formData: async () => fd }; + return new NextRequest( + new Request( + 'http://localhost/api/shop/admin/products/00000000-0000-4000-8000-000000000001', + { + method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, + body: fd, + } + ) + ); } describe('admin PATCH /shop/admin/products/:id (PRICE_CONFIG_ERROR contract)', () => { diff --git a/frontend/lib/tests/admin-product-sale-contract.test.ts b/frontend/lib/tests/admin-product-sale-contract.test.ts index 2732e406..4264de3a 100644 --- a/frontend/lib/tests/admin-product-sale-contract.test.ts +++ b/frontend/lib/tests/admin-product-sale-contract.test.ts @@ -95,6 +95,7 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable const req = new NextRequest( new Request('http://localhost/api/shop/admin/products', { method: 'POST', + headers: { origin: 'http://localhost:3000' }, body: makeFormData({ badge: 'SALE', prices: [ @@ -138,6 +139,7 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable 'http://localhost/api/shop/admin/products/11111111-1111-4111-8111-111111111111', { method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, body: makeFormData({ badge: 'SALE', prices: [ @@ -178,6 +180,7 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable const req = new NextRequest( new Request('http://localhost/api/shop/admin/products', { method: 'POST', + headers: { origin: 'http://localhost:3000' }, body: makeFormData({ badge: 'SALE', pricesRaw: '{' }), }) ); @@ -205,6 +208,7 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable 'http://localhost/api/shop/admin/products/11111111-1111-4111-8111-111111111111', { method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, body: makeFormData({ badge: 'SALE', pricesRaw: '{' }), } ) diff --git a/frontend/lib/tests/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/checkout-concurrency-stock1.test.ts index 922e6848..e459e36b 100644 --- a/frontend/lib/tests/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/checkout-concurrency-stock1.test.ts @@ -136,6 +136,7 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = 'Content-Type': 'application/json', 'Accept-Language': 'en-US,en;q=0.9', 'Idempotency-Key': idemKey, + Origin: 'http://localhost:3000', }, body, }); diff --git a/frontend/lib/tests/checkout-currency-policy.test.ts b/frontend/lib/tests/checkout-currency-policy.test.ts index 752c9104..0d34c8c6 100644 --- a/frontend/lib/tests/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/checkout-currency-policy.test.ts @@ -100,6 +100,7 @@ function makeCheckoutRequest( 'Content-Type': 'application/json', 'Idempotency-Key': opts.idempotencyKey, 'Accept-Language': opts.acceptLanguage, + Origin: 'http://localhost:3000', }); return new NextRequest( diff --git a/frontend/lib/tests/checkout-no-payments.test.ts b/frontend/lib/tests/checkout-no-payments.test.ts index fb2b1905..4b119870 100644 --- a/frontend/lib/tests/checkout-no-payments.test.ts +++ b/frontend/lib/tests/checkout-no-payments.test.ts @@ -1,5 +1,4 @@ // frontend/lib/tests/checkout-no-payments.test.ts -import { describe, it, expect, vi } from 'vitest'; import crypto from 'crypto'; import { eq, sql } from 'drizzle-orm'; import { NextRequest } from 'next/server'; @@ -7,6 +6,19 @@ import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { db } from '@/db'; import { orders, products, productPrices } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; +}); vi.mock('@/lib/logging', async () => { const actual = await vi.importActual('@/lib/logging'); @@ -178,6 +190,7 @@ async function postCheckout(params: { 'accept-language': params.acceptLanguage ?? 'en', 'idempotency-key': params.idemKey, 'x-forwarded-for': deriveTestIpFromIdemKey(params.idemKey), + origin: 'http://localhost:3000', }, body: JSON.stringify({ items: params.items }), diff --git a/frontend/lib/tests/checkout-origin-posture-contract.test.ts b/frontend/lib/tests/checkout-origin-posture-contract.test.ts new file mode 100644 index 00000000..df22d2b8 --- /dev/null +++ b/frontend/lib/tests/checkout-origin-posture-contract.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { POST } from '@/app/api/shop/checkout/route'; +import { makeCheckoutReq } from '@/lib/tests/helpers/makeCheckoutReq'; + +describe('checkout origin posture contract', () => { + it('blocks POST without Origin header', async () => { + const req = makeCheckoutReq({ + idempotencyKey: 'idem_origin_missing_0001', + origin: null, + }); + + const res = await POST(req); + + expect(res.status).toBe(403); + + const body = await res.json(); + expect(body?.error?.code).toBe('ORIGIN_NOT_ALLOWED'); + }); +}); diff --git a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts index 76420d64..a2068474 100644 --- a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts +++ b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + beforeAll, + afterAll, +} from 'vitest'; import { makeCheckoutReq } from '@/lib/tests/helpers/makeCheckoutReq'; import { InvalidPayloadError } from '@/lib/services/errors'; import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts'; @@ -71,6 +79,18 @@ beforeEach(() => { vi.clearAllMocks(); }); +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; +}); + describe('checkout: setOrderPaymentIntent rejection after order creation must not be 400', () => { it('new order (isNew=true): attach rejection returns 409 CHECKOUT_CONFLICT (not 400)', async () => { const co = createOrderWithItems as unknown as MockedFn; diff --git a/frontend/lib/tests/checkout-stripe-error-contract.test.ts b/frontend/lib/tests/checkout-stripe-error-contract.test.ts index 4734fc31..208aa6c6 100644 --- a/frontend/lib/tests/checkout-stripe-error-contract.test.ts +++ b/frontend/lib/tests/checkout-stripe-error-contract.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + beforeAll, + afterAll, +} from 'vitest'; import { makeCheckoutReq } from '@/lib/tests/helpers/makeCheckoutReq'; // 1) force payments enabled so route goes into Stripe flow @@ -53,6 +61,18 @@ beforeEach(() => { vi.clearAllMocks(); }); +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; +}); + describe('checkout: Stripe errors after order creation must not be 400', () => { it('new order (isNew=true): Stripe PI creation failure returns 502 STRIPE_ERROR', async () => { const co = createOrderWithItems as unknown as MockedFn; diff --git a/frontend/lib/tests/helpers/makeCheckoutReq.ts b/frontend/lib/tests/helpers/makeCheckoutReq.ts index 04209447..95de7b9e 100644 --- a/frontend/lib/tests/helpers/makeCheckoutReq.ts +++ b/frontend/lib/tests/helpers/makeCheckoutReq.ts @@ -13,9 +13,12 @@ export function makeCheckoutReq(params: { locale?: string; // mapped to Accept-Language items?: CheckoutItemInput[]; userId?: string; + origin?: string | null; }) { const locale = params.locale ?? 'en'; const idemKey = params.idempotencyKey; + const origin = + params.origin === undefined ? 'http://localhost:3000' : params.origin; const items = params.items ?? [ { @@ -34,13 +37,18 @@ export function makeCheckoutReq(params: { if (i.selectedColor !== undefined) base.selectedColor = i.selectedColor; return base; }); + const ip = deriveTestIpFromIdemKey(idemKey); const headers = new Headers({ 'content-type': 'application/json', 'accept-language': locale, 'idempotency-key': idemKey, - 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + 'x-forwarded-for': ip, + 'x-real-ip': ip, }); + if (origin) { + headers.set('origin', origin); + } const req = new Request('http://localhost/api/shop/checkout', { method: 'POST', diff --git a/frontend/lib/tests/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/order-items-snapshot-immutable.test.ts index 4089e875..744496dc 100644 --- a/frontend/lib/tests/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/order-items-snapshot-immutable.test.ts @@ -118,6 +118,7 @@ describe("P0-6 snapshots: order_items immutability", () => { "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", "Idempotency-Key": idem, + Origin: "http://localhost:3000", }, ); const { POST: checkoutPOST } = await import( diff --git a/frontend/lib/tests/origin-posture.test.ts b/frontend/lib/tests/origin-posture.test.ts new file mode 100644 index 00000000..f6c7a7a4 --- /dev/null +++ b/frontend/lib/tests/origin-posture.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { + normalizeOrigin, + guardBrowserSameOrigin, + guardNonBrowserOnly, +} from '@/lib/security/origin'; + +function makeReq(init: RequestInit) { + return new NextRequest(new Request('http://localhost/api/test', init)); +} + +describe('origin posture helpers', () => { + beforeEach(() => { + vi.stubEnv('APP_ORIGIN', 'http://localhost:3000'); + vi.stubEnv( + 'APP_ADDITIONAL_ORIGINS', + 'https://admin.example, https://preview.example/' + ); + + vi.stubEnv('NODE_ENV', 'test'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('normalizeOrigin trims and removes trailing slash', () => { + expect(normalizeOrigin(' https://example.com/ ')).toBe( + 'https://example.com' + ); + }); + + it('guardBrowserSameOrigin allows POST with allowed Origin', () => { + const req = makeReq({ + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + }); + const res = guardBrowserSameOrigin(req); + expect(res).toBeNull(); + }); + + it('guardBrowserSameOrigin allows POST with Origin from APP_ADDITIONAL_ORIGINS', () => { + const req = makeReq({ + method: 'POST', + headers: { origin: 'https://preview.example' }, + }); + const res = guardBrowserSameOrigin(req); + expect(res).toBeNull(); + }); + + it('guardBrowserSameOrigin blocks POST with missing Origin', async () => { + const req = makeReq({ method: 'POST' }); + const res = guardBrowserSameOrigin(req); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body?.error?.code).toBe('ORIGIN_NOT_ALLOWED'); + }); + + it('guardBrowserSameOrigin blocks POST with disallowed Origin', async () => { + const req = makeReq({ + method: 'POST', + headers: { origin: 'https://evil.example' }, + }); + const res = guardBrowserSameOrigin(req); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body?.error?.code).toBe('ORIGIN_NOT_ALLOWED'); + }); + + it('guardBrowserSameOrigin allows GET without Origin', () => { + const req = makeReq({ method: 'GET' }); + const res = guardBrowserSameOrigin(req); + expect(res).toBeNull(); + }); + + it('guardNonBrowserOnly blocks when Origin is present', async () => { + const req = makeReq({ + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + }); + const res = guardNonBrowserOnly(req); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body?.error?.code).toBe('BROWSER_CONTEXT_NOT_ALLOWED'); + }); + + it('guardNonBrowserOnly blocks when Sec-Fetch-Site is same-origin', async () => { + const req = makeReq({ + method: 'POST', + headers: { 'sec-fetch-site': 'same-origin' }, + }); + const res = guardNonBrowserOnly(req); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body?.error?.code).toBe('BROWSER_CONTEXT_NOT_ALLOWED'); + }); + + it('guardNonBrowserOnly allows when no browser signals are present', () => { + const req = makeReq({ method: 'POST' }); + const res = guardNonBrowserOnly(req); + expect(res).toBeNull(); + }); +}); diff --git a/frontend/lib/tests/rate-limit-subject.test.ts b/frontend/lib/tests/rate-limit-subject.test.ts index 87bd4458..749a8a4b 100644 --- a/frontend/lib/tests/rate-limit-subject.test.ts +++ b/frontend/lib/tests/rate-limit-subject.test.ts @@ -7,10 +7,12 @@ import { getRateLimitSubject, } from '@/lib/security/rate-limit'; -const prevTrust = process.env.TRUST_FORWARDED_HEADERS; +const prevTrustForwarded = process.env.TRUST_FORWARDED_HEADERS; +const prevTrustCf = process.env.TRUST_CF_CONNECTING_IP; afterEach(() => { - process.env.TRUST_FORWARDED_HEADERS = prevTrust; + process.env.TRUST_FORWARDED_HEADERS = prevTrustForwarded; + process.env.TRUST_CF_CONNECTING_IP = prevTrustCf; }); describe('rate limit subject', () => { @@ -44,8 +46,9 @@ describe('rate limit subject', () => { expect(getClientIpFromHeaders(headers)).toBe('198.51.100.7'); }); - it('prefers cf-connecting-ip over other headers (even when trust is true)', () => { + it('prefers cf-connecting-ip over other headers when TRUST_CF_CONNECTING_IP is true', () => { process.env.TRUST_FORWARDED_HEADERS = '1'; + process.env.TRUST_CF_CONNECTING_IP = '1'; const headers = new Headers({ 'cf-connecting-ip': '203.0.113.1', @@ -79,8 +82,9 @@ describe('rate limit subject', () => { expect(getClientIpFromHeaders(headers)).toBe('198.51.100.4'); }); - it('returns clean ip6_ subject for IPv6 client ip (no ":")', () => { + it('returns clean ip6_ subject for IPv6 client ip (no ":") when TRUST_CF_CONNECTING_IP is true', () => { process.env.TRUST_FORWARDED_HEADERS = '0'; + process.env.TRUST_CF_CONNECTING_IP = '1'; const headers = new Headers({ 'cf-connecting-ip': '2001:db8::1', diff --git a/frontend/lib/tests/stripe-webhook-mismatch.test.ts b/frontend/lib/tests/stripe-webhook-mismatch.test.ts index 45636eeb..167184dc 100644 --- a/frontend/lib/tests/stripe-webhook-mismatch.test.ts +++ b/frontend/lib/tests/stripe-webhook-mismatch.test.ts @@ -104,6 +104,7 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p makeReq('http://localhost/api/shop/checkout', checkoutBody, { 'accept-language': 'uk-UA,uk;q=0.9', 'idempotency-key': idemKey, + origin: 'http://localhost:3000', }) ); diff --git a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts b/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts index 86ae1efe..6ed80c87 100644 --- a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts +++ b/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; +import { NextRequest } from 'next/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { eq } from 'drizzle-orm'; - import { db } from '@/db'; import { orders, stripeEvents } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; @@ -61,13 +61,18 @@ async function callWebhook(params: { const { POST } = await import('@/app/api/shop/webhooks/stripe/route'); - return POST( - new Request('http://localhost/api/shop/webhooks/stripe', { - method: 'POST', - headers: { 'stripe-signature': 't=0,v1=deadbeef' }, - body: JSON.stringify({ id: params.eventId }), - }) as any - ); + const req = new NextRequest('http://localhost/api/shop/webhooks/stripe', { + method: 'POST', + headers: { + 'stripe-signature': 't=0,v1=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ id: params.eventId }), + // Node-fetch/undici інколи вимагає duplex при body; безпечно лишити як any. + duplex: 'half', + } as any); + + return POST(req as any); } async function cleanupByIds(params: { diff --git a/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts b/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts index 73fe65e5..fa14b0b4 100644 --- a/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts +++ b/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts @@ -105,4 +105,19 @@ describe('stripe webhook rate limit env precedence', () => { windowSeconds: 777, }); }); + + it('rejects zero and negative values (falls back to defaults)', () => { + vi.stubEnv('STRIPE_WEBHOOK_RL_MAX', '0'); + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '-1'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2bafce0b..5819e87c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -915,7 +915,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1223,7 +1222,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1267,7 +1265,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2758,22 +2755,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz", - "integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3097,7 +3078,7 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-x64": { + "node_modules/@parcel/watcher/node_modules/@parcel/watcher-win32-x64": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==", @@ -4625,7 +4606,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.3.tgz", "integrity": "sha512-MQsPNsMMprOu+p+BWYvD69aPjD52yQNXZaENRQX7IkbWqu7lp9k04D+RPeHWJEsuyzmCCoHx3Kqqf3tcXPoNvQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -4774,22 +4754,6 @@ "node": ">=10" } }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.10.tgz", - "integrity": "sha512-HvY8XUFuoTXn6lSccDLYFlXv1SU/PzYi4PyUqGT++WfTnbw/68N/7BdUZqglGRwiSqr0qhYt/EhmBpULj0J9rA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -5054,7 +5018,7 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-win32-x64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", @@ -5091,7 +5055,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5287,7 +5250,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5298,7 +5260,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5348,7 +5309,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -5987,7 +5947,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6320,9 +6279,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -6397,7 +6356,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7425,7 +7383,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7839,7 +7796,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8650,12 +8606,12 @@ } }, "node_modules/framer-motion": { - "version": "12.27.5", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.5.tgz", - "integrity": "sha512-yUFof7Y2Y2qDJxLKeA91qMazuA6QBOoLOZ0No2J5VIQuhJLWMmGwT/5qyCfpa9mNNS3C7lOR6NhlC3mLZjLw4g==", + "version": "12.28.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.28.1.tgz", + "integrity": "sha512-72GkO7DS4FfcSjf26wx0v+rzkW8Fhn4Djh04aDbuEg7NYG8X8MhJZc6/5weG/YeEgIP+fCo8FS2y1HnXH8k8fQ==", "license": "MIT", "dependencies": { - "motion-dom": "^12.27.5", + "motion-dom": "^12.28.1", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, @@ -9741,7 +9697,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10196,7 +10151,7 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-x64-msvc": { + "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", @@ -10234,9 +10189,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -10475,9 +10430,9 @@ } }, "node_modules/motion-dom": { - "version": "12.27.5", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.5.tgz", - "integrity": "sha512-UwBv2AUOkA7/TCHr67NGjg3aRT7nbsanmmenRoR7T6IJXZp34OZB+pooGnKjMd8CqqCsF/+qwT657EkukjgmiQ==", + "version": "12.28.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.28.1.tgz", + "integrity": "sha512-xqgID69syDvXwFJnUd5bW6ajGUAr/qevRoUe/EqpsXUbVIopyWrAOiwQOhpgVQD+B7Ra60zTdj5gVkmwncebMg==", "license": "MIT", "dependencies": { "motion-utils": "^12.27.2" @@ -10550,7 +10505,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.4.tgz", "integrity": "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.1.4", "@swc/helpers": "0.5.15", @@ -10674,6 +10628,22 @@ } } }, + "node_modules/next-intl/node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.10.tgz", + "integrity": "sha512-HvY8XUFuoTXn6lSccDLYFlXv1SU/PzYi4PyUqGT++WfTnbw/68N/7BdUZqglGRwiSqr0qhYt/EhmBpULj0J9rA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/next-intl/node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -10702,6 +10672,22 @@ "dev": true, "license": "ISC" }, + "node_modules/next/node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz", + "integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -11040,7 +11026,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", @@ -11204,7 +11189,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -11358,7 +11342,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11368,7 +11351,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12356,7 +12338,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12804,7 +12785,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13158,7 +13138,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13537,7 +13516,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13554,6 +13532,21 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz", + "integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/frontend/project-structure.txt b/frontend/project-structure.txt index d0972d47..ed5c8021 100644 --- a/frontend/project-structure.txt +++ b/frontend/project-structure.txt @@ -1,21 +1,94 @@ -📄 .DS_Store 📄 .env 📄 .env.example 📄 .gitignore 📄 .prettierrc -📄 README.md 📁 actions 📄 quiz.ts 📁 app - 📄 .DS_Store + 📁 api + 📁 auth + 📁 github + 📁 callback + 📄 route.ts + 📄 route.ts + 📁 google + 📁 callback + 📄 route.ts + 📄 route.ts + 📁 login + 📄 route.ts + 📁 logout + 📄 route.ts + 📁 me + 📄 route.ts + 📁 password-reset + 📁 confirm + 📄 route.ts + 📄 route.ts + 📁 resend-verification + 📄 route.ts + 📁 signup + 📄 route.ts + 📁 verify-email + 📄 route.ts + 📁 blog-search + 📄 route.ts + 📁 questions + 📁 [category] + 📄 route.ts + 📁 quiz + 📁 guest-result + 📄 route.ts + 📁 verify-answer + 📄 route.ts + 📁 [slug] + 📄 route.ts + 📁 shop + 📁 admin + 📁 orders + 📁 reconcile-stale + 📄 route.ts + 📄 route.ts + 📁 [id] + 📁 refund + 📄 route.ts + 📄 route.ts + 📁 products + 📄 route.ts + 📁 [id] + 📄 route.ts + 📁 status + 📄 route.ts + 📁 cart + 📁 rehydrate + 📄 route.ts + 📁 catalog + 📄 route.ts + 📁 checkout + 📄 route.ts + 📁 internal + 📁 orders + 📁 restock-stale + 📄 route.ts + 📁 orders + 📁 [id] + 📄 route.ts + 📁 webhooks + 📁 stripe + 📄 route.ts + 📄 globals.css + 📄 layout.tsx 📁 [locale] 📁 about 📄 page.tsx 📁 blog + 📁 category + 📁 [category] + 📄 page.tsx + 📄 page.tsx 📁 [slug] - 📄 PostDetails.tsx 📄 page.tsx - 📄 page.tsx + 📄 PostDetails.tsx 📁 contacts 📄 page.tsx 📁 dashboard @@ -43,20 +116,20 @@ 📁 shop 📁 admin 📁 orders + 📄 page.tsx 📁 [id] - 📄 RefundButton.tsx 📄 page.tsx - 📄 page.tsx + 📄 RefundButton.tsx 📄 page.tsx 📁 products + 📁 new + 📄 page.tsx + 📄 page.tsx 📁 [id] 📁 edit 📄 page.tsx 📁 _components 📄 product-form.tsx - 📁 new - 📄 page.tsx - 📄 page.tsx 📁 cart 📄 page.tsx 📁 checkout @@ -76,6 +149,7 @@ 📄 page.tsx 📄 page.tsx 📁 products + 📄 page.tsx 📁 [slug] 📄 page.tsx 📄 page.tsx @@ -83,80 +157,6 @@ 📄 page.tsx 📁 terms-of-service 📄 page.tsx - 📁 api - 📄 .DS_Store - 📁 auth - 📄 .DS_Store - 📁 github - 📁 callback - 📄 route.ts - 📄 route.ts - 📁 google - 📁 callback - 📄 route.ts - 📄 route.ts - 📁 login - 📄 route.ts - 📁 logout - 📄 route.ts - 📁 me - 📄 route.ts - 📁 password-reset - 📁 confirm - 📄 route.ts - 📄 route.ts - 📁 resend-verification - 📄 .DS_Store - 📄 route.ts - 📁 signup - 📄 route.ts - 📁 verify-email - 📄 route.ts - 📁 questions - 📁 [category] - 📄 route.ts - 📁 quiz - 📁 [slug] - 📄 route.ts - 📁 guest-result - 📄 route.ts - 📁 verify-answer - 📄 route.ts - 📁 shop - 📁 admin - 📁 orders - 📁 [id] - 📁 refund - 📄 route.ts - 📄 route.ts - 📁 reconcile-stale - 📄 route.ts - 📄 route.ts - 📁 products - 📁 [id] - 📄 route.ts - 📁 status - 📄 route.ts - 📄 route.ts - 📁 cart - 📁 rehydrate - 📄 route.ts - 📁 catalog - 📄 route.ts - 📁 checkout - 📄 route.ts - 📁 internal - 📁 orders - 📁 restock-stale - 📄 route.ts - 📁 orders - 📁 [id] - 📄 route.ts - 📁 webhooks - 📁 stripe - 📄 route.ts - 📄 globals.css - 📄 layout.tsx 📄 checkout.json 📄 client.ts 📁 components @@ -174,11 +174,18 @@ 📄 GitHubIcon.tsx 📄 GoogleIcon.tsx 📄 logoutButton.tsx + 📄 OAuthButtons.tsx + 📄 PostAuthQuizSync.tsx + 📄 ProviderButton.tsx 📁 blog 📄 AuthorModal.tsx 📄 BlogCard.tsx + 📄 BlogCategoryGrid.tsx + 📄 BlogCategoryLinks.tsx 📄 BlogFilters.tsx 📄 BlogGrid.tsx + 📄 BlogHeaderSearch.tsx + 📄 BlogNavLinks.tsx 📁 dashboard 📄 ProfileCard.tsx 📄 QuizSavedBanner.tsx @@ -259,7 +266,6 @@ 📄 index.ts 📁 legacy-migrations 📁 drizzle_legacy - 📄 .DS_Store 📄 0000_rich_magus.sql 📄 0001_black_random.sql 📄 0002_yielding_purple_man.sql @@ -344,6 +350,10 @@ 📄 seed-quiz-verify.ts 📄 seed-quiz-vue.ts 📄 seed-users.ts +📁 docs + 📁 payments + 📁 security + 📄 origin-posture.md 📁 drizzle 📄 0000_dry_young_avengers.sql 📄 0001_add_payment_attempts.sql @@ -417,7 +427,6 @@ 📄 errors.ts 📄 inventory.ts 📁 orders - 📄 _shared.ts 📄 checkout.ts 📄 payment-attempts.ts 📄 payment-intent.ts @@ -428,6 +437,7 @@ 📄 restock.ts 📄 summary.ts 📄 sweeps.ts + 📄 _shared.ts 📄 orders.ts 📁 products 📁 admin @@ -454,8 +464,6 @@ 📄 request-locale.ts 📄 slug.ts 📁 tests - 📁 __mocks__ - 📄 server-only.ts 📄 admin-api-killswitch.test.ts 📄 admin-csrf-contract.test.ts 📄 admin-product-patch-price-config-error-contract.test.ts @@ -464,15 +472,18 @@ 📄 checkout-concurrency-stock1.test.ts 📄 checkout-currency-policy.test.ts 📄 checkout-no-payments.test.ts + 📄 checkout-origin-posture-contract.test.ts 📄 checkout-set-payment-intent-reject-contract.test.ts 📄 checkout-stripe-error-contract.test.ts 📄 currency.test.ts 📄 format-money.test.ts 📁 helpers + 📄 ip.ts 📄 makeCheckoutReq.ts 📄 order-items-snapshot-immutable.test.ts 📄 order-items-variants.test.ts 📄 orders-access.test.ts + 📄 origin-posture.test.ts 📄 payment-state-legacy-writers.test.ts 📄 payment-state-machine.helper.test.ts 📄 payment-status-tripwire.test.ts @@ -481,6 +492,8 @@ 📄 product-admin-update-prices-patch-semantics.test.ts 📄 product-sale-invariant.test.ts 📄 public-product-visibility.test.ts + 📄 rate-limit-subject-normalization.test.ts + 📄 rate-limit-subject.test.ts 📄 restock-order-only-once.test.ts 📄 restock-release-failure-invariant.test.ts 📄 restock-stale-claim-gate.test.ts @@ -491,7 +504,10 @@ 📄 stripe-webhook-mismatch.test.ts 📄 stripe-webhook-paid-status-repair.test.ts 📄 stripe-webhook-psp-fields.test.ts + 📄 stripe-webhook-rate-limit-env.test.ts 📄 stripe-webhook-refund-full.test.ts + 📁 __mocks__ + 📄 server-only.ts 📁 types 📄 shop.ts 📄 utils.ts @@ -507,9 +523,9 @@ 📄 package-lock.json 📄 package.json 📁 parse - 📄 README.md 📄 parse.ts 📄 questions.json + 📄 README.md 📄 start.json 📁 typescript 📁 advanced @@ -532,8 +548,10 @@ 📄 favicon-light.svg 📄 lifestyle.jpg 📄 placeholder.svg +📄 README.md 📄 save-structure.cjs 📁 scripts 📄 shop-janitor-restock-stale.mjs 📄 tsconfig.json +📄 tsconfig.tsbuildinfo 📄 vitest.config.ts \ No newline at end of file diff --git a/studio/package-lock.json b/studio/package-lock.json index 2766ce98..0c2884d2 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -316,7 +316,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2046,7 +2045,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -2138,7 +2136,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2161,7 +2158,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2195,7 +2191,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2251,7 +2246,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -3750,7 +3744,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3965,7 +3958,6 @@ "resolved": "https://registry.npmjs.org/@portabletext/editor/-/editor-3.3.16.tgz", "integrity": "sha512-Z+/HXuOf1zwDpwp54uR9lsQtJ0tak5rr/9FgwObjUB1LAPNZm8IUezfQO56U3gvoQSZ7Rhj5/+BC+6hnnAKfyw==", "license": "MIT", - "peer": true, "dependencies": { "@portabletext/block-tools": "^4.1.11", "@portabletext/keyboard-shortcuts": "^2.1.1", @@ -4136,7 +4128,6 @@ "resolved": "https://registry.npmjs.org/@portabletext/sanity-bridge/-/sanity-bridge-1.2.14.tgz", "integrity": "sha512-Is4ggV86dEMm1XjTJVLswsCeWobJm5E61T9jzm64eyr9d25oVEr9lqskxPuXemap6m9t3lnKDln/Ey/qEaAeCw==", "license": "MIT", - "peer": true, "dependencies": { "@portabletext/schema": "^2.1.0", "@sanity/schema": "^4.20.3", @@ -4636,7 +4627,6 @@ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-7.14.0.tgz", "integrity": "sha512-eXue3rc4MqJh89mvuTC0h0pdoY8lwXjlV8odFB3EF7aSFKF7F5BL0NU2mlTrCZYbPAlV3JTvMPPLGJCORqOKDw==", "license": "MIT", - "peer": true, "dependencies": { "@sanity/eventsource": "^5.0.2", "get-it": "^8.7.0", @@ -5954,7 +5944,6 @@ "resolved": "https://registry.npmjs.org/@sanity/types/-/types-4.22.0.tgz", "integrity": "sha512-VWAUc8Xtj4IipQt99SzudRldJWHfBVnde+g5qnLZ/Nc1MpFjGiRWu/3smRN5mPOqlrtUfrr0ho/fBZnjE1CEMg==", "license": "MIT", - "peer": true, "dependencies": { "@sanity/client": "^7.13.2", "@sanity/media-library-types": "^1.0.1" @@ -6583,11 +6572,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", - "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6609,7 +6597,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6726,7 +6713,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -7066,7 +7052,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7589,9 +7574,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -7697,7 +7682,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8319,12 +8303,12 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", - "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -9184,7 +9168,6 @@ "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9259,7 +9242,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10059,12 +10041,12 @@ } }, "node_modules/framer-motion": { - "version": "12.27.5", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.5.tgz", - "integrity": "sha512-yUFof7Y2Y2qDJxLKeA91qMazuA6QBOoLOZ0No2J5VIQuhJLWMmGwT/5qyCfpa9mNNS3C7lOR6NhlC3mLZjLw4g==", + "version": "12.28.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.28.1.tgz", + "integrity": "sha512-72GkO7DS4FfcSjf26wx0v+rzkW8Fhn4Djh04aDbuEg7NYG8X8MhJZc6/5weG/YeEgIP+fCo8FS2y1HnXH8k8fQ==", "license": "MIT", "dependencies": { - "motion-dom": "^12.27.5", + "motion-dom": "^12.28.1", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, @@ -10955,7 +10937,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" } @@ -12197,7 +12178,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12547,15 +12527,15 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -13015,12 +12995,12 @@ "license": "MIT" }, "node_modules/motion": { - "version": "12.27.5", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.27.5.tgz", - "integrity": "sha512-Am4QS7Nd9+yhAOQSefziBmX0hYtc0HaWbXY5+0r/0J8eBBFf5jXzlBew+v+7i+eNmdVpDagVqwjES8fPYtEayA==", + "version": "12.28.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.28.1.tgz", + "integrity": "sha512-qGq5+6r4IMivHbT2EUhCwxz2NgFBuba3sWDrxcHt06+nYqKMevYJiVh/N90nMRof+vIUpiq8C22ZeOXwkWWiZg==", "license": "MIT", "dependencies": { - "framer-motion": "^12.27.5", + "framer-motion": "^12.28.1", "tslib": "^2.4.0" }, "peerDependencies": { @@ -13041,9 +13021,9 @@ } }, "node_modules/motion-dom": { - "version": "12.27.5", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.5.tgz", - "integrity": "sha512-UwBv2AUOkA7/TCHr67NGjg3aRT7nbsanmmenRoR7T6IJXZp34OZB+pooGnKjMd8CqqCsF/+qwT657EkukjgmiQ==", + "version": "12.28.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.28.1.tgz", + "integrity": "sha512-xqgID69syDvXwFJnUd5bW6ajGUAr/qevRoUe/EqpsXUbVIopyWrAOiwQOhpgVQD+B7Ra60zTdj5gVkmwncebMg==", "license": "MIT", "dependencies": { "motion-utils": "^12.27.2" @@ -13984,9 +13964,9 @@ } }, "node_modules/prettier": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", - "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -14246,7 +14226,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14277,7 +14256,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14344,8 +14322,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -14665,8 +14642,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -14995,7 +14971,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -15107,7 +15082,6 @@ "resolved": "https://registry.npmjs.org/sanity/-/sanity-4.22.0.tgz", "integrity": "sha512-wBmr/euVC6Kvni1gKP2qwkEsyxGQlfnPOhjowT7tjm0a0eOvBwS6uHnHtjr24wjhf8PeongurQzMewx2tTP8Wg==", "license": "MIT", - "peer": true, "dependencies": { "@date-fns/tz": "^1.4.1", "@dnd-kit/core": "^6.3.1", @@ -16147,8 +16121,7 @@ "version": "0.120.0", "resolved": "https://registry.npmjs.org/slate/-/slate-0.120.0.tgz", "integrity": "sha512-CXK/DADGgMZb4z9RTtXylzIDOxvmNJEF9bXV2bAGkLWhQ3rm7GORY9q0H/W41YJvAGZsLbH7nnrhMYr550hWDQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/slate-dom": { "version": "0.119.0", @@ -16652,7 +16625,6 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.8.tgz", "integrity": "sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -16846,7 +16818,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17130,7 +17101,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17487,7 +17457,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17598,7 +17567,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" },