create-nextblock 0.16.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/CLAUDE.md +22 -0
  2. package/docker-template/.env.docker.example +69 -69
  3. package/docker-template/docker/db/init/99-jwt.sql +6 -6
  4. package/docker-template/docker/db/init/99-roles.sql +25 -25
  5. package/docker-template/docker/kong/kong.yml +112 -112
  6. package/docker-template/scripts/docker-setup.mjs +310 -310
  7. package/package.json +1 -1
  8. package/templates/nextblock-template/.browserslistrc +11 -11
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/CLAUDE.md +26 -0
  11. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  12. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  13. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  14. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  15. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  16. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  17. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  18. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  19. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  20. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  21. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  22. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  23. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +270 -205
  24. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +8096 -7198
  25. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  26. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  27. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  28. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  29. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  30. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  31. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  32. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +442 -441
  33. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +4 -1
  34. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  35. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  36. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  37. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  38. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  39. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  40. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +3 -3
  41. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  42. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -136
  43. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  44. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  45. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  46. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  47. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  48. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  49. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  50. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  51. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  52. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  53. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  54. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  55. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  57. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  58. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -273
  59. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -251
  60. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  62. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  63. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  64. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  65. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  66. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  67. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  68. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  69. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  70. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  71. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  72. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  73. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  74. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  75. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  76. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  77. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  78. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  79. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  80. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  81. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  82. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  83. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  84. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  85. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  86. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  87. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  88. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  89. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  90. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  91. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  92. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  93. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  94. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  95. package/templates/nextblock-template/components/Header.tsx +38 -38
  96. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  97. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  98. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  99. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  100. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  101. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  102. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  103. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +11 -0
  104. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  105. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  106. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  107. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  108. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  109. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  110. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  111. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  112. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  113. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  114. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  115. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  116. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  117. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  118. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  119. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  120. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  121. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +11 -0
  122. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  123. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +19 -0
  124. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  125. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  126. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  127. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  128. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  129. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  130. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  131. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -135
  132. package/templates/nextblock-template/lib/cms/payments-reminder.ts +105 -105
  133. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  134. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  135. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  136. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  137. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  138. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  139. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  140. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  141. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  142. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  143. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  144. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  145. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  146. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  147. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  148. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  149. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  150. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  151. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +56 -54
  152. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  153. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  154. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  155. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  156. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  157. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  158. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  159. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  160. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  161. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  162. package/templates/nextblock-template/lib/seo/page-document.ts +4 -4
  163. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  164. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  165. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  166. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +42 -12
  167. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  168. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  169. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  170. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  171. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  172. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  173. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  174. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  175. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  176. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  177. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  178. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  179. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  180. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  181. package/templates/nextblock-template/next-env.d.ts +1 -0
  182. package/templates/nextblock-template/package.json +1 -1
  183. package/templates/nextblock-template/public/images/cortex_post.webp +0 -0
  184. package/templates/nextblock-template/public/images/update_nextblock.webp +0 -0
  185. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  186. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  187. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  188. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  189. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  190. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  191. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  192. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  193. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  194. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  195. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  196. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
@@ -1,460 +1,460 @@
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
- import { getStorageBackend } from '../storage/provider';
17
- import { ensureStorageBucket } from '../storage/supabase-storage';
18
-
19
- export interface ActionResult {
20
- ok: boolean;
21
- error?: string;
22
- message?: string;
23
- restartRecommended?: boolean;
24
- schemaReady?: boolean;
25
- }
26
-
27
- export interface ConnectionInput {
28
- supabaseUrl: string;
29
- anonKey: string;
30
- serviceRoleKey: string;
31
- postgresUrl?: string;
32
- siteUrl?: string;
33
- /** Supabase personal access token — needed by `npm run db:migrate` to link + push. */
34
- accessToken?: string;
35
- }
36
-
37
- /**
38
- * Sanity-check a Supabase API key offline. Legacy keys are JWTs carrying { role, ref };
39
- * newer keys are opaque sb_secret_* / sb_publishable_* strings. We use this to reject an
40
- * anon key pasted into the service-role field (and vice-versa) BEFORE it gets written —
41
- * otherwise it only surfaces much later as "permission denied" on the first write, since
42
- * a SELECT probe can't tell the keys apart (anon can also read site_settings).
43
- */
44
- function inspectSupabaseKey(key: string): {
45
- role?: string;
46
- ref?: string;
47
- format: 'jwt' | 'secret' | 'publishable' | 'unknown';
48
- } {
49
- if (key.startsWith('sb_secret_')) return { role: 'service_role', format: 'secret' };
50
- if (key.startsWith('sb_publishable_')) return { role: 'anon', format: 'publishable' };
51
- const parts = key.split('.');
52
- if (parts.length === 3) {
53
- try {
54
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
55
- return {
56
- role: typeof payload?.role === 'string' ? payload.role : undefined,
57
- ref: typeof payload?.ref === 'string' ? payload.ref : undefined,
58
- format: 'jwt',
59
- };
60
- } catch {
61
- // not a decodable JWT — fall through to 'unknown'
62
- }
63
- }
64
- return { format: 'unknown' };
65
- }
66
-
67
- /**
68
- * Step (Profile B / local only): validate the Supabase credentials, then persist them
69
- * to `.env.local` and the live process. Probes with the service-role key so we can
70
- * also report whether the schema has been applied yet.
71
- */
72
- export async function saveSupabaseConnection(input: ConnectionInput): Promise<ActionResult> {
73
- await assertNotProvisioned();
74
-
75
- const supabaseUrl = input.supabaseUrl?.trim();
76
- const anonKey = input.anonKey?.trim();
77
- const serviceRoleKey = input.serviceRoleKey?.trim();
78
-
79
- if (!supabaseUrl || !anonKey || !serviceRoleKey) {
80
- return {
81
- ok: false,
82
- error: 'Supabase URL, anon key, and service-role key are all required.',
83
- };
84
- }
85
- try {
86
- new URL(supabaseUrl);
87
- } catch {
88
- return { ok: false, error: 'The Supabase URL is not a valid URL.' };
89
- }
90
-
91
- // The anon and service-role keys are easy to swap (both start with "eyJ"). Catch a
92
- // swapped or wrong-project key here, offline, with a clear message.
93
- const svcKey = inspectSupabaseKey(serviceRoleKey);
94
- if (svcKey.role && svcKey.role !== 'service_role') {
95
- return {
96
- ok: false,
97
- error: `That service-role key is actually the "${svcKey.role}" key. Paste the secret service_role key from Supabase → Project Settings → API.`,
98
- };
99
- }
100
- const anonKeyInfo = inspectSupabaseKey(anonKey);
101
- if (anonKeyInfo.role && anonKeyInfo.role !== 'anon') {
102
- return {
103
- ok: false,
104
- error: `That anon key is actually the "${anonKeyInfo.role}" key. Paste the public anon key from Supabase → Project Settings → API.`,
105
- };
106
- }
107
- let urlRef: string | undefined;
108
- try {
109
- const host = new URL(supabaseUrl).hostname;
110
- if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
111
- urlRef = host.split('.')[0];
112
- }
113
- } catch {
114
- // already validated above
115
- }
116
- if (urlRef && svcKey.ref && svcKey.ref !== urlRef) {
117
- return {
118
- ok: false,
119
- error: `That service-role key belongs to project "${svcKey.ref}", but the URL is project "${urlRef}". Use keys from the same project.`,
120
- };
121
- }
122
-
123
- if (!isLocalWritableEnv()) {
124
- return {
125
- ok: false,
126
- error:
127
- 'This environment is read-only. Set the Supabase variables on your hosting platform instead of here.',
128
- };
129
- }
130
-
131
- // Validate the credentials with a service-role probe before writing anything.
132
- const probe = createSupabaseJsClient(supabaseUrl, serviceRoleKey, {
133
- auth: { persistSession: false, autoRefreshToken: false },
134
- });
135
-
136
- // Definitive service-role check: only the service_role can call the GoTrue admin API.
137
- // A SELECT on site_settings can't tell service_role from anon (both can read it), so
138
- // this catches a rotated/invalid key that the offline inspection above can't. Works on
139
- // a fresh project too (the auth schema always exists, independent of the public schema).
140
- try {
141
- const { error: adminErr } = await probe.auth.admin.listUsers({ page: 1, perPage: 1 });
142
- if (adminErr) {
143
- return {
144
- ok: false,
145
- error: `That key can't perform admin actions (${adminErr.message}). Confirm you pasted the secret service_role key from Supabase → Project Settings → API.`,
146
- };
147
- }
148
- } catch (caught) {
149
- return {
150
- ok: false,
151
- error: `Could not verify the service-role key: ${
152
- caught instanceof Error ? caught.message : 'unknown error'
153
- }`,
154
- };
155
- }
156
-
157
- let schemaReady = false;
158
- try {
159
- const { error } = await probe.from('site_settings').select('key').limit(1);
160
- if (error) {
161
- const missing = /relation|does not exist|schema cache/i.test(error.message);
162
- if (!missing) {
163
- return {
164
- ok: false,
165
- error: `Could not reach Supabase with those credentials: ${error.message}`,
166
- };
167
- }
168
- schemaReady = false; // reachable, but the schema isn't applied yet
169
- } else {
170
- schemaReady = true;
171
- }
172
- } catch (caught) {
173
- return {
174
- ok: false,
175
- error: `Could not connect to Supabase: ${
176
- caught instanceof Error ? caught.message : 'unknown error'
177
- }`,
178
- };
179
- }
180
-
181
- const values: Record<string, string> = {
182
- NEXT_PUBLIC_SUPABASE_URL: supabaseUrl,
183
- NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
184
- SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
185
- // Enable the build-time migration hook for this install (Milestone 4). On Vercel
186
- // VERCEL_ENV gates it instead; off-Vercel (local / Docker / standalone) production
187
- // builds read this flag. Only written to a local-writable .env (no-op on Vercel).
188
- NEXTBLOCK_BUILD_MIGRATE: '1',
189
- };
190
- if (input.siteUrl?.trim()) values.NEXT_PUBLIC_URL = input.siteUrl.trim();
191
- if (input.postgresUrl?.trim()) values.POSTGRES_URL = input.postgresUrl.trim();
192
- if (input.accessToken?.trim()) values.SUPABASE_ACCESS_TOKEN = input.accessToken.trim();
193
-
194
- // Derive the project ref from the URL (https://<ref>.supabase.co) so the CLI schema
195
- // step (`npm run db:migrate`) links + pushes to THIS project, not a stale one.
196
- try {
197
- const host = new URL(supabaseUrl).hostname;
198
- if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
199
- values.SUPABASE_PROJECT_ID = host.split('.')[0];
200
- }
201
- } catch {
202
- // already validated above; ignore
203
- }
204
-
205
- try {
206
- await writeEnvLocal(values);
207
- } catch (caught) {
208
- return {
209
- ok: false,
210
- error: `Could not write .env.local: ${
211
- caught instanceof Error ? caught.message : 'unknown error'
212
- }`,
213
- };
214
- }
215
-
216
- return {
217
- ok: true,
218
- schemaReady,
219
- restartRecommended: true,
220
- message: schemaReady
221
- ? 'Connection saved and verified.'
222
- : 'Connection saved. The database schema is not applied yet — run "npm run db:migrate", then re-check below.',
223
- };
224
- }
225
-
226
- /** Poll provisioning status (used by the wizard to advance past connection/schema). */
227
- export async function recheckStatus(): Promise<ProvisioningStatus & { writable: boolean }> {
228
- const status = await getProvisioningStatus();
229
- return { ...status, writable: isLocalWritableEnv() };
230
- }
231
-
232
- export interface CompleteSetupInput {
233
- admin: { email: string; password: string; fullName: string };
234
- /** Local-only extra env (media storage) collected by the wizard. */
235
- envValues?: Record<string, string>;
236
- /** "Start from a clean database" — wipe before installing (local dev only, server-gated). */
237
- resetFirst?: boolean;
238
- }
239
-
240
- /**
241
- * After a fresh migration, PostgREST may briefly not see the new tables (its schema
242
- * cache). applyMigrations issues a reload, but it's async — poll until a known new
243
- * table reads cleanly so the REST reads/writes below don't hit a false "table not
244
- * found". Best-effort: gives up after ~6s and lets the caller proceed.
245
- */
246
- async function waitForSchemaCache(
247
- supabase: ReturnType<typeof getServiceRoleSupabaseClient>,
248
- ): Promise<void> {
249
- for (let attempt = 0; attempt < 12; attempt++) {
250
- const { error } = await supabase.from('system_configuration').select('id').limit(1);
251
- if (!error) return;
252
- await new Promise((resolve) => setTimeout(resolve, 500));
253
- }
254
- }
255
-
256
- /**
257
- * Final step: persist remaining settings and create the first admin. The
258
- * handle_new_user trigger assigns the ADMIN role and flips is_admin_created, so we
259
- * never set the role ourselves. `email_confirm: true` means no SMTP round-trip is
260
- * required, which keeps every channel (including cloud-without-SMTP) unblocked.
261
- */
262
- export async function completeSetup(input: CompleteSetupInput): Promise<ActionResult> {
263
- // "Start from a clean database" (local dev only) deliberately re-installs over an
264
- // existing DB — it wipes everything first — so the one-shot guard is skipped in that
265
- // case. Otherwise enforce it: a provisioned instance can't be re-setup or wiped, and
266
- // we return a clean message rather than throwing an unhandled error.
267
- const willReset = input.resetFirst === true && isLocalWritableEnv();
268
- if (!willReset) {
269
- try {
270
- await assertNotProvisioned();
271
- } catch {
272
- return {
273
- ok: false,
274
- error:
275
- 'Setup has already been completed. Enable "Start from a clean database" to reinstall, or sign in instead.',
276
- };
277
- }
278
- }
279
-
280
- const email = input.admin?.email?.trim().toLowerCase();
281
- const password = input.admin?.password ?? '';
282
- const fullName = input.admin?.fullName?.trim() ?? '';
283
-
284
- if (!email || !password) {
285
- return { ok: false, error: 'Administrator email and password are required.' };
286
- }
287
- if (password.length < 8) {
288
- return { ok: false, error: 'Use an administrator password of at least 8 characters.' };
289
- }
290
-
291
- // 1) Persist any local env extras (media storage) for Profile B.
292
- if (
293
- input.envValues &&
294
- Object.keys(input.envValues).length > 0 &&
295
- isLocalWritableEnv()
296
- ) {
297
- try {
298
- await writeEnvLocal(input.envValues);
299
- } catch (caught) {
300
- return {
301
- ok: false,
302
- error: `Could not write .env.local: ${
303
- caught instanceof Error ? caught.message : 'unknown error'
304
- }`,
305
- };
306
- }
307
- }
308
-
309
- // 2) Service-role access is required from here on.
310
- let admin: ReturnType<typeof getServiceRoleSupabaseClient>;
311
- try {
312
- admin = getServiceRoleSupabaseClient();
313
- } catch {
314
- return {
315
- ok: false,
316
- error:
317
- 'The service-role key is not loaded yet. Restart the dev server after saving the connection, then retry.',
318
- };
319
- }
320
-
321
- // 2.5) "Start from a clean database" (the fresh-install checkbox): wipe the DB (public
322
- // schema + migration history + auth users) before installing, so each fresh setup
323
- // starts clean. Two reliable guards: assertNotProvisioned() above means this only
324
- // runs when no admin exists (a live site is immune), and isLocalWritableEnv()
325
- // restricts it to local dev (a deployed app can never be tricked into wiping
326
- // itself, even by a crafted request).
327
- if (willReset) {
328
- const reset = await resetDatabase();
329
- if (!reset.ok) {
330
- return { ok: false, error: `Could not reset the database: ${reset.error}` };
331
- }
332
- // Clear auth users via the admin API (reliable — SQL on the auth schema can be
333
- // permission-restricted), so the admin email is free to reuse on this fresh install.
334
- try {
335
- const { data: list } = await admin.auth.admin.listUsers();
336
- for (const existing of list?.users ?? []) {
337
- await admin.auth.admin.deleteUser(existing.id);
338
- }
339
- } catch {
340
- // Non-fatal — the createUser "already exists" fallback below also handles leftovers.
341
- }
342
- }
343
-
344
- // 3) Apply the database schema if it isn't there yet (e.g. a fresh Supabase project).
345
- // Direct Postgres connection — no CLI, no config.toml. Idempotent (tracks applied
346
- // migrations), so it's a no-op when the schema already exists (Docker, or a re-run).
347
- // Then wait for PostgREST to pick up the new tables before we touch them via REST.
348
- const schemaStatus = await getProvisioningStatus();
349
- if (!schemaStatus.schemaReady) {
350
- // applyMigrations prefers the Supabase Management API (HTTPS, needs only the access
351
- // token + project ref) and falls back to a direct Postgres connection. It returns a
352
- // clear error if neither is available.
353
- const schema = await applyMigrations();
354
- if (!schema.ok) {
355
- return { ok: false, error: `Could not apply the database schema: ${schema.error}` };
356
- }
357
- }
358
-
359
- // Ensure PostgREST has the schema cached before ANY REST read/write below — covers a
360
- // fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
361
- await waitForSchemaCache(admin);
362
-
363
- // On the native Supabase Storage backend (zero-key Vercel deploy), provision the public
364
- // media bucket now so the first upload doesn't have to. Best-effort — the upload path
365
- // also ensures it lazily, so a transient failure here never blocks finishing setup.
366
- if (getStorageBackend() === 'supabase') {
367
- try {
368
- await ensureStorageBucket();
369
- } catch (caught) {
370
- console.warn(
371
- 'Could not pre-create the Supabase Storage media bucket (will retry on first upload):',
372
- caught instanceof Error ? caught.message : caught,
373
- );
374
- }
375
- }
376
-
377
- // 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
378
- // Sign-up policy, bot protection, email, and payments are no longer collected by the
379
- // wizard; they are configured later from the CMS. New sign-ups default to requiring
380
- // email verification (auto_accept_signups = false) as a safe default.
381
- try {
382
- await setSystemConfigurationServiceRole({ auto_accept_signups: false });
383
- } catch (caught) {
384
- return {
385
- ok: false,
386
- error: `Failed to save settings: ${
387
- caught instanceof Error ? caught.message : 'unknown error'
388
- }`,
389
- };
390
- }
391
-
392
- // 4) Create the first admin account (already confirmed). The handle_new_user trigger
393
- // assigns ADMIN to the very first user and flips is_admin_created atomically.
394
- const createPayload = {
395
- email,
396
- password,
397
- email_confirm: true,
398
- user_metadata: { full_name: fullName },
399
- };
400
- let { data: created, error: createError } = await admin.auth.admin.createUser(createPayload);
401
-
402
- // If a clean-install reset left a stale auth user (admin-API cleanup above hit an edge
403
- // case), remove it and retry once so the operator can reuse their email.
404
- if (
405
- createError &&
406
- input.resetFirst === true &&
407
- /already|registered|exists/i.test(createError.message)
408
- ) {
409
- try {
410
- const { data: list } = await admin.auth.admin.listUsers();
411
- const stale = list?.users?.find((u) => u.email?.toLowerCase() === email);
412
- if (stale) {
413
- await admin.auth.admin.deleteUser(stale.id);
414
- ({ data: created, error: createError } = await admin.auth.admin.createUser(createPayload));
415
- }
416
- } catch {
417
- // fall through to the error handling below
418
- }
419
- }
420
-
421
- if (createError || !created?.user) {
422
- if (createError && /already|registered|exists/i.test(createError.message)) {
423
- return {
424
- ok: false,
425
- error:
426
- 'An account with this email already exists. Enable "Start from a clean database", or use a different email.',
427
- };
428
- }
429
- return {
430
- ok: false,
431
- error: `Could not create the administrator account: ${
432
- createError?.message ?? 'unknown error'
433
- }`,
434
- };
435
- }
436
-
437
- // Guard against a concurrent-setup race: assertNotProvisioned() is a check-then-act,
438
- // so two simultaneous submissions could both pass it. The trigger still grants ADMIN
439
- // to only the first user — so if this account came back as anything other than ADMIN,
440
- // another session won the race. Undo this spurious account and report it, rather than
441
- // signing the operator in as a non-admin.
442
- const { data: createdProfile } = await admin
443
- .from('profiles')
444
- .select('role')
445
- .eq('id', created.user.id)
446
- .maybeSingle();
447
- if (createdProfile?.role !== 'ADMIN') {
448
- await admin.auth.admin.deleteUser(created.user.id).catch((err) => {
449
- console.warn('Failed to clean up stale admin user after race condition:', err);
450
- });
451
- return {
452
- ok: false,
453
- error: 'Setup was just completed by another session. Please sign in instead.',
454
- };
455
- }
456
-
457
- // The wizard establishes the session afterwards via the canonical signInAction (a more
458
- // reliable cookie path than signing in here), so we just report success.
459
- return { ok: true, message: 'Setup complete.' };
460
- }
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
+ import { getStorageBackend } from '../storage/provider';
17
+ import { ensureStorageBucket } from '../storage/supabase-storage';
18
+
19
+ export interface ActionResult {
20
+ ok: boolean;
21
+ error?: string;
22
+ message?: string;
23
+ restartRecommended?: boolean;
24
+ schemaReady?: boolean;
25
+ }
26
+
27
+ export interface ConnectionInput {
28
+ supabaseUrl: string;
29
+ anonKey: string;
30
+ serviceRoleKey: string;
31
+ postgresUrl?: string;
32
+ siteUrl?: string;
33
+ /** Supabase personal access token — needed by `npm run db:migrate` to link + push. */
34
+ accessToken?: string;
35
+ }
36
+
37
+ /**
38
+ * Sanity-check a Supabase API key offline. Legacy keys are JWTs carrying { role, ref };
39
+ * newer keys are opaque sb_secret_* / sb_publishable_* strings. We use this to reject an
40
+ * anon key pasted into the service-role field (and vice-versa) BEFORE it gets written —
41
+ * otherwise it only surfaces much later as "permission denied" on the first write, since
42
+ * a SELECT probe can't tell the keys apart (anon can also read site_settings).
43
+ */
44
+ function inspectSupabaseKey(key: string): {
45
+ role?: string;
46
+ ref?: string;
47
+ format: 'jwt' | 'secret' | 'publishable' | 'unknown';
48
+ } {
49
+ if (key.startsWith('sb_secret_')) return { role: 'service_role', format: 'secret' };
50
+ if (key.startsWith('sb_publishable_')) return { role: 'anon', format: 'publishable' };
51
+ const parts = key.split('.');
52
+ if (parts.length === 3) {
53
+ try {
54
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
55
+ return {
56
+ role: typeof payload?.role === 'string' ? payload.role : undefined,
57
+ ref: typeof payload?.ref === 'string' ? payload.ref : undefined,
58
+ format: 'jwt',
59
+ };
60
+ } catch {
61
+ // not a decodable JWT — fall through to 'unknown'
62
+ }
63
+ }
64
+ return { format: 'unknown' };
65
+ }
66
+
67
+ /**
68
+ * Step (Profile B / local only): validate the Supabase credentials, then persist them
69
+ * to `.env.local` and the live process. Probes with the service-role key so we can
70
+ * also report whether the schema has been applied yet.
71
+ */
72
+ export async function saveSupabaseConnection(input: ConnectionInput): Promise<ActionResult> {
73
+ await assertNotProvisioned();
74
+
75
+ const supabaseUrl = input.supabaseUrl?.trim();
76
+ const anonKey = input.anonKey?.trim();
77
+ const serviceRoleKey = input.serviceRoleKey?.trim();
78
+
79
+ if (!supabaseUrl || !anonKey || !serviceRoleKey) {
80
+ return {
81
+ ok: false,
82
+ error: 'Supabase URL, anon key, and service-role key are all required.',
83
+ };
84
+ }
85
+ try {
86
+ new URL(supabaseUrl);
87
+ } catch {
88
+ return { ok: false, error: 'The Supabase URL is not a valid URL.' };
89
+ }
90
+
91
+ // The anon and service-role keys are easy to swap (both start with "eyJ"). Catch a
92
+ // swapped or wrong-project key here, offline, with a clear message.
93
+ const svcKey = inspectSupabaseKey(serviceRoleKey);
94
+ if (svcKey.role && svcKey.role !== 'service_role') {
95
+ return {
96
+ ok: false,
97
+ error: `That service-role key is actually the "${svcKey.role}" key. Paste the secret service_role key from Supabase → Project Settings → API.`,
98
+ };
99
+ }
100
+ const anonKeyInfo = inspectSupabaseKey(anonKey);
101
+ if (anonKeyInfo.role && anonKeyInfo.role !== 'anon') {
102
+ return {
103
+ ok: false,
104
+ error: `That anon key is actually the "${anonKeyInfo.role}" key. Paste the public anon key from Supabase → Project Settings → API.`,
105
+ };
106
+ }
107
+ let urlRef: string | undefined;
108
+ try {
109
+ const host = new URL(supabaseUrl).hostname;
110
+ if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
111
+ urlRef = host.split('.')[0];
112
+ }
113
+ } catch {
114
+ // already validated above
115
+ }
116
+ if (urlRef && svcKey.ref && svcKey.ref !== urlRef) {
117
+ return {
118
+ ok: false,
119
+ error: `That service-role key belongs to project "${svcKey.ref}", but the URL is project "${urlRef}". Use keys from the same project.`,
120
+ };
121
+ }
122
+
123
+ if (!isLocalWritableEnv()) {
124
+ return {
125
+ ok: false,
126
+ error:
127
+ 'This environment is read-only. Set the Supabase variables on your hosting platform instead of here.',
128
+ };
129
+ }
130
+
131
+ // Validate the credentials with a service-role probe before writing anything.
132
+ const probe = createSupabaseJsClient(supabaseUrl, serviceRoleKey, {
133
+ auth: { persistSession: false, autoRefreshToken: false },
134
+ });
135
+
136
+ // Definitive service-role check: only the service_role can call the GoTrue admin API.
137
+ // A SELECT on site_settings can't tell service_role from anon (both can read it), so
138
+ // this catches a rotated/invalid key that the offline inspection above can't. Works on
139
+ // a fresh project too (the auth schema always exists, independent of the public schema).
140
+ try {
141
+ const { error: adminErr } = await probe.auth.admin.listUsers({ page: 1, perPage: 1 });
142
+ if (adminErr) {
143
+ return {
144
+ ok: false,
145
+ error: `That key can't perform admin actions (${adminErr.message}). Confirm you pasted the secret service_role key from Supabase → Project Settings → API.`,
146
+ };
147
+ }
148
+ } catch (caught) {
149
+ return {
150
+ ok: false,
151
+ error: `Could not verify the service-role key: ${
152
+ caught instanceof Error ? caught.message : 'unknown error'
153
+ }`,
154
+ };
155
+ }
156
+
157
+ let schemaReady = false;
158
+ try {
159
+ const { error } = await probe.from('site_settings').select('key').limit(1);
160
+ if (error) {
161
+ const missing = /relation|does not exist|schema cache/i.test(error.message);
162
+ if (!missing) {
163
+ return {
164
+ ok: false,
165
+ error: `Could not reach Supabase with those credentials: ${error.message}`,
166
+ };
167
+ }
168
+ schemaReady = false; // reachable, but the schema isn't applied yet
169
+ } else {
170
+ schemaReady = true;
171
+ }
172
+ } catch (caught) {
173
+ return {
174
+ ok: false,
175
+ error: `Could not connect to Supabase: ${
176
+ caught instanceof Error ? caught.message : 'unknown error'
177
+ }`,
178
+ };
179
+ }
180
+
181
+ const values: Record<string, string> = {
182
+ NEXT_PUBLIC_SUPABASE_URL: supabaseUrl,
183
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
184
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
185
+ // Enable the build-time migration hook for this install (Milestone 4). On Vercel
186
+ // VERCEL_ENV gates it instead; off-Vercel (local / Docker / standalone) production
187
+ // builds read this flag. Only written to a local-writable .env (no-op on Vercel).
188
+ NEXTBLOCK_BUILD_MIGRATE: '1',
189
+ };
190
+ if (input.siteUrl?.trim()) values.NEXT_PUBLIC_URL = input.siteUrl.trim();
191
+ if (input.postgresUrl?.trim()) values.POSTGRES_URL = input.postgresUrl.trim();
192
+ if (input.accessToken?.trim()) values.SUPABASE_ACCESS_TOKEN = input.accessToken.trim();
193
+
194
+ // Derive the project ref from the URL (https://<ref>.supabase.co) so the CLI schema
195
+ // step (`npm run db:migrate`) links + pushes to THIS project, not a stale one.
196
+ try {
197
+ const host = new URL(supabaseUrl).hostname;
198
+ if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
199
+ values.SUPABASE_PROJECT_ID = host.split('.')[0];
200
+ }
201
+ } catch {
202
+ // already validated above; ignore
203
+ }
204
+
205
+ try {
206
+ await writeEnvLocal(values);
207
+ } catch (caught) {
208
+ return {
209
+ ok: false,
210
+ error: `Could not write .env.local: ${
211
+ caught instanceof Error ? caught.message : 'unknown error'
212
+ }`,
213
+ };
214
+ }
215
+
216
+ return {
217
+ ok: true,
218
+ schemaReady,
219
+ restartRecommended: true,
220
+ message: schemaReady
221
+ ? 'Connection saved and verified.'
222
+ : 'Connection saved. The database schema is not applied yet — run "npm run db:migrate", then re-check below.',
223
+ };
224
+ }
225
+
226
+ /** Poll provisioning status (used by the wizard to advance past connection/schema). */
227
+ export async function recheckStatus(): Promise<ProvisioningStatus & { writable: boolean }> {
228
+ const status = await getProvisioningStatus();
229
+ return { ...status, writable: isLocalWritableEnv() };
230
+ }
231
+
232
+ export interface CompleteSetupInput {
233
+ admin: { email: string; password: string; fullName: string };
234
+ /** Local-only extra env (media storage) collected by the wizard. */
235
+ envValues?: Record<string, string>;
236
+ /** "Start from a clean database" — wipe before installing (local dev only, server-gated). */
237
+ resetFirst?: boolean;
238
+ }
239
+
240
+ /**
241
+ * After a fresh migration, PostgREST may briefly not see the new tables (its schema
242
+ * cache). applyMigrations issues a reload, but it's async — poll until a known new
243
+ * table reads cleanly so the REST reads/writes below don't hit a false "table not
244
+ * found". Best-effort: gives up after ~6s and lets the caller proceed.
245
+ */
246
+ async function waitForSchemaCache(
247
+ supabase: ReturnType<typeof getServiceRoleSupabaseClient>,
248
+ ): Promise<void> {
249
+ for (let attempt = 0; attempt < 12; attempt++) {
250
+ const { error } = await supabase.from('system_configuration').select('id').limit(1);
251
+ if (!error) return;
252
+ await new Promise((resolve) => setTimeout(resolve, 500));
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Final step: persist remaining settings and create the first admin. The
258
+ * handle_new_user trigger assigns the ADMIN role and flips is_admin_created, so we
259
+ * never set the role ourselves. `email_confirm: true` means no SMTP round-trip is
260
+ * required, which keeps every channel (including cloud-without-SMTP) unblocked.
261
+ */
262
+ export async function completeSetup(input: CompleteSetupInput): Promise<ActionResult> {
263
+ // "Start from a clean database" (local dev only) deliberately re-installs over an
264
+ // existing DB — it wipes everything first — so the one-shot guard is skipped in that
265
+ // case. Otherwise enforce it: a provisioned instance can't be re-setup or wiped, and
266
+ // we return a clean message rather than throwing an unhandled error.
267
+ const willReset = input.resetFirst === true && isLocalWritableEnv();
268
+ if (!willReset) {
269
+ try {
270
+ await assertNotProvisioned();
271
+ } catch {
272
+ return {
273
+ ok: false,
274
+ error:
275
+ 'Setup has already been completed. Enable "Start from a clean database" to reinstall, or sign in instead.',
276
+ };
277
+ }
278
+ }
279
+
280
+ const email = input.admin?.email?.trim().toLowerCase();
281
+ const password = input.admin?.password ?? '';
282
+ const fullName = input.admin?.fullName?.trim() ?? '';
283
+
284
+ if (!email || !password) {
285
+ return { ok: false, error: 'Administrator email and password are required.' };
286
+ }
287
+ if (password.length < 8) {
288
+ return { ok: false, error: 'Use an administrator password of at least 8 characters.' };
289
+ }
290
+
291
+ // 1) Persist any local env extras (media storage) for Profile B.
292
+ if (
293
+ input.envValues &&
294
+ Object.keys(input.envValues).length > 0 &&
295
+ isLocalWritableEnv()
296
+ ) {
297
+ try {
298
+ await writeEnvLocal(input.envValues);
299
+ } catch (caught) {
300
+ return {
301
+ ok: false,
302
+ error: `Could not write .env.local: ${
303
+ caught instanceof Error ? caught.message : 'unknown error'
304
+ }`,
305
+ };
306
+ }
307
+ }
308
+
309
+ // 2) Service-role access is required from here on.
310
+ let admin: ReturnType<typeof getServiceRoleSupabaseClient>;
311
+ try {
312
+ admin = getServiceRoleSupabaseClient();
313
+ } catch {
314
+ return {
315
+ ok: false,
316
+ error:
317
+ 'The service-role key is not loaded yet. Restart the dev server after saving the connection, then retry.',
318
+ };
319
+ }
320
+
321
+ // 2.5) "Start from a clean database" (the fresh-install checkbox): wipe the DB (public
322
+ // schema + migration history + auth users) before installing, so each fresh setup
323
+ // starts clean. Two reliable guards: assertNotProvisioned() above means this only
324
+ // runs when no admin exists (a live site is immune), and isLocalWritableEnv()
325
+ // restricts it to local dev (a deployed app can never be tricked into wiping
326
+ // itself, even by a crafted request).
327
+ if (willReset) {
328
+ const reset = await resetDatabase();
329
+ if (!reset.ok) {
330
+ return { ok: false, error: `Could not reset the database: ${reset.error}` };
331
+ }
332
+ // Clear auth users via the admin API (reliable — SQL on the auth schema can be
333
+ // permission-restricted), so the admin email is free to reuse on this fresh install.
334
+ try {
335
+ const { data: list } = await admin.auth.admin.listUsers();
336
+ for (const existing of list?.users ?? []) {
337
+ await admin.auth.admin.deleteUser(existing.id);
338
+ }
339
+ } catch {
340
+ // Non-fatal — the createUser "already exists" fallback below also handles leftovers.
341
+ }
342
+ }
343
+
344
+ // 3) Apply the database schema if it isn't there yet (e.g. a fresh Supabase project).
345
+ // Direct Postgres connection — no CLI, no config.toml. Idempotent (tracks applied
346
+ // migrations), so it's a no-op when the schema already exists (Docker, or a re-run).
347
+ // Then wait for PostgREST to pick up the new tables before we touch them via REST.
348
+ const schemaStatus = await getProvisioningStatus();
349
+ if (!schemaStatus.schemaReady) {
350
+ // applyMigrations prefers the Supabase Management API (HTTPS, needs only the access
351
+ // token + project ref) and falls back to a direct Postgres connection. It returns a
352
+ // clear error if neither is available.
353
+ const schema = await applyMigrations();
354
+ if (!schema.ok) {
355
+ return { ok: false, error: `Could not apply the database schema: ${schema.error}` };
356
+ }
357
+ }
358
+
359
+ // Ensure PostgREST has the schema cached before ANY REST read/write below — covers a
360
+ // fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
361
+ await waitForSchemaCache(admin);
362
+
363
+ // On the native Supabase Storage backend (zero-key Vercel deploy), provision the public
364
+ // media bucket now so the first upload doesn't have to. Best-effort — the upload path
365
+ // also ensures it lazily, so a transient failure here never blocks finishing setup.
366
+ if (getStorageBackend() === 'supabase') {
367
+ try {
368
+ await ensureStorageBucket();
369
+ } catch (caught) {
370
+ console.warn(
371
+ 'Could not pre-create the Supabase Storage media bucket (will retry on first upload):',
372
+ caught instanceof Error ? caught.message : caught,
373
+ );
374
+ }
375
+ }
376
+
377
+ // 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
378
+ // Sign-up policy, bot protection, email, and payments are no longer collected by the
379
+ // wizard; they are configured later from the CMS. New sign-ups default to requiring
380
+ // email verification (auto_accept_signups = false) as a safe default.
381
+ try {
382
+ await setSystemConfigurationServiceRole({ auto_accept_signups: false });
383
+ } catch (caught) {
384
+ return {
385
+ ok: false,
386
+ error: `Failed to save settings: ${
387
+ caught instanceof Error ? caught.message : 'unknown error'
388
+ }`,
389
+ };
390
+ }
391
+
392
+ // 4) Create the first admin account (already confirmed). The handle_new_user trigger
393
+ // assigns ADMIN to the very first user and flips is_admin_created atomically.
394
+ const createPayload = {
395
+ email,
396
+ password,
397
+ email_confirm: true,
398
+ user_metadata: { full_name: fullName },
399
+ };
400
+ let { data: created, error: createError } = await admin.auth.admin.createUser(createPayload);
401
+
402
+ // If a clean-install reset left a stale auth user (admin-API cleanup above hit an edge
403
+ // case), remove it and retry once so the operator can reuse their email.
404
+ if (
405
+ createError &&
406
+ input.resetFirst === true &&
407
+ /already|registered|exists/i.test(createError.message)
408
+ ) {
409
+ try {
410
+ const { data: list } = await admin.auth.admin.listUsers();
411
+ const stale = list?.users?.find((u) => u.email?.toLowerCase() === email);
412
+ if (stale) {
413
+ await admin.auth.admin.deleteUser(stale.id);
414
+ ({ data: created, error: createError } = await admin.auth.admin.createUser(createPayload));
415
+ }
416
+ } catch {
417
+ // fall through to the error handling below
418
+ }
419
+ }
420
+
421
+ if (createError || !created?.user) {
422
+ if (createError && /already|registered|exists/i.test(createError.message)) {
423
+ return {
424
+ ok: false,
425
+ error:
426
+ 'An account with this email already exists. Enable "Start from a clean database", or use a different email.',
427
+ };
428
+ }
429
+ return {
430
+ ok: false,
431
+ error: `Could not create the administrator account: ${
432
+ createError?.message ?? 'unknown error'
433
+ }`,
434
+ };
435
+ }
436
+
437
+ // Guard against a concurrent-setup race: assertNotProvisioned() is a check-then-act,
438
+ // so two simultaneous submissions could both pass it. The trigger still grants ADMIN
439
+ // to only the first user — so if this account came back as anything other than ADMIN,
440
+ // another session won the race. Undo this spurious account and report it, rather than
441
+ // signing the operator in as a non-admin.
442
+ const { data: createdProfile } = await admin
443
+ .from('profiles')
444
+ .select('role')
445
+ .eq('id', created.user.id)
446
+ .maybeSingle();
447
+ if (createdProfile?.role !== 'ADMIN') {
448
+ await admin.auth.admin.deleteUser(created.user.id).catch((err) => {
449
+ console.warn('Failed to clean up stale admin user after race condition:', err);
450
+ });
451
+ return {
452
+ ok: false,
453
+ error: 'Setup was just completed by another session. Please sign in instead.',
454
+ };
455
+ }
456
+
457
+ // The wizard establishes the session afterwards via the canonical signInAction (a more
458
+ // reliable cookie path than signing in here), so we just report success.
459
+ return { ok: true, message: 'Setup complete.' };
460
+ }