@aglyn/plugins-forms 1.0.0-beta.221 → 1.0.0-beta.223

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 (75) hide show
  1. package/README.md +1 -0
  2. package/package.json +14 -12
  3. package/src/lib/components/form-detail-card.js +166 -29
  4. package/src/lib/components/form-detail-card.js.map +1 -1
  5. package/src/lib/components/form-submissions-card.component.d.ts +12 -0
  6. package/src/lib/components/form-submissions-card.component.js +35 -2
  7. package/src/lib/components/form-submissions-card.component.js.map +1 -1
  8. package/src/lib/components/form.js +2 -1
  9. package/src/lib/components/form.js.map +1 -1
  10. package/src/lib/components/host-forms-card.component.d.ts +8 -0
  11. package/src/lib/components/host-forms-card.component.js +26 -1
  12. package/src/lib/components/host-forms-card.component.js.map +1 -1
  13. package/src/lib/components/site-package-form-preview.component.d.ts +13 -0
  14. package/src/lib/components/site-package-form-preview.component.js +57 -0
  15. package/src/lib/components/site-package-form-preview.component.js.map +1 -0
  16. package/src/lib/constants/site-package.d.ts +18 -0
  17. package/src/lib/constants/site-package.js +18 -0
  18. package/src/lib/constants/site-package.js.map +1 -0
  19. package/src/lib/declarations.console-server.d.ts +39 -0
  20. package/src/lib/declarations.console-server.js +68 -0
  21. package/src/lib/declarations.console-server.js.map +1 -0
  22. package/src/lib/declarations.server.js +22 -0
  23. package/src/lib/declarations.server.js.map +1 -1
  24. package/src/lib/model/form-door.d.ts +37 -0
  25. package/src/lib/model/form-door.js +50 -0
  26. package/src/lib/model/form-door.js.map +1 -0
  27. package/src/lib/model/form-submission-counts.d.ts +26 -0
  28. package/src/lib/model/form-submission-counts.js +35 -0
  29. package/src/lib/model/form-submission-counts.js.map +1 -0
  30. package/src/lib/model/form-submission-list.d.ts +40 -0
  31. package/src/lib/model/form-submission-list.js +66 -0
  32. package/src/lib/model/form-submission-list.js.map +1 -0
  33. package/src/lib/model/form-submission-record.d.ts +52 -0
  34. package/src/lib/model/form-submission-record.js +70 -0
  35. package/src/lib/model/form-submission-record.js.map +1 -0
  36. package/src/lib/model/form-unavailable.d.ts +83 -0
  37. package/src/lib/model/form-unavailable.js +88 -0
  38. package/src/lib/model/form-unavailable.js.map +1 -0
  39. package/src/lib/model/forms-record-lists.d.ts +31 -0
  40. package/src/lib/model/forms-record-lists.js +55 -0
  41. package/src/lib/model/forms-record-lists.js.map +1 -0
  42. package/src/lib/plan-entitlements.js +6 -6
  43. package/src/lib/plan-entitlements.js.map +1 -1
  44. package/src/lib/plugin.js +34 -0
  45. package/src/lib/plugin.js.map +1 -1
  46. package/src/lib/server/api-v1/form-submissions.d.ts +34 -0
  47. package/src/lib/server/api-v1/form-submissions.js +273 -0
  48. package/src/lib/server/api-v1/form-submissions.js.map +1 -0
  49. package/src/lib/server/api-v1/openapi.d.ts +26 -0
  50. package/src/lib/server/api-v1/openapi.js +133 -0
  51. package/src/lib/server/api-v1/openapi.js.map +1 -0
  52. package/src/lib/server/form-intake-gate.d.ts +34 -0
  53. package/src/lib/server/form-intake-gate.js +44 -0
  54. package/src/lib/server/form-intake-gate.js.map +1 -0
  55. package/src/lib/server/form-submission-index.d.ts +26 -0
  56. package/src/lib/server/form-submission-index.js +51 -0
  57. package/src/lib/server/form-submission-index.js.map +1 -0
  58. package/src/lib/server/form-submit.d.ts +43 -0
  59. package/src/lib/server/form-submit.js +850 -0
  60. package/src/lib/server/form-submit.js.map +1 -0
  61. package/src/lib/server/increment-form-stats.d.ts +70 -0
  62. package/src/lib/server/increment-form-stats.js +112 -0
  63. package/src/lib/server/increment-form-stats.js.map +1 -0
  64. package/src/lib/server.d.ts +10 -0
  65. package/src/lib/server.js +15 -0
  66. package/src/lib/server.js.map +1 -1
  67. package/src/lib/transfer/form-submissions-transfer-key.d.ts +37 -0
  68. package/src/lib/transfer/form-submissions-transfer-key.js +40 -0
  69. package/src/lib/transfer/form-submissions-transfer-key.js.map +1 -0
  70. package/src/lib/transfer/form-submissions-transfer.d.ts +98 -0
  71. package/src/lib/transfer/form-submissions-transfer.js +563 -0
  72. package/src/lib/transfer/form-submissions-transfer.js.map +1 -0
  73. package/src/lib/transfer/form-submissions-transfer.server.d.ts +26 -0
  74. package/src/lib/transfer/form-submissions-transfer.server.js +31 -0
  75. package/src/lib/transfer/form-submissions-transfer.server.js.map +1 -0
@@ -0,0 +1,850 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import * as Aglyn from "@aglyn/aglyn/server";
18
+ import { extractEmailFromFields } from "@aglyn/aglyn/server";
19
+ import { consumeRateLimit, firebaseAdmin, getOrgForHost, notifyHostManagers, visitorWriteRefusal } from "@aglyn/tenant-data-admin";
20
+ import { emitHostEvent } from "@aglyn/tenant-runtime";
21
+ import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
22
+ // By path: the server-only contract a door credits its outcome through.
23
+ import { CONVERSION_TOUCH_DETAIL, conversionDescriptionSentences, creditConversion, describeConversion, resolveConversionTouch } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
24
+ import { normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
25
+ import { findScreenIdByRoutePath, screenRoutePathFromUrl } from "@aglyn/aglyn/app-utils/screen-route";
26
+ // The leaf: the Inbox list's search keys, the same function its backfill
27
+ // restates and its query's normalizers read (AGL-3321).
28
+ import { messageSearchFields } from "@aglyn/aglyn/app-utils/message-search";
29
+ // By path: the server-only contract a submission is filed as a record through.
30
+ import { writeFormRecordTarget } from "@aglyn/aglyn/plugin-manager/submission-record-target";
31
+ // By path: where a manager reads submissions, declared by the plugin whose
32
+ // page shows them, for the notifications below (AGL-3080).
33
+ import { pluginRecordPageLink } from "@aglyn/aglyn/plugin-manager/plugin-record-pages";
34
+ import { FieldValue } from "firebase-admin/firestore";
35
+ import { isCredentialFieldName } from "@aglyn/shared-util-email/hosted-page-screen";
36
+ import { FORMS_OFF_FOR_SITE_REFUSAL, formSubmissionDoorPlugin } from "../model/form-door.js";
37
+ import { FORM_ABUSE_CEILING_CODE } from "../model/form-unavailable.js";
38
+ import { incrementFormStats } from "./increment-form-stats.js";
39
+ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
40
+ const MAX_FIELDS = 20;
41
+ const MAX_PAYLOAD_CHARS = 10000;
42
+ // Per-IP submission limit (AGL-794). Previously a per-instance Map, which on
43
+ // serverless resets each cold start and is held per concurrent instance — so
44
+ // a spammer got roughly RATE_MAX × instances and could widen it by going
45
+ // wider. The monthly plan quota was never the right thing to be defended BY:
46
+ // burning a site's whole allowance is the damage, not the protection — and
47
+ // since AGL-1280 it is not even a wall on a paid plan, it meters. This
48
+ // counter is global, keyed per (site, IP).
49
+ //
50
+ // It is the SHAPE of abuse control, not the whole of it (AGL-1655): 10/60s
51
+ // per address is 14,400/day from one IP and multiplies by the number of
52
+ // addresses, so per-IP alone leaves the site's total unbounded. The per-site
53
+ // monthly ceiling below is what bounds the total.
54
+ const RATE_WINDOW_MS = 60000;
55
+ const RATE_MAX = 10;
56
+ const json = (body, status = 200)=>Response.json(body, {
57
+ status
58
+ });
59
+ /**
60
+ * The ceiling is a per-MONTH count, so the honest retry hint is the month
61
+ * boundary rather than a made-up interval. UTC, matching `monthKey`.
62
+ */ const secondsUntilNextMonth = (now = new Date())=>{
63
+ const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
64
+ return Math.max(1, Math.ceil((next - now.getTime()) / 1000));
65
+ };
66
+ /**
67
+ * The one useful thing a refused visitor can be given (AGL-1666): the site's
68
+ * OWN published support address, so a lost lead has somewhere else to go.
69
+ *
70
+ * Read through the host-token registry rather than reaching into the
71
+ * document, because that registry is what defines this field as the address
72
+ * a site publishes to visitors — its description is literally "where
73
+ * visitors should write for help". Anything else on the host document is
74
+ * account data and must not leave the console.
75
+ *
76
+ * Free: the host snapshot is already loaded for the existence check.
77
+ */ const publishedContactEmail = (host)=>{
78
+ var _Aglyn_HOST_TOKENS_supportEmail;
79
+ return host ? (_Aglyn_HOST_TOKENS_supportEmail = Aglyn.HOST_TOKENS['supportEmail']) == null ? void 0 : _Aglyn_HOST_TOKENS_supportEmail.resolve(host) : undefined;
80
+ };
81
+ /**
82
+ * Make a tripped ceiling OBSERVABLE (AGL-1655) rather than a silent drop.
83
+ *
84
+ * Two audiences, one write. Staff get a durable per-month refusal count at
85
+ * `hosts/{id}/counters/formSubmissionsRefused` — the same counters-document
86
+ * shape `contactsDropped` already uses for free-tier contact drops, and
87
+ * client-unwritable for the same reason (AGL-1367). The site's managers get
88
+ * one in-app notification, on the FIRST refusal of the month only: a
89
+ * notification per refused bot request would be the flood again, delivered.
90
+ *
91
+ * Best-effort throughout. A bookkeeping failure must not turn a contained
92
+ * refusal into a 500, because a 500 is a retry invitation.
93
+ */ async function recordAbuseCeilingTrip(hostRef, hostId, monthKey, ceiling) {
94
+ try {
95
+ var _refusedSnapshot_get;
96
+ const refusedRef = hostRef.collection('counters').doc('formSubmissionsRefused');
97
+ const refusedSnapshot = await refusedRef.get();
98
+ const alreadyRefused = Number((_refusedSnapshot_get = refusedSnapshot.get(monthKey)) != null ? _refusedSnapshot_get : 0);
99
+ await refusedRef.set({
100
+ [monthKey]: FieldValue.increment(1),
101
+ // Explicit values only — Firestore rejects `undefined`.
102
+ ceiling,
103
+ lastRefusedAtMs: Date.now()
104
+ }, {
105
+ merge: true
106
+ });
107
+ if (alreadyRefused === 0) {
108
+ // Every fact here is the route's own (AGL-3432): the refusal sits before
109
+ // every write, so a refused submission is neither stored nor counted
110
+ // toward the bill, and the counter is month-keyed, so forms accept
111
+ // again at the UTC month boundary — the same date the inbox notice
112
+ // prints. `{site}` is the site's name, filled by `notifyHostManagers`.
113
+ const reopens = Aglyn.nextUtcMonthStart().toLocaleDateString('en-US', {
114
+ month: 'long',
115
+ day: 'numeric',
116
+ timeZone: 'UTC'
117
+ });
118
+ await notifyHostManagers(hostId, _extends({
119
+ type: 'system.formSubmissionsPaused',
120
+ title: 'Form submissions paused on {site} — unusual volume',
121
+ body: `Forms on {site} stopped accepting submissions: the site reached ` + `${ceiling.toLocaleString()} this month, the safety limit for ` + 'unusual volume. Submissions sent now are refused and not saved, ' + `and none of them are billed. Forms accept submissions again on ` + `${reopens}. Contact support if this is real traffic.`
122
+ }, submissionsLink(hostId)));
123
+ }
124
+ } catch (error) {
125
+ console.error('form abuse ceiling bookkeeping failed', error);
126
+ }
127
+ }
128
+ /**
129
+ * A notification's link to where the site's managers read submissions — the
130
+ * page the plugin that shows `formSubmission` records declares, opened on one
131
+ * submission when `submissionId` is given and that page can open one — and no
132
+ * link where no plugin in this build shows them. A key left out rather than
133
+ * set to `undefined`, which Firestore rejects.
134
+ */ function submissionsLink(hostId, submissionId) {
135
+ const link = pluginRecordPageLink('formSubmission', hostId, submissionId);
136
+ return link ? {
137
+ link
138
+ } : {};
139
+ }
140
+ /**
141
+ * Count a honeypot hit so the bot rate is a NUMBER, not a guess (AGL-1664).
142
+ *
143
+ * The attestation decision (App Check / CAPTCHA / neither) asked to be made
144
+ * on a measured per-host bot rate, and the honeypot was the instrument named
145
+ * for it — but a hit used to return fake success and record nothing, so the
146
+ * number it was supposed to produce did not exist. This writes the same
147
+ * counters-document shape `formSubmissionsRefused` uses (per-month key,
148
+ * client-unwritable, AGL-1367), keyed per host because "which site is the
149
+ * bot filling in" is the decision input.
150
+ *
151
+ * The host-existence read is the orphan guard: this path runs BEFORE the
152
+ * route's shape validation, and an attacker spraying invented host ids must
153
+ * not mint counter documents under hosts that do not exist. Cost per hit is
154
+ * one read + one write — the same order the legitimate path already pays the
155
+ * rate limiter — where the old path cost nothing; that is the price of the
156
+ * measurement, paid only on requests that self-identified as bots.
157
+ *
158
+ * No notification, ever: honeypot hits are routine background noise, and a
159
+ * flood of them delivered as alerts would be the flood again (the
160
+ * `formSubmissionsPaused` lesson). Best-effort for the same reason the
161
+ * refusal bookkeeping is — a bookkeeping failure must not turn a dropped bot
162
+ * request into a 500 retry invitation.
163
+ */ async function recordHoneypotHit(hostId) {
164
+ try {
165
+ if (typeof hostId !== 'string' || !hostId || hostId.length > 128) return;
166
+ const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId);
167
+ if (!(await hostRef.get()).exists) return;
168
+ await hostRef.collection('counters').doc('formSubmissionsSpam').set({
169
+ [Aglyn.utcMonthKey()]: FieldValue.increment(1),
170
+ lastSpamAtMs: Date.now()
171
+ }, {
172
+ merge: true
173
+ });
174
+ } catch (error) {
175
+ console.error('honeypot bookkeeping failed', error);
176
+ }
177
+ }
178
+ /**
179
+ * The opt-in a form CARRIES, or `false`.
180
+ *
181
+ * `upsertHostContact` has accepted `marketingConsent` since AGL-301 and would
182
+ * stamp `marketingConsentAtMs` from it, and this route passed it zero times —
183
+ * so a subscribe checkbox a merchant put on their form was collected, stored
184
+ * as a submission field, and then dropped on the way to the contact. That is
185
+ * the missing INPUT for the send-time consent join
186
+ * (`docs/specs/email-overhaul.md` §1d): the join can only ever be as good as
187
+ * what the capture surfaces record.
188
+ *
189
+ * ⛔ THE FACT OF SUBMISSION IS NOT AN OPT-IN. Only a field whose VALUE is a
190
+ * tick counts, and only under a name that means marketing consent. A form is
191
+ * submitted to ask a question, book a table or claim a refund, and treating
192
+ * any of those as a subscription is exactly the inference the consent arc
193
+ * refused to make.
194
+ *
195
+ * What a tick is comes from `isConsentCheckboxTicked`. A box with no text of
196
+ * its own posts an affirmative value on any form. A Checkboxes field posts
197
+ * the text of its option, which is a tick only where a BOUND form's stored
198
+ * `declarations` show that field is a checkbox with exactly one option: an
199
+ * unbound form has no declaration, and the request never supplies one.
200
+ *
201
+ * The name list is `MARKETING_CONSENT_FIELD_NAMES`, closed rather than a
202
+ * substring match on "consent" (a clinic intake form's `consentToTreatment`
203
+ * is a different instrument), and shared with the publish check: a design
204
+ * `checkFormContract` accepts as recording consent is one this route reads
205
+ * an opt-in from.
206
+ */ function readDeclaredMarketingConsent(payload, fields, declarations) {
207
+ // A first-class body field, for a caller that models the checkbox
208
+ // explicitly rather than as one more form field.
209
+ if (payload['marketingConsent'] === true) return true;
210
+ for (const [key, value] of Object.entries(fields)){
211
+ if (!Aglyn.isMarketingConsentFieldName(key)) continue;
212
+ const declaration = declarations.find((entry)=>(entry == null ? void 0 : entry.fieldName) === key);
213
+ if (Aglyn.isConsentCheckboxTicked(value, declaration)) return true;
214
+ }
215
+ return false;
216
+ }
217
+ /**
218
+ * `POST /api/forms/submit` — every form's door, and a Marketing popup's
219
+ * (AGL-76, AGL-3080).
220
+ *
221
+ * This plugin's route, served by the tenant's plugin API dispatcher at the
222
+ * address it has always had, and registered as a route that KEEPS ITS OWN
223
+ * VISITOR GATES (`ownVisitorGates`): the dispatcher hands it the request
224
+ * untouched, because every gate below is a published answer — which plugin's
225
+ * switch decides a submission (the door it came through, not this route's
226
+ * owner), the "not accepting" notice a site with Forms off gives, the
227
+ * per-(site, address) limit and the monthly ceiling, and where lockdown sits
228
+ * among them. The dispatcher's own versions of those would answer first and
229
+ * differently.
230
+ *
231
+ * Lead-capture submissions endpoint (AGL-76): validates the target host,
232
+ * drops honeypot hits silently, applies the plan's monthly submission quota
233
+ * via a per-month counter — a hard wall on free, a meter on plans that carry
234
+ * the infra pass-through (AGL-1280) — refuses above the per-site abuse
235
+ * ceiling (AGL-1655), and stores the submission for the console inbox. Writes
236
+ * go through the admin SDK, so client rules never allow arbitrary submission
237
+ * writes.
238
+ *
239
+ * The two 429s are different answers and must stay distinguishable: the plan
240
+ * wall means the customer's allowance is spent, the ceiling means the site is
241
+ * being flooded. Only the ceiling refusal carries `code`.
242
+ */ export async function POST(request) {
243
+ var _readClientIp;
244
+ // The dispatcher serves every method at its catch-all; this door takes a
245
+ // POST alone, as the route it replaced did.
246
+ if (request.method !== 'POST') {
247
+ return new Response(null, {
248
+ status: 405,
249
+ headers: {
250
+ Allow: 'POST'
251
+ }
252
+ });
253
+ }
254
+ const payload = await request.json().catch(()=>({}));
255
+ // `datasetId`, `dataset` and `fieldMap` are deliberately not read: the
256
+ // dataset a record lands in comes from the page's signed binding (below).
257
+ const { hostId, formId, formName, path, fields, website } = payload;
258
+ // Honeypot filled → pretend success so bots learn nothing, counted so the
259
+ // owner of the attestation decision learns something (AGL-1664).
260
+ if (typeof website === 'string' && website.trim()) {
261
+ await recordHoneypotHit(hostId);
262
+ return json({
263
+ received: true
264
+ });
265
+ }
266
+ if (typeof hostId !== 'string' || !hostId || typeof fields !== 'object' || fields === null || Array.isArray(fields) || Object.keys(fields).length === 0 || Object.keys(fields).length > MAX_FIELDS || JSON.stringify(fields).length > MAX_PAYLOAD_CHARS) {
267
+ return json({
268
+ error: 'Invalid submission'
269
+ }, 400);
270
+ }
271
+ // Keeps counting under the no-address bucket rather than being skipped: a
272
+ // public form endpoint that stops counting is an open write, and the key
273
+ // already carries the site, so an unreadable address shares one budget per
274
+ // SITE rather than one across the deployment.
275
+ const ip = (_readClientIp = readClientIp(request.headers)) != null ? _readClientIp : NO_CLIENT_ADDRESS_BUCKET;
276
+ const rate = await consumeRateLimit(`form:${hostId}:${ip}`, {
277
+ limit: RATE_MAX,
278
+ windowMs: RATE_WINDOW_MS
279
+ });
280
+ if (!rate.allowed) {
281
+ // Note this is NOT told to the caller, degraded or not. The body and the
282
+ // status are identical either way: "the global limiter is currently a
283
+ // per-instance one" is exactly the sentence an abuser would spend the
284
+ // window on, and this endpoint is public and unauthenticated.
285
+ return Response.json({
286
+ error: 'Too many submissions'
287
+ }, {
288
+ status: 429,
289
+ headers: {
290
+ 'Retry-After': String(Math.max(1, Math.ceil((rate.resetMs - Date.now()) / 1000)))
291
+ }
292
+ });
293
+ }
294
+ try {
295
+ var _ref, _form_get, _ref1;
296
+ const firestore = firebaseAdmin.app().firestore();
297
+ const hostRef = firestore.collection('hosts').doc(hostId);
298
+ const hostSnapshot = await hostRef.get();
299
+ if (!hostSnapshot.exists) {
300
+ return json({
301
+ error: 'Unknown site'
302
+ }, 404);
303
+ }
304
+ // Lockdown (AGL-1511). Placed after the host exists and BEFORE the quota
305
+ // read and every write: a paused submission must not consume the
306
+ // customer's monthly allowance, and it must not be half-recorded.
307
+ // Refused with the visitor pause copy — the site around this form is
308
+ // still serving, so an outage page here would be both a lie and a lost
309
+ // lead. The `/api` surface is outside the middleware matcher, so this is
310
+ // the only lockdown enforcement a form POST ever meets, full or
311
+ // read-only.
312
+ const paused = await visitorWriteRefusal({
313
+ hostId,
314
+ request,
315
+ surface: 'form'
316
+ });
317
+ if (paused) return paused;
318
+ // Monthly quota by the owning org's plan (dark-launch: orgs
319
+ // without a plan are uncapped, matching every other gate).
320
+ // Plan/quota gates ride the owning org's doc (AGL-238).
321
+ const owningOrg = await getOrgForHost(hostId);
322
+ const orgBilling = owningOrg == null ? void 0 : owningOrg.org;
323
+ /*
324
+ * A site that switched Forms off accepts no form submission (AGL-3029).
325
+ *
326
+ * Asked of the site's own plugin set — the org's resolved set minus this
327
+ * host's deny-list, the same answer the tenant draws the page from — and
328
+ * about the plugin whose door the submission came through: a Marketing
329
+ * popup's capture belongs to Marketing, and keeps working on a site that
330
+ * still shows the popup. Core imports no plugin, so both are ids.
331
+ *
332
+ * Placed after the site is known to exist and before the quota read and
333
+ * every write: a refused submission is not counted, not billed and not
334
+ * stored. 404, the answer the route already gives a retired form, because
335
+ * the honest reply to the visitor is the same — this site takes no form
336
+ * here — and the sentence names no setting a stranger has no use for.
337
+ */ if (!Aglyn.isHostPluginEnabled(orgBilling, hostSnapshot.data(), formSubmissionDoorPlugin(payload))) {
338
+ return json({
339
+ error: FORMS_OFF_FOR_SITE_REFUSAL
340
+ }, 404);
341
+ }
342
+ // Shared with the console surface that reads these counters back
343
+ // (AGL-1666) — a differently-derived key there would read 0 refusals on
344
+ // exactly the sites being refused.
345
+ const monthKey = Aglyn.utcMonthKey();
346
+ const counterRef = hostRef.collection('counters').doc('formSubmissions');
347
+ {
348
+ var _counterSnapshot_get;
349
+ // Plan-less orgs resolve as free (AGL-247) — the cap always runs.
350
+ const counterSnapshot = await counterRef.get();
351
+ const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
352
+ // AGL-1280: a plan with the metered infra pass-through is never cut
353
+ // off — submissions past the included count bill at cost × 1.3 like
354
+ // storage, API requests and contacts already do. Free keeps the hard
355
+ // wall, because there is no subscription to meter onto. A dropped
356
+ // submission is a lost lead, so this is the meter where being wrong
357
+ // in the "cut them off" direction costs the customer the most.
358
+ if (!Aglyn.checkFormSubmissionQuota(orgBilling, used).allowed) {
359
+ return json({
360
+ error: 'Submission limit reached'
361
+ }, 429);
362
+ }
363
+ // Abuse ceiling (AGL-1655). The plan gate above cannot refuse a
364
+ // metered plan by design, so on every paying customer it was the only
365
+ // thing between a bot flood and an unbounded invoice — and it always
366
+ // said yes. The per-IP limiter is 10/60s, which one address turns into
367
+ // 14,400/day and any number of addresses multiplies.
368
+ //
369
+ // This is the ceiling that can say no. It reuses the count already
370
+ // read above (no extra Firestore read on the happy path) and sits
371
+ // BEFORE every write, so a refused submission never touches
372
+ // `counters/formSubmissions` — the document /api/billing/report-usage
373
+ // invoices from. Refusing and still billing would have been the same
374
+ // bug with a smaller number.
375
+ const ceiling = Aglyn.checkFormSubmissionAbuseCeiling(orgBilling, used);
376
+ if (ceiling.exceeded) {
377
+ await recordAbuseCeilingTrip(hostRef, hostId, monthKey, ceiling.ceiling);
378
+ const contact = publishedContactEmail(hostSnapshot.data());
379
+ return Response.json(_extends({
380
+ error: 'Submissions are paused for this site',
381
+ // Machine-readable so a client can tell containment apart from
382
+ // the plan wall it shares a status with.
383
+ code: FORM_ABUSE_CEILING_CODE
384
+ }, contact ? {
385
+ contact
386
+ } : {}), {
387
+ status: 429,
388
+ headers: {
389
+ 'Retry-After': String(secondsUntilNextMonth())
390
+ }
391
+ });
392
+ }
393
+ }
394
+ /*
395
+ * NO CREDENTIALS, whatever the page asked for (AGL-3356).
396
+ *
397
+ * A published page that asks for a password, a card number or a
398
+ * one-time code in a field of its own is held by the page review before
399
+ * a visitor sees it. This endpoint is the one path that does not pass a
400
+ * publish: it takes whatever names a request carries, so a page the
401
+ * review never read — a marketplace plugin's frame, a hand-built request
402
+ * — could still post one here. Such a field is dropped, never stored,
403
+ * never forwarded to a workflow or a contact.
404
+ */ const sanitizedFields = {};
405
+ for (const [key, value] of Object.entries(fields)){
406
+ if (isCredentialFieldName(String(key))) continue;
407
+ sanitizedFields[String(key).slice(0, 64)] = String(value).slice(0, 2000);
408
+ }
409
+ if (!Object.keys(sanitizedFields).length) {
410
+ return json({
411
+ error: 'Invalid submission'
412
+ }, 400);
413
+ }
414
+ /*
415
+ * The bound form entity (`docs/specs/reusable-forms.md` §2c).
416
+ *
417
+ * READ AND VERIFIED, never trusted. `formId` arrives in a public,
418
+ * unauthenticated body, so stamping it unchecked would let anyone file
419
+ * rows into any form's list on any site — the per-form list is the
420
+ * surface this id exists to make correct, and an unverified id would make
421
+ * it wrong in a way that looks right.
422
+ *
423
+ * One extra document read, and only when a form is bound. A form with no
424
+ * `formId` pays nothing, which is the same shape the dataset binding and
425
+ * `rateDegraded` already take.
426
+ */ const boundFormId = String(formId != null ? formId : '').trim().slice(0, 128);
427
+ const formSnapshot = boundFormId ? await hostRef.collection('forms').doc(boundFormId).get() : null;
428
+ const form = (formSnapshot == null ? void 0 : formSnapshot.exists) ? formSnapshot : null;
429
+ /*
430
+ * A RETIRED form collects nothing (AGL-2671).
431
+ *
432
+ * Retirement is what a merchant does with a form they have finished with
433
+ * but must keep — the submissions, the leads and the contact timeline
434
+ * those built are filed under it. Leaving it able to collect would make
435
+ * the retirement a UI illusion: the row would be gone from the list and
436
+ * out of the funnel's grading while a form still placed on a published
437
+ * page kept filing leads nobody was looking at, which is the quiet half
438
+ * of a lost lead rather than a retirement.
439
+ *
440
+ * Refused HERE, on the verified entity, rather than by unpublishing the
441
+ * page: a form is placed by whoever placed it, on any number of pages, and
442
+ * the document is the one place that knows it is retired.
443
+ *
444
+ * 404 rather than a 4xx that explains: the visitor's honest answer is that
445
+ * this form is not here any more, and the shape matches `Unknown site`
446
+ * above. Only ever for a BOUND form — a submission carrying no `formId`
447
+ * has no entity to be retired and is untouched.
448
+ */ if (Aglyn.isFormArchived(form == null ? void 0 : form.data())) {
449
+ return json({
450
+ error: 'This form is no longer accepting submissions'
451
+ }, 404);
452
+ }
453
+ /*
454
+ * The caption follows the entity once one is bound.
455
+ *
456
+ * `formName` stays written either way: it is what the pre-entity `?form=`
457
+ * filter reads, and no phase of this work removes it. Resolving it from
458
+ * the form's own `displayName` keeps the Inbox's label from going stale
459
+ * the moment somebody renames a form.
460
+ *
461
+ * ⚠️ This does NOT make the legacy filter rename-safe, and it is not
462
+ * meant to — renaming still splits what `?form=` returns, exactly as it
463
+ * always did. `formId` is the filter that survives a rename; that is the
464
+ * whole reason it exists.
465
+ */ const resolvedFormName = form ? String((_ref = (_form_get = form.get('displayName')) != null ? _form_get : formName) != null ? _ref : 'Form').slice(0, 100) : String(formName != null ? formName : 'Form').slice(0, 100);
466
+ /*
467
+ * Consent, from the field the FORM declares.
468
+ *
469
+ * ⛔ Still never inferred from the fact of submission. What the entity
470
+ * adds is a declared place to look: `consentFieldName` names the one
471
+ * field that IS the opt-in, so a merchant's checkbox called anything at
472
+ * all is readable, and a clinic intake form's `consentToTreatment` stays
473
+ * the different instrument it is.
474
+ *
475
+ * A bound form that declares NO consent field falls through to the closed
476
+ * name list below rather than recording nothing. Adoption must not be the
477
+ * moment a form that was capturing opt-ins stops — consent is carried
478
+ * forward, never dropped as a side effect of a migration.
479
+ *
480
+ * Both readers are handed the form's STORED declaration: a Checkboxes
481
+ * field posts the text of the option that was ticked, and only the
482
+ * declaration says a consent field has exactly one option, whose text is
483
+ * then the tick. An unbound form has none, so its boxes count only by an
484
+ * affirmative value.
485
+ */ const formFieldDecls = Array.isArray(form == null ? void 0 : form.get('fields')) ? form == null ? void 0 : form.get('fields') : [];
486
+ const declaredMarketingConsent = (form == null ? void 0 : form.get('consentFieldName')) ? Aglyn.readFormDeclaredConsent({
487
+ consentFieldName: String(form.get('consentFieldName')),
488
+ fields: formFieldDecls
489
+ }, sanitizedFields) : readDeclaredMarketingConsent(payload, fields, formFieldDecls);
490
+ /*
491
+ * THE DISCLOSURE THE FORM SHOWED beside that opt-in (AGL-3320).
492
+ *
493
+ * The Form block renders the site's consent-group sentence under its
494
+ * consent field and posts back the key of what it rendered
495
+ * (`/api/consent/disclosure`). Passed to the record system as it arrived:
496
+ * the writer pools the opt-in across the group only when this is the
497
+ * group's CURRENT key, so a page rendered before a rename or before a
498
+ * site joined records the one site the visitor was on. Trusting it costs
499
+ * nothing — it can only ever narrow what the checkbox above already
500
+ * claims, never widen it past the declaration.
501
+ */ const disclosedConsentGroup = typeof payload['__consentGroup'] === 'string' ? payload['__consentGroup'].trim().slice(0, 64) : '';
502
+ /*
503
+ * THE CAMPAIGNS THE FORM IS FILED UNDER.
504
+ *
505
+ * Off the form document this route has already read, so it costs nothing,
506
+ * and off the VERIFIED form for the same reason the id is: a membership
507
+ * taken from the request body would let anyone file people into any
508
+ * campaign on any site.
509
+ *
510
+ * ⚠️ NOT the campaign touch resolved below, and the two never write each
511
+ * other's field. The touch is where this visitor came FROM and is
512
+ * browser-supplied; this is which campaigns the merchant put the form in,
513
+ * and it is true of every person who fills that form in — including the
514
+ * one who arrived by typing the address. Recording only the touch is what
515
+ * left three forms assigned to a campaign producing contacts that belonged
516
+ * to none.
517
+ */ const formCampaignIds = form ? Aglyn.readContainerIds(form.data(), 'campaign') : [];
518
+ /*
519
+ * THE CAMPAIGNS THE PAGE IS FILED UNDER (AGL-3461), stamped beside the
520
+ * form's for the same reason and with the same caveat: where the merchant
521
+ * filed the page, true of everyone who submits on it, and never a credit.
522
+ *
523
+ * The screen is found in the routing map on the host snapshot this route
524
+ * already holds, from the path the browser sent, so a path naming no page
525
+ * this site serves names no screen and costs nothing. Its campaigns are
526
+ * read off the screen document — one read, only when the path resolves.
527
+ * A failed read stamps nothing rather than failing the submission.
528
+ */ const pageRoutePath = screenRoutePathFromUrl(typeof path === 'string' ? path : '');
529
+ const pageScreenId = pageRoutePath ? findScreenIdByRoutePath(hostSnapshot.get('screens'), pageRoutePath) : undefined;
530
+ const pageCampaignIds = pageScreenId ? await hostRef.collection('screens').doc(pageScreenId).get().then((screen)=>screen.exists ? Aglyn.readContainerIds(screen.data(), 'campaign') : []).catch((error)=>{
531
+ console.error('form submission page campaigns read failed', error);
532
+ return [];
533
+ }) : [];
534
+ /*
535
+ * THE CUSTOM CONTACT FIELDS THIS FORM SAVES TO (AGL-2601).
536
+ *
537
+ * Off the VERIFIED form's declaration, like the campaigns above and for
538
+ * the same reason: a mapping taken from the request body would let anyone
539
+ * write any value under any field of any contact on any site. The
540
+ * definitions are read from the owning org — one bounded read of a small
541
+ * collection — and only when the declaration maps at least one field, so
542
+ * a form that saves nowhere pays nothing.
543
+ *
544
+ * Coerced by the definition's TYPE before it goes anywhere near the
545
+ * contact: `collectMappedContactCustom` is the same rule the API applies
546
+ * to a `custom` body, so a number typed into a form and a number sent
547
+ * over the API are stored as the same thing. A blank field contributes no
548
+ * key at all, so a submission never clears a value the merchant set.
549
+ */ const mappedContactCustom = form && (owningOrg == null ? void 0 : owningOrg.orgId) && formFieldDecls.some((decl)=>decl == null ? void 0 : decl.contactFieldKey) ? Aglyn.collectMappedContactCustom({
550
+ fields: sanitizedFields,
551
+ decls: formFieldDecls,
552
+ definitions: (await firestore.collection('orgs').doc(owningOrg.orgId).collection(Aglyn.CRM_COLLECTIONS.contactFields).limit(Aglyn.CONTACT_FIELDS_MAX_PER_ORG).get()).docs.map((snapshot)=>snapshot.data())
553
+ }) : {};
554
+ // One instant for the whole submission. The attribution window is
555
+ // measured against it in three places below, and three calls to
556
+ // `Date.now()` would let a slow write decide whether a touch was inside
557
+ // the window for the contact and outside it for the lead.
558
+ const submittedAtMs = Date.now();
559
+ /*
560
+ * The organization this site belongs to, off the two documents this
561
+ * route has already read — the host, whose `orgId` only the org APIs
562
+ * write, and the `hostIndex` mirror the plan gate resolved through.
563
+ */ const hostOrgId = hostSnapshot.get('orgId');
564
+ const submissionOrgId = typeof hostOrgId === 'string' && hostOrgId ? hostOrgId : owningOrg == null ? void 0 : owningOrg.orgId;
565
+ const submissionRef = await hostRef.collection('formSubmissions').add(_extends({}, submissionOrgId ? {
566
+ orgId: submissionOrgId
567
+ } : {}, {
568
+ hostId
569
+ }, form ? {
570
+ formId: form.id
571
+ } : {}, formCampaignIds.length ? {
572
+ campaignIds: formCampaignIds
573
+ } : {}, pageCampaignIds.length ? {
574
+ pageCampaignIds
575
+ } : {}, {
576
+ formName: resolvedFormName,
577
+ path: String(path != null ? path : '').slice(0, 500),
578
+ fields: sanitizedFields
579
+ }, messageSearchFields(sanitizedFields), {
580
+ read: false,
581
+ createdAt: FieldValue.serverTimestamp()
582
+ }, rate.degraded ? {
583
+ rateDegraded: true
584
+ } : {}));
585
+ // Contacts ingestion (AGL-197): forms don't guarantee an email field —
586
+ // best-effort extraction; never blocks the submission.
587
+ const contactEmail = extractEmailFromFields(sanitizedFields);
588
+ /*
589
+ * Whether this submission was filed to the site's Leads.
590
+ *
591
+ * The form counts PEOPLE (AGL-3330). The lead silo keys one person to one
592
+ * document, so a returning visitor's second submission updates the lead
593
+ * it created the first time; the form's lead count moves only when the
594
+ * capture puts this form's source on a lead for the first time
595
+ * (`leadCounted`), which is exactly what a recount of the leads'
596
+ * `sources` finds — so the increment and the recount agree, and a lead
597
+ * erased later can be taken back off. A routed submission by somebody
598
+ * the workspace already holds as a contact lands on the contact and files
599
+ * no lead (AGL-3232), and is not counted.
600
+ */ let leadStored = false;
601
+ /** Whether that lead is one more person on this form's lead count. */ let leadCounted = false;
602
+ /**
603
+ * The lead or contact the person was filed as, for the Inbox to link to
604
+ * (AGL-3461) — by the id the record system answered with, which is the
605
+ * id its own record route addresses.
606
+ */ let capturedRecord = null;
607
+ /*
608
+ * WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SUBMISSION.
609
+ *
610
+ * One visitor action lands in three collections here — the submission,
611
+ * the contact and (when the form declares itself a lead surface) the
612
+ * lead — and all three are the same person arriving from the same place.
613
+ * Resolving here and handing the result down is what keeps the lookup at
614
+ * one per submission instead of three, and it is also what guarantees the
615
+ * three records cannot disagree about what to credit.
616
+ *
617
+ * The answer is the crediting plugin's (`plugin-conversion-credit.ts`),
618
+ * asked with what the visitor's browser sent under `campaignTouch` —
619
+ * untrusted, so the creditor re-parses and window-checks it before it
620
+ * can name anything. A submission carrying none — direct traffic, or a
621
+ * visitor whose consent posture never let a touch be remembered — and a
622
+ * workspace whose plugins credit nothing both resolve to null and write
623
+ * no credit at all.
624
+ */ const arrival = await resolveConversionTouch({
625
+ hostId,
626
+ wire: payload['campaignTouch'],
627
+ email: contactEmail,
628
+ atMs: submittedAtMs
629
+ });
630
+ // The submission itself is an outcome whether or not it named anybody:
631
+ // an anonymous enquiry from a campaign link is a conversion that campaign
632
+ // caused, and refusing to count it because no email field was filled in
633
+ // would under-report exactly the forms that ask for the least.
634
+ void creditConversion({
635
+ hostId,
636
+ kind: 'form',
637
+ refId: submissionRef.id,
638
+ touch: arrival,
639
+ convertedAtMs: submittedAtMs
640
+ });
641
+ /*
642
+ * THE CAMPAIGNS IN WORDS, for the managers' alert below (AGL-3461): the
643
+ * one the visitor arrived through and the ones the form and page are
644
+ * filed under, named by the plugin that keeps campaigns. Started here so
645
+ * its reads overlap the capture and the counters; asked only when there
646
+ * is something to name, and it never throws.
647
+ */ const filedUnder = normalizeContainerIds([
648
+ ...formCampaignIds,
649
+ ...pageCampaignIds
650
+ ]);
651
+ const describing = arrival || filedUnder.length ? describeConversion({
652
+ hostId,
653
+ touch: arrival,
654
+ containerIds: filedUnder
655
+ }) : Promise.resolve(null);
656
+ if (contactEmail) {
657
+ var _ref2, _sanitizedFields_name, _captured_sourceAdded;
658
+ var _form_get1;
659
+ /*
660
+ * WHICH RECORD THE PERSON LANDS ON is the record system's decision
661
+ * (AGL-3232), and the form says only what kind of surface it is: a
662
+ * lead surface when its author switched lead routing on
663
+ * (`docs/specs/reusable-forms.md` §4a), a touch otherwise. The writer
664
+ * files a lead — through `addHostLead`, keyed on the person and
665
+ * counted against the ceiling — unless the workspace already holds the
666
+ * address as a contact, and answers which record it wrote.
667
+ *
668
+ * AWAITED, unlike the capture of every other door, and only because
669
+ * the answer is counted: the form's `stats.leads` rides the same
670
+ * `update` the submission counters ride below, and a `void` call with
671
+ * the counter chained onto its `then` would put the one figure on this
672
+ * page that is about routing on a promise the runtime is free to
673
+ * abandon once the response is sent. The posture is unchanged —
674
+ * `recordCapturedContact` never throws — so awaiting it cannot fail the
675
+ * submission any more than voiding it could.
676
+ */ const routed = (form == null ? void 0 : (_form_get1 = form.get('routing')) == null ? void 0 : _form_get1.lead) === true;
677
+ const captured = await recordCapturedContact(_extends({
678
+ /*
679
+ * Not gated on the org resolving. The record system keys a person on
680
+ * the SITE they were met on, so an org lookup that came back empty
681
+ * costs the capture nothing — and refusing to record the person over
682
+ * it would turn a lookup failure into a lost contact, silently, which
683
+ * is the failure this whole seam is arranged to avoid.
684
+ */ orgId: (_ref2 = owningOrg == null ? void 0 : owningOrg.orgId) != null ? _ref2 : '',
685
+ hostId,
686
+ identity: {
687
+ email: contactEmail,
688
+ name: (_sanitizedFields_name = sanitizedFields['name']) != null ? _sanitizedFields_name : sanitizedFields['fullName']
689
+ },
690
+ interaction: {
691
+ source: 'form',
692
+ refId: submissionRef.id,
693
+ summary: `Submitted "${resolvedFormName.slice(0, 60)}"`
694
+ },
695
+ surface: routed ? 'lead' : 'touch'
696
+ }, declaredMarketingConsent ? {
697
+ marketingConsent: true
698
+ } : {}, declaredMarketingConsent && disclosedConsentGroup ? {
699
+ disclosedConsentGroup
700
+ } : {}, formCampaignIds.length ? {
701
+ containers: {
702
+ campaign: formCampaignIds
703
+ }
704
+ } : {}, Object.keys(mappedContactCustom).length ? {
705
+ profile: {
706
+ custom: mappedContactCustom
707
+ }
708
+ } : {}, {
709
+ detail: _extends({}, form ? {
710
+ formId: form.id
711
+ } : {}, typeof path === 'string' && path ? {
712
+ path
713
+ } : {}, arrival ? {
714
+ [CONVERSION_TOUCH_DETAIL]: arrival
715
+ } : {})
716
+ }));
717
+ leadStored = (captured == null ? void 0 : captured.ok) === true && captured.record === 'lead';
718
+ /*
719
+ * A form counts the PEOPLE it filed (AGL-3330): the capture says
720
+ * whether this one was new to the form, and a writer that does not
721
+ * say is read as whether the lead itself was new.
722
+ */ leadCounted = leadStored && (captured == null ? void 0 : captured.ok) === true && captured.record === 'lead' && ((_captured_sourceAdded = captured.sourceAdded) != null ? _captured_sourceAdded : captured.created) === true;
723
+ if ((captured == null ? void 0 : captured.ok) === true) {
724
+ const id = captured.record === 'lead' ? captured.leadId : captured.contactId;
725
+ if (id) capturedRecord = {
726
+ kind: captured.record,
727
+ id
728
+ };
729
+ }
730
+ }
731
+ /*
732
+ * WHERE ELSE THE SUBMISSION IS FILED (AGL-3080): a record in whatever
733
+ * plugin keeps the records a form may write into — a dataset row, today.
734
+ *
735
+ * Asked AFTER the submission is stored, through the platform's contract
736
+ * (`plugin-manager/submission-record-target.ts`), and the target decides from
737
+ * what the page's compose stamped onto this form, never from the body
738
+ * alone: the route is public. It answers the submission's `routing`,
739
+ * which the Inbox reads to say where it went or why it did not. The Inbox
740
+ * copy above is canonical either way, and nothing here can fail the
741
+ * submission.
742
+ */ const filed = await writeFormRecordTarget({
743
+ hostId,
744
+ orgId: (_ref1 = owningOrg == null ? void 0 : owningOrg.orgId) != null ? _ref1 : null,
745
+ orgBilling,
746
+ body: payload,
747
+ fields: sanitizedFields
748
+ });
749
+ /*
750
+ * What the submission led to, on the row in ONE update: where else it
751
+ * was filed, and the lead or contact it filed (AGL-3461). Written only
752
+ * when there is either, so the ordinary anonymous enquiry pays nothing.
753
+ */ const outcomeNotes = _extends({}, filed.routing ? {
754
+ routing: filed.routing
755
+ } : {}, capturedRecord ? {
756
+ capturedRecord
757
+ } : {});
758
+ if (Object.keys(outcomeNotes).length) {
759
+ try {
760
+ await submissionRef.update(outcomeNotes);
761
+ } catch (error) {
762
+ console.error('form submission routing note failed', error);
763
+ }
764
+ }
765
+ await counterRef.set({
766
+ [monthKey]: FieldValue.increment(1)
767
+ }, {
768
+ merge: true
769
+ });
770
+ /*
771
+ * Per-form counters (`docs/specs/reusable-forms.md` §5a), through the
772
+ * one writer that retries and REPORTS a failure rather than swallowing
773
+ * it (AGL-3330) — see `incrementFormStats`.
774
+ *
775
+ * `stats.leads` is here rather than beside the lead writer for the same
776
+ * reason the submission counters are: a second write to say a lead was
777
+ * filed would be a second write per submission on the collection the
778
+ * customer is billed against.
779
+ */ if (form) {
780
+ await incrementFormStats({
781
+ formRef: form.ref,
782
+ hostId,
783
+ monthKey,
784
+ submittedAtMs,
785
+ leadCounted
786
+ });
787
+ }
788
+ // Event trigger (AGL-128/148): field values join the automation
789
+ // scope; action-produced site alerts ride back to the visitor.
790
+ // In-app notification to the site's managers (AGL-259).
791
+ /*
792
+ * The body names the form and the site on its own (AGL-3432): a manager
793
+ * of several sites reads the email body, not always the subject, and
794
+ * "Page: /contact" said neither. `{site}` is filled by
795
+ * `notifyHostManagers` from the host doc it already reads.
796
+ *
797
+ * Then the campaigns (AGL-3461): the one the visitor was credited to and
798
+ * how, and what the form and page are filed under, as the crediting
799
+ * plugin names them.
800
+ *
801
+ * It opens THIS submission, not the list: the address the plugin that
802
+ * shows submissions declares for one record (`submissionsLink`), stored
803
+ * in the host-link shape every host notification uses and rewritten onto
804
+ * the site's address when it is followed, in the console and in the email
805
+ * alike. A notification holds one link, so the lead and the campaign are
806
+ * links inside the reader.
807
+ */ const alertPath = typeof path === 'string' ? path.slice(0, 500) : '';
808
+ void notifyHostManagers(hostId, _extends({
809
+ type: 'content.formSubmission',
810
+ title: `New form submission — ${resolvedFormName}`,
811
+ body: [
812
+ `Someone submitted “${resolvedFormName}” on {site}` + (alertPath ? ` (page ${alertPath}).` : '.'),
813
+ ...conversionDescriptionSentences(await describing)
814
+ ].join(' ')
815
+ }, submissionsLink(hostId, submissionRef.id)));
816
+ const submittedEmail = typeof sanitizedFields['email'] === 'string' ? sanitizedFields['email'] : '';
817
+ /*
818
+ * `formId` is the VERIFIED form's id (AGL-3458), the way `contactCreated`
819
+ * carries it: a condition keyed on it survives the form being renamed,
820
+ * where one on `formName` silently stops matching. It is written last
821
+ * and only from the form this route read, so a submitted field that
822
+ * calls itself `formId` can neither claim another form's automations
823
+ * nor stand in for a form the site does not have.
824
+ */ const submittedFields = Object.fromEntries(Object.entries(sanitizedFields).filter(([key])=>key !== 'formId'));
825
+ const { alerts } = await emitHostEvent(hostId, 'formSubmission', _extends({
826
+ formName: resolvedFormName,
827
+ path: String(path != null ? path : '').slice(0, 500)
828
+ }, submittedFields, form ? {
829
+ formId: form.id
830
+ } : {}), // The visitor who submitted, by the address they typed if any (AGL-3376).
831
+ {
832
+ actor: _extends({
833
+ kind: 'visitor'
834
+ }, submittedEmail ? {
835
+ email: submittedEmail
836
+ } : {})
837
+ });
838
+ return json({
839
+ received: true,
840
+ alerts
841
+ });
842
+ } catch (error) {
843
+ console.error(error);
844
+ return json({
845
+ error: 'Submission failed'
846
+ }, 500);
847
+ }
848
+ }
849
+
850
+ //# sourceMappingURL=form-submit.js.map