@elytracms/forms 0.0.0-stage → 0.0.94
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/LICENSE +21 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/next/index.d.ts +20 -0
- package/dist/next/index.js +19 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/routes.d.ts +145 -0
- package/dist/next/routes.js +214 -0
- package/dist/next/routes.js.map +1 -0
- package/dist/next/submit.d.ts +83 -0
- package/dist/next/submit.js +137 -0
- package/dist/next/submit.js.map +1 -0
- package/dist/shared/delivery.d.ts +154 -0
- package/dist/shared/delivery.js +218 -0
- package/dist/shared/delivery.js.map +1 -0
- package/dist/shared/kind.d.ts +120 -0
- package/dist/shared/kind.js +98 -0
- package/dist/shared/kind.js.map +1 -0
- package/dist/shared/mail.d.ts +35 -0
- package/dist/shared/mail.js +47 -0
- package/dist/shared/mail.js.map +1 -0
- package/dist/shared/source.d.ts +70 -0
- package/dist/shared/source.js +40 -0
- package/dist/shared/source.js.map +1 -0
- package/dist/shared/submission.d.ts +83 -0
- package/dist/shared/submission.js +146 -0
- package/dist/shared/submission.js.map +1 -0
- package/dist/studio/appendix.d.ts +11 -0
- package/dist/studio/appendix.js +50 -0
- package/dist/studio/appendix.js.map +1 -0
- package/dist/studio/inbox.d.ts +39 -0
- package/dist/studio/inbox.js +211 -0
- package/dist/studio/inbox.js.map +1 -0
- package/dist/studio/index.d.ts +15 -0
- package/dist/studio/index.js +13 -0
- package/dist/studio/index.js.map +1 -0
- package/dist/studio/lens.d.ts +68 -0
- package/dist/studio/lens.js +149 -0
- package/dist/studio/lens.js.map +1 -0
- package/package.json +75 -3
- package/src/convex/forms.convex.test.ts +552 -0
- package/src/convex/index.ts +340 -0
- package/src/shared/delivery.test.ts +122 -0
- package/src/shared/delivery.ts +263 -0
- package/src/shared/kind.ts +120 -0
- package/src/shared/mail.ts +67 -0
- package/src/shared/source.test.ts +83 -0
- package/src/shared/source.ts +107 -0
- package/src/shared/submission.test.ts +97 -0
- package/src/shared/submission.ts +163 -0
- package/README.md +0 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CONVEX_MAX_NESTING_DEPTH } from '@elytracms/core/cms-core'
|
|
3
|
+
import { recordRowDepth, resolveRecordSave } from '@elytracms/core/persistence'
|
|
4
|
+
import { submissionRecordKind } from './kind'
|
|
5
|
+
import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, readSubmissionPayload } from './submission'
|
|
6
|
+
|
|
7
|
+
/** EC-613 — the one reader of a site's submission payload (the receiver's handler runs it). */
|
|
8
|
+
|
|
9
|
+
const submission = (data: Record<string, unknown>, target = 'contact') => ({ kind: 'submission' as const, target, data })
|
|
10
|
+
|
|
11
|
+
describe('readSubmissionPayload', () => {
|
|
12
|
+
it('keeps the fields sorted by name (the one order every runtime agrees on), drops empty answers, reads the reserved keys', () => {
|
|
13
|
+
const read = readSubmissionPayload(
|
|
14
|
+
submission({
|
|
15
|
+
name: 'Ada',
|
|
16
|
+
topics: ['cakes', 'tarts'],
|
|
17
|
+
empty: ' ',
|
|
18
|
+
none: [],
|
|
19
|
+
newsletter: true,
|
|
20
|
+
guests: 4,
|
|
21
|
+
[FORMS_SOURCE_KEY]: 'https://example.test/contact',
|
|
22
|
+
[FORMS_HONEYPOT_KEY]: '',
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
expect(read).toEqual({
|
|
26
|
+
ok: true,
|
|
27
|
+
columns: {
|
|
28
|
+
form: 'contact',
|
|
29
|
+
payload: [
|
|
30
|
+
{ name: 'guests', value: 4 },
|
|
31
|
+
{ name: 'name', value: 'Ada' },
|
|
32
|
+
{ name: 'newsletter', value: true },
|
|
33
|
+
{ name: 'topics', value: ['cakes', 'tarts'] },
|
|
34
|
+
],
|
|
35
|
+
sourceUrl: 'https://example.test/contact',
|
|
36
|
+
spam: 'clean',
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('a filled honeypot is the verdict, not a refusal (the row is kept and marked)', () => {
|
|
42
|
+
const read = readSubmissionPayload(submission({ name: 'Bot', [FORMS_HONEYPOT_KEY]: 'http://spam.test' }))
|
|
43
|
+
expect(read.ok && read.columns.spam).toBe('honeypot')
|
|
44
|
+
// Control: an empty honeypot is clean.
|
|
45
|
+
const clean = readSubmissionPayload(submission({ name: 'Ada', [FORMS_HONEYPOT_KEY]: '' }))
|
|
46
|
+
expect(clean.ok && clean.columns.spam).toBe('clean')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('refuses, visibly, a value that is not a form value and an unknown reserved key', () => {
|
|
50
|
+
const read = readSubmissionPayload(submission({ name: 'Ada', nested: { deep: { deeper: 1 } }, 'elytra:admin': 'yes' }))
|
|
51
|
+
expect(read.ok).toBe(false)
|
|
52
|
+
if (read.ok) return
|
|
53
|
+
expect(read.status).toBe(422)
|
|
54
|
+
expect(read.issues.map((issue) => issue.path.join('.'))).toEqual(['data.elytra:admin', 'data.nested'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('refuses a submission with no answer — unless it carries files', () => {
|
|
58
|
+
expect(readSubmissionPayload(submission({ name: '' })).ok).toBe(false)
|
|
59
|
+
expect(readSubmissionPayload(submission({ name: '' }), { hasFiles: true }).ok).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('the submission kind', () => {
|
|
64
|
+
it('a maximal row stays far below Convex’s nesting ceiling (the values are flat by construction)', () => {
|
|
65
|
+
const columns = resolveRecordSave(submissionRecordKind, 'submission_max', undefined, {
|
|
66
|
+
form: 'contact',
|
|
67
|
+
payload: [{ name: 'topics', value: ['a', 'b'] }],
|
|
68
|
+
files: [{ field: 'cv', name: 'cv.pdf', storageId: 's1', contentType: 'application/pdf', size: 1, url: 'https://x.test/s1' }],
|
|
69
|
+
spam: 'clean',
|
|
70
|
+
deliveryState: 'failed',
|
|
71
|
+
deliveryLog: [{ at: 't', attempt: 1, to: 'a@example.test', ok: false, error: 'x', retryAt: 't2' }],
|
|
72
|
+
submittedAt: 't',
|
|
73
|
+
meta: { tag: 'x' },
|
|
74
|
+
})
|
|
75
|
+
expect(recordRowDepth(columns)).toBeLessThanOrEqual(6)
|
|
76
|
+
expect(recordRowDepth(columns)).toBeLessThan(CONVEX_MAX_NESTING_DEPTH)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('a create defaults the inbox status to new; an edit never re-applies it', () => {
|
|
80
|
+
const created = resolveRecordSave(submissionRecordKind, 's', undefined, {
|
|
81
|
+
form: 'contact',
|
|
82
|
+
payload: [],
|
|
83
|
+
spam: 'clean',
|
|
84
|
+
deliveryState: 'pending',
|
|
85
|
+
submittedAt: 't',
|
|
86
|
+
})
|
|
87
|
+
expect(created.status).toBe('new')
|
|
88
|
+
const edited = resolveRecordSave(submissionRecordKind, 's', { ...created, status: 'done' }, { notes: 'called back' })
|
|
89
|
+
expect(edited).toMatchObject({ status: 'done', notes: 'called back' })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keys the inbox by form and by status, ordered by the submission time (EC-616 slots)', () => {
|
|
93
|
+
expect(submissionRecordKind.keys).toEqual(['form', 'status'])
|
|
94
|
+
expect(submissionRecordKind.time).toBe('submittedAt')
|
|
95
|
+
expect(submissionRecordKind.module).toBe('forms')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { convexFieldNameProblem } from '@elytracms/core/ingest'
|
|
2
|
+
import type { IngestPayload } from '@elytracms/core/ingest'
|
|
3
|
+
import { formValueSchema } from './kind'
|
|
4
|
+
import type { FormValue, SpamVerdict, SubmissionField } from './kind'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`
|
|
8
|
+
* payload carries for this addon, and the ONE reader the receiver's handler turns it into a
|
|
9
|
+
* row with. Pure (no Convex), so the rules are unit-tested on their own.
|
|
10
|
+
*
|
|
11
|
+
* `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,
|
|
12
|
+
* in the `elytra:` namespace:
|
|
13
|
+
*
|
|
14
|
+
* - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);
|
|
15
|
+
* - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).
|
|
16
|
+
*
|
|
17
|
+
* Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are
|
|
18
|
+
* not answers and are not stored; a submission with no answer and no file is refused.
|
|
19
|
+
*
|
|
20
|
+
* A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
|
|
21
|
+
* Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
|
|
22
|
+
* leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
|
|
23
|
+
* 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
|
|
24
|
+
* site helpers refuse a name before sending, with the same reason the envelope would give.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The reserved namespace of the forms wire. */
|
|
28
|
+
export const FORMS_RESERVED_PREFIX = 'elytra:'
|
|
29
|
+
/** The page a submission came from. */
|
|
30
|
+
export const FORMS_SOURCE_KEY = 'elytra:source'
|
|
31
|
+
/** The honeypot's value. */
|
|
32
|
+
export const FORMS_HONEYPOT_KEY = 'elytra:honeypot'
|
|
33
|
+
/**
|
|
34
|
+
* The hidden input a native (no-JS) form post names its form with — read by the `./next`
|
|
35
|
+
* form-post route, never sent on: it becomes the ingest payload's `target`.
|
|
36
|
+
*/
|
|
37
|
+
export const FORMS_TARGET_FIELD = 'elytra:form'
|
|
38
|
+
|
|
39
|
+
/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */
|
|
40
|
+
export const FORM_FIELD_NAME_MAX = 200
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's
|
|
44
|
+
* rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms
|
|
45
|
+
* addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to
|
|
46
|
+
* follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.
|
|
47
|
+
*/
|
|
48
|
+
export function formFieldNameProblem(name: string): string | undefined {
|
|
49
|
+
const core = convexFieldNameProblem(name)
|
|
50
|
+
if (core) return core
|
|
51
|
+
if (name.length === 0) return 'is empty'
|
|
52
|
+
if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */
|
|
57
|
+
export function formFieldNameMessage(name: string, problem: string): string {
|
|
58
|
+
return `The field name “${name}” ${problem}.`
|
|
59
|
+
}
|
|
60
|
+
/** The honeypot field both consumers' forms already render (overridable in the site helpers). */
|
|
61
|
+
export const DEFAULT_HONEYPOT_FIELD = 'website'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The file types a form upload may have — documents and raster images. Anything that a
|
|
65
|
+
* browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is
|
|
66
|
+
* refused by the receiver's handler (415), and so is every type not listed.
|
|
67
|
+
*/
|
|
68
|
+
export const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [
|
|
69
|
+
'application/pdf',
|
|
70
|
+
'application/msword',
|
|
71
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
72
|
+
'application/vnd.oasis.opendocument.text',
|
|
73
|
+
'application/rtf',
|
|
74
|
+
'text/plain',
|
|
75
|
+
'text/csv',
|
|
76
|
+
'image/jpeg',
|
|
77
|
+
'image/png',
|
|
78
|
+
'image/webp',
|
|
79
|
+
'image/gif',
|
|
80
|
+
'image/heic',
|
|
81
|
+
'image/heif',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
/** The longest source URL a row keeps. */
|
|
85
|
+
const SOURCE_URL_MAX = 2000
|
|
86
|
+
|
|
87
|
+
/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */
|
|
88
|
+
export interface SubmissionColumns {
|
|
89
|
+
form: string
|
|
90
|
+
payload: SubmissionField[]
|
|
91
|
+
sourceUrl?: string
|
|
92
|
+
spam: SpamVerdict
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type SubmissionRead =
|
|
96
|
+
| { ok: true; columns: SubmissionColumns }
|
|
97
|
+
| { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }
|
|
98
|
+
|
|
99
|
+
function isEmptyValue(value: FormValue): boolean {
|
|
100
|
+
return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)
|
|
105
|
+
* a value that is not a form value, an unknown reserved key, an over-long source URL, and a
|
|
106
|
+
* submission with no answer — unless it carries files (`hasFiles`).
|
|
107
|
+
*/
|
|
108
|
+
export function readSubmissionPayload(
|
|
109
|
+
payload: Extract<IngestPayload, { kind: 'submission' }>,
|
|
110
|
+
options: { hasFiles?: boolean } = {},
|
|
111
|
+
): SubmissionRead {
|
|
112
|
+
const issues: { path: (string | number)[]; message: string }[] = []
|
|
113
|
+
const fields: SubmissionField[] = []
|
|
114
|
+
let sourceUrl: string | undefined
|
|
115
|
+
let spam: SpamVerdict = 'clean'
|
|
116
|
+
// Sorted by name: an object's key order does not survive a Convex function boundary
|
|
117
|
+
// (measured — the receiver's handler receives the keys sorted), so the stored order is the
|
|
118
|
+
// one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).
|
|
119
|
+
for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
120
|
+
if (name === FORMS_SOURCE_KEY) {
|
|
121
|
+
if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {
|
|
122
|
+
issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })
|
|
123
|
+
} else if (raw.length > 0) sourceUrl = raw
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (name === FORMS_HONEYPOT_KEY) {
|
|
127
|
+
if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (name.startsWith(FORMS_RESERVED_PREFIX)) {
|
|
131
|
+
issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const value = formValueSchema.safeParse(raw)
|
|
135
|
+
if (!value.success) {
|
|
136
|
+
issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const nameProblem = formFieldNameProblem(name)
|
|
140
|
+
if (nameProblem) {
|
|
141
|
+
issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })
|
|
145
|
+
}
|
|
146
|
+
if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {
|
|
147
|
+
issues.push({ path: ['data'], message: 'The submission carries no answer.' })
|
|
148
|
+
}
|
|
149
|
+
if (issues.length > 0) {
|
|
150
|
+
return { ok: false, status: 422, message: 'The submission is not valid.', issues }
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The display text of one value (a list joined by commas, a boolean as yes/no). */
|
|
159
|
+
export function formatFormValue(value: FormValue): string {
|
|
160
|
+
if (Array.isArray(value)) return value.join(', ')
|
|
161
|
+
if (typeof value === 'boolean') return value ? 'yes' : 'no'
|
|
162
|
+
return String(value)
|
|
163
|
+
}
|
package/README.md
DELETED