create-nextblock 0.8.11 → 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 +101 -35
- package/docker-template/.dockerignore +23 -0
- package/docker-template/.env.docker.example +56 -0
- package/docker-template/Dockerfile +85 -0
- package/docker-template/docker/db/init/99-jwt.sql +6 -0
- package/docker-template/docker/db/init/99-roles.sql +25 -0
- package/docker-template/docker/kong/kong.yml +112 -0
- package/docker-template/docker/migrate/run-migrations.sh +51 -0
- package/docker-template/docker-compose.yml +219 -0
- package/docker-template/scripts/docker-setup.mjs +242 -0
- package/package.json +1 -1
- package/scripts/sync-template.js +29 -0
- package/templates/nextblock-template/.dockerignore +23 -0
- package/templates/nextblock-template/Dockerfile +85 -0
- 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/api/upload/presigned-url/route.ts +9 -9
- 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/db/init/99-jwt.sql +6 -0
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -0
- package/templates/nextblock-template/docker/kong/kong.yml +112 -0
- package/templates/nextblock-template/docker/migrate/run-migrations.sh +51 -0
- package/templates/nextblock-template/docker-compose.yml +219 -0
- 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/custom-block-r2-upload.test.ts +5 -5
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +2 -2
- 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/next.config.js +9 -0
- package/templates/nextblock-template/package.json +6 -2
- package/templates/nextblock-template/proxy.ts +143 -49
- package/templates/nextblock-template/scripts/docker-setup.mjs +242 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Server-side schema applier for the /setup wizard. Two backends, preferred in order:
|
|
3
|
+
//
|
|
4
|
+
// 1. Supabase Management API (HTTPS) — used when a SUPABASE_ACCESS_TOKEN + project ref
|
|
5
|
+
// are available. Robust on any network (IPv4, no direct-DB host to resolve) and
|
|
6
|
+
// needs no Postgres connection string. This is the default for Supabase Cloud.
|
|
7
|
+
// 2. Direct Postgres connection via POSTGRES_URL (the 'postgres' driver, same pattern
|
|
8
|
+
// as app/api/cron/reset-sandbox/route.ts) — fallback for self-hosted / no-token.
|
|
9
|
+
//
|
|
10
|
+
// Either way, migrations run in version order; each file is applied AND recorded in
|
|
11
|
+
// supabase_migrations.schema_migrations inside one transaction, so a failure rolls back
|
|
12
|
+
// cleanly and a retry re-runs from a clean state (critical: some migrations aren't
|
|
13
|
+
// idempotent). Applied versions are tracked exactly like the Supabase CLI.
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import postgres from 'postgres';
|
|
18
|
+
import { isLocalWritableEnv } from './env-status';
|
|
19
|
+
|
|
20
|
+
export interface SchemaApplyResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
applied: number;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Locate the migrations directory: the Nx monorepo keeps them at
|
|
28
|
+
* <workspaceRoot>/libs/db/src/supabase/migrations; a standalone create-nextblock
|
|
29
|
+
* project materializes them at <projectRoot>/supabase/migrations.
|
|
30
|
+
*/
|
|
31
|
+
function resolveMigrationsDir(): string | null {
|
|
32
|
+
// Monorepo first: find the nearest nx.json ancestor (the workspace root) and use its
|
|
33
|
+
// libs/db migrations. Checking nx.json before any supabase/ dir avoids accidentally
|
|
34
|
+
// picking up a stray app-level supabase/migrations folder.
|
|
35
|
+
let dir = process.cwd();
|
|
36
|
+
for (let i = 0; i < 8; i++) {
|
|
37
|
+
if (existsSync(path.join(dir, 'nx.json'))) {
|
|
38
|
+
const monorepo = path.join(dir, 'libs', 'db', 'src', 'supabase', 'migrations');
|
|
39
|
+
if (existsSync(monorepo)) return monorepo;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Standalone create-nextblock project: nearest supabase/migrations from cwd upward.
|
|
48
|
+
dir = process.cwd();
|
|
49
|
+
for (let i = 0; i < 8; i++) {
|
|
50
|
+
const standalone = path.join(dir, 'supabase', 'migrations');
|
|
51
|
+
if (existsSync(standalone)) return standalone;
|
|
52
|
+
const parent = path.dirname(dir);
|
|
53
|
+
if (parent === dir) break;
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Supabase project ref, from SUPABASE_PROJECT_ID or the project URL host. */
|
|
61
|
+
function resolveProjectRef(): string | null {
|
|
62
|
+
const fromId = process.env.SUPABASE_PROJECT_ID?.trim();
|
|
63
|
+
if (fromId) return fromId;
|
|
64
|
+
try {
|
|
65
|
+
const host = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').hostname;
|
|
66
|
+
if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
|
|
67
|
+
return host.split('.')[0];
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// not a cloud URL
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Turn a non-2xx Management API response into a short, non-HTML error message. */
|
|
76
|
+
function summarizeApiError(status: number, body: string): string {
|
|
77
|
+
const isHtml = /<!doctype|<html/i.test(body);
|
|
78
|
+
const detail = isHtml ? 'gateway error (HTML response)' : body.slice(0, 200).trim();
|
|
79
|
+
return `Supabase Management API ${status}${detail ? `: ${detail}` : ''}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a SQL query via the Supabase Management API with retry + timeout. Transient
|
|
84
|
+
* gateway/server errors (5xx) and rate limits (429) are retried with backoff — applying
|
|
85
|
+
* ~31 migrations is many sequential calls, and the gateway occasionally returns a 502
|
|
86
|
+
* HTML page. 4xx errors fail fast (they won't get better on retry). Returns the parsed
|
|
87
|
+
* JSON body (or null).
|
|
88
|
+
*/
|
|
89
|
+
async function managementApiQuery(ref: string, token: string, query: string): Promise<unknown> {
|
|
90
|
+
const endpoint = `https://api.supabase.com/v1/projects/${ref}/database/query`;
|
|
91
|
+
const maxAttempts = 4;
|
|
92
|
+
let lastError = 'unknown error';
|
|
93
|
+
|
|
94
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
95
|
+
if (attempt > 0) {
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 700 * attempt));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let res: Response;
|
|
100
|
+
try {
|
|
101
|
+
res = await fetch(endpoint, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ query }),
|
|
105
|
+
signal: AbortSignal.timeout(45_000),
|
|
106
|
+
});
|
|
107
|
+
} catch (caught) {
|
|
108
|
+
// Network error / timeout — retry.
|
|
109
|
+
lastError = caught instanceof Error ? caught.message : 'network error';
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (res.ok) {
|
|
114
|
+
try {
|
|
115
|
+
return await res.json();
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let body = '';
|
|
122
|
+
try {
|
|
123
|
+
body = await res.text();
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
lastError = summarizeApiError(res.status, body);
|
|
128
|
+
|
|
129
|
+
// Retry transient gateway/server errors and rate limits; fail fast on 4xx.
|
|
130
|
+
if (res.status >= 500 || res.status === 429) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(lastError);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error(`${lastError} (after ${maxAttempts} attempts)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TRACKING_DDL =
|
|
140
|
+
'create schema if not exists supabase_migrations;' +
|
|
141
|
+
'create table if not exists supabase_migrations.schema_migrations ' +
|
|
142
|
+
'(version text primary key, name text, statements text[]);';
|
|
143
|
+
|
|
144
|
+
function recordSql(version: string, file: string): string {
|
|
145
|
+
return (
|
|
146
|
+
`insert into supabase_migrations.schema_migrations (version, name) ` +
|
|
147
|
+
`values ('${version}', '${file.replace(/'/g, "''")}') on conflict (version) do nothing;`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Wrap a migration file + its version record in one explicit transaction. A mid-file
|
|
153
|
+
* failure aborts the whole thing (nothing applied, nothing recorded) so a retry re-runs
|
|
154
|
+
* from a clean state — essential for non-idempotent migrations (e.g. 25's ADD CONSTRAINT).
|
|
155
|
+
*/
|
|
156
|
+
function transactionalMigration(version: string, file: string, sqlText: string): string {
|
|
157
|
+
return `begin;\n${sqlText}\n;\n${recordSql(version, file)}\ncommit;`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Destructive reset: drop the public schema (all app tables + triggers) and the
|
|
161
|
+
// migration history (separate schema — survives a plain DROP SCHEMA public). Wrapped in
|
|
162
|
+
// one transaction so it's all-or-nothing. Auth users are NOT cleared here (SQL on the
|
|
163
|
+
// auth schema can be permission-restricted via the Management API); the caller clears
|
|
164
|
+
// them with the admin API instead. The ONLY caller (completeSetup) gates this on BOTH
|
|
165
|
+
// assertNotProvisioned() (no admin exists — a live site is immune) AND isLocalWritableEnv()
|
|
166
|
+
// (local dev only). Plus this function self-guards on isLocalWritableEnv(). Never unguarded.
|
|
167
|
+
const RESET_SQL =
|
|
168
|
+
'begin;' +
|
|
169
|
+
'drop schema if exists public cascade;' +
|
|
170
|
+
'create schema public;' +
|
|
171
|
+
'grant all on schema public to postgres;' +
|
|
172
|
+
'grant all on schema public to anon;' +
|
|
173
|
+
'grant all on schema public to authenticated;' +
|
|
174
|
+
'grant all on schema public to service_role;' +
|
|
175
|
+
'drop schema if exists supabase_migrations cascade;' +
|
|
176
|
+
'commit;';
|
|
177
|
+
|
|
178
|
+
export async function resetDatabase(): Promise<{ ok: boolean; error?: string }> {
|
|
179
|
+
// Defense-in-depth: refuse to wipe anything outside local development, regardless of
|
|
180
|
+
// the caller. This is the last line guarding an irreversible, destructive operation.
|
|
181
|
+
if (!isLocalWritableEnv()) {
|
|
182
|
+
return { ok: false, error: 'Database reset is only allowed in local development.' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ref = resolveProjectRef();
|
|
186
|
+
const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
|
|
187
|
+
|
|
188
|
+
if (ref && token) {
|
|
189
|
+
try {
|
|
190
|
+
await managementApiQuery(ref, token, RESET_SQL);
|
|
191
|
+
return { ok: true };
|
|
192
|
+
} catch (caught) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
|
|
201
|
+
if (dbUrl) {
|
|
202
|
+
const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
|
|
203
|
+
try {
|
|
204
|
+
await db.unsafe(RESET_SQL);
|
|
205
|
+
return { ok: true };
|
|
206
|
+
} catch (caught) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
|
|
210
|
+
};
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
await db.end({ timeout: 5 });
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: 'No Supabase access token or Postgres connection string is available to reset.',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function applyMigrations(): Promise<SchemaApplyResult> {
|
|
227
|
+
const migrationsDir = resolveMigrationsDir();
|
|
228
|
+
if (!migrationsDir) {
|
|
229
|
+
return { ok: false, applied: 0, error: 'Could not locate the migrations directory.' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const files = (await readdir(migrationsDir))
|
|
233
|
+
.filter((name) => /^\d+_.*\.sql$/.test(name))
|
|
234
|
+
.sort();
|
|
235
|
+
const readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
|
|
236
|
+
|
|
237
|
+
const ref = resolveProjectRef();
|
|
238
|
+
const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
|
|
239
|
+
if (ref && token) {
|
|
240
|
+
return applyViaManagementApi(ref, token, files, readSql);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
|
|
244
|
+
if (dbUrl) {
|
|
245
|
+
return applyViaPostgres(dbUrl, files, readSql);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
applied: 0,
|
|
251
|
+
error:
|
|
252
|
+
'No Supabase access token or Postgres connection string is available to apply the schema.',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Backend 1: Supabase Management API (HTTPS). */
|
|
257
|
+
async function applyViaManagementApi(
|
|
258
|
+
ref: string,
|
|
259
|
+
token: string,
|
|
260
|
+
files: string[],
|
|
261
|
+
readSql: (file: string) => Promise<string>,
|
|
262
|
+
): Promise<SchemaApplyResult> {
|
|
263
|
+
const run = (query: string) => managementApiQuery(ref, token, query);
|
|
264
|
+
|
|
265
|
+
const toRows = (raw: unknown): Array<Record<string, unknown>> => {
|
|
266
|
+
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
|
|
267
|
+
const obj = raw as { result?: unknown; data?: unknown } | null;
|
|
268
|
+
if (Array.isArray(obj?.result)) return obj!.result as Array<Record<string, unknown>>;
|
|
269
|
+
if (Array.isArray(obj?.data)) return obj!.data as Array<Record<string, unknown>>;
|
|
270
|
+
return [];
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
let applied = 0;
|
|
274
|
+
try {
|
|
275
|
+
await run(TRACKING_DDL);
|
|
276
|
+
const done = new Set(
|
|
277
|
+
toRows(await run('select version from supabase_migrations.schema_migrations;'))
|
|
278
|
+
.map((r) => r.version)
|
|
279
|
+
.filter(Boolean) as string[],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Self-heal stale history: if versions are recorded but a core table is missing
|
|
283
|
+
// (e.g. someone ran DROP SCHEMA public CASCADE without clearing the history, which
|
|
284
|
+
// lives in the separate supabase_migrations schema), those records are lying — clear
|
|
285
|
+
// them and re-apply everything from scratch.
|
|
286
|
+
if (done.size > 0) {
|
|
287
|
+
const exists =
|
|
288
|
+
toRows(await run("select to_regclass('public.site_settings') as t;"))[0]?.t != null;
|
|
289
|
+
if (!exists) {
|
|
290
|
+
await run('delete from supabase_migrations.schema_migrations;');
|
|
291
|
+
done.clear();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
const version = file.split('_')[0];
|
|
297
|
+
if (done.has(version)) continue;
|
|
298
|
+
const sqlText = await readSql(file);
|
|
299
|
+
await run(transactionalMigration(version, file, sqlText));
|
|
300
|
+
applied += 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await run("notify pgrst, 'reload schema';");
|
|
304
|
+
return { ok: true, applied };
|
|
305
|
+
} catch (caught) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
applied,
|
|
309
|
+
error:
|
|
310
|
+
caught instanceof Error
|
|
311
|
+
? caught.message
|
|
312
|
+
: 'unknown error applying migrations via the Management API',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Backend 2: direct Postgres connection. */
|
|
318
|
+
async function applyViaPostgres(
|
|
319
|
+
dbUrl: string,
|
|
320
|
+
files: string[],
|
|
321
|
+
readSql: (file: string) => Promise<string>,
|
|
322
|
+
): Promise<SchemaApplyResult> {
|
|
323
|
+
const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
|
|
324
|
+
let applied = 0;
|
|
325
|
+
try {
|
|
326
|
+
await db.unsafe(TRACKING_DDL);
|
|
327
|
+
const rows = await db<{ version: string }[]>`
|
|
328
|
+
select version from supabase_migrations.schema_migrations
|
|
329
|
+
`;
|
|
330
|
+
const done = new Set(rows.map((r) => r.version));
|
|
331
|
+
|
|
332
|
+
// Self-heal stale history (see Management API backend): recorded versions but a core
|
|
333
|
+
// table missing means the schema was wiped without clearing history — re-apply all.
|
|
334
|
+
if (done.size > 0) {
|
|
335
|
+
const sentinel = await db<{ t: string | null }[]>`
|
|
336
|
+
select to_regclass('public.site_settings')::text as t
|
|
337
|
+
`;
|
|
338
|
+
if (!sentinel[0]?.t) {
|
|
339
|
+
await db`delete from supabase_migrations.schema_migrations`;
|
|
340
|
+
done.clear();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
const version = file.split('_')[0];
|
|
346
|
+
if (done.has(version)) continue;
|
|
347
|
+
const sqlText = await readSql(file);
|
|
348
|
+
await db.unsafe(transactionalMigration(version, file, sqlText));
|
|
349
|
+
applied += 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (applied > 0) {
|
|
353
|
+
await db.unsafe("notify pgrst, 'reload schema';");
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, applied };
|
|
356
|
+
} catch (caught) {
|
|
357
|
+
const message =
|
|
358
|
+
caught instanceof Error ? caught.message : 'unknown error applying migrations';
|
|
359
|
+
// Newer Supabase projects expose no IPv4 address for the DIRECT db.<ref>.supabase.co
|
|
360
|
+
// host, so on an IPv4-only network DNS fails (ENOTFOUND/ENOENT). The Session pooler
|
|
361
|
+
// host is IPv4 — point users there.
|
|
362
|
+
const isUnreachable = /ENOTFOUND|ENOENT|EAI_AGAIN|getaddrinfo|ECONNREFUSED|ETIMEDOUT/i.test(
|
|
363
|
+
message,
|
|
364
|
+
);
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
applied,
|
|
368
|
+
error: isUnreachable
|
|
369
|
+
? `Could not reach the database host (${message}). Provide a Supabase access token (so the wizard can use the Management API over HTTPS), or use the Session pooler connection string (Supabase dashboard → Connect → Session pooler), which works on IPv4 networks.`
|
|
370
|
+
: message,
|
|
371
|
+
};
|
|
372
|
+
} finally {
|
|
373
|
+
try {
|
|
374
|
+
await db.end({ timeout: 5 });
|
|
375
|
+
} catch {
|
|
376
|
+
// Closing a connection should never mask the real result.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Server-only read/write for the `system_configuration` singleton table.
|
|
3
|
+
// Mirrors lib/privacy/settings.ts, but the data lives in a dedicated ADMIN-only
|
|
4
|
+
// table (migration 00000000000030) rather than the public site_settings store.
|
|
5
|
+
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
import type { Json } from '@nextblock-cms/db';
|
|
7
|
+
import { DEFAULT_SYSTEM_CONFIGURATION, type SystemConfiguration } from './types';
|
|
8
|
+
|
|
9
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
10
|
+
if (typeof value === 'boolean') return value;
|
|
11
|
+
if (typeof value === 'string') return value === 'true' || value === 'on';
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asSettings(value: unknown): Record<string, unknown> {
|
|
16
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
17
|
+
? (value as Record<string, unknown>)
|
|
18
|
+
: {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ConfigPatch = Partial<Pick<SystemConfiguration, 'auto_accept_signups'>> & {
|
|
22
|
+
settings?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Build a row payload, casting the loosely-typed settings object to the DB Json type. */
|
|
26
|
+
function toRow(patch: ConfigPatch): {
|
|
27
|
+
auto_accept_signups?: boolean;
|
|
28
|
+
settings?: Json;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
} {
|
|
31
|
+
const row: { auto_accept_signups?: boolean; settings?: Json; updated_at: string } = {
|
|
32
|
+
updated_at: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
if (patch.auto_accept_signups !== undefined) {
|
|
35
|
+
row.auto_accept_signups = patch.auto_accept_signups;
|
|
36
|
+
}
|
|
37
|
+
if (patch.settings !== undefined) {
|
|
38
|
+
row.settings = patch.settings as unknown as Json;
|
|
39
|
+
}
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the singleton config. Uses the service-role client so it works from any
|
|
45
|
+
* context — including the anonymous public sign-up path, which must read
|
|
46
|
+
* `auto_accept_signups` even though the table is otherwise ADMIN-only. Falls back to
|
|
47
|
+
* safe defaults when the service-role key is absent (unconfigured instance).
|
|
48
|
+
*/
|
|
49
|
+
export async function getSystemConfiguration(): Promise<SystemConfiguration> {
|
|
50
|
+
let supabase: ReturnType<typeof getServiceRoleSupabaseClient>;
|
|
51
|
+
try {
|
|
52
|
+
supabase = getServiceRoleSupabaseClient();
|
|
53
|
+
} catch {
|
|
54
|
+
return DEFAULT_SYSTEM_CONFIGURATION;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { data, error } = await supabase
|
|
58
|
+
.from('system_configuration')
|
|
59
|
+
.select('auto_accept_signups, settings')
|
|
60
|
+
.eq('id', 1)
|
|
61
|
+
.maybeSingle();
|
|
62
|
+
|
|
63
|
+
if (error || !data) {
|
|
64
|
+
return DEFAULT_SYSTEM_CONFIGURATION;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
auto_accept_signups: asBool(data.auto_accept_signups, false),
|
|
69
|
+
settings: asSettings(data.settings),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Persist a partial update via the request-scoped client. RLS enforces ADMIN — used
|
|
75
|
+
* by the CMS security settings page. The wizard (which runs before any admin exists)
|
|
76
|
+
* uses setSystemConfigurationServiceRole instead.
|
|
77
|
+
*/
|
|
78
|
+
export async function updateSystemConfiguration(patch: ConfigPatch): Promise<void> {
|
|
79
|
+
const supabase = createClient();
|
|
80
|
+
const { error } = await supabase
|
|
81
|
+
.from('system_configuration')
|
|
82
|
+
.update(toRow(patch))
|
|
83
|
+
.eq('id', 1);
|
|
84
|
+
|
|
85
|
+
if (error) {
|
|
86
|
+
console.error('Error saving system configuration:', error.message);
|
|
87
|
+
throw new Error('Failed to save system configuration.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Seed/update the singleton with the service-role client (bypasses RLS). Used by the
|
|
93
|
+
* /setup wizard before the first admin exists.
|
|
94
|
+
*/
|
|
95
|
+
export async function setSystemConfigurationServiceRole(patch: ConfigPatch): Promise<void> {
|
|
96
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
97
|
+
const { error } = await supabase
|
|
98
|
+
.from('system_configuration')
|
|
99
|
+
.upsert({ id: 1, ...toRow(patch) });
|
|
100
|
+
|
|
101
|
+
if (error) {
|
|
102
|
+
console.error('Error seeding system configuration:', error.message);
|
|
103
|
+
throw new Error('Failed to seed system configuration.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Pure, client-safe types for the system_configuration singleton.
|
|
2
|
+
// No server-only dependencies — importable from client and server modules.
|
|
3
|
+
|
|
4
|
+
export interface SystemConfiguration {
|
|
5
|
+
/**
|
|
6
|
+
* When true, new public sign-ups skip outbound email verification: the signup
|
|
7
|
+
* route creates an already-confirmed account via the service role instead of
|
|
8
|
+
* relying on an SMTP confirmation link. Default false (defense-in-depth).
|
|
9
|
+
*/
|
|
10
|
+
auto_accept_signups: boolean;
|
|
11
|
+
/** Forward-compatible feature toggles. Never holds secrets. */
|
|
12
|
+
settings: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SYSTEM_CONFIGURATION: SystemConfiguration = {
|
|
16
|
+
auto_accept_signups: false,
|
|
17
|
+
settings: {},
|
|
18
|
+
};
|
|
@@ -81,8 +81,17 @@ const securityHeaders = [
|
|
|
81
81
|
},
|
|
82
82
|
];
|
|
83
83
|
|
|
84
|
+
// Self-hosted Docker images build a standalone server (`node apps/nextblock/server.js`) instead
|
|
85
|
+
// of `next start` + a full node_modules tree. Gated on DOCKER_BUILD so Vercel/cloud builds are
|
|
86
|
+
// completely untouched. outputFileTracingRoot is pinned to the monorepo root so the standalone
|
|
87
|
+
// output nests predictably under apps/nextblock (see the root Dockerfile runner stage).
|
|
88
|
+
const isDockerStandalone = process.env.DOCKER_BUILD === 'true';
|
|
89
|
+
|
|
84
90
|
/** @type {import('next').NextConfig} */
|
|
85
91
|
const nextConfig = {
|
|
92
|
+
...(isDockerStandalone
|
|
93
|
+
? { output: 'standalone', outputFileTracingRoot: path.join(__dirname, '../../') }
|
|
94
|
+
: {}),
|
|
86
95
|
experimental: {
|
|
87
96
|
optimizePackageImports: ['@nextblock-cms/ui', '@nextblock-cms/utils'],
|
|
88
97
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextblock-cms/template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"lint": "next lint",
|
|
10
10
|
"deploy:supabase": "node tools/deploy-supabase.js",
|
|
11
|
-
"configure:supabase-auth": "node tools/configure-supabase-auth.js"
|
|
11
|
+
"configure:supabase-auth": "node tools/configure-supabase-auth.js",
|
|
12
|
+
"docker:setup": "node scripts/docker-setup.mjs",
|
|
13
|
+
"docker:up": "docker compose up -d --build",
|
|
14
|
+
"docker:down": "docker compose down",
|
|
15
|
+
"docker:logs": "docker compose logs -f nextblock-cms"
|
|
12
16
|
},
|
|
13
17
|
"dependencies": {
|
|
14
18
|
"@ai-sdk/openai-compatible": "^2.0.42",
|