create-nextblock 0.13.10 → 0.13.11

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