@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,340 @@
1
+ import { internalActionGeneric, makeFunctionReference } from 'convex/server'
2
+ import { v } from 'convex/values'
3
+ import type { GenericId } from 'convex/values'
4
+ import { createUuidIdFactory } from '@elytracms/core/determinism'
5
+ import { INGEST_MAX_UPLOAD_BYTES, INGEST_UPLOAD_TTL_MS } from '@elytracms/core/ingest'
6
+ import type { IngestHandlerResult } from '@elytracms/core/ingest'
7
+ import { resolveRecordQuery } from '@elytracms/core/persistence'
8
+ import type { RecordWireRow } from '@elytracms/core/persistence'
9
+ import { internalMutation, internalQuery, mutation } from '@elytracms/studio/convex/functions'
10
+ import type { MutationCtx } from '@elytracms/studio/convex/functions'
11
+ import { requireCapability } from '@elytracms/studio/convex/guard'
12
+ import {
13
+ claimIngestUpload,
14
+ ingestHandler,
15
+ ingestHandlerArgs,
16
+ ingestUploadRecordKind,
17
+ parseIngestPayload,
18
+ recordIngestUpload,
19
+ } from '@elytracms/studio/convex/ingest'
20
+ import {
21
+ defineRecordFunctions,
22
+ findRecordDoc,
23
+ listRecordWire,
24
+ saveRecord,
25
+ } from '@elytracms/studio/convex/record-functions'
26
+ import { submissionRecordKind } from '../shared/kind'
27
+ import type { DeliveryEntry, DeliveryState, SubmissionField, SubmissionFile } from '../shared/kind'
28
+ import { FORMS_UPLOAD_CONTENT_TYPES, readSubmissionPayload } from '../shared/submission'
29
+ import {
30
+ FORMS_DELIVERY_URL_ENV,
31
+ applyDeliveryOutcome,
32
+ attemptFormDelivery,
33
+ deliveredRecipients,
34
+ deliveryOutcomeSchema,
35
+ } from '../shared/delivery'
36
+ import type { FormDeliveryRequest } from '../shared/delivery'
37
+
38
+ /**
39
+ * `@elytracms/forms/convex` — the deployment half of the forms addon (EC-613). The project
40
+ * reaches it with ONE re-export file, named by the kind's `module`:
41
+ *
42
+ * ```ts
43
+ * // studio/convex/forms.ts
44
+ * export * from '@elytracms/forms/convex'
45
+ * ```
46
+ *
47
+ * …and registers the receiver's handler in its one `http.ts` line:
48
+ *
49
+ * ```ts
50
+ * import { formsIngest } from '@elytracms/forms/convex'
51
+ * export default defineStudioHttp({ auth, ingest: { submission: formsIngest } })
52
+ * ```
53
+ *
54
+ * What it holds:
55
+ *
56
+ * - **the kind's functions** — `get` / `list` (the record store's, EC-616), `save` (the
57
+ * record store's write, plus: a write that moves a row INTO `pending` schedules its
58
+ * delivery — the editor's "retry"), `remove` (the row AND its stored files);
59
+ * - **`ingest`** — the receiver's `submission` handler (EC-617): an `upload` is accepted
60
+ * under the forms upload policy and its intent recorded; a `submission` is read
61
+ * (`readSubmissionPayload`), its uploads claimed, the row written and its delivery
62
+ * scheduled. A claim that fails refuses the submission and deletes the files already
63
+ * claimed for it — no claimed bytes ever sit without a row;
64
+ * - **`deliver`** — the scheduled action per row: it asks the site's delivery route to send
65
+ * (`attemptFormDelivery`) and `recordDelivery` logs the answer per recipient and schedules
66
+ * the next attempt (`applyDeliveryOutcome`);
67
+ * - **`unclaimedUploads`** — internal: the upload intents past their life that no submission
68
+ * claimed (their bytes cannot be located — EC-617's residue — so they are listed, not swept).
69
+ *
70
+ * Environment: `ELYTRA_INGEST_SECRET` (the ingest pair, EC-617) and
71
+ * `ELYTRA_FORMS_DELIVERY_URL` (the site's delivery route, e.g. `https://example.com/api/forms`).
72
+ */
73
+
74
+ const kind = submissionRecordKind
75
+
76
+ /** The functions of this module, addressed by the kind's module (`forms:deliver`). */
77
+ const fn = (name: string) => `${kind.module}:${name}` as const
78
+ const deliverRef = makeFunctionReference<'action', { submissionId: string; attempt: number }>(fn('deliver'))
79
+ const deliveryInputRef = makeFunctionReference<'query', { submissionId: string }, DeliveryInput | null>(fn('deliveryInput'))
80
+ const recordDeliveryRef = makeFunctionReference<'mutation', { submissionId: string; attempt: number; outcome: unknown }>(
81
+ fn('recordDelivery'),
82
+ )
83
+
84
+ /** The receiver's handler, for the project's `http.ts`: `defineStudioHttp({ auth, ingest: { submission: formsIngest } })`. */
85
+ export const formsIngest = ingestHandler(fn('ingest'))
86
+
87
+ /** The forms upload policy (the receiver's 10 MiB cap; documents and raster images only). */
88
+ const UPLOAD_POLICY = { maxBytes: INGEST_MAX_UPLOAD_BYTES, contentTypes: FORMS_UPLOAD_CONTENT_TYPES }
89
+
90
+ function env(name: string): string | undefined {
91
+ const value = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[name]?.trim()
92
+ return value ? value : undefined
93
+ }
94
+
95
+ async function scheduleDelivery(ctx: MutationCtx, submissionId: string, attempt: number, delayMs: number): Promise<void> {
96
+ await ctx.scheduler.runAfter(delayMs, deliverRef, { submissionId, attempt })
97
+ }
98
+
99
+ // --- The kind's functions ------------------------------------------------------------------
100
+
101
+ const records = defineRecordFunctions(kind)
102
+
103
+ /** One submission by id (the record store's `get`). */
104
+ export const get = records.get
105
+ /** The project's submissions, filtered by `form` / `status` and time (the record store's `list`). */
106
+ export const list = records.list
107
+
108
+ const columnArgs = Object.fromEntries(
109
+ [...Object.keys(kind.fields), 'meta'].map((column) => [column, v.optional(v.any())]),
110
+ )
111
+
112
+ /**
113
+ * The record store's write (EC-577: absent keeps, `null` clears, a value sets), and one rule
114
+ * of the forms addon: a write that moves a row INTO `pending` schedules its delivery — how an
115
+ * editor retries a failed one. The inbox writes only the editor's columns it changed, so a
116
+ * save never carries a stale copy of the delivery the action is writing.
117
+ */
118
+ export const save = mutation({
119
+ args: { [kind.idKey]: v.string(), ...columnArgs, createdAt: v.string(), updatedAt: v.string() },
120
+ handler: async (ctx, args: Record<string, unknown>) => {
121
+ await requireCapability(ctx, kind.access.write)
122
+ const { [kind.idKey]: submissionId, createdAt, updatedAt, ...write } = args
123
+ const id = submissionId as string
124
+ const before = await findRecordDoc(ctx, kind, id)
125
+ const row = await saveRecord(ctx, kind, id, write, { createdAt: createdAt as string, updatedAt: updatedAt as string })
126
+ if (row['deliveryState'] === 'pending' && before?.data['deliveryState'] !== 'pending') {
127
+ await scheduleDelivery(ctx, id, 1, 0)
128
+ }
129
+ return row
130
+ },
131
+ })
132
+
133
+ /** Delete a submission AND the files it claimed (no bytes outlive their row). */
134
+ export const remove = mutation({
135
+ args: { [kind.idKey]: v.string() },
136
+ handler: async (ctx, args: Record<string, unknown>) => {
137
+ await requireCapability(ctx, kind.access.write)
138
+ const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
139
+ if (!doc) return
140
+ await deleteFiles(ctx, (doc.data['files'] as SubmissionFile[] | undefined) ?? [])
141
+ await ctx.db.delete(doc._id)
142
+ },
143
+ })
144
+
145
+ async function deleteFiles(ctx: MutationCtx, files: readonly { storageId: string }[]): Promise<void> {
146
+ for (const file of files) {
147
+ const id = file.storageId as GenericId<'_storage'>
148
+ if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
149
+ }
150
+ }
151
+
152
+ // --- The receiver's handler ------------------------------------------------------------------
153
+
154
+ let mintSubmissionUuid: (() => string) | undefined
155
+
156
+ /** The file names a payload field lists (the `./next` helpers send each file's name as the field's value). */
157
+ function fileNames(payload: readonly SubmissionField[], field: string): string[] {
158
+ const value = payload.find((entry) => entry.name === field)?.value
159
+ if (typeof value === 'string') return [value]
160
+ return Array.isArray(value) ? value : []
161
+ }
162
+
163
+ /** The receiver's `submission` handler (and the `upload`s a submission claims) — see the module header. */
164
+ export const ingest = internalMutation({
165
+ args: ingestHandlerArgs,
166
+ handler: async (ctx, { payload, receivedAt }): Promise<IngestHandlerResult> => {
167
+ const body = parseIngestPayload(payload)
168
+ if (body.kind === 'upload') return recordIngestUpload(ctx, body, receivedAt, UPLOAD_POLICY)
169
+ if (body.kind !== 'submission') {
170
+ return { ok: false, status: 422, message: 'The forms addon takes submissions and their uploads only.' }
171
+ }
172
+ const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
173
+ if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
174
+
175
+ // Claim every upload; on the first refusal, delete what this submission already claimed.
176
+ const files: SubmissionFile[] = []
177
+ const namesUsed = new Map<string, number>()
178
+ for (const claim of body.uploads ?? []) {
179
+ const claimed = await claimIngestUpload(ctx, claim, body.target, receivedAt)
180
+ if (!claimed.ok) {
181
+ await deleteFiles(ctx, files)
182
+ return claimed
183
+ }
184
+ const index = namesUsed.get(claimed.field) ?? 0
185
+ namesUsed.set(claimed.field, index + 1)
186
+ const name = fileNames(read.columns.payload, claimed.field)[index]
187
+ const url = await ctx.storage.getUrl(claimed.storageId as GenericId<'_storage'>)
188
+ files.push({
189
+ field: claimed.field,
190
+ ...(name ? { name } : {}),
191
+ storageId: claimed.storageId,
192
+ contentType: claimed.contentType,
193
+ size: claimed.size,
194
+ ...(url ? { url } : {}),
195
+ })
196
+ }
197
+
198
+ // EC-578: the one id factory (UUID v4) — never a clock or a counter.
199
+ mintSubmissionUuid ??= createUuidIdFactory()
200
+ const id = `${kind.idPrefix}_${mintSubmissionUuid()}`
201
+ const spam = read.columns.spam !== 'clean'
202
+ const deliveryState: DeliveryState = spam ? 'skipped' : 'pending'
203
+ await saveRecord(
204
+ ctx,
205
+ kind,
206
+ id,
207
+ {
208
+ ...read.columns,
209
+ ...(files.length > 0 ? { files } : {}),
210
+ status: spam ? 'spam' : 'new',
211
+ deliveryState,
212
+ submittedAt: receivedAt,
213
+ },
214
+ { createdAt: receivedAt, updatedAt: receivedAt },
215
+ )
216
+ if (deliveryState === 'pending') await scheduleDelivery(ctx, id, 1, 0)
217
+ // A filled honeypot is answered like any other submission: a bot learns nothing.
218
+ return { ok: true, id }
219
+ },
220
+ })
221
+
222
+ // --- Delivery --------------------------------------------------------------------------------
223
+
224
+ /** What the delivery action reads of a row. */
225
+ export interface DeliveryInput {
226
+ form: string
227
+ payload: SubmissionField[]
228
+ files: { field: string; name?: string; contentType: string; size: number; url: string }[]
229
+ sourceUrl?: string
230
+ submittedAt: string
231
+ deliveryState: DeliveryState
232
+ delivered: string[]
233
+ }
234
+
235
+ /** The delivery action's read of one row (internal). */
236
+ export const deliveryInput = internalQuery({
237
+ args: { submissionId: v.string() },
238
+ handler: async (ctx, { submissionId }): Promise<DeliveryInput | null> => {
239
+ const doc = await findRecordDoc(ctx, kind, submissionId)
240
+ if (!doc) return null
241
+ const data = doc.data as Record<string, unknown>
242
+ const files = ((data['files'] as SubmissionFile[] | undefined) ?? []).flatMap((file) =>
243
+ file.url
244
+ ? [{ field: file.field, ...(file.name ? { name: file.name } : {}), contentType: file.contentType, size: file.size, url: file.url }]
245
+ : [],
246
+ )
247
+ return {
248
+ form: data['form'] as string,
249
+ payload: data['payload'] as SubmissionField[],
250
+ files,
251
+ ...(typeof data['sourceUrl'] === 'string' ? { sourceUrl: data['sourceUrl'] } : {}),
252
+ submittedAt: data['submittedAt'] as string,
253
+ deliveryState: data['deliveryState'] as DeliveryState,
254
+ delivered: deliveredRecipients((data['deliveryLog'] as DeliveryEntry[] | undefined) ?? []),
255
+ }
256
+ },
257
+ })
258
+
259
+ /**
260
+ * One delivery attempt for one row — scheduled by `ingest`, by a retry, and by itself after a
261
+ * retryable failure. A row that is no longer `pending` (an editor intervened) is left alone.
262
+ */
263
+ export const deliver = internalActionGeneric({
264
+ args: { submissionId: v.string(), attempt: v.number() },
265
+ handler: async (ctx, { submissionId, attempt }) => {
266
+ const input = await ctx.runQuery(deliveryInputRef, { submissionId })
267
+ if (!input || input.deliveryState !== 'pending') return { attempted: false as const }
268
+ const request: FormDeliveryRequest = {
269
+ kind: 'formDelivery',
270
+ submissionId,
271
+ form: input.form,
272
+ payload: input.payload,
273
+ files: input.files,
274
+ ...(input.sourceUrl ? { sourceUrl: input.sourceUrl } : {}),
275
+ submittedAt: input.submittedAt,
276
+ attempt,
277
+ delivered: input.delivered,
278
+ }
279
+ const outcome = await attemptFormDelivery({
280
+ request,
281
+ url: env(FORMS_DELIVERY_URL_ENV),
282
+ secret: env('ELYTRA_INGEST_SECRET'),
283
+ })
284
+ await ctx.runMutation(recordDeliveryRef, { submissionId, attempt, outcome })
285
+ return { attempted: true as const, outcome: outcome.kind }
286
+ },
287
+ })
288
+
289
+ /** Log one attempt's outcome on its row and schedule the next attempt (internal). */
290
+ export const recordDelivery = internalMutation({
291
+ args: { submissionId: v.string(), attempt: v.number(), outcome: v.any() },
292
+ handler: async (ctx, { submissionId, attempt, outcome }) => {
293
+ const doc = await findRecordDoc(ctx, kind, submissionId)
294
+ if (!doc || doc.data['deliveryState'] !== 'pending') return
295
+ const log = (doc.data['deliveryLog'] as DeliveryEntry[] | undefined) ?? []
296
+ const now = new Date().toISOString()
297
+ const step = applyDeliveryOutcome(
298
+ { log, attempt, now, delivered: deliveredRecipients(log) },
299
+ deliveryOutcomeSchema.parse(outcome),
300
+ )
301
+ await saveRecord(
302
+ ctx,
303
+ kind,
304
+ submissionId,
305
+ { deliveryState: step.deliveryState, deliveryLog: step.deliveryLog },
306
+ { createdAt: doc.createdAt, updatedAt: now },
307
+ )
308
+ if (step.retryInMs !== undefined) await scheduleDelivery(ctx, submissionId, attempt + 1, step.retryInMs)
309
+ },
310
+ })
311
+
312
+ // --- Orphaned uploads ------------------------------------------------------------------------
313
+
314
+ /**
315
+ * The upload intents past their life that no submission claimed (internal — run it from the
316
+ * dashboard or `npx convex run forms:unclaimedUploads`). Their bytes cannot be located: Convex
317
+ * returns a stored file's id to the uploader only (EC-617's documented residue), so they are
318
+ * listed here, never swept.
319
+ */
320
+ export const unclaimedUploads = internalQuery({
321
+ args: {},
322
+ handler: async (ctx) => {
323
+ const before = new Date(Date.now() - INGEST_UPLOAD_TTL_MS).toISOString()
324
+ const rows: RecordWireRow[] = await listRecordWire(
325
+ ctx,
326
+ ingestUploadRecordKind,
327
+ resolveRecordQuery(ingestUploadRecordKind, { where: { state: 'issued' }, before }),
328
+ )
329
+ return {
330
+ count: rows.length,
331
+ declaredBytes: rows.reduce((sum, row) => sum + Number(row['size'] ?? 0), 0),
332
+ uploads: rows.map((row) => ({
333
+ uploadId: String(row[ingestUploadRecordKind.idKey]),
334
+ form: String(row['target']),
335
+ field: String(row['field']),
336
+ issuedAt: String(row['issuedAt']),
337
+ })),
338
+ }
339
+ },
340
+ })
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ DELIVERY_MAX_ATTEMPTS,
4
+ applyDeliveryOutcome,
5
+ attemptFormDelivery,
6
+ deliveredRecipients,
7
+ formDeliveryRequestSchema,
8
+ verifyFormDeliverySignature,
9
+ } from './delivery'
10
+ import type { DeliveryOutcome, FormDeliveryRequest } from './delivery'
11
+ import { FORMS_DELIVERY_SIGNATURE_HEADER } from './delivery'
12
+ import type { DeliveryEntry } from './kind'
13
+
14
+ /** EC-613 — the delivery state machine (pure) and the deployment's one attempt (injected transport). */
15
+
16
+ const NOW = '2026-10-26T12:00:00.000Z'
17
+ const step = (outcome: DeliveryOutcome, attempt = 1, log: DeliveryEntry[] = []) =>
18
+ applyDeliveryOutcome({ log, attempt, now: NOW, delivered: deliveredRecipients(log) }, outcome)
19
+
20
+ describe('applyDeliveryOutcome', () => {
21
+ it('every recipient reached → sent, one log line each, nothing scheduled', () => {
22
+ const result = step({ kind: 'results', results: [{ to: 'a@x.test', ok: true, id: 'm1' }, { to: 'b@x.test', ok: true }] })
23
+ expect(result).toEqual({
24
+ deliveryState: 'sent',
25
+ deliveryLog: [
26
+ { at: NOW, attempt: 1, to: 'a@x.test', ok: true, id: 'm1' },
27
+ { at: NOW, attempt: 1, to: 'b@x.test', ok: true },
28
+ ],
29
+ })
30
+ })
31
+
32
+ it('a failed recipient is retried after the backoff; the one reached is never re-sent', () => {
33
+ const first = step({ kind: 'results', results: [{ to: 'a@x.test', ok: true }, { to: 'b@x.test', ok: false, error: 'mailbox full' }] })
34
+ expect(first.deliveryState).toBe('pending')
35
+ expect(first.retryInMs).toBe(60_000)
36
+ expect(first.deliveryLog[1]).toMatchObject({ to: 'b@x.test', ok: false, error: 'mailbox full', retryAt: '2026-10-26T12:01:00.000Z' })
37
+ expect(deliveredRecipients(first.deliveryLog)).toEqual(['a@x.test'])
38
+ // Attempt 2 reaches b — the row is sent.
39
+ const second = step({ kind: 'results', results: [{ to: 'b@x.test', ok: true }] }, 2, first.deliveryLog)
40
+ expect(second.deliveryState).toBe('sent')
41
+ })
42
+
43
+ it('the last attempt ends the run: partial when someone was reached, failed when nobody was', () => {
44
+ const partialLog: DeliveryEntry[] = [{ at: NOW, attempt: 1, to: 'a@x.test', ok: true }]
45
+ const failedRecipient: DeliveryOutcome = { kind: 'results', results: [{ to: 'b@x.test', ok: false, error: 'refused' }] }
46
+ expect(step(failedRecipient, DELIVERY_MAX_ATTEMPTS, partialLog)).toMatchObject({ deliveryState: 'partial' })
47
+ expect(step(failedRecipient, DELIVERY_MAX_ATTEMPTS)).toMatchObject({ deliveryState: 'failed' })
48
+ expect(step(failedRecipient, DELIVERY_MAX_ATTEMPTS).retryInMs).toBeUndefined()
49
+ })
50
+
51
+ it('an unreachable site and a 5xx/429 are retried; a 4xx and an unset route fail at once', () => {
52
+ expect(step({ kind: 'unreachable', message: 'ECONNREFUSED' })).toMatchObject({ deliveryState: 'pending', retryInMs: 60_000 })
53
+ expect(step({ kind: 'refused', status: 503, message: 'down' }, 2)).toMatchObject({ deliveryState: 'pending', retryInMs: 600_000 })
54
+ expect(step({ kind: 'refused', status: 429, message: 'slow down' })).toMatchObject({ deliveryState: 'pending' })
55
+ expect(step({ kind: 'refused', status: 404, message: 'No form “x”.' })).toEqual({
56
+ deliveryState: 'failed',
57
+ deliveryLog: [{ at: NOW, attempt: 1, ok: false, error: 'HTTP 404: No form “x”.' }],
58
+ })
59
+ expect(step({ kind: 'unconfigured', message: 'ELYTRA_FORMS_DELIVERY_URL is not set' })).toMatchObject({ deliveryState: 'failed' })
60
+ })
61
+
62
+ it('a form with no recipients is skipped (an inbox-only form), with a note', () => {
63
+ expect(step({ kind: 'results', results: [] })).toEqual({
64
+ deliveryState: 'skipped',
65
+ deliveryLog: [{ at: NOW, attempt: 1, ok: true, note: 'no recipients' }],
66
+ })
67
+ })
68
+ })
69
+
70
+ describe('attemptFormDelivery — the deployment’s request to the site', () => {
71
+ const request: FormDeliveryRequest = {
72
+ kind: 'formDelivery',
73
+ submissionId: 'submission_1',
74
+ form: 'contact',
75
+ payload: [{ name: 'email', value: 'ada@example.test' }],
76
+ files: [],
77
+ submittedAt: NOW,
78
+ attempt: 1,
79
+ delivered: [],
80
+ }
81
+
82
+ it('signs the request with the ingest secret; the site verifies it (control: another secret does not)', async () => {
83
+ let seen: { body: string; signature: string | null } | undefined
84
+ const fetchImpl = (async (_url: string, init: RequestInit) => {
85
+ seen = { body: String(init.body), signature: new Headers(init.headers).get(FORMS_DELIVERY_SIGNATURE_HEADER) }
86
+ return Response.json({ results: [{ to: 'shop@example.test', ok: true }] })
87
+ }) as unknown as typeof fetch
88
+ const outcome = await attemptFormDelivery({ request, url: 'https://site.test/api/forms', secret: 's3cret', fetchImpl })
89
+ expect(outcome).toEqual({ kind: 'results', results: [{ to: 'shop@example.test', ok: true }] })
90
+ expect(formDeliveryRequestSchema.parse(JSON.parse(seen!.body))).toEqual(request)
91
+ expect(await verifyFormDeliverySignature(seen!.body, seen!.signature, 's3cret')).toBe(true)
92
+ expect(await verifyFormDeliverySignature(seen!.body, seen!.signature, 'other')).toBe(false)
93
+ })
94
+
95
+ it('unset route or secret → unconfigured, and nothing is sent', async () => {
96
+ let calls = 0
97
+ const fetchImpl = (async () => {
98
+ calls += 1
99
+ return Response.json({ results: [] })
100
+ }) as unknown as typeof fetch
101
+ expect((await attemptFormDelivery({ request, url: undefined, secret: 's', fetchImpl })).kind).toBe('unconfigured')
102
+ expect((await attemptFormDelivery({ request, url: 'https://site.test', secret: undefined, fetchImpl })).kind).toBe('unconfigured')
103
+ expect(calls).toBe(0)
104
+ })
105
+
106
+ it('maps the site’s answers: 401 names the secret pair, 4xx keeps its error, a bad 200 body and a network error are failures', async () => {
107
+ const answer = (response: Response | Error) =>
108
+ (async () => {
109
+ if (response instanceof Error) throw response
110
+ return response
111
+ }) as unknown as typeof fetch
112
+ const attempt = (fetchImpl: typeof fetch) => attemptFormDelivery({ request, url: 'https://site.test', secret: 's', fetchImpl })
113
+ expect(await attempt(answer(new Response('', { status: 401 })))).toMatchObject({ kind: 'refused', status: 401, message: expect.stringMatching(/INGEST_SECRET/) })
114
+ expect(await attempt(answer(Response.json({ error: 'No form “contact”.' }, { status: 404 })))).toEqual({
115
+ kind: 'refused',
116
+ status: 404,
117
+ message: 'No form “contact”.',
118
+ })
119
+ expect(await attempt(answer(Response.json({ nope: true })))).toMatchObject({ kind: 'refused', status: 502 })
120
+ expect(await attempt(answer(new Error('ECONNREFUSED')))).toMatchObject({ kind: 'unreachable', message: expect.stringMatching(/ECONNREFUSED/) })
121
+ })
122
+ })