@aglyn/plugins-forms 1.0.0-beta.220 → 1.0.0-beta.222

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