@elytracms/forms 0.0.0-stage → 0.0.95

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +28 -0
  3. package/dist/index.js +24 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/next/index.d.ts +20 -0
  6. package/dist/next/index.js +19 -0
  7. package/dist/next/index.js.map +1 -0
  8. package/dist/next/routes.d.ts +145 -0
  9. package/dist/next/routes.js +214 -0
  10. package/dist/next/routes.js.map +1 -0
  11. package/dist/next/submit.d.ts +83 -0
  12. package/dist/next/submit.js +137 -0
  13. package/dist/next/submit.js.map +1 -0
  14. package/dist/shared/delivery.d.ts +154 -0
  15. package/dist/shared/delivery.js +218 -0
  16. package/dist/shared/delivery.js.map +1 -0
  17. package/dist/shared/kind.d.ts +120 -0
  18. package/dist/shared/kind.js +98 -0
  19. package/dist/shared/kind.js.map +1 -0
  20. package/dist/shared/mail.d.ts +35 -0
  21. package/dist/shared/mail.js +47 -0
  22. package/dist/shared/mail.js.map +1 -0
  23. package/dist/shared/source.d.ts +70 -0
  24. package/dist/shared/source.js +40 -0
  25. package/dist/shared/source.js.map +1 -0
  26. package/dist/shared/submission.d.ts +83 -0
  27. package/dist/shared/submission.js +146 -0
  28. package/dist/shared/submission.js.map +1 -0
  29. package/dist/studio/appendix.d.ts +11 -0
  30. package/dist/studio/appendix.js +50 -0
  31. package/dist/studio/appendix.js.map +1 -0
  32. package/dist/studio/inbox.d.ts +39 -0
  33. package/dist/studio/inbox.js +211 -0
  34. package/dist/studio/inbox.js.map +1 -0
  35. package/dist/studio/index.d.ts +15 -0
  36. package/dist/studio/index.js +13 -0
  37. package/dist/studio/index.js.map +1 -0
  38. package/dist/studio/lens.d.ts +68 -0
  39. package/dist/studio/lens.js +149 -0
  40. package/dist/studio/lens.js.map +1 -0
  41. package/package.json +75 -3
  42. package/src/convex/forms.convex.test.ts +552 -0
  43. package/src/convex/index.ts +340 -0
  44. package/src/shared/delivery.test.ts +122 -0
  45. package/src/shared/delivery.ts +263 -0
  46. package/src/shared/kind.ts +120 -0
  47. package/src/shared/mail.ts +67 -0
  48. package/src/shared/source.test.ts +83 -0
  49. package/src/shared/source.ts +107 -0
  50. package/src/shared/submission.test.ts +97 -0
  51. package/src/shared/submission.ts +163 -0
  52. 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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.