@reblu/site-next 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
+ import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
3
+
4
+ /**
5
+ * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
+ * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
+ * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
+ * scalar keys and the two bracket groups are ever read.
9
+ */
10
+ declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
+ /**
12
+ * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
+ * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
+ * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
+ * for any already-canonical (non-empty-string, in-range) input — that is the
16
+ * property the round-trip tests pin.
17
+ */
18
+ declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
+ /**
20
+ * Convert Next's `searchParams` route prop —
21
+ * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
+ * a repeated query key grouped into an array — into a `URLSearchParams` for
23
+ * {@link parseShopSearchParams}.
24
+ *
25
+ * NOT the same as `new URLSearchParams(searchParams)`: the native
26
+ * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
+ * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
+ * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
+ * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
+ * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
+ * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
+ * filter — this helper is what actually preserves the repeat.
33
+ */
34
+ declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
+ /**
36
+ * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
+ * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
+ * dependency on `@reblu/site-blocks` (this package has none today, and this
39
+ * one shape does not warrant adding one). Any object matching this shape is
40
+ * assignable to `ShopFiltersHrefs` by structural typing.
41
+ */
42
+ interface ShopFilterHrefs {
43
+ taxonomy: (axisKey: string, value: string | undefined) => string;
44
+ attr: (fieldKey: string, value: string) => string;
45
+ price: (min: number | undefined, max: number | undefined) => string;
46
+ onSale: (nextActive: boolean) => string;
47
+ clearAll: () => string;
48
+ }
49
+ /**
50
+ * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
+ * href preserves every OTHER current param (category, q, sort, every other
52
+ * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
+ * never silently stuck on page 4 of a now-smaller result set.
54
+ *
55
+ * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
+ * it directly with its own `searchParams` prop and route pathname — no
57
+ * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
+ * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
+ */
60
+ declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
+
62
+ /**
63
+ * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
+ * the EXACT SAME params in one call. This is the seam that keeps a shop
65
+ * collection page correct: facet counts must reflect the same filter context
66
+ * the product list is showing, and a hand-rolled page that calls
67
+ * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
+ * separately is exactly how they drift apart.
69
+ *
70
+ * Server-safe (no `'use client'`, no React) — call it directly from an async
71
+ * Server Component (route `page.tsx`) with the params parsed via
72
+ * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
+ */
74
+ interface ShopCollection {
75
+ products: ListResult<ShopProductListItem>;
76
+ facets: ShopFacets;
77
+ /**
78
+ * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
+ * `'unavailable'` when it failed and `facets` above is the degraded
80
+ * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
+ * empty `facets` as "this tenant configured no filterable axes" when it may
82
+ * really mean "the facets endpoint is down right now". A logged
83
+ * `console.warn` (see below) covers the case nobody checks this field.
84
+ */
85
+ facetsStatus: 'ok' | 'unavailable';
86
+ }
87
+ /**
88
+ * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
+ * equally critical. A product listing with no products is not a listing —
90
+ * that failure must propagate so the caller's own error handling (or a
91
+ * thrown-away `try/catch` degrading the whole page) sees it. A product
92
+ * listing with no FACETS is still a perfectly good listing, just without
93
+ * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
+ * of letting one upstream endpoint's outage take the other down too, is what
95
+ * `Promise.allSettled` (rather than `Promise.all`) buys here.
96
+ *
97
+ * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
+ * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
+ * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
+ * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
+ * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
+ * it (retry, surface a banner, alert) do so without parsing a log line.
103
+ */
104
+ declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
105
+
106
+ export { type ShopCollection as S, type ShopFilterHrefs as a, buildShopFilterHrefs as b, getShopCollection as g, parseShopSearchParams as p, serializeShopParams as s, toURLSearchParams as t };
@@ -63,7 +63,14 @@ function createProxyRoute(targetPath, opts = {}) {
63
63
  const xri = request.headers.get("x-real-ip");
64
64
  if (xri) forwardedHeaders["x-real-ip"] = xri;
65
65
  const upperMethod = method.toUpperCase();
66
- const body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
66
+ let body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
67
+ if (body && opts.transformBody) {
68
+ try {
69
+ body = opts.transformBody(body, request);
70
+ } catch (err) {
71
+ console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err);
72
+ }
73
+ }
67
74
  const search = request.nextUrl?.search ?? "";
68
75
  const controller = new AbortController();
69
76
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n const body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n /**\n * Transform the raw request body before it is forwarded upstream (REB-837\n * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection\n * is the first caller). Receives the original `request` so a transform\n * can derive values from it (e.g. `request.nextUrl.origin`). Only called\n * for a non-GET/HEAD request with a non-empty body. A thrown error is\n * logged and the ORIGINAL body is forwarded unchanged — a transform bug\n * must never turn into a hard failure for every request through this\n * route.\n */\n transformBody?: (rawBody: string, request: NextRequest) => string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n let body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n if (body && opts.transformBody) {\n try {\n body = opts.transformBody(body, request)\n } catch (err) {\n console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err)\n }\n }\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -99,6 +99,17 @@ interface CreateProxyRouteOptions {
99
99
  * @default 'Error de conexión con el servidor.'
100
100
  */
101
101
  proxyErrorMessage?: string;
102
+ /**
103
+ * Transform the raw request body before it is forwarded upstream (REB-837
104
+ * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection
105
+ * is the first caller). Receives the original `request` so a transform
106
+ * can derive values from it (e.g. `request.nextUrl.origin`). Only called
107
+ * for a non-GET/HEAD request with a non-empty body. A thrown error is
108
+ * logged and the ORIGINAL body is forwarded unchanged — a transform bug
109
+ * must never turn into a hard failure for every request through this
110
+ * route.
111
+ */
112
+ transformBody?: (rawBody: string, request: NextRequest) => string;
102
113
  }
103
114
  /**
104
115
  * Creates a Next.js Route Handler that proxies `request` to
@@ -99,6 +99,17 @@ interface CreateProxyRouteOptions {
99
99
  * @default 'Error de conexión con el servidor.'
100
100
  */
101
101
  proxyErrorMessage?: string;
102
+ /**
103
+ * Transform the raw request body before it is forwarded upstream (REB-837
104
+ * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection
105
+ * is the first caller). Receives the original `request` so a transform
106
+ * can derive values from it (e.g. `request.nextUrl.origin`). Only called
107
+ * for a non-GET/HEAD request with a non-empty body. A thrown error is
108
+ * logged and the ORIGINAL body is forwarded unchanged — a transform bug
109
+ * must never turn into a hard failure for every request through this
110
+ * route.
111
+ */
112
+ transformBody?: (rawBody: string, request: NextRequest) => string;
102
113
  }
103
114
  /**
104
115
  * Creates a Next.js Route Handler that proxies `request` to
@@ -39,7 +39,14 @@ function createProxyRoute(targetPath, opts = {}) {
39
39
  const xri = request.headers.get("x-real-ip");
40
40
  if (xri) forwardedHeaders["x-real-ip"] = xri;
41
41
  const upperMethod = method.toUpperCase();
42
- const body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
42
+ let body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
43
+ if (body && opts.transformBody) {
44
+ try {
45
+ body = opts.transformBody(body, request);
46
+ } catch (err) {
47
+ console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err);
48
+ }
49
+ }
43
50
  const search = request.nextUrl?.search ?? "";
44
51
  const controller = new AbortController();
45
52
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n const body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n /**\n * Transform the raw request body before it is forwarded upstream (REB-837\n * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection\n * is the first caller). Receives the original `request` so a transform\n * can derive values from it (e.g. `request.nextUrl.origin`). Only called\n * for a non-GET/HEAD request with a non-empty body. A thrown error is\n * logged and the ORIGINAL body is forwarded unchanged — a transform bug\n * must never turn into a hard failure for every request through this\n * route.\n */\n transformBody?: (rawBody: string, request: NextRequest) => string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n let body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n if (body && opts.transformBody) {\n try {\n body = opts.transformBody(body, request)\n } catch (err) {\n console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err)\n }\n }\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/shop/route-handlers.ts
21
+ var route_handlers_exports = {};
22
+ __export(route_handlers_exports, {
23
+ createShopApplyDiscountRoute: () => createShopApplyDiscountRoute,
24
+ createShopCheckEmailRoute: () => createShopCheckEmailRoute,
25
+ createShopCheckoutRoute: () => createShopCheckoutRoute,
26
+ createShopOrderRoute: () => createShopOrderRoute,
27
+ createShopValidateCartRoute: () => createShopValidateCartRoute
28
+ });
29
+ module.exports = __toCommonJS(route_handlers_exports);
30
+
31
+ // src/route-handlers.ts
32
+ function jsonErrorResponse(status, error, message) {
33
+ return new Response(
34
+ JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),
35
+ { status, headers: { "Content-Type": "application/json; charset=utf-8" } }
36
+ );
37
+ }
38
+ function createProxyRoute(targetPath, opts = {}) {
39
+ const {
40
+ method = "POST",
41
+ forwardSetCookie = false,
42
+ requireJson = false,
43
+ invalidRequestMessage = "Solicitud no v\xE1lida",
44
+ timeoutMs = 8e3,
45
+ configurationErrorMessage = "Error de configuraci\xF3n del servidor.",
46
+ timeoutMessage = "La solicitud ha superado el tiempo de espera.",
47
+ proxyErrorMessage = "Error de conexi\xF3n con el servidor."
48
+ } = opts;
49
+ return async function handler(request) {
50
+ if (requireJson) {
51
+ const contentType = request.headers.get("content-type") ?? "";
52
+ if (!contentType.includes("application/json")) {
53
+ return jsonErrorResponse(400, "invalid_request", invalidRequestMessage);
54
+ }
55
+ }
56
+ const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? "";
57
+ const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? "";
58
+ if (!baseUrl || !apiKey) {
59
+ console.error(
60
+ `[createProxyRoute] Missing configuration for ${targetPath}: ` + `${!baseUrl ? "baseUrl/REBLU_API_BASE_URL " : ""}${!apiKey ? "apiKey/REBLU_API_KEY" : ""}`.trim()
61
+ );
62
+ return jsonErrorResponse(500, "missing_configuration", configurationErrorMessage);
63
+ }
64
+ const forwardedHeaders = {};
65
+ const ua = request.headers.get("user-agent");
66
+ if (ua) forwardedHeaders["user-agent"] = ua;
67
+ const xff = request.headers.get("x-forwarded-for");
68
+ if (xff) forwardedHeaders["x-forwarded-for"] = xff;
69
+ const xri = request.headers.get("x-real-ip");
70
+ if (xri) forwardedHeaders["x-real-ip"] = xri;
71
+ const upperMethod = method.toUpperCase();
72
+ let body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
73
+ if (body && opts.transformBody) {
74
+ try {
75
+ body = opts.transformBody(body, request);
76
+ } catch (err) {
77
+ console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err);
78
+ }
79
+ }
80
+ const search = request.nextUrl?.search ?? "";
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
83
+ try {
84
+ const res = await fetch(`${baseUrl}${targetPath}${search}`, {
85
+ method,
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ "X-Reblu-Api-Key": apiKey,
89
+ ...forwardedHeaders
90
+ },
91
+ ...body ? { body } : {},
92
+ cache: "no-store",
93
+ signal: controller.signal
94
+ });
95
+ const data = await res.text();
96
+ const responseHeaders = new Headers({
97
+ "Content-Type": res.headers.get("Content-Type") ?? "application/json"
98
+ });
99
+ if (forwardSetCookie) {
100
+ for (const cookie of res.headers.getSetCookie()) {
101
+ responseHeaders.append("set-cookie", cookie);
102
+ }
103
+ }
104
+ return new Response(data, { status: res.status, headers: responseHeaders });
105
+ } catch (err) {
106
+ if (err instanceof Error && err.name === "AbortError") {
107
+ console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`);
108
+ return jsonErrorResponse(502, "upstream_timeout", timeoutMessage);
109
+ }
110
+ console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err);
111
+ return jsonErrorResponse(502, "proxy_error", proxyErrorMessage);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ };
116
+ }
117
+
118
+ // src/shop/checkout-redirect.ts
119
+ var DEFAULT_CHECKOUT_REDIRECT_HOSTS = ["checkout.stripe.com"];
120
+ function isSafeCheckoutRedirectUrl(url, allowedHosts = DEFAULT_CHECKOUT_REDIRECT_HOSTS) {
121
+ let parsed;
122
+ try {
123
+ parsed = new URL(url);
124
+ } catch {
125
+ return false;
126
+ }
127
+ return parsed.protocol === "https:" && allowedHosts.includes(parsed.hostname);
128
+ }
129
+
130
+ // src/shop/route-handlers.ts
131
+ var DEFAULT_WRITE_OPTS = { requireJson: true };
132
+ function normalizeSitePath(path, fallback) {
133
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) || path.startsWith("//")) {
134
+ console.error(
135
+ `[createShopCheckoutRoute] Rejected an absolute/protocol-relative path "${path}" \u2014 using the default instead.`
136
+ );
137
+ return fallback;
138
+ }
139
+ return path.startsWith("/") ? path : `/${path}`;
140
+ }
141
+ function unsafeRedirectResponse(message) {
142
+ return new Response(
143
+ JSON.stringify({ error: "unsafe_redirect", message, correlation_id: crypto.randomUUID() }),
144
+ { status: 502, headers: { "Content-Type": "application/json; charset=utf-8" } }
145
+ );
146
+ }
147
+ function extractCheckoutUrl(rawBody) {
148
+ try {
149
+ const parsed = JSON.parse(rawBody);
150
+ return typeof parsed?.data?.url === "string" ? parsed.data.url : void 0;
151
+ } catch {
152
+ return void 0;
153
+ }
154
+ }
155
+ var UNSAFE_PASSTHROUGH_HEADERS = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding"]);
156
+ function safeResponseHeaders(headers) {
157
+ const safe = new Headers();
158
+ for (const [key, value] of headers.entries()) {
159
+ if (!UNSAFE_PASSTHROUGH_HEADERS.has(key.toLowerCase())) safe.set(key, value);
160
+ }
161
+ return safe;
162
+ }
163
+ function createShopCheckoutRoute(opts = {}) {
164
+ const {
165
+ successPath,
166
+ cancelPath,
167
+ cartPath = "/cart",
168
+ siteOrigin,
169
+ allowedRedirectHosts = DEFAULT_CHECKOUT_REDIRECT_HOSTS,
170
+ unsafeRedirectMessage = "Error de conexi\xF3n con el proveedor de pago.",
171
+ ...proxyOpts
172
+ } = opts;
173
+ const resolvedCartPath = normalizeSitePath(cartPath, "/cart");
174
+ const defaultSuccessPath = `${resolvedCartPath}?pago=ok&session_id={CHECKOUT_SESSION_ID}`;
175
+ const defaultCancelPath = `${resolvedCartPath}?pago=cancelado`;
176
+ const resolvedSuccessPath = successPath !== void 0 ? normalizeSitePath(successPath, defaultSuccessPath) : defaultSuccessPath;
177
+ const resolvedCancelPath = cancelPath !== void 0 ? normalizeSitePath(cancelPath, defaultCancelPath) : defaultCancelPath;
178
+ const proxyHandler = createProxyRoute("/api/site/shop/checkout", {
179
+ ...DEFAULT_WRITE_OPTS,
180
+ ...proxyOpts,
181
+ transformBody: (rawBody, request) => {
182
+ const origin = siteOrigin ?? process.env.NEXT_PUBLIC_SITE_URL ?? request.nextUrl?.origin;
183
+ if (!origin) return rawBody;
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(rawBody);
187
+ } catch {
188
+ return rawBody;
189
+ }
190
+ if (typeof parsed.successUrl !== "string") {
191
+ parsed.successUrl = `${origin}${resolvedSuccessPath}`;
192
+ }
193
+ if (typeof parsed.cancelUrl !== "string") {
194
+ parsed.cancelUrl = `${origin}${resolvedCancelPath}`;
195
+ }
196
+ return JSON.stringify(parsed);
197
+ }
198
+ });
199
+ return async function handler(request) {
200
+ const response = await proxyHandler(request);
201
+ if (!response.ok) return response;
202
+ const rawBody = await response.text();
203
+ const url = extractCheckoutUrl(rawBody);
204
+ if (!url || !isSafeCheckoutRedirectUrl(url, allowedRedirectHosts)) {
205
+ console.error("[createShopCheckoutRoute] Blocked an unsafe checkout redirect URL");
206
+ return unsafeRedirectResponse(unsafeRedirectMessage);
207
+ }
208
+ return new Response(rawBody, { status: response.status, headers: safeResponseHeaders(response.headers) });
209
+ };
210
+ }
211
+ function createShopValidateCartRoute(opts = {}) {
212
+ return createProxyRoute("/api/site/shop/validate-cart", { ...DEFAULT_WRITE_OPTS, ...opts });
213
+ }
214
+ function createShopApplyDiscountRoute(opts = {}) {
215
+ return createProxyRoute("/api/site/shop/apply-discount", { ...DEFAULT_WRITE_OPTS, ...opts });
216
+ }
217
+ function createShopCheckEmailRoute(opts = {}) {
218
+ return createProxyRoute("/api/site/shop/check-email", { ...DEFAULT_WRITE_OPTS, ...opts });
219
+ }
220
+ function createShopOrderRoute(opts = {}) {
221
+ return async function handler(request, context) {
222
+ const { sessionId } = await context.params;
223
+ const targetPath = `/api/site/shop/order/${encodeURIComponent(sessionId)}`;
224
+ return createProxyRoute(targetPath, { ...opts, method: "GET" })(request);
225
+ };
226
+ }
227
+ // Annotate the CommonJS export names for ESM import in node:
228
+ 0 && (module.exports = {
229
+ createShopApplyDiscountRoute,
230
+ createShopCheckEmailRoute,
231
+ createShopCheckoutRoute,
232
+ createShopOrderRoute,
233
+ createShopValidateCartRoute
234
+ });
235
+ //# sourceMappingURL=shop-route-handlers.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shop/route-handlers.ts","../src/route-handlers.ts","../src/shop/checkout-redirect.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-route-handlers` (REB-829) — Route Handler factories\n * for the cart/checkout write flow (`/api/site/shop/{checkout, validate-cart,\n * apply-discount, check-email, order/[sessionId]}`). Every demo-site route\n * these replace (`app/api/shop/*`) either hand-rolled the SAME proxy\n * boilerplate `./route-handlers`'s `createProxyRoute` already generalizes\n * (checkout/validate-cart/apply-discount/check-email), or called\n * `proxyToBaseApi` directly for the one dynamic-path route (`order/\n * [sessionId]`) that `createProxyRoute` deliberately does not cover (its own\n * docblock: \"a per-request DYNAMIC targetPath... is genuinely out of scope\").\n * This module is the dynamic-path extension `createProxyRoute` named as a\n * gap, kept as its own file (not folded into `./server`, REB-809's filters/\n * collection glue) because it is a distinct concern — a page that only reads\n * shop data never needs these factories, and a cart/checkout integration\n * never needs the filters glue.\n *\n * Server-only, dependency-free (only `createProxyRoute`, the local\n * `./checkout-redirect` helper, and the `next/server` type) — same isolation\n * direction as `./route-handlers`/`./shop/server.ts`: this entry must NEVER\n * carry `'use client'` (see `tsup.config.ts`).\n *\n * Mirrors `createProxyRoute`'s own API-key/server-only handling verbatim —\n * `REBLU_API_KEY` is read lazily, server-side, and injected as\n * `X-Reblu-Api-Key` on the upstream fetch only; it never reaches the\n * Response these handlers return to the browser.\n *\n * // app/api/shop/checkout/route.ts\n * export const POST = createShopCheckoutRoute()\n *\n * // app/api/shop/order/[sessionId]/route.ts\n * export const GET = createShopOrderRoute()\n */\nimport type { NextRequest } from 'next/server'\nimport { createProxyRoute, type CreateProxyRouteOptions } from '../route-handlers'\nimport { DEFAULT_CHECKOUT_REDIRECT_HOSTS, isSafeCheckoutRedirectUrl } from './checkout-redirect'\n\n/** Options for a fixed-path write route. `method` is always `'POST'` here. */\nexport type ShopWriteRouteOptions = Omit<CreateProxyRouteOptions, 'method'>\n\n/**\n * `requireJson: true` by default for every write route below: the browser\n * clients these proxy for (the cart/checkout UI) always set\n * `Content-Type: application/json`, and rejecting anything else with a 400\n * BEFORE the upstream fetch is strictly safer than forwarding an\n * unparseable body to base-api. A SITE with a non-JSON caller can still pass\n * `{ requireJson: false }`.\n */\nconst DEFAULT_WRITE_OPTS: ShopWriteRouteOptions = { requireJson: true }\n\n/** Options for `createShopCheckoutRoute` — standard success/cancel URLs (REB-837) + the redirect-origin re-check (REB-836). */\nexport interface ShopCheckoutRouteOptions extends ShopWriteRouteOptions {\n /**\n * Path (relative to the resolved SITE origin — see `siteOrigin` below,\n * never a caller-supplied value, so this can never redirect off-SITE)\n * used to build a default `successUrl` when the posted body doesn't\n * already include one. The literal `{CHECKOUT_SESSION_ID}` placeholder is\n * preserved verbatim — Stripe substitutes it at redirect time, same as\n * `cartPath`'s `{cart}` shorthand in the issue brief. Normalized (REB-837\n * review fix): must resolve to a single SITE-relative path starting with\n * `/` — an absolute URL or a protocol-relative `//` is rejected (logged,\n * falls back to the default) rather than silently building an off-SITE\n * redirect.\n * @default `${cartPath}?pago=ok&session_id={CHECKOUT_SESSION_ID}`\n */\n successPath?: string\n /**\n * Path used to build a default `cancelUrl`, same normalization and origin\n * rule as `successPath`.\n * @default `${cartPath}?pago=cancelado`\n */\n cancelPath?: string\n /** Base path the `successPath`/`cancelPath` defaults are built from. @default '/cart' */\n cartPath?: string\n /**\n * Explicit SITE origin to build `successUrl`/`cancelUrl` from, overriding\n * `request.nextUrl.origin` (REB-837 review fix). Needed behind a reverse\n * proxy or when the Route Handler's own request origin doesn't match the\n * SITE's real public origin. Falls back to `NEXT_PUBLIC_SITE_URL` when\n * omitted, then to `request.nextUrl.origin`.\n */\n siteOrigin?: string\n /** Allowed hosts for base-api's returned Checkout URL. @default ['checkout.stripe.com'] */\n allowedRedirectHosts?: string[]\n /**\n * Overrides the `message` field of the 502 body returned when base-api's\n * Checkout URL fails the redirect-origin re-check.\n * @default 'Error de conexión con el proveedor de pago.'\n */\n unsafeRedirectMessage?: string\n}\n\n/**\n * Normalizes a SITE-relative path for `successPath`/`cancelPath`/`cartPath`\n * (REB-837 review fix): must start with exactly one `/`, never an absolute\n * URL (`scheme://…`) or a protocol-relative `//…` — either would let a\n * misconfigured caller build a redirect off the SITE's own origin despite\n * the origin itself being trustworthy (request-derived or explicit\n * `siteOrigin`). A path missing its leading slash is PREFIXED with one; an\n * absolute/protocol-relative path is REJECTED outright (logged once) and\n * falls back to `fallback` rather than silently building a dangerous URL.\n */\nfunction normalizeSitePath(path: string, fallback: string): string {\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(path) || path.startsWith('//')) {\n console.error(\n `[createShopCheckoutRoute] Rejected an absolute/protocol-relative path \"${path}\" — using the default instead.`,\n )\n return fallback\n }\n return path.startsWith('/') ? path : `/${path}`\n}\n\nfunction unsafeRedirectResponse(message: string): Response {\n return new Response(\n JSON.stringify({ error: 'unsafe_redirect', message, correlation_id: crypto.randomUUID() }),\n { status: 502, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/** Extracts `data.url` from a JSON response body, or `undefined` if absent/malformed. */\nfunction extractCheckoutUrl(rawBody: string): string | undefined {\n try {\n const parsed = JSON.parse(rawBody) as { data?: { url?: unknown } }\n return typeof parsed?.data?.url === 'string' ? parsed.data.url : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * Transport headers that describe the UPSTREAM byte stream, not `rawBody`\n * (re-serialized text `fetch` already decoded) — forwarding them verbatim\n * would tell the browser to gunzip/measure plain JSON and break checkout.\n * Review follow-up (native review on REB-836): this is an explicit blocklist,\n * not reliance on `createProxyRoute`'s current header set, so a future change\n * to that shared factory (e.g. the parallel REB-837 work) can't silently\n * reintroduce the leak here.\n */\nconst UNSAFE_PASSTHROUGH_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding'])\n\n/** Copies `headers` minus {@link UNSAFE_PASSTHROUGH_HEADERS} — safe to pair with a re-serialized body. */\nfunction safeResponseHeaders(headers: Headers): Headers {\n const safe = new Headers()\n for (const [key, value] of headers.entries()) {\n if (!UNSAFE_PASSTHROUGH_HEADERS.has(key.toLowerCase())) safe.set(key, value)\n }\n return safe\n}\n\n/**\n * `POST /api/site/shop/checkout` — starts a hosted Stripe Checkout session.\n *\n * REB-837: when the posted body doesn't already carry `successUrl`/\n * `cancelUrl` (an explicit value on the body always wins — this never\n * overrides a caller that builds its own), injects them built from\n * `successPath`/`cancelPath` and the resolved SITE origin (`siteOrigin` >\n * `NEXT_PUBLIC_SITE_URL` > `request.nextUrl.origin` — see `siteOrigin`'s\n * docblock). If no origin resolves, the body is forwarded unchanged —\n * base-api's own required-field validation is the backstop, not a second\n * origin check here.\n *\n * REB-836 (defence in depth): every SITE hands the response's `url` straight\n * to `window.location.assign`, so on a successful upstream response this\n * RE-CHECKS that `url` is `https:` on an allowed host (default\n * `checkout.stripe.com`) before ever returning it to the browser — a\n * compromised/misconfigured base-api or a tampered response in transit must\n * never redirect a buyer off Stripe's domain. Mirrors `@reblu/site-client`'s\n * own `shop.checkout()` guard (`./checkout-redirect.ts`'s docblock explains\n * why this is a deliberate duplicate, not a shared import). Non-2xx upstream\n * responses (validation errors, 404s, etc.) pass through unchanged — there is\n * no redirect URL to check.\n */\nexport function createShopCheckoutRoute(opts: ShopCheckoutRouteOptions = {}) {\n const {\n successPath,\n cancelPath,\n cartPath = '/cart',\n siteOrigin,\n allowedRedirectHosts = DEFAULT_CHECKOUT_REDIRECT_HOSTS,\n unsafeRedirectMessage = 'Error de conexión con el proveedor de pago.',\n ...proxyOpts\n } = opts\n\n const resolvedCartPath = normalizeSitePath(cartPath, '/cart')\n const defaultSuccessPath = `${resolvedCartPath}?pago=ok&session_id={CHECKOUT_SESSION_ID}`\n const defaultCancelPath = `${resolvedCartPath}?pago=cancelado`\n const resolvedSuccessPath =\n successPath !== undefined ? normalizeSitePath(successPath, defaultSuccessPath) : defaultSuccessPath\n const resolvedCancelPath =\n cancelPath !== undefined ? normalizeSitePath(cancelPath, defaultCancelPath) : defaultCancelPath\n\n const proxyHandler = createProxyRoute('/api/site/shop/checkout', {\n ...DEFAULT_WRITE_OPTS,\n ...proxyOpts,\n transformBody: (rawBody, request) => {\n // Lazy, per-request (R6): `siteOrigin` wins (explicit, proxy-safe),\n // then `NEXT_PUBLIC_SITE_URL` (env fallback for a SITE behind a\n // reverse proxy where `request.nextUrl.origin` doesn't match its real\n // public origin), then the request's own origin.\n const origin = siteOrigin ?? process.env.NEXT_PUBLIC_SITE_URL ?? request.nextUrl?.origin\n if (!origin) return rawBody\n\n let parsed: Record<string, unknown>\n try {\n parsed = JSON.parse(rawBody) as Record<string, unknown>\n } catch {\n return rawBody\n }\n\n if (typeof parsed.successUrl !== 'string') {\n parsed.successUrl = `${origin}${resolvedSuccessPath}`\n }\n if (typeof parsed.cancelUrl !== 'string') {\n parsed.cancelUrl = `${origin}${resolvedCancelPath}`\n }\n return JSON.stringify(parsed)\n },\n })\n\n return async function handler(request: NextRequest): Promise<Response> {\n const response = await proxyHandler(request)\n if (!response.ok) return response\n\n const rawBody = await response.text()\n const url = extractCheckoutUrl(rawBody)\n if (!url || !isSafeCheckoutRedirectUrl(url, allowedRedirectHosts)) {\n console.error('[createShopCheckoutRoute] Blocked an unsafe checkout redirect URL')\n return unsafeRedirectResponse(unsafeRedirectMessage)\n }\n\n return new Response(rawBody, { status: response.status, headers: safeResponseHeaders(response.headers) })\n }\n}\n\n/** `POST /api/site/shop/validate-cart` — live stock/price re-check before checkout. */\nexport function createShopValidateCartRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/validate-cart', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** `POST /api/site/shop/apply-discount` — validates a discount code against the cart. */\nexport function createShopApplyDiscountRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/apply-discount', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** `POST /api/site/shop/check-email` — whether a guest's email already has an account. */\nexport function createShopCheckEmailRoute(opts: ShopWriteRouteOptions = {}) {\n return createProxyRoute('/api/site/shop/check-email', { ...DEFAULT_WRITE_OPTS, ...opts })\n}\n\n/** Options for the dynamic order-lookup route. `method` is always `'GET'`. */\nexport type ShopOrderRouteOptions = Omit<CreateProxyRouteOptions, 'method'>\n\n/**\n * `GET /api/site/shop/order/[sessionId]` — the one route in this file with a\n * per-request dynamic target path, so it can't be a one-line\n * `createProxyRoute(...)` call like the others: it reads Next's route\n * `params`, builds `/api/site/shop/order/{sessionId}` per request, and\n * delegates everything else (env, timeout, forwarding, error shaping) to\n * `createProxyRoute`.\n */\nexport function createShopOrderRoute(opts: ShopOrderRouteOptions = {}) {\n return async function handler(\n request: NextRequest,\n context: { params: Promise<{ sessionId: string }> },\n ): Promise<Response> {\n const { sessionId } = await context.params\n const targetPath = `/api/site/shop/order/${encodeURIComponent(sessionId)}`\n return createProxyRoute(targetPath, { ...opts, method: 'GET' })(request)\n }\n}\n","/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n /**\n * Transform the raw request body before it is forwarded upstream (REB-837\n * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection\n * is the first caller). Receives the original `request` so a transform\n * can derive values from it (e.g. `request.nextUrl.origin`). Only called\n * for a non-GET/HEAD request with a non-empty body. A thrown error is\n * logged and the ORIGINAL body is forwarded unchanged — a transform bug\n * must never turn into a hard failure for every request through this\n * route.\n */\n transformBody?: (rawBody: string, request: NextRequest) => string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n let body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n if (body && opts.transformBody) {\n try {\n body = opts.transformBody(body, request)\n } catch (err) {\n console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err)\n }\n }\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n","/**\n * Stripe Checkout redirect-origin guard (REB-836, defence in depth) — the\n * server-side half. `createShopCheckoutRoute` re-checks base-api's Checkout\n * URL before handing it to the browser, which otherwise immediately\n * `window.location.assign`s it.\n *\n * This is a DELIBERATE DUPLICATE of `@reblu/site-client`'s\n * `isSafeCheckoutRedirectUrl` (`packages/site-client/src/checkout-redirect.ts`),\n * not a shared import: the `./shop-route-handlers` entry is dependency-free by\n * design (see `../route-handlers.ts`'s docblock — \"must stay dependency-free\n * (no `@reblu/site-ui`)... so importing it from an `app/api/*` route never\n * drags in client components\"), and pulling in `@reblu/site-client` here\n * would break that isolation. Update both together if Stripe's Checkout host\n * ever changes.\n */\n\n/** The only Checkout redirect host trusted by default. */\nexport const DEFAULT_CHECKOUT_REDIRECT_HOSTS: readonly string[] = ['checkout.stripe.com']\n\n/**\n * True only when `url` parses as an absolute URL, uses `https:`, and its\n * hostname is in `allowedHosts` (default: {@link DEFAULT_CHECKOUT_REDIRECT_HOSTS}).\n * Never throws — an unparsable `url` is simply unsafe.\n */\nexport function isSafeCheckoutRedirectUrl(\n url: string,\n allowedHosts: readonly string[] = DEFAULT_CHECKOUT_REDIRECT_HOSTS,\n): boolean {\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n return false\n }\n return parsed.protocol === 'https:' && allowedHosts.includes(parsed.hostname)\n}\n"],"mappings":"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","names":[]}