create-brainerce-store 1.67.0 → 1.68.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.
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.67.0",
34
+ version: "1.68.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -38,6 +38,13 @@ NEXT_PUBLIC_BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
38
38
  # Set it in your hosting provider's environment variables, not in this file:
39
39
  # .env.local is gitignored and never travels with a deploy.
40
40
  # `NEXT_PUBLIC_SITE_URL` is still read as a backwards-compatible alias.
41
+ #
42
+ # On some hosts SITE_URL is NOT optional. Platforms whose proxy forwards
43
+ # requests to the app as `Host: localhost:<port>` (OpenAI Sites /
44
+ # *.chatgpt.site, among others) hide the real hostname from the server, so
45
+ # nothing can be resolved from the request. Set SITE_URL there, or server-side
46
+ # API calls send `Origin: http://localhost:3000` and a sales channel with a
47
+ # configured Domain rejects them with 403.
41
48
 
42
49
  # Optional: floating WhatsApp contact button (used by some designs, e.g.
43
50
  # passepartout). Digits only, international format without "+"
@@ -81,6 +81,13 @@ development and on any host with nothing configured.
81
81
  `getRequestOrigin()` (for the `Origin` header sent to Brainerce). Hand-rolled
82
82
  versions have shipped `http://` on HTTPS hosts and internal container
83
83
  hostnames.
84
+ - **Some hosts hide the real hostname from the app.** Platforms whose proxy
85
+ forwards requests as `Host: localhost:<port>` with no `x-forwarded-host`
86
+ (OpenAI Sites / `*.chatgpt.site` does this) leave the resolver blind — the
87
+ request looks like it arrived on localhost. On such platforms `SITE_URL` is
88
+ not optional: set it, and the resolver prefers it over any internal host it
89
+ sees. Without it, server-side API calls send `Origin: http://localhost:3000`
90
+ and a channel with a configured Domain rejects them with 403.
84
91
  - **When the merchant gives you a real domain**, set `SITE_URL` in the hosting
85
92
  provider's environment variables — not in `.env.local`, which is gitignored
86
93
  and never travels with a deploy.
@@ -92,6 +92,13 @@ development and on any host with nothing configured.
92
92
  `getRequestOrigin()` (for the `Origin` header sent to Brainerce). Hand-rolled
93
93
  versions have shipped `http://` on HTTPS hosts and internal container
94
94
  hostnames.
95
+ - **Some hosts hide the real hostname from the app.** Platforms whose proxy
96
+ forwards requests as `Host: localhost:<port>` with no `x-forwarded-host`
97
+ (OpenAI Sites / `*.chatgpt.site` does this) leave the resolver blind — the
98
+ request looks like it arrived on localhost. On such platforms `SITE_URL` is
99
+ not optional: set it, and the resolver prefers it over any internal host it
100
+ sees. Without it, server-side API calls send `Origin: http://localhost:3000`
101
+ and a channel with a configured Domain rejects them with 403.
95
102
  - **When the merchant gives you a real domain**, set `SITE_URL` in the hosting
96
103
  provider's environment variables — not in `.env.local`, which is gitignored
97
104
  and never travels with a deploy.
@@ -83,7 +83,10 @@ export default async function BlogPostPage({ params }: PageProps) {
83
83
  const slug = decodeSlug(rawSlug);
84
84
  const client = await getServerClient(locale);
85
85
  const [post, storeInfo] = await Promise.all([
86
- client.blog.getPost(slug).catch(() => null),
86
+ // getPost returns null only on a genuine 404; any other API failure (a
87
+ // 403 from the channel's origin check, a network error) throws and
88
+ // surfaces as the error it is instead of masquerading as a missing post.
89
+ client.blog.getPost(slug),
87
90
  client.getStoreInfo().catch(() => null),
88
91
  ]);
89
92
  if (!post) {
@@ -220,7 +223,10 @@ export default async function BlogPostPage({ params }: PageProps) {
220
223
  const slug = decodeSlug(rawSlug);
221
224
  const client = await getServerClient();
222
225
  const [post, storeInfo] = await Promise.all([
223
- client.blog.getPost(slug).catch(() => null),
226
+ // getPost returns null only on a genuine 404; any other API failure (a
227
+ // 403 from the channel's origin check, a network error) throws and
228
+ // surfaces as the error it is instead of masquerading as a missing post.
229
+ client.blog.getPost(slug),
224
230
  client.getStoreInfo().catch(() => null),
225
231
  ]);
226
232
  if (!post) {
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { notFound } from 'next/navigation';
3
- import type { Product } from 'brainerce';
3
+ import { BrainerceError } from 'brainerce';
4
4
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
5
  import { buildMetaDescription } from '@/core/lib/seo';
6
6
  import { sanitizeHtml } from '@/core/lib/sanitize';
@@ -77,8 +77,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
77
77
  images: category.image ? [category.image] : [],
78
78
  },
79
79
  };
80
- } catch {
81
- return { title: 'Category not found' };
80
+ } catch (err) {
81
+ if (err instanceof BrainerceError && err.statusCode === 404) {
82
+ return { title: 'Category not found' };
83
+ }
84
+ // Anything else (channel origin rejection, network failure) hits the page
85
+ // render below too and surfaces there — don't stamp a misleading title.
86
+ return {};
82
87
  }
83
88
  }
84
89
 
@@ -87,14 +92,18 @@ export default async function CategoryPage({ params }: Props) {
87
92
  const slug = decodeSlug(rawSlug);
88
93
  const client = await getServerClient(locale);
89
94
 
90
- const category = await client.getCategoryBySlug(slug).catch(() => null);
95
+ // Only a genuine 404 becomes a storefront 404; a 403 from the channel's
96
+ // origin check or a network failure must surface as the error it is, not
97
+ // masquerade as a missing category (or an empty one).
98
+ const category = await client.getCategoryBySlug(slug).catch((err: unknown) => {
99
+ if (err instanceof BrainerceError && err.statusCode === 404) return null;
100
+ throw err;
101
+ });
91
102
  if (!category) notFound();
92
103
 
93
104
  // The listing endpoint owns pagination/FX/publish gating — reuse it rather
94
105
  // than duplicating a product query here.
95
- const { data: products } = await client
96
- .getProducts({ categories: [category.id], limit: 48 })
97
- .catch(() => ({ data: [] as Product[] }));
106
+ const { data: products } = await client.getProducts({ categories: [category.id], limit: 48 });
98
107
 
99
108
  const baseUrl = await getCanonicalSiteUrl();
100
109
  const url = `${baseUrl}/category/${category.slug || slug}`;
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import Link from 'next/link';
5
+
6
+ /**
7
+ * Error page — rendered when a page render throws. Kept deliberately separate
8
+ * from the 404 page: an API failure (a rejected Origin, a network error, a
9
+ * 5xx) is NOT "product not found", and dressing it up as a 404 sends the
10
+ * merchant hunting for a slug bug that isn't there. The full error is in the
11
+ * server log; this page only needs to say "temporary" and offer a retry.
12
+ */
13
+ export default function ErrorPage({
14
+ error,
15
+ reset,
16
+ }: {
17
+ error: Error & { digest?: string };
18
+ reset: () => void;
19
+ }) {
20
+ useEffect(() => {
21
+ // The server log has the full error; the digest printed here links the two.
22
+ console.error(error);
23
+ }, [error]);
24
+
25
+ return (
26
+ <div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-24 text-center">
27
+ <p className="text-sm font-medium text-muted-foreground">
28
+ <%- language === 'he' ? 'שגיאה' : 'Error' %>
29
+ </p>
30
+ <h1 className="mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
31
+ <%- language === 'he' ? 'משהו השתבש' : 'Something went wrong' %>
32
+ </h1>
33
+ <p className="mt-4 text-muted-foreground">
34
+ <%- language === 'he' ? 'זו תקלה זמנית בטעינת העמוד — לא עמוד חסר. נסו שוב בעוד רגע.' : 'This is a temporary problem loading the page — not a missing page. Please try again in a moment.' %>
35
+ </p>
36
+ <div className="mt-8 flex gap-4">
37
+ <button
38
+ type="button"
39
+ onClick={reset}
40
+ className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
41
+ >
42
+ <%- language === 'he' ? 'לנסות שוב' : 'Try again' %>
43
+ </button>
44
+ <Link
45
+ href="/"
46
+ className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
47
+ >
48
+ <%- language === 'he' ? 'לדף הבית' : 'Go home' %>
49
+ </Link>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -40,7 +40,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
40
40
  export default async function StaticPage({ params }: PageProps) {
41
41
  const { locale, slug: rawSlug } = await params;
42
42
  const slug = decodeSlug(rawSlug);
43
- const page = await (await getServerClient(locale)).content.page.getBySlug(slug, locale).catch(() => null);
43
+ // getBySlug returns null only on a genuine 404; any other API failure (a
44
+ // 403 from the channel's origin check, a network error) throws and surfaces
45
+ // as the error it is instead of masquerading as a missing page.
46
+ const page = await (await getServerClient(locale)).content.page.getBySlug(slug, locale);
44
47
  if (!page) notFound();
45
48
  return (
46
49
  <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
@@ -75,7 +78,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
75
78
  export default async function StaticPage({ params }: PageProps) {
76
79
  const { slug: rawSlug } = await params;
77
80
  const slug = decodeSlug(rawSlug);
78
- const page = await (await getServerClient()).content.page.getBySlug(slug).catch(() => null);
81
+ // getBySlug returns null only on a genuine 404; any other API failure (a
82
+ // 403 from the channel's origin check, a network error) throws and surfaces
83
+ // as the error it is instead of masquerading as a missing page.
84
+ const page = await (await getServerClient()).content.page.getBySlug(slug);
79
85
  if (!page) notFound();
80
86
  return (
81
87
  <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { notFound, permanentRedirect } from 'next/navigation';
3
- import { getProductPriceInfo } from 'brainerce';
3
+ import { BrainerceError, getProductPriceInfo } from 'brainerce';
4
4
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
5
  import { resolveCurrency } from '@/core/lib/resolve-currency';
6
6
  import { buildMetaDescription } from '@/core/lib/seo';
@@ -123,10 +123,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
123
123
  ...(brandName ? { 'product:brand': brandName } : {}),
124
124
  },
125
125
  };
126
- } catch {
127
- return {
128
- title: 'Product not found',
129
- };
126
+ } catch (err) {
127
+ if (err instanceof BrainerceError && err.statusCode === 404) {
128
+ return { title: 'Product not found' };
129
+ }
130
+ // Anything else (channel origin rejection, network failure) hits the page
131
+ // render below too and surfaces there — don't stamp a misleading title.
132
+ return {};
130
133
  }
131
134
  }
132
135
 
@@ -138,7 +141,14 @@ export default async function ProductDetailPage({ params }: Props) {
138
141
  let product;
139
142
  try {
140
143
  product = await client.getProductBySlug(slug);
141
- } catch {
144
+ } catch (err) {
145
+ // Only a genuine 404 may become a storefront 404. Anything else — a 403
146
+ // from the channel's origin check, a network failure, a 5xx — is a
147
+ // configuration or availability problem; disguising it as "product not
148
+ // found" sends the merchant hunting for a slug bug that isn't there.
149
+ if (!(err instanceof BrainerceError && err.statusCode === 404)) {
150
+ throw err;
151
+ }
142
152
  // The slug may have been RENAMED — the platform records every rename.
143
153
  // 301 the old URL to the current slug so its ranking and inbound links
144
154
  // carry over; genuine unknowns fall through to the 404 page. Default
@@ -22,16 +22,37 @@ import { headers } from 'next/headers';
22
22
  * and must not be forgeable by a request header.
23
23
  *
24
24
  * {@link getRequestOrigin} — the address a request actually ARRIVED on.
25
- * Request headers first, because this is what the backend's origin check
26
- * has to match; claiming the production domain from a preview deployment
27
- * would pass a check that ought to fail.
25
+ * Request headers first unless they name an internal host (localhost,
26
+ * 127.0.0.1, a bind address) and an origin is configured, because an
27
+ * internal host is how the platform's proxy addresses this server, not
28
+ * where the request came from. Claiming the production domain from a
29
+ * preview deployment on a real hostname would still pass a check that
30
+ * ought to fail, so public hostnames keep winning over the env.
28
31
  */
29
32
 
30
33
  const DEV_FALLBACK = 'http://localhost:3000';
31
34
 
32
- function isLoopback(host: string): boolean {
33
- const name = host.split(':')[0].toLowerCase();
34
- return name === 'localhost' || name === '127.0.0.1' || name === '[::1]' || name === '::1';
35
+ /**
36
+ * Hosts that can never be a storefront's public address: loopback names and
37
+ * the 0.0.0.0 bind address. Some hosting proxies (OpenAI Sites among them)
38
+ * forward requests to the app with `Host: localhost:<port>` and no
39
+ * `x-forwarded-host`, so seeing one of these in a request means "the proxy's
40
+ * internal leg", not "the shopper is on localhost".
41
+ */
42
+ function isInternalHost(host: string): boolean {
43
+ const name = host.startsWith('[')
44
+ ? host.slice(1, host.includes(']') ? host.indexOf(']') : host.length)
45
+ : host.split(':')[0];
46
+ const lower = name.toLowerCase();
47
+ return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === '0.0.0.0';
48
+ }
49
+
50
+ function isInternalOrigin(origin: string): boolean {
51
+ try {
52
+ return isInternalHost(new URL(origin).host);
53
+ } catch {
54
+ return false;
55
+ }
35
56
  }
36
57
 
37
58
  /**
@@ -44,7 +65,7 @@ function toOrigin(raw: string | undefined): string | null {
44
65
  if (!trimmed) return null;
45
66
  const hasProtocol = /^https?:\/\//i.test(trimmed);
46
67
  const bare = trimmed.replace(/^\/\//, '');
47
- const candidate = hasProtocol ? trimmed : `${isLoopback(bare) ? 'http' : 'https'}://${bare}`;
68
+ const candidate = hasProtocol ? trimmed : `${isInternalHost(bare) ? 'http' : 'https'}://${bare}`;
48
69
  try {
49
70
  return new URL(candidate).origin;
50
71
  } catch {
@@ -112,7 +133,7 @@ function fromForwardedHeaders(h: Headers): string | null {
112
133
  const host = forwardedHost || h.get('host')?.trim();
113
134
  if (!host) return null;
114
135
  const proto =
115
- h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isLoopback(host) ? 'http' : 'https');
136
+ h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isInternalHost(host) ? 'http' : 'https');
116
137
  return toOrigin(`${proto}://${host}`);
117
138
  }
118
139
 
@@ -187,11 +208,23 @@ export async function getRequestOrigin(requestHeaders?: Headers): Promise<string
187
208
  // dynamically rather than prerendered against a fallback origin.
188
209
  const h = requestHeaders ?? (await headers());
189
210
  const fromRequest = fromForwardedHeaders(h);
190
- if (fromRequest) return fromRequest;
191
211
 
212
+ // A public hostname from the request wins outright — it is what this
213
+ // request actually arrived on.
214
+ if (fromRequest && !isInternalOrigin(fromRequest)) return fromRequest;
215
+
216
+ // An internal host here does NOT mean the request arrived on localhost. It
217
+ // means the platform's proxy addresses this server by its bind address
218
+ // (`Host: localhost:3000`, no `x-forwarded-host`) — OpenAI Sites does
219
+ // exactly this — and the request's true origin is invisible to us. A
220
+ // configured origin is the only correct answer then. In plain local dev
221
+ // nothing is configured, so the loopback host (with its real port) still
222
+ // wins below.
192
223
  const configured = getSiteUrlFromEnv();
193
224
  if (configured) return configured;
194
225
 
226
+ if (fromRequest) return fromRequest;
227
+
195
228
  warnUnresolved();
196
229
  return DEV_FALLBACK;
197
230
  }