@stacksjs/defaults 0.70.380 → 0.71.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 (50) hide show
  1. package/app/Actions/Auth/MagicLinkConsumeAction.ts +53 -0
  2. package/app/Actions/Auth/MagicLinkSendAction.ts +35 -0
  3. package/app/Actions/Cms/SitemapAction.ts +23 -2
  4. package/app/Jobs/PublishScheduledPagesJob.ts +26 -0
  5. package/app/Middleware/Site.ts +14 -0
  6. package/app/Middleware.ts +1 -0
  7. package/app/Models/Automation.ts +23 -0
  8. package/app/Models/AutomationRun.ts +26 -0
  9. package/app/Models/Campaign.ts +67 -3
  10. package/app/Models/CampaignSend.ts +90 -6
  11. package/app/Models/CampaignVariant.ts +25 -0
  12. package/app/Models/CommunicationSuppression.ts +22 -0
  13. package/app/Models/ConsentEvent.ts +26 -0
  14. package/app/Models/Content/Menu.ts +56 -0
  15. package/app/Models/Content/MenuItem.ts +91 -0
  16. package/app/Models/Content/Page.ts +111 -14
  17. package/app/Models/Content/PageRevision.ts +86 -0
  18. package/app/Models/Content/Post.ts +24 -9
  19. package/app/Models/Content/Redirect.ts +80 -0
  20. package/app/Models/Forms/Form.ts +91 -0
  21. package/app/Models/Forms/FormField.ts +127 -0
  22. package/app/Models/Forms/FormSubmission.ts +114 -0
  23. package/app/Models/MagicLinkToken.ts +97 -0
  24. package/app/Models/SenderDomain.ts +22 -0
  25. package/app/Models/Site.ts +112 -0
  26. package/app/Models/SiteDomain.ts +74 -0
  27. package/app/Models/SmsOptOut.ts +65 -0
  28. package/app/Models/UsageEvent.ts +24 -0
  29. package/app/Models/commerce/Auction.ts +173 -0
  30. package/app/Models/commerce/AuctionItem.ts +204 -0
  31. package/app/Models/commerce/Bid.ts +129 -0
  32. package/app/Models/commerce/Pledge.ts +112 -0
  33. package/bootstrap.ts +7 -0
  34. package/functions/public-application-url.ts +1 -1
  35. package/ide/vscode/package.json +1 -1
  36. package/package.json +2 -2
  37. package/resources/functions/dashboard/sidebar.ts +109 -3
  38. package/resources/functions/dashboard/toggles.ts +90 -2
  39. package/resources/views/cms/blocks/columns.stx +10 -0
  40. package/resources/views/cms/blocks/cta.stx +8 -0
  41. package/resources/views/cms/blocks/embed.stx +14 -0
  42. package/resources/views/cms/blocks/form.stx +132 -0
  43. package/resources/views/cms/blocks/hero.stx +16 -0
  44. package/resources/views/cms/blocks/image.stx +7 -0
  45. package/resources/views/cms/blocks/rich-text.stx +4 -0
  46. package/resources/views/cms/page.stx +28 -0
  47. package/routes/auth.ts +7 -0
  48. package/routes/forms.ts +110 -0
  49. package/views/auth/magic/[token].stx +88 -0
  50. package/views/dashboard/.discovered-models.json +46 -1
@@ -6,6 +6,7 @@
6
6
  * persistence stay inside the component instead of being serialized as HTML.
7
7
  */
8
8
  import { existsSync, readFileSync } from 'node:fs'
9
+ import { createRequire } from 'node:module'
9
10
  import { resolve } from 'node:path'
10
11
 
11
12
  type ModelCategory = 'userland' | 'data' | 'commerce' | 'content' | 'marketing' | 'system'
@@ -57,9 +58,24 @@ export interface DashboardSectionToggles {
57
58
  data: DataRowToggles
58
59
  }
59
60
 
61
+ /** An application-defined sidebar row, from `config/dashboard.ts:nav`. */
62
+ export interface AppNavItem {
63
+ label: string
64
+ href: string
65
+ icon?: string
66
+ roles?: string[]
67
+ }
68
+
69
+ export interface AppNavSection {
70
+ title: string
71
+ items: AppNavItem[]
72
+ }
73
+
60
74
  export interface DiscoveredManifest {
61
75
  models: DiscoveredModel[]
62
76
  sections: DashboardSectionToggles
77
+ /** Sections this application declared. Empty for a project that declared none. */
78
+ nav: AppNavSection[]
63
79
  }
64
80
 
65
81
  export const DEFAULT_DATA_TOGGLES: DataRowToggles = {
@@ -103,7 +119,7 @@ export function loadDiscoveredManifest(
103
119
  manifestPath = resolve(process.cwd(), 'storage/framework/defaults/views/dashboard/.discovered-models.json'),
104
120
  ): DiscoveredManifest {
105
121
  if (!existsSync(manifestPath))
106
- return { models: [], sections: defaultToggles() }
122
+ return { models: [], sections: defaultToggles(), nav: [] }
107
123
 
108
124
  try {
109
125
  return parseDiscoveredManifest(readFileSync(manifestPath, 'utf8'))
@@ -138,9 +154,35 @@ export function parseDiscoveredManifest(source: string): DiscoveredManifest {
138
154
  return {
139
155
  models,
140
156
  sections: normalizeManifestToggles(envelope.sections),
157
+ nav: normalizeManifestNav(envelope.nav),
141
158
  }
142
159
  }
143
160
 
161
+ /**
162
+ * App-declared sections are validated at config load; this only has to survive
163
+ * a manifest written by an older framework version, which has no `nav` key.
164
+ */
165
+ function normalizeManifestNav(value: unknown): AppNavSection[] {
166
+ if (value === undefined)
167
+ return []
168
+
169
+ if (!Array.isArray(value))
170
+ throw new TypeError('manifest nav must be an array')
171
+
172
+ return value.map((entry, index) => {
173
+ const section = objectValue(entry, `manifest nav[${index}]`)
174
+ if (typeof section.title !== 'string' || !section.title)
175
+ throw new TypeError(`manifest nav[${index}].title must be a non-empty string`)
176
+ if (!Array.isArray(section.items))
177
+ throw new TypeError(`manifest nav[${index}].items must be an array`)
178
+
179
+ return {
180
+ title: section.title,
181
+ items: section.items as AppNavItem[],
182
+ }
183
+ })
184
+ }
185
+
144
186
  function normalizeManifestToggles(value: unknown): DashboardSectionToggles {
145
187
  const sections = objectValue(value, 'manifest sections')
146
188
  const data = sections.data === undefined
@@ -241,9 +283,25 @@ function categoryNavItems(
241
283
  export function buildNavSections(
242
284
  discoveredModels: DiscoveredModel[] = [],
243
285
  toggles: DashboardSectionToggles = DEFAULT_TOGGLES,
286
+ appNav: AppNavSection[] = [],
244
287
  ): Array<[string, string, NavItem[]]> {
245
288
  const sections: Array<[string, string, NavItem[]]> = []
246
289
 
290
+ // Application sections come first. An app that declares its own pages is
291
+ // saying those are the product; the framework's operational surfaces
292
+ // (queue, logs, deployments) are support and belong below them.
293
+ for (const [index, section] of appNav.entries()) {
294
+ if (section.items.length === 0)
295
+ continue
296
+
297
+ sections.push([`app-nav-${index}`, section.title, section.items.map(item => ({
298
+ to: item.href,
299
+ icon: item.icon ?? 'circle',
300
+ text: item.label,
301
+ ...(item.roles && item.roles.length > 0 ? { roles: item.roles } : {}),
302
+ }))])
303
+ }
304
+
247
305
  // Library section: the established views live at the project root
248
306
  // (`/functions`, `/packages`, `/releases`). Components use the explicit
249
307
  // `/library/components` path so it does not collide with STX's component
@@ -549,9 +607,57 @@ function titleCase(label: string): string {
549
607
  * discovered-models manifest synchronously (stx server-script friendly)
550
608
  * and applies the shared section and toggle logic.
551
609
  */
610
+ /**
611
+ * Framework sections name their icons from the map above. App-declared rows
612
+ * may instead give a full iconify class, which is passed through untouched -
613
+ * an application should not be limited to the icons the framework happened to
614
+ * name, and `i-hugeicons-champion` is the spelling its own templates use.
615
+ */
616
+ function resolveNavIcon(icon: string): string {
617
+ if (icon.startsWith('i-'))
618
+ return icon
619
+
620
+ return NAV_ICON_CLASSES[icon] ?? NAV_ICON_CLASSES.file!
621
+ }
622
+
623
+ /**
624
+ * Read app-declared sections straight from `config/dashboard.ts`.
625
+ *
626
+ * The manifest is the primary source, but it is written by the dashboard dev
627
+ * command - so an app running a published framework older than the `nav` key
628
+ * would get a manifest without one, and its own pages would stay unreachable.
629
+ * Reading the config directly also means editing it shows up on the next
630
+ * reload rather than on the next manifest write.
631
+ *
632
+ * `require` rather than `import`: this module is called synchronously from STX
633
+ * server-script context, and Bun transpiles the TS config on the way in.
634
+ */
635
+ function loadAppNavFromConfig(configPath = resolve(process.cwd(), 'config/dashboard.ts')): AppNavSection[] {
636
+ if (!existsSync(configPath))
637
+ return []
638
+
639
+ let config: unknown
640
+ try {
641
+ const requireFrom = createRequire(import.meta.url)
642
+ const loaded = requireFrom(configPath) as { default?: unknown }
643
+ config = loaded?.default ?? loaded
644
+ }
645
+ catch {
646
+ // An unreadable config is the dashboard's problem elsewhere, not the
647
+ // sidebar's: fall back to the framework sections rather than blanking the
648
+ // whole navigation.
649
+ return []
650
+ }
651
+
652
+ // Validation deliberately outside the catch - a malformed `nav` is an
653
+ // authoring mistake and should say so, not silently render nothing.
654
+ return normalizeManifestNav((config as { nav?: unknown } | undefined)?.nav)
655
+ }
656
+
552
657
  export function buildWebSidebarSections(): WebSidebarSection[] {
553
658
  const manifest = loadDiscoveredManifest()
554
- const sections = buildNavSections(manifest.models, manifest.sections)
659
+ const appNav = manifest.nav.length > 0 ? manifest.nav : loadAppNavFromConfig()
660
+ const sections = buildNavSections(manifest.models, manifest.sections, appNav)
555
661
 
556
662
  return [
557
663
  {
@@ -565,7 +671,7 @@ export function buildWebSidebarSections(): WebSidebarSection[] {
565
671
  items: items.map(item => ({
566
672
  id: navItemId(item.to),
567
673
  label: item.text,
568
- icon: NAV_ICON_CLASSES[item.icon] ?? NAV_ICON_CLASSES.file,
674
+ icon: resolveNavIcon(item.icon),
569
675
  iconColor: 'blue',
570
676
  href: item.to,
571
677
  ...(item.roles && item.roles.length > 0 ? { roles: item.roles } : {}),
@@ -10,6 +10,29 @@ export interface DashboardDataRowToggles {
10
10
  allModels: boolean
11
11
  }
12
12
 
13
+ /**
14
+ * One row of an application-defined sidebar section.
15
+ *
16
+ * `icon` is either a full iconify class (`i-hugeicons-calendar-03`), which is
17
+ * passed through as written, or one of the framework's own short sidebar icon
18
+ * names (`calendar`, `bell`, `chart`), which resolves through its icon map.
19
+ * The first form is what an app wants: it is the same spelling every template
20
+ * in the project already uses, and it is not limited to the set the framework
21
+ * happens to have named.
22
+ */
23
+ export interface DashboardNavItem {
24
+ label: string
25
+ href: string
26
+ icon?: string
27
+ /** Role gate, matching the per-model `dashboard.roles` metadata. */
28
+ roles?: string[]
29
+ }
30
+
31
+ export interface DashboardNavSection {
32
+ title: string
33
+ items: DashboardNavItem[]
34
+ }
35
+
13
36
  export interface ResolvedDashboardToggles {
14
37
  library: boolean
15
38
  content: boolean
@@ -73,14 +96,79 @@ export function resolveDashboardToggles(value: unknown): ResolvedDashboardToggle
73
96
  }
74
97
 
75
98
  export async function loadDashboardToggles(configPath: string): Promise<ResolvedDashboardToggles> {
99
+ return (await loadDashboardConfig(configPath)).toggles
100
+ }
101
+
102
+ /**
103
+ * Application-defined sidebar sections from `config/dashboard.ts:nav`.
104
+ *
105
+ * The framework's own sections are a fixed list, so before this an app that
106
+ * added dashboard pages under `resources/views/dashboard/` had pages the
107
+ * sidebar could not reach - the pages worked, but only if you typed the URL.
108
+ * Declaring them in config keeps the sidebar in one place and out of the
109
+ * framework's own registry, which an app cannot edit without vendoring it.
110
+ */
111
+ export function resolveDashboardNav(value: unknown): DashboardNavSection[] {
112
+ if (!value || typeof value !== 'object')
113
+ return []
114
+
115
+ const config = value as Record<string, unknown>
116
+ if (config.nav === undefined)
117
+ return []
118
+
119
+ if (!Array.isArray(config.nav))
120
+ throw new TypeError('dashboard config nav must be an array of sections')
121
+
122
+ return config.nav.map((entry, index) => {
123
+ const section = objectValue(entry, `dashboard config nav[${index}]`)
124
+ if (typeof section.title !== 'string' || !section.title)
125
+ throw new TypeError(`dashboard config nav[${index}].title must be a non-empty string`)
126
+ if (!Array.isArray(section.items))
127
+ throw new TypeError(`dashboard config nav[${index}].items must be an array`)
128
+
129
+ return {
130
+ title: section.title,
131
+ items: section.items.map((value, itemIndex) => {
132
+ const label = `dashboard config nav[${index}].items[${itemIndex}]`
133
+ const item = objectValue(value, label)
134
+ if (typeof item.label !== 'string' || !item.label)
135
+ throw new TypeError(`${label}.label must be a non-empty string`)
136
+ if (typeof item.href !== 'string' || !item.href)
137
+ throw new TypeError(`${label}.href must be a non-empty string`)
138
+
139
+ return {
140
+ label: item.label,
141
+ href: item.href,
142
+ icon: typeof item.icon === 'string' ? item.icon : undefined,
143
+ roles: Array.isArray(item.roles) ? item.roles.map(String) : undefined,
144
+ }
145
+ }),
146
+ }
147
+ })
148
+ }
149
+
150
+ export interface LoadedDashboardConfig {
151
+ toggles: ResolvedDashboardToggles
152
+ nav: DashboardNavSection[]
153
+ }
154
+
155
+ /**
156
+ * Read `config/dashboard.ts` once and return everything the sidebar needs.
157
+ * One import rather than two: the config module is cache-busted on mtime, so a
158
+ * second load of the same file would be a second evaluation of user code.
159
+ */
160
+ export async function loadDashboardConfig(configPath: string): Promise<LoadedDashboardConfig> {
76
161
  if (!existsSync(configPath))
77
- return defaultDashboardToggles()
162
+ return { toggles: defaultDashboardToggles(), nav: [] }
78
163
 
79
164
  try {
80
165
  const moduleUrl = pathToFileURL(configPath)
81
166
  moduleUrl.searchParams.set('dashboard-config-mtime', String(statSync(configPath).mtimeMs))
82
167
  const configModule = await import(moduleUrl.href) as { default?: unknown }
83
- return resolveDashboardToggles(configModule.default)
168
+ return {
169
+ toggles: resolveDashboardToggles(configModule.default),
170
+ nav: resolveDashboardNav(configModule.default),
171
+ }
84
172
  }
85
173
  catch (error) {
86
174
  throw new Error(`Could not load config/dashboard.ts: ${error instanceof Error ? error.message : String(error)}`)
@@ -0,0 +1,10 @@
1
+ {{-- Columns block: 1-4 sanitized HTML columns on a responsive grid. --}}
2
+ <div class="cms-columns mx-auto max-w-6xl px-6 py-8">
3
+ {{-- Literal class names, not `md:grid-cols-{{ n }}`: Crosswind extracts
4
+ classes from template SOURCE, so a composed name generates no CSS. --}}
5
+ <div class="grid grid-cols-1 gap-8 {{ props.columns.length === 4 ? 'md:grid-cols-4' : (props.columns.length === 3 ? 'md:grid-cols-3' : (props.columns.length === 2 ? 'md:grid-cols-2' : '')) }}">
6
+ @foreach (props.columns as column)
7
+ <div class="leading-relaxed">{!! column.html !!}</div>
8
+ @endforeach
9
+ </div>
10
+ </div>
@@ -0,0 +1,8 @@
1
+ {{-- Call-to-action band. --}}
2
+ <section class="cms-cta mx-auto max-w-4xl px-6 py-16 text-center">
3
+ <h2 class="text-3xl tracking-tight font-semibold">{{ props.heading }}</h2>
4
+ @if (props.body)
5
+ <p class="mt-4 text-lg text-gray-600 max-w-[65ch] mx-auto">{{ props.body }}</p>
6
+ @endif
7
+ <a href="{{ props.buttonHref }}" class="mt-8 inline-block rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98]">{{ props.buttonLabel }}</a>
8
+ </section>
@@ -0,0 +1,14 @@
1
+ {{-- Embed block: sandboxed iframe, never inline third-party markup. --}}
2
+ <div class="cms-embed mx-auto max-w-4xl px-6 py-8">
3
+ <div class="relative w-full overflow-hidden rounded-lg {{ props.aspect === '4:3' ? 'aspect-[4/3]' : (props.aspect === '1:1' ? 'aspect-square' : 'aspect-video') }}">
4
+ <iframe
5
+ src="{{ props.src }}"
6
+ title="{{ props.title }}"
7
+ class="absolute inset-0 h-full w-full"
8
+ sandbox="allow-scripts allow-same-origin allow-presentation"
9
+ referrerpolicy="no-referrer"
10
+ loading="lazy"
11
+ allowfullscreen
12
+ ></iframe>
13
+ </div>
14
+ </div>
@@ -0,0 +1,132 @@
1
+ {{--
2
+ Form block: renders a @stacksjs/forms form inline on a CMS page.
3
+ `props.formUuid` names the form; the client script fetches the public
4
+ definition (same-origin), renders the fields, evaluates conditions live,
5
+ and submits with the CSRF header the page response seeded.
6
+ --}}
7
+ <div class="cms-form mx-auto max-w-xl px-6 py-8" data-form-uuid="{{ props.formUuid }}">
8
+ <script client>
9
+ const host = useRef('host')
10
+ const definition = state(null)
11
+ const values = reactive({})
12
+ const errors = state({})
13
+ const sending = state(false)
14
+ const done = state('')
15
+ const openedAt = Date.now()
16
+
17
+ effect(() => {
18
+ if (!host() || definition())
19
+ return
20
+ const uuid = host().dataset.formUuid
21
+ if (!uuid)
22
+ return
23
+ fetch(`/api/forms/${uuid}`, { credentials: 'same-origin' })
24
+ .then(reply => reply.ok ? reply.json() : null)
25
+ .then((body) => { if (body) definition.set(body) })
26
+ .catch(() => {})
27
+ })
28
+
29
+ function fieldVisible(field) {
30
+ const conditions = field.conditions
31
+ if (!conditions || !conditions.rules || conditions.rules.length === 0)
32
+ return true
33
+ const results = conditions.rules.map((rule) => {
34
+ const actual = values[rule.field]
35
+ switch (rule.op) {
36
+ case 'eq': return String(actual ?? '') === String(rule.value ?? '')
37
+ case 'neq': return String(actual ?? '') !== String(rule.value ?? '')
38
+ case 'contains': return String(actual ?? '').includes(String(rule.value ?? ''))
39
+ case 'gt': return Number(actual) > Number(rule.value)
40
+ case 'lt': return Number(actual) < Number(rule.value)
41
+ case 'empty': return actual === undefined || actual === null || String(actual) === ''
42
+ case 'not_empty': return actual !== undefined && actual !== null && String(actual) !== ''
43
+ default: return false
44
+ }
45
+ })
46
+ const matched = conditions.logic === 'all' ? results.every(Boolean) : results.some(Boolean)
47
+ return conditions.action === 'show' ? matched : !matched
48
+ }
49
+
50
+ async function submit(event) {
51
+ event.preventDefault()
52
+ if (sending())
53
+ return
54
+ sending.set(true)
55
+ errors.set({})
56
+
57
+ try {
58
+ const uuid = host().dataset.formUuid
59
+ const reply = await fetch(`/api/forms/${uuid}/submissions`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ credentials: 'same-origin',
63
+ body: JSON.stringify({ ...values, _renderedMs: Date.now() - openedAt }),
64
+ })
65
+ const body = await reply.json().catch(() => ({}))
66
+
67
+ if (reply.status === 422) {
68
+ errors.set(body.errors ?? {})
69
+ return
70
+ }
71
+ if (!reply.ok) {
72
+ errors.set({ _form: body.message ?? 'Something went wrong. Please try again.' })
73
+ return
74
+ }
75
+ if (body.redirect) {
76
+ navigate(body.redirect)
77
+ return
78
+ }
79
+ done.set(body.message ?? 'Thanks - your response was received.')
80
+ }
81
+ catch {
82
+ errors.set({ _form: 'Something went wrong. Please try again.' })
83
+ }
84
+ finally {
85
+ sending.set(false)
86
+ }
87
+ }
88
+ </script>
89
+
90
+ <div ref="host" data-form-uuid="{{ props.formUuid }}">
91
+ <div :if="done()" class="rounded-lg border border-green-200 bg-green-50 px-5 py-4 text-[15px] text-green-900">{{ done() }}</div>
92
+ <form :else-if="definition()" @submit="submit" novalidate>
93
+ <h2 class="text-2xl font-semibold tracking-tight">{{ definition().name }}</h2>
94
+ <p :if="errors()._form" class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[14px] text-red-800">{{ errors()._form }}</p>
95
+
96
+ <div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
97
+ <template :for="field in definition().fields">
98
+ <div :if="fieldVisible(field)" class="{{ field.width === 'half' ? '' : 'sm:col-span-2' }}">
99
+ <h3 :if="field.type === 'section_break'" class="mt-2 border-t border-gray-200 pt-4 text-lg font-medium">{{ field.label }}</h3>
100
+ <template :else>
101
+ <label class="block text-[14px] font-medium text-gray-800">{{ field.label }}<span :if="field.required" aria-hidden="true" class="text-red-500"> *</span></label>
102
+ <textarea :if="field.type === 'textarea'" rows="4" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]"></textarea>
103
+ <select :else-if="field.type === 'select'" :required="field.required" @change="values[field.name] = $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px] bg-white">
104
+ <option value="">Choose...</option>
105
+ <option :for="choice in field.options.choices ?? []" value="{{ choice.value }}">{{ choice.label }}</option>
106
+ </select>
107
+ <div :else-if="field.type === 'radio'" class="mt-2 space-y-2">
108
+ <label :for="choice in field.options.choices ?? []" class="flex items-center gap-2.5 text-[15px]">
109
+ <input type="radio" name="{{ field.name }}" value="{{ choice.value }}" @change="values[field.name] = $event.target.value" class="h-4 w-4">
110
+ {{ choice.label }}
111
+ </label>
112
+ </div>
113
+ <label :else-if="field.type === 'checkbox'" class="mt-2 flex items-center gap-2.5 text-[15px]">
114
+ <input type="checkbox" @change="values[field.name] = $event.target.checked" class="h-4 w-4 rounded">
115
+ {{ field.options.placeholder ?? 'Yes' }}
116
+ </label>
117
+ <input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = field.type === 'currency' ? Math.round(Number($event.target.value) * 100) : $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]">
118
+ <p :if="errors()[field.name]" class="mt-1.5 text-[13px] text-red-600">{{ errors()[field.name] }}</p>
119
+ </template>
120
+ </div>
121
+ </template>
122
+ </div>
123
+
124
+ <input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="values._hp = $event.target.value">
125
+
126
+ <button type="submit" :disabled="sending()" class="mt-7 rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98] disabled:opacity-60">
127
+ {{ sending() ? 'Sending...' : (definition().submitLabel ?? 'Submit') }}
128
+ </button>
129
+ </form>
130
+ <p :else class="text-[15px] text-gray-500">Loading form...</p>
131
+ </div>
132
+ </div>
@@ -0,0 +1,16 @@
1
+ {{-- Hero block: heading, optional subheading/image/CTA. --}}
2
+ <section class="cms-hero relative overflow-hidden {{ props.align === 'center' ? 'text-center' : 'text-left' }}">
3
+ @if (props.imageUrl)
4
+ <img src="{{ props.imageUrl }}" alt="" class="absolute inset-0 h-full w-full object-cover" aria-hidden="true">
5
+ <div class="absolute inset-0 bg-black/45" aria-hidden="true"></div>
6
+ @endif
7
+ <div class="relative mx-auto max-w-4xl px-6 py-24 {{ props.imageUrl ? 'text-white' : '' }}">
8
+ <h1 class="text-4xl md:text-6xl tracking-tighter leading-none font-semibold">{{ props.heading }}</h1>
9
+ @if (props.subheading)
10
+ <p class="mt-5 text-lg md:text-xl max-w-[65ch] {{ props.align === 'center' ? 'mx-auto' : '' }} opacity-90">{{ props.subheading }}</p>
11
+ @endif
12
+ @if (props.ctaLabel && props.ctaHref)
13
+ <a href="{{ props.ctaHref }}" class="mt-8 inline-block rounded-lg px-6 py-3 font-medium bg-white text-gray-900 shadow-sm transition-transform active:scale-[0.98]">{{ props.ctaLabel }}</a>
14
+ @endif
15
+ </div>
16
+ </section>
@@ -0,0 +1,7 @@
1
+ {{-- Image block with required alt text (the editor enforces it; the schema requires it). --}}
2
+ <figure class="cms-image mx-auto px-6 py-8 {{ props.width === 'full' ? 'max-w-none px-0' : (props.width === 'wide' ? 'max-w-6xl' : 'max-w-3xl') }}">
3
+ <img src="{{ props.src }}" alt="{{ props.alt }}" class="w-full rounded-lg" loading="lazy">
4
+ @if (props.caption)
5
+ <figcaption class="mt-3 text-sm text-gray-500">{{ props.caption }}</figcaption>
6
+ @endif
7
+ </figure>
@@ -0,0 +1,4 @@
1
+ {{-- Rich text block. `props.html` is sanitized by the render pipeline before it gets here. --}}
2
+ <div class="cms-rich-text mx-auto max-w-3xl px-6 py-8 leading-relaxed">
3
+ {!! props.html !!}
4
+ </div>
@@ -0,0 +1,28 @@
1
+ {{--
2
+ The default CMS page shell. Every published block-document page renders
3
+ through this template unless the app overrides it at
4
+ `resources/views/cms/page.stx`. It receives:
5
+
6
+ site - { id, name, subdomain, settings } for the request's site
7
+ page - { id, title, path, template, metaDescription }
8
+ content - the pre-rendered, sanitized block HTML
9
+
10
+ Deliberately minimal chrome: real sites override this to add their own
11
+ navigation (fetchMenuTree), theme tokens and footer.
12
+ --}}
13
+ <!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <title>{{ page.title }}</title>
19
+ @if (page.metaDescription)
20
+ <meta name="description" content="{{ page.metaDescription }}">
21
+ @endif
22
+ </head>
23
+ <body>
24
+ <main class="cms-page" data-template="{{ page.template }}">
25
+ {!! content !!}
26
+ </main>
27
+ </body>
28
+ </html>
package/routes/auth.ts CHANGED
@@ -34,6 +34,13 @@ import { route } from '@stacksjs/router'
34
34
  // `routes/api.ts` (user routes win) gets to pick its own limits.
35
35
  route.post('/login', 'Actions/Auth/LoginAction').rateLimit(5, 'minute')
36
36
  route.post('/register', 'Actions/Auth/RegisterAction').rateLimit(3, 'minute')
37
+ // Magic links (config.auth.magicLink.enabled gates both, 404 when off).
38
+ // The send endpoint answers a uniform 202 either way (anti-enumeration
39
+ // lives in sendMagicLink); the consume endpoint is a POST because email
40
+ // scanners prefetch GETs and would burn single-use tokens - the GET page
41
+ // at /auth/magic/{token} is an interstitial that posts here.
42
+ route.post('/auth/magic-link', 'Actions/Auth/MagicLinkSendAction').rateLimit(3, 'minute')
43
+ route.post('/auth/magic-link/consume', 'Actions/Auth/MagicLinkConsumeAction').rateLimit(10, 'minute')
37
44
  // Passkey ENROLLMENT (attaching a new credential to an account) must be
38
45
  // auth-gated — it's not a login flow, it's a logged-in user adding a
39
46
  // second factor to their own account. Previously unauthenticated and
@@ -0,0 +1,110 @@
1
+ import { requestHost, resolveSiteByHost, sitesOptions } from '@stacksjs/sites'
2
+ import { response, route } from '@stacksjs/router'
3
+
4
+ /**
5
+ * Public form endpoints (`@stacksjs/forms`). The admin builder surface is
6
+ * the models' own auth'd `useApi` routes plus the export route below; these
7
+ * two are what a visitor's browser talks to.
8
+ *
9
+ * CSRF: the submit endpoint keeps the default-on double-submit protection.
10
+ * A form rendered by the CMS `form` block lives on the same origin, so the
11
+ * page-seeded cookie + header work exactly like every other public form.
12
+ * (Cross-origin embeds would need a `.skipCsrf()` variant with an Origin
13
+ * allowlist - deliberately not shipped until something needs it.)
14
+ */
15
+
16
+ async function siteIdForRequest(request: any): Promise<number | null> {
17
+ const options = sitesOptions()
18
+ if (!options.enabled)
19
+ return null
20
+
21
+ const headers: Headers = request.headers instanceof Headers
22
+ ? request.headers
23
+ : new Headers(request.headers ?? {})
24
+ const site = await resolveSiteByHost(requestHost(headers, options), undefined, options)
25
+ return site?.id ?? null
26
+ }
27
+
28
+ route.get('/api/forms/{uuid}', async (request: any) => {
29
+ const { loadFormByUuid, publicDefinition } = await import('@stacksjs/forms')
30
+
31
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
32
+ if (!form || form.status === 'draft')
33
+ return response.notFound('Form not found')
34
+
35
+ return response.json(publicDefinition(form))
36
+ }).rateLimit(60, 'minute')
37
+
38
+ route.post('/api/forms/{uuid}/submissions', async (request: any) => {
39
+ const { dispatchSubmissionNotifications, loadFormByUuid, submitForm } = await import('@stacksjs/forms')
40
+
41
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
42
+ if (!form)
43
+ return response.notFound('Form not found')
44
+
45
+ const body = (typeof request.all === 'function' ? request.all() : request.body) ?? {}
46
+ const { _hp, _renderedMs, ...payload } = body as Record<string, unknown>
47
+
48
+ const result = await submitForm(form, payload, {
49
+ ip: typeof request.ip === 'function' ? request.ip() : request.ip,
50
+ honeypot: typeof _hp === 'string' ? _hp : undefined,
51
+ renderedAtMs: typeof _renderedMs === 'number' ? _renderedMs : undefined,
52
+ })
53
+
54
+ if (!result.ok) {
55
+ if (result.status === 422)
56
+ return response.json({ errors: result.errors }, { status: 422 })
57
+ return response.json({ message: result.message }, { status: result.status })
58
+ }
59
+
60
+ // Fire-and-forget AFTER the write: a slow mail transport must not hold a
61
+ // parent's phone on a spinner, and a failed one must not undo the answers.
62
+ if (result.submissionId > 0) {
63
+ void dispatchSubmissionNotifications(form, result, {
64
+ ...await submissionIdentity(result.submissionId),
65
+ }).catch(() => {})
66
+ }
67
+
68
+ return response.json({
69
+ message: result.confirmation ?? 'Thanks - your response was received.',
70
+ status: result.status,
71
+ submission: result.submissionUuid,
72
+ amount_cents: result.amountCents,
73
+ redirect: result.redirect,
74
+ }, { status: 201 })
75
+ }).rateLimit(10, 'minute')
76
+
77
+ async function submissionIdentity(submissionId: number): Promise<{ email: string | null, name: string | null, values: Record<string, unknown> }> {
78
+ const { db } = await import('@stacksjs/database')
79
+ const row = await db
80
+ .selectFrom('form_submissions')
81
+ .where('id', '=', submissionId)
82
+ .select(['email', 'name', 'values'])
83
+ .executeTakeFirst() as { email: string | null, name: string | null, values: string | null } | undefined
84
+
85
+ let values: Record<string, unknown> = {}
86
+ try {
87
+ values = row?.values ? JSON.parse(row.values) as Record<string, unknown> : {}
88
+ }
89
+ catch {
90
+ // unreadable values only degrade the notification summary
91
+ }
92
+ return { email: row?.email ?? null, name: row?.name ?? null, values }
93
+ }
94
+
95
+ /** Admin CSV export. Auth'd; site scoping rides the form lookup. */
96
+ route.get('/api/admin/forms/{uuid}/submissions.csv', async (request: any) => {
97
+ const { exportSubmissionsCsv, loadFormByUuid } = await import('@stacksjs/forms')
98
+
99
+ const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
100
+ if (!form)
101
+ return response.notFound('Form not found')
102
+
103
+ const csv = await exportSubmissionsCsv(form)
104
+ return new Response(csv, {
105
+ headers: {
106
+ 'Content-Type': 'text/csv; charset=utf-8',
107
+ 'Content-Disposition': `attachment; filename="${form.handle}-submissions.csv"`,
108
+ },
109
+ })
110
+ }).middleware('auth')