@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 finaldream
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@elytracms/forms` — forms, the first Elytra addon (EC-613, on the addon contract of
3
+ * EC-616 and the public ingest seam of EC-617).
4
+ *
5
+ * A site's form post becomes a SUBMISSION — a row in the project's record store that the
6
+ * studio lists in an inbox — and its mail is a scheduled delivery with a per-recipient log
7
+ * on that row, so a failed mail is a visible issue on a submission that exists, never a lost
8
+ * message. Uploads go into the deployment's storage, claimed by the submission.
9
+ *
10
+ * This entry holds what every plane shares — the record kind, the form source, the forms
11
+ * wire and the pure rules. The planes:
12
+ *
13
+ * | Entry | What it holds | How the project wires it |
14
+ * | --- | --- | --- |
15
+ * | `@elytracms/forms/convex` | the kind's functions, the receiver's handler, the delivery | `studio/convex/forms.ts`: `export * from '@elytracms/forms/convex'`, and `ingest: { submission: formsIngest }` in `studio/convex/http.ts` |
16
+ * | `@elytracms/forms/studio` | the inbox | `formsInbox({ source })` in `defineStudio({ sidebar })` |
17
+ * | `@elytracms/forms/next` | the browser submit, the no-JS form post, the delivery route | `frontend/app/api/forms/route.ts` (+ the ingest route) |
18
+ */
19
+ export { DELIVERY_LOG_LIMIT, DELIVERY_STATES, FORM_VALUE_MAX_LENGTH, SPAM_VERDICTS, SUBMISSION_EDITOR_COLUMNS, SUBMISSION_STATUSES, deliveryEntrySchema, formValueSchema, submissionFieldSchema, submissionFileSchema, submissionRecordKind, } from './shared/kind.js';
20
+ export type { DeliveryEntry, DeliveryState, FormValue, SpamVerdict, SubmissionEditorColumn, SubmissionField, SubmissionFile, SubmissionRecord, SubmissionStatus, } from './shared/kind.js';
21
+ export { defaultReplyTo, defineFormSource, isEmailAddress } from './shared/source.js';
22
+ export type { FormDocument, FormFieldLabel, FormSource, FormSourceConfig } from './shared/source.js';
23
+ export { DEFAULT_HONEYPOT_FIELD, FORMS_HONEYPOT_KEY, FORMS_RESERVED_PREFIX, FORMS_SOURCE_KEY, FORMS_TARGET_FIELD, FORMS_UPLOAD_CONTENT_TYPES, formatFormValue, readSubmissionPayload, FORM_FIELD_NAME_MAX, formFieldNameMessage, formFieldNameProblem, } from './shared/submission.js';
24
+ export type { SubmissionColumns, SubmissionRead } from './shared/submission.js';
25
+ export { DELIVERY_BACKOFF_MS, DELIVERY_MAX_ATTEMPTS, DELIVERY_TIMEOUT_MS, FORMS_DELIVERY_SIGNATURE_HEADER, FORMS_DELIVERY_URL_ENV, applyDeliveryOutcome, attemptFormDelivery, deliveredRecipients, deliveryNeedsAttention, deliveryOutcomeSchema, deliveryResultSchema, formDeliveryRequestSchema, formDeliveryResponseSchema, signedFormDeliveryRequest, verifyFormDeliverySignature, } from './shared/delivery.js';
26
+ export type { DeliveryOutcome, DeliveryResult, DeliveryStep, FormDeliveryRequest, FormDeliveryResponse, } from './shared/delivery.js';
27
+ export { labelPayload, plainLabel, submissionMailText } from './shared/mail.js';
28
+ export type { LabelledField } from './shared/mail.js';
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@elytracms/forms` — forms, the first Elytra addon (EC-613, on the addon contract of
3
+ * EC-616 and the public ingest seam of EC-617).
4
+ *
5
+ * A site's form post becomes a SUBMISSION — a row in the project's record store that the
6
+ * studio lists in an inbox — and its mail is a scheduled delivery with a per-recipient log
7
+ * on that row, so a failed mail is a visible issue on a submission that exists, never a lost
8
+ * message. Uploads go into the deployment's storage, claimed by the submission.
9
+ *
10
+ * This entry holds what every plane shares — the record kind, the form source, the forms
11
+ * wire and the pure rules. The planes:
12
+ *
13
+ * | Entry | What it holds | How the project wires it |
14
+ * | --- | --- | --- |
15
+ * | `@elytracms/forms/convex` | the kind's functions, the receiver's handler, the delivery | `studio/convex/forms.ts`: `export * from '@elytracms/forms/convex'`, and `ingest: { submission: formsIngest }` in `studio/convex/http.ts` |
16
+ * | `@elytracms/forms/studio` | the inbox | `formsInbox({ source })` in `defineStudio({ sidebar })` |
17
+ * | `@elytracms/forms/next` | the browser submit, the no-JS form post, the delivery route | `frontend/app/api/forms/route.ts` (+ the ingest route) |
18
+ */
19
+ export { DELIVERY_LOG_LIMIT, DELIVERY_STATES, FORM_VALUE_MAX_LENGTH, SPAM_VERDICTS, SUBMISSION_EDITOR_COLUMNS, SUBMISSION_STATUSES, deliveryEntrySchema, formValueSchema, submissionFieldSchema, submissionFileSchema, submissionRecordKind, } from './shared/kind.js';
20
+ export { defaultReplyTo, defineFormSource, isEmailAddress } from './shared/source.js';
21
+ export { DEFAULT_HONEYPOT_FIELD, FORMS_HONEYPOT_KEY, FORMS_RESERVED_PREFIX, FORMS_SOURCE_KEY, FORMS_TARGET_FIELD, FORMS_UPLOAD_CONTENT_TYPES, formatFormValue, readSubmissionPayload, FORM_FIELD_NAME_MAX, formFieldNameMessage, formFieldNameProblem, } from './shared/submission.js';
22
+ export { DELIVERY_BACKOFF_MS, DELIVERY_MAX_ATTEMPTS, DELIVERY_TIMEOUT_MS, FORMS_DELIVERY_SIGNATURE_HEADER, FORMS_DELIVERY_URL_ENV, applyDeliveryOutcome, attemptFormDelivery, deliveredRecipients, deliveryNeedsAttention, deliveryOutcomeSchema, deliveryResultSchema, formDeliveryRequestSchema, formDeliveryResponseSchema, signedFormDeliveryRequest, verifyFormDeliverySignature, } from './shared/delivery.js';
23
+ export { labelPayload, plainLabel, submissionMailText } from './shared/mail.js';
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,aAAa,EACb,yBAAyB,EACzB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,EACf,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,eAAe,CAAA;AAYtB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAElF,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,0BAA0B,EAC1B,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAA;AAE5B,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,mBAAmB,EACnB,+BAA+B,EAC/B,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,yBAAyB,EACzB,0BAA0B,EAC1B,yBAAyB,EACzB,2BAA2B,GAC5B,MAAM,mBAAmB,CAAA;AAQ1B,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA","sourcesContent":["/**\n * `@elytracms/forms` — forms, the first Elytra addon (EC-613, on the addon contract of\n * EC-616 and the public ingest seam of EC-617).\n *\n * A site's form post becomes a SUBMISSION — a row in the project's record store that the\n * studio lists in an inbox — and its mail is a scheduled delivery with a per-recipient log\n * on that row, so a failed mail is a visible issue on a submission that exists, never a lost\n * message. Uploads go into the deployment's storage, claimed by the submission.\n *\n * This entry holds what every plane shares — the record kind, the form source, the forms\n * wire and the pure rules. The planes:\n *\n * | Entry | What it holds | How the project wires it |\n * | --- | --- | --- |\n * | `@elytracms/forms/convex` | the kind's functions, the receiver's handler, the delivery | `studio/convex/forms.ts`: `export * from '@elytracms/forms/convex'`, and `ingest: { submission: formsIngest }` in `studio/convex/http.ts` |\n * | `@elytracms/forms/studio` | the inbox | `formsInbox({ source })` in `defineStudio({ sidebar })` |\n * | `@elytracms/forms/next` | the browser submit, the no-JS form post, the delivery route | `frontend/app/api/forms/route.ts` (+ the ingest route) |\n */\nexport {\n DELIVERY_LOG_LIMIT,\n DELIVERY_STATES,\n FORM_VALUE_MAX_LENGTH,\n SPAM_VERDICTS,\n SUBMISSION_EDITOR_COLUMNS,\n SUBMISSION_STATUSES,\n deliveryEntrySchema,\n formValueSchema,\n submissionFieldSchema,\n submissionFileSchema,\n submissionRecordKind,\n} from './shared/kind'\nexport type {\n DeliveryEntry,\n DeliveryState,\n FormValue,\n SpamVerdict,\n SubmissionEditorColumn,\n SubmissionField,\n SubmissionFile,\n SubmissionRecord,\n SubmissionStatus,\n} from './shared/kind'\nexport { defaultReplyTo, defineFormSource, isEmailAddress } from './shared/source'\nexport type { FormDocument, FormFieldLabel, FormSource, FormSourceConfig } from './shared/source'\nexport {\n DEFAULT_HONEYPOT_FIELD,\n FORMS_HONEYPOT_KEY,\n FORMS_RESERVED_PREFIX,\n FORMS_SOURCE_KEY,\n FORMS_TARGET_FIELD,\n FORMS_UPLOAD_CONTENT_TYPES,\n formatFormValue,\n readSubmissionPayload,\n FORM_FIELD_NAME_MAX,\n formFieldNameMessage,\n formFieldNameProblem,\n} from './shared/submission'\nexport type { SubmissionColumns, SubmissionRead } from './shared/submission'\nexport {\n DELIVERY_BACKOFF_MS,\n DELIVERY_MAX_ATTEMPTS,\n DELIVERY_TIMEOUT_MS,\n FORMS_DELIVERY_SIGNATURE_HEADER,\n FORMS_DELIVERY_URL_ENV,\n applyDeliveryOutcome,\n attemptFormDelivery,\n deliveredRecipients,\n deliveryNeedsAttention,\n deliveryOutcomeSchema,\n deliveryResultSchema,\n formDeliveryRequestSchema,\n formDeliveryResponseSchema,\n signedFormDeliveryRequest,\n verifyFormDeliverySignature,\n} from './shared/delivery'\nexport type {\n DeliveryOutcome,\n DeliveryResult,\n DeliveryStep,\n FormDeliveryRequest,\n FormDeliveryResponse,\n} from './shared/delivery'\nexport { labelPayload, plainLabel, submissionMailText } from './shared/mail'\nexport type { LabelledField } from './shared/mail'\n"]}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@elytracms/forms/next` — the forms addon's SITE half (EC-613): how a form post reaches the
3
+ * project, and how its mail leaves the site.
4
+ *
5
+ * - {@link submitForm} — the browser submit (a client component): files go straight to the
6
+ * deployment's storage through the ingest seam's upload URLs, then the submission;
7
+ * - {@link createFormPostRoute} — the same calls for a form posted without JavaScript, with a
8
+ * 303 back to the page and an outcome flag;
9
+ * - {@link formsDeliveryRoute} / {@link createFormsDeliveryRoute} — the route the deployment's
10
+ * scheduled delivery calls: the recipients from the published form document, one mail per
11
+ * recipient through YOUR transport, answered per recipient.
12
+ *
13
+ * The ingest route itself is the engine's (`elytra.ingestRoute()`, EC-617) — its gate,
14
+ * limiter and signature cover every call these helpers make.
15
+ */
16
+ export { formPayload, sendFormSubmission, submitForm } from './submit.js';
17
+ export type { FormPayload, FormPayloadOptions, SubmitResult, SubmitTransport } from './submit.js';
18
+ export { createFormPostRoute, createFormsDeliveryRoute, formPostOutcome, formsDeliveryRoute, plainLabel } from './routes.js';
19
+ export type { FormMail, FormMailTransport, FormPostOutcome, FormPostRouteOptions, FormsDeliveryHost, FormsDeliveryRouteOptions, IngestRouteLike, } from './routes.js';
20
+ export { DEFAULT_HONEYPOT_FIELD, FORMS_TARGET_FIELD } from '../shared/submission.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@elytracms/forms/next` — the forms addon's SITE half (EC-613): how a form post reaches the
3
+ * project, and how its mail leaves the site.
4
+ *
5
+ * - {@link submitForm} — the browser submit (a client component): files go straight to the
6
+ * deployment's storage through the ingest seam's upload URLs, then the submission;
7
+ * - {@link createFormPostRoute} — the same calls for a form posted without JavaScript, with a
8
+ * 303 back to the page and an outcome flag;
9
+ * - {@link formsDeliveryRoute} / {@link createFormsDeliveryRoute} — the route the deployment's
10
+ * scheduled delivery calls: the recipients from the published form document, one mail per
11
+ * recipient through YOUR transport, answered per recipient.
12
+ *
13
+ * The ingest route itself is the engine's (`elytra.ingestRoute()`, EC-617) — its gate,
14
+ * limiter and signature cover every call these helpers make.
15
+ */
16
+ export { formPayload, sendFormSubmission, submitForm } from './submit.js';
17
+ export { createFormPostRoute, createFormsDeliveryRoute, formPostOutcome, formsDeliveryRoute, plainLabel } from './routes.js';
18
+ export { DEFAULT_HONEYPOT_FIELD, FORMS_TARGET_FIELD } from '../shared/submission.js';
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/next/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAEtE,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,eAAe,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAUzH,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA","sourcesContent":["/**\n * `@elytracms/forms/next` — the forms addon's SITE half (EC-613): how a form post reaches the\n * project, and how its mail leaves the site.\n *\n * - {@link submitForm} — the browser submit (a client component): files go straight to the\n * deployment's storage through the ingest seam's upload URLs, then the submission;\n * - {@link createFormPostRoute} — the same calls for a form posted without JavaScript, with a\n * 303 back to the page and an outcome flag;\n * - {@link formsDeliveryRoute} / {@link createFormsDeliveryRoute} — the route the deployment's\n * scheduled delivery calls: the recipients from the published form document, one mail per\n * recipient through YOUR transport, answered per recipient.\n *\n * The ingest route itself is the engine's (`elytra.ingestRoute()`, EC-617) — its gate,\n * limiter and signature cover every call these helpers make.\n */\nexport { formPayload, sendFormSubmission, submitForm } from './submit'\nexport type { FormPayload, FormPayloadOptions, SubmitResult, SubmitTransport } from './submit'\nexport { createFormPostRoute, createFormsDeliveryRoute, formPostOutcome, formsDeliveryRoute, plainLabel } from './routes'\nexport type {\n FormMail,\n FormMailTransport,\n FormPostOutcome,\n FormPostRouteOptions,\n FormsDeliveryHost,\n FormsDeliveryRouteOptions,\n IngestRouteLike,\n} from './routes'\nexport { DEFAULT_HONEYPOT_FIELD, FORMS_TARGET_FIELD } from '../shared/submission'\n"]}
@@ -0,0 +1,145 @@
1
+ import type { SubmissionField } from '../shared/kind.js';
2
+ import type { FormDocument, FormSource } from '../shared/source.js';
3
+ import { plainLabel } from '../shared/mail.js';
4
+ import type { LabelledField } from '../shared/mail.js';
5
+ import type { SubmitResult } from './submit.js';
6
+ /**
7
+ * EC-613 — the forms addon's two SITE routes:
8
+ *
9
+ * - {@link createFormPostRoute} — a form posted WITHOUT JavaScript (`<form method="post">`,
10
+ * both consumers' forms) turned into the same ingest calls the browser helper sends, then a
11
+ * 303 back to the page with the outcome (`?form=ok|invalid|upload|busy|refused|failed`). Its
12
+ * files pass through the site's function, so they are bound by the host's request body
13
+ * limit; `submitForm` (JavaScript) sends the bytes straight to storage instead;
14
+ * - {@link createFormsDeliveryRoute} — the route the deployment's scheduled delivery calls:
15
+ * it verifies the deployment's signature, resolves the form and its RECIPIENTS from the
16
+ * published form document (never from the payload), and hands one mail per recipient to
17
+ * the project's TRANSPORT (any vendor — the addon supplies the slot, never the vendor). It
18
+ * answers per recipient; the deployment logs the answer on the row.
19
+ */
20
+ /** The ingest route a form-post route sends through (`elytra.ingestRoute()`). */
21
+ export interface IngestRouteLike {
22
+ POST(request: Request): Promise<Response>;
23
+ }
24
+ /** The `?form=` outcome of a no-JS post. */
25
+ export type FormPostOutcome = 'ok' | 'invalid' | 'upload' | 'busy' | 'refused' | 'failed';
26
+ /** Map a submit result onto the outcome flag a page shows. */
27
+ export declare function formPostOutcome(result: SubmitResult): FormPostOutcome;
28
+ export interface FormPostRouteOptions {
29
+ /** The site's ingest route — `elytra.ingestRoute()` (its gate, limiter and signature apply). */
30
+ ingest: IngestRouteLike;
31
+ /** The honeypot field the forms render (default `website`). */
32
+ honeypot?: string;
33
+ /** The query parameter the outcome is reported in (default `form`). */
34
+ param?: string;
35
+ /** Transport for the file bytes (tests). */
36
+ fetchImpl?: typeof fetch;
37
+ }
38
+ /**
39
+ * The no-JS form post: the form names its target in a hidden `elytra:form` input
40
+ * (`<input type="hidden" name="elytra:form" value="contact">`).
41
+ *
42
+ * ```ts
43
+ * // frontend/app/api/form/route.ts
44
+ * export const { POST } = createFormPostRoute({ ingest: elytra.ingestRoute() })
45
+ * ```
46
+ */
47
+ export declare function createFormPostRoute(options: FormPostRouteOptions): {
48
+ POST(request: Request): Promise<Response>;
49
+ };
50
+ /** One mail the transport sends — to ONE recipient. */
51
+ export interface FormMail {
52
+ to: string;
53
+ subject: string;
54
+ /** A plain-text body (labelled fields, file links, time, page). Write your own from `submission` if you prefer. */
55
+ text: string;
56
+ replyTo?: string;
57
+ /** The submission's files, by URL — attach them (nodemailer and Resend both take a URL) or link them. */
58
+ attachments: {
59
+ filename: string;
60
+ contentType: string;
61
+ size: number;
62
+ url: string;
63
+ }[];
64
+ form: {
65
+ key: string;
66
+ title?: string;
67
+ };
68
+ submission: {
69
+ id: string;
70
+ payload: SubmissionField[];
71
+ /** The payload labelled by the form, in the form's order. */
72
+ fields: LabelledField[];
73
+ sourceUrl?: string;
74
+ submittedAt: string;
75
+ };
76
+ }
77
+ /**
78
+ * The project's mail transport: send one mail, resolve (optionally with the vendor's message
79
+ * id) when it was accepted, THROW when it was not — the error message is logged on the row.
80
+ */
81
+ export type FormMailTransport = (mail: FormMail) => Promise<{
82
+ id?: string;
83
+ } | void>;
84
+ export interface FormsDeliveryRouteOptions<V> {
85
+ /** The ingest secret (`INGEST_SECRET` — equal to the deployment's `ELYTRA_INGEST_SECRET`). Unset → 503. */
86
+ secret: string | undefined;
87
+ /** How the addon reads your form documents. */
88
+ source: FormSource<V>;
89
+ /** The PUBLISHED form documents (the site's content read). A throw is answered 503 (the deployment retries). */
90
+ forms: () => Promise<readonly FormDocument<V>[]>;
91
+ transport: FormMailTransport;
92
+ /** The locale a localized field label is written in (default: its first entry). */
93
+ locale?: string;
94
+ /** The subject line (default `New submission: <form title>`). */
95
+ subject?: (form: {
96
+ key: string;
97
+ title?: string;
98
+ }) => string;
99
+ }
100
+ /** The route the deployment's delivery calls (see the module header). */
101
+ export declare function createFormsDeliveryRoute<V>(options: FormsDeliveryRouteOptions<V>): {
102
+ POST(request: Request): Promise<Response>;
103
+ };
104
+ /** The slice of the delivery kit (`createElytraHost`'s handle) the delivery route reads. */
105
+ export interface FormsDeliveryHost {
106
+ env: {
107
+ ingestSecret?: string | undefined;
108
+ };
109
+ loadContent(request: {
110
+ perspective: 'published';
111
+ }): Promise<{
112
+ client(init: {
113
+ perspective: 'published';
114
+ }): {
115
+ listDocuments(collection: string, query?: {
116
+ cursor?: string;
117
+ }): {
118
+ ok: true;
119
+ documents: readonly {
120
+ id: string;
121
+ }[];
122
+ nextCursor: string | null;
123
+ } | {
124
+ ok: false;
125
+ errors: readonly {
126
+ message: string;
127
+ }[];
128
+ };
129
+ };
130
+ }>;
131
+ }
132
+ /**
133
+ * The delivery route over the site's own kit — the published forms through its content read,
134
+ * the secret from its env:
135
+ *
136
+ * ```ts
137
+ * // frontend/app/api/forms/route.ts
138
+ * export const { POST } = formsDeliveryRoute(elytra, { source: contactForms, transport: sendWithResend })
139
+ * ```
140
+ */
141
+ export declare function formsDeliveryRoute<V>(host: FormsDeliveryHost, options: Omit<FormsDeliveryRouteOptions<V>, 'secret' | 'forms'>): {
142
+ POST(request: Request): Promise<Response>;
143
+ };
144
+ /** A label as the mail writes it (re-exported for transports that compose their own body). */
145
+ export { plainLabel };
@@ -0,0 +1,214 @@
1
+ import { FORMS_TARGET_FIELD } from '../shared/submission.js';
2
+ import { defaultReplyTo } from '../shared/source.js';
3
+ import { labelPayload, plainLabel, submissionMailText } from '../shared/mail.js';
4
+ import { formDeliveryRequestSchema, verifyFormDeliverySignature, FORMS_DELIVERY_SIGNATURE_HEADER } from '../shared/delivery.js';
5
+ import { formPayload, sendFormSubmission } from './submit.js';
6
+ /** Map a submit result onto the outcome flag a page shows. */
7
+ export function formPostOutcome(result) {
8
+ if (result.ok)
9
+ return 'ok';
10
+ if (result.status === 413 || result.status === 415 || result.stage === 'storage')
11
+ return 'upload';
12
+ if (result.status === 400 || result.status === 404 || result.status === 422)
13
+ return 'invalid';
14
+ if (result.status === 429)
15
+ return 'busy';
16
+ if (result.status === 403)
17
+ return 'refused';
18
+ return 'failed';
19
+ }
20
+ /** 303 back to the page the form was posted from (same-origin `Referer` only, else `/`). */
21
+ function redirectBack(request, param, outcome) {
22
+ let path = '/';
23
+ const referer = request.headers.get('referer');
24
+ if (referer) {
25
+ try {
26
+ const url = new URL(referer);
27
+ if (url.origin === new URL(request.url).origin)
28
+ path = url.pathname;
29
+ }
30
+ catch {
31
+ // a malformed referer → '/'
32
+ }
33
+ }
34
+ const target = new URL(path, request.url);
35
+ target.searchParams.set(param, outcome);
36
+ return Response.redirect(target, 303);
37
+ }
38
+ /** The same-origin page a post came from, as the submission's `$source`. */
39
+ function sourceOf(request) {
40
+ const referer = request.headers.get('referer');
41
+ if (!referer)
42
+ return undefined;
43
+ try {
44
+ const url = new URL(referer);
45
+ return url.origin === new URL(request.url).origin ? url.href : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * The no-JS form post: the form names its target in a hidden `elytra:form` input
53
+ * (`<input type="hidden" name="elytra:form" value="contact">`).
54
+ *
55
+ * ```ts
56
+ * // frontend/app/api/form/route.ts
57
+ * export const { POST } = createFormPostRoute({ ingest: elytra.ingestRoute() })
58
+ * ```
59
+ */
60
+ export function createFormPostRoute(options) {
61
+ const param = options.param ?? 'form';
62
+ const fetchImpl = options.fetchImpl ?? fetch;
63
+ return {
64
+ async POST(request) {
65
+ let formData;
66
+ try {
67
+ formData = await request.formData();
68
+ }
69
+ catch {
70
+ return redirectBack(request, param, 'invalid');
71
+ }
72
+ const target = formData.get(FORMS_TARGET_FIELD);
73
+ if (typeof target !== 'string' || target.length === 0)
74
+ return redirectBack(request, param, 'invalid');
75
+ const sourceUrl = sourceOf(request);
76
+ const payload = formPayload(formData.entries(), {
77
+ ...(options.honeypot ? { honeypot: options.honeypot } : {}),
78
+ ...(sourceUrl ? { sourceUrl } : {}),
79
+ });
80
+ // Every ingest call carries the visitor's own request headers (origin, client address
81
+ // for the limiter, whatever the bot gate reads) — only the body is the payload.
82
+ const headers = new Headers(request.headers);
83
+ headers.delete('content-length');
84
+ headers.set('content-type', 'application/json');
85
+ const result = await sendFormSubmission({
86
+ target,
87
+ payload,
88
+ transport: {
89
+ ingest: (body) => options.ingest.POST(new Request(request.url, { method: 'POST', headers, body: JSON.stringify(body) })),
90
+ store: (uploadUrl, file, contentType) => fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),
91
+ },
92
+ });
93
+ return redirectBack(request, param, formPostOutcome(result));
94
+ },
95
+ };
96
+ }
97
+ const MAX_DELIVERY_BODY = 512 * 1024;
98
+ function json(status, body) {
99
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
100
+ }
101
+ /** The route the deployment's delivery calls (see the module header). */
102
+ export function createFormsDeliveryRoute(options) {
103
+ return {
104
+ async POST(request) {
105
+ if (!options.secret)
106
+ return json(503, { error: 'Form delivery is not configured on this site (INGEST_SECRET is unset).' });
107
+ const raw = await request.text();
108
+ if (raw.length > MAX_DELIVERY_BODY)
109
+ return json(413, { error: 'The delivery request is too large.' });
110
+ if (!(await verifyFormDeliverySignature(raw, request.headers.get(FORMS_DELIVERY_SIGNATURE_HEADER), options.secret))) {
111
+ return json(401, { error: 'Invalid or missing signature.' });
112
+ }
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(raw);
116
+ }
117
+ catch {
118
+ return json(400, { error: 'The delivery request is not JSON.' });
119
+ }
120
+ const read = formDeliveryRequestSchema.safeParse(parsed);
121
+ if (!read.success)
122
+ return json(400, { error: 'The delivery request has an invalid shape.' });
123
+ const delivery = read.data;
124
+ let forms;
125
+ try {
126
+ forms = await options.forms();
127
+ }
128
+ catch (error) {
129
+ return json(503, { error: `The site could not read its forms: ${error instanceof Error ? error.message : String(error)}` });
130
+ }
131
+ const form = options.source.find(forms, delivery.form);
132
+ if (!form)
133
+ return json(404, { error: `No published form “${delivery.form}” in “${options.source.collection}”.` });
134
+ const title = options.source.title?.(form.values);
135
+ const formInfo = { key: delivery.form, ...(title ? { title } : {}) };
136
+ const fields = labelPayload(delivery.payload, options.source.fields?.(form.values) ?? []);
137
+ const replyTo = (options.source.replyTo ?? defaultReplyTo)(delivery.payload);
138
+ const mail = {
139
+ subject: options.subject?.(formInfo) ?? `New submission: ${title ?? delivery.form}`,
140
+ text: submissionMailText({
141
+ ...(title ? { formTitle: title } : {}),
142
+ fields,
143
+ files: delivery.files,
144
+ ...(delivery.sourceUrl ? { sourceUrl: delivery.sourceUrl } : {}),
145
+ submittedAt: delivery.submittedAt,
146
+ ...(options.locale ? { locale: options.locale } : {}),
147
+ }),
148
+ ...(replyTo ? { replyTo } : {}),
149
+ attachments: delivery.files.map((file) => ({
150
+ filename: file.name ?? file.field,
151
+ contentType: file.contentType,
152
+ size: file.size,
153
+ url: file.url,
154
+ })),
155
+ form: formInfo,
156
+ submission: {
157
+ id: delivery.submissionId,
158
+ payload: delivery.payload,
159
+ fields,
160
+ ...(delivery.sourceUrl ? { sourceUrl: delivery.sourceUrl } : {}),
161
+ submittedAt: delivery.submittedAt,
162
+ },
163
+ };
164
+ const already = new Set(delivery.delivered);
165
+ const results = [];
166
+ for (const to of options.source.recipientsOf(form)) {
167
+ if (already.has(to))
168
+ continue;
169
+ try {
170
+ const sent = await options.transport({ to, ...mail });
171
+ results.push({ to, ok: true, ...(sent && sent.id ? { id: sent.id } : {}) });
172
+ }
173
+ catch (error) {
174
+ results.push({ to, ok: false, error: error instanceof Error ? error.message : String(error) });
175
+ }
176
+ }
177
+ return json(200, { results });
178
+ },
179
+ };
180
+ }
181
+ /**
182
+ * The delivery route over the site's own kit — the published forms through its content read,
183
+ * the secret from its env:
184
+ *
185
+ * ```ts
186
+ * // frontend/app/api/forms/route.ts
187
+ * export const { POST } = formsDeliveryRoute(elytra, { source: contactForms, transport: sendWithResend })
188
+ * ```
189
+ */
190
+ export function formsDeliveryRoute(host, options) {
191
+ return createFormsDeliveryRoute({
192
+ ...options,
193
+ secret: host.env.ingestSecret,
194
+ forms: async () => {
195
+ const content = await host.loadContent({ perspective: 'published' });
196
+ const client = content.client({ perspective: 'published' });
197
+ const out = [];
198
+ let cursor;
199
+ do {
200
+ const listed = client.listDocuments(options.source.collection, cursor ? { cursor } : undefined);
201
+ if (!listed.ok)
202
+ throw new Error(listed.errors.map((error) => error.message).join(' ') || 'the forms could not be listed');
203
+ // A delivered document is flat (`{ id, collection, …fields }`) — its fields are the values.
204
+ for (const document of listed.documents)
205
+ out.push({ id: document.id, values: document });
206
+ cursor = listed.nextCursor ?? undefined;
207
+ } while (cursor);
208
+ return out;
209
+ },
210
+ });
211
+ }
212
+ /** A label as the mail writes it (re-exported for transports that compose their own body). */
213
+ export { plainLabel };
214
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/next/routes.ts"],"names":[],"mappings":"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","sourcesContent":["import { FORMS_TARGET_FIELD } from '../shared/submission'\nimport type { SubmissionField } from '../shared/kind'\nimport type { FormDocument, FormSource } from '../shared/source'\nimport { defaultReplyTo } from '../shared/source'\nimport { labelPayload, plainLabel, submissionMailText } from '../shared/mail'\nimport type { LabelledField } from '../shared/mail'\nimport { formDeliveryRequestSchema, verifyFormDeliverySignature, FORMS_DELIVERY_SIGNATURE_HEADER } from '../shared/delivery'\nimport type { DeliveryResult } from '../shared/delivery'\nimport { formPayload, sendFormSubmission } from './submit'\nimport type { SubmitResult } from './submit'\n\n/**\n * EC-613 — the forms addon's two SITE routes:\n *\n * - {@link createFormPostRoute} — a form posted WITHOUT JavaScript (`<form method=\"post\">`,\n * both consumers' forms) turned into the same ingest calls the browser helper sends, then a\n * 303 back to the page with the outcome (`?form=ok|invalid|upload|busy|refused|failed`). Its\n * files pass through the site's function, so they are bound by the host's request body\n * limit; `submitForm` (JavaScript) sends the bytes straight to storage instead;\n * - {@link createFormsDeliveryRoute} — the route the deployment's scheduled delivery calls:\n * it verifies the deployment's signature, resolves the form and its RECIPIENTS from the\n * published form document (never from the payload), and hands one mail per recipient to\n * the project's TRANSPORT (any vendor — the addon supplies the slot, never the vendor). It\n * answers per recipient; the deployment logs the answer on the row.\n */\n\n/** The ingest route a form-post route sends through (`elytra.ingestRoute()`). */\nexport interface IngestRouteLike {\n POST(request: Request): Promise<Response>\n}\n\n/** The `?form=` outcome of a no-JS post. */\nexport type FormPostOutcome = 'ok' | 'invalid' | 'upload' | 'busy' | 'refused' | 'failed'\n\n/** Map a submit result onto the outcome flag a page shows. */\nexport function formPostOutcome(result: SubmitResult): FormPostOutcome {\n if (result.ok) return 'ok'\n if (result.status === 413 || result.status === 415 || result.stage === 'storage') return 'upload'\n if (result.status === 400 || result.status === 404 || result.status === 422) return 'invalid'\n if (result.status === 429) return 'busy'\n if (result.status === 403) return 'refused'\n return 'failed'\n}\n\n/** 303 back to the page the form was posted from (same-origin `Referer` only, else `/`). */\nfunction redirectBack(request: Request, param: string, outcome: FormPostOutcome): Response {\n let path = '/'\n const referer = request.headers.get('referer')\n if (referer) {\n try {\n const url = new URL(referer)\n if (url.origin === new URL(request.url).origin) path = url.pathname\n } catch {\n // a malformed referer → '/'\n }\n }\n const target = new URL(path, request.url)\n target.searchParams.set(param, outcome)\n return Response.redirect(target, 303)\n}\n\n/** The same-origin page a post came from, as the submission's `$source`. */\nfunction sourceOf(request: Request): string | undefined {\n const referer = request.headers.get('referer')\n if (!referer) return undefined\n try {\n const url = new URL(referer)\n return url.origin === new URL(request.url).origin ? url.href : undefined\n } catch {\n return undefined\n }\n}\n\nexport interface FormPostRouteOptions {\n /** The site's ingest route — `elytra.ingestRoute()` (its gate, limiter and signature apply). */\n ingest: IngestRouteLike\n /** The honeypot field the forms render (default `website`). */\n honeypot?: string\n /** The query parameter the outcome is reported in (default `form`). */\n param?: string\n /** Transport for the file bytes (tests). */\n fetchImpl?: typeof fetch\n}\n\n/**\n * The no-JS form post: the form names its target in a hidden `elytra:form` input\n * (`<input type=\"hidden\" name=\"elytra:form\" value=\"contact\">`).\n *\n * ```ts\n * // frontend/app/api/form/route.ts\n * export const { POST } = createFormPostRoute({ ingest: elytra.ingestRoute() })\n * ```\n */\nexport function createFormPostRoute(options: FormPostRouteOptions): { POST(request: Request): Promise<Response> } {\n const param = options.param ?? 'form'\n const fetchImpl = options.fetchImpl ?? fetch\n return {\n async POST(request: Request): Promise<Response> {\n let formData: FormData\n try {\n formData = await request.formData()\n } catch {\n return redirectBack(request, param, 'invalid')\n }\n const target = formData.get(FORMS_TARGET_FIELD)\n if (typeof target !== 'string' || target.length === 0) return redirectBack(request, param, 'invalid')\n const sourceUrl = sourceOf(request)\n const payload = formPayload(formData.entries() as Iterable<[string, string | Blob]>, {\n ...(options.honeypot ? { honeypot: options.honeypot } : {}),\n ...(sourceUrl ? { sourceUrl } : {}),\n })\n // Every ingest call carries the visitor's own request headers (origin, client address\n // for the limiter, whatever the bot gate reads) — only the body is the payload.\n const headers = new Headers(request.headers)\n headers.delete('content-length')\n headers.set('content-type', 'application/json')\n const result = await sendFormSubmission({\n target,\n payload,\n transport: {\n ingest: (body) => options.ingest.POST(new Request(request.url, { method: 'POST', headers, body: JSON.stringify(body) })),\n store: (uploadUrl, file, contentType) =>\n fetchImpl(uploadUrl, { method: 'POST', headers: { 'content-type': contentType }, body: file }),\n },\n })\n return redirectBack(request, param, formPostOutcome(result))\n },\n }\n}\n\n// --- Delivery --------------------------------------------------------------------------------\n\n/** One mail the transport sends — to ONE recipient. */\nexport interface FormMail {\n to: string\n subject: string\n /** A plain-text body (labelled fields, file links, time, page). Write your own from `submission` if you prefer. */\n text: string\n replyTo?: string\n /** The submission's files, by URL — attach them (nodemailer and Resend both take a URL) or link them. */\n attachments: { filename: string; contentType: string; size: number; url: string }[]\n form: { key: string; title?: string }\n submission: {\n id: string\n payload: SubmissionField[]\n /** The payload labelled by the form, in the form's order. */\n fields: LabelledField[]\n sourceUrl?: string\n submittedAt: string\n }\n}\n\n/**\n * The project's mail transport: send one mail, resolve (optionally with the vendor's message\n * id) when it was accepted, THROW when it was not — the error message is logged on the row.\n */\nexport type FormMailTransport = (mail: FormMail) => Promise<{ id?: string } | void>\n\nexport interface FormsDeliveryRouteOptions<V> {\n /** The ingest secret (`INGEST_SECRET` — equal to the deployment's `ELYTRA_INGEST_SECRET`). Unset → 503. */\n secret: string | undefined\n /** How the addon reads your form documents. */\n source: FormSource<V>\n /** The PUBLISHED form documents (the site's content read). A throw is answered 503 (the deployment retries). */\n forms: () => Promise<readonly FormDocument<V>[]>\n transport: FormMailTransport\n /** The locale a localized field label is written in (default: its first entry). */\n locale?: string\n /** The subject line (default `New submission: <form title>`). */\n subject?: (form: { key: string; title?: string }) => string\n}\n\nconst MAX_DELIVERY_BODY = 512 * 1024\n\nfunction json(status: number, body: Record<string, unknown>): Response {\n return Response.json(body, { status, headers: { 'cache-control': 'no-store' } })\n}\n\n/** The route the deployment's delivery calls (see the module header). */\nexport function createFormsDeliveryRoute<V>(options: FormsDeliveryRouteOptions<V>): { POST(request: Request): Promise<Response> } {\n return {\n async POST(request: Request): Promise<Response> {\n if (!options.secret) return json(503, { error: 'Form delivery is not configured on this site (INGEST_SECRET is unset).' })\n const raw = await request.text()\n if (raw.length > MAX_DELIVERY_BODY) return json(413, { error: 'The delivery request is too large.' })\n if (!(await verifyFormDeliverySignature(raw, request.headers.get(FORMS_DELIVERY_SIGNATURE_HEADER), options.secret))) {\n return json(401, { error: 'Invalid or missing signature.' })\n }\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return json(400, { error: 'The delivery request is not JSON.' })\n }\n const read = formDeliveryRequestSchema.safeParse(parsed)\n if (!read.success) return json(400, { error: 'The delivery request has an invalid shape.' })\n const delivery = read.data\n\n let forms: readonly FormDocument<V>[]\n try {\n forms = await options.forms()\n } catch (error) {\n return json(503, { error: `The site could not read its forms: ${error instanceof Error ? error.message : String(error)}` })\n }\n const form = options.source.find(forms, delivery.form)\n if (!form) return json(404, { error: `No published form “${delivery.form}” in “${options.source.collection}”.` })\n\n const title = options.source.title?.(form.values)\n const formInfo = { key: delivery.form, ...(title ? { title } : {}) }\n const fields = labelPayload(delivery.payload, options.source.fields?.(form.values) ?? [])\n const replyTo = (options.source.replyTo ?? defaultReplyTo)(delivery.payload)\n const mail = {\n subject: options.subject?.(formInfo) ?? `New submission: ${title ?? delivery.form}`,\n text: submissionMailText({\n ...(title ? { formTitle: title } : {}),\n fields,\n files: delivery.files,\n ...(delivery.sourceUrl ? { sourceUrl: delivery.sourceUrl } : {}),\n submittedAt: delivery.submittedAt,\n ...(options.locale ? { locale: options.locale } : {}),\n }),\n ...(replyTo ? { replyTo } : {}),\n attachments: delivery.files.map((file) => ({\n filename: file.name ?? file.field,\n contentType: file.contentType,\n size: file.size,\n url: file.url,\n })),\n form: formInfo,\n submission: {\n id: delivery.submissionId,\n payload: delivery.payload,\n fields,\n ...(delivery.sourceUrl ? { sourceUrl: delivery.sourceUrl } : {}),\n submittedAt: delivery.submittedAt,\n },\n }\n\n const already = new Set(delivery.delivered)\n const results: DeliveryResult[] = []\n for (const to of options.source.recipientsOf(form)) {\n if (already.has(to)) continue\n try {\n const sent = await options.transport({ to, ...mail })\n results.push({ to, ok: true, ...(sent && sent.id ? { id: sent.id } : {}) })\n } catch (error) {\n results.push({ to, ok: false, error: error instanceof Error ? error.message : String(error) })\n }\n }\n return json(200, { results })\n },\n }\n}\n\n/** The slice of the delivery kit (`createElytraHost`'s handle) the delivery route reads. */\nexport interface FormsDeliveryHost {\n env: { ingestSecret?: string | undefined }\n loadContent(request: { perspective: 'published' }): Promise<{\n client(init: { perspective: 'published' }): {\n listDocuments(\n collection: string,\n query?: { cursor?: string },\n ):\n | { ok: true; documents: readonly { id: string }[]; nextCursor: string | null }\n | { ok: false; errors: readonly { message: string }[] }\n }\n }>\n}\n\n/**\n * The delivery route over the site's own kit — the published forms through its content read,\n * the secret from its env:\n *\n * ```ts\n * // frontend/app/api/forms/route.ts\n * export const { POST } = formsDeliveryRoute(elytra, { source: contactForms, transport: sendWithResend })\n * ```\n */\nexport function formsDeliveryRoute<V>(\n host: FormsDeliveryHost,\n options: Omit<FormsDeliveryRouteOptions<V>, 'secret' | 'forms'>,\n): { POST(request: Request): Promise<Response> } {\n return createFormsDeliveryRoute<V>({\n ...options,\n secret: host.env.ingestSecret,\n forms: async () => {\n const content = await host.loadContent({ perspective: 'published' })\n const client = content.client({ perspective: 'published' })\n const out: FormDocument<V>[] = []\n let cursor: string | undefined\n do {\n const listed = client.listDocuments(options.source.collection, cursor ? { cursor } : undefined)\n if (!listed.ok) throw new Error(listed.errors.map((error) => error.message).join(' ') || 'the forms could not be listed')\n // A delivered document is flat (`{ id, collection, …fields }`) — its fields are the values.\n for (const document of listed.documents) out.push({ id: document.id, values: document as unknown as V })\n cursor = listed.nextCursor ?? undefined\n } while (cursor)\n return out\n },\n })\n}\n\n/** A label as the mail writes it (re-exported for transports that compose their own body). */\nexport { plainLabel }\n"]}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * EC-613 — a form post as the ingest seam's calls (EC-617): one `upload` per file (the route
3
+ * asks the receiver for ONE short-lived storage URL, the bytes go straight to it), then one
4
+ * `submission` that claims the stored files. The ONE flow, with injected transports, so the
5
+ * browser (`submitForm`) and the site's no-JS form-post route (`createFormPostRoute`) send
6
+ * exactly the same calls.
7
+ */
8
+ /** A posted value: text, or a file (a `File` / `Blob` with a name). */
9
+ type PostedValue = string | Blob;
10
+ /** The form's fields as the ingest payload carries them, and the files to upload. */
11
+ export interface FormPayload {
12
+ data: Record<string, string | string[]>;
13
+ files: {
14
+ field: string;
15
+ file: Blob;
16
+ name: string;
17
+ }[];
18
+ }
19
+ export interface FormPayloadOptions {
20
+ /** The honeypot field the form renders (default `website`); its value goes to `elytra:honeypot`. */
21
+ honeypot?: string;
22
+ /** The page the form was submitted from (`$source`). */
23
+ sourceUrl?: string;
24
+ }
25
+ /**
26
+ * Read a form's entries (pure): repeated names become a list, the honeypot moves to
27
+ * `elytra:honeypot`, `elytra:form` (the no-JS target input) is not a field, and every non-empty file is
28
+ * an upload whose NAME is the field's value (what the inbox and the mail list). A file input
29
+ * left empty posts a nameless zero-byte part — it is skipped.
30
+ */
31
+ export declare function formPayload(entries: Iterable<[string, PostedValue]>, options?: FormPayloadOptions): FormPayload;
32
+ /** What a submit came to. */
33
+ export type SubmitResult = {
34
+ ok: true;
35
+ id: string;
36
+ } | {
37
+ ok: false;
38
+ /** Which call failed: an upload URL, the bytes, or the submission. */
39
+ stage: 'upload' | 'storage' | 'submission';
40
+ status: number;
41
+ error: string;
42
+ issues?: {
43
+ path: (string | number)[];
44
+ message: string;
45
+ }[];
46
+ };
47
+ /** The two transports the flow runs over. */
48
+ export interface SubmitTransport {
49
+ /** Send one ingest payload through the site's ingest route; resolve with its answer. */
50
+ ingest(payload: Record<string, unknown>): Promise<Response>;
51
+ /** Send one file's bytes to an issued storage URL; resolve with the storage's answer (`{ storageId }`). */
52
+ store(uploadUrl: string, file: Blob, contentType: string): Promise<Response>;
53
+ }
54
+ /**
55
+ * Send one form post to form `target` (its key — see `defineFormSource`'s `key`): each file
56
+ * uploaded first, then the submission claiming them. The first failure ends it and is
57
+ * returned with its stage and status; nothing is retried here.
58
+ */
59
+ export declare function sendFormSubmission(input: {
60
+ target: string;
61
+ payload: FormPayload;
62
+ transport: SubmitTransport;
63
+ }): Promise<SubmitResult>;
64
+ /**
65
+ * Submit a form from the BROWSER (a client component's submit handler): its fields and files
66
+ * through the site's ingest route (default `/api/ingest`), the files' bytes straight to the
67
+ * deployment's storage — so an upload is not bound by the site's function body limit.
68
+ *
69
+ * ```tsx
70
+ * const result = await submitForm({ form: event.currentTarget, target: 'contact' })
71
+ * ```
72
+ */
73
+ export declare function submitForm(input: {
74
+ form: HTMLFormElement | FormData;
75
+ target: string;
76
+ /** The site's ingest route (default `/api/ingest`). */
77
+ endpoint?: string;
78
+ honeypot?: string;
79
+ /** Default: the current page. */
80
+ sourceUrl?: string;
81
+ fetchImpl?: typeof fetch;
82
+ }): Promise<SubmitResult>;
83
+ export {};