create-nextblock 0.9.5 → 0.9.61

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.9.5",
3
+ "version": "0.9.61",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  // app/api/process-image/route.ts
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import { getS3Client } from '@nextblock-cms/utils/server';
3
+ import { getS3Client } from '@nextblock-cms/utils/server';
4
4
  import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
5
5
  import sharp from 'sharp';
6
6
  import { Readable } from 'stream';
@@ -54,13 +54,13 @@ export async function POST(request: NextRequest) {
54
54
 
55
55
 
56
56
  try {
57
- const s3Client = await getS3Client();
58
- if (!s3Client) {
59
- console.error('R2 client is not configured. Check your R2 environment variables.');
60
- return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
61
- }
62
-
63
- const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
57
+ const s3Client = await getS3Client();
58
+ if (!s3Client) {
59
+ console.error('R2 client is not configured. Check your R2 environment variables.');
60
+ return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
61
+ }
62
+
63
+ const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
64
64
 
65
65
  if (!originalObjectKey || !originalContentType) {
66
66
  return NextResponse.json({ error: 'Missing objectKey or contentType in request body.' }, { status: 400 });
@@ -127,7 +127,11 @@ export async function POST(request: NextRequest) {
127
127
  .toFormat(TARGET_FORMAT, { quality: 75 }) // Adjust quality as needed
128
128
  .toBuffer();
129
129
 
130
- const newObjectKey = `${baseName}_${size.label}.${TARGET_FORMAT}`;
130
+ // size.label keeps its '_avif' suffix as the stored variantLabel (used for
131
+ // variant selection), but it's redundant in the filename since the extension is
132
+ // already .avif — strip it so keys read `..._large.avif`, not `..._large_avif.avif`.
133
+ const fileSuffix = size.label.replace(/_avif$/, '');
134
+ const newObjectKey = `${baseName}_${fileSuffix}.${TARGET_FORMAT}`;
131
135
  const newPublicUrl = `${R2_PUBLIC_URL_BASE}/${newObjectKey}`;
132
136
 
133
137
  const putObjectParams = {
@@ -67,7 +67,13 @@ export default function ContentLanguageSwitcher({
67
67
  .eq('translation_group_id', currentItem.translation_group_id);
68
68
 
69
69
  if (error) {
70
- console.error(`Error fetching translations for ${itemType} group ${currentItem.translation_group_id}:`, error);
70
+ // Surface a useful message: a network "Failed to fetch" (e.g. the browser
71
+ // Supabase client before the runtime public env is in place) serializes to {},
72
+ // so log the message/code explicitly.
73
+ console.error(
74
+ `Error fetching translations for ${itemType} group ${currentItem.translation_group_id}:`,
75
+ error.message || error.code || JSON.stringify(error),
76
+ );
71
77
  setTranslations([]);
72
78
  } else if (data) {
73
79
  const mappedTranslations = data.map(item => {
@@ -116,17 +122,17 @@ export default function ContentLanguageSwitcher({
116
122
 
117
123
  // Link to create new translation if it doesn't exist
118
124
  // This requires a more complex "create translation" flow or pre-created placeholders
119
- const tableMap = {
120
- 'page': 'pages',
121
- 'post': 'posts',
122
- 'product': 'products'
123
- };
124
- const baseUrl = `/cms/${tableMap[itemType]}`;
125
- const editUrl = version
126
- ? `${baseUrl}/${version.id}/edit`
127
- : itemType === 'product'
128
- ? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
129
- : `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
125
+ const tableMap = {
126
+ 'page': 'pages',
127
+ 'post': 'posts',
128
+ 'product': 'products'
129
+ };
130
+ const baseUrl = `/cms/${tableMap[itemType]}`;
131
+ const editUrl = version
132
+ ? `${baseUrl}/${version.id}/edit`
133
+ : itemType === 'product'
134
+ ? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
135
+ : `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
130
136
 
131
137
  if (version) {
132
138
  return (
@@ -160,4 +166,4 @@ export default function ContentLanguageSwitcher({
160
166
  </DropdownMenuContent>
161
167
  </DropdownMenu>
162
168
  );
163
- }
169
+ }
@@ -8,6 +8,7 @@ import { DeferredCartDrawer } from '../components/DeferredCartDrawer';
8
8
  import { CURRENCY_COOKIE_NAME } from '@nextblock-cms/ecommerce/currency-constants';
9
9
  import { ToasterProvider } from './ToasterProvider';
10
10
  import { AppShell } from '../components/AppShell';
11
+ import { PublicEnvBootstrap } from '../components/PublicEnvBootstrap';
11
12
  import { ConsentGatedAnalytics } from '../components/privacy/ConsentGatedAnalytics';
12
13
  import { ConsentBanner } from '../components/privacy/ConsentBanner';
13
14
  import { getPrivacySettings } from '../lib/privacy/settings';
@@ -447,21 +448,13 @@ export default async function RootLayout({
447
448
  ? (await import('@vercel/toolbar/next')).VercelToolbar
448
449
  : null;
449
450
 
450
- // Expose the PUBLIC Supabase values (url + anon key — both safe to ship to the
451
- // browser) at runtime. In production the client uses the build-time-inlined
452
- // NEXT_PUBLIC_* and ignores this; it only matters in local dev, where the wizard
453
- // writes those vars at runtime and the already-loaded browser bundle would otherwise
454
- // hold stale empties until a dev-server restart. Read from server process.env here,
455
- // so it's always fresh.
456
- const publicEnvBootstrap = (() => {
457
- const url = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
458
- const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
459
- if (!url || !anonKey) return '';
460
- return `window.__NEXTBLOCK_PUBLIC_ENV__=${JSON.stringify({ url, anonKey }).replace(
461
- /</g,
462
- '\\u003c',
463
- )};`;
464
- })();
451
+ // Expose the PUBLIC Supabase values (url + anon key — both safe to ship to the browser)
452
+ // to the client at runtime via <PublicEnvBootstrap>. In production the client uses the
453
+ // build-time-inlined NEXT_PUBLIC_* and these just match; it only matters in local dev,
454
+ // where the wizard writes those vars at runtime and the loaded bundle would otherwise
455
+ // hold stale empties until a dev-server restart. Read from server process.env (fresh).
456
+ const publicSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
457
+ const publicSupabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
465
458
 
466
459
  return (
467
460
  <html lang={serverDeterminedLocale} suppressHydrationWarning>
@@ -470,14 +463,13 @@ export default async function RootLayout({
470
463
  {globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
471
464
  </head>
472
465
  <body className="min-h-screen">
473
- {publicEnvBootstrap && (
474
- <Script
475
- id="nextblock-public-env"
476
- strategy={TRUSTED_TYPES_SCRIPT_STRATEGY}
477
- nonce={nonce}
478
- dangerouslySetInnerHTML={{ __html: publicEnvBootstrap }}
479
- />
480
- )}
466
+ {/* Sets window.__NEXTBLOCK_PUBLIC_ENV__ synchronously during render, before any
467
+ descendant calls the browser Supabase client — the local-dev runtime fallback. */}
468
+ <PublicEnvBootstrap
469
+ url={publicSupabaseUrl}
470
+ anonKey={publicSupabaseAnonKey}
471
+ r2Base={process.env.NEXT_PUBLIC_R2_BASE_URL || ''}
472
+ />
481
473
  {/* In development this loads after hydration to avoid browser-hidden nonce comparisons. */}
482
474
  <Script
483
475
  id="trusted-types-bootstrap"
@@ -65,7 +65,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
65
65
  <TranslationBridge translations={translations}>
66
66
  <ThemeProvider
67
67
  attribute="class"
68
- defaultTheme="system"
68
+ defaultTheme="light"
69
69
  enableSystem
70
70
  disableTransitionOnChange
71
71
  nonce={nonce}
@@ -182,8 +182,12 @@ export default function SetupWizard({
182
182
  put('R2_BUCKET_NAME', storage.bucket);
183
183
  put('R2_ACCESS_KEY_ID', storage.accessKeyId);
184
184
  put('R2_SECRET_ACCESS_KEY', storage.secretAccessKey);
185
+ // Both must hold the bucket's public read URL: image src is built from
186
+ // NEXT_PUBLIC_R2_BASE_URL, while next.config remotePatterns + CSP read
187
+ // NEXT_PUBLIC_R2_PUBLIC_URL. The wizard collects one URL and writes it to both
188
+ // (baseUrl only differs if a channel prefilled a separate custom domain).
185
189
  put('NEXT_PUBLIC_R2_PUBLIC_URL', storage.publicUrl);
186
- put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl);
190
+ put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl || storage.publicUrl);
187
191
  put('SMTP_HOST', smtp.host);
188
192
  put('SMTP_PORT', smtp.port);
189
193
  put('SMTP_USER', smtp.user);
@@ -734,21 +738,19 @@ function StorageStep({
734
738
  onChange={(e) => setStorage({ ...storage, secretAccessKey: e.target.value })}
735
739
  />
736
740
  </Field>
737
- <Field label="Public URL" htmlFor="r2Public">
738
- <Input
739
- id="r2Public"
740
- value={storage.publicUrl}
741
- onChange={(e) => setStorage({ ...storage, publicUrl: e.target.value })}
742
- />
743
- </Field>
744
- <Field label="Custom domain (optional)" htmlFor="r2Base">
745
- <Input
746
- id="r2Base"
747
- value={storage.baseUrl}
748
- onChange={(e) => setStorage({ ...storage, baseUrl: e.target.value })}
749
- />
750
- </Field>
751
741
  </div>
742
+ <Field label="Public bucket URL" htmlFor="r2Public">
743
+ <Input
744
+ id="r2Public"
745
+ placeholder="https://pub-xxxx.r2.dev"
746
+ value={storage.publicUrl}
747
+ onChange={(e) => setStorage({ ...storage, publicUrl: e.target.value })}
748
+ />
749
+ <p className="mt-1 text-xs text-muted-foreground">
750
+ Your bucket&apos;s public URL (Cloudflare R2 → your bucket → Public Development URL)
751
+ or a custom domain. Used to serve all media on your site.
752
+ </p>
753
+ </Field>
752
754
  </div>
753
755
  );
754
756
  }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ // Makes the PUBLIC Supabase values available to the browser at runtime via a global,
4
+ // set SYNCHRONOUSLY during render (before any descendant calls the browser createClient).
5
+ //
6
+ // Why: NEXT_PUBLIC_* are inlined into the client bundle at build time. On a fresh local
7
+ // dev setup the dev server starts with no env, so the loaded bundle holds empties; the
8
+ // /setup wizard then writes the env at runtime. Browser-side readers (the Supabase client,
9
+ // env checks, and resolveMediaUrl's R2 base) read `process.env.NEXT_PUBLIC_* ||
10
+ // window.__NEXTBLOCK_PUBLIC_ENV__`, so this fills the gap without a dev-server restart. In
11
+ // production the inlined values win and these props just match them (a harmless no-op). All
12
+ // of these (Supabase url + anon key, R2 public base) are public values (safe to ship).
13
+ //
14
+ // This runs at render time (not in an effect, and not via a nonce'd <script> that could
15
+ // be deferred/blocked in dev), so the global is set before sibling/descendant components
16
+ // render. It's mounted in the root layout, so the value persists across client navigations.
17
+ type PublicEnv = { url: string; anonKey: string; r2Base?: string };
18
+
19
+ declare global {
20
+ interface Window {
21
+ __NEXTBLOCK_PUBLIC_ENV__?: PublicEnv;
22
+ }
23
+ }
24
+
25
+ export function PublicEnvBootstrap({ url, anonKey, r2Base }: PublicEnv) {
26
+ if (typeof window !== 'undefined' && url && anonKey) {
27
+ window.__NEXTBLOCK_PUBLIC_ENV__ = { url, anonKey, r2Base };
28
+ }
29
+ return null;
30
+ }
@@ -20,7 +20,15 @@ const BUNDLED_PUBLIC_MEDIA_KEYS = new Set([
20
20
 
21
21
  export function resolveMediaUrl(
22
22
  objectKey?: string | null,
23
- baseUrl = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
23
+ // On the client, NEXT_PUBLIC_R2_BASE_URL is inlined at build time, so a fresh-clone
24
+ // dev bundle built with no env holds an empty string; fall back to the runtime value
25
+ // injected by <PublicEnvBootstrap> (window.__NEXTBLOCK_PUBLIC_ENV__.r2Base). On the
26
+ // server, process.env is always current and the window branch is skipped.
27
+ baseUrl = process.env.NEXT_PUBLIC_R2_BASE_URL ||
28
+ (typeof window !== 'undefined'
29
+ ? (window as { __NEXTBLOCK_PUBLIC_ENV__?: { r2Base?: string } })
30
+ .__NEXTBLOCK_PUBLIC_ENV__?.r2Base || ''
31
+ : '')
24
32
  ) {
25
33
  if (!objectKey) return null;
26
34
 
@@ -32,6 +32,36 @@ export interface ConnectionInput {
32
32
  accessToken?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * Sanity-check a Supabase API key offline. Legacy keys are JWTs carrying { role, ref };
37
+ * newer keys are opaque sb_secret_* / sb_publishable_* strings. We use this to reject an
38
+ * anon key pasted into the service-role field (and vice-versa) BEFORE it gets written —
39
+ * otherwise it only surfaces much later as "permission denied" on the first write, since
40
+ * a SELECT probe can't tell the keys apart (anon can also read site_settings).
41
+ */
42
+ function inspectSupabaseKey(key: string): {
43
+ role?: string;
44
+ ref?: string;
45
+ format: 'jwt' | 'secret' | 'publishable' | 'unknown';
46
+ } {
47
+ if (key.startsWith('sb_secret_')) return { role: 'service_role', format: 'secret' };
48
+ if (key.startsWith('sb_publishable_')) return { role: 'anon', format: 'publishable' };
49
+ const parts = key.split('.');
50
+ if (parts.length === 3) {
51
+ try {
52
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
53
+ return {
54
+ role: typeof payload?.role === 'string' ? payload.role : undefined,
55
+ ref: typeof payload?.ref === 'string' ? payload.ref : undefined,
56
+ format: 'jwt',
57
+ };
58
+ } catch {
59
+ // not a decodable JWT — fall through to 'unknown'
60
+ }
61
+ }
62
+ return { format: 'unknown' };
63
+ }
64
+
35
65
  /**
36
66
  * Step (Profile B / local only): validate the Supabase credentials, then persist them
37
67
  * to `.env.local` and the live process. Probes with the service-role key so we can
@@ -56,6 +86,38 @@ export async function saveSupabaseConnection(input: ConnectionInput): Promise<Ac
56
86
  return { ok: false, error: 'The Supabase URL is not a valid URL.' };
57
87
  }
58
88
 
89
+ // The anon and service-role keys are easy to swap (both start with "eyJ"). Catch a
90
+ // swapped or wrong-project key here, offline, with a clear message.
91
+ const svcKey = inspectSupabaseKey(serviceRoleKey);
92
+ if (svcKey.role && svcKey.role !== 'service_role') {
93
+ return {
94
+ ok: false,
95
+ error: `That service-role key is actually the "${svcKey.role}" key. Paste the secret service_role key from Supabase → Project Settings → API.`,
96
+ };
97
+ }
98
+ const anonKeyInfo = inspectSupabaseKey(anonKey);
99
+ if (anonKeyInfo.role && anonKeyInfo.role !== 'anon') {
100
+ return {
101
+ ok: false,
102
+ error: `That anon key is actually the "${anonKeyInfo.role}" key. Paste the public anon key from Supabase → Project Settings → API.`,
103
+ };
104
+ }
105
+ let urlRef: string | undefined;
106
+ try {
107
+ const host = new URL(supabaseUrl).hostname;
108
+ if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
109
+ urlRef = host.split('.')[0];
110
+ }
111
+ } catch {
112
+ // already validated above
113
+ }
114
+ if (urlRef && svcKey.ref && svcKey.ref !== urlRef) {
115
+ return {
116
+ ok: false,
117
+ error: `That service-role key belongs to project "${svcKey.ref}", but the URL is project "${urlRef}". Use keys from the same project.`,
118
+ };
119
+ }
120
+
59
121
  if (!isLocalWritableEnv()) {
60
122
  return {
61
123
  ok: false,
@@ -69,6 +131,27 @@ export async function saveSupabaseConnection(input: ConnectionInput): Promise<Ac
69
131
  auth: { persistSession: false, autoRefreshToken: false },
70
132
  });
71
133
 
134
+ // Definitive service-role check: only the service_role can call the GoTrue admin API.
135
+ // A SELECT on site_settings can't tell service_role from anon (both can read it), so
136
+ // this catches a rotated/invalid key that the offline inspection above can't. Works on
137
+ // a fresh project too (the auth schema always exists, independent of the public schema).
138
+ try {
139
+ const { error: adminErr } = await probe.auth.admin.listUsers({ page: 1, perPage: 1 });
140
+ if (adminErr) {
141
+ return {
142
+ ok: false,
143
+ error: `That key can't perform admin actions (${adminErr.message}). Confirm you pasted the secret service_role key from Supabase → Project Settings → API.`,
144
+ };
145
+ }
146
+ } catch (caught) {
147
+ return {
148
+ ok: false,
149
+ error: `Could not verify the service-role key: ${
150
+ caught instanceof Error ? caught.message : 'unknown error'
151
+ }`,
152
+ };
153
+ }
154
+
72
155
  let schemaReady = false;
73
156
  try {
74
157
  const { error } = await probe.from('site_settings').select('key').limit(1);
@@ -141,6 +141,19 @@ const TRACKING_DDL =
141
141
  'create table if not exists supabase_migrations.schema_migrations ' +
142
142
  '(version text primary key, name text, statements text[]);';
143
143
 
144
+ // Re-grant the Supabase API roles on everything in `public` after all migrations run.
145
+ // Migration 06 grants ON ALL TABLES, but only covers tables that exist at that point;
146
+ // later migrations (e.g. content_drafts in 14) rely on the schema's DEFAULT PRIVILEGES,
147
+ // which a `DROP SCHEMA public CASCADE` reset wipes. This final, idempotent pass mirrors
148
+ // migration 06's grants over the FINAL table set, so no table is left ungranted
149
+ // ("permission denied"). RLS still governs row access on top of these base grants.
150
+ const GRANTS_SQL =
151
+ 'grant usage on schema public to anon, authenticated, service_role;' +
152
+ 'grant select on all tables in schema public to anon;' +
153
+ 'grant all on all tables in schema public to authenticated, service_role;' +
154
+ 'grant all on all sequences in schema public to anon, authenticated, service_role;' +
155
+ 'grant execute on all functions in schema public to anon, authenticated, service_role;';
156
+
144
157
  function recordSql(version: string, file: string): string {
145
158
  return (
146
159
  `insert into supabase_migrations.schema_migrations (version, name) ` +
@@ -300,6 +313,7 @@ async function applyViaManagementApi(
300
313
  applied += 1;
301
314
  }
302
315
 
316
+ await run(GRANTS_SQL);
303
317
  await run("notify pgrst, 'reload schema';");
304
318
  return { ok: true, applied };
305
319
  } catch (caught) {
@@ -349,9 +363,8 @@ async function applyViaPostgres(
349
363
  applied += 1;
350
364
  }
351
365
 
352
- if (applied > 0) {
353
- await db.unsafe("notify pgrst, 'reload schema';");
354
- }
366
+ await db.unsafe(GRANTS_SQL);
367
+ await db.unsafe("notify pgrst, 'reload schema';");
355
368
  return { ok: true, applied };
356
369
  } catch (caught) {
357
370
  const message =
@@ -151,6 +151,19 @@ function getRemotePatterns() {
151
151
  /** @type {RemotePattern[]} */
152
152
  const patterns = [];
153
153
 
154
+ // Well-known media/storage providers, allowlisted by wildcard. next.config.js is
155
+ // evaluated once at server start and is NOT re-read when .env.local changes, so on a
156
+ // fresh install (the /setup wizard writes the R2/storage env at runtime) the exact
157
+ // env-derived hosts below would be missing until a dev-server restart — which made
158
+ // next/image hard-crash with "hostname ... is not configured". These static patterns
159
+ // cover the common buckets (Cloudflare R2 public + S3 endpoints, Supabase Storage)
160
+ // regardless of env timing. Custom domains are still picked up from env below.
161
+ patterns.push(
162
+ { protocol: 'https', hostname: '**.r2.dev', pathname: '/**' },
163
+ { protocol: 'https', hostname: '**.r2.cloudflarestorage.com', pathname: '/**' },
164
+ { protocol: 'https', hostname: '**.supabase.co', pathname: '/**' },
165
+ );
166
+
154
167
  // Add R2 Bucket URL if authenticated
155
168
  if (process.env.NEXT_PUBLIC_R2_PUBLIC_URL) {
156
169
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.9.5",
3
+ "version": "0.9.61",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",