create-nextblock 0.14.1 → 0.14.4

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 (263) hide show
  1. package/docker-template/.dockerignore +23 -23
  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/libs/db/tsconfig.lib.json +3 -3
  8. package/libs/editor/tsconfig.lib.json +3 -3
  9. package/libs/ui/tsconfig.lib.json +3 -3
  10. package/libs/utils/tsconfig.json +3 -3
  11. package/package.json +1 -1
  12. package/project.json +19 -19
  13. package/templates/nextblock-template/.browserslistrc +11 -11
  14. package/templates/nextblock-template/.dockerignore +23 -23
  15. package/templates/nextblock-template/README.md +34 -34
  16. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  17. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  18. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  19. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  20. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
  21. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  22. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  23. package/templates/nextblock-template/app/actions/postActions.ts +146 -146
  24. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  25. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  26. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  27. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  28. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  29. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  30. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  31. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  32. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5881 -5623
  33. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  34. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  35. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  36. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  37. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  38. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  39. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  40. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  41. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  42. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  43. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  44. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  45. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  46. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  47. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  48. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  49. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  50. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
  51. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  52. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  53. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  54. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  55. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  56. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  57. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  58. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  59. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  60. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  61. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  62. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  63. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  64. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  65. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  66. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  67. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  68. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  69. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  70. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  71. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  72. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  73. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  75. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  76. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  77. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  78. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  79. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  80. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  81. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  82. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  83. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  86. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  87. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  88. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  95. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  97. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  98. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  99. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  103. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
  104. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  105. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  106. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  107. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  108. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  109. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  110. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
  111. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +49 -0
  116. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  117. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  118. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  119. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  120. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  121. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  122. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  123. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  124. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  125. package/templates/nextblock-template/app/providers.tsx +16 -4
  126. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  127. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  128. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  129. package/templates/nextblock-template/app/sitemap.ts +130 -130
  130. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  131. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  132. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  133. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  134. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  135. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  136. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  137. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  138. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  139. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  140. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  141. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  142. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  143. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  144. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  145. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  146. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  147. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  148. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  149. package/templates/nextblock-template/components/form-message.tsx +32 -32
  150. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  151. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  152. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  153. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  154. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  155. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  157. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  158. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  159. package/templates/nextblock-template/components/theme-switcher.tsx +77 -82
  160. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  161. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  162. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  163. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  164. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  165. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  166. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  167. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  168. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  169. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +279 -202
  170. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  171. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  172. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  173. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  174. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  175. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  176. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  177. package/templates/nextblock-template/docs/README.md +39 -39
  178. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  179. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  180. package/templates/nextblock-template/index.d.ts +7 -7
  181. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  182. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  183. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  184. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  185. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  186. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  187. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  188. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  189. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  190. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  191. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  192. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  193. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  194. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  195. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  196. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  197. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  198. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  199. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  200. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  201. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  202. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  203. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  204. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  205. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  206. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  207. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  208. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  209. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  210. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  211. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  212. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  213. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  214. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  217. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  218. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  219. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  220. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  221. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  222. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  223. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  224. package/templates/nextblock-template/lib/search/types.ts +27 -27
  225. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  226. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  227. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  228. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -10
  229. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  230. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  231. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  232. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  233. package/templates/nextblock-template/lib/site-url.ts +48 -48
  234. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  235. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  236. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  240. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  241. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  242. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  243. package/templates/nextblock-template/next-env.d.ts +2 -2
  244. package/templates/nextblock-template/package.json +1 -1
  245. package/templates/nextblock-template/postcss.config.js +6 -6
  246. package/templates/nextblock-template/scripts/backup.js +115 -115
  247. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  248. package/templates/nextblock-template/scripts/restore.js +385 -385
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  250. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  251. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  252. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  253. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  254. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  255. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  256. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  257. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  262. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  263. package/tsconfig.base.json +3 -3
@@ -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
+ }