@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,93 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from '../better-auth.js'
3
+ import type { VulseDb } from '../../core/db.js'
4
+ import { GlobalsRepo } from '../../core/repos/globals.js'
5
+ import { GlobalSetDefinitionSchema } from '../../core/globals/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
+
11
+ export function globalsRoutes(db: VulseDb, auth: Auth) {
12
+ const globals = new GlobalsRepo(db)
13
+
14
+ return {
15
+ list: defineHandler(auth, { requireRole: ['admin', 'editor'] }, async () => {
16
+ const rows = await globals.listSets()
17
+ return rows.map((row) => ({
18
+ handle: row.handle,
19
+ label: row.label,
20
+ fieldCount: row.definition.fields.length,
21
+ createdAt: row.createdAt,
22
+ updatedAt: row.updatedAt,
23
+ }))
24
+ }),
25
+
26
+ get: defineHandler(auth, {
27
+ params: paramsHandle,
28
+ requireRole: ['admin', 'editor'],
29
+ }, async ({ params }) => {
30
+ const set = await globals.findSetByHandle(params.handle)
31
+ if (!set) throw new NotFoundError('global set not found')
32
+ const value = await globals.getValue(params.handle)
33
+ return {
34
+ set: {
35
+ handle: set.handle,
36
+ label: set.label,
37
+ fields: set.definition.fields,
38
+ createdAt: set.createdAt,
39
+ updatedAt: set.updatedAt,
40
+ },
41
+ value: value ? {
42
+ handle: value.handle,
43
+ content: value.content,
44
+ createdAt: value.createdAt,
45
+ updatedAt: value.updatedAt,
46
+ } : null,
47
+ }
48
+ }),
49
+
50
+ create: defineHandler(auth, {
51
+ body: GlobalSetDefinitionSchema,
52
+ requireRole: ['admin'],
53
+ }, async ({ body }) => {
54
+ const row = await globals.createSet(body)
55
+ return {
56
+ handle: row.handle,
57
+ label: row.label,
58
+ fields: row.definition.fields,
59
+ createdAt: row.createdAt,
60
+ updatedAt: row.updatedAt,
61
+ }
62
+ }),
63
+
64
+ update: defineHandler(auth, {
65
+ params: paramsHandle,
66
+ body: GlobalSetDefinitionSchema,
67
+ requireRole: ['admin'],
68
+ }, async ({ params, body }) => {
69
+ const row = await globals.updateSet(params.handle, body)
70
+ return {
71
+ handle: row.handle,
72
+ label: row.label,
73
+ fields: row.definition.fields,
74
+ createdAt: row.createdAt,
75
+ updatedAt: row.updatedAt,
76
+ }
77
+ }),
78
+
79
+ updateValue: defineHandler(auth, {
80
+ params: paramsHandle,
81
+ body: z.record(z.string(), z.unknown()),
82
+ requireRole: ['admin'],
83
+ }, async ({ params, body }) => globals.updateValue(params.handle, body)),
84
+
85
+ delete: defineHandler(auth, {
86
+ params: paramsHandle,
87
+ requireRole: ['admin'],
88
+ }, async ({ params }) => {
89
+ await globals.deleteSet(params.handle)
90
+ return null
91
+ }),
92
+ }
93
+ }
@@ -0,0 +1,145 @@
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 { AuthContext, Role } from '../../core/blueprints/types.js'
5
+ import { MediaRepo, type MediaRow } from '../../core/repos/media.js'
6
+ import { defineHandler } from '../handler.js'
7
+ import { putToR2, deleteFromR2 } from '../r2.js'
8
+ import { probeDimensions } from '../image-probe.js'
9
+ import { buildDeliveryUrl, type CfImagesConfig } from '../cf-images.js'
10
+ import { AccessDeniedError, NotFoundError, ValidationError } from '../../core/errors.js'
11
+ import { fail, ok } from '../envelope.js'
12
+
13
+ export interface MediaEnv {
14
+ bucket: R2Bucket
15
+ cfImages: CfImagesConfig
16
+ }
17
+
18
+ export interface MediaItem extends MediaRow {
19
+ deliveryUrl: string | null
20
+ previewUrl: string
21
+ }
22
+
23
+ const paramsId = z.object({ id: z.string() })
24
+
25
+ const ALLOWED_MIME = new Set([
26
+ 'image/jpeg',
27
+ 'image/png',
28
+ 'image/gif',
29
+ 'image/webp',
30
+ 'image/avif',
31
+ 'image/svg+xml',
32
+ ])
33
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024 // 25 MB
34
+
35
+ export function mediaRoutes(db: VulseDb, auth: Auth, mediaEnv: MediaEnv) {
36
+ const repo = new MediaRepo(db)
37
+
38
+ function withUrls(row: MediaRow): MediaItem {
39
+ const deliveryUrl = buildDeliveryUrl(mediaEnv.cfImages, row.id)
40
+ return {
41
+ ...row,
42
+ deliveryUrl,
43
+ previewUrl: deliveryUrl ?? `/api/vulse/media/${row.id}/file`,
44
+ }
45
+ }
46
+
47
+ async function requireRole(request: Request, roles: Role[]): Promise<AuthContext> {
48
+ const session = await auth.api.getSession({ headers: request.headers })
49
+ const authCtx: AuthContext = session ? {
50
+ user: {
51
+ id: session.user.id,
52
+ email: session.user.email,
53
+ role: (session.user as { role?: Role }).role ?? 'member',
54
+ },
55
+ } : { user: null }
56
+ if (!authCtx.user) throw new AccessDeniedError('Authentication required')
57
+ if (!roles.includes(authCtx.user.role)) {
58
+ throw new AccessDeniedError(`Requires role: ${roles.join(' or ')}`)
59
+ }
60
+ return authCtx
61
+ }
62
+
63
+ return {
64
+ list: defineHandler(auth, { requireRole: ['admin', 'editor'] }, async () => {
65
+ return (await repo.list({})).map(withUrls)
66
+ }),
67
+
68
+ upload: async (request: Request): Promise<Response> => {
69
+ try {
70
+ const authCtx = await requireRole(request, ['admin', 'editor'])
71
+ const form = await request.formData()
72
+ const entry = form.get('file')
73
+ if (!entry || typeof entry === 'string') throw new ValidationError('file required')
74
+ const file = entry as File
75
+ if (!ALLOWED_MIME.has(file.type)) {
76
+ throw new ValidationError(`Unsupported file type: ${file.type || 'unknown'}`)
77
+ }
78
+ if (file.size > MAX_UPLOAD_BYTES) {
79
+ throw new ValidationError(`File too large (max ${MAX_UPLOAD_BYTES} bytes)`)
80
+ }
81
+ const buf = await file.arrayBuffer()
82
+ if (buf.byteLength > MAX_UPLOAD_BYTES) {
83
+ throw new ValidationError(`File too large (max ${MAX_UPLOAD_BYTES} bytes)`)
84
+ }
85
+ const dims = probeDimensions(buf, file.type)
86
+ const { key } = await putToR2({ bucket: mediaEnv.bucket }, buf, file.type)
87
+ const row = await repo.create({
88
+ r2Key: key,
89
+ mime: file.type,
90
+ size: file.size,
91
+ ...(dims?.width !== undefined ? { width: dims.width } : {}),
92
+ ...(dims?.height !== undefined ? { height: dims.height } : {}),
93
+ uploadedBy: authCtx.user!.id,
94
+ })
95
+ return ok(withUrls(row))
96
+ } catch (err) {
97
+ return fail(err)
98
+ }
99
+ },
100
+
101
+ updateAlt: defineHandler(auth, {
102
+ params: paramsId,
103
+ body: z.object({ alt: z.string() }),
104
+ requireRole: ['admin', 'editor'],
105
+ }, async ({ params, body }) => {
106
+ await repo.updateAlt(params.id, body.alt)
107
+ return { ok: true }
108
+ }),
109
+
110
+ delete: defineHandler(auth, {
111
+ params: paramsId,
112
+ requireRole: ['admin', 'editor'],
113
+ }, async ({ params }) => {
114
+ await repo.softDelete(params.id)
115
+ return { ok: true }
116
+ }),
117
+
118
+ file: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
119
+ try {
120
+ await requireRole(request, ['admin', 'editor'])
121
+ const id = rawParams.id
122
+ if (!id) throw new ValidationError('id required')
123
+ const row = await repo.findById(id)
124
+ if (!row || row.deletedAt) throw new NotFoundError(`Media ${id} not found`)
125
+ const obj = await mediaEnv.bucket.get(row.r2Key)
126
+ if (!obj) throw new NotFoundError(`Media file ${id} not found`)
127
+ const headers = new Headers()
128
+ if (obj.httpMetadata?.contentType) headers.set('content-type', obj.httpMetadata.contentType)
129
+ headers.set('cache-control', 'private, max-age=3600')
130
+ return new Response(obj.body, { headers })
131
+ } catch (err) {
132
+ return fail(err)
133
+ }
134
+ },
135
+
136
+ purge: async (): Promise<{ purged: number }> => {
137
+ const rows = await repo.listPurgeable(7)
138
+ for (const r of rows) {
139
+ await deleteFromR2({ bucket: mediaEnv.bucket }, r.r2Key)
140
+ await repo.hardDelete(r.id)
141
+ }
142
+ return { purged: rows.length }
143
+ },
144
+ }
145
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from '../better-auth.js'
3
+ import type { VulseDb } from '../../core/db.js'
4
+ import type { BlueprintRegistry } from '../../core/blueprints/registry.js'
5
+ import { defineHandler } from '../handler.js'
6
+ import { resolvePreviewPath } from '../../core/blueprints/preview-path.js'
7
+ import { PreviewSessionsRepo } from '../../core/repos/preview-sessions.js'
8
+ import { AccessDeniedError, NotFoundError } from '../../core/errors.js'
9
+
10
+ function buildPreviewUrl(origin: string, pathTemplate: string, slug: string, token: string): string {
11
+ const path = pathTemplate.replace('{slug}', encodeURIComponent(slug))
12
+ const url = new URL(path, origin)
13
+ url.searchParams.set('vulse_live_preview', token)
14
+ return url.toString()
15
+ }
16
+
17
+ export function previewSessionsRoutes(db: VulseDb, auth: Auth, registry: BlueprintRegistry) {
18
+ const repo = new PreviewSessionsRepo(db)
19
+
20
+ return {
21
+ create: defineHandler(auth, {
22
+ requireRole: ['admin', 'editor'],
23
+ body: z.object({
24
+ collection: z.string(),
25
+ entryId: z.string().nullable().optional(),
26
+ slug: z.string(),
27
+ content: z.record(z.string(), z.unknown()),
28
+ locale: z.string().optional(),
29
+ }),
30
+ }, async ({ auth: ctx, body, url }) => {
31
+ const bp = registry.get(body.collection)
32
+ if (!bp) throw new NotFoundError(`Collection ${body.collection} not found`)
33
+ const row = await repo.create({
34
+ userId: ctx.user!.id,
35
+ collection: body.collection,
36
+ slug: body.slug,
37
+ content: body.content,
38
+ entryId: body.entryId ?? null,
39
+ ...(body.locale !== undefined ? { locale: body.locale } : {}),
40
+ })
41
+ const previewPath = resolvePreviewPath(bp)
42
+ return {
43
+ id: row.id,
44
+ previewUrl: buildPreviewUrl(url.origin, previewPath, row.slug, row.id),
45
+ expiresAt: row.expiresAt.toISOString(),
46
+ }
47
+ }),
48
+
49
+ update: defineHandler(auth, {
50
+ requireRole: ['admin', 'editor'],
51
+ params: z.object({ id: z.string() }),
52
+ body: z.object({
53
+ slug: z.string().optional(),
54
+ content: z.record(z.string(), z.unknown()).optional(),
55
+ locale: z.string().optional(),
56
+ }),
57
+ }, async ({ auth: ctx, params, body }) => {
58
+ const patch: { slug?: string; content?: unknown; locale?: string } = {}
59
+ if (body.slug !== undefined) patch.slug = body.slug
60
+ if (body.content !== undefined) patch.content = body.content
61
+ if (body.locale !== undefined) patch.locale = body.locale
62
+ const updated = await repo.update(params.id, ctx.user!.id, patch)
63
+ if (!updated) throw new AccessDeniedError('Session not found or not owned by you')
64
+ return { expiresAt: updated.expiresAt.toISOString() }
65
+ }),
66
+
67
+ remove: defineHandler(auth, {
68
+ requireRole: ['admin', 'editor'],
69
+ params: z.object({ id: z.string() }),
70
+ }, async ({ auth: ctx, params }) => {
71
+ const ok = await repo.delete(params.id, ctx.user!.id)
72
+ if (!ok) throw new NotFoundError('Session not found')
73
+ return { ok: true }
74
+ }),
75
+ }
76
+ }
@@ -0,0 +1,36 @@
1
+ import type { Auth } from '../better-auth.js'
2
+ import { defineHandler } from '../handler.js'
3
+ import { mintPreviewToken } from '../preview.js'
4
+
5
+ function safePreviewTarget(raw: string | null, origin: string): URL {
6
+ const fallback = new URL('/', origin)
7
+ if (!raw) return fallback
8
+ // Reject anything that could escape origin: protocol-relative URLs (//evil.com)
9
+ // and any input that isn't a single leading "/" path.
10
+ if (!raw.startsWith('/') || raw.startsWith('//')) return fallback
11
+ try {
12
+ const candidate = new URL(raw, origin)
13
+ if (candidate.origin !== new URL(origin).origin) return fallback
14
+ return candidate
15
+ } catch {
16
+ return fallback
17
+ }
18
+ }
19
+
20
+ export function previewRoutes(auth: Auth, secret: string) {
21
+ return {
22
+ start: defineHandler(auth, { requireRole: ['admin', 'editor'] }, async ({ auth: authCtx, url }) => {
23
+ const token = await mintPreviewToken(secret, authCtx.user!.id)
24
+ const secure = url.protocol === 'https:'
25
+ const cookie = `vulse_preview=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600${secure ? '; Secure' : ''}`
26
+ const redirect = safePreviewTarget(url.searchParams.get('to'), url.origin)
27
+ return new Response(null, { status: 302, headers: { Location: redirect.toString(), 'Set-Cookie': cookie } })
28
+ }),
29
+ stop: defineHandler(auth, {}, async ({ url }) => {
30
+ return new Response(null, {
31
+ status: 302,
32
+ headers: { Location: new URL('/', url.origin).toString(), 'Set-Cookie': 'vulse_preview=; Path=/; Max-Age=0' },
33
+ })
34
+ }),
35
+ }
36
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'astro/zod'
2
+ import type { VulseDb } from '../../core/db.js'
3
+ import type { Auth } from '../better-auth.js'
4
+ import { RevisionsRepo } from '../../core/repos/revisions.js'
5
+ import { resolveLocale } from '../../core/locales.js'
6
+ import { defineHandler } from '../handler.js'
7
+
8
+ export function revisionsRoutes(db: VulseDb, auth: Auth) {
9
+ const repo = new RevisionsRepo(db)
10
+ return {
11
+ list: defineHandler(auth, {
12
+ params: z.object({ collection: z.string(), id: z.string() }),
13
+ requireRole: ['admin', 'editor'],
14
+ }, async ({ params, url }) => {
15
+ const locale = await resolveLocale(db, url.searchParams.get('locale'))
16
+ return await repo.listByEntry(params.id, locale)
17
+ }),
18
+
19
+ restore: defineHandler(auth, {
20
+ params: z.object({ collection: z.string(), id: z.string(), version: z.string() }),
21
+ requireRole: ['admin', 'editor'],
22
+ }, async ({ params, url, auth: authCtx }) => {
23
+ if (!authCtx.user) throw new Error('unreachable')
24
+ const locale = await resolveLocale(db, url.searchParams.get('locale'))
25
+ await repo.restore(params.id, Number(params.version), { userId: authCtx.user.id, locale })
26
+ return { restored: Number(params.version) }
27
+ }),
28
+ }
29
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from 'astro/zod'
2
+ import type { VulseDb } from '../../core/db.js'
3
+ import type { Auth } from '../better-auth.js'
4
+ import { defineHandler } from '../handler.js'
5
+ import { searchSdk } from '../sdk/search.js'
6
+
7
+ export function searchRoutes(db: VulseDb, auth: Auth) {
8
+ const sdk = searchSdk(db)
9
+ return {
10
+ query: defineHandler(auth, {
11
+ params: z.object({}),
12
+ body: z.object({
13
+ q: z.string(),
14
+ collections: z.array(z.string()).optional(),
15
+ limit: z.number().optional(),
16
+ includeDrafts: z.boolean().optional(),
17
+ locale: z.string().optional(),
18
+ }),
19
+ }, async ({ body, auth: authCtx }) => {
20
+ const role = authCtx.user?.role
21
+ const mayReadDrafts = role === 'admin' || role === 'editor'
22
+ const includeDrafts = body.includeDrafts === true && mayReadDrafts
23
+ return sdk.query(body.q, {
24
+ ...(body.collections !== undefined ? { collections: body.collections } : {}),
25
+ ...(body.limit !== undefined ? { limit: body.limit } : {}),
26
+ ...(body.locale !== undefined ? { locale: body.locale } : {}),
27
+ includeDrafts,
28
+ })
29
+ }),
30
+ }
31
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'astro/zod'
2
+ import type { Auth } from '../better-auth.js'
3
+ import type { VulseDb } from '../../core/db.js'
4
+ import { SetDefinitionSchema } from '../../core/sets/definition.js'
5
+ import { createSet, deleteSet, getSet, listSets, updateSet } from '../../core/sets/service.js'
6
+ import { NotFoundError } from '../../core/errors.js'
7
+ import { defineHandler } from '../handler.js'
8
+
9
+ const paramsHandle = z.object({ handle: z.string() })
10
+
11
+ export function setsRoutes(db: VulseDb, auth: Auth) {
12
+ return {
13
+ list: defineHandler(auth, {}, async () => listSets(db)),
14
+
15
+ get: defineHandler(auth, { params: paramsHandle }, async ({ params }) => {
16
+ const row = await getSet(db, params.handle)
17
+ if (!row) throw new NotFoundError('set not found')
18
+ return row
19
+ }),
20
+
21
+ create: defineHandler(auth, {
22
+ body: SetDefinitionSchema,
23
+ requireRole: ['admin'],
24
+ }, async ({ body }) => createSet(db, body)),
25
+
26
+ update: defineHandler(auth, {
27
+ params: paramsHandle,
28
+ body: SetDefinitionSchema,
29
+ requireRole: ['admin'],
30
+ }, async ({ params, body }) => updateSet(db, params.handle, body)),
31
+
32
+ delete: defineHandler(auth, {
33
+ params: paramsHandle,
34
+ requireRole: ['admin'],
35
+ }, async ({ params }) => {
36
+ await deleteSet(db, params.handle)
37
+ return null
38
+ }),
39
+ }
40
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'astro/zod'
2
+ import type { VulseDb } from '../../core/db.js'
3
+ import type { Auth } from '../better-auth.js'
4
+ import { defineHandler } from '../handler.js'
5
+ import { SettingsRepo } from '../../core/repos/settings.js'
6
+ import { invalidateRuntime } from '../runtime.js'
7
+
8
+ const AUTH_SETTING_KEYS = new Set(['allowMemberSignUp', 'allowedSignUpDomains'])
9
+
10
+ export function settingsRoutes(db: VulseDb, auth: Auth) {
11
+ const repo = new SettingsRepo(db)
12
+ return {
13
+ list: defineHandler(auth, { requireRole: ['admin'] }, async () => await repo.all()),
14
+ set: defineHandler(auth, {
15
+ params: z.object({ key: z.string() }),
16
+ body: z.object({ value: z.unknown() }),
17
+ requireRole: ['admin'],
18
+ }, async ({ params, body }) => {
19
+ await repo.set(params.key, body.value)
20
+ if (AUTH_SETTING_KEYS.has(params.key)) invalidateRuntime()
21
+ return { key: params.key }
22
+ }),
23
+ }
24
+ }
@@ -0,0 +1,127 @@
1
+ import { z } from 'astro/zod'
2
+ import { and, eq, like, or } from 'drizzle-orm'
3
+ import { hashPassword } from 'better-auth/crypto'
4
+ import { nanoid } from 'nanoid'
5
+ import type { VulseDb } from '../../core/db.js'
6
+ import type { Auth } from '../better-auth.js'
7
+ import { account as accountTable, user as userTable } from '../../core/schema.js'
8
+ import { defineHandler } from '../handler.js'
9
+ import { NotFoundError } from '../../core/errors.js'
10
+
11
+ const userFields = {
12
+ id: userTable.id,
13
+ email: userTable.email,
14
+ name: userTable.name,
15
+ role: userTable.role,
16
+ displayName: userTable.displayName,
17
+ emailVerified: userTable.emailVerified,
18
+ createdAt: userTable.createdAt,
19
+ updatedAt: userTable.updatedAt,
20
+ }
21
+
22
+ export function usersRoutes(db: VulseDb, auth: Auth) {
23
+ return {
24
+ list: defineHandler(auth, { requireRole: ['admin'] }, async ({ url }) => {
25
+ const q = url.searchParams.get('q')?.trim()
26
+ if (q) {
27
+ const pattern = `%${q}%`
28
+ return await db.select(userFields).from(userTable).where(or(
29
+ like(userTable.email, pattern),
30
+ like(userTable.name, pattern),
31
+ like(userTable.displayName, pattern),
32
+ eq(userTable.id, q),
33
+ ))
34
+ }
35
+ return await db.select(userFields).from(userTable)
36
+ }),
37
+
38
+ get: defineHandler(auth, {
39
+ params: z.object({ id: z.string() }),
40
+ requireRole: ['admin'],
41
+ }, async ({ params }) => {
42
+ const rows = await db.select(userFields).from(userTable).where(eq(userTable.id, params.id))
43
+ if (!rows.length) throw new NotFoundError(`User ${params.id} not found`)
44
+ return rows[0]
45
+ }),
46
+
47
+ update: defineHandler(auth, {
48
+ params: z.object({ id: z.string() }),
49
+ body: z.object({
50
+ name: z.string().min(1).optional(),
51
+ displayName: z.string().nullable().optional(),
52
+ role: z.enum(['admin', 'editor', 'member']).optional(),
53
+ }),
54
+ requireRole: ['admin'],
55
+ }, async ({ params, body }) => {
56
+ const existing = await db.select({ id: userTable.id }).from(userTable).where(eq(userTable.id, params.id))
57
+ if (!existing.length) throw new NotFoundError(`User ${params.id} not found`)
58
+
59
+ const patch: Partial<typeof userTable.$inferInsert> = { updatedAt: new Date() }
60
+ if (body.name !== undefined) patch.name = body.name
61
+ if (body.displayName !== undefined) patch.displayName = body.displayName
62
+ if (body.role !== undefined) patch.role = body.role
63
+
64
+ await db.update(userTable).set(patch).where(eq(userTable.id, params.id))
65
+ const rows = await db.select(userFields).from(userTable).where(eq(userTable.id, params.id))
66
+ return rows[0]
67
+ }),
68
+
69
+ setRole: defineHandler(auth, {
70
+ params: z.object({ id: z.string() }),
71
+ body: z.object({ role: z.enum(['admin', 'editor', 'member']) }),
72
+ requireRole: ['admin'],
73
+ }, async ({ params, body }) => {
74
+ const existing = await db.select({ id: userTable.id }).from(userTable).where(eq(userTable.id, params.id))
75
+ if (!existing.length) throw new NotFoundError(`User ${params.id} not found`)
76
+ await db.update(userTable).set({ role: body.role, updatedAt: new Date() }).where(eq(userTable.id, params.id))
77
+ return { id: params.id, role: body.role }
78
+ }),
79
+
80
+ resetPassword: defineHandler(auth, {
81
+ params: z.object({ id: z.string() }),
82
+ body: z.discriminatedUnion('action', [
83
+ z.object({ action: z.literal('email'), redirectTo: z.string().optional() }),
84
+ z.object({ action: z.literal('set'), password: z.string().min(8) }),
85
+ ]),
86
+ requireRole: ['admin'],
87
+ }, async ({ params, body, request }) => {
88
+ const rows = await db.select({ id: userTable.id, email: userTable.email }).from(userTable).where(eq(userTable.id, params.id))
89
+ if (!rows.length) throw new NotFoundError(`User ${params.id} not found`)
90
+ const target = rows[0]!
91
+
92
+ if (body.action === 'email') {
93
+ await auth.api.requestPasswordReset({
94
+ body: {
95
+ email: target.email,
96
+ redirectTo: body.redirectTo ?? '/reset-password',
97
+ },
98
+ headers: request.headers,
99
+ })
100
+ return { action: 'email' as const, sent: true }
101
+ }
102
+
103
+ const hashedPassword = await hashPassword(body.password)
104
+ const accounts = await db.select({ id: accountTable.id }).from(accountTable).where(and(
105
+ eq(accountTable.userId, params.id),
106
+ eq(accountTable.providerId, 'credential'),
107
+ ))
108
+
109
+ const now = new Date()
110
+ if (accounts.length) {
111
+ await db.update(accountTable).set({ password: hashedPassword, updatedAt: now }).where(eq(accountTable.id, accounts[0]!.id))
112
+ } else {
113
+ await db.insert(accountTable).values({
114
+ id: nanoid(),
115
+ userId: params.id,
116
+ accountId: params.id,
117
+ providerId: 'credential',
118
+ password: hashedPassword,
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ })
122
+ }
123
+
124
+ return { action: 'set' as const, updated: true }
125
+ }),
126
+ }
127
+ }