@ekrist1/vulse 0.1.6-alpha.3 → 0.1.7-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/dist/cli/migrate.d.ts.map +1 -1
  2. package/dist/cli/migrate.js +3 -4
  3. package/dist/cli/setup.d.ts.map +1 -1
  4. package/dist/cli/setup.js +11 -10
  5. package/dist/core/blueprints/compile.d.ts +1 -1
  6. package/dist/core/blueprints/compile.d.ts.map +1 -1
  7. package/dist/core/blueprints/compile.js +3 -3
  8. package/dist/core/blueprints/mutations.js +2 -2
  9. package/dist/core/forms/rate-limit.d.ts +1 -1
  10. package/dist/core/forms/rate-limit.d.ts.map +1 -1
  11. package/dist/core/forms/rate-limit.js +3 -3
  12. package/dist/core/forms/unique.d.ts +1 -1
  13. package/dist/core/forms/unique.d.ts.map +1 -1
  14. package/dist/core/forms/unique.js +4 -4
  15. package/dist/core/globals/compile.d.ts +1 -1
  16. package/dist/core/globals/compile.d.ts.map +1 -1
  17. package/dist/core/globals/compile.js +2 -2
  18. package/dist/core/globals/definition.d.ts +1 -1
  19. package/dist/core/globals/definition.d.ts.map +1 -1
  20. package/dist/core/globals/definition.js +3 -3
  21. package/dist/core/repos/globals.js +3 -3
  22. package/dist/core/sha256.d.ts +3 -0
  23. package/dist/core/sha256.d.ts.map +1 -0
  24. package/dist/core/sha256.js +9 -0
  25. package/dist/integration/index.js +1 -1
  26. package/dist/integration/install-hook.d.ts.map +1 -1
  27. package/dist/integration/install-hook.js +6 -4
  28. package/dist/integration/wrangler-config.d.ts +12 -0
  29. package/dist/integration/wrangler-config.d.ts.map +1 -0
  30. package/dist/integration/wrangler-config.js +97 -0
  31. package/dist/integration/wrangler-patch.d.ts +1 -0
  32. package/dist/integration/wrangler-patch.d.ts.map +1 -1
  33. package/dist/integration/wrangler-patch.js +2 -1
  34. package/dist/server/routes/form-submit.js +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +11 -3
  38. package/src/admin/assets/logo-mark.svg +5 -0
  39. package/src/admin/client/active-locale.ts +17 -0
  40. package/src/admin/client/api.ts +21 -0
  41. package/src/admin/client/form-from-zod.ts +7 -0
  42. package/src/admin/client/live-preview-enabled.ts +5 -0
  43. package/src/admin/components/AdminShell.astro +45 -0
  44. package/src/admin/components/AuthSettings.vue +60 -0
  45. package/src/admin/components/BlockEditor.vue +53 -0
  46. package/src/admin/components/BlueprintEditor.vue +1783 -0
  47. package/src/admin/components/CollectionKindIcon.vue +26 -0
  48. package/src/admin/components/CollectionTree.vue +220 -0
  49. package/src/admin/components/EntryEditorWithPreview.vue +130 -0
  50. package/src/admin/components/EntryForm.vue +411 -0
  51. package/src/admin/components/EntryList.vue +121 -0
  52. package/src/admin/components/EntryStatusBadge.vue +24 -0
  53. package/src/admin/components/FormEditor.vue +233 -0
  54. package/src/admin/components/FormList.vue +54 -0
  55. package/src/admin/components/GlobalSetEditor.vue +272 -0
  56. package/src/admin/components/GlobalSetList.vue +55 -0
  57. package/src/admin/components/LivePreviewPanel.vue +171 -0
  58. package/src/admin/components/LoginForm.vue +53 -0
  59. package/src/admin/components/MediaLibrary.vue +106 -0
  60. package/src/admin/components/MediaPicker.vue +49 -0
  61. package/src/admin/components/RevisionDiff.vue +11 -0
  62. package/src/admin/components/RevisionList.vue +134 -0
  63. package/src/admin/components/SeoFields.vue +113 -0
  64. package/src/admin/components/SetEditor.vue +137 -0
  65. package/src/admin/components/SetList.vue +32 -0
  66. package/src/admin/components/SettingsForm.vue +189 -0
  67. package/src/admin/components/SideNav.vue +152 -0
  68. package/src/admin/components/SubmissionDetail.vue +45 -0
  69. package/src/admin/components/SubmissionList.vue +89 -0
  70. package/src/admin/components/ToastHost.vue +33 -0
  71. package/src/admin/components/TreeRow.vue +163 -0
  72. package/src/admin/components/UserEditor.vue +186 -0
  73. package/src/admin/components/UserList.vue +46 -0
  74. package/src/admin/components/blocks/BlockItem.vue +32 -0
  75. package/src/admin/components/blocks/BlockToolbar.vue +12 -0
  76. package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
  77. package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
  78. package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
  79. package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
  80. package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
  81. package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
  82. package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
  83. package/src/admin/components/fields/BlocksField.vue +123 -0
  84. package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
  85. package/src/admin/components/fields/BoolField.vue +10 -0
  86. package/src/admin/components/fields/DateField.vue +22 -0
  87. package/src/admin/components/fields/EntriesField.vue +153 -0
  88. package/src/admin/components/fields/EntryField.vue +138 -0
  89. package/src/admin/components/fields/EnumField.vue +81 -0
  90. package/src/admin/components/fields/FieldRenderer.vue +87 -0
  91. package/src/admin/components/fields/GridField.vue +173 -0
  92. package/src/admin/components/fields/LinkField.vue +219 -0
  93. package/src/admin/components/fields/MediaField.vue +69 -0
  94. package/src/admin/components/fields/NumberField.vue +12 -0
  95. package/src/admin/components/fields/ObjectField.vue +18 -0
  96. package/src/admin/components/fields/RefField.vue +170 -0
  97. package/src/admin/components/fields/RepeaterField.vue +27 -0
  98. package/src/admin/components/fields/ReplicatorField.vue +121 -0
  99. package/src/admin/components/fields/TextField.vue +11 -0
  100. package/src/admin/components/fields/TextareaField.vue +11 -0
  101. package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
  102. package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
  103. package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
  104. package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
  105. package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
  106. package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
  107. package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
  108. package/src/admin/components/fields/emoji-extension.ts +54 -0
  109. package/src/admin/components/fields/link-extension.ts +48 -0
  110. package/src/admin/components/fields/set-node-utils.ts +115 -0
  111. package/src/admin/components/fields/url-utils.ts +85 -0
  112. package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
  113. package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
  114. package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
  115. package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
  116. package/src/admin/components/fields/vulse-set-extension.ts +66 -0
  117. package/src/admin/components/fields/vulse-video-extension.ts +65 -0
  118. package/src/admin/composables/toast.ts +35 -0
  119. package/src/admin/composables/useEntrySearch.ts +112 -0
  120. package/src/admin/composables/useSets.ts +31 -0
  121. package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
  122. package/src/admin/pages/collections/[name]/[id].astro +90 -0
  123. package/src/admin/pages/collections/[name]/index.astro +31 -0
  124. package/src/admin/pages/collections/[name]/new.astro +38 -0
  125. package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
  126. package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
  127. package/src/admin/pages/forms/[handle].astro +9 -0
  128. package/src/admin/pages/forms/index.astro +7 -0
  129. package/src/admin/pages/forms/new.astro +7 -0
  130. package/src/admin/pages/index.astro +36 -0
  131. package/src/admin/pages/login.astro +14 -0
  132. package/src/admin/pages/media.astro +8 -0
  133. package/src/admin/pages/schema/[handle].astro +10 -0
  134. package/src/admin/pages/schema/new.astro +9 -0
  135. package/src/admin/pages/settings/auth.astro +9 -0
  136. package/src/admin/pages/settings/globals/[handle].astro +9 -0
  137. package/src/admin/pages/settings/globals/index.astro +7 -0
  138. package/src/admin/pages/settings/globals/new.astro +7 -0
  139. package/src/admin/pages/settings/index.astro +8 -0
  140. package/src/admin/pages/settings/sets/[handle].astro +9 -0
  141. package/src/admin/pages/settings/sets/index.astro +7 -0
  142. package/src/admin/pages/settings/sets/new.astro +7 -0
  143. package/src/admin/pages/users/[id].astro +10 -0
  144. package/src/admin/pages/users/index.astro +8 -0
  145. package/src/admin/styles/admin.css +166 -0
  146. package/src/core/access.ts +9 -0
  147. package/src/core/blocks/schema.ts +66 -0
  148. package/src/core/blueprints/code-to-definition.ts +156 -0
  149. package/src/core/blueprints/compile.ts +176 -0
  150. package/src/core/blueprints/define.ts +12 -0
  151. package/src/core/blueprints/definition.ts +185 -0
  152. package/src/core/blueprints/load.ts +144 -0
  153. package/src/core/blueprints/mutations.ts +236 -0
  154. package/src/core/blueprints/preview-path.ts +33 -0
  155. package/src/core/blueprints/reflect-fields.ts +305 -0
  156. package/src/core/blueprints/registry.ts +14 -0
  157. package/src/core/blueprints/seed.ts +20 -0
  158. package/src/core/blueprints/select-helpers.ts +30 -0
  159. package/src/core/blueprints/seo.ts +180 -0
  160. package/src/core/blueprints/types.ts +59 -0
  161. package/src/core/blueprints/zod-helpers.ts +86 -0
  162. package/src/core/db.ts +11 -0
  163. package/src/core/errors.ts +34 -0
  164. package/src/core/forms/compile.ts +84 -0
  165. package/src/core/forms/definition.ts +102 -0
  166. package/src/core/forms/rate-limit.ts +52 -0
  167. package/src/core/forms/unique.ts +38 -0
  168. package/src/core/globals/compile.ts +35 -0
  169. package/src/core/globals/definition.ts +27 -0
  170. package/src/core/locales.ts +45 -0
  171. package/src/core/migrations.ts +48 -0
  172. package/src/core/parse-content.ts +85 -0
  173. package/src/core/plugins/definition.ts +150 -0
  174. package/src/core/preview-content.ts +21 -0
  175. package/src/core/repos/entries.ts +504 -0
  176. package/src/core/repos/forms.ts +270 -0
  177. package/src/core/repos/globals.ts +179 -0
  178. package/src/core/repos/media.ts +106 -0
  179. package/src/core/repos/preview-sessions.ts +108 -0
  180. package/src/core/repos/revisions.ts +60 -0
  181. package/src/core/repos/settings.ts +23 -0
  182. package/src/core/schema.ts +244 -0
  183. package/src/core/sets/compile.ts +12 -0
  184. package/src/core/sets/definition.ts +10 -0
  185. package/src/core/sets/service.ts +82 -0
  186. package/src/core/sets/validate-tree.ts +57 -0
  187. package/src/core/sha256.ts +10 -0
  188. package/src/core/slug.ts +30 -0
  189. package/src/scaffold/collection-write.ts +83 -0
  190. package/src/scaffold/collection.ts +277 -0
  191. package/src/server/assets/live-preview-bridge.content.ts +2 -0
  192. package/src/server/assets/live-preview-bridge.js +535 -0
  193. package/src/server/better-auth.ts +82 -0
  194. package/src/server/cf-images.ts +34 -0
  195. package/src/server/cron.ts +37 -0
  196. package/src/server/email.ts +17 -0
  197. package/src/server/endpoints/api-auth.ts +10 -0
  198. package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
  199. package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
  200. package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
  201. package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
  202. package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
  203. package/src/server/endpoints/api-vulse-entries.ts +23 -0
  204. package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
  205. package/src/server/endpoints/api-vulse-form-public.ts +7 -0
  206. package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
  207. package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
  208. package/src/server/endpoints/api-vulse-forms.ts +12 -0
  209. package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
  210. package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
  211. package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
  212. package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
  213. package/src/server/endpoints/api-vulse-globals.ts +12 -0
  214. package/src/server/endpoints/api-vulse-media-file.ts +7 -0
  215. package/src/server/endpoints/api-vulse-media-id.ts +12 -0
  216. package/src/server/endpoints/api-vulse-media.ts +12 -0
  217. package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
  218. package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
  219. package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
  220. package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
  221. package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
  222. package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
  223. package/src/server/endpoints/api-vulse-revisions.ts +7 -0
  224. package/src/server/endpoints/api-vulse-search.ts +7 -0
  225. package/src/server/endpoints/api-vulse-sets.ts +23 -0
  226. package/src/server/endpoints/api-vulse-settings.ts +12 -0
  227. package/src/server/endpoints/api-vulse-users-id.ts +12 -0
  228. package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
  229. package/src/server/endpoints/api-vulse-users-role.ts +9 -0
  230. package/src/server/endpoints/api-vulse-users.ts +7 -0
  231. package/src/server/endpoints/with-runtime.ts +11 -0
  232. package/src/server/env.ts +23 -0
  233. package/src/server/envelope.ts +21 -0
  234. package/src/server/forms/email.ts +11 -0
  235. package/src/server/forms/process-submission.ts +95 -0
  236. package/src/server/forms/queue.ts +25 -0
  237. package/src/server/forms/templates.ts +24 -0
  238. package/src/server/forms/webhook.ts +19 -0
  239. package/src/server/handler.ts +66 -0
  240. package/src/server/image-probe.ts +35 -0
  241. package/src/server/loader.ts +54 -0
  242. package/src/server/plugins.ts +214 -0
  243. package/src/server/preview.ts +25 -0
  244. package/src/server/r2.ts +13 -0
  245. package/src/server/routes/blueprints.ts +62 -0
  246. package/src/server/routes/entries.ts +255 -0
  247. package/src/server/routes/form-submit.ts +168 -0
  248. package/src/server/routes/form-upload.ts +100 -0
  249. package/src/server/routes/forms.ts +88 -0
  250. package/src/server/routes/globals-public.ts +30 -0
  251. package/src/server/routes/globals.ts +93 -0
  252. package/src/server/routes/media.ts +145 -0
  253. package/src/server/routes/preview-sessions.ts +76 -0
  254. package/src/server/routes/preview.ts +36 -0
  255. package/src/server/routes/revisions.ts +29 -0
  256. package/src/server/routes/search.ts +31 -0
  257. package/src/server/routes/sets.ts +40 -0
  258. package/src/server/routes/settings.ts +24 -0
  259. package/src/server/routes/users.ts +127 -0
  260. package/src/server/runtime.ts +99 -0
  261. package/src/server/sdk/collections.ts +98 -0
  262. package/src/server/sdk/index.ts +25 -0
  263. package/src/server/sdk/media.ts +11 -0
  264. package/src/server/sdk/search.ts +90 -0
@@ -0,0 +1,255 @@
1
+ import { z } from 'astro/zod'
2
+ import type { VulseDb } from '../../core/db.js'
3
+ import type { Auth } from '../better-auth.js'
4
+ import type { BlueprintRegistry } from '../../core/blueprints/registry.js'
5
+ import { DEFAULT_LOCALE, EntriesRepo } from '../../core/repos/entries.js'
6
+ import { AccessDeniedError, NotFoundError, ValidationError } from '../../core/errors.js'
7
+ import { evaluate } from '../../core/access.js'
8
+ import { parseContent } from '../../core/parse-content.js'
9
+ import { defineHandler } from '../handler.js'
10
+ import { isValidLocaleCode, readLocalesConfig } from '../../core/locales.js'
11
+
12
+ /**
13
+ * The URL slug is owned by the entry locale row. If a user schema also declares a
14
+ * `slug` field (common for templates), it is hidden from the form; we mirror
15
+ * the canonical slug into content here so schemas that require it still parse.
16
+ */
17
+ function withCanonicalSlug(content: unknown, slug: string | undefined): unknown {
18
+ if (slug === undefined) return content
19
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) return content
20
+ return { ...(content as Record<string, unknown>), slug }
21
+ }
22
+
23
+ const paramsByCollection = z.object({ collection: z.string() })
24
+ const paramsById = z.object({ collection: z.string(), id: z.string() })
25
+
26
+ async function resolveLocaleParam(db: VulseDb, raw: string | null | undefined): Promise<string> {
27
+ const cfg = await readLocalesConfig(db)
28
+ if (!raw || raw === DEFAULT_LOCALE) return cfg.defaultLocale
29
+ if (!isValidLocaleCode(raw)) throw new ValidationError(`Invalid locale code: ${raw}`)
30
+ if (!cfg.locales.includes(raw)) {
31
+ throw new ValidationError(`Locale '${raw}' is not enabled for this site.`, {
32
+ field: 'locale',
33
+ supported: cfg.locales,
34
+ })
35
+ }
36
+ return raw
37
+ }
38
+
39
+ export function entriesRoutes(db: VulseDb, auth: Auth, reg: BlueprintRegistry) {
40
+ const entries = new EntriesRepo(db)
41
+
42
+ function blueprintFor(name: string) {
43
+ const bp = reg.get(name)
44
+ if (!bp) throw new NotFoundError(`Unknown collection: ${name}`)
45
+ return bp
46
+ }
47
+
48
+ return {
49
+ list: defineHandler(auth, { params: paramsByCollection }, async ({ params, url, auth: authCtx }) => {
50
+ const bp = blueprintFor(params.collection)
51
+ const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
52
+ if (!(await evaluate(bp, 'read', { user: authCtx.user }))) {
53
+ return await entries.list({ collection: params.collection, locale, status: 'published' })
54
+ }
55
+ const parentRaw = url.searchParams.get('parentId')
56
+ const parentId = parentRaw === 'root' || parentRaw === '' ? null : parentRaw ?? undefined
57
+ return await entries.list({
58
+ collection: params.collection,
59
+ locale,
60
+ ...(parentId !== undefined ? { parentId } : {}),
61
+ })
62
+ }),
63
+
64
+ tree: defineHandler(auth, {
65
+ params: paramsByCollection,
66
+ requireRole: ['admin', 'editor'],
67
+ }, async ({ params, url }) => {
68
+ const bp = blueprintFor(params.collection)
69
+ if (!bp.tree) throw new ValidationError('Collection does not support tree structure')
70
+ const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
71
+ return await entries.tree(params.collection, locale)
72
+ }),
73
+
74
+ findById: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
75
+ const bp = blueprintFor(params.collection)
76
+ const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
77
+ const row = await entries.findById(params.id, locale)
78
+ if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
79
+ const allowed = await evaluate(bp, 'read', {
80
+ user: authCtx.user,
81
+ entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
82
+ })
83
+ if (!allowed) throw new NotFoundError(`Entry ${params.id} not found`)
84
+ return row
85
+ }),
86
+
87
+ listLocales: defineHandler(auth, {
88
+ params: paramsById,
89
+ requireRole: ['admin', 'editor'],
90
+ }, async ({ params }) => {
91
+ blueprintFor(params.collection)
92
+ return await entries.listLocales(params.id)
93
+ }),
94
+
95
+ create: defineHandler(auth, {
96
+ params: paramsByCollection,
97
+ body: z.object({
98
+ slug: z.string(),
99
+ content: z.unknown(),
100
+ status: z.enum(['draft', 'published']).optional(),
101
+ parentId: z.string().nullable().optional(),
102
+ locale: z.string().optional(),
103
+ }),
104
+ }, async ({ params, body, auth: authCtx }) => {
105
+ const bp = blueprintFor(params.collection)
106
+ const allowed = await evaluate(bp, 'create', { user: authCtx.user })
107
+ if (!allowed) throw new AccessDeniedError('Cannot create')
108
+ if (!authCtx.user) throw new AccessDeniedError('Authentication required')
109
+ if (body.parentId && !bp.tree) throw new ValidationError('Collection does not support nesting')
110
+ const locale = await resolveLocaleParam(db, body.locale)
111
+ const validated = parseContent(bp.schema, withCanonicalSlug(body.content, body.slug))
112
+ return await entries.create({
113
+ collection: params.collection,
114
+ slug: body.slug,
115
+ content: validated,
116
+ locale,
117
+ ...(body.status !== undefined ? { status: body.status } : {}),
118
+ ...(body.parentId !== undefined ? { parentId: body.parentId } : {}),
119
+ draftsEnabled: bp.drafts === true,
120
+ createdBy: authCtx.user.id,
121
+ })
122
+ }),
123
+
124
+ createLocale: defineHandler(auth, {
125
+ params: paramsById,
126
+ body: z.object({
127
+ locale: z.string(),
128
+ slug: z.string(),
129
+ content: z.unknown(),
130
+ status: z.enum(['draft', 'published']).optional(),
131
+ }),
132
+ }, async ({ params, body, auth: authCtx }) => {
133
+ const bp = blueprintFor(params.collection)
134
+ const allowed = await evaluate(bp, 'create', { user: authCtx.user })
135
+ if (!allowed) throw new AccessDeniedError('Cannot create')
136
+ if (!authCtx.user) throw new AccessDeniedError('Authentication required')
137
+ const locale = await resolveLocaleParam(db, body.locale)
138
+ const validated = parseContent(bp.schema, withCanonicalSlug(body.content, body.slug))
139
+ return await entries.createLocale(params.id, {
140
+ locale,
141
+ slug: body.slug,
142
+ content: validated,
143
+ updatedBy: authCtx.user.id,
144
+ ...(body.status !== undefined ? { status: body.status } : {}),
145
+ draftsEnabled: bp.drafts === true,
146
+ })
147
+ }),
148
+
149
+ update: defineHandler(auth, {
150
+ params: paramsById,
151
+ body: z.object({
152
+ slug: z.string().optional(),
153
+ content: z.unknown().optional(),
154
+ status: z.enum(['draft', 'published']).optional(),
155
+ changeSummary: z.string().optional(),
156
+ publish: z.boolean().optional(),
157
+ locale: z.string().optional(),
158
+ }),
159
+ }, async ({ params, body, auth: authCtx }) => {
160
+ const bp = blueprintFor(params.collection)
161
+ const locale = await resolveLocaleParam(db, body.locale)
162
+ const row = await entries.findById(params.id, locale)
163
+ if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
164
+ const allowed = await evaluate(bp, 'update', {
165
+ user: authCtx.user,
166
+ entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
167
+ })
168
+ if (!allowed) throw new AccessDeniedError('Cannot update')
169
+ if (!authCtx.user) throw new AccessDeniedError('Authentication required')
170
+ const nextSlug = body.slug ?? row.slug
171
+ const validated = body.content !== undefined
172
+ ? parseContent(bp.schema, withCanonicalSlug(body.content, nextSlug))
173
+ : undefined
174
+ return await entries.updateWithRevision(params.id, {
175
+ locale,
176
+ ...(body.slug !== undefined ? { slug: body.slug } : {}),
177
+ ...(validated !== undefined ? { content: validated } : {}),
178
+ ...(body.status !== undefined ? { status: body.status } : {}),
179
+ ...(body.publish !== undefined ? { publish: body.publish } : {}),
180
+ draftsEnabled: bp.drafts === true,
181
+ updatedBy: authCtx.user.id,
182
+ ...(body.changeSummary !== undefined ? { changeSummary: body.changeSummary } : {}),
183
+ })
184
+ }),
185
+
186
+ move: defineHandler(auth, {
187
+ params: paramsById,
188
+ body: z.object({
189
+ parentId: z.string().nullable(),
190
+ sortOrder: z.number().int().positive().optional(),
191
+ }),
192
+ }, async ({ params, body, auth: authCtx }) => {
193
+ const bp = blueprintFor(params.collection)
194
+ if (!bp.tree) throw new ValidationError('Collection does not support tree structure')
195
+ const shell = await entries.findShellById(params.id)
196
+ if (!shell || shell.collection !== params.collection) throw new NotFoundError(`Entry ${params.id} not found`)
197
+ const allowed = await evaluate(bp, 'update', {
198
+ user: authCtx.user,
199
+ entry: { id: shell.id, status: 'draft', createdBy: shell.createdBy, content: {} },
200
+ })
201
+ if (!allowed) throw new AccessDeniedError('Cannot move')
202
+ return await entries.move(params.collection, params.id, {
203
+ parentId: body.parentId,
204
+ ...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
205
+ })
206
+ }),
207
+
208
+ publish: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
209
+ const bp = blueprintFor(params.collection)
210
+ if (!bp.drafts) throw new ValidationError('Drafts not enabled for this collection')
211
+ const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
212
+ const row = await entries.findById(params.id, locale)
213
+ if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
214
+ const allowed = await evaluate(bp, 'update', {
215
+ user: authCtx.user,
216
+ entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
217
+ })
218
+ if (!allowed) throw new AccessDeniedError('Cannot publish')
219
+ if (!authCtx.user) throw new AccessDeniedError('Authentication required')
220
+ const content = row.draftContent ?? row.content
221
+ const validated = parseContent(bp.schema, content)
222
+ return await entries.updateWithRevision(params.id, {
223
+ locale,
224
+ content: validated,
225
+ publish: true,
226
+ draftsEnabled: true,
227
+ updatedBy: authCtx.user.id,
228
+ })
229
+ }),
230
+
231
+ delete: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
232
+ const bp = blueprintFor(params.collection)
233
+ const localeParam = url.searchParams.get('locale')
234
+ const shell = await entries.findShellById(params.id)
235
+ if (!shell || shell.collection !== params.collection) throw new NotFoundError(`Entry ${params.id} not found`)
236
+ const allowed = await evaluate(bp, 'delete', {
237
+ user: authCtx.user,
238
+ entry: { id: shell.id, status: 'draft', createdBy: shell.createdBy, content: {} },
239
+ })
240
+ if (!allowed) throw new AccessDeniedError('Cannot delete')
241
+ if (localeParam) {
242
+ const locale = await resolveLocaleParam(db, localeParam)
243
+ const summaries = await entries.listLocales(params.id)
244
+ if (summaries.length <= 1) {
245
+ await entries.delete(params.id)
246
+ return { deleted: true }
247
+ }
248
+ await entries.deleteLocale(params.id, locale)
249
+ return { deleted: true, locale }
250
+ }
251
+ await entries.delete(params.id)
252
+ return { deleted: true }
253
+ }),
254
+ }
255
+ }
@@ -0,0 +1,168 @@
1
+ import type { VulseDb } from '../../core/db.js'
2
+ import { FormsRepo, SubmissionsRepo, FormUploadDraftsRepo } from '../../core/repos/forms.js'
3
+ import { compileForm } from '../../core/forms/compile.js'
4
+ import { checkRateLimit, hashIp } from '../../core/forms/rate-limit.js'
5
+ import { insertUniqueValues } from '../../core/forms/unique.js'
6
+ import { NotFoundError, ValidationError } from '../../core/errors.js'
7
+ import { fail, ok } from '../envelope.js'
8
+ import { enqueueFormProcess } from '../forms/queue.js'
9
+ import { runFormAfterSubmitHooks, runFormBeforeSubmitHooks } from '../plugins.js'
10
+
11
+ export interface FormSubmitRouteOptions {
12
+ queue?: Queue
13
+ env?: Record<string, unknown>
14
+ }
15
+
16
+ export function formSubmitRoutes(db: VulseDb, options: FormSubmitRouteOptions = {}) {
17
+ const forms = new FormsRepo(db)
18
+ const submissions = new SubmissionsRepo(db)
19
+ const drafts = new FormUploadDraftsRepo(db)
20
+
21
+ return {
22
+ public: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
23
+ try {
24
+ const handle = rawParams.handle
25
+ if (!handle) throw new ValidationError('handle required')
26
+ const form = await forms.findByHandle(handle)
27
+ if (!form || !form.enabled) throw new NotFoundError('form not found')
28
+
29
+ const def = form.definition
30
+ const honeypot = def.settings.honeypotField ?? '_hp'
31
+ const publicFields = def.fields
32
+ .filter((f) => f.ui.kind !== 'honeypot')
33
+ .map((f) => ({
34
+ name: f.name,
35
+ label: f.label,
36
+ ui: f.ui,
37
+ optional: f.optional,
38
+ validation: f.validation,
39
+ }))
40
+
41
+ return ok({
42
+ handle: form.handle,
43
+ label: form.label,
44
+ fields: publicFields,
45
+ successMessage: def.settings.successMessage,
46
+ redirectTo: def.settings.redirectTo,
47
+ honeypotField: honeypot,
48
+ })
49
+ } catch (err) {
50
+ return fail(err)
51
+ }
52
+ },
53
+
54
+ submit: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
55
+ try {
56
+ const handle = rawParams.handle
57
+ if (!handle) throw new ValidationError('handle required')
58
+ const form = await forms.findByHandle(handle)
59
+ if (!form || !form.enabled || !form.definition.settings.enabled) {
60
+ throw new NotFoundError('form not found')
61
+ }
62
+
63
+ const def = form.definition
64
+ const honeypot = def.settings.honeypotField ?? '_hp'
65
+ let body = await request.json().catch(() => ({})) as Record<string, unknown>
66
+ const ip = request.headers.get('cf-connecting-ip')
67
+ ?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
68
+ ?? '0.0.0.0'
69
+
70
+ const beforeSubmit = await runFormBeforeSubmitHooks({
71
+ request,
72
+ form: def,
73
+ payload: body,
74
+ ip,
75
+ headers: request.headers,
76
+ }, options.env)
77
+ if (beforeSubmit.action === 'drop') {
78
+ return ok({
79
+ ok: true,
80
+ message: def.settings.successMessage ?? 'Thank you!',
81
+ })
82
+ }
83
+ body = beforeSubmit.payload
84
+
85
+ const hp = body[honeypot]
86
+ if (hp !== undefined && hp !== null && String(hp).length > 0) {
87
+ return ok({
88
+ ok: true,
89
+ message: def.settings.successMessage ?? 'Thank you!',
90
+ })
91
+ }
92
+
93
+ const rate = def.settings.rateLimit ?? { maxPerIp: 10, windowSec: 3600 }
94
+ const rl = await checkRateLimit(db, handle, await hashIp(ip), rate)
95
+ if (!rl.allowed) {
96
+ return new Response(JSON.stringify({
97
+ ok: false,
98
+ error: { code: 'RATE_LIMIT', message: 'Too many submissions' },
99
+ }), {
100
+ status: 429,
101
+ headers: {
102
+ 'content-type': 'application/json',
103
+ ...(rl.retryAfterSec ? { 'retry-after': String(rl.retryAfterSec) } : {}),
104
+ },
105
+ })
106
+ }
107
+
108
+ const { schema, uniqueFields, inputFields } = compileForm(def)
109
+ const parsed = schema.safeParse(body)
110
+ if (!parsed.success) {
111
+ throw new ValidationError('Invalid submission', { issues: parsed.error.issues })
112
+ }
113
+
114
+ const payload = parsed.data as Record<string, unknown>
115
+ const fileRefs: Array<{ field: string; mediaId: string }> = []
116
+
117
+ for (const field of inputFields) {
118
+ if (field.ui.kind !== 'file') continue
119
+ const mediaId = payload[field.name]
120
+ if (typeof mediaId !== 'string') continue
121
+ const draft = await drafts.findValid(handle, field.name, mediaId)
122
+ if (!draft) throw new ValidationError(`Invalid or expired file for field "${field.name}"`)
123
+ fileRefs.push({ field: field.name, mediaId })
124
+ await drafts.attachToSubmission(draft.id)
125
+ }
126
+
127
+ const submission = await submissions.create({
128
+ formHandle: handle,
129
+ payload,
130
+ fileRefs,
131
+ meta: {
132
+ ip,
133
+ ...(request.headers.get('user-agent') ? { userAgent: request.headers.get('user-agent')! } : {}),
134
+ ...(request.headers.get('referer') ? { referer: request.headers.get('referer')! } : {}),
135
+ },
136
+ })
137
+
138
+ try {
139
+ await insertUniqueValues(db, handle, submission.id, payload, uniqueFields)
140
+ } catch (err) {
141
+ await submissions.delete(submission.id)
142
+ throw err
143
+ }
144
+
145
+ await enqueueFormProcess(options.queue, submission.id)
146
+ await runFormAfterSubmitHooks({
147
+ request,
148
+ form: def,
149
+ payload,
150
+ submission,
151
+ ip,
152
+ headers: request.headers,
153
+ }, options.env)
154
+
155
+ if (def.settings.redirectTo) {
156
+ return ok({ ok: true, redirect: def.settings.redirectTo })
157
+ }
158
+ return ok({
159
+ ok: true,
160
+ message: def.settings.successMessage ?? 'Thank you!',
161
+ submissionId: submission.id,
162
+ })
163
+ } catch (err) {
164
+ return fail(err)
165
+ }
166
+ },
167
+ }
168
+ }
@@ -0,0 +1,100 @@
1
+ import type { VulseDb } from '../../core/db.js'
2
+ import { FormsRepo, FormUploadDraftsRepo } from '../../core/repos/forms.js'
3
+ import { MediaRepo } from '../../core/repos/media.js'
4
+ import { NotFoundError, ValidationError } from '../../core/errors.js'
5
+ import { fail, ok } from '../envelope.js'
6
+ import { putToR2 } from '../r2.js'
7
+
8
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024
9
+ const BLOCKED_MIME = new Set([
10
+ 'application/x-msdownload',
11
+ 'application/x-executable',
12
+ 'application/vnd.microsoft.portable-executable',
13
+ ])
14
+
15
+ export interface FormUploadEnv {
16
+ bucket: R2Bucket
17
+ }
18
+
19
+ export function formUploadRoutes(db: VulseDb, mediaEnv: FormUploadEnv) {
20
+ const forms = new FormsRepo(db)
21
+ const drafts = new FormUploadDraftsRepo(db)
22
+ const media = new MediaRepo(db)
23
+
24
+ return {
25
+ upload: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
26
+ try {
27
+ const handle = rawParams.handle
28
+ if (!handle) throw new ValidationError('handle required')
29
+ const form = await forms.findByHandle(handle)
30
+ if (!form || !form.enabled) throw new NotFoundError('form not found')
31
+
32
+ const formData = await request.formData()
33
+ const field = formData.get('field')
34
+ const fileEntry = formData.get('file')
35
+ if (typeof field !== 'string' || !field) throw new ValidationError('field required')
36
+ if (!fileEntry || typeof fileEntry === 'string') throw new ValidationError('file required')
37
+
38
+ const file = fileEntry as File
39
+ const fieldDef = form.definition.fields.find((f) => f.name === field)
40
+ if (!fieldDef || fieldDef.ui.kind !== 'file') {
41
+ throw new ValidationError(`Unknown file field "${field}"`)
42
+ }
43
+
44
+ const maxBytes = fieldDef.ui.maxBytes ?? DEFAULT_MAX_BYTES
45
+ if (file.size > maxBytes) throw new ValidationError(`File too large (max ${maxBytes} bytes)`)
46
+ if (BLOCKED_MIME.has(file.type)) throw new ValidationError(`File type not allowed: ${file.type}`)
47
+
48
+ if (fieldDef.ui.accept?.length) {
49
+ const okMime = fieldDef.ui.accept.some((a) =>
50
+ a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : file.type === a,
51
+ )
52
+ if (!okMime) throw new ValidationError(`File type not allowed: ${file.type}`)
53
+ }
54
+
55
+ const buf = await file.arrayBuffer()
56
+ const { key } = await putToR2({ bucket: mediaEnv.bucket }, buf, file.type || 'application/octet-stream')
57
+ const row = await media.create({
58
+ r2Key: key,
59
+ mime: file.type || 'application/octet-stream',
60
+ size: file.size,
61
+ uploadedBy: null,
62
+ })
63
+
64
+ const ttlHours = form.definition.settings.uploadDraftTtlHours ?? 24
65
+ const expiresAt = new Date(Date.now() + ttlHours * 3600_000)
66
+ const draft = await drafts.create({
67
+ formHandle: handle,
68
+ fieldName: field,
69
+ mediaId: row.id,
70
+ expiresAt,
71
+ })
72
+
73
+ return ok({ mediaId: row.id, draftId: draft.id, expiresAt: expiresAt.toISOString() })
74
+ } catch (err) {
75
+ return fail(err)
76
+ }
77
+ },
78
+ }
79
+ }
80
+
81
+ export async function purgeExpiredFormUploadDrafts(
82
+ db: VulseDb,
83
+ bucket: R2Bucket,
84
+ ): Promise<{ purged: number }> {
85
+ const drafts = new FormUploadDraftsRepo(db)
86
+ const media = new MediaRepo(db)
87
+ const { deleteFromR2 } = await import('../r2.js')
88
+ const expired = await drafts.listExpired(new Date())
89
+ let purged = 0
90
+ for (const draft of expired) {
91
+ const row = await media.findById(draft.mediaId)
92
+ if (row) {
93
+ await deleteFromR2({ bucket }, row.r2Key)
94
+ await media.hardDelete(row.id)
95
+ }
96
+ await drafts.delete(draft.id)
97
+ purged++
98
+ }
99
+ return { purged }
100
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from '../better-auth.js'
3
+ import type { VulseDb } from '../../core/db.js'
4
+ import { FormsRepo, SubmissionsRepo } from '../../core/repos/forms.js'
5
+ import { FormDefinitionSchema } from '../../core/forms/definition.js'
6
+ import { NotFoundError } from '../../core/errors.js'
7
+ import { defineHandler } from '../handler.js'
8
+
9
+ const paramsHandle = z.object({ handle: z.string() })
10
+ const paramsHandleId = z.object({ handle: z.string(), id: z.string() })
11
+
12
+ export function formsRoutes(db: VulseDb, auth: Auth) {
13
+ const forms = new FormsRepo(db)
14
+ const submissions = new SubmissionsRepo(db)
15
+
16
+ return {
17
+ list: defineHandler(auth, { requireRole: ['admin', 'editor'] }, async () => forms.list()),
18
+
19
+ create: defineHandler(auth, {
20
+ body: FormDefinitionSchema,
21
+ requireRole: ['admin'],
22
+ }, async ({ body }) => forms.create(body)),
23
+
24
+ get: defineHandler(auth, {
25
+ params: paramsHandle,
26
+ requireRole: ['admin', 'editor'],
27
+ }, async ({ params }) => {
28
+ const row = await forms.findByHandle(params.handle)
29
+ if (!row) throw new NotFoundError('form not found')
30
+ return row
31
+ }),
32
+
33
+ update: defineHandler(auth, {
34
+ params: paramsHandle,
35
+ body: FormDefinitionSchema,
36
+ requireRole: ['admin'],
37
+ }, async ({ params, body }) => forms.update(params.handle, body)),
38
+
39
+ delete: defineHandler(auth, {
40
+ params: paramsHandle,
41
+ requireRole: ['admin'],
42
+ }, async ({ params }) => {
43
+ await forms.delete(params.handle)
44
+ return null
45
+ }),
46
+
47
+ listSubmissions: defineHandler(auth, {
48
+ params: paramsHandle,
49
+ requireRole: ['admin', 'editor'],
50
+ }, async ({ params, url }) => {
51
+ const limit = Number(url.searchParams.get('limit') ?? '50')
52
+ const offset = Number(url.searchParams.get('offset') ?? '0')
53
+ return submissions.list({
54
+ formHandle: params.handle,
55
+ limit: Number.isFinite(limit) ? limit : 50,
56
+ offset: Number.isFinite(offset) ? offset : 0,
57
+ })
58
+ }),
59
+
60
+ getSubmission: defineHandler(auth, {
61
+ params: paramsHandleId,
62
+ requireRole: ['admin', 'editor'],
63
+ }, async ({ params }) => {
64
+ const row = await submissions.findById(params.id)
65
+ if (!row || row.formHandle !== params.handle) throw new NotFoundError('submission not found')
66
+ return row
67
+ }),
68
+
69
+ deleteSubmission: defineHandler(auth, {
70
+ params: paramsHandleId,
71
+ requireRole: ['admin', 'editor'],
72
+ }, async ({ params }) => {
73
+ const row = await submissions.findById(params.id)
74
+ if (!row || row.formHandle !== params.handle) throw new NotFoundError('submission not found')
75
+ await submissions.delete(params.id)
76
+ return null
77
+ }),
78
+
79
+ bulkDeleteSubmissions: defineHandler(auth, {
80
+ params: paramsHandle,
81
+ body: z.object({ ids: z.array(z.string()).min(1) }),
82
+ requireRole: ['admin', 'editor'],
83
+ }, async ({ params, body }) => {
84
+ const deleted = await submissions.deleteMany(body.ids)
85
+ return { deleted }
86
+ }),
87
+ }
88
+ }
@@ -0,0 +1,30 @@
1
+ import type { VulseDb } from '../../core/db.js'
2
+ import { GlobalsRepo } from '../../core/repos/globals.js'
3
+ import { NotFoundError } from '../../core/errors.js'
4
+ import { fail, ok } from '../envelope.js'
5
+
6
+ export function globalsPublicRoutes(db: VulseDb) {
7
+ const globals = new GlobalsRepo(db)
8
+
9
+ return {
10
+ list: async (): Promise<Response> => {
11
+ try {
12
+ return ok(await globals.publicValues())
13
+ } catch (err) {
14
+ return fail(err)
15
+ }
16
+ },
17
+
18
+ get: async (_request: Request, rawParams: Record<string, string>): Promise<Response> => {
19
+ try {
20
+ const handle = rawParams.handle
21
+ if (!handle) throw new NotFoundError('global set not found')
22
+ const content = await globals.publicValue(handle)
23
+ if (content === null) throw new NotFoundError('global set not found')
24
+ return ok(content)
25
+ } catch (err) {
26
+ return fail(err)
27
+ }
28
+ },
29
+ }
30
+ }