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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/actions/package-actions.ts +9 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3 -2
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +5 -1
- package/templates/nextblock-template/app/api/draft/route.ts +5 -1
- package/templates/nextblock-template/app/api/media/library/route.ts +6 -2
- package/templates/nextblock-template/app/api/process-image/route.ts +58 -43
- package/templates/nextblock-template/app/api/revalidate/route.ts +21 -18
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +20 -8
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +34 -28
- package/templates/nextblock-template/app/article/[slug]/page.tsx +4 -13
- package/templates/nextblock-template/app/checkout/success/actions.ts +3 -2
- package/templates/nextblock-template/app/cms/media/actions.ts +47 -31
- package/templates/nextblock-template/app/cms/orders/actions.ts +29 -29
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/users/actions.ts +119 -118
- package/templates/nextblock-template/app/cms/users/page.tsx +3 -3
- package/templates/nextblock-template/app/layout.tsx +10 -7
- package/templates/nextblock-template/app/lib/site-settings.ts +7 -4
- package/templates/nextblock-template/app/page.tsx +2 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/robots.txt/route.ts +6 -3
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +55 -8
- package/templates/nextblock-template/app/setup/page.tsx +17 -1
- package/templates/nextblock-template/app/sitemap.ts +5 -3
- package/templates/nextblock-template/context/language-rest-client.ts +3 -2
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +125 -32
- package/templates/nextblock-template/lib/app-secrets.ts +39 -0
- package/templates/nextblock-template/lib/auth/crypto.ts +4 -1
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +23 -3
- package/templates/nextblock-template/lib/setup/actions.ts +16 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +44 -5
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +172 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +23 -7
- package/templates/nextblock-template/lib/site-url.ts +48 -0
- package/templates/nextblock-template/lib/storage/provider.ts +66 -0
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -0
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +1 -1
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/next.config.js +37 -2
- package/templates/nextblock-template/package.json +3 -3
- package/templates/nextblock-template/proxy.ts +39 -4
|
@@ -2,6 +2,11 @@ import 'server-only';
|
|
|
2
2
|
import { unstable_cache } from 'next/cache';
|
|
3
3
|
import { createClient as createSupabaseJsClient } from '@supabase/supabase-js';
|
|
4
4
|
import type { Database } from '@nextblock-cms/db';
|
|
5
|
+
import {
|
|
6
|
+
resolveSupabaseAnonKey,
|
|
7
|
+
resolveSupabaseServiceKey,
|
|
8
|
+
resolveSupabaseUrl,
|
|
9
|
+
} from '../../lib/setup/env-status';
|
|
5
10
|
import {
|
|
6
11
|
DEFAULT_SITE_TITLE,
|
|
7
12
|
DEFAULT_SITE_DESCRIPTION,
|
|
@@ -28,11 +33,9 @@ export function createStaticSupabaseClient() {
|
|
|
28
33
|
// (getSiteSettings + the getCached* helpers) already swallow failures and use
|
|
29
34
|
// fallbacks, and loadLayoutData short-circuits before reaching here when there is
|
|
30
35
|
// no Supabase env at all.
|
|
31
|
-
const supabaseUrl =
|
|
36
|
+
const supabaseUrl = resolveSupabaseUrl() || 'https://dummy.supabase.co';
|
|
32
37
|
const supabaseKey =
|
|
33
|
-
|
|
34
|
-
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
35
|
-
'dummy-anon-key';
|
|
38
|
+
resolveSupabaseServiceKey() || resolveSupabaseAnonKey() || 'dummy-anon-key';
|
|
36
39
|
|
|
37
40
|
return createSupabaseJsClient<Database>(supabaseUrl, supabaseKey, {
|
|
38
41
|
auth: {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './lib/seo';
|
|
16
16
|
import { getSiteSettings } from './lib/site-settings';
|
|
17
17
|
import { getRequestOrigin } from '../lib/visual-editing/edit-info';
|
|
18
|
+
import { isSupabaseConfigured } from '../lib/setup/env-status';
|
|
18
19
|
|
|
19
20
|
const DEFAULT_LOCALE = 'en';
|
|
20
21
|
const LANGUAGE_COOKIE_KEY = 'NEXT_USER_LOCALE';
|
|
@@ -65,7 +66,7 @@ async function getPreferredLocale() {
|
|
|
65
66
|
export async function generateMetadata(): Promise<Metadata> {
|
|
66
67
|
// Unconfigured instance (pre-/setup): skip all DB work so metadata generation
|
|
67
68
|
// can't crash the boot. The proxy redirects unconfigured traffic to /setup anyway.
|
|
68
|
-
if (!
|
|
69
|
+
if (!isSupabaseConfigured()) {
|
|
69
70
|
return { title: 'NextBlock' };
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -74,8 +74,15 @@ function resolveCategoryName(category: any, languageCode?: string | null) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export async function generateStaticParams() {
|
|
77
|
-
// Unconfigured instance (pre-/setup): no DB to read product slugs from.
|
|
78
|
-
|
|
77
|
+
// Unconfigured instance (pre-/setup): no DB to read product slugs from. Accept every
|
|
78
|
+
// Supabase key alias the Vercel integration may inject (incl. the new publishable key).
|
|
79
|
+
const hasSupabaseEnv =
|
|
80
|
+
(process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
|
|
81
|
+
(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
82
|
+
process.env.SUPABASE_ANON_KEY ||
|
|
83
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
|
|
84
|
+
process.env.SUPABASE_PUBLISHABLE_KEY);
|
|
85
|
+
if (!hasSupabaseEnv) {
|
|
79
86
|
return [];
|
|
80
87
|
}
|
|
81
88
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { resolveSiteUrl, hasResolvedSiteUrl } from '../../lib/site-url';
|
|
2
|
+
|
|
1
3
|
export async function GET() {
|
|
2
|
-
|
|
4
|
+
// Explicit NEXT_PUBLIC_URL → Vercel production URL → local-dev fallback.
|
|
5
|
+
const siteUrl = resolveSiteUrl();
|
|
3
6
|
|
|
4
|
-
if (!
|
|
7
|
+
if (!hasResolvedSiteUrl()) {
|
|
5
8
|
console.warn(
|
|
6
|
-
'Warning:
|
|
9
|
+
'Warning: no site URL is set for robots.txt (NEXT_PUBLIC_URL / Vercel production URL). Defaulting to http://localhost:3000. Set NEXT_PUBLIC_URL for production.'
|
|
7
10
|
);
|
|
8
11
|
}
|
|
9
12
|
|
|
@@ -43,6 +43,15 @@ interface SmtpPrefill {
|
|
|
43
43
|
fromName: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface SupabaseEnvDetected {
|
|
47
|
+
NEXT_PUBLIC_SUPABASE_URL: boolean;
|
|
48
|
+
SUPABASE_URL: boolean;
|
|
49
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: boolean;
|
|
50
|
+
SUPABASE_ANON_KEY: boolean;
|
|
51
|
+
SUPABASE_SERVICE_ROLE_KEY: boolean;
|
|
52
|
+
POSTGRES_URL: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
interface Props {
|
|
47
56
|
channel: DeployChannel;
|
|
48
57
|
configured: boolean;
|
|
@@ -51,6 +60,8 @@ interface Props {
|
|
|
51
60
|
storagePrefill: StoragePrefill;
|
|
52
61
|
smtpPrefill: SmtpPrefill;
|
|
53
62
|
turnstilePrefill: { siteKey: string };
|
|
63
|
+
/** Which Supabase env vars the running deployment can actually see (read-only channels). */
|
|
64
|
+
supabaseEnvDetected: SupabaseEnvDetected;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
type StepId = 'connection' | 'storage' | 'email' | 'bot' | 'signups' | 'admin';
|
|
@@ -80,6 +91,7 @@ export default function SetupWizard({
|
|
|
80
91
|
storagePrefill,
|
|
81
92
|
smtpPrefill,
|
|
82
93
|
turnstilePrefill,
|
|
94
|
+
supabaseEnvDetected,
|
|
83
95
|
}: Props) {
|
|
84
96
|
const [isPending, startTransition] = useTransition();
|
|
85
97
|
const [message, setMessage] = useState<Msg>(null);
|
|
@@ -114,9 +126,14 @@ export default function SetupWizard({
|
|
|
114
126
|
if (!configured) {
|
|
115
127
|
list.push('connection');
|
|
116
128
|
}
|
|
117
|
-
|
|
129
|
+
// One-click Vercel deploys use the connected Supabase project for media storage with
|
|
130
|
+
// zero keys (native Supabase Storage), so there's nothing to configure — skip the step.
|
|
131
|
+
if (channel !== 'vercel') {
|
|
132
|
+
list.push('storage');
|
|
133
|
+
}
|
|
134
|
+
list.push('email', 'bot', 'signups', 'admin');
|
|
118
135
|
return list;
|
|
119
|
-
}, [configured]);
|
|
136
|
+
}, [configured, channel]);
|
|
120
137
|
|
|
121
138
|
const [stepIndex, setStepIndex] = useState(0);
|
|
122
139
|
const current = steps[stepIndex];
|
|
@@ -339,12 +356,42 @@ export default function SetupWizard({
|
|
|
339
356
|
/>
|
|
340
357
|
</Field>
|
|
341
358
|
{!writable && (
|
|
342
|
-
<
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
359
|
+
<div className="space-y-3 rounded-md border p-4">
|
|
360
|
+
<p className="text-sm font-medium">
|
|
361
|
+
On {CHANNEL_LABEL[channel]} you don't fill this in — the database is
|
|
362
|
+
connected through your platform's environment variables (e.g. Vercel's
|
|
363
|
+
Supabase integration).
|
|
364
|
+
</p>
|
|
365
|
+
<div className="text-xs">
|
|
366
|
+
<p className="mb-1 text-muted-foreground">
|
|
367
|
+
Supabase variables this deployment can currently see:
|
|
368
|
+
</p>
|
|
369
|
+
<ul className="space-y-0.5 font-mono">
|
|
370
|
+
{(
|
|
371
|
+
[
|
|
372
|
+
'NEXT_PUBLIC_SUPABASE_URL',
|
|
373
|
+
'SUPABASE_URL',
|
|
374
|
+
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
|
|
375
|
+
'SUPABASE_ANON_KEY',
|
|
376
|
+
'SUPABASE_SERVICE_ROLE_KEY',
|
|
377
|
+
'POSTGRES_URL',
|
|
378
|
+
] as const
|
|
379
|
+
).map((k) => (
|
|
380
|
+
<li key={k}>
|
|
381
|
+
{supabaseEnvDetected[k] ? '✅' : '❌'} {k}
|
|
382
|
+
</li>
|
|
383
|
+
))}
|
|
384
|
+
</ul>
|
|
385
|
+
</div>
|
|
386
|
+
<Alert className="py-2 px-4">
|
|
387
|
+
<AlertDescription className="text-xs">
|
|
388
|
+
{supabaseEnvDetected.NEXT_PUBLIC_SUPABASE_URL ||
|
|
389
|
+
supabaseEnvDetected.SUPABASE_URL
|
|
390
|
+
? 'Keys detected — reload this page and the database step will be skipped.'
|
|
391
|
+
: 'No Supabase keys are visible to this deployment yet. If you just created the database via the Vercel integration, the keys were added AFTER this build — open Vercel → Deployments → ⋯ → Redeploy once, then reload. Env vars only bind on a new deployment.'}
|
|
392
|
+
</AlertDescription>
|
|
393
|
+
</Alert>
|
|
394
|
+
</div>
|
|
348
395
|
)}
|
|
349
396
|
</div>
|
|
350
397
|
)}
|
|
@@ -81,7 +81,8 @@ export default async function SetupPage() {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const channel = detectChannel();
|
|
84
|
-
const supabaseUrl =
|
|
84
|
+
const supabaseUrl =
|
|
85
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || '';
|
|
85
86
|
|
|
86
87
|
return (
|
|
87
88
|
<SetupWizard
|
|
@@ -98,6 +99,21 @@ export default async function SetupPage() {
|
|
|
98
99
|
fromName: process.env.SMTP_FROM_NAME ?? '',
|
|
99
100
|
}}
|
|
100
101
|
turnstilePrefill={{ siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '' }}
|
|
102
|
+
supabaseEnvDetected={{
|
|
103
|
+
NEXT_PUBLIC_SUPABASE_URL: Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL),
|
|
104
|
+
SUPABASE_URL: Boolean(process.env.SUPABASE_URL),
|
|
105
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: Boolean(
|
|
106
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
107
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
|
108
|
+
),
|
|
109
|
+
SUPABASE_ANON_KEY: Boolean(
|
|
110
|
+
process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_PUBLISHABLE_KEY,
|
|
111
|
+
),
|
|
112
|
+
SUPABASE_SERVICE_ROLE_KEY: Boolean(
|
|
113
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY,
|
|
114
|
+
),
|
|
115
|
+
POSTGRES_URL: Boolean(process.env.POSTGRES_URL),
|
|
116
|
+
}}
|
|
101
117
|
/>
|
|
102
118
|
);
|
|
103
119
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
fetchAllPublishedPosts,
|
|
7
7
|
type SitemapEntry,
|
|
8
8
|
} from './lib/sitemap-utils';
|
|
9
|
+
import { resolveSiteUrl, hasResolvedSiteUrl } from '../lib/site-url';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Cache the generated sitemap and rebuild it at most once an hour. Crawlers get
|
|
@@ -23,7 +24,8 @@ export const revalidate = 3600;
|
|
|
23
24
|
type SitemapItem = MetadataRoute.Sitemap[number];
|
|
24
25
|
type ChangeFrequency = NonNullable<SitemapItem['changeFrequency']>;
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
// Explicit NEXT_PUBLIC_URL → Vercel production URL → local-dev fallback.
|
|
28
|
+
const BASE_URL = resolveSiteUrl();
|
|
27
29
|
|
|
28
30
|
function toAbsolute(path: string): string {
|
|
29
31
|
return `${BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
|
@@ -73,9 +75,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
73
75
|
return [];
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
if (!
|
|
78
|
+
if (!hasResolvedSiteUrl()) {
|
|
77
79
|
console.warn(
|
|
78
|
-
'Warning:
|
|
80
|
+
'Warning: no site URL is set for the sitemap (NEXT_PUBLIC_URL / Vercel production URL). Defaulting to http://localhost:3000 — set NEXT_PUBLIC_URL in production.',
|
|
79
81
|
);
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { Database } from '@nextblock-cms/db';
|
|
2
|
+
import { resolveSupabaseAnonKey, resolveSupabaseUrl } from '../lib/setup/env-status';
|
|
2
3
|
|
|
3
4
|
type Language = Database['public']['Tables']['languages']['Row'];
|
|
4
5
|
|
|
5
6
|
export async function fetchActiveLanguagesFromRest(): Promise<Language[]> {
|
|
6
|
-
const supabaseUrl =
|
|
7
|
-
const supabaseAnonKey =
|
|
7
|
+
const supabaseUrl = resolveSupabaseUrl();
|
|
8
|
+
const supabaseAnonKey = resolveSupabaseAnonKey();
|
|
8
9
|
|
|
9
10
|
if (!supabaseUrl || !supabaseAnonKey) {
|
|
10
11
|
return [];
|
|
@@ -12,32 +12,127 @@ The badge links to `https://vercel.com/new/clone` with these query parameters:
|
|
|
12
12
|
| Parameter | Purpose |
|
|
13
13
|
| :--- | :--- |
|
|
14
14
|
| `repository-url` | The NextBlock repo to clone into the user's Git provider. |
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `envDescription` / `envLink` | Help text + a link back to this doc. |
|
|
15
|
+
| `project-name` / `repository-name` | Pre-fill the new Vercel project and Git repo names. |
|
|
16
|
+
| `stores=[{"type":"integration","integrationSlug":"supabase","productSlug":"supabase"}]` | Vercel's **native Supabase Marketplace integration**. During import you're prompted to **create a Supabase database** (name + region); Vercel **provisions it, connects it to the project, and injects the env vars before the first build**. |
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
There is deliberately **no `env=` parameter** — the deploy prompts for **zero** values you
|
|
19
|
+
have to type (see "No environment variables required" below). The only interaction is the
|
|
20
|
+
Supabase integration's "create database" step, which can't be skipped because provisioning
|
|
21
|
+
a Postgres DB requires choosing a region/plan.
|
|
22
|
+
|
|
23
|
+
> **Why `stores`, not `integration-ids`?** The legacy `integration-ids=oac_…` parameter
|
|
24
|
+
> triggers an OAuth integration that *creates* a Supabase project but leaves it
|
|
25
|
+
> **disconnected** — it injects **no** environment variables into the Vercel project, so
|
|
26
|
+
> the app boots unconfigured and you have to wire the keys up by hand. The modern `stores`
|
|
27
|
+
> parameter is the native Marketplace path that actually **connects the store and injects
|
|
28
|
+
> credentials before the build**. ([Vercel Deploy Button docs](https://vercel.com/docs/deploy-button/source))
|
|
29
|
+
|
|
30
|
+
## Connect the database (Supabase integration)
|
|
31
|
+
|
|
32
|
+
The `stores` parameter makes Vercel prompt you to create a Supabase database during import.
|
|
33
|
+
Pick a **name** and **region**, and Vercel **automatically injects** the integration's
|
|
34
|
+
environment variables into the project — you never copy a value. The native integration
|
|
35
|
+
injects the **new-style** Supabase key names, e.g.:
|
|
36
|
+
|
|
37
|
+
`NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` /
|
|
38
|
+
`SUPABASE_PUBLISHABLE_KEY` (the anon-equivalent), `SUPABASE_SECRET_KEY` (the
|
|
39
|
+
service-role-equivalent), and `POSTGRES_URL` (+ the other `POSTGRES_*` parts).
|
|
40
|
+
|
|
41
|
+
NextBlock reads **all** of these aliases (see `apps/nextblock/lib/setup/env-status.ts`):
|
|
42
|
+
it accepts `NEXT_PUBLIC_SUPABASE_URL` *or* `SUPABASE_URL`; anon *or* `PUBLISHABLE_KEY`;
|
|
43
|
+
`SUPABASE_SERVICE_ROLE_KEY` *or* `SUPABASE_SECRET_KEY`. So the wizard's connection step
|
|
44
|
+
auto-skips no matter which copy the integration set, and the browser client gets the
|
|
45
|
+
publishable key bridged in as the anon key at build time.
|
|
46
|
+
|
|
47
|
+
> If the database step still appears after a deploy that connected the store, the env vars
|
|
48
|
+
> landed **after** that build — **Redeploy** once (Vercel binds env vars at deploy time).
|
|
49
|
+
> With the `stores` flow they're injected *before* the first build, so this is rarely needed.
|
|
50
|
+
|
|
51
|
+
On that deploy the wizard skips the database-connection step, **auto-applies the schema**
|
|
52
|
+
(the migrations are embedded in the build — see `lib/setup/migrations-bundle.ts` — and run
|
|
53
|
+
via the injected `POSTGRES_URL`), uses the connected Supabase project for media storage,
|
|
54
|
+
and goes straight to creating the first administrator.
|
|
55
|
+
|
|
56
|
+
> Until the schema is applied and the first admin exists, **every route redirects to
|
|
57
|
+
> `/setup`** (the homepage does *not* 404). The middleware treats a "schema not applied yet"
|
|
58
|
+
> database error as *unprovisioned* and funnels traffic to the wizard.
|
|
59
|
+
|
|
60
|
+
## Build configuration (Nx monorepo)
|
|
61
|
+
|
|
62
|
+
NextBlock is an Nx monorepo — the Next.js app lives at `apps/nextblock`, not the repo
|
|
63
|
+
root — so a bare `next build` at the root fails with *"Couldn't find any `pages` or
|
|
64
|
+
`app` directory."* The root [`vercel.json`](../vercel.json) pins the correct build, so
|
|
65
|
+
the one-click deploy needs **no manual dashboard configuration**:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"buildCommand": "npx nx build nextblock --prod",
|
|
70
|
+
"outputDirectory": "apps/nextblock/.next",
|
|
71
|
+
"framework": "nextjs"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **`buildCommand`** runs the Nx target from the repo root, which resolves the
|
|
76
|
+
workspace libraries (`@nextblock-cms/*` via the TS path aliases) and builds the app.
|
|
77
|
+
- **`outputDirectory`** points at the app's `.next`. This `@nx/next` version emits it
|
|
78
|
+
to `apps/nextblock/.next` — **not** `dist/apps/nextblock/.next` (which only receives
|
|
79
|
+
the deploy wrapper: `package.json`, `next.config.js`, `public/`). Verify with
|
|
80
|
+
`npx nx build nextblock --prod` then check `apps/nextblock/.next/BUILD_ID`.
|
|
81
|
+
- **`framework: nextjs`** keeps Vercel's first-class Next.js runtime (SSR/ISR
|
|
82
|
+
functions, image optimization, the `proxy.ts` middleware).
|
|
83
|
+
|
|
84
|
+
Leave the Vercel project's **Root Directory unset** (the repo root) — the build command
|
|
85
|
+
already targets the app. Do **not** set Root Directory to `apps/nextblock`: the app
|
|
86
|
+
imports the workspace libraries one level up, which a custom Root Directory would hide
|
|
87
|
+
(Vercel forbids `..` above a custom root).
|
|
88
|
+
|
|
89
|
+
## No environment variables required
|
|
90
|
+
|
|
91
|
+
A Deploy-Button URL can only carry variable **names**, never values — so secrets can
|
|
92
|
+
never be pre-filled through it. Rather than make you paste random strings, NextBlock
|
|
93
|
+
resolves everything in-app, and the button prompts for nothing:
|
|
94
|
+
|
|
95
|
+
- **`NEXT_PUBLIC_URL`** — optional. When unset the app falls back to Vercel's
|
|
96
|
+
production URL (`VERCEL_PROJECT_PRODUCTION_URL` server-side /
|
|
97
|
+
`NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` in the browser), i.e. your
|
|
98
|
+
`*.vercel.app` domain. Sitemap, robots, and canonical links use it automatically.
|
|
99
|
+
Add a custom domain later, set `NEXT_PUBLIC_URL=https://yourdomain.com` in the
|
|
100
|
+
Vercel project, and redeploy (it is inlined at build time).
|
|
101
|
+
- **`DRAFT_MODE_SECRET`** and **`REVALIDATE_SECRET_TOKEN`** — optional. When unset they
|
|
102
|
+
are derived deterministically from the Supabase service-role key (HMAC-SHA256), so
|
|
103
|
+
Draft Mode and on-demand revalidation work out of the box. Setting either env var
|
|
104
|
+
overrides the derived value — do this if you want a fixed `REVALIDATE_SECRET_TOKEN`
|
|
105
|
+
to paste into a Supabase revalidation webhook. (See `apps/nextblock/lib/app-secrets.ts`.)
|
|
106
|
+
- **`CRON_SECRET`** — optional. The cron endpoints enforce the `Authorization: Bearer`
|
|
107
|
+
header **only when it is set**. Leave it unset for a frictionless deploy, or set it
|
|
108
|
+
in the Vercel project to lock the cron endpoints down. The destructive
|
|
109
|
+
`/api/cron/reset-sandbox` job is independently gated to sandbox-mode only (404
|
|
110
|
+
otherwise), so it never runs on a normal deploy.
|
|
24
111
|
|
|
25
112
|
## What the wizard does on Vercel
|
|
26
113
|
|
|
27
114
|
1. **Database** — already connected (integration-injected). The wizard verifies a
|
|
28
|
-
first admin doesn't exist yet, otherwise it redirects to `/cms/dashboard`.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
115
|
+
first admin doesn't exist yet, otherwise it redirects to `/cms/dashboard`. The
|
|
116
|
+
connection step is **auto-skipped**.
|
|
117
|
+
2. **Schema** — applied **automatically**. The Supabase integration creates the project
|
|
118
|
+
but does **not** run NextBlock's migrations, so the wizard applies them itself on the
|
|
119
|
+
final step: it runs the build-embedded migration bundle over the injected
|
|
120
|
+
`POSTGRES_URL` (no CLI, no manual SQL). Idempotent — a re-run is a no-op. See
|
|
121
|
+
[docs/04](./04-DATABASE-AND-AUTH.md) and [docs/05](./05-DEVELOPER-GUIDE.md).
|
|
122
|
+
3. **Storage** — **skipped on Vercel; nothing to configure.** Media uses **native
|
|
123
|
+
Supabase Storage** through the already-connected project: the injected
|
|
124
|
+
`SUPABASE_SECRET_KEY` authenticates uploads/downloads/deletes via the Supabase client
|
|
125
|
+
(no S3 access keys required), a public `media` bucket is auto-created, and images are
|
|
126
|
+
served from `<project>/storage/v1/object/public/media/…`. The storage backend is
|
|
127
|
+
selected automatically (`apps/nextblock/lib/storage/provider.ts`): if S3/R2 keys are
|
|
128
|
+
present it uses them; otherwise it falls back to native Supabase Storage. (Cloudflare
|
|
129
|
+
R2 remains the default for non-Vercel installs — it has a more generous free storage
|
|
130
|
+
tier; set the `R2_*` env vars to use it on Vercel instead.)
|
|
131
|
+
|
|
132
|
+
> **Upload size:** the CMS media uploader proxies the file through a serverless
|
|
133
|
+
> function (`/api/upload/proxy`), which is subject to Vercel's ~4.5 MB request-body
|
|
134
|
+
> limit — larger single images fail on the Hobby/Pro serverless runtime regardless of
|
|
135
|
+
> storage backend. This is a platform constraint, not a NextBlock one.
|
|
41
136
|
4. **Email / Bot protection / Sign-ups** — optional steps; bot-protection and the
|
|
42
137
|
sign-up policy persist to the database and work immediately. SMTP, if used, is set
|
|
43
138
|
as Vercel environment variables.
|
|
@@ -47,19 +142,17 @@ step and goes straight to creating the first administrator.
|
|
|
47
142
|
> Filesystem is read-only on Vercel, so the wizard never writes `.env.local` there —
|
|
48
143
|
> all configuration is environment variables (platform-managed) plus the database.
|
|
49
144
|
|
|
50
|
-
## Cron jobs and the
|
|
51
|
-
|
|
52
|
-
`vercel.json` declares two daily crons (`/api/cron/reset-sandbox` and
|
|
53
|
-
`/api/cron/sync-currencies`). Vercel's **Hobby (free) tier allows one cron per day**.
|
|
54
|
-
For a free-tier production deploy, either:
|
|
145
|
+
## Cron jobs and the Hobby plan
|
|
55
146
|
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
147
|
+
`vercel.json` declares two crons (`/api/cron/reset-sandbox` at 03:00 and
|
|
148
|
+
`/api/cron/sync-currencies` at 18:00). Vercel's **Hobby (free) tier allows up to 100
|
|
149
|
+
cron jobs, each running at most once per day** — both jobs are daily, so they deploy
|
|
150
|
+
fine on the free tier. (Hobby timing is approximate, ±59 min, which is irrelevant for
|
|
151
|
+
daily jobs; only sub-daily schedules like `0 * * * *` are rejected on Hobby.)
|
|
60
152
|
|
|
61
|
-
|
|
62
|
-
|
|
153
|
+
`reset-sandbox` only does work in sandbox mode — it returns 404 otherwise — so on a
|
|
154
|
+
normal deploy it is a harmless no-op. Delete it from `vercel.json` if you'd rather not
|
|
155
|
+
see it scheduled.
|
|
63
156
|
|
|
64
157
|
## After deploy
|
|
65
158
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
// Resolve the app's internal shared secrets WITHOUT requiring the operator to paste
|
|
5
|
+
// random strings at deploy time. When an explicit env var is set it always wins;
|
|
6
|
+
// otherwise the secret is derived deterministically from the always-present Supabase
|
|
7
|
+
// service-role key. This is what lets a one-click Vercel deploy work with zero env
|
|
8
|
+
// input while Draft Mode and on-demand revalidation still function out of the box.
|
|
9
|
+
//
|
|
10
|
+
// HMAC-SHA256 is one-way, so a leaked derived secret never exposes the service-role
|
|
11
|
+
// key. The derived value is stable for a given Supabase project; rotating the
|
|
12
|
+
// service-role key rotates these secrets too (set the explicit env var to pin one
|
|
13
|
+
// independently — e.g. to configure a Supabase revalidation webhook with a fixed
|
|
14
|
+
// token). `npm run setup` still writes explicit values locally, so dev is unchanged.
|
|
15
|
+
//
|
|
16
|
+
// CRON_SECRET is intentionally NOT derived here: Vercel Cron injects
|
|
17
|
+
// `Authorization: Bearer ${process.env.CRON_SECRET}` read from the env at invocation,
|
|
18
|
+
// so a derived value could never match it. The cron routes instead treat CRON_SECRET
|
|
19
|
+
// as optional (enforced only when set) — see app/api/cron/*/route.ts.
|
|
20
|
+
|
|
21
|
+
function deriveFromServiceRole(label: string): string {
|
|
22
|
+
// Accept the Marketplace integration's SUPABASE_SECRET_KEY alias too, so derivation
|
|
23
|
+
// still works on a one-click deploy that only injected the new key name.
|
|
24
|
+
const master = (
|
|
25
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY
|
|
26
|
+
)?.trim();
|
|
27
|
+
if (!master) return '';
|
|
28
|
+
return createHmac('sha256', master).update(`nextblock:${label}`).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Secret for the programmatic Draft Mode preview entry (`/api/draft?secret=`). */
|
|
32
|
+
export function resolveDraftModeSecret(): string {
|
|
33
|
+
return process.env.DRAFT_MODE_SECRET?.trim() || deriveFromServiceRole('draft-mode');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Shared secret a Supabase webhook sends to `/api/revalidate` (`x-revalidate-secret`). */
|
|
37
|
+
export function resolveRevalidateSecret(): string {
|
|
38
|
+
return process.env.REVALIDATE_SECRET_TOKEN?.trim() || deriveFromServiceRole('revalidate');
|
|
39
|
+
}
|
|
@@ -21,7 +21,10 @@ export function generateNumericCode(digits = 6): string {
|
|
|
21
21
|
function getSecret(): string {
|
|
22
22
|
// Dedicated secret if provided, otherwise fall back to the service-role key
|
|
23
23
|
// (server-only, never shipped to the client).
|
|
24
|
-
const secret =
|
|
24
|
+
const secret =
|
|
25
|
+
process.env.NB_2FA_SECRET ||
|
|
26
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY ||
|
|
27
|
+
process.env.SUPABASE_SECRET_KEY;
|
|
25
28
|
if (!secret) {
|
|
26
29
|
throw new Error('Missing NB_2FA_SECRET / SUPABASE_SERVICE_ROLE_KEY for 2FA signing.');
|
|
27
30
|
}
|
|
@@ -5,6 +5,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
5
5
|
import { getS3PresignClient } from '@nextblock-cms/utils/server';
|
|
6
6
|
|
|
7
7
|
import { resolveMediaUrl } from './media/resolveMediaUrl';
|
|
8
|
+
import { getStorageBackend, getStorageBucket } from './storage/provider';
|
|
9
|
+
import { supabaseCreateSignedUpload } from './storage/supabase-storage';
|
|
8
10
|
import {
|
|
9
11
|
buildR2UploadObjectKey,
|
|
10
12
|
R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
|
|
@@ -30,17 +32,36 @@ export async function createR2PresignedUpload(
|
|
|
30
32
|
payload: ValidR2PresignedUploadPayload,
|
|
31
33
|
options: { now?: Date; nonce?: string; userId: string }
|
|
32
34
|
): Promise<R2PresignedUploadResult> {
|
|
33
|
-
const
|
|
35
|
+
const backend = getStorageBackend();
|
|
36
|
+
const bucketName = getStorageBucket();
|
|
34
37
|
if (!bucketName) {
|
|
35
38
|
throw new R2PresignedUploadError('File uploads are not configured on this server.', 500);
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
const objectKey = buildR2UploadObjectKey(payload, options);
|
|
42
|
+
const publicUrl = resolveMediaUrl(objectKey) ?? `/${objectKey}`;
|
|
43
|
+
|
|
44
|
+
if (backend === 'supabase') {
|
|
45
|
+
// Native Supabase Storage: a signed upload URL the browser PUTs to directly.
|
|
46
|
+
const { signedUrl } = await supabaseCreateSignedUpload(objectKey);
|
|
47
|
+
return {
|
|
48
|
+
expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': payload.contentType,
|
|
51
|
+
},
|
|
52
|
+
method: 'PUT',
|
|
53
|
+
objectKey,
|
|
54
|
+
presignedUrl: signedUrl,
|
|
55
|
+
publicUrl,
|
|
56
|
+
uploadUrl: signedUrl,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
const s3Client = await getS3PresignClient();
|
|
39
61
|
if (!s3Client) {
|
|
40
62
|
throw new R2PresignedUploadError('File uploads are not configured on this server.', 500);
|
|
41
63
|
}
|
|
42
64
|
|
|
43
|
-
const objectKey = buildR2UploadObjectKey(payload, options);
|
|
44
65
|
const command = new PutObjectCommand({
|
|
45
66
|
Bucket: bucketName,
|
|
46
67
|
ContentType: payload.contentType,
|
|
@@ -52,7 +73,6 @@ export async function createR2PresignedUpload(
|
|
|
52
73
|
const presignedUrl = await getSignedUrl(s3Client, command, {
|
|
53
74
|
expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
|
|
54
75
|
});
|
|
55
|
-
const publicUrl = resolveMediaUrl(objectKey) ?? `/${objectKey}`;
|
|
56
76
|
|
|
57
77
|
return {
|
|
58
78
|
expiresIn: R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS,
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from './provisioning';
|
|
14
14
|
import { applyMigrations, resetDatabase } from './schema-apply';
|
|
15
15
|
import { setSystemConfigurationServiceRole } from './system-config';
|
|
16
|
+
import { getStorageBackend } from '../storage/provider';
|
|
17
|
+
import { ensureStorageBucket } from '../storage/supabase-storage';
|
|
16
18
|
|
|
17
19
|
export interface ActionResult {
|
|
18
20
|
ok: boolean;
|
|
@@ -360,6 +362,20 @@ export async function completeSetup(input: CompleteSetupInput): Promise<ActionRe
|
|
|
360
362
|
// fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
|
|
361
363
|
await waitForSchemaCache(admin);
|
|
362
364
|
|
|
365
|
+
// On the native Supabase Storage backend (zero-key Vercel deploy), provision the public
|
|
366
|
+
// media bucket now so the first upload doesn't have to. Best-effort — the upload path
|
|
367
|
+
// also ensures it lazily, so a transient failure here never blocks finishing setup.
|
|
368
|
+
if (getStorageBackend() === 'supabase') {
|
|
369
|
+
try {
|
|
370
|
+
await ensureStorageBucket();
|
|
371
|
+
} catch (caught) {
|
|
372
|
+
console.warn(
|
|
373
|
+
'Could not pre-create the Supabase Storage media bucket (will retry on first upload):',
|
|
374
|
+
caught instanceof Error ? caught.message : caught,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
363
379
|
// 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
|
|
364
380
|
try {
|
|
365
381
|
if (input.turnstile && input.turnstile.provider !== 'none') {
|