create-nextblock 0.9.0 → 0.9.5
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/bin/create-nextblock.js +40 -60
- package/docker-template/.env.docker.example +5 -4
- package/docker-template/Dockerfile +20 -3
- package/docker-template/docker-compose.yml +5 -1
- package/docker-template/scripts/docker-setup.mjs +19 -4
- package/package.json +1 -1
- package/templates/nextblock-template/Dockerfile +20 -3
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/actions.ts +58 -8
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
- package/templates/nextblock-template/app/layout.tsx +57 -3
- package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
- package/templates/nextblock-template/app/page.tsx +6 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +771 -0
- package/templates/nextblock-template/app/setup/layout.tsx +13 -0
- package/templates/nextblock-template/app/setup/page.tsx +103 -0
- package/templates/nextblock-template/components/AppShell.tsx +12 -0
- package/templates/nextblock-template/components/header-auth.tsx +24 -62
- package/templates/nextblock-template/docker-compose.yml +5 -1
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
- package/templates/nextblock-template/docs/README.md +2 -0
- package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
- package/templates/nextblock-template/lib/setup/actions.ts +370 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +379 -0
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
- package/templates/nextblock-template/lib/setup/types.ts +18 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +143 -49
- package/templates/nextblock-template/scripts/docker-setup.mjs +19 -4
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
// Server actions backing the browser /setup wizard. Every mutating action is guarded
|
|
3
|
+
// by assertNotProvisioned() so setup can only run once (until a first admin exists).
|
|
4
|
+
|
|
5
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
import { createClient as createSupabaseJsClient } from '@supabase/supabase-js';
|
|
7
|
+
import { isLocalWritableEnv } from './env-status';
|
|
8
|
+
import { writeEnvLocal } from './env-write';
|
|
9
|
+
import {
|
|
10
|
+
assertNotProvisioned,
|
|
11
|
+
getProvisioningStatus,
|
|
12
|
+
type ProvisioningStatus,
|
|
13
|
+
} from './provisioning';
|
|
14
|
+
import { applyMigrations, resetDatabase } from './schema-apply';
|
|
15
|
+
import { setSystemConfigurationServiceRole } from './system-config';
|
|
16
|
+
|
|
17
|
+
export interface ActionResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
message?: string;
|
|
21
|
+
restartRecommended?: boolean;
|
|
22
|
+
schemaReady?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConnectionInput {
|
|
26
|
+
supabaseUrl: string;
|
|
27
|
+
anonKey: string;
|
|
28
|
+
serviceRoleKey: string;
|
|
29
|
+
postgresUrl?: string;
|
|
30
|
+
siteUrl?: string;
|
|
31
|
+
/** Supabase personal access token — needed by `npm run db:migrate` to link + push. */
|
|
32
|
+
accessToken?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Step (Profile B / local only): validate the Supabase credentials, then persist them
|
|
37
|
+
* to `.env.local` and the live process. Probes with the service-role key so we can
|
|
38
|
+
* also report whether the schema has been applied yet.
|
|
39
|
+
*/
|
|
40
|
+
export async function saveSupabaseConnection(input: ConnectionInput): Promise<ActionResult> {
|
|
41
|
+
await assertNotProvisioned();
|
|
42
|
+
|
|
43
|
+
const supabaseUrl = input.supabaseUrl?.trim();
|
|
44
|
+
const anonKey = input.anonKey?.trim();
|
|
45
|
+
const serviceRoleKey = input.serviceRoleKey?.trim();
|
|
46
|
+
|
|
47
|
+
if (!supabaseUrl || !anonKey || !serviceRoleKey) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: 'Supabase URL, anon key, and service-role key are all required.',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
new URL(supabaseUrl);
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, error: 'The Supabase URL is not a valid URL.' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isLocalWritableEnv()) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error:
|
|
63
|
+
'This environment is read-only. Set the Supabase variables on your hosting platform instead of here.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate the credentials with a service-role probe before writing anything.
|
|
68
|
+
const probe = createSupabaseJsClient(supabaseUrl, serviceRoleKey, {
|
|
69
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let schemaReady = false;
|
|
73
|
+
try {
|
|
74
|
+
const { error } = await probe.from('site_settings').select('key').limit(1);
|
|
75
|
+
if (error) {
|
|
76
|
+
const missing = /relation|does not exist|schema cache/i.test(error.message);
|
|
77
|
+
if (!missing) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `Could not reach Supabase with those credentials: ${error.message}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
schemaReady = false; // reachable, but the schema isn't applied yet
|
|
84
|
+
} else {
|
|
85
|
+
schemaReady = true;
|
|
86
|
+
}
|
|
87
|
+
} catch (caught) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `Could not connect to Supabase: ${
|
|
91
|
+
caught instanceof Error ? caught.message : 'unknown error'
|
|
92
|
+
}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const values: Record<string, string> = {
|
|
97
|
+
NEXT_PUBLIC_SUPABASE_URL: supabaseUrl,
|
|
98
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
|
|
99
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
100
|
+
};
|
|
101
|
+
if (input.siteUrl?.trim()) values.NEXT_PUBLIC_URL = input.siteUrl.trim();
|
|
102
|
+
if (input.postgresUrl?.trim()) values.POSTGRES_URL = input.postgresUrl.trim();
|
|
103
|
+
if (input.accessToken?.trim()) values.SUPABASE_ACCESS_TOKEN = input.accessToken.trim();
|
|
104
|
+
|
|
105
|
+
// Derive the project ref from the URL (https://<ref>.supabase.co) so the CLI schema
|
|
106
|
+
// step (`npm run db:migrate`) links + pushes to THIS project, not a stale one.
|
|
107
|
+
try {
|
|
108
|
+
const host = new URL(supabaseUrl).hostname;
|
|
109
|
+
if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
|
|
110
|
+
values.SUPABASE_PROJECT_ID = host.split('.')[0];
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// already validated above; ignore
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await writeEnvLocal(values);
|
|
118
|
+
} catch (caught) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: `Could not write .env.local: ${
|
|
122
|
+
caught instanceof Error ? caught.message : 'unknown error'
|
|
123
|
+
}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
schemaReady,
|
|
130
|
+
restartRecommended: true,
|
|
131
|
+
message: schemaReady
|
|
132
|
+
? 'Connection saved and verified.'
|
|
133
|
+
: 'Connection saved. The database schema is not applied yet — run "npm run db:migrate", then re-check below.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Poll provisioning status (used by the wizard to advance past connection/schema). */
|
|
138
|
+
export async function recheckStatus(): Promise<ProvisioningStatus & { writable: boolean }> {
|
|
139
|
+
const status = await getProvisioningStatus();
|
|
140
|
+
return { ...status, writable: isLocalWritableEnv() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface CompleteSetupInput {
|
|
144
|
+
admin: { email: string; password: string; fullName: string };
|
|
145
|
+
autoAcceptSignups: boolean;
|
|
146
|
+
/** Local-only extra env (storage / SMTP) collected by the wizard. */
|
|
147
|
+
envValues?: Record<string, string>;
|
|
148
|
+
/** Bot protection — stored in the DB so it works on read-only channels too. */
|
|
149
|
+
turnstile?: { provider: 'none' | 'turnstile'; siteKey: string; secretKey: string };
|
|
150
|
+
/** "Start from a clean database" — wipe before installing (local dev only, server-gated). */
|
|
151
|
+
resetFirst?: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* After a fresh migration, PostgREST may briefly not see the new tables (its schema
|
|
156
|
+
* cache). applyMigrations issues a reload, but it's async — poll until a known new
|
|
157
|
+
* table reads cleanly so the REST reads/writes below don't hit a false "table not
|
|
158
|
+
* found". Best-effort: gives up after ~6s and lets the caller proceed.
|
|
159
|
+
*/
|
|
160
|
+
async function waitForSchemaCache(
|
|
161
|
+
supabase: ReturnType<typeof getServiceRoleSupabaseClient>,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
164
|
+
const { error } = await supabase.from('system_configuration').select('id').limit(1);
|
|
165
|
+
if (!error) return;
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Final step: persist remaining settings and create the first admin. The
|
|
172
|
+
* handle_new_user trigger assigns the ADMIN role and flips is_admin_created, so we
|
|
173
|
+
* never set the role ourselves. `email_confirm: true` means no SMTP round-trip is
|
|
174
|
+
* required, which keeps every channel (including cloud-without-SMTP) unblocked.
|
|
175
|
+
*/
|
|
176
|
+
export async function completeSetup(input: CompleteSetupInput): Promise<ActionResult> {
|
|
177
|
+
// "Start from a clean database" (local dev only) deliberately re-installs over an
|
|
178
|
+
// existing DB — it wipes everything first — so the one-shot guard is skipped in that
|
|
179
|
+
// case. Otherwise enforce it: a provisioned instance can't be re-setup or wiped, and
|
|
180
|
+
// we return a clean message rather than throwing an unhandled error.
|
|
181
|
+
const willReset = input.resetFirst === true && isLocalWritableEnv();
|
|
182
|
+
if (!willReset) {
|
|
183
|
+
try {
|
|
184
|
+
await assertNotProvisioned();
|
|
185
|
+
} catch {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error:
|
|
189
|
+
'Setup has already been completed. Enable "Start from a clean database" to reinstall, or sign in instead.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const email = input.admin?.email?.trim().toLowerCase();
|
|
195
|
+
const password = input.admin?.password ?? '';
|
|
196
|
+
const fullName = input.admin?.fullName?.trim() ?? '';
|
|
197
|
+
|
|
198
|
+
if (!email || !password) {
|
|
199
|
+
return { ok: false, error: 'Administrator email and password are required.' };
|
|
200
|
+
}
|
|
201
|
+
if (password.length < 8) {
|
|
202
|
+
return { ok: false, error: 'Use an administrator password of at least 8 characters.' };
|
|
203
|
+
}
|
|
204
|
+
if (input.turnstile?.provider === 'turnstile' && !input.turnstile.secretKey?.trim()) {
|
|
205
|
+
return { ok: false, error: 'Enter a Turnstile secret key, or disable bot protection.' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 1) Persist any local env extras (storage / SMTP) for Profile B.
|
|
209
|
+
if (
|
|
210
|
+
input.envValues &&
|
|
211
|
+
Object.keys(input.envValues).length > 0 &&
|
|
212
|
+
isLocalWritableEnv()
|
|
213
|
+
) {
|
|
214
|
+
try {
|
|
215
|
+
await writeEnvLocal(input.envValues);
|
|
216
|
+
} catch (caught) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: `Could not write .env.local: ${
|
|
220
|
+
caught instanceof Error ? caught.message : 'unknown error'
|
|
221
|
+
}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 2) Service-role access is required from here on.
|
|
227
|
+
let admin: ReturnType<typeof getServiceRoleSupabaseClient>;
|
|
228
|
+
try {
|
|
229
|
+
admin = getServiceRoleSupabaseClient();
|
|
230
|
+
} catch {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error:
|
|
234
|
+
'The service-role key is not loaded yet. Restart the dev server after saving the connection, then retry.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 2.5) "Start from a clean database" (the fresh-install checkbox): wipe the DB (public
|
|
239
|
+
// schema + migration history + auth users) before installing, so each fresh setup
|
|
240
|
+
// starts clean. Two reliable guards: assertNotProvisioned() above means this only
|
|
241
|
+
// runs when no admin exists (a live site is immune), and isLocalWritableEnv()
|
|
242
|
+
// restricts it to local dev (a deployed app can never be tricked into wiping
|
|
243
|
+
// itself, even by a crafted request).
|
|
244
|
+
if (willReset) {
|
|
245
|
+
const reset = await resetDatabase();
|
|
246
|
+
if (!reset.ok) {
|
|
247
|
+
return { ok: false, error: `Could not reset the database: ${reset.error}` };
|
|
248
|
+
}
|
|
249
|
+
// Clear auth users via the admin API (reliable — SQL on the auth schema can be
|
|
250
|
+
// permission-restricted), so the admin email is free to reuse on this fresh install.
|
|
251
|
+
try {
|
|
252
|
+
const { data: list } = await admin.auth.admin.listUsers();
|
|
253
|
+
for (const existing of list?.users ?? []) {
|
|
254
|
+
await admin.auth.admin.deleteUser(existing.id);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Non-fatal — the createUser "already exists" fallback below also handles leftovers.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 3) Apply the database schema if it isn't there yet (e.g. a fresh Supabase project).
|
|
262
|
+
// Direct Postgres connection — no CLI, no config.toml. Idempotent (tracks applied
|
|
263
|
+
// migrations), so it's a no-op when the schema already exists (Docker, or a re-run).
|
|
264
|
+
// Then wait for PostgREST to pick up the new tables before we touch them via REST.
|
|
265
|
+
const schemaStatus = await getProvisioningStatus();
|
|
266
|
+
if (!schemaStatus.schemaReady) {
|
|
267
|
+
// applyMigrations prefers the Supabase Management API (HTTPS, needs only the access
|
|
268
|
+
// token + project ref) and falls back to a direct Postgres connection. It returns a
|
|
269
|
+
// clear error if neither is available.
|
|
270
|
+
const schema = await applyMigrations();
|
|
271
|
+
if (!schema.ok) {
|
|
272
|
+
return { ok: false, error: `Could not apply the database schema: ${schema.error}` };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Ensure PostgREST has the schema cached before ANY REST read/write below — covers a
|
|
277
|
+
// fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
|
|
278
|
+
await waitForSchemaCache(admin);
|
|
279
|
+
|
|
280
|
+
// 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
|
|
281
|
+
try {
|
|
282
|
+
if (input.turnstile && input.turnstile.provider !== 'none') {
|
|
283
|
+
await admin.from('site_settings').upsert({
|
|
284
|
+
key: 'bot_protection_public',
|
|
285
|
+
value: { provider: input.turnstile.provider, siteKey: input.turnstile.siteKey },
|
|
286
|
+
});
|
|
287
|
+
await admin.from('site_settings').upsert({
|
|
288
|
+
key: 'bot_protection_secret',
|
|
289
|
+
value: { secretKey: input.turnstile.secretKey },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
await setSystemConfigurationServiceRole({
|
|
293
|
+
auto_accept_signups: Boolean(input.autoAcceptSignups),
|
|
294
|
+
});
|
|
295
|
+
} catch (caught) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
error: `Failed to save settings: ${
|
|
299
|
+
caught instanceof Error ? caught.message : 'unknown error'
|
|
300
|
+
}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 4) Create the first admin account (already confirmed). The handle_new_user trigger
|
|
305
|
+
// assigns ADMIN to the very first user and flips is_admin_created atomically.
|
|
306
|
+
const createPayload = {
|
|
307
|
+
email,
|
|
308
|
+
password,
|
|
309
|
+
email_confirm: true,
|
|
310
|
+
user_metadata: { full_name: fullName },
|
|
311
|
+
};
|
|
312
|
+
let { data: created, error: createError } = await admin.auth.admin.createUser(createPayload);
|
|
313
|
+
|
|
314
|
+
// If a clean-install reset left a stale auth user (admin-API cleanup above hit an edge
|
|
315
|
+
// case), remove it and retry once so the operator can reuse their email.
|
|
316
|
+
if (
|
|
317
|
+
createError &&
|
|
318
|
+
input.resetFirst === true &&
|
|
319
|
+
/already|registered|exists/i.test(createError.message)
|
|
320
|
+
) {
|
|
321
|
+
try {
|
|
322
|
+
const { data: list } = await admin.auth.admin.listUsers();
|
|
323
|
+
const stale = list?.users?.find((u) => u.email?.toLowerCase() === email);
|
|
324
|
+
if (stale) {
|
|
325
|
+
await admin.auth.admin.deleteUser(stale.id);
|
|
326
|
+
({ data: created, error: createError } = await admin.auth.admin.createUser(createPayload));
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// fall through to the error handling below
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (createError || !created?.user) {
|
|
334
|
+
if (createError && /already|registered|exists/i.test(createError.message)) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error:
|
|
338
|
+
'An account with this email already exists. Enable "Start from a clean database", or use a different email.',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: `Could not create the administrator account: ${
|
|
344
|
+
createError?.message ?? 'unknown error'
|
|
345
|
+
}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Guard against a concurrent-setup race: assertNotProvisioned() is a check-then-act,
|
|
350
|
+
// so two simultaneous submissions could both pass it. The trigger still grants ADMIN
|
|
351
|
+
// to only the first user — so if this account came back as anything other than ADMIN,
|
|
352
|
+
// another session won the race. Undo this spurious account and report it, rather than
|
|
353
|
+
// signing the operator in as a non-admin.
|
|
354
|
+
const { data: createdProfile } = await admin
|
|
355
|
+
.from('profiles')
|
|
356
|
+
.select('role')
|
|
357
|
+
.eq('id', created.user.id)
|
|
358
|
+
.maybeSingle();
|
|
359
|
+
if (createdProfile?.role !== 'ADMIN') {
|
|
360
|
+
await admin.auth.admin.deleteUser(created.user.id).catch(() => {});
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: 'Setup was just completed by another session. Please sign in instead.',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The wizard establishes the session afterwards via the canonical signInAction (a more
|
|
368
|
+
// reliable cookie path than signing in here), so we just report success.
|
|
369
|
+
return { ok: true, message: 'Setup complete.' };
|
|
370
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Dependency-free environment / provisioning detection.
|
|
2
|
+
//
|
|
3
|
+
// This module is imported from places that run in very different runtimes — the
|
|
4
|
+
// `proxy.ts` middleware (Edge), server components, server actions, and even
|
|
5
|
+
// `next.config.js` (Node, at build time). It therefore MUST stay free of
|
|
6
|
+
// `server-only`, of any Node-only imports (`node:fs`, etc.), and of any Supabase
|
|
7
|
+
// client so it is safe to evaluate anywhere.
|
|
8
|
+
//
|
|
9
|
+
// "Configured" here means the Supabase connection vars exist — there is no
|
|
10
|
+
// `DATABASE_URL` in NextBlock; the database is reached through
|
|
11
|
+
// `NEXT_PUBLIC_SUPABASE_URL` + the anon / service-role keys.
|
|
12
|
+
|
|
13
|
+
export type DeployChannel = 'docker' | 'vercel' | 'local';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when the public Supabase connection vars are present — enough to create an
|
|
17
|
+
* anon client and reach the database. This is the single predicate the boot path,
|
|
18
|
+
* the middleware gate, and the wizard all use to decide "is this instance wired up?".
|
|
19
|
+
*/
|
|
20
|
+
export function isSupabaseConfigured(): boolean {
|
|
21
|
+
return Boolean(
|
|
22
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when service-role work is also possible (creating the first admin, applying
|
|
28
|
+
* schema). The wizard's first-admin step and schema-apply step require this.
|
|
29
|
+
*/
|
|
30
|
+
export function isFullyConfigured(): boolean {
|
|
31
|
+
return isSupabaseConfigured() && Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which of the four deploy channels we appear to be running in. Drives the wizard's
|
|
36
|
+
* channel-aware prefills (Docker -> MinIO storage, Vercel -> Supabase Storage S3,
|
|
37
|
+
* local -> the user's own Cloudflare R2).
|
|
38
|
+
*/
|
|
39
|
+
export function detectChannel(): DeployChannel {
|
|
40
|
+
if (process.env.VERCEL === '1') return 'vercel';
|
|
41
|
+
|
|
42
|
+
// Docker is identified by the internal Supabase gateway URL (Kong :8000) or the MinIO
|
|
43
|
+
// storage marker the docker setup writes — NOT by NEXT_PUBLIC_IS_SANDBOX, which the
|
|
44
|
+
// managed cloud sandbox also sets.
|
|
45
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '';
|
|
46
|
+
if (
|
|
47
|
+
url.includes('localhost:8000') ||
|
|
48
|
+
url.includes('127.0.0.1:8000') ||
|
|
49
|
+
url.includes('kong:8000') ||
|
|
50
|
+
process.env.R2_ACCOUNT_ID === 'minio'
|
|
51
|
+
) {
|
|
52
|
+
return 'docker';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return 'local';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True only where the wizard may safely write `.env.local` to the working directory:
|
|
60
|
+
* local dev. NOT on Vercel (read-only FS, env is injected by the platform) and NOT
|
|
61
|
+
* inside the baked standalone Docker runner (env is set by compose, FS is read-only).
|
|
62
|
+
*/
|
|
63
|
+
export function isLocalWritableEnv(): boolean {
|
|
64
|
+
// Require NODE_ENV === 'development' explicitly (not `!== 'production'`): an UNSET
|
|
65
|
+
// NODE_ENV must NOT count as local. `next dev` / `nx serve` set it to 'development';
|
|
66
|
+
// `next build`/`next start`, Vercel, and the Docker runner set it to 'production'.
|
|
67
|
+
// The remaining checks are belt-and-suspenders. This is a SAFETY boundary (it gates
|
|
68
|
+
// destructive setup actions), so it must fail closed.
|
|
69
|
+
return (
|
|
70
|
+
process.env.NODE_ENV === 'development' &&
|
|
71
|
+
process.env.VERCEL !== '1' &&
|
|
72
|
+
process.env.DOCKER_BUILD !== 'true' &&
|
|
73
|
+
process.env.NEXTBLOCK_RUNTIME !== 'standalone'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Records the unconfigured state on `process.env.NEXTBLOCK_UNCONFIGURED` so other
|
|
79
|
+
* call sites (CSP construction, middleware) can read it without recomputing.
|
|
80
|
+
* Idempotent; returns the boolean it set.
|
|
81
|
+
*/
|
|
82
|
+
export function markUnconfiguredFlag(): boolean {
|
|
83
|
+
const unconfigured = !isSupabaseConfigured();
|
|
84
|
+
process.env.NEXTBLOCK_UNCONFIGURED = unconfigured ? 'true' : 'false';
|
|
85
|
+
return unconfigured;
|
|
86
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Server-only `.env.local` writer for the local-dev (Profile B) setup path.
|
|
3
|
+
//
|
|
4
|
+
// Reimplements the three tiny helpers from tools/scripts/lib/supabase-keys.mjs
|
|
5
|
+
// (generateSecret / readEnvValue / upsertEnv) inside the app so the browser wizard
|
|
6
|
+
// writes `.env.local` identically to the terminal flow it replaces — without pulling
|
|
7
|
+
// a file from outside the app's build graph. Kept dependency-free (node built-ins).
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { isLocalWritableEnv } from './env-status';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where `.env.local` belongs. In this Nx monorepo the canonical location is the
|
|
16
|
+
* workspace root (where `npm run setup` writes and `db:migrate`/`db:types` read), but
|
|
17
|
+
* `nx serve` runs server actions with cwd = apps/nextblock — so we walk up to the
|
|
18
|
+
* nearest ancestor containing `nx.json`. A standalone create-nextblock project has no
|
|
19
|
+
* `nx.json`, so we fall back to cwd (its own root, where `next dev` runs).
|
|
20
|
+
*/
|
|
21
|
+
function resolveEnvDir(): string {
|
|
22
|
+
let dir = process.cwd();
|
|
23
|
+
for (let i = 0; i < 8; i++) {
|
|
24
|
+
if (existsSync(path.join(dir, 'nx.json'))) return dir;
|
|
25
|
+
const parent = path.dirname(dir);
|
|
26
|
+
if (parent === dir) break;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
return process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 64-char (32-byte) hex secret. Matches tools/scripts/lib/supabase-keys.mjs. */
|
|
33
|
+
export function generateSecret(): string {
|
|
34
|
+
return randomBytes(32).toString('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read a `KEY=` value from an .env body (tolerates surrounding quotes). */
|
|
38
|
+
function readEnvValue(envContent: string, key: string): string {
|
|
39
|
+
for (const line of envContent.split(/\r?\n/)) {
|
|
40
|
+
if (line.startsWith(`${key}=`)) {
|
|
41
|
+
return line
|
|
42
|
+
.slice(key.length + 1)
|
|
43
|
+
.trim()
|
|
44
|
+
.replace(/^"(.*)"$/, '$1');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Apply `KEY=value` replacements line-by-line, appending keys not already present. */
|
|
51
|
+
function upsertEnv(envContent: string, replacements: Record<string, string>): string {
|
|
52
|
+
const applied = new Set<string>();
|
|
53
|
+
const lines = envContent.split(/\r?\n/).map((line) => {
|
|
54
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
55
|
+
if (line.startsWith(`${key}=`)) {
|
|
56
|
+
applied.add(key);
|
|
57
|
+
return `${key}=${value}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return line;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
64
|
+
if (!applied.has(key)) {
|
|
65
|
+
lines.push(`${key}=${value}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ROTATING_SECRET_KEYS = ['CRON_SECRET', 'DRAFT_MODE_SECRET', 'REVALIDATE_SECRET_TOKEN'];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write KEY=value pairs to `.env.local` at the working directory AND mirror them into
|
|
76
|
+
* the live `process.env` so the running dev server can use them for server-side work
|
|
77
|
+
* (schema probe, admin creation) without a hard restart.
|
|
78
|
+
*
|
|
79
|
+
* Caveat the caller must surface: `NEXT_PUBLIC_*` values are inlined into client
|
|
80
|
+
* bundles at build/compile time, so the browser-side Supabase client still needs a
|
|
81
|
+
* dev-server restart to pick them up. No-op (returns false) outside a local writable
|
|
82
|
+
* environment (Vercel/Docker runner are read-only / platform-managed).
|
|
83
|
+
*/
|
|
84
|
+
export async function writeEnvLocal(values: Record<string, string>): Promise<boolean> {
|
|
85
|
+
if (!isLocalWritableEnv()) return false;
|
|
86
|
+
|
|
87
|
+
const envPath = path.join(resolveEnvDir(), '.env.local');
|
|
88
|
+
let existing = '';
|
|
89
|
+
try {
|
|
90
|
+
existing = await readFile(envPath, 'utf8');
|
|
91
|
+
} catch {
|
|
92
|
+
existing = '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Generate the three rotating secrets only if absent (idempotent), like setup.mjs.
|
|
96
|
+
const ensured: Record<string, string> = { ...values };
|
|
97
|
+
for (const key of ROTATING_SECRET_KEYS) {
|
|
98
|
+
if (!ensured[key] && !readEnvValue(existing, key)) {
|
|
99
|
+
ensured[key] = generateSecret();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const next = upsertEnv(existing, ensured);
|
|
104
|
+
await writeFile(envPath, next.endsWith('\n') ? next : `${next}\n`, 'utf8');
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of Object.entries(ensured)) {
|
|
107
|
+
process.env[key] = value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Server-only provisioning status used by the /setup page (to redirect away once
|
|
3
|
+
// complete) and by the wizard's server actions (to refuse re-running setup).
|
|
4
|
+
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { isSupabaseConfigured } from './env-status';
|
|
6
|
+
|
|
7
|
+
export interface ProvisioningStatus {
|
|
8
|
+
/** Supabase connection vars are present. */
|
|
9
|
+
configured: boolean;
|
|
10
|
+
/** Core tables exist (migrations applied). */
|
|
11
|
+
schemaReady: boolean;
|
|
12
|
+
/** The first admin has been created (site_settings.is_admin_created === true). */
|
|
13
|
+
hasAdmin: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getProvisioningStatus(): Promise<ProvisioningStatus> {
|
|
17
|
+
if (!isSupabaseConfigured()) {
|
|
18
|
+
return { configured: false, schemaReady: false, hasAdmin: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Prefer the service-role client so the read is authoritative regardless of caller
|
|
22
|
+
// role/session; fall back to the request client if the service key isn't set yet.
|
|
23
|
+
let supabase: ReturnType<typeof getServiceRoleSupabaseClient>;
|
|
24
|
+
try {
|
|
25
|
+
supabase = getServiceRoleSupabaseClient();
|
|
26
|
+
} catch {
|
|
27
|
+
supabase = createClient() as unknown as ReturnType<typeof getServiceRoleSupabaseClient>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const { data, error } = await supabase
|
|
32
|
+
.from('site_settings')
|
|
33
|
+
.select('value')
|
|
34
|
+
.eq('key', 'is_admin_created')
|
|
35
|
+
.maybeSingle();
|
|
36
|
+
|
|
37
|
+
if (error) {
|
|
38
|
+
// A missing table (PostgREST PGRST205, or a "relation does not exist" message)
|
|
39
|
+
// means the schema hasn't been applied yet.
|
|
40
|
+
const missing =
|
|
41
|
+
(error as { code?: string }).code === 'PGRST205' ||
|
|
42
|
+
/relation|does not exist|schema cache/i.test(error.message ?? '');
|
|
43
|
+
return { configured: true, schemaReady: !missing, hasAdmin: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasAdmin = data?.value === true || data?.value === 'true';
|
|
47
|
+
return { configured: true, schemaReady: true, hasAdmin };
|
|
48
|
+
} catch {
|
|
49
|
+
return { configured: true, schemaReady: false, hasAdmin: false };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Throws if setup has already completed. Guards every mutating wizard action. */
|
|
54
|
+
export async function assertNotProvisioned(): Promise<void> {
|
|
55
|
+
const { hasAdmin } = await getProvisioningStatus();
|
|
56
|
+
if (hasAdmin) {
|
|
57
|
+
throw new Error('Setup has already been completed.');
|
|
58
|
+
}
|
|
59
|
+
}
|