create-nextblock 0.9.92 → 0.9.98

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +9 -2
  3. package/templates/nextblock-template/app/actions/package-actions.ts +9 -4
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3 -2
  5. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +5 -1
  6. package/templates/nextblock-template/app/api/draft/route.ts +5 -1
  7. package/templates/nextblock-template/app/api/media/library/route.ts +6 -2
  8. package/templates/nextblock-template/app/api/process-image/route.ts +58 -43
  9. package/templates/nextblock-template/app/api/revalidate/route.ts +21 -18
  10. package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +20 -8
  11. package/templates/nextblock-template/app/api/upload/proxy/route.ts +34 -28
  12. package/templates/nextblock-template/app/article/[slug]/page.tsx +4 -13
  13. package/templates/nextblock-template/app/checkout/success/actions.ts +3 -2
  14. package/templates/nextblock-template/app/cms/media/actions.ts +47 -31
  15. package/templates/nextblock-template/app/cms/orders/actions.ts +29 -29
  16. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -28
  17. package/templates/nextblock-template/app/cms/users/actions.ts +119 -118
  18. package/templates/nextblock-template/app/cms/users/page.tsx +3 -3
  19. package/templates/nextblock-template/app/layout.tsx +10 -7
  20. package/templates/nextblock-template/app/lib/site-settings.ts +7 -4
  21. package/templates/nextblock-template/app/page.tsx +2 -1
  22. package/templates/nextblock-template/app/product/[slug]/page.tsx +9 -2
  23. package/templates/nextblock-template/app/robots.txt/route.ts +6 -3
  24. package/templates/nextblock-template/app/setup/SetupWizard.tsx +55 -8
  25. package/templates/nextblock-template/app/setup/page.tsx +17 -1
  26. package/templates/nextblock-template/app/sitemap.ts +5 -3
  27. package/templates/nextblock-template/context/language-rest-client.ts +3 -2
  28. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +125 -32
  29. package/templates/nextblock-template/lib/app-secrets.ts +39 -0
  30. package/templates/nextblock-template/lib/auth/crypto.ts +4 -1
  31. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +23 -3
  32. package/templates/nextblock-template/lib/setup/actions.ts +16 -0
  33. package/templates/nextblock-template/lib/setup/env-status.ts +44 -5
  34. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +172 -0
  35. package/templates/nextblock-template/lib/setup/schema-apply.ts +23 -7
  36. package/templates/nextblock-template/lib/site-url.ts +48 -0
  37. package/templates/nextblock-template/lib/storage/provider.ts +66 -0
  38. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -0
  39. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +1 -1
  40. package/templates/nextblock-template/next-env.d.ts +1 -1
  41. package/templates/nextblock-template/next.config.js +37 -2
  42. package/templates/nextblock-template/package.json +3 -3
  43. package/templates/nextblock-template/proxy.ts +39 -4
@@ -16,6 +16,7 @@ import { readdir, readFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import postgres from 'postgres';
18
18
  import { isLocalWritableEnv } from './env-status';
19
+ import { MIGRATIONS_BUNDLE } from './migrations-bundle';
19
20
 
20
21
  export interface SchemaApplyResult {
21
22
  ok: boolean;
@@ -238,14 +239,29 @@ export async function resetDatabase(): Promise<{ ok: boolean; error?: string }>
238
239
 
239
240
  export async function applyMigrations(): Promise<SchemaApplyResult> {
240
241
  const migrationsDir = resolveMigrationsDir();
241
- if (!migrationsDir) {
242
- return { ok: false, applied: 0, error: 'Could not locate the migrations directory.' };
243
- }
244
242
 
245
- const files = (await readdir(migrationsDir))
246
- .filter((name) => /^\d+_.*\.sql$/.test(name))
247
- .sort();
248
- const readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
243
+ let files: string[];
244
+ let readSql: (file: string) => Promise<string>;
245
+
246
+ if (migrationsDir) {
247
+ // Local dev / Docker: read the canonical .sql files from disk (always current).
248
+ files = (await readdir(migrationsDir))
249
+ .filter((name) => /^\d+_.*\.sql$/.test(name))
250
+ .sort();
251
+ readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
252
+ } else if (MIGRATIONS_BUNDLE.length > 0) {
253
+ // Serverless (Vercel): libs/db isn't on the function filesystem, so fall back to the
254
+ // build-time embedded bundle (npm run generate:migrations-bundle).
255
+ files = MIGRATIONS_BUNDLE.map((m) => m.name).sort();
256
+ const sqlByName = new Map(MIGRATIONS_BUNDLE.map((m) => [m.name, m.sql]));
257
+ readSql = async (file: string) => sqlByName.get(file) ?? '';
258
+ } else {
259
+ return {
260
+ ok: false,
261
+ applied: 0,
262
+ error: 'Could not locate the migrations (no directory and no embedded bundle).',
263
+ };
264
+ }
249
265
 
250
266
  const ref = resolveProjectRef();
251
267
  const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
@@ -0,0 +1,48 @@
1
+ // Resolve the canonical public site URL across deploy channels WITHOUT requiring
2
+ // NEXT_PUBLIC_URL to be set. On Vercel it falls back to the auto-provisioned
3
+ // production URL, so a one-click deploy needs no URL input at all.
4
+ //
5
+ // Dependency-free and safe to import anywhere — server components, route handlers,
6
+ // or client components — like lib/setup/env-status.ts. It only reads `process.env`:
7
+ // NEXT_PUBLIC_* names are inlined into the browser bundle at build time, the others
8
+ // resolve server-side only (and are simply absent — harmless — in the browser).
9
+
10
+ const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
11
+
12
+ /**
13
+ * Vercel always sets a production domain (even on preview deployments):
14
+ * `VERCEL_PROJECT_PRODUCTION_URL` server-side, and Vercel exposes the
15
+ * framework-prefixed `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` to the browser
16
+ * bundle at build time. Neither includes the protocol. We prefer the production URL
17
+ * over the per-deployment `VERCEL_URL` so absolute links (sitemap, canonical, OG)
18
+ * stay stable across deploys.
19
+ */
20
+ function vercelProductionUrl(): string {
21
+ const host =
22
+ process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
23
+ process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
24
+ return host ? `https://${stripTrailingSlash(host)}` : '';
25
+ }
26
+
27
+ /**
28
+ * Canonical absolute site origin (e.g. `https://example.com`), no trailing slash.
29
+ * Precedence: explicit `NEXT_PUBLIC_URL` → Vercel production URL → `fallback`.
30
+ */
31
+ export function resolveSiteUrl(fallback = 'http://localhost:3000'): string {
32
+ const explicit = process.env.NEXT_PUBLIC_URL?.trim();
33
+ if (explicit) return stripTrailingSlash(explicit);
34
+
35
+ const vercel = vercelProductionUrl();
36
+ if (vercel) return vercel;
37
+
38
+ return stripTrailingSlash(fallback);
39
+ }
40
+
41
+ /**
42
+ * True when a real, production-intended site URL is available (an explicit
43
+ * `NEXT_PUBLIC_URL` or the Vercel production URL) — i.e. {@link resolveSiteUrl}
44
+ * is NOT returning the local-dev fallback. Use this to gate "URL not set" warnings.
45
+ */
46
+ export function hasResolvedSiteUrl(): boolean {
47
+ return Boolean(process.env.NEXT_PUBLIC_URL?.trim() || vercelProductionUrl());
48
+ }
@@ -0,0 +1,66 @@
1
+ import 'server-only';
2
+
3
+ import { resolveSupabaseServiceKey, resolveSupabaseUrl } from '../setup/env-status';
4
+
5
+ // NextBlock has two storage backends:
6
+ //
7
+ // 's3' — the existing S3-compatible path (Cloudflare R2, self-hosted MinIO, or
8
+ // even Supabase Storage's S3 endpoint when you create S3 access keys).
9
+ // Driven by the R2_* env vars + @aws-sdk/client-s3. Unchanged.
10
+ // 'supabase' — native Supabase Storage via the supabase-js client and the injected
11
+ // service-role/secret key. NO S3 access keys required. This is what makes
12
+ // the one-click Vercel deploy work with zero storage configuration: the
13
+ // Marketplace integration injects the Supabase URL + secret key but NOT S3
14
+ // keys, so the S3 path can't authenticate — the native API can.
15
+ //
16
+ // The S3 path always wins when its keys are present, so local dev, Docker/MinIO, and BYO
17
+ // Cloudflare R2 are completely unaffected. The native path is only chosen as the zero-key
18
+ // fallback for a connected Supabase project.
19
+
20
+ export type StorageBackend = 's3' | 'supabase';
21
+
22
+ /** Which storage backend to use. Explicit STORAGE_PROVIDER wins; otherwise inferred. */
23
+ export function getStorageBackend(): StorageBackend {
24
+ const explicit = process.env.STORAGE_PROVIDER?.trim().toLowerCase();
25
+ if (explicit === 'supabase') return 'supabase';
26
+ if (explicit === 's3' || explicit === 'r2' || explicit === 'minio') return 's3';
27
+
28
+ // S3/R2/MinIO wins whenever its credentials exist (local, Docker, BYO R2, or
29
+ // Supabase-S3-with-keys). Only fall back to native Supabase Storage when there are no
30
+ // S3 keys but a Supabase project IS connected (service key + URL) — the Vercel case.
31
+ const hasS3Keys = Boolean(
32
+ process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY,
33
+ );
34
+ if (hasS3Keys) return 's3';
35
+ if (resolveSupabaseServiceKey() && resolveSupabaseUrl()) return 'supabase';
36
+ return 's3';
37
+ }
38
+
39
+ /** The bucket name to read/write. Defaults to "media" on the native Supabase backend. */
40
+ export function getStorageBucket(): string {
41
+ const explicit = process.env.STORAGE_BUCKET || process.env.R2_BUCKET_NAME;
42
+ if (explicit) return explicit;
43
+ return getStorageBackend() === 'supabase' ? 'media' : '';
44
+ }
45
+
46
+ /**
47
+ * The public base URL objects are served from — what resolveMediaUrl() joins object keys
48
+ * onto. An explicit R2 public/base URL always wins; otherwise, on the native Supabase
49
+ * backend, derive Supabase Storage's public object endpoint
50
+ * (`<project>/storage/v1/object/public/<bucket>`) so every display call site works with
51
+ * zero extra config. Kept in sync with the CJS computation in next.config.js.
52
+ */
53
+ export function resolveMediaBaseUrl(): string {
54
+ const explicit =
55
+ process.env.NEXT_PUBLIC_R2_BASE_URL || process.env.NEXT_PUBLIC_R2_PUBLIC_URL;
56
+ if (explicit) return explicit.replace(/\/+$/, '');
57
+
58
+ if (getStorageBackend() === 'supabase') {
59
+ const url = resolveSupabaseUrl();
60
+ const bucket = getStorageBucket();
61
+ if (url && bucket) {
62
+ return `${url.replace(/\/+$/, '')}/storage/v1/object/public/${bucket}`;
63
+ }
64
+ }
65
+ return '';
66
+ }
@@ -0,0 +1,103 @@
1
+ import 'server-only';
2
+
3
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
4
+
5
+ import { getStorageBucket } from './provider';
6
+
7
+ // Native Supabase Storage operations, authenticated with the service-role/secret key.
8
+ // Used only when getStorageBackend() === 'supabase' (the zero-S3-key Vercel path). These
9
+ // mirror what the S3 routes do via @aws-sdk/client-s3, so the upload → process → display
10
+ // → delete pipeline works identically without any S3 access keys.
11
+
12
+ // The public bucket is created once per process. Cache the in-flight promise (not a bare
13
+ // boolean) so concurrent uploads share a SINGLE createBucket call instead of racing — and
14
+ // clear it on failure so a later request can retry rather than inheriting a cached rejection.
15
+ let bucketEnsurePromise: Promise<void> | null = null;
16
+
17
+ /** Idempotently ensure a PUBLIC bucket exists for media. Safe to call concurrently. */
18
+ export function ensureStorageBucket(): Promise<void> {
19
+ if (bucketEnsurePromise) return bucketEnsurePromise;
20
+
21
+ bucketEnsurePromise = (async () => {
22
+ const bucket = getStorageBucket();
23
+ if (!bucket) throw new Error('No storage bucket name is configured.');
24
+
25
+ const admin = getServiceRoleSupabaseClient();
26
+ // public: true is REQUIRED — media is served from /storage/v1/object/public/<bucket>,
27
+ // which returns 401 for a private bucket. Do not drop this flag.
28
+ const { error } = await admin.storage.createBucket(bucket, { public: true });
29
+ if (error && !/already exists|duplicate|resource already exists/i.test(error.message)) {
30
+ // Non-"exists" error — confirm via getBucket before treating it as fatal.
31
+ const { data } = await admin.storage.getBucket(bucket);
32
+ if (!data) {
33
+ throw new Error(`Could not ensure storage bucket "${bucket}": ${error.message}`);
34
+ }
35
+ }
36
+ })();
37
+
38
+ // On failure, drop the cached promise so the next caller retries (don't cache a rejection).
39
+ bucketEnsurePromise.catch(() => {
40
+ bucketEnsurePromise = null;
41
+ });
42
+
43
+ return bucketEnsurePromise;
44
+ }
45
+
46
+ /** Upload (or overwrite) an object. */
47
+ export async function supabaseUploadObject(
48
+ key: string,
49
+ body: Buffer,
50
+ contentType: string,
51
+ ): Promise<void> {
52
+ await ensureStorageBucket();
53
+ const admin = getServiceRoleSupabaseClient();
54
+ const { error } = await admin.storage
55
+ .from(getStorageBucket())
56
+ .upload(key, body, { contentType, upsert: true });
57
+ if (error) {
58
+ throw new Error(`Supabase Storage upload failed for "${key}": ${error.message}`);
59
+ }
60
+ }
61
+
62
+ /** Download an object as a Buffer (used by the image processor to fetch the original). */
63
+ export async function supabaseDownloadObject(key: string): Promise<Buffer> {
64
+ const admin = getServiceRoleSupabaseClient();
65
+ const { data, error } = await admin.storage.from(getStorageBucket()).download(key);
66
+ if (error || !data) {
67
+ throw new Error(
68
+ `Supabase Storage download failed for "${key}": ${error?.message ?? 'no data returned'}`,
69
+ );
70
+ }
71
+ return Buffer.from(await data.arrayBuffer());
72
+ }
73
+
74
+ /**
75
+ * Create a signed upload URL for a NEW object key. The browser PUTs the file bytes to the
76
+ * returned URL directly (the same shape as an S3 presigned PUT), so large uploads never
77
+ * pass through the serverless function. The token is embedded in the URL.
78
+ */
79
+ export async function supabaseCreateSignedUpload(
80
+ key: string,
81
+ ): Promise<{ signedUrl: string; token: string }> {
82
+ await ensureStorageBucket();
83
+ const admin = getServiceRoleSupabaseClient();
84
+ const { data, error } = await admin.storage
85
+ .from(getStorageBucket())
86
+ .createSignedUploadUrl(key);
87
+ if (error || !data?.signedUrl) {
88
+ throw new Error(
89
+ `Supabase Storage signed upload URL failed for "${key}": ${error?.message ?? 'no URL returned'}`,
90
+ );
91
+ }
92
+ return { signedUrl: data.signedUrl, token: data.token };
93
+ }
94
+
95
+ /** Remove one or more objects. No-op for an empty list. */
96
+ export async function supabaseRemoveObjects(keys: string[]): Promise<void> {
97
+ if (keys.length === 0) return;
98
+ const admin = getServiceRoleSupabaseClient();
99
+ const { error } = await admin.storage.from(getStorageBucket()).remove(keys);
100
+ if (error) {
101
+ throw new Error(`Supabase Storage remove failed: ${error.message}`);
102
+ }
103
+ }
@@ -372,7 +372,7 @@ export function updateDraftBlockContent(
372
372
  }
373
373
 
374
374
  export function createVerificationClient() {
375
- if (process.env.SUPABASE_SERVICE_ROLE_KEY) {
375
+ if (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY) {
376
376
  return getServiceRoleSupabaseClient();
377
377
  }
378
378
 
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -96,8 +96,24 @@ const nextConfig = {
96
96
  optimizePackageImports: ['@nextblock-cms/ui', '@nextblock-cms/utils'],
97
97
  },
98
98
  env: {
99
- NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
100
- NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
99
+ // Bridge every alias the Vercel Supabase integration may inject into the public
100
+ // vars the app + browser client read, so the build inlines a value whichever copy
101
+ // is present. The Marketplace integration injects the new publishable key
102
+ // (NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) rather than the legacy anon key — without
103
+ // this bridge the client bundle would ship an empty anon key. Keep the alias order
104
+ // in sync with apps/nextblock/lib/setup/env-status.ts.
105
+ NEXT_PUBLIC_SUPABASE_URL:
106
+ process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL,
107
+ NEXT_PUBLIC_SUPABASE_ANON_KEY:
108
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
109
+ process.env.SUPABASE_ANON_KEY ||
110
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
111
+ process.env.SUPABASE_PUBLISHABLE_KEY,
112
+ // On a zero-key Supabase deploy, media is served from Supabase Storage's public
113
+ // object endpoint — inline that as the base URL so every resolveMediaUrl() display
114
+ // call site works with no manual storage config (see lib/storage/provider.ts).
115
+ NEXT_PUBLIC_R2_BASE_URL:
116
+ process.env.NEXT_PUBLIC_R2_BASE_URL || computeSupabaseMediaBaseUrl(),
101
117
  },
102
118
  images: {
103
119
  formats: ['image/avif', 'image/webp'],
@@ -147,6 +163,25 @@ module.exports = shouldEnableVercelToolbarPlugin
147
163
  ? withVercelToolbar()(nextConfig)
148
164
  : nextConfig;
149
165
 
166
+ /**
167
+ * Mirror of lib/storage/provider.ts resolveMediaBaseUrl() for the native Supabase backend,
168
+ * in CJS so it can run at build time. Returns undefined unless this is a zero-S3-key
169
+ * Supabase deploy (no R2 keys, a connected Supabase project), in which case it returns
170
+ * Supabase Storage's public object endpoint for the media bucket.
171
+ * @returns {string | undefined}
172
+ */
173
+ function computeSupabaseMediaBaseUrl() {
174
+ if (process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY) return undefined;
175
+ const explicit = (process.env.STORAGE_PROVIDER || '').toLowerCase();
176
+ if (explicit && explicit !== 'supabase') return undefined;
177
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
178
+ const hasSecret =
179
+ process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
180
+ if (!url || !hasSecret) return undefined;
181
+ const bucket = process.env.STORAGE_BUCKET || process.env.R2_BUCKET_NAME || 'media';
182
+ return `${url.replace(/\/+$/, '')}/storage/v1/object/public/${bucket}`;
183
+ }
184
+
150
185
  function getRemotePatterns() {
151
186
  /** @type {RemotePattern[]} */
152
187
  const patterns = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.9.92",
3
+ "version": "0.9.98",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",
@@ -49,7 +49,7 @@
49
49
  "katex": "^0.16.45",
50
50
  "lodash.debounce": "^4.0.8",
51
51
  "lucide-react": "^0.577.0",
52
- "next": "16.2.7",
52
+ "next": "16.2.9",
53
53
  "next-themes": "^0.4.6",
54
54
  "nodemailer": "^9.0.1",
55
55
  "papaparse": "^5.5.3",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/postcss": "^4.2.4",
75
75
  "autoprefixer": "^10.5.0",
76
76
  "eslint": "^9.39.4",
77
- "eslint-config-next": "16.2.7",
77
+ "eslint-config-next": "16.2.9",
78
78
  "postcss": "^8.5.12",
79
79
  "tailwindcss": "^4.2.4",
80
80
  "typescript": "^5.9.3",
@@ -2,6 +2,7 @@ import { createServerClient, type CookieOptions } from '@supabase/ssr';
2
2
  import { NextResponse, type NextRequest } from 'next/server';
3
3
  import type { SupabaseClient } from '@supabase/supabase-js';
4
4
  import type { Database } from '@nextblock-cms/db';
5
+ import { resolveSupabaseAnonKey, resolveSupabaseUrl } from './lib/setup/env-status';
5
6
 
6
7
  type Profile = Database['public']['Tables']['profiles']['Row'];
7
8
  type UserRole = Database['public']['Enums']['user_role'];
@@ -55,7 +56,12 @@ function isSetupAllowlisted(pathname: string): boolean {
55
56
  pathname.startsWith('/api/setup') ||
56
57
  pathname.startsWith('/auth/') ||
57
58
  pathname.startsWith('/_next/') ||
58
- pathname.startsWith('/favicon')
59
+ pathname.startsWith('/favicon') ||
60
+ // Crawler-facing static routes: keep them reachable while unprovisioned so a fresh
61
+ // deploy never redirects robots.txt / the sitemap to /setup (which would let crawlers
62
+ // treat the wizard as the canonical entry point).
63
+ pathname === '/robots.txt' ||
64
+ pathname.startsWith('/sitemap')
59
65
  );
60
66
  }
61
67
 
@@ -63,12 +69,32 @@ function isSetupAllowlisted(pathname: string): boolean {
63
69
  // modules persist across requests in a worker, so this avoids a per-request DB hit.
64
70
  let provisionedAdminCache: { value: boolean; expires: number } | null = null;
65
71
 
72
+ /**
73
+ * A Supabase/PostgREST error that means the table itself is absent — i.e. the schema
74
+ * was never applied. This is NOT a transient hiccup: it's the signature of a fresh,
75
+ * unprovisioned deploy (env injected, migrations not yet run), so the caller treats it
76
+ * as "no admin" and funnels traffic to /setup instead of failing open.
77
+ * 42P01 = undefined_table (Postgres); PGRST205 = PostgREST "table not in schema cache".
78
+ */
79
+ function isSchemaMissingError(error: { code?: string; message?: string } | null): boolean {
80
+ if (!error) return false;
81
+ const code = error.code ?? '';
82
+ if (code === '42P01' || code === 'PGRST205') return true;
83
+ const message = (error.message ?? '').toLowerCase();
84
+ return (
85
+ message.includes('does not exist') ||
86
+ message.includes('schema cache') ||
87
+ message.includes('could not find the table')
88
+ );
89
+ }
90
+
66
91
  /**
67
92
  * Returns true once the system has a first admin (site_settings.is_admin_created).
68
93
  * `is_admin_created` is a non-sensitive, anon-readable key, so the request-scoped
69
94
  * anon client can read it. Cached aggressively once true (it never reverts) and
70
95
  * briefly while false (so the gate releases promptly after the wizard runs).
71
- * Fail-open: any read error returns true so a hiccup never traps the whole site.
96
+ * A missing-schema error returns false (unprovisioned /setup); any OTHER read error
97
+ * fails open (returns true) so a transient hiccup never traps the whole site.
72
98
  */
73
99
  async function hasProvisionedAdmin(supabase: SupabaseClient): Promise<boolean> {
74
100
  const now = Date.now();
@@ -84,6 +110,12 @@ async function hasProvisionedAdmin(supabase: SupabaseClient): Promise<boolean> {
84
110
  .maybeSingle();
85
111
 
86
112
  if (error) {
113
+ // Schema not applied yet → definitively unprovisioned: send traffic to /setup
114
+ // (otherwise the homepage 404s on a fresh deploy because no content exists).
115
+ if (isSchemaMissingError(error)) {
116
+ provisionedAdminCache = { value: false, expires: now + 3_000 };
117
+ return false;
118
+ }
87
119
  return true;
88
120
  }
89
121
 
@@ -307,8 +339,11 @@ function createContentSecurityPolicy(nonceValue: string, supabaseUrl: string | u
307
339
 
308
340
  export async function proxy(request: NextRequest) {
309
341
  const { pathname } = request.nextUrl;
310
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
311
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
342
+ // Resolve Supabase creds under every alias the Vercel integration may inject (prefixed,
343
+ // non-prefixed, and the new publishable key) so the gate doesn't bounce a configured
344
+ // deploy to /setup just because the credentials arrived under a different name.
345
+ const supabaseUrl = resolveSupabaseUrl();
346
+ const supabaseAnonKey = resolveSupabaseAnonKey();
312
347
  const configured = Boolean(supabaseUrl && supabaseAnonKey);
313
348
  process.env.NEXTBLOCK_UNCONFIGURED = configured ? 'false' : 'true';
314
349