create-nextblock 0.16.1 → 0.16.2

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 (258) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  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/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. package/tsconfig.base.json +3 -3
@@ -1,466 +1,466 @@
1
- /**
2
- * The database side of managed redirects: reading `public.cms_redirects` and
3
- * turning its rows into the pure `RedirectRule` shape the SEO engine understands.
4
- *
5
- * This module exists as its own file, separate from `proxy.ts`, for one reason:
6
- * the proxy cannot be unit tested. It is a Next.js entry point that receives a
7
- * real `NextRequest` and returns a `NextResponse`, and everything interesting it
8
- * does about redirects — deciding whether a path is even worth a lookup, deciding
9
- * what a malformed row should become, deciding whether a read failure means "no
10
- * redirects" or "we could not tell" — is ordinary logic that deserves ordinary
11
- * tests. So all of that logic lives here, behind plain functions over plain
12
- * values, and `proxy.ts` keeps only the request/response plumbing plus the cache.
13
- *
14
- * WHY THE ROW TYPE IS HAND-WRITTEN. `libs/db`'s generated `Database` type is
15
- * produced by `npm run db:types` against a live Supabase project. The migration
16
- * that creates `cms_redirects` (00000000000030_seo_redirects_and_robots.sql) is
17
- * committed but has not been applied yet, so the generated type does not contain
18
- * the table and `Database['public']['Tables']['cms_redirects']` would not compile.
19
- * Writing the row shape by hand from the migration's DDL keeps this file building
20
- * today; once the migration is applied and the types are regenerated, this
21
- * interface becomes a redundant-but-harmless restatement of the generated one, and
22
- * the columns it names are exactly the columns the SELECT below asks for, so a
23
- * drift between the two would show up as a failing query rather than as silently
24
- * wrong data.
25
- *
26
- * NOTHING HERE THROWS. Every export is either pure or wrapped in a try/catch that
27
- * degrades to a safe value. The only caller on the hot path is the Next.js proxy,
28
- * which runs in front of every request on the site: an exception escaping into it
29
- * does not produce a broken redirect, it produces a 500 for every page, every
30
- * asset and every API route at once. "Fail open, serve the page" is the only
31
- * acceptable failure mode for a redirect lookup.
32
- */
33
-
34
- import {
35
- normalizeRedirectPath,
36
- type RedirectRule,
37
- type RedirectStatusCode,
38
- } from '@nextblock-cms/utils/seo';
39
-
40
- /** The table the proxy and the admin screen both read. */
41
- export const CMS_REDIRECTS_TABLE = 'cms_redirects';
42
-
43
- /**
44
- * The columns fetched for a proxy lookup. `created_at` / `updated_at` are
45
- * deliberately not selected: the proxy never shows them, and a redirect lookup
46
- * sits in front of every page render, so there is no reason to move bytes that
47
- * nothing on this path reads.
48
- */
49
- const REDIRECT_LOOKUP_COLUMNS = 'id, source_path, destination_path, status_code, is_active';
50
-
51
- /**
52
- * A row of `public.cms_redirects`, transcribed from the migration's DDL.
53
- *
54
- * Every column is NOT NULL in Postgres, which is why none of these are optional —
55
- * but `mapRedirectRow` still validates each one at runtime rather than trusting
56
- * this declaration, because the value actually arriving here came over the wire
57
- * from PostgREST and a TypeScript interface asserts nothing about it.
58
- */
59
- export interface CmsRedirectRow {
60
- created_at: string;
61
- destination_path: string;
62
- id: string;
63
- is_active: boolean;
64
- source_path: string;
65
- status_code: number;
66
- updated_at: string;
67
- }
68
-
69
- /**
70
- * The minimal shape `fetchActiveRedirects` needs from a Supabase client.
71
- *
72
- * Typing the parameter structurally rather than as `SupabaseClient` is what makes
73
- * this function testable without a network or a running database: a test passes an
74
- * object literal with a `from` method. It also keeps the module honest about how
75
- * little it actually uses — one table, one filter, no auth, no realtime.
76
- */
77
- export interface RedirectQueryClient {
78
- from: (table: string) => any;
79
- }
80
-
81
- /**
82
- * Coerces whatever the `status_code` column produced into one of the two codes
83
- * the redirect engine models.
84
- *
85
- * The database already constrains this to 301 or 302, so in practice the fallback
86
- * never fires. It exists because the alternative — returning `null` and dropping
87
- * the rule — would mean a single unexpected value silently un-publishes a redirect
88
- * an operator can plainly see in the admin list. Defaulting to 301 keeps the rule
89
- * working, and permanent-rather-than-temporary matches what every row in the table
90
- * already is by default.
91
- *
92
- * Numeric strings are accepted because a jsonb round trip or a hand-written import
93
- * can turn `301` into `"301"`, and refusing that would be pedantry rather than
94
- * safety.
95
- */
96
- function coerceRedirectStatusCode(value: unknown): RedirectStatusCode {
97
- const numeric =
98
- typeof value === 'number'
99
- ? value
100
- : typeof value === 'string'
101
- ? Number.parseInt(value, 10)
102
- : Number.NaN;
103
-
104
- return numeric === 302 ? 302 : 301;
105
- }
106
-
107
- /**
108
- * Maps one database row onto the engine's camelCase `RedirectRule`, or returns
109
- * `null` when the row cannot be trusted.
110
- *
111
- * Malformed means "a field the redirect cannot work without is missing or is not a
112
- * string": an id we could not report in an error, a source we could not match, or a
113
- * destination we could not send anyone to. Those rows are dropped individually
114
- * rather than failing the whole load, so one bad row cannot take every other
115
- * redirect on the site offline with it.
116
- *
117
- * `is_active` is read as "anything other than an explicit `false` counts as active".
118
- * The query already filters on `is_active = true`, so this only decides the outcome
119
- * for a row that arrived through some other path (a test, or a future admin-side
120
- * reader that wants inactive rules too), and treating an absent flag as active
121
- * matches the column's `DEFAULT true`.
122
- */
123
- export function mapRedirectRow(row: unknown): RedirectRule | null {
124
- if (typeof row !== 'object' || row === null || Array.isArray(row)) {
125
- return null;
126
- }
127
-
128
- const candidate = row as Record<string, unknown>;
129
- const id = candidate['id'];
130
- const sourcePath = candidate['source_path'];
131
- const destinationPath = candidate['destination_path'];
132
-
133
- if (typeof id !== 'string' || id.trim() === '') {
134
- return null;
135
- }
136
- if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
137
- return null;
138
- }
139
- if (typeof destinationPath !== 'string' || destinationPath.trim() === '') {
140
- return null;
141
- }
142
-
143
- return {
144
- destinationPath: destinationPath.trim(),
145
- id,
146
- isActive: candidate['is_active'] !== false,
147
- sourcePath: sourcePath.trim(),
148
- statusCode: coerceRedirectStatusCode(candidate['status_code']),
149
- };
150
- }
151
-
152
- /**
153
- * A Supabase/PostgREST error that means the table itself is absent — i.e. the
154
- * schema was never applied.
155
- *
156
- * This is NOT a transient hiccup: it is the signature of a fresh, unprovisioned
157
- * deploy (env injected, migrations not yet run), or — for `cms_redirects`
158
- * specifically — of an existing install that has pulled the code but not yet run
159
- * `npm run db:migrate`. Both are steady states that can last for days, so a caller
160
- * must be able to tell them apart from a database that is merely busy and back off
161
- * accordingly instead of hammering a table that does not exist.
162
- *
163
- * 42P01 = undefined_table (Postgres); PGRST205 = PostgREST "table not in schema
164
- * cache". The message sniffing underneath is there because PostgREST does not
165
- * always populate `code` on a schema-cache miss, and a wrong answer here is cheap
166
- * in both directions: at worst we negative-cache a transient failure for a few
167
- * seconds.
168
- *
169
- * This lives here rather than in `proxy.ts` because both the redirect lookup and
170
- * the proxy's existing `is_admin_created` probe need exactly this test, and two
171
- * copies of a predicate that decides whether a site funnels all its traffic to
172
- * /setup is one copy too many.
173
- */
174
- export function isSchemaMissingError(
175
- error: { code?: string | null; message?: string | null } | null | undefined,
176
- ): boolean {
177
- if (!error) {
178
- return false;
179
- }
180
-
181
- const code = error.code ?? '';
182
- if (code === '42P01' || code === 'PGRST205') {
183
- return true;
184
- }
185
-
186
- const message = (error.message ?? '').toLowerCase();
187
- return (
188
- message.includes('does not exist') ||
189
- message.includes('schema cache') ||
190
- message.includes('could not find the table')
191
- );
192
- }
193
-
194
- /**
195
- * Loads every active redirect rule.
196
- *
197
- * The three-valued return is the whole point of this function, so it is worth
198
- * being explicit about it:
199
- *
200
- * - `[]` means "the table is readable and there are no active redirects". Almost
201
- * every site is in this state, and it is a *positive* answer: the caller may
202
- * cache it for as long as it caches a real rule set.
203
- * - `null` means "we could not read". The table may be missing (migration not yet
204
- * applied), the database may be down, PostgREST may have hiccuped. The caller
205
- * must NOT treat this as "no redirects, and here is a nice long cache entry" —
206
- * it should negative-cache briefly and retry, so redirects start working within
207
- * seconds of the migration landing rather than a minute later.
208
- * - A non-empty array is the rule set.
209
- *
210
- * Collapsing those two empty-ish cases into one would be the classic bug: an
211
- * unmigrated install would look identical to a migrated one with no rules, and the
212
- * moment a real outage began the site would cheerfully cache "no redirects" for
213
- * the full success TTL.
214
- *
215
- * The function never throws. A PostgREST error object, a rejected promise and a
216
- * client that blows up mid-chain all become `null`.
217
- */
218
- export async function fetchActiveRedirects(
219
- supabase: RedirectQueryClient,
220
- ): Promise<RedirectRule[] | null> {
221
- try {
222
- const { data, error } = await supabase
223
- .from(CMS_REDIRECTS_TABLE)
224
- .select(REDIRECT_LOOKUP_COLUMNS)
225
- .eq('is_active', true);
226
-
227
- // A missing table and a transient failure land in the same place on purpose:
228
- // this caller's only question is "can I trust an empty result?", and the answer
229
- // is no either way. `isSchemaMissingError` is exported for the callers that DO
230
- // need to distinguish (the proxy's provisioning gate), not for this one.
231
- if (error) {
232
- return null;
233
- }
234
-
235
- // A null/undefined `data` with no error is not something PostgREST does for a
236
- // list query — it returns `[]` — so if it happens, something has gone wrong
237
- // enough that "I could not read this" is the truthful answer.
238
- if (!Array.isArray(data)) {
239
- return null;
240
- }
241
-
242
- const rules: RedirectRule[] = [];
243
- for (const row of data) {
244
- const rule = mapRedirectRow(row);
245
- if (rule !== null) {
246
- rules.push(rule);
247
- }
248
- }
249
-
250
- return rules;
251
- } catch {
252
- return null;
253
- }
254
- }
255
-
256
- /**
257
- * Path prefixes that can never be a redirect source, checked before any database
258
- * work happens.
259
- *
260
- * This guard is doing far more work than it looks like it is. The proxy matcher is
261
- * `/((?!_next/static|_next/image|favicon.ico|auth/.*|api/auth/.*|api/revalidate|api/revalidate-log).*)`,
262
- * which is an exclusion list of six specific paths — meaning the proxy runs on
263
- * essentially EVERYTHING else: every API route, every dynamically served image,
264
- * every font, every `.map` file a browser asks for. Without this filter a single
265
- * page view would fire a redirect lookup for the document and then again for each
266
- * of its subresources, and while the module-level cache in `proxy.ts` absorbs most
267
- * of that, the cold-start burst on every new worker would not be absorbed at all.
268
- *
269
- * It is also a correctness guard, not only a performance one. `/cms` and `/setup`
270
- * are administrative surfaces where an operator-authored redirect could lock an
271
- * admin out of the very screen they would use to delete it, and `/api` routes are
272
- * called by code that follows redirects blindly.
273
- */
274
- const REDIRECT_SKIP_PREFIXES = [
275
- '/cms',
276
- '/api',
277
- '/_next',
278
- '/setup',
279
- '/auth',
280
- '/images',
281
- '/favicon',
282
- '/robots.txt',
283
- '/sitemap',
284
- ] as const;
285
-
286
- /**
287
- * True when a request path must not be looked up in the redirect table at all.
288
- *
289
- * Prefix matching is deliberately boundary-aware (`/setup` and `/setup/...`, never
290
- * `/setup-guide`): the skip list is a list of reserved namespaces, not a list of
291
- * string prefixes, and swallowing `/images-of-our-team` because it starts with
292
- * `/images` would make a legitimate content URL permanently unredirectable for a
293
- * reason no operator could ever guess.
294
- *
295
- * The final rule catches the long tail of static assets (`/logo.png`,
296
- * `/site.webmanifest`, `/anything.js.map`) by matching a known asset EXTENSION —
297
- * deliberately not "the last segment contains a dot".
298
- *
299
- * That distinction is the whole point of this rule, and getting it wrong would
300
- * quietly break the feature's single most common use case. A redirect's source is
301
- * almost never a NextBlock slug; it is a URL from the site that existed BEFORE
302
- * NextBlock, and those end in `.html`, `.php`, `.aspx`, `.htm`, `.jsp` far more
303
- * often than not. Skipping every dotted path would mean an operator could save
304
- * `/about-us.html -> /about`, see it listed in the admin screen, and watch it never
305
- * fire — with no error anywhere to explain why. So page-ish extensions are
306
- * explicitly NOT in this list, and neither is anything unrecognised: a slug like
307
- * `/widget-2.0` stays redirectable because `0` is not an asset extension.
308
- */
309
- const REDIRECT_SKIP_EXTENSIONS = new Set([
310
- 'avif', 'bmp', 'css', 'eot', 'gif', 'ico', 'jpeg', 'jpg', 'js', 'json', 'map',
311
- 'mjs', 'mp3', 'mp4', 'ogg', 'otf', 'pdf', 'png', 'svg', 'ttf', 'txt', 'wasm',
312
- 'webm', 'webmanifest', 'webp', 'woff', 'woff2', 'xml', 'zip',
313
- ]);
314
-
315
- export function shouldSkipRedirectLookup(pathname: string): boolean {
316
- // A non-string or empty path is not something a rule could match anyway, and
317
- // "skip" is the fail-open answer.
318
- if (typeof pathname !== 'string' || pathname === '') {
319
- return true;
320
- }
321
-
322
- for (const prefix of REDIRECT_SKIP_PREFIXES) {
323
- if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
324
- return true;
325
- }
326
- }
327
-
328
- const lastSegment = pathname.split('/').pop() ?? '';
329
- const dotIndex = lastSegment.lastIndexOf('.');
330
- if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) {
331
- return false;
332
- }
333
-
334
- return REDIRECT_SKIP_EXTENSIONS.has(lastSegment.slice(dotIndex + 1).toLowerCase());
335
- }
336
-
337
- /**
338
- * Whether a resolved destination would send the visitor straight back to the path
339
- * they asked for.
340
- *
341
- * The database refuses `source_path = destination_path`, but that constraint
342
- * compares raw strings while matching compares *normalised* ones, so `/about` →
343
- * `/about/` satisfies the CHECK and would still loop forever in a browser. Both
344
- * sides go through `normalizeRedirectPath` here — the same function the match
345
- * used — so this comparison cannot disagree with the lookup that produced the rule.
346
- *
347
- * BOTH ARGUMENTS ARE PATHNAMES, AND THAT IS LOAD-BEARING. The proxy calls this with
348
- * `request.nextUrl.pathname` and the resolved target's `.pathname`, neither of which
349
- * can ever contain a query string, which is what keeps `resolveRedirectTarget`'s
350
- * query carrying from being able to move this decision: a rule caught as a loop
351
- * before the query was carried is still caught after it. Feeding a full URL or a
352
- * path-plus-query in here would break that — `/about` → `/about/?ref=x` would stop
353
- * looking like the loop it is, and the visitor's browser would bounce until it gave
354
- * up.
355
- */
356
- export function isSelfRedirect(requestPathname: string, destinationPathname: string): boolean {
357
- return normalizeRedirectPath(requestPathname) === normalizeRedirectPath(destinationPathname);
358
- }
359
-
360
- /**
361
- * The parts of the incoming request `resolveRedirectTarget` needs.
362
- *
363
- * Taking two plain strings rather than a `NextRequest` is what keeps the function
364
- * testable: everything else in the managed-redirect path is exercised by the tests
365
- * beside this file, and building the destination URL is the one step that was
366
- * silently wrong in production precisely because it lived in the untestable proxy
367
- * body.
368
- */
369
- export interface RedirectTargetRequest {
370
- /**
371
- * The incoming query string, leading `?` included, or `''` when there is none —
372
- * i.e. exactly what `request.nextUrl.search` yields.
373
- */
374
- search: string;
375
- /** The absolute URL of the incoming request; the base a relative destination resolves against. */
376
- url: string;
377
- }
378
-
379
- /**
380
- * Resolves a matched rule's destination into the absolute URL to send the visitor
381
- * to, carrying the incoming query string when the destination does not define one
382
- * of its own.
383
- *
384
- * WHY THE QUERY IS CARRIED. The first version of this built the destination from
385
- * `rule.destinationPath` alone, on the assumption that a redirect's destination is
386
- * the whole story — that the path an operator typed fully identifies where the
387
- * visitor should land. That assumption is wrong twice over. A visitor arriving at
388
- * `/old-page?utm_source=newsletter&utm_campaign=spring` was forwarded to
389
- * `/new-page` with the campaign parameters gone, which silently destroys attribution
390
- * for exactly the inbound links redirects exist to rescue; and a legacy URL whose
391
- * meaning lives in its query (`/product.php?id=42`) arrived at the new page having
392
- * lost the only part of itself that said which product it was. The query is part of
393
- * the request the visitor made, not decoration on it, so it travels with them.
394
- *
395
- * A DESTINATION THAT DEFINES ITS OWN QUERY WINS OUTRIGHT, and nothing is merged.
396
- * `?page=2` on the destination is an operator being explicit about where this rule
397
- * leads; appending a visitor's parameters to it would produce a URL the operator
398
- * never wrote and cannot predict, and for a key present on both sides there is no
399
- * defensible answer about which value the application should read.
400
- *
401
- * WHY AN OFF-SITE DESTINATION IS GIVEN NOTHING. Forwarding the query only happens
402
- * when the resolved target lands on the same origin as the request. Off-site it is
403
- * suppressed, deliberately, for three reasons. First, a query string on an inbound
404
- * legacy link is scoped to this site and routinely carries more than campaign tags —
405
- * order numbers, the email address in an unsubscribe link, one-time tokens — and
406
- * copying it into a `Location:` pointed at a third party discloses all of it to a
407
- * host the operator merely named as a destination, on every hit, with no way for a
408
- * visitor to opt out. Second, this proxy already sends
409
- * `Referrer-Policy: strict-origin-when-cross-origin` on the very same response,
410
- * which is a standing decision that another origin does not get this site's paths
411
- * and query strings; putting the query in the Location would contradict that policy
412
- * one header away from where it is declared. Third, the benefit does not transfer:
413
- * attribution is a first-party concern, and the receiving property has its own
414
- * campaign parameters rather than a use for ours. Being wrong is asymmetric — the
415
- * cost of not forwarding is at worst a lost parameter on an off-site hop, which an
416
- * operator who genuinely wants it can simply write into the destination URL, while
417
- * the cost of forwarding is a leak nobody asked for.
418
- *
419
- * Same origin is tested against the resolved target rather than against the
420
- * destination's syntax, so an operator who writes their own site out in full
421
- * (`https://example.com/new` on example.com) gets the same treatment as one who
422
- * writes `/new`, which is what they would expect.
423
- *
424
- * RETURNING NULL RATHER THAN THROWING is the other half of this function's job. The
425
- * `URL` constructor throws on input it cannot parse, and a saved rule can hold such
426
- * input: `//example.com/x` passes `validateRedirectRule` as an external destination,
427
- * yet `new URL('//example.com/x')` with no base is a `TypeError`. Inside the proxy
428
- * that throw is caught, but only by the block-wide handler that also catches real
429
- * database failures, so a permanently broken rule would log an error on every
430
- * matching request forever. Resolving every destination against the request URL
431
- * fixes that case outright — a protocol-relative destination now picks up the
432
- * request's scheme, exactly as a browser resolves one — and anything still
433
- * unparseable becomes `null`, which the proxy reads as "serve the page".
434
- */
435
- export function resolveRedirectTarget(
436
- destinationPath: string,
437
- request: RedirectTargetRequest,
438
- ): URL | null {
439
- const destination = typeof destinationPath === 'string' ? destinationPath.trim() : '';
440
- if (destination === '') {
441
- return null;
442
- }
443
-
444
- let base: URL;
445
- let target: URL;
446
- try {
447
- base = new URL(request.url);
448
- // The base is ignored for an absolute destination and used for every other
449
- // shape, so one call covers site-relative, protocol-relative and absolute
450
- // destinations without branching on which one this is.
451
- target = new URL(destination, base);
452
- } catch {
453
- return null;
454
- }
455
-
456
- const incomingSearch = typeof request.search === 'string' ? request.search : '';
457
-
458
- // `target.search` is `''` both for a destination with no query and for one written
459
- // with a bare trailing `?`, and treating those the same is right: neither expresses
460
- // an intent to arrive with an empty query.
461
- if (incomingSearch !== '' && target.search === '' && target.origin === base.origin) {
462
- target.search = incomingSearch;
463
- }
464
-
465
- return target;
466
- }
1
+ /**
2
+ * The database side of managed redirects: reading `public.cms_redirects` and
3
+ * turning its rows into the pure `RedirectRule` shape the SEO engine understands.
4
+ *
5
+ * This module exists as its own file, separate from `proxy.ts`, for one reason:
6
+ * the proxy cannot be unit tested. It is a Next.js entry point that receives a
7
+ * real `NextRequest` and returns a `NextResponse`, and everything interesting it
8
+ * does about redirects — deciding whether a path is even worth a lookup, deciding
9
+ * what a malformed row should become, deciding whether a read failure means "no
10
+ * redirects" or "we could not tell" — is ordinary logic that deserves ordinary
11
+ * tests. So all of that logic lives here, behind plain functions over plain
12
+ * values, and `proxy.ts` keeps only the request/response plumbing plus the cache.
13
+ *
14
+ * WHY THE ROW TYPE IS HAND-WRITTEN. `libs/db`'s generated `Database` type is
15
+ * produced by `npm run db:types` against a live Supabase project. The migration
16
+ * that creates `cms_redirects` (00000000000030_seo_redirects_and_robots.sql) is
17
+ * committed but has not been applied yet, so the generated type does not contain
18
+ * the table and `Database['public']['Tables']['cms_redirects']` would not compile.
19
+ * Writing the row shape by hand from the migration's DDL keeps this file building
20
+ * today; once the migration is applied and the types are regenerated, this
21
+ * interface becomes a redundant-but-harmless restatement of the generated one, and
22
+ * the columns it names are exactly the columns the SELECT below asks for, so a
23
+ * drift between the two would show up as a failing query rather than as silently
24
+ * wrong data.
25
+ *
26
+ * NOTHING HERE THROWS. Every export is either pure or wrapped in a try/catch that
27
+ * degrades to a safe value. The only caller on the hot path is the Next.js proxy,
28
+ * which runs in front of every request on the site: an exception escaping into it
29
+ * does not produce a broken redirect, it produces a 500 for every page, every
30
+ * asset and every API route at once. "Fail open, serve the page" is the only
31
+ * acceptable failure mode for a redirect lookup.
32
+ */
33
+
34
+ import {
35
+ normalizeRedirectPath,
36
+ type RedirectRule,
37
+ type RedirectStatusCode,
38
+ } from '@nextblock-cms/utils/seo';
39
+
40
+ /** The table the proxy and the admin screen both read. */
41
+ export const CMS_REDIRECTS_TABLE = 'cms_redirects';
42
+
43
+ /**
44
+ * The columns fetched for a proxy lookup. `created_at` / `updated_at` are
45
+ * deliberately not selected: the proxy never shows them, and a redirect lookup
46
+ * sits in front of every page render, so there is no reason to move bytes that
47
+ * nothing on this path reads.
48
+ */
49
+ const REDIRECT_LOOKUP_COLUMNS = 'id, source_path, destination_path, status_code, is_active';
50
+
51
+ /**
52
+ * A row of `public.cms_redirects`, transcribed from the migration's DDL.
53
+ *
54
+ * Every column is NOT NULL in Postgres, which is why none of these are optional —
55
+ * but `mapRedirectRow` still validates each one at runtime rather than trusting
56
+ * this declaration, because the value actually arriving here came over the wire
57
+ * from PostgREST and a TypeScript interface asserts nothing about it.
58
+ */
59
+ export interface CmsRedirectRow {
60
+ created_at: string;
61
+ destination_path: string;
62
+ id: string;
63
+ is_active: boolean;
64
+ source_path: string;
65
+ status_code: number;
66
+ updated_at: string;
67
+ }
68
+
69
+ /**
70
+ * The minimal shape `fetchActiveRedirects` needs from a Supabase client.
71
+ *
72
+ * Typing the parameter structurally rather than as `SupabaseClient` is what makes
73
+ * this function testable without a network or a running database: a test passes an
74
+ * object literal with a `from` method. It also keeps the module honest about how
75
+ * little it actually uses — one table, one filter, no auth, no realtime.
76
+ */
77
+ export interface RedirectQueryClient {
78
+ from: (table: string) => any;
79
+ }
80
+
81
+ /**
82
+ * Coerces whatever the `status_code` column produced into one of the two codes
83
+ * the redirect engine models.
84
+ *
85
+ * The database already constrains this to 301 or 302, so in practice the fallback
86
+ * never fires. It exists because the alternative — returning `null` and dropping
87
+ * the rule — would mean a single unexpected value silently un-publishes a redirect
88
+ * an operator can plainly see in the admin list. Defaulting to 301 keeps the rule
89
+ * working, and permanent-rather-than-temporary matches what every row in the table
90
+ * already is by default.
91
+ *
92
+ * Numeric strings are accepted because a jsonb round trip or a hand-written import
93
+ * can turn `301` into `"301"`, and refusing that would be pedantry rather than
94
+ * safety.
95
+ */
96
+ function coerceRedirectStatusCode(value: unknown): RedirectStatusCode {
97
+ const numeric =
98
+ typeof value === 'number'
99
+ ? value
100
+ : typeof value === 'string'
101
+ ? Number.parseInt(value, 10)
102
+ : Number.NaN;
103
+
104
+ return numeric === 302 ? 302 : 301;
105
+ }
106
+
107
+ /**
108
+ * Maps one database row onto the engine's camelCase `RedirectRule`, or returns
109
+ * `null` when the row cannot be trusted.
110
+ *
111
+ * Malformed means "a field the redirect cannot work without is missing or is not a
112
+ * string": an id we could not report in an error, a source we could not match, or a
113
+ * destination we could not send anyone to. Those rows are dropped individually
114
+ * rather than failing the whole load, so one bad row cannot take every other
115
+ * redirect on the site offline with it.
116
+ *
117
+ * `is_active` is read as "anything other than an explicit `false` counts as active".
118
+ * The query already filters on `is_active = true`, so this only decides the outcome
119
+ * for a row that arrived through some other path (a test, or a future admin-side
120
+ * reader that wants inactive rules too), and treating an absent flag as active
121
+ * matches the column's `DEFAULT true`.
122
+ */
123
+ export function mapRedirectRow(row: unknown): RedirectRule | null {
124
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
125
+ return null;
126
+ }
127
+
128
+ const candidate = row as Record<string, unknown>;
129
+ const id = candidate['id'];
130
+ const sourcePath = candidate['source_path'];
131
+ const destinationPath = candidate['destination_path'];
132
+
133
+ if (typeof id !== 'string' || id.trim() === '') {
134
+ return null;
135
+ }
136
+ if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
137
+ return null;
138
+ }
139
+ if (typeof destinationPath !== 'string' || destinationPath.trim() === '') {
140
+ return null;
141
+ }
142
+
143
+ return {
144
+ destinationPath: destinationPath.trim(),
145
+ id,
146
+ isActive: candidate['is_active'] !== false,
147
+ sourcePath: sourcePath.trim(),
148
+ statusCode: coerceRedirectStatusCode(candidate['status_code']),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * A Supabase/PostgREST error that means the table itself is absent — i.e. the
154
+ * schema was never applied.
155
+ *
156
+ * This is NOT a transient hiccup: it is the signature of a fresh, unprovisioned
157
+ * deploy (env injected, migrations not yet run), or — for `cms_redirects`
158
+ * specifically — of an existing install that has pulled the code but not yet run
159
+ * `npm run db:migrate`. Both are steady states that can last for days, so a caller
160
+ * must be able to tell them apart from a database that is merely busy and back off
161
+ * accordingly instead of hammering a table that does not exist.
162
+ *
163
+ * 42P01 = undefined_table (Postgres); PGRST205 = PostgREST "table not in schema
164
+ * cache". The message sniffing underneath is there because PostgREST does not
165
+ * always populate `code` on a schema-cache miss, and a wrong answer here is cheap
166
+ * in both directions: at worst we negative-cache a transient failure for a few
167
+ * seconds.
168
+ *
169
+ * This lives here rather than in `proxy.ts` because both the redirect lookup and
170
+ * the proxy's existing `is_admin_created` probe need exactly this test, and two
171
+ * copies of a predicate that decides whether a site funnels all its traffic to
172
+ * /setup is one copy too many.
173
+ */
174
+ export function isSchemaMissingError(
175
+ error: { code?: string | null; message?: string | null } | null | undefined,
176
+ ): boolean {
177
+ if (!error) {
178
+ return false;
179
+ }
180
+
181
+ const code = error.code ?? '';
182
+ if (code === '42P01' || code === 'PGRST205') {
183
+ return true;
184
+ }
185
+
186
+ const message = (error.message ?? '').toLowerCase();
187
+ return (
188
+ message.includes('does not exist') ||
189
+ message.includes('schema cache') ||
190
+ message.includes('could not find the table')
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Loads every active redirect rule.
196
+ *
197
+ * The three-valued return is the whole point of this function, so it is worth
198
+ * being explicit about it:
199
+ *
200
+ * - `[]` means "the table is readable and there are no active redirects". Almost
201
+ * every site is in this state, and it is a *positive* answer: the caller may
202
+ * cache it for as long as it caches a real rule set.
203
+ * - `null` means "we could not read". The table may be missing (migration not yet
204
+ * applied), the database may be down, PostgREST may have hiccuped. The caller
205
+ * must NOT treat this as "no redirects, and here is a nice long cache entry" —
206
+ * it should negative-cache briefly and retry, so redirects start working within
207
+ * seconds of the migration landing rather than a minute later.
208
+ * - A non-empty array is the rule set.
209
+ *
210
+ * Collapsing those two empty-ish cases into one would be the classic bug: an
211
+ * unmigrated install would look identical to a migrated one with no rules, and the
212
+ * moment a real outage began the site would cheerfully cache "no redirects" for
213
+ * the full success TTL.
214
+ *
215
+ * The function never throws. A PostgREST error object, a rejected promise and a
216
+ * client that blows up mid-chain all become `null`.
217
+ */
218
+ export async function fetchActiveRedirects(
219
+ supabase: RedirectQueryClient,
220
+ ): Promise<RedirectRule[] | null> {
221
+ try {
222
+ const { data, error } = await supabase
223
+ .from(CMS_REDIRECTS_TABLE)
224
+ .select(REDIRECT_LOOKUP_COLUMNS)
225
+ .eq('is_active', true);
226
+
227
+ // A missing table and a transient failure land in the same place on purpose:
228
+ // this caller's only question is "can I trust an empty result?", and the answer
229
+ // is no either way. `isSchemaMissingError` is exported for the callers that DO
230
+ // need to distinguish (the proxy's provisioning gate), not for this one.
231
+ if (error) {
232
+ return null;
233
+ }
234
+
235
+ // A null/undefined `data` with no error is not something PostgREST does for a
236
+ // list query — it returns `[]` — so if it happens, something has gone wrong
237
+ // enough that "I could not read this" is the truthful answer.
238
+ if (!Array.isArray(data)) {
239
+ return null;
240
+ }
241
+
242
+ const rules: RedirectRule[] = [];
243
+ for (const row of data) {
244
+ const rule = mapRedirectRow(row);
245
+ if (rule !== null) {
246
+ rules.push(rule);
247
+ }
248
+ }
249
+
250
+ return rules;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Path prefixes that can never be a redirect source, checked before any database
258
+ * work happens.
259
+ *
260
+ * This guard is doing far more work than it looks like it is. The proxy matcher is
261
+ * `/((?!_next/static|_next/image|favicon.ico|auth/.*|api/auth/.*|api/revalidate|api/revalidate-log).*)`,
262
+ * which is an exclusion list of six specific paths — meaning the proxy runs on
263
+ * essentially EVERYTHING else: every API route, every dynamically served image,
264
+ * every font, every `.map` file a browser asks for. Without this filter a single
265
+ * page view would fire a redirect lookup for the document and then again for each
266
+ * of its subresources, and while the module-level cache in `proxy.ts` absorbs most
267
+ * of that, the cold-start burst on every new worker would not be absorbed at all.
268
+ *
269
+ * It is also a correctness guard, not only a performance one. `/cms` and `/setup`
270
+ * are administrative surfaces where an operator-authored redirect could lock an
271
+ * admin out of the very screen they would use to delete it, and `/api` routes are
272
+ * called by code that follows redirects blindly.
273
+ */
274
+ const REDIRECT_SKIP_PREFIXES = [
275
+ '/cms',
276
+ '/api',
277
+ '/_next',
278
+ '/setup',
279
+ '/auth',
280
+ '/images',
281
+ '/favicon',
282
+ '/robots.txt',
283
+ '/sitemap',
284
+ ] as const;
285
+
286
+ /**
287
+ * True when a request path must not be looked up in the redirect table at all.
288
+ *
289
+ * Prefix matching is deliberately boundary-aware (`/setup` and `/setup/...`, never
290
+ * `/setup-guide`): the skip list is a list of reserved namespaces, not a list of
291
+ * string prefixes, and swallowing `/images-of-our-team` because it starts with
292
+ * `/images` would make a legitimate content URL permanently unredirectable for a
293
+ * reason no operator could ever guess.
294
+ *
295
+ * The final rule catches the long tail of static assets (`/logo.png`,
296
+ * `/site.webmanifest`, `/anything.js.map`) by matching a known asset EXTENSION —
297
+ * deliberately not "the last segment contains a dot".
298
+ *
299
+ * That distinction is the whole point of this rule, and getting it wrong would
300
+ * quietly break the feature's single most common use case. A redirect's source is
301
+ * almost never a NextBlock slug; it is a URL from the site that existed BEFORE
302
+ * NextBlock, and those end in `.html`, `.php`, `.aspx`, `.htm`, `.jsp` far more
303
+ * often than not. Skipping every dotted path would mean an operator could save
304
+ * `/about-us.html -> /about`, see it listed in the admin screen, and watch it never
305
+ * fire — with no error anywhere to explain why. So page-ish extensions are
306
+ * explicitly NOT in this list, and neither is anything unrecognised: a slug like
307
+ * `/widget-2.0` stays redirectable because `0` is not an asset extension.
308
+ */
309
+ const REDIRECT_SKIP_EXTENSIONS = new Set([
310
+ 'avif', 'bmp', 'css', 'eot', 'gif', 'ico', 'jpeg', 'jpg', 'js', 'json', 'map',
311
+ 'mjs', 'mp3', 'mp4', 'ogg', 'otf', 'pdf', 'png', 'svg', 'ttf', 'txt', 'wasm',
312
+ 'webm', 'webmanifest', 'webp', 'woff', 'woff2', 'xml', 'zip',
313
+ ]);
314
+
315
+ export function shouldSkipRedirectLookup(pathname: string): boolean {
316
+ // A non-string or empty path is not something a rule could match anyway, and
317
+ // "skip" is the fail-open answer.
318
+ if (typeof pathname !== 'string' || pathname === '') {
319
+ return true;
320
+ }
321
+
322
+ for (const prefix of REDIRECT_SKIP_PREFIXES) {
323
+ if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
324
+ return true;
325
+ }
326
+ }
327
+
328
+ const lastSegment = pathname.split('/').pop() ?? '';
329
+ const dotIndex = lastSegment.lastIndexOf('.');
330
+ if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) {
331
+ return false;
332
+ }
333
+
334
+ return REDIRECT_SKIP_EXTENSIONS.has(lastSegment.slice(dotIndex + 1).toLowerCase());
335
+ }
336
+
337
+ /**
338
+ * Whether a resolved destination would send the visitor straight back to the path
339
+ * they asked for.
340
+ *
341
+ * The database refuses `source_path = destination_path`, but that constraint
342
+ * compares raw strings while matching compares *normalised* ones, so `/about` →
343
+ * `/about/` satisfies the CHECK and would still loop forever in a browser. Both
344
+ * sides go through `normalizeRedirectPath` here — the same function the match
345
+ * used — so this comparison cannot disagree with the lookup that produced the rule.
346
+ *
347
+ * BOTH ARGUMENTS ARE PATHNAMES, AND THAT IS LOAD-BEARING. The proxy calls this with
348
+ * `request.nextUrl.pathname` and the resolved target's `.pathname`, neither of which
349
+ * can ever contain a query string, which is what keeps `resolveRedirectTarget`'s
350
+ * query carrying from being able to move this decision: a rule caught as a loop
351
+ * before the query was carried is still caught after it. Feeding a full URL or a
352
+ * path-plus-query in here would break that — `/about` → `/about/?ref=x` would stop
353
+ * looking like the loop it is, and the visitor's browser would bounce until it gave
354
+ * up.
355
+ */
356
+ export function isSelfRedirect(requestPathname: string, destinationPathname: string): boolean {
357
+ return normalizeRedirectPath(requestPathname) === normalizeRedirectPath(destinationPathname);
358
+ }
359
+
360
+ /**
361
+ * The parts of the incoming request `resolveRedirectTarget` needs.
362
+ *
363
+ * Taking two plain strings rather than a `NextRequest` is what keeps the function
364
+ * testable: everything else in the managed-redirect path is exercised by the tests
365
+ * beside this file, and building the destination URL is the one step that was
366
+ * silently wrong in production precisely because it lived in the untestable proxy
367
+ * body.
368
+ */
369
+ export interface RedirectTargetRequest {
370
+ /**
371
+ * The incoming query string, leading `?` included, or `''` when there is none —
372
+ * i.e. exactly what `request.nextUrl.search` yields.
373
+ */
374
+ search: string;
375
+ /** The absolute URL of the incoming request; the base a relative destination resolves against. */
376
+ url: string;
377
+ }
378
+
379
+ /**
380
+ * Resolves a matched rule's destination into the absolute URL to send the visitor
381
+ * to, carrying the incoming query string when the destination does not define one
382
+ * of its own.
383
+ *
384
+ * WHY THE QUERY IS CARRIED. The first version of this built the destination from
385
+ * `rule.destinationPath` alone, on the assumption that a redirect's destination is
386
+ * the whole story — that the path an operator typed fully identifies where the
387
+ * visitor should land. That assumption is wrong twice over. A visitor arriving at
388
+ * `/old-page?utm_source=newsletter&utm_campaign=spring` was forwarded to
389
+ * `/new-page` with the campaign parameters gone, which silently destroys attribution
390
+ * for exactly the inbound links redirects exist to rescue; and a legacy URL whose
391
+ * meaning lives in its query (`/product.php?id=42`) arrived at the new page having
392
+ * lost the only part of itself that said which product it was. The query is part of
393
+ * the request the visitor made, not decoration on it, so it travels with them.
394
+ *
395
+ * A DESTINATION THAT DEFINES ITS OWN QUERY WINS OUTRIGHT, and nothing is merged.
396
+ * `?page=2` on the destination is an operator being explicit about where this rule
397
+ * leads; appending a visitor's parameters to it would produce a URL the operator
398
+ * never wrote and cannot predict, and for a key present on both sides there is no
399
+ * defensible answer about which value the application should read.
400
+ *
401
+ * WHY AN OFF-SITE DESTINATION IS GIVEN NOTHING. Forwarding the query only happens
402
+ * when the resolved target lands on the same origin as the request. Off-site it is
403
+ * suppressed, deliberately, for three reasons. First, a query string on an inbound
404
+ * legacy link is scoped to this site and routinely carries more than campaign tags —
405
+ * order numbers, the email address in an unsubscribe link, one-time tokens — and
406
+ * copying it into a `Location:` pointed at a third party discloses all of it to a
407
+ * host the operator merely named as a destination, on every hit, with no way for a
408
+ * visitor to opt out. Second, this proxy already sends
409
+ * `Referrer-Policy: strict-origin-when-cross-origin` on the very same response,
410
+ * which is a standing decision that another origin does not get this site's paths
411
+ * and query strings; putting the query in the Location would contradict that policy
412
+ * one header away from where it is declared. Third, the benefit does not transfer:
413
+ * attribution is a first-party concern, and the receiving property has its own
414
+ * campaign parameters rather than a use for ours. Being wrong is asymmetric — the
415
+ * cost of not forwarding is at worst a lost parameter on an off-site hop, which an
416
+ * operator who genuinely wants it can simply write into the destination URL, while
417
+ * the cost of forwarding is a leak nobody asked for.
418
+ *
419
+ * Same origin is tested against the resolved target rather than against the
420
+ * destination's syntax, so an operator who writes their own site out in full
421
+ * (`https://example.com/new` on example.com) gets the same treatment as one who
422
+ * writes `/new`, which is what they would expect.
423
+ *
424
+ * RETURNING NULL RATHER THAN THROWING is the other half of this function's job. The
425
+ * `URL` constructor throws on input it cannot parse, and a saved rule can hold such
426
+ * input: `//example.com/x` passes `validateRedirectRule` as an external destination,
427
+ * yet `new URL('//example.com/x')` with no base is a `TypeError`. Inside the proxy
428
+ * that throw is caught, but only by the block-wide handler that also catches real
429
+ * database failures, so a permanently broken rule would log an error on every
430
+ * matching request forever. Resolving every destination against the request URL
431
+ * fixes that case outright — a protocol-relative destination now picks up the
432
+ * request's scheme, exactly as a browser resolves one — and anything still
433
+ * unparseable becomes `null`, which the proxy reads as "serve the page".
434
+ */
435
+ export function resolveRedirectTarget(
436
+ destinationPath: string,
437
+ request: RedirectTargetRequest,
438
+ ): URL | null {
439
+ const destination = typeof destinationPath === 'string' ? destinationPath.trim() : '';
440
+ if (destination === '') {
441
+ return null;
442
+ }
443
+
444
+ let base: URL;
445
+ let target: URL;
446
+ try {
447
+ base = new URL(request.url);
448
+ // The base is ignored for an absolute destination and used for every other
449
+ // shape, so one call covers site-relative, protocol-relative and absolute
450
+ // destinations without branching on which one this is.
451
+ target = new URL(destination, base);
452
+ } catch {
453
+ return null;
454
+ }
455
+
456
+ const incomingSearch = typeof request.search === 'string' ? request.search : '';
457
+
458
+ // `target.search` is `''` both for a destination with no query and for one written
459
+ // with a bare trailing `?`, and treating those the same is right: neither expresses
460
+ // an intent to arrive with an empty query.
461
+ if (incomingSearch !== '' && target.search === '' && target.origin === base.origin) {
462
+ target.search = incomingSearch;
463
+ }
464
+
465
+ return target;
466
+ }