@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.
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -4
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +11 -10
- package/dist/core/blueprints/compile.d.ts +1 -1
- package/dist/core/blueprints/compile.d.ts.map +1 -1
- package/dist/core/blueprints/compile.js +3 -3
- package/dist/core/blueprints/mutations.js +2 -2
- package/dist/core/forms/rate-limit.d.ts +1 -1
- package/dist/core/forms/rate-limit.d.ts.map +1 -1
- package/dist/core/forms/rate-limit.js +3 -3
- package/dist/core/forms/unique.d.ts +1 -1
- package/dist/core/forms/unique.d.ts.map +1 -1
- package/dist/core/forms/unique.js +4 -4
- package/dist/core/globals/compile.d.ts +1 -1
- package/dist/core/globals/compile.d.ts.map +1 -1
- package/dist/core/globals/compile.js +2 -2
- package/dist/core/globals/definition.d.ts +1 -1
- package/dist/core/globals/definition.d.ts.map +1 -1
- package/dist/core/globals/definition.js +3 -3
- package/dist/core/repos/globals.js +3 -3
- package/dist/core/sha256.d.ts +3 -0
- package/dist/core/sha256.d.ts.map +1 -0
- package/dist/core/sha256.js +9 -0
- package/dist/integration/index.js +1 -1
- package/dist/integration/install-hook.d.ts.map +1 -1
- package/dist/integration/install-hook.js +6 -4
- package/dist/integration/wrangler-config.d.ts +12 -0
- package/dist/integration/wrangler-config.d.ts.map +1 -0
- package/dist/integration/wrangler-config.js +97 -0
- package/dist/integration/wrangler-patch.d.ts +1 -0
- package/dist/integration/wrangler-patch.d.ts.map +1 -1
- package/dist/integration/wrangler-patch.js +2 -1
- package/dist/server/routes/form-submit.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/src/admin/assets/logo-mark.svg +5 -0
- package/src/admin/client/active-locale.ts +17 -0
- package/src/admin/client/api.ts +21 -0
- package/src/admin/client/form-from-zod.ts +7 -0
- package/src/admin/client/live-preview-enabled.ts +5 -0
- package/src/admin/components/AdminShell.astro +45 -0
- package/src/admin/components/AuthSettings.vue +60 -0
- package/src/admin/components/BlockEditor.vue +53 -0
- package/src/admin/components/BlueprintEditor.vue +1783 -0
- package/src/admin/components/CollectionKindIcon.vue +26 -0
- package/src/admin/components/CollectionTree.vue +220 -0
- package/src/admin/components/EntryEditorWithPreview.vue +130 -0
- package/src/admin/components/EntryForm.vue +411 -0
- package/src/admin/components/EntryList.vue +121 -0
- package/src/admin/components/EntryStatusBadge.vue +24 -0
- package/src/admin/components/FormEditor.vue +233 -0
- package/src/admin/components/FormList.vue +54 -0
- package/src/admin/components/GlobalSetEditor.vue +272 -0
- package/src/admin/components/GlobalSetList.vue +55 -0
- package/src/admin/components/LivePreviewPanel.vue +171 -0
- package/src/admin/components/LoginForm.vue +53 -0
- package/src/admin/components/MediaLibrary.vue +106 -0
- package/src/admin/components/MediaPicker.vue +49 -0
- package/src/admin/components/RevisionDiff.vue +11 -0
- package/src/admin/components/RevisionList.vue +134 -0
- package/src/admin/components/SeoFields.vue +113 -0
- package/src/admin/components/SetEditor.vue +137 -0
- package/src/admin/components/SetList.vue +32 -0
- package/src/admin/components/SettingsForm.vue +189 -0
- package/src/admin/components/SideNav.vue +152 -0
- package/src/admin/components/SubmissionDetail.vue +45 -0
- package/src/admin/components/SubmissionList.vue +89 -0
- package/src/admin/components/ToastHost.vue +33 -0
- package/src/admin/components/TreeRow.vue +163 -0
- package/src/admin/components/UserEditor.vue +186 -0
- package/src/admin/components/UserList.vue +46 -0
- package/src/admin/components/blocks/BlockItem.vue +32 -0
- package/src/admin/components/blocks/BlockToolbar.vue +12 -0
- package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
- package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
- package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
- package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
- package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
- package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
- package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
- package/src/admin/components/fields/BlocksField.vue +123 -0
- package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
- package/src/admin/components/fields/BoolField.vue +10 -0
- package/src/admin/components/fields/DateField.vue +22 -0
- package/src/admin/components/fields/EntriesField.vue +153 -0
- package/src/admin/components/fields/EntryField.vue +138 -0
- package/src/admin/components/fields/EnumField.vue +81 -0
- package/src/admin/components/fields/FieldRenderer.vue +87 -0
- package/src/admin/components/fields/GridField.vue +173 -0
- package/src/admin/components/fields/LinkField.vue +219 -0
- package/src/admin/components/fields/MediaField.vue +69 -0
- package/src/admin/components/fields/NumberField.vue +12 -0
- package/src/admin/components/fields/ObjectField.vue +18 -0
- package/src/admin/components/fields/RefField.vue +170 -0
- package/src/admin/components/fields/RepeaterField.vue +27 -0
- package/src/admin/components/fields/ReplicatorField.vue +121 -0
- package/src/admin/components/fields/TextField.vue +11 -0
- package/src/admin/components/fields/TextareaField.vue +11 -0
- package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
- package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
- package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
- package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
- package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
- package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
- package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
- package/src/admin/components/fields/emoji-extension.ts +54 -0
- package/src/admin/components/fields/link-extension.ts +48 -0
- package/src/admin/components/fields/set-node-utils.ts +115 -0
- package/src/admin/components/fields/url-utils.ts +85 -0
- package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
- package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
- package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
- package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
- package/src/admin/components/fields/vulse-set-extension.ts +66 -0
- package/src/admin/components/fields/vulse-video-extension.ts +65 -0
- package/src/admin/composables/toast.ts +35 -0
- package/src/admin/composables/useEntrySearch.ts +112 -0
- package/src/admin/composables/useSets.ts +31 -0
- package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
- package/src/admin/pages/collections/[name]/[id].astro +90 -0
- package/src/admin/pages/collections/[name]/index.astro +31 -0
- package/src/admin/pages/collections/[name]/new.astro +38 -0
- package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
- package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
- package/src/admin/pages/forms/[handle].astro +9 -0
- package/src/admin/pages/forms/index.astro +7 -0
- package/src/admin/pages/forms/new.astro +7 -0
- package/src/admin/pages/index.astro +36 -0
- package/src/admin/pages/login.astro +14 -0
- package/src/admin/pages/media.astro +8 -0
- package/src/admin/pages/schema/[handle].astro +10 -0
- package/src/admin/pages/schema/new.astro +9 -0
- package/src/admin/pages/settings/auth.astro +9 -0
- package/src/admin/pages/settings/globals/[handle].astro +9 -0
- package/src/admin/pages/settings/globals/index.astro +7 -0
- package/src/admin/pages/settings/globals/new.astro +7 -0
- package/src/admin/pages/settings/index.astro +8 -0
- package/src/admin/pages/settings/sets/[handle].astro +9 -0
- package/src/admin/pages/settings/sets/index.astro +7 -0
- package/src/admin/pages/settings/sets/new.astro +7 -0
- package/src/admin/pages/users/[id].astro +10 -0
- package/src/admin/pages/users/index.astro +8 -0
- package/src/admin/styles/admin.css +166 -0
- package/src/core/access.ts +9 -0
- package/src/core/blocks/schema.ts +66 -0
- package/src/core/blueprints/code-to-definition.ts +156 -0
- package/src/core/blueprints/compile.ts +176 -0
- package/src/core/blueprints/define.ts +12 -0
- package/src/core/blueprints/definition.ts +185 -0
- package/src/core/blueprints/load.ts +144 -0
- package/src/core/blueprints/mutations.ts +236 -0
- package/src/core/blueprints/preview-path.ts +33 -0
- package/src/core/blueprints/reflect-fields.ts +305 -0
- package/src/core/blueprints/registry.ts +14 -0
- package/src/core/blueprints/seed.ts +20 -0
- package/src/core/blueprints/select-helpers.ts +30 -0
- package/src/core/blueprints/seo.ts +180 -0
- package/src/core/blueprints/types.ts +59 -0
- package/src/core/blueprints/zod-helpers.ts +86 -0
- package/src/core/db.ts +11 -0
- package/src/core/errors.ts +34 -0
- package/src/core/forms/compile.ts +84 -0
- package/src/core/forms/definition.ts +102 -0
- package/src/core/forms/rate-limit.ts +52 -0
- package/src/core/forms/unique.ts +38 -0
- package/src/core/globals/compile.ts +35 -0
- package/src/core/globals/definition.ts +27 -0
- package/src/core/locales.ts +45 -0
- package/src/core/migrations.ts +48 -0
- package/src/core/parse-content.ts +85 -0
- package/src/core/plugins/definition.ts +150 -0
- package/src/core/preview-content.ts +21 -0
- package/src/core/repos/entries.ts +504 -0
- package/src/core/repos/forms.ts +270 -0
- package/src/core/repos/globals.ts +179 -0
- package/src/core/repos/media.ts +106 -0
- package/src/core/repos/preview-sessions.ts +108 -0
- package/src/core/repos/revisions.ts +60 -0
- package/src/core/repos/settings.ts +23 -0
- package/src/core/schema.ts +244 -0
- package/src/core/sets/compile.ts +12 -0
- package/src/core/sets/definition.ts +10 -0
- package/src/core/sets/service.ts +82 -0
- package/src/core/sets/validate-tree.ts +57 -0
- package/src/core/sha256.ts +10 -0
- package/src/core/slug.ts +30 -0
- package/src/scaffold/collection-write.ts +83 -0
- package/src/scaffold/collection.ts +277 -0
- package/src/server/assets/live-preview-bridge.content.ts +2 -0
- package/src/server/assets/live-preview-bridge.js +535 -0
- package/src/server/better-auth.ts +82 -0
- package/src/server/cf-images.ts +34 -0
- package/src/server/cron.ts +37 -0
- package/src/server/email.ts +17 -0
- package/src/server/endpoints/api-auth.ts +10 -0
- package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
- package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
- package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
- package/src/server/endpoints/api-vulse-entries.ts +23 -0
- package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
- package/src/server/endpoints/api-vulse-form-public.ts +7 -0
- package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
- package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
- package/src/server/endpoints/api-vulse-forms.ts +12 -0
- package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
- package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
- package/src/server/endpoints/api-vulse-globals.ts +12 -0
- package/src/server/endpoints/api-vulse-media-file.ts +7 -0
- package/src/server/endpoints/api-vulse-media-id.ts +12 -0
- package/src/server/endpoints/api-vulse-media.ts +12 -0
- package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
- package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
- package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions.ts +7 -0
- package/src/server/endpoints/api-vulse-search.ts +7 -0
- package/src/server/endpoints/api-vulse-sets.ts +23 -0
- package/src/server/endpoints/api-vulse-settings.ts +12 -0
- package/src/server/endpoints/api-vulse-users-id.ts +12 -0
- package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
- package/src/server/endpoints/api-vulse-users-role.ts +9 -0
- package/src/server/endpoints/api-vulse-users.ts +7 -0
- package/src/server/endpoints/with-runtime.ts +11 -0
- package/src/server/env.ts +23 -0
- package/src/server/envelope.ts +21 -0
- package/src/server/forms/email.ts +11 -0
- package/src/server/forms/process-submission.ts +95 -0
- package/src/server/forms/queue.ts +25 -0
- package/src/server/forms/templates.ts +24 -0
- package/src/server/forms/webhook.ts +19 -0
- package/src/server/handler.ts +66 -0
- package/src/server/image-probe.ts +35 -0
- package/src/server/loader.ts +54 -0
- package/src/server/plugins.ts +214 -0
- package/src/server/preview.ts +25 -0
- package/src/server/r2.ts +13 -0
- package/src/server/routes/blueprints.ts +62 -0
- package/src/server/routes/entries.ts +255 -0
- package/src/server/routes/form-submit.ts +168 -0
- package/src/server/routes/form-upload.ts +100 -0
- package/src/server/routes/forms.ts +88 -0
- package/src/server/routes/globals-public.ts +30 -0
- package/src/server/routes/globals.ts +93 -0
- package/src/server/routes/media.ts +145 -0
- package/src/server/routes/preview-sessions.ts +76 -0
- package/src/server/routes/preview.ts +36 -0
- package/src/server/routes/revisions.ts +29 -0
- package/src/server/routes/search.ts +31 -0
- package/src/server/routes/sets.ts +40 -0
- package/src/server/routes/settings.ts +24 -0
- package/src/server/routes/users.ts +127 -0
- package/src/server/runtime.ts +99 -0
- package/src/server/sdk/collections.ts +98 -0
- package/src/server/sdk/index.ts +25 -0
- package/src/server/sdk/media.ts +11 -0
- package/src/server/sdk/search.ts +90 -0
package/src/core/db.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/d1'
|
|
2
|
+
import * as schema from './schema.js'
|
|
3
|
+
|
|
4
|
+
export type VulseDb = ReturnType<typeof createDb>
|
|
5
|
+
|
|
6
|
+
export function createDb(binding: D1Database) {
|
|
7
|
+
if (!binding) throw new Error('Vulse: D1 binding "DB" is missing. Add it to wrangler.toml.')
|
|
8
|
+
return drizzle(binding, { schema })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export * as schema from './schema.js'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ErrorCode = 'VALIDATION' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'CONFLICT' | 'INTERNAL'
|
|
2
|
+
|
|
3
|
+
export class VulseError extends Error {
|
|
4
|
+
readonly code: ErrorCode
|
|
5
|
+
readonly status: number
|
|
6
|
+
readonly details?: Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
constructor(code: ErrorCode, status: number, message: string, details?: Record<string, unknown>) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'VulseError'
|
|
11
|
+
this.code = code
|
|
12
|
+
this.status = status
|
|
13
|
+
if (details !== undefined) {
|
|
14
|
+
this.details = details
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static isVulseError(e: unknown): e is VulseError {
|
|
19
|
+
return e instanceof VulseError
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ValidationError extends VulseError {
|
|
24
|
+
constructor(message: string, details?: Record<string, unknown>) { super('VALIDATION', 422, message, details) }
|
|
25
|
+
}
|
|
26
|
+
export class NotFoundError extends VulseError {
|
|
27
|
+
constructor(message = 'Not found') { super('NOT_FOUND', 404, message) }
|
|
28
|
+
}
|
|
29
|
+
export class AccessDeniedError extends VulseError {
|
|
30
|
+
constructor(message = 'Access denied') { super('ACCESS_DENIED', 403, message) }
|
|
31
|
+
}
|
|
32
|
+
export class ConflictError extends VulseError {
|
|
33
|
+
constructor(message: string, details?: Record<string, unknown>) { super('CONFLICT', 409, message, details) }
|
|
34
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
import type { FormDefinition, FormFieldDefinition } from './definition.js'
|
|
3
|
+
|
|
4
|
+
export interface CompiledForm {
|
|
5
|
+
schema: z.ZodObject<z.ZodRawShape>
|
|
6
|
+
inputFields: FormFieldDefinition[]
|
|
7
|
+
uniqueFields: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LAYOUT_KINDS = new Set(['submit', 'honeypot'])
|
|
11
|
+
|
|
12
|
+
export function compileForm(def: FormDefinition): CompiledForm {
|
|
13
|
+
const inputFields = def.fields.filter((f) => !LAYOUT_KINDS.has(f.ui.kind))
|
|
14
|
+
const uniqueFields = inputFields
|
|
15
|
+
.filter((f) => f.validation?.unique)
|
|
16
|
+
.map((f) => f.name)
|
|
17
|
+
|
|
18
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
19
|
+
for (const field of inputFields) {
|
|
20
|
+
shape[field.name] = compileFormField(field)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
schema: z.object(shape),
|
|
25
|
+
inputFields,
|
|
26
|
+
uniqueFields,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compileFormField(f: FormFieldDefinition): z.ZodTypeAny {
|
|
31
|
+
let s: z.ZodTypeAny = z.never()
|
|
32
|
+
const v = f.validation
|
|
33
|
+
|
|
34
|
+
switch (f.ui.kind) {
|
|
35
|
+
case 'text':
|
|
36
|
+
case 'textarea': {
|
|
37
|
+
let str = z.string()
|
|
38
|
+
if (v?.min !== undefined) str = str.min(v.min)
|
|
39
|
+
if (v?.max !== undefined) str = str.max(v.max)
|
|
40
|
+
if (v?.pattern) str = str.regex(new RegExp(v.pattern))
|
|
41
|
+
if (v?.url) str = str.url()
|
|
42
|
+
s = str
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
case 'email': {
|
|
46
|
+
let str = z.string().email()
|
|
47
|
+
if (v?.min !== undefined) str = str.min(v.min)
|
|
48
|
+
if (v?.max !== undefined) str = str.max(v.max)
|
|
49
|
+
s = str
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
case 'number': {
|
|
53
|
+
let num = v?.integer ? z.coerce.number().int() : z.coerce.number()
|
|
54
|
+
if (v?.min !== undefined) num = num.min(v.min)
|
|
55
|
+
if (v?.max !== undefined) num = num.max(v.max)
|
|
56
|
+
s = num
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
case 'date':
|
|
60
|
+
case 'time':
|
|
61
|
+
case 'datetime':
|
|
62
|
+
s = z.string()
|
|
63
|
+
break
|
|
64
|
+
case 'select':
|
|
65
|
+
case 'radio':
|
|
66
|
+
s = z.enum(f.ui.options as [string, ...string[]])
|
|
67
|
+
break
|
|
68
|
+
case 'checkbox':
|
|
69
|
+
s = z.boolean()
|
|
70
|
+
break
|
|
71
|
+
case 'file':
|
|
72
|
+
s = z.string().min(1)
|
|
73
|
+
break
|
|
74
|
+
case 'hidden':
|
|
75
|
+
s = z.string()
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (f.default !== undefined) s = s.default(f.default)
|
|
80
|
+
if (f.optional && !v?.required) s = s.optional()
|
|
81
|
+
else if (v?.required) s = s.refine((val) => val !== '' && val !== undefined && val !== null, { message: 'Required' })
|
|
82
|
+
|
|
83
|
+
return s
|
|
84
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
const handleRegex = /^[a-z][a-z0-9_-]*$/
|
|
4
|
+
const fieldNameRegex = /^[a-z_][a-z0-9_-]*$/
|
|
5
|
+
|
|
6
|
+
export const FormFieldValidationSchema = z.object({
|
|
7
|
+
required: z.boolean().optional(),
|
|
8
|
+
min: z.number().optional(),
|
|
9
|
+
max: z.number().optional(),
|
|
10
|
+
pattern: z.string().optional(),
|
|
11
|
+
email: z.boolean().optional(),
|
|
12
|
+
url: z.boolean().optional(),
|
|
13
|
+
integer: z.boolean().optional(),
|
|
14
|
+
unique: z.boolean().optional(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type FormFieldValidation = z.infer<typeof FormFieldValidationSchema>
|
|
18
|
+
|
|
19
|
+
export const FormFieldUiSchema = z.discriminatedUnion('kind', [
|
|
20
|
+
z.object({ kind: z.literal('text') }),
|
|
21
|
+
z.object({ kind: z.literal('textarea') }),
|
|
22
|
+
z.object({ kind: z.literal('email') }),
|
|
23
|
+
z.object({ kind: z.literal('number') }),
|
|
24
|
+
z.object({ kind: z.literal('date') }),
|
|
25
|
+
z.object({ kind: z.literal('time') }),
|
|
26
|
+
z.object({ kind: z.literal('datetime') }),
|
|
27
|
+
z.object({ kind: z.enum(['select', 'radio']), options: z.array(z.string()).min(1) }),
|
|
28
|
+
z.object({ kind: z.literal('checkbox'), label: z.string().optional() }),
|
|
29
|
+
z.object({
|
|
30
|
+
kind: z.literal('file'),
|
|
31
|
+
accept: z.array(z.string()).optional(),
|
|
32
|
+
maxBytes: z.number().int().positive().optional(),
|
|
33
|
+
}),
|
|
34
|
+
z.object({ kind: z.literal('hidden'), value: z.string().optional() }),
|
|
35
|
+
z.object({ kind: z.literal('honeypot') }),
|
|
36
|
+
z.object({ kind: z.literal('submit'), label: z.string().optional() }),
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
export type FormFieldUi = z.infer<typeof FormFieldUiSchema>
|
|
40
|
+
|
|
41
|
+
export const FormFieldDefinitionSchema = z.object({
|
|
42
|
+
name: z.string().regex(fieldNameRegex),
|
|
43
|
+
label: z.string().optional(),
|
|
44
|
+
ui: FormFieldUiSchema,
|
|
45
|
+
optional: z.boolean(),
|
|
46
|
+
default: z.unknown().optional(),
|
|
47
|
+
validation: FormFieldValidationSchema.optional(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type FormFieldDefinition = z.infer<typeof FormFieldDefinitionSchema>
|
|
51
|
+
|
|
52
|
+
export const FormSettingsSchema = z.object({
|
|
53
|
+
enabled: z.boolean().default(true),
|
|
54
|
+
successMessage: z.string().optional(),
|
|
55
|
+
redirectTo: z.string().optional(),
|
|
56
|
+
honeypotField: z.string().optional(),
|
|
57
|
+
rateLimit: z.object({
|
|
58
|
+
maxPerIp: z.number().int().positive(),
|
|
59
|
+
windowSec: z.number().int().positive(),
|
|
60
|
+
}).optional(),
|
|
61
|
+
uploadDraftTtlHours: z.number().int().positive().optional(),
|
|
62
|
+
notifyEmails: z.array(z.string().email()).optional(),
|
|
63
|
+
confirmationEmail: z.object({
|
|
64
|
+
enabled: z.boolean(),
|
|
65
|
+
toField: z.string(),
|
|
66
|
+
subject: z.string(),
|
|
67
|
+
bodyTemplate: z.string(),
|
|
68
|
+
}).optional(),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export type FormSettings = z.infer<typeof FormSettingsSchema>
|
|
72
|
+
|
|
73
|
+
export const FormActionSchema = z.discriminatedUnion('type', [
|
|
74
|
+
z.object({
|
|
75
|
+
type: z.literal('notify'),
|
|
76
|
+
emails: z.array(z.string().email()),
|
|
77
|
+
template: z.string().optional(),
|
|
78
|
+
}),
|
|
79
|
+
z.object({
|
|
80
|
+
type: z.literal('confirmation'),
|
|
81
|
+
toField: z.string(),
|
|
82
|
+
subject: z.string(),
|
|
83
|
+
bodyTemplate: z.string(),
|
|
84
|
+
}),
|
|
85
|
+
z.object({
|
|
86
|
+
type: z.literal('webhook'),
|
|
87
|
+
url: z.string().url(),
|
|
88
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
export type FormAction = z.infer<typeof FormActionSchema>
|
|
93
|
+
|
|
94
|
+
export const FormDefinitionSchema = z.object({
|
|
95
|
+
handle: z.string().regex(handleRegex),
|
|
96
|
+
label: z.string().min(1),
|
|
97
|
+
fields: z.array(FormFieldDefinitionSchema).default([]),
|
|
98
|
+
settings: FormSettingsSchema,
|
|
99
|
+
actions: z.array(FormActionSchema).default([]),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export type FormDefinition = z.infer<typeof FormDefinitionSchema>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { sha256Hex } from '../sha256.js'
|
|
2
|
+
import { eq, and, lt } from 'drizzle-orm'
|
|
3
|
+
import type { VulseDb } from '../db.js'
|
|
4
|
+
import { vulseFormRateLimits } from '../schema.js'
|
|
5
|
+
|
|
6
|
+
export async function hashIp(ip: string): Promise<string> {
|
|
7
|
+
return sha256Hex(ip)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function checkRateLimit(
|
|
11
|
+
db: VulseDb,
|
|
12
|
+
formHandle: string,
|
|
13
|
+
ipHash: string,
|
|
14
|
+
opts: { maxPerIp: number; windowSec: number } = { maxPerIp: 10, windowSec: 3600 },
|
|
15
|
+
): Promise<{ allowed: boolean; retryAfterSec?: number }> {
|
|
16
|
+
const windowMs = opts.windowSec * 1000
|
|
17
|
+
const now = Date.now()
|
|
18
|
+
const windowStart = new Date(Math.floor(now / windowMs) * windowMs)
|
|
19
|
+
|
|
20
|
+
const existing = await db.select().from(vulseFormRateLimits)
|
|
21
|
+
.where(and(
|
|
22
|
+
eq(vulseFormRateLimits.formHandle, formHandle),
|
|
23
|
+
eq(vulseFormRateLimits.ipHash, ipHash),
|
|
24
|
+
eq(vulseFormRateLimits.windowStart, windowStart),
|
|
25
|
+
))
|
|
26
|
+
.get()
|
|
27
|
+
|
|
28
|
+
if (!existing) {
|
|
29
|
+
await db.insert(vulseFormRateLimits).values({
|
|
30
|
+
formHandle,
|
|
31
|
+
ipHash,
|
|
32
|
+
windowStart,
|
|
33
|
+
count: 1,
|
|
34
|
+
})
|
|
35
|
+
return { allowed: true }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (existing.count >= opts.maxPerIp) {
|
|
39
|
+
const retryAfterSec = Math.ceil((windowStart.getTime() + windowMs - now) / 1000)
|
|
40
|
+
return { allowed: false, retryAfterSec }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await db.update(vulseFormRateLimits)
|
|
44
|
+
.set({ count: existing.count + 1 })
|
|
45
|
+
.where(and(
|
|
46
|
+
eq(vulseFormRateLimits.formHandle, formHandle),
|
|
47
|
+
eq(vulseFormRateLimits.ipHash, ipHash),
|
|
48
|
+
eq(vulseFormRateLimits.windowStart, windowStart),
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
return { allowed: true }
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sha256Hex } from '../sha256.js'
|
|
2
|
+
import type { VulseDb } from '../db.js'
|
|
3
|
+
import { vulseFormUniqueValues } from '../schema.js'
|
|
4
|
+
import { ConflictError } from '../errors.js'
|
|
5
|
+
|
|
6
|
+
export function normalizeUniqueValue(value: unknown): string {
|
|
7
|
+
return String(value).trim().toLowerCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function hashUniqueValue(value: unknown): Promise<string> {
|
|
11
|
+
return sha256Hex(normalizeUniqueValue(value))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function insertUniqueValues(
|
|
15
|
+
db: VulseDb,
|
|
16
|
+
formHandle: string,
|
|
17
|
+
submissionId: string,
|
|
18
|
+
fields: Record<string, unknown>,
|
|
19
|
+
uniqueFieldNames: string[],
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const now = new Date()
|
|
22
|
+
for (const name of uniqueFieldNames) {
|
|
23
|
+
const value = fields[name]
|
|
24
|
+
if (value === undefined || value === null || value === '') continue
|
|
25
|
+
const valueHash = await hashUniqueValue(value)
|
|
26
|
+
try {
|
|
27
|
+
await db.insert(vulseFormUniqueValues).values({
|
|
28
|
+
formHandle,
|
|
29
|
+
fieldName: name,
|
|
30
|
+
valueHash,
|
|
31
|
+
submissionId,
|
|
32
|
+
createdAt: now,
|
|
33
|
+
})
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ConflictError(`Duplicate value for field "${name}"`, { field: name })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { z } from 'astro/zod'
|
|
2
|
+
import { compileBlueprintSchema } from '../blueprints/compile.js'
|
|
3
|
+
import type { CompiledSet } from '../sets/compile.js'
|
|
4
|
+
import type { FieldDefinition } from '../blueprints/definition.js'
|
|
5
|
+
import { type GlobalSetDefinition, hashGlobalSetDefinition } from './definition.js'
|
|
6
|
+
|
|
7
|
+
export interface CompiledGlobalSet {
|
|
8
|
+
handle: string
|
|
9
|
+
label: string
|
|
10
|
+
fields: FieldDefinition[]
|
|
11
|
+
schema: z.ZodObject<z.ZodRawShape>
|
|
12
|
+
hash: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function compileGlobalSet(
|
|
16
|
+
def: GlobalSetDefinition,
|
|
17
|
+
sets?: Map<string, CompiledSet>,
|
|
18
|
+
): Promise<CompiledGlobalSet> {
|
|
19
|
+
const options = sets ? { sets } : {}
|
|
20
|
+
return {
|
|
21
|
+
handle: def.handle,
|
|
22
|
+
label: def.label,
|
|
23
|
+
fields: def.fields,
|
|
24
|
+
schema: compileBlueprintSchema(
|
|
25
|
+
{
|
|
26
|
+
handle: def.handle,
|
|
27
|
+
label: def.label,
|
|
28
|
+
singleton: true,
|
|
29
|
+
fields: def.fields,
|
|
30
|
+
},
|
|
31
|
+
options,
|
|
32
|
+
),
|
|
33
|
+
hash: await hashGlobalSetDefinition(def),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
import { sha256Hex } from '../sha256.js'
|
|
3
|
+
import { type FieldDefinition, FieldDefinitionSchema } from '../blueprints/definition.js'
|
|
4
|
+
|
|
5
|
+
export const GlobalSetDefinitionSchema = z.object({
|
|
6
|
+
handle: z.string().regex(/^[a-z][a-z0-9_-]*$/),
|
|
7
|
+
label: z.string().min(1),
|
|
8
|
+
fields: z.array(FieldDefinitionSchema).default([]),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export type GlobalSetDefinition = z.infer<typeof GlobalSetDefinitionSchema>
|
|
12
|
+
|
|
13
|
+
export async function hashGlobalSetDefinition(def: GlobalSetDefinition): Promise<string> {
|
|
14
|
+
const canonical = JSON.stringify({
|
|
15
|
+
handle: def.handle,
|
|
16
|
+
label: def.label,
|
|
17
|
+
fields: def.fields.map((f: FieldDefinition) => ({
|
|
18
|
+
name: f.name,
|
|
19
|
+
label: f.label ?? null,
|
|
20
|
+
ui: f.ui,
|
|
21
|
+
optional: f.optional,
|
|
22
|
+
default: f.default ?? null,
|
|
23
|
+
validation: f.validation ?? null,
|
|
24
|
+
})),
|
|
25
|
+
})
|
|
26
|
+
return sha256Hex(canonical)
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { VulseDb } from './db.js'
|
|
2
|
+
import { SettingsRepo } from './repos/settings.js'
|
|
3
|
+
import { DEFAULT_LOCALE } from './repos/entries.js'
|
|
4
|
+
|
|
5
|
+
export const LOCALES_KEY = 'locales'
|
|
6
|
+
export const DEFAULT_LOCALE_KEY = 'defaultLocale'
|
|
7
|
+
|
|
8
|
+
// BCP-47-ish: 2-3 letter base + optional region. Conservative, easy to extend later.
|
|
9
|
+
const LOCALE_CODE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/
|
|
10
|
+
|
|
11
|
+
export interface LocalesConfig {
|
|
12
|
+
/** Ordered list of locales supported by the site. Always contains defaultLocale. */
|
|
13
|
+
locales: string[]
|
|
14
|
+
/** The locale used when none is specified. */
|
|
15
|
+
defaultLocale: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isValidLocaleCode(code: string): boolean {
|
|
19
|
+
return code === DEFAULT_LOCALE || LOCALE_CODE_RE.test(code)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readLocalesConfig(db: VulseDb): Promise<LocalesConfig> {
|
|
23
|
+
const repo = new SettingsRepo(db)
|
|
24
|
+
const [raw, def] = await Promise.all([
|
|
25
|
+
repo.get<unknown>(LOCALES_KEY),
|
|
26
|
+
repo.get<string>(DEFAULT_LOCALE_KEY),
|
|
27
|
+
])
|
|
28
|
+
const locales = Array.isArray(raw)
|
|
29
|
+
? raw.filter((v): v is string => typeof v === 'string' && isValidLocaleCode(v))
|
|
30
|
+
: []
|
|
31
|
+
const defaultLocale = typeof def === 'string' && isValidLocaleCode(def) ? def : DEFAULT_LOCALE
|
|
32
|
+
if (locales.length === 0) locales.push(defaultLocale)
|
|
33
|
+
if (!locales.includes(defaultLocale)) locales.unshift(defaultLocale)
|
|
34
|
+
return { locales, defaultLocale }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate a locale param against site configuration; throws if unknown. */
|
|
38
|
+
export async function resolveLocale(db: VulseDb, candidate: string | null | undefined): Promise<string> {
|
|
39
|
+
const cfg = await readLocalesConfig(db)
|
|
40
|
+
if (!candidate || candidate === DEFAULT_LOCALE) return cfg.defaultLocale
|
|
41
|
+
if (!cfg.locales.includes(candidate)) {
|
|
42
|
+
throw new Error(`Unknown locale '${candidate}'. Supported: ${cfg.locales.join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
return candidate
|
|
45
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import initSql from '../../migrations/0000_init.sql?raw'
|
|
2
|
+
import collectionsSetsSql from '../../migrations/0001_collections_sets.sql?raw'
|
|
3
|
+
import ftsSql from '../../migrations/0003_fts.sql?raw'
|
|
4
|
+
import formsSql from '../../migrations/0004_forms.sql?raw'
|
|
5
|
+
import globalsSql from '../../migrations/0005_globals.sql?raw'
|
|
6
|
+
import previewSessionsSql from '../../migrations/0006_preview_sessions.sql?raw'
|
|
7
|
+
|
|
8
|
+
const MIGRATIONS = [
|
|
9
|
+
{ id: '0000_init', sql: initSql },
|
|
10
|
+
{ id: '0001_collections_sets', sql: collectionsSetsSql },
|
|
11
|
+
// 0002_tree_drafts was folded into 0000_init when the schema was reshaped for
|
|
12
|
+
// i18n. The ID is intentionally skipped so the ledger remains forward-only.
|
|
13
|
+
{ id: '0003_fts', sql: ftsSql },
|
|
14
|
+
{ id: '0004_forms', sql: formsSql },
|
|
15
|
+
{ id: '0005_globals', sql: globalsSql },
|
|
16
|
+
{ id: '0006_preview_sessions', sql: previewSessionsSql },
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
function splitStatements(sql: string): string[] {
|
|
20
|
+
return sql
|
|
21
|
+
.split('--> statement-breakpoint')
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Applies bundled SQL migrations directly to a D1 binding (used in tests and Workers). */
|
|
27
|
+
export async function applyMigrations(db: D1Database): Promise<void> {
|
|
28
|
+
await db.exec(
|
|
29
|
+
'CREATE TABLE IF NOT EXISTS _vulse_migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
for (const migration of MIGRATIONS) {
|
|
33
|
+
const applied = await db
|
|
34
|
+
.prepare('SELECT id FROM _vulse_migrations WHERE id = ?')
|
|
35
|
+
.bind(migration.id)
|
|
36
|
+
.first()
|
|
37
|
+
if (applied) continue
|
|
38
|
+
|
|
39
|
+
for (const stmt of splitStatements(migration.sql)) {
|
|
40
|
+
await db.exec(stmt.replace(/\s+/g, ' '))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await db
|
|
44
|
+
.prepare('INSERT INTO _vulse_migrations (id, applied_at) VALUES (?, ?)')
|
|
45
|
+
.bind(migration.id, Date.now())
|
|
46
|
+
.run()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { z } from 'astro/zod'
|
|
2
|
+
import { ValidationError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
export interface ContentValidationIssue {
|
|
5
|
+
path: (string | number)[]
|
|
6
|
+
message: string
|
|
7
|
+
code?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parses arbitrary content against a Zod schema. On failure, throws a
|
|
12
|
+
* ValidationError whose message names the first offending field (so the
|
|
13
|
+
* top-level error banner reads sensibly) and whose `details.issues` carries
|
|
14
|
+
* the full list with humanized messages for inline field display.
|
|
15
|
+
*/
|
|
16
|
+
export function parseContent<S extends z.ZodTypeAny>(schema: S, content: unknown): z.infer<S> {
|
|
17
|
+
const parsed = schema.safeParse(content)
|
|
18
|
+
if (parsed.success) return parsed.data
|
|
19
|
+
|
|
20
|
+
const issues: ContentValidationIssue[] = parsed.error.issues.map((issue) => ({
|
|
21
|
+
path: issue.path.filter((p): p is string | number => typeof p === 'string' || typeof p === 'number'),
|
|
22
|
+
message: humanizeIssue(issue),
|
|
23
|
+
code: issue.code,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
throw new ValidationError(summarize(issues), { issues })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function summarize(issues: ContentValidationIssue[]): string {
|
|
30
|
+
if (issues.length === 0) return 'Validation failed'
|
|
31
|
+
const first = issues[0]!
|
|
32
|
+
const fieldLabel = first.path.length ? first.path.join('.') : 'Content'
|
|
33
|
+
if (issues.length === 1) return `${fieldLabel}: ${first.message}`
|
|
34
|
+
return `${fieldLabel}: ${first.message} (and ${issues.length - 1} more issue${issues.length - 1 === 1 ? '' : 's'})`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ZodIssueLike {
|
|
38
|
+
code: string
|
|
39
|
+
message: string
|
|
40
|
+
path: (string | number | symbol)[]
|
|
41
|
+
// Zod 4 fields
|
|
42
|
+
expected?: string
|
|
43
|
+
origin?: string
|
|
44
|
+
minimum?: number | bigint
|
|
45
|
+
maximum?: number | bigint
|
|
46
|
+
inclusive?: boolean
|
|
47
|
+
format?: string
|
|
48
|
+
// Zod 3 fields (kept for compatibility)
|
|
49
|
+
received?: string
|
|
50
|
+
type?: string
|
|
51
|
+
validation?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function humanizeIssue(issue: unknown): string {
|
|
55
|
+
const i = issue as ZodIssueLike
|
|
56
|
+
|
|
57
|
+
// Missing required value.
|
|
58
|
+
if (i.code === 'invalid_type') {
|
|
59
|
+
if (i.received === 'undefined' || i.received === 'null') return 'This field is required.'
|
|
60
|
+
if (/received (undefined|null)/.test(i.message)) return 'This field is required.'
|
|
61
|
+
if (i.expected) return `Expected ${i.expected}.`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Length / numeric bounds.
|
|
65
|
+
const origin = i.origin ?? i.type
|
|
66
|
+
if (i.code === 'too_small') {
|
|
67
|
+
if (origin === 'string' && Number(i.minimum) === 1) return 'This field is required.'
|
|
68
|
+
if (origin === 'string') return `Must be at least ${i.minimum} characters.`
|
|
69
|
+
if (origin === 'number') return `Must be ${i.inclusive ? 'at least' : 'greater than'} ${i.minimum}.`
|
|
70
|
+
if (origin === 'array') return `Add at least ${i.minimum} item${i.minimum === 1 ? '' : 's'}.`
|
|
71
|
+
}
|
|
72
|
+
if (i.code === 'too_big') {
|
|
73
|
+
if (origin === 'string') return `Must be at most ${i.maximum} characters.`
|
|
74
|
+
if (origin === 'number') return `Must be ${i.inclusive ? 'at most' : 'less than'} ${i.maximum}.`
|
|
75
|
+
if (origin === 'array') return `No more than ${i.maximum} item${i.maximum === 1 ? '' : 's'}.`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// String formats (Zod 4: invalid_format with format; Zod 3: invalid_string with validation).
|
|
79
|
+
const fmt = i.format ?? i.validation
|
|
80
|
+
if (fmt === 'email') return 'Enter a valid email address.'
|
|
81
|
+
if (fmt === 'url') return 'Enter a valid URL.'
|
|
82
|
+
|
|
83
|
+
if (i.message === 'Required') return 'This field is required.'
|
|
84
|
+
return i.message
|
|
85
|
+
}
|