@aglyn/plugins-forms 1.0.0-beta.222 → 1.0.0-beta.224
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.
- package/package.json +14 -14
- package/src/lib/model/form-submission-record.d.ts +3 -1
- package/src/lib/model/form-submission-record.js +3 -1
- package/src/lib/model/form-submission-record.js.map +1 -1
- package/src/lib/model/forms-record-lists.d.ts +1 -1
- package/src/lib/model/forms-record-lists.js +7 -2
- package/src/lib/model/forms-record-lists.js.map +1 -1
- package/src/lib/server/form-submit.js +98 -17
- package/src/lib/server/form-submit.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-forms",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.224",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
40
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.224",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.224",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.224",
|
|
31
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.224",
|
|
32
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.224",
|
|
33
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.224",
|
|
34
|
+
"@aglyn/shared-util-email": "1.0.0-beta.224",
|
|
35
|
+
"@aglyn/shared-util-http": "1.0.0-beta.224",
|
|
36
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.224",
|
|
37
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.224",
|
|
38
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.224",
|
|
39
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.224",
|
|
40
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.224",
|
|
41
41
|
"@swc/helpers": "0.5.23"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
@@ -34,7 +34,9 @@ import type { PluginIndexedRecord } from '@aglyn/aglyn/plugin-manager/plugin-rec
|
|
|
34
34
|
* `formId` for a verified form entity; `formName` and `path`; `fields`, the
|
|
35
35
|
* values as typed, keyed by field name; `senderTokens` and `searchTokens`,
|
|
36
36
|
* the word-prefix keys a list filters and searches by; `read`, a boolean;
|
|
37
|
-
* `createdAt`, the server time it arrived; and `campaignIds`,
|
|
37
|
+
* `createdAt`, the server time it arrived; and `campaignIds`,
|
|
38
|
+
* `pageCampaignIds` (the campaigns its page was filed under), `routing`,
|
|
39
|
+
* `capturedRecord` (the lead or contact it filed, `{ kind, id }`) and
|
|
38
40
|
* `rateDegraded` where they apply. A reader may change `read`, and stamp
|
|
39
41
|
* `repliedAtMs` with `read` when it answers one; it may keep its own records
|
|
40
42
|
* of what it did with one under the document (the Inbox's `replies` and
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
* `formId` for a verified form entity; `formName` and `path`; `fields`, the
|
|
33
33
|
* values as typed, keyed by field name; `senderTokens` and `searchTokens`,
|
|
34
34
|
* the word-prefix keys a list filters and searches by; `read`, a boolean;
|
|
35
|
-
* `createdAt`, the server time it arrived; and `campaignIds`,
|
|
35
|
+
* `createdAt`, the server time it arrived; and `campaignIds`,
|
|
36
|
+
* `pageCampaignIds` (the campaigns its page was filed under), `routing`,
|
|
37
|
+
* `capturedRecord` (the lead or contact it filed, `{ kind, id }`) and
|
|
36
38
|
* `rateDegraded` where they apply. A reader may change `read`, and stamp
|
|
37
39
|
* `repliedAtMs` with `read` when it answers one; it may keep its own records
|
|
38
40
|
* of what it did with one under the document (the Inbox's `replies` and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/model/form-submission-record.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginIndexedRecord } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\n\n/**\n * A FORM SUBMISSION, as this plugin shares it with another (AGL-3080).\n *\n * Submissions are this plugin's records: its door (`/api/forms/submit`)\n * writes every one, its `/v1` resource serves them, its counters are what the\n * plan bills, and they live where it keeps them —\n * `hosts/{hostId}/formSubmissions/{id}`, which only this plugin names. The\n * Inbox is the console's reader of them, and reads, marks and threads them\n * through what this plugin publishes for the `formSubmission` kind: a list\n * source in the console (`form-submission-list.ts`) and a record index on the\n * server (`server/form-submission-index.ts`).\n *\n * ## The record, as a reader may rely on it\n *\n * Every stored document carries, from the moment the door writes it:\n * `hostId`, and `orgId` when the site has one (both frozen by the rules);\n * `formId` for a verified form entity; `formName` and `path`; `fields`, the\n * values as typed, keyed by field name; `senderTokens` and `searchTokens`,\n * the word-prefix keys a list filters and searches by; `read`, a boolean;\n * `createdAt`, the server time it arrived; and `campaignIds
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/model/form-submission-record.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginIndexedRecord } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\n\n/**\n * A FORM SUBMISSION, as this plugin shares it with another (AGL-3080).\n *\n * Submissions are this plugin's records: its door (`/api/forms/submit`)\n * writes every one, its `/v1` resource serves them, its counters are what the\n * plan bills, and they live where it keeps them —\n * `hosts/{hostId}/formSubmissions/{id}`, which only this plugin names. The\n * Inbox is the console's reader of them, and reads, marks and threads them\n * through what this plugin publishes for the `formSubmission` kind: a list\n * source in the console (`form-submission-list.ts`) and a record index on the\n * server (`server/form-submission-index.ts`).\n *\n * ## The record, as a reader may rely on it\n *\n * Every stored document carries, from the moment the door writes it:\n * `hostId`, and `orgId` when the site has one (both frozen by the rules);\n * `formId` for a verified form entity; `formName` and `path`; `fields`, the\n * values as typed, keyed by field name; `senderTokens` and `searchTokens`,\n * the word-prefix keys a list filters and searches by; `read`, a boolean;\n * `createdAt`, the server time it arrived; and `campaignIds`,\n * `pageCampaignIds` (the campaigns its page was filed under), `routing`,\n * `capturedRecord` (the lead or contact it filed, `{ kind, id }`) and\n * `rateDegraded` where they apply. A reader may change `read`, and stamp\n * `repliedAtMs` with `read` when it answers one; it may keep its own records\n * of what it did with one under the document (the Inbox's `replies` and\n * `listAssignments`). Nothing else on it is a reader's to write, and the\n * security rules hold the client half of that line.\n *\n * `null` for a document that holds nothing. Otherwise its id, a name — the\n * form it was sent to, as it was called when it arrived — and `facts`:\n * `hostId`, `formId` (`null` for a submission no form entity filed),\n * `formName`, `path`, `fields`, `read` and `createdAtMs` (`null` before the\n * server stamped it).\n */\nexport function formSubmissionIndexedRecord(\n id: string,\n data: Readonly<Record<string, unknown>> | undefined,\n): PluginIndexedRecord | null {\n if (!data) return null\n const text = (value: unknown): string => (typeof value === 'string' ? value : '')\n const formName = text(data['formName'])\n const createdAt = data['createdAt'] as { toMillis?: () => number } | null | undefined\n const createdAtMs =\n typeof createdAt?.toMillis === 'function' ? createdAt.toMillis() : null\n return {\n id,\n name: formName || 'Form',\n facts: {\n hostId: text(data['hostId']) || null,\n formId: text(data['formId']) || null,\n formName,\n path: text(data['path']),\n fields:\n data['fields'] && typeof data['fields'] === 'object' ? data['fields'] : {},\n read: data['read'] === true,\n createdAtMs,\n },\n }\n}\n"],"names":["formSubmissionIndexedRecord","id","data","text","value","formName","createdAt","createdAtMs","toMillis","name","facts","hostId","formId","path","fields","read"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GACD,OAAO,SAASA,4BACdC,EAAU,EACVC,IAAmD;IAEnD,IAAI,CAACA,MAAM,OAAO;IAClB,MAAMC,OAAO,CAACC,QAA4B,OAAOA,UAAU,WAAWA,QAAQ;IAC9E,MAAMC,WAAWF,KAAKD,IAAI,CAAC,WAAW;IACtC,MAAMI,YAAYJ,IAAI,CAAC,YAAY;IACnC,MAAMK,cACJ,QAAOD,6BAAAA,UAAWE,QAAQ,MAAK,aAAaF,UAAUE,QAAQ,KAAK;IACrE,OAAO;QACLP;QACAQ,MAAMJ,YAAY;QAClBK,OAAO;YACLC,QAAQR,KAAKD,IAAI,CAAC,SAAS,KAAK;YAChCU,QAAQT,KAAKD,IAAI,CAAC,SAAS,KAAK;YAChCG;YACAQ,MAAMV,KAAKD,IAAI,CAAC,OAAO;YACvBY,QACEZ,IAAI,CAAC,SAAS,IAAI,OAAOA,IAAI,CAAC,SAAS,KAAK,WAAWA,IAAI,CAAC,SAAS,GAAG,CAAC;YAC3Ea,MAAMb,IAAI,CAAC,OAAO,KAAK;YACvBK;QACF;IACF;AACF"}
|
|
@@ -24,7 +24,7 @@ import { type PluginRecordListSource } from '@aglyn/aglyn/plugin-manager/plugin-
|
|
|
24
24
|
* answers nothing at the organization, where no site is named. An archived
|
|
25
25
|
* form collects nothing, so it is left out — an automation keyed on it would
|
|
26
26
|
* never fire. A form with no display name reads by its id, as every form list
|
|
27
|
-
* names one.
|
|
27
|
+
* names one. Its one fact, `routesLeads`, says whether it files leads.
|
|
28
28
|
*/
|
|
29
29
|
export declare const formRecordListSource: PluginRecordListSource;
|
|
30
30
|
/** Called from the console registrar, owner named for a spec that calls it directly. */
|
|
@@ -25,7 +25,7 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
|
25
25
|
* answers nothing at the organization, where no site is named. An archived
|
|
26
26
|
* form collects nothing, so it is left out — an automation keyed on it would
|
|
27
27
|
* never fire. A form with no display name reads by its id, as every form list
|
|
28
|
-
* names one.
|
|
28
|
+
* names one. Its one fact, `routesLeads`, says whether it files leads.
|
|
29
29
|
*/ export const formRecordListSource = {
|
|
30
30
|
query (firestore, request) {
|
|
31
31
|
if (!request.hostId) return null;
|
|
@@ -34,10 +34,15 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
|
34
34
|
record (id, data) {
|
|
35
35
|
var _data_displayName;
|
|
36
36
|
if (data['archivedAt'] != null) return null;
|
|
37
|
+
// Whether the form files its people as leads (`routing.lead`): a picker's
|
|
38
|
+
// recipe keys on the record such a form makes.
|
|
39
|
+
const routing = data['routing'];
|
|
37
40
|
return {
|
|
38
41
|
id,
|
|
39
42
|
name: String((_data_displayName = data['displayName']) != null ? _data_displayName : '').trim() || id,
|
|
40
|
-
facts: {
|
|
43
|
+
facts: {
|
|
44
|
+
routesLeads: (routing == null ? void 0 : routing.lead) === true
|
|
45
|
+
}
|
|
41
46
|
};
|
|
42
47
|
}
|
|
43
48
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/model/forms-record-lists.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordListSource,\n type PluginRecordListSource,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-lists'\nimport { collection, documentId, limit, orderBy, query } from 'firebase/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * A site's forms, listed in the console for another plugin's picker\n * (AGL-3080) — a recipe that keys an automation on the form a person picks.\n *\n * A form is the site's: the query lists `hosts/{hostId}/forms`, ordered by\n * document id so a reader asking one past its window learns it was cut, and\n * answers nothing at the organization, where no site is named. An archived\n * form collects nothing, so it is left out — an automation keyed on it would\n * never fire. A form with no display name reads by its id, as every form list\n * names one.\n */\nexport const formRecordListSource: PluginRecordListSource = {\n query(firestore, request) {\n if (!request.hostId) return null\n return query(\n collection(firestore, 'hosts', request.hostId, 'forms'),\n orderBy(documentId()),\n limit(request.limit),\n )\n },\n record(id, data) {\n if (data['archivedAt'] != null) return null\n return {
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/model/forms-record-lists.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordListSource,\n type PluginRecordListSource,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-lists'\nimport { collection, documentId, limit, orderBy, query } from 'firebase/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * A site's forms, listed in the console for another plugin's picker\n * (AGL-3080) — a recipe that keys an automation on the form a person picks.\n *\n * A form is the site's: the query lists `hosts/{hostId}/forms`, ordered by\n * document id so a reader asking one past its window learns it was cut, and\n * answers nothing at the organization, where no site is named. An archived\n * form collects nothing, so it is left out — an automation keyed on it would\n * never fire. A form with no display name reads by its id, as every form list\n * names one. Its one fact, `routesLeads`, says whether it files leads.\n */\nexport const formRecordListSource: PluginRecordListSource = {\n query(firestore, request) {\n if (!request.hostId) return null\n return query(\n collection(firestore, 'hosts', request.hostId, 'forms'),\n orderBy(documentId()),\n limit(request.limit),\n )\n },\n record(id, data) {\n if (data['archivedAt'] != null) return null\n // Whether the form files its people as leads (`routing.lead`): a picker's\n // recipe keys on the record such a form makes.\n const routing = data['routing'] as { lead?: unknown } | null | undefined\n return {\n id,\n name: String(data['displayName'] ?? '').trim() || id,\n facts: { routesLeads: routing?.lead === true },\n }\n },\n}\n\n/** Called from the console registrar, owner named for a spec that calls it directly. */\nexport function registerFormRecordList(): void {\n registerPluginRecordListSource('form', formRecordListSource, { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordListSource","collection","documentId","limit","orderBy","query","BUNDLE_ID","formRecordListSource","firestore","request","hostId","record","id","data","routing","name","String","trim","facts","routesLeads","lead","registerFormRecordList","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,8BAA8B,QAEzB,kDAAiD;AACxD,SAASC,UAAU,EAAEC,UAAU,EAAEC,KAAK,EAAEC,OAAO,EAAEC,KAAK,QAAQ,qBAAoB;AAClF,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,uBAA+C;IAC1DF,OAAMG,SAAS,EAAEC,OAAO;QACtB,IAAI,CAACA,QAAQC,MAAM,EAAE,OAAO;QAC5B,OAAOL,MACLJ,WAAWO,WAAW,SAASC,QAAQC,MAAM,EAAE,UAC/CN,QAAQF,eACRC,MAAMM,QAAQN,KAAK;IAEvB;IACAQ,QAAOC,EAAE,EAAEC,IAAI;YAOEA;QANf,IAAIA,IAAI,CAAC,aAAa,IAAI,MAAM,OAAO;QACvC,0EAA0E;QAC1E,+CAA+C;QAC/C,MAAMC,UAAUD,IAAI,CAAC,UAAU;QAC/B,OAAO;YACLD;YACAG,MAAMC,QAAOH,oBAAAA,IAAI,CAAC,cAAc,YAAnBA,oBAAuB,IAAII,IAAI,MAAML;YAClDM,OAAO;gBAAEC,aAAaL,CAAAA,2BAAAA,QAASM,IAAI,MAAK;YAAK;QAC/C;IACF;AACF,EAAC;AAED,sFAAsF,GACtF,OAAO,SAASC;IACdrB,+BAA+B,QAAQO,sBAAsB;QAAEe,UAAUhB;IAAU;AACrF"}
|
|
@@ -20,7 +20,9 @@ import { consumeRateLimit, firebaseAdmin, getOrgForHost, notifyHostManagers, vis
|
|
|
20
20
|
import { emitHostEvent } from "@aglyn/tenant-runtime";
|
|
21
21
|
import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
|
|
22
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";
|
|
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";
|
|
24
26
|
// The leaf: the Inbox list's search keys, the same function its backfill
|
|
25
27
|
// restates and its query's normalizers read (AGL-3321).
|
|
26
28
|
import { messageSearchFields } from "@aglyn/aglyn/app-utils/message-search";
|
|
@@ -125,11 +127,12 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
125
127
|
}
|
|
126
128
|
/**
|
|
127
129
|
* A notification's link to where the site's managers read submissions — the
|
|
128
|
-
* page the plugin that shows `formSubmission` records declares
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
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);
|
|
133
136
|
return link ? {
|
|
134
137
|
link
|
|
135
138
|
} : {};
|
|
@@ -513,6 +516,22 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
513
516
|
* to none.
|
|
514
517
|
*/ const formCampaignIds = form ? Aglyn.readContainerIds(form.data(), 'campaign') : [];
|
|
515
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
|
+
/*
|
|
516
535
|
* THE CUSTOM CONTACT FIELDS THIS FORM SAVES TO (AGL-2601).
|
|
517
536
|
*
|
|
518
537
|
* Off the VERIFIED form's declaration, like the campaigns above and for
|
|
@@ -551,6 +570,8 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
551
570
|
formId: form.id
|
|
552
571
|
} : {}, formCampaignIds.length ? {
|
|
553
572
|
campaignIds: formCampaignIds
|
|
573
|
+
} : {}, pageCampaignIds.length ? {
|
|
574
|
+
pageCampaignIds
|
|
554
575
|
} : {}, {
|
|
555
576
|
formName: resolvedFormName,
|
|
556
577
|
path: String(path != null ? path : '').slice(0, 500),
|
|
@@ -578,6 +599,11 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
578
599
|
* no lead (AGL-3232), and is not counted.
|
|
579
600
|
*/ let leadStored = false;
|
|
580
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;
|
|
581
607
|
/*
|
|
582
608
|
* WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SUBMISSION.
|
|
583
609
|
*
|
|
@@ -612,6 +638,21 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
612
638
|
touch: arrival,
|
|
613
639
|
convertedAtMs: submittedAtMs
|
|
614
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);
|
|
615
656
|
if (contactEmail) {
|
|
616
657
|
var _ref2, _sanitizedFields_name, _captured_sourceAdded;
|
|
617
658
|
var _form_get1;
|
|
@@ -679,6 +720,13 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
679
720
|
* whether this one was new to the form, and a writer that does not
|
|
680
721
|
* say is read as whether the lead itself was new.
|
|
681
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
|
+
}
|
|
682
730
|
}
|
|
683
731
|
/*
|
|
684
732
|
* WHERE ELSE THE SUBMISSION IS FILED (AGL-3080): a record in whatever
|
|
@@ -698,11 +746,18 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
698
746
|
body: payload,
|
|
699
747
|
fields: sanitizedFields
|
|
700
748
|
});
|
|
701
|
-
|
|
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) {
|
|
702
759
|
try {
|
|
703
|
-
await submissionRef.update(
|
|
704
|
-
routing: filed.routing
|
|
705
|
-
});
|
|
760
|
+
await submissionRef.update(outcomeNotes);
|
|
706
761
|
} catch (error) {
|
|
707
762
|
console.error('form submission routing note failed', error);
|
|
708
763
|
}
|
|
@@ -733,20 +788,46 @@ const json = (body, status = 200)=>Response.json(body, {
|
|
|
733
788
|
// Event trigger (AGL-128/148): field values join the automation
|
|
734
789
|
// scope; action-produced site alerts ride back to the visitor.
|
|
735
790
|
// In-app notification to the site's managers (AGL-259).
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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) : '';
|
|
740
808
|
void notifyHostManagers(hostId, _extends({
|
|
741
809
|
type: 'content.formSubmission',
|
|
742
810
|
title: `New form submission — ${resolvedFormName}`,
|
|
743
|
-
body:
|
|
744
|
-
|
|
811
|
+
body: [
|
|
812
|
+
`Someone submitted “${resolvedFormName}” on {site}` + (alertPath ? ` (page ${alertPath}).` : '.'),
|
|
813
|
+
...conversionDescriptionSentences(await describing)
|
|
814
|
+
].join(' ')
|
|
815
|
+
}, submissionsLink(hostId, submissionRef.id)));
|
|
745
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'));
|
|
746
825
|
const { alerts } = await emitHostEvent(hostId, 'formSubmission', _extends({
|
|
747
826
|
formName: resolvedFormName,
|
|
748
827
|
path: String(path != null ? path : '').slice(0, 500)
|
|
749
|
-
},
|
|
828
|
+
}, submittedFields, form ? {
|
|
829
|
+
formId: form.id
|
|
830
|
+
} : {}), // The visitor who submitted, by the address they typed if any (AGL-3376).
|
|
750
831
|
{
|
|
751
832
|
actor: _extends({
|
|
752
833
|
kind: 'visitor'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/server/form-submit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport { extractEmailFromFields } from '@aglyn/aglyn/server'\nimport {\n consumeRateLimit,\n firebaseAdmin,\n getOrgForHost,\n notifyHostManagers,\n visitorWriteRefusal,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\n// By path: the server-only contract a door credits its outcome through.\nimport {\n CONVERSION_TOUCH_DETAIL,\n creditConversion,\n resolveConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\n// The leaf: the Inbox list's search keys, the same function its backfill\n// restates and its query's normalizers read (AGL-3321).\nimport { messageSearchFields } from '@aglyn/aglyn/app-utils/message-search'\n// By path: the server-only contract a submission is filed as a record through.\nimport { writeFormRecordTarget } from '@aglyn/aglyn/plugin-manager/submission-record-target'\n// By path: where a manager reads submissions, declared by the plugin whose\n// page shows them, for the notifications below (AGL-3080).\nimport { pluginRecordPageLink } from '@aglyn/aglyn/plugin-manager/plugin-record-pages'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { isCredentialFieldName } from '@aglyn/shared-util-email/hosted-page-screen'\nimport { FORMS_OFF_FOR_SITE_REFUSAL, formSubmissionDoorPlugin } from '../model/form-door'\nimport { FORM_ABUSE_CEILING_CODE } from '../model/form-unavailable'\nimport { incrementFormStats } from './increment-form-stats'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\nconst MAX_FIELDS = 20\nconst MAX_PAYLOAD_CHARS = 10000\n\n// Per-IP submission limit (AGL-794). Previously a per-instance Map, which on\n// serverless resets each cold start and is held per concurrent instance — so\n// a spammer got roughly RATE_MAX × instances and could widen it by going\n// wider. The monthly plan quota was never the right thing to be defended BY:\n// burning a site's whole allowance is the damage, not the protection — and\n// since AGL-1280 it is not even a wall on a paid plan, it meters. This\n// counter is global, keyed per (site, IP).\n//\n// It is the SHAPE of abuse control, not the whole of it (AGL-1655): 10/60s\n// per address is 14,400/day from one IP and multiplies by the number of\n// addresses, so per-IP alone leaves the site's total unbounded. The per-site\n// monthly ceiling below is what bounds the total.\nconst RATE_WINDOW_MS = 60_000\nconst RATE_MAX = 10\n\nconst json = (body: unknown, status = 200) => Response.json(body, { status })\n\n/**\n * The ceiling is a per-MONTH count, so the honest retry hint is the month\n * boundary rather than a made-up interval. UTC, matching `monthKey`.\n */\nconst secondsUntilNextMonth = (now = new Date()): number => {\n const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)\n return Math.max(1, Math.ceil((next - now.getTime()) / 1000))\n}\n\n/**\n * The one useful thing a refused visitor can be given (AGL-1666): the site's\n * OWN published support address, so a lost lead has somewhere else to go.\n *\n * Read through the host-token registry rather than reaching into the\n * document, because that registry is what defines this field as the address\n * a site publishes to visitors — its description is literally \"where\n * visitors should write for help\". Anything else on the host document is\n * account data and must not leave the console.\n *\n * Free: the host snapshot is already loaded for the existence check.\n */\nconst publishedContactEmail = (\n host: Record<string, any> | undefined,\n): string | undefined =>\n host ? Aglyn.HOST_TOKENS['supportEmail']?.resolve(host) : undefined\n\n/**\n * Make a tripped ceiling OBSERVABLE (AGL-1655) rather than a silent drop.\n *\n * Two audiences, one write. Staff get a durable per-month refusal count at\n * `hosts/{id}/counters/formSubmissionsRefused` — the same counters-document\n * shape `contactsDropped` already uses for free-tier contact drops, and\n * client-unwritable for the same reason (AGL-1367). The site's managers get\n * one in-app notification, on the FIRST refusal of the month only: a\n * notification per refused bot request would be the flood again, delivered.\n *\n * Best-effort throughout. A bookkeeping failure must not turn a contained\n * refusal into a 500, because a 500 is a retry invitation.\n */\nasync function recordAbuseCeilingTrip(\n hostRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n monthKey: string,\n ceiling: number,\n): Promise<void> {\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc('formSubmissionsRefused')\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n // Every fact here is the route's own (AGL-3432): the refusal sits before\n // every write, so a refused submission is neither stored nor counted\n // toward the bill, and the counter is month-keyed, so forms accept\n // again at the UTC month boundary — the same date the inbox notice\n // prints. `{site}` is the site's name, filled by `notifyHostManagers`.\n const reopens = Aglyn.nextUtcMonthStart().toLocaleDateString('en-US', {\n month: 'long',\n day: 'numeric',\n timeZone: 'UTC',\n })\n await notifyHostManagers(hostId, {\n type: 'system.formSubmissionsPaused',\n title: 'Form submissions paused on {site} — unusual volume',\n body:\n `Forms on {site} stopped accepting submissions: the site reached ` +\n `${ceiling.toLocaleString()} this month, the safety limit for ` +\n 'unusual volume. Submissions sent now are refused and not saved, ' +\n `and none of them are billed. Forms accept submissions again on ` +\n `${reopens}. Contact support if this is real traffic.`,\n ...submissionsLink(hostId),\n })\n }\n } catch (error) {\n console.error('form abuse ceiling bookkeeping failed', error)\n }\n}\n\n/**\n * A notification's link to where the site's managers read submissions — the\n * page the plugin that shows `formSubmission` records declares — and no link\n * where no plugin in this build shows them. A key left out rather than set to\n * `undefined`, which Firestore rejects.\n */\nfunction submissionsLink(hostId: string): { link?: string } {\n const link = pluginRecordPageLink('formSubmission', hostId)\n return link ? { link } : {}\n}\n\n/**\n * Count a honeypot hit so the bot rate is a NUMBER, not a guess (AGL-1664).\n *\n * The attestation decision (App Check / CAPTCHA / neither) asked to be made\n * on a measured per-host bot rate, and the honeypot was the instrument named\n * for it — but a hit used to return fake success and record nothing, so the\n * number it was supposed to produce did not exist. This writes the same\n * counters-document shape `formSubmissionsRefused` uses (per-month key,\n * client-unwritable, AGL-1367), keyed per host because \"which site is the\n * bot filling in\" is the decision input.\n *\n * The host-existence read is the orphan guard: this path runs BEFORE the\n * route's shape validation, and an attacker spraying invented host ids must\n * not mint counter documents under hosts that do not exist. Cost per hit is\n * one read + one write — the same order the legitimate path already pays the\n * rate limiter — where the old path cost nothing; that is the price of the\n * measurement, paid only on requests that self-identified as bots.\n *\n * No notification, ever: honeypot hits are routine background noise, and a\n * flood of them delivered as alerts would be the flood again (the\n * `formSubmissionsPaused` lesson). Best-effort for the same reason the\n * refusal bookkeeping is — a bookkeeping failure must not turn a dropped bot\n * request into a 500 retry invitation.\n */\nasync function recordHoneypotHit(hostId: unknown): Promise<void> {\n try {\n if (typeof hostId !== 'string' || !hostId || hostId.length > 128) return\n const hostRef = firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n if (!(await hostRef.get()).exists) return\n await hostRef\n .collection('counters')\n .doc('formSubmissionsSpam')\n .set(\n {\n [Aglyn.utcMonthKey()]: FieldValue.increment(1),\n lastSpamAtMs: Date.now(),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('honeypot bookkeeping failed', error)\n }\n}\n\n/**\n * The opt-in a form CARRIES, or `false`.\n *\n * `upsertHostContact` has accepted `marketingConsent` since AGL-301 and would\n * stamp `marketingConsentAtMs` from it, and this route passed it zero times —\n * so a subscribe checkbox a merchant put on their form was collected, stored\n * as a submission field, and then dropped on the way to the contact. That is\n * the missing INPUT for the send-time consent join\n * (`docs/specs/email-overhaul.md` §1d): the join can only ever be as good as\n * what the capture surfaces record.\n *\n * ⛔ THE FACT OF SUBMISSION IS NOT AN OPT-IN. Only a field whose VALUE is a\n * tick counts, and only under a name that means marketing consent. A form is\n * submitted to ask a question, book a table or claim a refund, and treating\n * any of those as a subscription is exactly the inference the consent arc\n * refused to make.\n *\n * What a tick is comes from `isConsentCheckboxTicked`. A box with no text of\n * its own posts an affirmative value on any form. A Checkboxes field posts\n * the text of its option, which is a tick only where a BOUND form's stored\n * `declarations` show that field is a checkbox with exactly one option: an\n * unbound form has no declaration, and the request never supplies one.\n *\n * The name list is `MARKETING_CONSENT_FIELD_NAMES`, closed rather than a\n * substring match on \"consent\" (a clinic intake form's `consentToTreatment`\n * is a different instrument), and shared with the publish check: a design\n * `checkFormContract` accepts as recording consent is one this route reads\n * an opt-in from.\n */\nfunction readDeclaredMarketingConsent(\n payload: Record<string, any>,\n fields: Record<string, unknown>,\n declarations: readonly Aglyn.FormFieldDecl[],\n): boolean {\n // A first-class body field, for a caller that models the checkbox\n // explicitly rather than as one more form field.\n if (payload['marketingConsent'] === true) return true\n for (const [key, value] of Object.entries(fields)) {\n if (!Aglyn.isMarketingConsentFieldName(key)) continue\n const declaration = declarations.find((entry) => entry?.fieldName === key)\n if (Aglyn.isConsentCheckboxTicked(value, declaration)) return true\n }\n return false\n}\n\n/**\n * `POST /api/forms/submit` — every form's door, and a Marketing popup's\n * (AGL-76, AGL-3080).\n *\n * This plugin's route, served by the tenant's plugin API dispatcher at the\n * address it has always had, and registered as a route that KEEPS ITS OWN\n * VISITOR GATES (`ownVisitorGates`): the dispatcher hands it the request\n * untouched, because every gate below is a published answer — which plugin's\n * switch decides a submission (the door it came through, not this route's\n * owner), the \"not accepting\" notice a site with Forms off gives, the\n * per-(site, address) limit and the monthly ceiling, and where lockdown sits\n * among them. The dispatcher's own versions of those would answer first and\n * differently.\n *\n * Lead-capture submissions endpoint (AGL-76): validates the target host,\n * drops honeypot hits silently, applies the plan's monthly submission quota\n * via a per-month counter — a hard wall on free, a meter on plans that carry\n * the infra pass-through (AGL-1280) — refuses above the per-site abuse\n * ceiling (AGL-1655), and stores the submission for the console inbox. Writes\n * go through the admin SDK, so client rules never allow arbitrary submission\n * writes.\n *\n * The two 429s are different answers and must stay distinguishable: the plan\n * wall means the customer's allowance is spent, the ceiling means the site is\n * being flooded. Only the ceiling refusal carries `code`.\n */\nexport async function POST(request: Request): Promise<Response> {\n // The dispatcher serves every method at its catch-all; this door takes a\n // POST alone, as the route it replaced did.\n if (request.method !== 'POST') {\n return new Response(null, { status: 405, headers: { Allow: 'POST' } })\n }\n const payload = (await request.json().catch(() => ({}))) as Record<string, any>\n // `datasetId`, `dataset` and `fieldMap` are deliberately not read: the\n // dataset a record lands in comes from the page's signed binding (below).\n const { hostId, formId, formName, path, fields, website } = payload\n\n // Honeypot filled → pretend success so bots learn nothing, counted so the\n // owner of the attestation decision learns something (AGL-1664).\n if (typeof website === 'string' && website.trim()) {\n await recordHoneypotHit(hostId)\n return json({ received: true })\n }\n if (\n typeof hostId !== 'string' ||\n !hostId ||\n typeof fields !== 'object' ||\n fields === null ||\n Array.isArray(fields) ||\n Object.keys(fields).length === 0 ||\n Object.keys(fields).length > MAX_FIELDS ||\n JSON.stringify(fields).length > MAX_PAYLOAD_CHARS\n ) {\n return json({ error: 'Invalid submission' }, 400)\n }\n // Keeps counting under the no-address bucket rather than being skipped: a\n // public form endpoint that stops counting is an open write, and the key\n // already carries the site, so an unreadable address shares one budget per\n // SITE rather than one across the deployment.\n const ip = readClientIp(request.headers) ?? NO_CLIENT_ADDRESS_BUCKET\n const rate = await consumeRateLimit(`form:${hostId}:${ip}`, {\n limit: RATE_MAX,\n windowMs: RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n // Note this is NOT told to the caller, degraded or not. The body and the\n // status are identical either way: \"the global limiter is currently a\n // per-instance one\" is exactly the sentence an abuser would spend the\n // window on, and this endpoint is public and unauthenticated.\n return Response.json(\n { error: 'Too many submissions' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(\n Math.max(1, Math.ceil((rate.resetMs - Date.now()) / 1000)),\n ),\n },\n },\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return json({ error: 'Unknown site' }, 404)\n }\n\n // Lockdown (AGL-1511). Placed after the host exists and BEFORE the quota\n // read and every write: a paused submission must not consume the\n // customer's monthly allowance, and it must not be half-recorded.\n // Refused with the visitor pause copy — the site around this form is\n // still serving, so an outage page here would be both a lie and a lost\n // lead. The `/api` surface is outside the middleware matcher, so this is\n // the only lockdown enforcement a form POST ever meets, full or\n // read-only.\n const paused = await visitorWriteRefusal({\n hostId,\n request,\n surface: 'form',\n })\n if (paused) return paused\n\n // Monthly quota by the owning org's plan (dark-launch: orgs\n // without a plan are uncapped, matching every other gate).\n // Plan/quota gates ride the owning org's doc (AGL-238).\n const owningOrg = await getOrgForHost(hostId)\n const orgBilling = owningOrg?.org\n\n /*\n * A site that switched Forms off accepts no form submission (AGL-3029).\n *\n * Asked of the site's own plugin set — the org's resolved set minus this\n * host's deny-list, the same answer the tenant draws the page from — and\n * about the plugin whose door the submission came through: a Marketing\n * popup's capture belongs to Marketing, and keeps working on a site that\n * still shows the popup. Core imports no plugin, so both are ids.\n *\n * Placed after the site is known to exist and before the quota read and\n * every write: a refused submission is not counted, not billed and not\n * stored. 404, the answer the route already gives a retired form, because\n * the honest reply to the visitor is the same — this site takes no form\n * here — and the sentence names no setting a stranger has no use for.\n */\n if (\n !Aglyn.isHostPluginEnabled(\n orgBilling as never,\n hostSnapshot.data() as never,\n formSubmissionDoorPlugin(payload),\n )\n ) {\n return json({ error: FORMS_OFF_FOR_SITE_REFUSAL }, 404)\n }\n // Shared with the console surface that reads these counters back\n // (AGL-1666) — a differently-derived key there would read 0 refusals on\n // exactly the sites being refused.\n const monthKey = Aglyn.utcMonthKey()\n const counterRef = hostRef.collection('counters').doc('formSubmissions')\n {\n // Plan-less orgs resolve as free (AGL-247) — the cap always runs.\n const counterSnapshot = await counterRef.get()\n const used = Number(counterSnapshot.get(monthKey) ?? 0)\n // AGL-1280: a plan with the metered infra pass-through is never cut\n // off — submissions past the included count bill at cost × 1.3 like\n // storage, API requests and contacts already do. Free keeps the hard\n // wall, because there is no subscription to meter onto. A dropped\n // submission is a lost lead, so this is the meter where being wrong\n // in the \"cut them off\" direction costs the customer the most.\n if (!Aglyn.checkFormSubmissionQuota(orgBilling as any, used).allowed) {\n return json({ error: 'Submission limit reached' }, 429)\n }\n // Abuse ceiling (AGL-1655). The plan gate above cannot refuse a\n // metered plan by design, so on every paying customer it was the only\n // thing between a bot flood and an unbounded invoice — and it always\n // said yes. The per-IP limiter is 10/60s, which one address turns into\n // 14,400/day and any number of addresses multiplies.\n //\n // This is the ceiling that can say no. It reuses the count already\n // read above (no extra Firestore read on the happy path) and sits\n // BEFORE every write, so a refused submission never touches\n // `counters/formSubmissions` — the document /api/billing/report-usage\n // invoices from. Refusing and still billing would have been the same\n // bug with a smaller number.\n const ceiling = Aglyn.checkFormSubmissionAbuseCeiling(\n orgBilling as any,\n used,\n )\n if (ceiling.exceeded) {\n await recordAbuseCeilingTrip(hostRef, hostId, monthKey, ceiling.ceiling)\n const contact = publishedContactEmail(hostSnapshot.data())\n return Response.json(\n {\n error: 'Submissions are paused for this site',\n // Machine-readable so a client can tell containment apart from\n // the plan wall it shares a status with.\n code: FORM_ABUSE_CEILING_CODE,\n // The site's own published support address, when it has one\n // (AGL-1666). NOT the ceiling, the count or the plan: this body\n // is read by a stranger to the site, and the reason a site\n // stopped accepting is the owner's to see, not a caller's.\n ...(contact ? { contact } : {}),\n },\n {\n status: 429,\n headers: { 'Retry-After': String(secondsUntilNextMonth()) },\n },\n )\n }\n }\n\n /*\n * NO CREDENTIALS, whatever the page asked for (AGL-3356).\n *\n * A published page that asks for a password, a card number or a\n * one-time code in a field of its own is held by the page review before\n * a visitor sees it. This endpoint is the one path that does not pass a\n * publish: it takes whatever names a request carries, so a page the\n * review never read — a marketplace plugin's frame, a hand-built request\n * — could still post one here. Such a field is dropped, never stored,\n * never forwarded to a workflow or a contact.\n */\n const sanitizedFields: Record<string, string> = {}\n for (const [key, value] of Object.entries(fields)) {\n if (isCredentialFieldName(String(key))) continue\n sanitizedFields[String(key).slice(0, 64)] = String(value).slice(0, 2000)\n }\n if (!Object.keys(sanitizedFields).length) {\n return json({ error: 'Invalid submission' }, 400)\n }\n\n /*\n * The bound form entity (`docs/specs/reusable-forms.md` §2c).\n *\n * READ AND VERIFIED, never trusted. `formId` arrives in a public,\n * unauthenticated body, so stamping it unchecked would let anyone file\n * rows into any form's list on any site — the per-form list is the\n * surface this id exists to make correct, and an unverified id would make\n * it wrong in a way that looks right.\n *\n * One extra document read, and only when a form is bound. A form with no\n * `formId` pays nothing, which is the same shape the dataset binding and\n * `rateDegraded` already take.\n */\n const boundFormId = String(formId ?? '').trim().slice(0, 128)\n const formSnapshot = boundFormId\n ? await hostRef.collection('forms').doc(boundFormId).get()\n : null\n const form = formSnapshot?.exists ? formSnapshot : null\n /*\n * A RETIRED form collects nothing (AGL-2671).\n *\n * Retirement is what a merchant does with a form they have finished with\n * but must keep — the submissions, the leads and the contact timeline\n * those built are filed under it. Leaving it able to collect would make\n * the retirement a UI illusion: the row would be gone from the list and\n * out of the funnel's grading while a form still placed on a published\n * page kept filing leads nobody was looking at, which is the quiet half\n * of a lost lead rather than a retirement.\n *\n * Refused HERE, on the verified entity, rather than by unpublishing the\n * page: a form is placed by whoever placed it, on any number of pages, and\n * the document is the one place that knows it is retired.\n *\n * 404 rather than a 4xx that explains: the visitor's honest answer is that\n * this form is not here any more, and the shape matches `Unknown site`\n * above. Only ever for a BOUND form — a submission carrying no `formId`\n * has no entity to be retired and is untouched.\n */\n if (Aglyn.isFormArchived(form?.data() as { archivedAt?: number } | undefined)) {\n return json({ error: 'This form is no longer accepting submissions' }, 404)\n }\n /*\n * The caption follows the entity once one is bound.\n *\n * `formName` stays written either way: it is what the pre-entity `?form=`\n * filter reads, and no phase of this work removes it. Resolving it from\n * the form's own `displayName` keeps the Inbox's label from going stale\n * the moment somebody renames a form.\n *\n * ⚠️ This does NOT make the legacy filter rename-safe, and it is not\n * meant to — renaming still splits what `?form=` returns, exactly as it\n * always did. `formId` is the filter that survives a rename; that is the\n * whole reason it exists.\n */\n const resolvedFormName = form\n ? String(form.get('displayName') ?? formName ?? 'Form').slice(0, 100)\n : String(formName ?? 'Form').slice(0, 100)\n /*\n * Consent, from the field the FORM declares.\n *\n * ⛔ Still never inferred from the fact of submission. What the entity\n * adds is a declared place to look: `consentFieldName` names the one\n * field that IS the opt-in, so a merchant's checkbox called anything at\n * all is readable, and a clinic intake form's `consentToTreatment` stays\n * the different instrument it is.\n *\n * A bound form that declares NO consent field falls through to the closed\n * name list below rather than recording nothing. Adoption must not be the\n * moment a form that was capturing opt-ins stops — consent is carried\n * forward, never dropped as a side effect of a migration.\n *\n * Both readers are handed the form's STORED declaration: a Checkboxes\n * field posts the text of the option that was ticked, and only the\n * declaration says a consent field has exactly one option, whose text is\n * then the tick. An unbound form has none, so its boxes count only by an\n * affirmative value.\n */\n const formFieldDecls = Array.isArray(form?.get('fields'))\n ? (form?.get('fields') as Aglyn.FormFieldDecl[])\n : []\n const declaredMarketingConsent = form?.get('consentFieldName')\n ? Aglyn.readFormDeclaredConsent(\n {\n consentFieldName: String(form.get('consentFieldName')),\n fields: formFieldDecls,\n },\n sanitizedFields,\n )\n : readDeclaredMarketingConsent(payload, fields, formFieldDecls)\n /*\n * THE DISCLOSURE THE FORM SHOWED beside that opt-in (AGL-3320).\n *\n * The Form block renders the site's consent-group sentence under its\n * consent field and posts back the key of what it rendered\n * (`/api/consent/disclosure`). Passed to the record system as it arrived:\n * the writer pools the opt-in across the group only when this is the\n * group's CURRENT key, so a page rendered before a rename or before a\n * site joined records the one site the visitor was on. Trusting it costs\n * nothing — it can only ever narrow what the checkbox above already\n * claims, never widen it past the declaration.\n */\n const disclosedConsentGroup =\n typeof payload['__consentGroup'] === 'string'\n ? payload['__consentGroup'].trim().slice(0, 64)\n : ''\n /*\n * THE CAMPAIGNS THE FORM IS FILED UNDER.\n *\n * Off the form document this route has already read, so it costs nothing,\n * and off the VERIFIED form for the same reason the id is: a membership\n * taken from the request body would let anyone file people into any\n * campaign on any site.\n *\n * ⚠️ NOT the campaign touch resolved below, and the two never write each\n * other's field. The touch is where this visitor came FROM and is\n * browser-supplied; this is which campaigns the merchant put the form in,\n * and it is true of every person who fills that form in — including the\n * one who arrived by typing the address. Recording only the touch is what\n * left three forms assigned to a campaign producing contacts that belonged\n * to none.\n */\n const formCampaignIds = form\n ? Aglyn.readContainerIds(form.data() as Record<string, unknown>, 'campaign')\n : []\n /*\n * THE CUSTOM CONTACT FIELDS THIS FORM SAVES TO (AGL-2601).\n *\n * Off the VERIFIED form's declaration, like the campaigns above and for\n * the same reason: a mapping taken from the request body would let anyone\n * write any value under any field of any contact on any site. The\n * definitions are read from the owning org — one bounded read of a small\n * collection — and only when the declaration maps at least one field, so\n * a form that saves nowhere pays nothing.\n *\n * Coerced by the definition's TYPE before it goes anywhere near the\n * contact: `collectMappedContactCustom` is the same rule the API applies\n * to a `custom` body, so a number typed into a form and a number sent\n * over the API are stored as the same thing. A blank field contributes no\n * key at all, so a submission never clears a value the merchant set.\n */\n const mappedContactCustom =\n form && owningOrg?.orgId && formFieldDecls.some((decl) => decl?.contactFieldKey)\n ? Aglyn.collectMappedContactCustom({\n fields: sanitizedFields,\n decls: formFieldDecls,\n definitions: (\n await firestore\n .collection('orgs')\n .doc(owningOrg.orgId)\n .collection(Aglyn.CRM_COLLECTIONS.contactFields)\n .limit(Aglyn.CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n ).docs.map((snapshot) => snapshot.data() as Aglyn.ContactFieldDefinition),\n })\n : {}\n // One instant for the whole submission. The attribution window is\n // measured against it in three places below, and three calls to\n // `Date.now()` would let a slow write decide whether a touch was inside\n // the window for the contact and outside it for the lead.\n const submittedAtMs = Date.now()\n /*\n * The organization this site belongs to, off the two documents this\n * route has already read — the host, whose `orgId` only the org APIs\n * write, and the `hostIndex` mirror the plan gate resolved through.\n */\n const hostOrgId = hostSnapshot.get('orgId')\n const submissionOrgId =\n typeof hostOrgId === 'string' && hostOrgId ? hostOrgId : owningOrg?.orgId\n const submissionRef = await hostRef.collection('formSubmissions').add({\n /*\n * WHERE THE ROW LIVES, as fields (AGL-3303). The organization's Inbox\n * reads every site's submissions with ONE collection-group query,\n * `where('orgId', '==', …)` newest first, and the rules admit that\n * read on the org named here; `hostId` is what that list addresses a\n * row's site by. Both are frozen against a client update, and a site\n * with no org carries no `orgId`, so it is in no organization's list.\n */\n ...(submissionOrgId ? { orgId: submissionOrgId } : {}),\n hostId,\n // Stamped only for a form that exists on THIS site. An unverified id\n // never reaches the row, so the per-form list cannot be written into\n // from outside.\n ...(form ? { formId: form.id } : {}),\n /*\n * The form's campaign membership, copied onto the row.\n *\n * A STAMP of what the form said at the moment this arrived, not a live\n * edge: nothing edits a submission's campaigns afterwards, and refiling\n * the form later does not rewrite the submissions it already produced.\n * That is why this collection is not in the Marketing plugin's\n * `CAMPAIGN_MEMBER_HOST_COLLECTIONS` — the deletion pass walks the\n * collections a PICKER writes, and a campaign's removal must not rewrite\n * an unbounded, billed history collection to tidy up a field that is\n * only ever read alongside the campaigns a picker still offers.\n *\n * Written only when the form names one, so the field is absent on the\n * rows that would carry an empty array forever.\n */\n ...(formCampaignIds.length ? { campaignIds: formCampaignIds } : {}),\n formName: resolvedFormName,\n path: String(path ?? '').slice(0, 500),\n fields: sanitizedFields,\n /*\n * WHAT THE INBOX'S LIST ASKS (AGL-3321). Its From filter and its\n * search are `array-contains` on these word-prefix arrays, and Read is\n * an equality on `read` — so every row carries all three from the\n * moment it exists. The fields never change after this write, so no\n * other writer restamps them; `backfill-form-submission-filters.mjs`\n * stamps the rows written before.\n */\n ...messageSearchFields(sanitizedFields),\n read: false,\n createdAt: FieldValue.serverTimestamp(),\n // Accepted while the durable limiter was degraded (AGL-1667).\n //\n // The limiter fails soft by design (AGL-794, reaffirmed in AGL-1679) and\n // that is not reversed here: a Firestore blip must not stop a customer's\n // site collecting leads, and the fallback can only ever allow MORE than\n // 10/min, never fewer, so nothing legitimate is refused by it. What was\n // wrong is that the route read `allowed` and `resetMs` and never\n // `degraded`, so the window left no trace on THIS endpoint at all.\n //\n // The AGL-1679 marker records that the store degraded; it cannot say\n // which submissions came in under the wider cap, and that is the\n // correlation the incident actually needs — these rows are billed\n // (`/api/billing/report-usage` prices `counters/formSubmissions`) and a\n // customer disputing a spike is owed a per-row answer, not \"the limiter\n // was unwell around then\".\n //\n // Stamped rather than counted because it rides a write that is already\n // happening: a separate counter would mean an EXTRA Firestore write per\n // submission, issued precisely while Firestore is the thing that is\n // failing. Written only when true — an absent field is the healthy case\n // and costs nothing on the millions of rows that are fine.\n ...(rate.degraded ? { rateDegraded: true } : {}),\n })\n // Contacts ingestion (AGL-197): forms don't guarantee an email field —\n // best-effort extraction; never blocks the submission.\n const contactEmail = extractEmailFromFields(sanitizedFields)\n /*\n * Whether this submission was filed to the site's Leads.\n *\n * The form counts PEOPLE (AGL-3330). The lead silo keys one person to one\n * document, so a returning visitor's second submission updates the lead\n * it created the first time; the form's lead count moves only when the\n * capture puts this form's source on a lead for the first time\n * (`leadCounted`), which is exactly what a recount of the leads'\n * `sources` finds — so the increment and the recount agree, and a lead\n * erased later can be taken back off. A routed submission by somebody\n * the workspace already holds as a contact lands on the contact and files\n * no lead (AGL-3232), and is not counted.\n */\n let leadStored = false\n /** Whether that lead is one more person on this form's lead count. */\n let leadCounted = false\n /*\n * WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SUBMISSION.\n *\n * One visitor action lands in three collections here — the submission,\n * the contact and (when the form declares itself a lead surface) the\n * lead — and all three are the same person arriving from the same place.\n * Resolving here and handing the result down is what keeps the lookup at\n * one per submission instead of three, and it is also what guarantees the\n * three records cannot disagree about what to credit.\n *\n * The answer is the crediting plugin's (`plugin-conversion-credit.ts`),\n * asked with what the visitor's browser sent under `campaignTouch` —\n * untrusted, so the creditor re-parses and window-checks it before it\n * can name anything. A submission carrying none — direct traffic, or a\n * visitor whose consent posture never let a touch be remembered — and a\n * workspace whose plugins credit nothing both resolve to null and write\n * no credit at all.\n */\n const arrival = await resolveConversionTouch({\n hostId,\n wire: payload['campaignTouch'],\n email: contactEmail,\n atMs: submittedAtMs,\n })\n // The submission itself is an outcome whether or not it named anybody:\n // an anonymous enquiry from a campaign link is a conversion that campaign\n // caused, and refusing to count it because no email field was filled in\n // would under-report exactly the forms that ask for the least.\n void creditConversion({\n hostId,\n kind: 'form',\n refId: submissionRef.id,\n touch: arrival,\n convertedAtMs: submittedAtMs,\n })\n if (contactEmail) {\n /*\n * WHICH RECORD THE PERSON LANDS ON is the record system's decision\n * (AGL-3232), and the form says only what kind of surface it is: a\n * lead surface when its author switched lead routing on\n * (`docs/specs/reusable-forms.md` §4a), a touch otherwise. The writer\n * files a lead — through `addHostLead`, keyed on the person and\n * counted against the ceiling — unless the workspace already holds the\n * address as a contact, and answers which record it wrote.\n *\n * AWAITED, unlike the capture of every other door, and only because\n * the answer is counted: the form's `stats.leads` rides the same\n * `update` the submission counters ride below, and a `void` call with\n * the counter chained onto its `then` would put the one figure on this\n * page that is about routing on a promise the runtime is free to\n * abandon once the response is sent. The posture is unchanged —\n * `recordCapturedContact` never throws — so awaiting it cannot fail the\n * submission any more than voiding it could.\n */\n const routed = form?.get('routing')?.lead === true\n const captured = await recordCapturedContact({\n /*\n * Not gated on the org resolving. The record system keys a person on\n * the SITE they were met on, so an org lookup that came back empty\n * costs the capture nothing — and refusing to record the person over\n * it would turn a lookup failure into a lost contact, silently, which\n * is the failure this whole seam is arranged to avoid.\n */\n orgId: owningOrg?.orgId ?? '',\n hostId,\n identity: {\n email: contactEmail,\n name: sanitizedFields['name'] ?? sanitizedFields['fullName'],\n },\n interaction: {\n source: 'form',\n refId: submissionRef.id,\n summary: `Submitted \"${resolvedFormName.slice(0, 60)}\"`,\n },\n surface: routed ? 'lead' : 'touch',\n ...(declaredMarketingConsent ? { marketingConsent: true } : {}),\n ...(declaredMarketingConsent && disclosedConsentGroup\n ? { disclosedConsentGroup }\n : {}),\n // Filed under the form's campaigns, inside this site's own facet on a\n // row the whole org shares. Membership is not consent, and this passes\n // none: `marketingConsent` above is the only input that records one.\n ...(formCampaignIds.length\n ? { containers: { campaign: formCampaignIds } }\n : {}),\n // The mapped custom field values. Absent when nothing mapped, so the\n // owner adds no `custom` key for nothing.\n ...(Object.keys(mappedContactCustom).length\n ? { profile: { custom: mappedContactCustom } }\n : {}),\n detail: {\n /*\n * THE ENTRY POINT, for the owner to put on the person's timeline.\n *\n * Which form and which page a person came in through is a fact\n * about this capture rather than about the person, which is why it\n * rides `detail` and not `profile`. `sources` says only that SOME\n * form produced this contact — every form sets the same flag — and\n * answering \"which one\" by reading the submission back would be a\n * document read per row of a timeline the console renders straight\n * out of the contact.\n *\n * The id only for a VERIFIED form, matching the submission: an\n * unverified id never reaches a stored row on this path.\n */\n ...(form ? { formId: form.id } : {}),\n ...(typeof path === 'string' && path ? { path } : {}),\n // Where the visitor ARRIVED from, already resolved above — never\n // the same fact as `campaignIds`, which is where the merchant filed\n // the form.\n ...(arrival ? { [CONVERSION_TOUCH_DETAIL]: arrival } : {}),\n },\n })\n leadStored = captured?.ok === true && captured.record === 'lead'\n /*\n * A form counts the PEOPLE it filed (AGL-3330): the capture says\n * whether this one was new to the form, and a writer that does not\n * say is read as whether the lead itself was new.\n */\n leadCounted =\n leadStored &&\n captured?.ok === true &&\n captured.record === 'lead' &&\n (captured.sourceAdded ?? captured.created) === true\n }\n /*\n * WHERE ELSE THE SUBMISSION IS FILED (AGL-3080): a record in whatever\n * plugin keeps the records a form may write into — a dataset row, today.\n *\n * Asked AFTER the submission is stored, through the platform's contract\n * (`plugin-manager/submission-record-target.ts`), and the target decides from\n * what the page's compose stamped onto this form, never from the body\n * alone: the route is public. It answers the submission's `routing`,\n * which the Inbox reads to say where it went or why it did not. The Inbox\n * copy above is canonical either way, and nothing here can fail the\n * submission.\n */\n const filed = await writeFormRecordTarget({\n hostId,\n orgId: owningOrg?.orgId ?? null,\n orgBilling,\n body: payload,\n fields: sanitizedFields,\n })\n if (filed.routing) {\n try {\n await submissionRef.update({ routing: filed.routing })\n } catch (error) {\n console.error('form submission routing note failed', error)\n }\n }\n await counterRef.set({ [monthKey]: FieldValue.increment(1) }, { merge: true })\n /*\n * Per-form counters (`docs/specs/reusable-forms.md` §5a), through the\n * one writer that retries and REPORTS a failure rather than swallowing\n * it (AGL-3330) — see `incrementFormStats`.\n *\n * `stats.leads` is here rather than beside the lead writer for the same\n * reason the submission counters are: a second write to say a lead was\n * filed would be a second write per submission on the collection the\n * customer is billed against.\n */\n if (form) {\n await incrementFormStats({\n formRef: form.ref,\n hostId,\n monthKey,\n submittedAtMs,\n leadCounted,\n })\n }\n // Event trigger (AGL-128/148): field values join the automation\n // scope; action-produced site alerts ride back to the visitor.\n // In-app notification to the site's managers (AGL-259).\n // The body names the form and the site on its own (AGL-3432): a manager\n // of several sites reads the email body, not always the subject, and\n // \"Page: /contact\" said neither. `{site}` is filled by\n // `notifyHostManagers` from the host doc it already reads.\n void notifyHostManagers(hostId, {\n type: 'content.formSubmission',\n title: `New form submission — ${resolvedFormName}`,\n body:\n `Someone submitted “${resolvedFormName}” on {site}` +\n (typeof path === 'string' && path ? ` (page ${path.slice(0, 500)}).` : '.'),\n ...submissionsLink(hostId),\n })\n const submittedEmail =\n typeof sanitizedFields['email'] === 'string' ? sanitizedFields['email'] : ''\n const { alerts } = await emitHostEvent(\n hostId,\n 'formSubmission',\n {\n formName: resolvedFormName,\n path: String(path ?? '').slice(0, 500),\n ...sanitizedFields,\n },\n // The visitor who submitted, by the address they typed if any (AGL-3376).\n { actor: { kind: 'visitor', ...(submittedEmail ? { email: submittedEmail } : {}) } },\n )\n return json({ received: true, alerts })\n } catch (error) {\n console.error(error)\n return json({ error: 'Submission failed' }, 500)\n }\n}\n"],"names":["Aglyn","extractEmailFromFields","consumeRateLimit","firebaseAdmin","getOrgForHost","notifyHostManagers","visitorWriteRefusal","emitHostEvent","recordCapturedContact","CONVERSION_TOUCH_DETAIL","creditConversion","resolveConversionTouch","messageSearchFields","writeFormRecordTarget","pluginRecordPageLink","FieldValue","isCredentialFieldName","FORMS_OFF_FOR_SITE_REFUSAL","formSubmissionDoorPlugin","FORM_ABUSE_CEILING_CODE","incrementFormStats","NO_CLIENT_ADDRESS_BUCKET","readClientIp","MAX_FIELDS","MAX_PAYLOAD_CHARS","RATE_WINDOW_MS","RATE_MAX","json","body","status","Response","secondsUntilNextMonth","now","Date","next","UTC","getUTCFullYear","getUTCMonth","Math","max","ceil","getTime","publishedContactEmail","host","HOST_TOKENS","resolve","undefined","recordAbuseCeilingTrip","hostRef","hostId","monthKey","ceiling","refusedSnapshot","refusedRef","collection","doc","get","alreadyRefused","Number","set","increment","lastRefusedAtMs","merge","reopens","nextUtcMonthStart","toLocaleDateString","month","day","timeZone","type","title","toLocaleString","submissionsLink","error","console","link","recordHoneypotHit","length","app","firestore","exists","utcMonthKey","lastSpamAtMs","readDeclaredMarketingConsent","payload","fields","declarations","key","value","Object","entries","isMarketingConsentFieldName","declaration","find","entry","fieldName","isConsentCheckboxTicked","POST","request","method","headers","Allow","catch","formId","formName","path","website","trim","received","Array","isArray","keys","JSON","stringify","ip","rate","limit","windowMs","allowed","String","resetMs","form","hostSnapshot","paused","surface","owningOrg","orgBilling","org","isHostPluginEnabled","data","counterRef","counterSnapshot","used","checkFormSubmissionQuota","checkFormSubmissionAbuseCeiling","exceeded","contact","code","sanitizedFields","slice","boundFormId","formSnapshot","isFormArchived","resolvedFormName","formFieldDecls","declaredMarketingConsent","readFormDeclaredConsent","consentFieldName","disclosedConsentGroup","formCampaignIds","readContainerIds","mappedContactCustom","orgId","some","decl","contactFieldKey","collectMappedContactCustom","decls","definitions","CRM_COLLECTIONS","contactFields","CONTACT_FIELDS_MAX_PER_ORG","docs","map","snapshot","submittedAtMs","hostOrgId","submissionOrgId","submissionRef","add","id","campaignIds","read","createdAt","serverTimestamp","degraded","rateDegraded","contactEmail","leadStored","leadCounted","arrival","wire","email","atMs","kind","refId","touch","convertedAtMs","captured","routed","lead","identity","name","interaction","source","summary","marketingConsent","containers","campaign","profile","custom","detail","ok","record","sourceAdded","created","filed","routing","update","formRef","ref","submittedEmail","alerts","actor"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/server/form-submit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport { extractEmailFromFields } from '@aglyn/aglyn/server'\nimport {\n consumeRateLimit,\n firebaseAdmin,\n getOrgForHost,\n notifyHostManagers,\n visitorWriteRefusal,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\n// By path: the server-only contract a door credits its outcome through.\nimport {\n CONVERSION_TOUCH_DETAIL,\n conversionDescriptionSentences,\n creditConversion,\n describeConversion,\n resolveConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { normalizeContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\nimport {\n findScreenIdByRoutePath,\n screenRoutePathFromUrl,\n} from '@aglyn/aglyn/app-utils/screen-route'\n// The leaf: the Inbox list's search keys, the same function its backfill\n// restates and its query's normalizers read (AGL-3321).\nimport { messageSearchFields } from '@aglyn/aglyn/app-utils/message-search'\n// By path: the server-only contract a submission is filed as a record through.\nimport { writeFormRecordTarget } from '@aglyn/aglyn/plugin-manager/submission-record-target'\n// By path: where a manager reads submissions, declared by the plugin whose\n// page shows them, for the notifications below (AGL-3080).\nimport { pluginRecordPageLink } from '@aglyn/aglyn/plugin-manager/plugin-record-pages'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { isCredentialFieldName } from '@aglyn/shared-util-email/hosted-page-screen'\nimport { FORMS_OFF_FOR_SITE_REFUSAL, formSubmissionDoorPlugin } from '../model/form-door'\nimport { FORM_ABUSE_CEILING_CODE } from '../model/form-unavailable'\nimport { incrementFormStats } from './increment-form-stats'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\nconst MAX_FIELDS = 20\nconst MAX_PAYLOAD_CHARS = 10000\n\n// Per-IP submission limit (AGL-794). Previously a per-instance Map, which on\n// serverless resets each cold start and is held per concurrent instance — so\n// a spammer got roughly RATE_MAX × instances and could widen it by going\n// wider. The monthly plan quota was never the right thing to be defended BY:\n// burning a site's whole allowance is the damage, not the protection — and\n// since AGL-1280 it is not even a wall on a paid plan, it meters. This\n// counter is global, keyed per (site, IP).\n//\n// It is the SHAPE of abuse control, not the whole of it (AGL-1655): 10/60s\n// per address is 14,400/day from one IP and multiplies by the number of\n// addresses, so per-IP alone leaves the site's total unbounded. The per-site\n// monthly ceiling below is what bounds the total.\nconst RATE_WINDOW_MS = 60_000\nconst RATE_MAX = 10\n\nconst json = (body: unknown, status = 200) => Response.json(body, { status })\n\n/**\n * The ceiling is a per-MONTH count, so the honest retry hint is the month\n * boundary rather than a made-up interval. UTC, matching `monthKey`.\n */\nconst secondsUntilNextMonth = (now = new Date()): number => {\n const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)\n return Math.max(1, Math.ceil((next - now.getTime()) / 1000))\n}\n\n/**\n * The one useful thing a refused visitor can be given (AGL-1666): the site's\n * OWN published support address, so a lost lead has somewhere else to go.\n *\n * Read through the host-token registry rather than reaching into the\n * document, because that registry is what defines this field as the address\n * a site publishes to visitors — its description is literally \"where\n * visitors should write for help\". Anything else on the host document is\n * account data and must not leave the console.\n *\n * Free: the host snapshot is already loaded for the existence check.\n */\nconst publishedContactEmail = (\n host: Record<string, any> | undefined,\n): string | undefined =>\n host ? Aglyn.HOST_TOKENS['supportEmail']?.resolve(host) : undefined\n\n/**\n * Make a tripped ceiling OBSERVABLE (AGL-1655) rather than a silent drop.\n *\n * Two audiences, one write. Staff get a durable per-month refusal count at\n * `hosts/{id}/counters/formSubmissionsRefused` — the same counters-document\n * shape `contactsDropped` already uses for free-tier contact drops, and\n * client-unwritable for the same reason (AGL-1367). The site's managers get\n * one in-app notification, on the FIRST refusal of the month only: a\n * notification per refused bot request would be the flood again, delivered.\n *\n * Best-effort throughout. A bookkeeping failure must not turn a contained\n * refusal into a 500, because a 500 is a retry invitation.\n */\nasync function recordAbuseCeilingTrip(\n hostRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n monthKey: string,\n ceiling: number,\n): Promise<void> {\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc('formSubmissionsRefused')\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n // Every fact here is the route's own (AGL-3432): the refusal sits before\n // every write, so a refused submission is neither stored nor counted\n // toward the bill, and the counter is month-keyed, so forms accept\n // again at the UTC month boundary — the same date the inbox notice\n // prints. `{site}` is the site's name, filled by `notifyHostManagers`.\n const reopens = Aglyn.nextUtcMonthStart().toLocaleDateString('en-US', {\n month: 'long',\n day: 'numeric',\n timeZone: 'UTC',\n })\n await notifyHostManagers(hostId, {\n type: 'system.formSubmissionsPaused',\n title: 'Form submissions paused on {site} — unusual volume',\n body:\n `Forms on {site} stopped accepting submissions: the site reached ` +\n `${ceiling.toLocaleString()} this month, the safety limit for ` +\n 'unusual volume. Submissions sent now are refused and not saved, ' +\n `and none of them are billed. Forms accept submissions again on ` +\n `${reopens}. Contact support if this is real traffic.`,\n ...submissionsLink(hostId),\n })\n }\n } catch (error) {\n console.error('form abuse ceiling bookkeeping failed', error)\n }\n}\n\n/**\n * A notification's link to where the site's managers read submissions — the\n * page the plugin that shows `formSubmission` records declares, opened on one\n * submission when `submissionId` is given and that page can open one — and no\n * link where no plugin in this build shows them. A key left out rather than\n * set to `undefined`, which Firestore rejects.\n */\nfunction submissionsLink(hostId: string, submissionId?: string): { link?: string } {\n const link = pluginRecordPageLink('formSubmission', hostId, submissionId)\n return link ? { link } : {}\n}\n\n/**\n * Count a honeypot hit so the bot rate is a NUMBER, not a guess (AGL-1664).\n *\n * The attestation decision (App Check / CAPTCHA / neither) asked to be made\n * on a measured per-host bot rate, and the honeypot was the instrument named\n * for it — but a hit used to return fake success and record nothing, so the\n * number it was supposed to produce did not exist. This writes the same\n * counters-document shape `formSubmissionsRefused` uses (per-month key,\n * client-unwritable, AGL-1367), keyed per host because \"which site is the\n * bot filling in\" is the decision input.\n *\n * The host-existence read is the orphan guard: this path runs BEFORE the\n * route's shape validation, and an attacker spraying invented host ids must\n * not mint counter documents under hosts that do not exist. Cost per hit is\n * one read + one write — the same order the legitimate path already pays the\n * rate limiter — where the old path cost nothing; that is the price of the\n * measurement, paid only on requests that self-identified as bots.\n *\n * No notification, ever: honeypot hits are routine background noise, and a\n * flood of them delivered as alerts would be the flood again (the\n * `formSubmissionsPaused` lesson). Best-effort for the same reason the\n * refusal bookkeeping is — a bookkeeping failure must not turn a dropped bot\n * request into a 500 retry invitation.\n */\nasync function recordHoneypotHit(hostId: unknown): Promise<void> {\n try {\n if (typeof hostId !== 'string' || !hostId || hostId.length > 128) return\n const hostRef = firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n if (!(await hostRef.get()).exists) return\n await hostRef\n .collection('counters')\n .doc('formSubmissionsSpam')\n .set(\n {\n [Aglyn.utcMonthKey()]: FieldValue.increment(1),\n lastSpamAtMs: Date.now(),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('honeypot bookkeeping failed', error)\n }\n}\n\n/**\n * The opt-in a form CARRIES, or `false`.\n *\n * `upsertHostContact` has accepted `marketingConsent` since AGL-301 and would\n * stamp `marketingConsentAtMs` from it, and this route passed it zero times —\n * so a subscribe checkbox a merchant put on their form was collected, stored\n * as a submission field, and then dropped on the way to the contact. That is\n * the missing INPUT for the send-time consent join\n * (`docs/specs/email-overhaul.md` §1d): the join can only ever be as good as\n * what the capture surfaces record.\n *\n * ⛔ THE FACT OF SUBMISSION IS NOT AN OPT-IN. Only a field whose VALUE is a\n * tick counts, and only under a name that means marketing consent. A form is\n * submitted to ask a question, book a table or claim a refund, and treating\n * any of those as a subscription is exactly the inference the consent arc\n * refused to make.\n *\n * What a tick is comes from `isConsentCheckboxTicked`. A box with no text of\n * its own posts an affirmative value on any form. A Checkboxes field posts\n * the text of its option, which is a tick only where a BOUND form's stored\n * `declarations` show that field is a checkbox with exactly one option: an\n * unbound form has no declaration, and the request never supplies one.\n *\n * The name list is `MARKETING_CONSENT_FIELD_NAMES`, closed rather than a\n * substring match on \"consent\" (a clinic intake form's `consentToTreatment`\n * is a different instrument), and shared with the publish check: a design\n * `checkFormContract` accepts as recording consent is one this route reads\n * an opt-in from.\n */\nfunction readDeclaredMarketingConsent(\n payload: Record<string, any>,\n fields: Record<string, unknown>,\n declarations: readonly Aglyn.FormFieldDecl[],\n): boolean {\n // A first-class body field, for a caller that models the checkbox\n // explicitly rather than as one more form field.\n if (payload['marketingConsent'] === true) return true\n for (const [key, value] of Object.entries(fields)) {\n if (!Aglyn.isMarketingConsentFieldName(key)) continue\n const declaration = declarations.find((entry) => entry?.fieldName === key)\n if (Aglyn.isConsentCheckboxTicked(value, declaration)) return true\n }\n return false\n}\n\n/**\n * `POST /api/forms/submit` — every form's door, and a Marketing popup's\n * (AGL-76, AGL-3080).\n *\n * This plugin's route, served by the tenant's plugin API dispatcher at the\n * address it has always had, and registered as a route that KEEPS ITS OWN\n * VISITOR GATES (`ownVisitorGates`): the dispatcher hands it the request\n * untouched, because every gate below is a published answer — which plugin's\n * switch decides a submission (the door it came through, not this route's\n * owner), the \"not accepting\" notice a site with Forms off gives, the\n * per-(site, address) limit and the monthly ceiling, and where lockdown sits\n * among them. The dispatcher's own versions of those would answer first and\n * differently.\n *\n * Lead-capture submissions endpoint (AGL-76): validates the target host,\n * drops honeypot hits silently, applies the plan's monthly submission quota\n * via a per-month counter — a hard wall on free, a meter on plans that carry\n * the infra pass-through (AGL-1280) — refuses above the per-site abuse\n * ceiling (AGL-1655), and stores the submission for the console inbox. Writes\n * go through the admin SDK, so client rules never allow arbitrary submission\n * writes.\n *\n * The two 429s are different answers and must stay distinguishable: the plan\n * wall means the customer's allowance is spent, the ceiling means the site is\n * being flooded. Only the ceiling refusal carries `code`.\n */\nexport async function POST(request: Request): Promise<Response> {\n // The dispatcher serves every method at its catch-all; this door takes a\n // POST alone, as the route it replaced did.\n if (request.method !== 'POST') {\n return new Response(null, { status: 405, headers: { Allow: 'POST' } })\n }\n const payload = (await request.json().catch(() => ({}))) as Record<string, any>\n // `datasetId`, `dataset` and `fieldMap` are deliberately not read: the\n // dataset a record lands in comes from the page's signed binding (below).\n const { hostId, formId, formName, path, fields, website } = payload\n\n // Honeypot filled → pretend success so bots learn nothing, counted so the\n // owner of the attestation decision learns something (AGL-1664).\n if (typeof website === 'string' && website.trim()) {\n await recordHoneypotHit(hostId)\n return json({ received: true })\n }\n if (\n typeof hostId !== 'string' ||\n !hostId ||\n typeof fields !== 'object' ||\n fields === null ||\n Array.isArray(fields) ||\n Object.keys(fields).length === 0 ||\n Object.keys(fields).length > MAX_FIELDS ||\n JSON.stringify(fields).length > MAX_PAYLOAD_CHARS\n ) {\n return json({ error: 'Invalid submission' }, 400)\n }\n // Keeps counting under the no-address bucket rather than being skipped: a\n // public form endpoint that stops counting is an open write, and the key\n // already carries the site, so an unreadable address shares one budget per\n // SITE rather than one across the deployment.\n const ip = readClientIp(request.headers) ?? NO_CLIENT_ADDRESS_BUCKET\n const rate = await consumeRateLimit(`form:${hostId}:${ip}`, {\n limit: RATE_MAX,\n windowMs: RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n // Note this is NOT told to the caller, degraded or not. The body and the\n // status are identical either way: \"the global limiter is currently a\n // per-instance one\" is exactly the sentence an abuser would spend the\n // window on, and this endpoint is public and unauthenticated.\n return Response.json(\n { error: 'Too many submissions' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(\n Math.max(1, Math.ceil((rate.resetMs - Date.now()) / 1000)),\n ),\n },\n },\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return json({ error: 'Unknown site' }, 404)\n }\n\n // Lockdown (AGL-1511). Placed after the host exists and BEFORE the quota\n // read and every write: a paused submission must not consume the\n // customer's monthly allowance, and it must not be half-recorded.\n // Refused with the visitor pause copy — the site around this form is\n // still serving, so an outage page here would be both a lie and a lost\n // lead. The `/api` surface is outside the middleware matcher, so this is\n // the only lockdown enforcement a form POST ever meets, full or\n // read-only.\n const paused = await visitorWriteRefusal({\n hostId,\n request,\n surface: 'form',\n })\n if (paused) return paused\n\n // Monthly quota by the owning org's plan (dark-launch: orgs\n // without a plan are uncapped, matching every other gate).\n // Plan/quota gates ride the owning org's doc (AGL-238).\n const owningOrg = await getOrgForHost(hostId)\n const orgBilling = owningOrg?.org\n\n /*\n * A site that switched Forms off accepts no form submission (AGL-3029).\n *\n * Asked of the site's own plugin set — the org's resolved set minus this\n * host's deny-list, the same answer the tenant draws the page from — and\n * about the plugin whose door the submission came through: a Marketing\n * popup's capture belongs to Marketing, and keeps working on a site that\n * still shows the popup. Core imports no plugin, so both are ids.\n *\n * Placed after the site is known to exist and before the quota read and\n * every write: a refused submission is not counted, not billed and not\n * stored. 404, the answer the route already gives a retired form, because\n * the honest reply to the visitor is the same — this site takes no form\n * here — and the sentence names no setting a stranger has no use for.\n */\n if (\n !Aglyn.isHostPluginEnabled(\n orgBilling as never,\n hostSnapshot.data() as never,\n formSubmissionDoorPlugin(payload),\n )\n ) {\n return json({ error: FORMS_OFF_FOR_SITE_REFUSAL }, 404)\n }\n // Shared with the console surface that reads these counters back\n // (AGL-1666) — a differently-derived key there would read 0 refusals on\n // exactly the sites being refused.\n const monthKey = Aglyn.utcMonthKey()\n const counterRef = hostRef.collection('counters').doc('formSubmissions')\n {\n // Plan-less orgs resolve as free (AGL-247) — the cap always runs.\n const counterSnapshot = await counterRef.get()\n const used = Number(counterSnapshot.get(monthKey) ?? 0)\n // AGL-1280: a plan with the metered infra pass-through is never cut\n // off — submissions past the included count bill at cost × 1.3 like\n // storage, API requests and contacts already do. Free keeps the hard\n // wall, because there is no subscription to meter onto. A dropped\n // submission is a lost lead, so this is the meter where being wrong\n // in the \"cut them off\" direction costs the customer the most.\n if (!Aglyn.checkFormSubmissionQuota(orgBilling as any, used).allowed) {\n return json({ error: 'Submission limit reached' }, 429)\n }\n // Abuse ceiling (AGL-1655). The plan gate above cannot refuse a\n // metered plan by design, so on every paying customer it was the only\n // thing between a bot flood and an unbounded invoice — and it always\n // said yes. The per-IP limiter is 10/60s, which one address turns into\n // 14,400/day and any number of addresses multiplies.\n //\n // This is the ceiling that can say no. It reuses the count already\n // read above (no extra Firestore read on the happy path) and sits\n // BEFORE every write, so a refused submission never touches\n // `counters/formSubmissions` — the document /api/billing/report-usage\n // invoices from. Refusing and still billing would have been the same\n // bug with a smaller number.\n const ceiling = Aglyn.checkFormSubmissionAbuseCeiling(\n orgBilling as any,\n used,\n )\n if (ceiling.exceeded) {\n await recordAbuseCeilingTrip(hostRef, hostId, monthKey, ceiling.ceiling)\n const contact = publishedContactEmail(hostSnapshot.data())\n return Response.json(\n {\n error: 'Submissions are paused for this site',\n // Machine-readable so a client can tell containment apart from\n // the plan wall it shares a status with.\n code: FORM_ABUSE_CEILING_CODE,\n // The site's own published support address, when it has one\n // (AGL-1666). NOT the ceiling, the count or the plan: this body\n // is read by a stranger to the site, and the reason a site\n // stopped accepting is the owner's to see, not a caller's.\n ...(contact ? { contact } : {}),\n },\n {\n status: 429,\n headers: { 'Retry-After': String(secondsUntilNextMonth()) },\n },\n )\n }\n }\n\n /*\n * NO CREDENTIALS, whatever the page asked for (AGL-3356).\n *\n * A published page that asks for a password, a card number or a\n * one-time code in a field of its own is held by the page review before\n * a visitor sees it. This endpoint is the one path that does not pass a\n * publish: it takes whatever names a request carries, so a page the\n * review never read — a marketplace plugin's frame, a hand-built request\n * — could still post one here. Such a field is dropped, never stored,\n * never forwarded to a workflow or a contact.\n */\n const sanitizedFields: Record<string, string> = {}\n for (const [key, value] of Object.entries(fields)) {\n if (isCredentialFieldName(String(key))) continue\n sanitizedFields[String(key).slice(0, 64)] = String(value).slice(0, 2000)\n }\n if (!Object.keys(sanitizedFields).length) {\n return json({ error: 'Invalid submission' }, 400)\n }\n\n /*\n * The bound form entity (`docs/specs/reusable-forms.md` §2c).\n *\n * READ AND VERIFIED, never trusted. `formId` arrives in a public,\n * unauthenticated body, so stamping it unchecked would let anyone file\n * rows into any form's list on any site — the per-form list is the\n * surface this id exists to make correct, and an unverified id would make\n * it wrong in a way that looks right.\n *\n * One extra document read, and only when a form is bound. A form with no\n * `formId` pays nothing, which is the same shape the dataset binding and\n * `rateDegraded` already take.\n */\n const boundFormId = String(formId ?? '').trim().slice(0, 128)\n const formSnapshot = boundFormId\n ? await hostRef.collection('forms').doc(boundFormId).get()\n : null\n const form = formSnapshot?.exists ? formSnapshot : null\n /*\n * A RETIRED form collects nothing (AGL-2671).\n *\n * Retirement is what a merchant does with a form they have finished with\n * but must keep — the submissions, the leads and the contact timeline\n * those built are filed under it. Leaving it able to collect would make\n * the retirement a UI illusion: the row would be gone from the list and\n * out of the funnel's grading while a form still placed on a published\n * page kept filing leads nobody was looking at, which is the quiet half\n * of a lost lead rather than a retirement.\n *\n * Refused HERE, on the verified entity, rather than by unpublishing the\n * page: a form is placed by whoever placed it, on any number of pages, and\n * the document is the one place that knows it is retired.\n *\n * 404 rather than a 4xx that explains: the visitor's honest answer is that\n * this form is not here any more, and the shape matches `Unknown site`\n * above. Only ever for a BOUND form — a submission carrying no `formId`\n * has no entity to be retired and is untouched.\n */\n if (Aglyn.isFormArchived(form?.data() as { archivedAt?: number } | undefined)) {\n return json({ error: 'This form is no longer accepting submissions' }, 404)\n }\n /*\n * The caption follows the entity once one is bound.\n *\n * `formName` stays written either way: it is what the pre-entity `?form=`\n * filter reads, and no phase of this work removes it. Resolving it from\n * the form's own `displayName` keeps the Inbox's label from going stale\n * the moment somebody renames a form.\n *\n * ⚠️ This does NOT make the legacy filter rename-safe, and it is not\n * meant to — renaming still splits what `?form=` returns, exactly as it\n * always did. `formId` is the filter that survives a rename; that is the\n * whole reason it exists.\n */\n const resolvedFormName = form\n ? String(form.get('displayName') ?? formName ?? 'Form').slice(0, 100)\n : String(formName ?? 'Form').slice(0, 100)\n /*\n * Consent, from the field the FORM declares.\n *\n * ⛔ Still never inferred from the fact of submission. What the entity\n * adds is a declared place to look: `consentFieldName` names the one\n * field that IS the opt-in, so a merchant's checkbox called anything at\n * all is readable, and a clinic intake form's `consentToTreatment` stays\n * the different instrument it is.\n *\n * A bound form that declares NO consent field falls through to the closed\n * name list below rather than recording nothing. Adoption must not be the\n * moment a form that was capturing opt-ins stops — consent is carried\n * forward, never dropped as a side effect of a migration.\n *\n * Both readers are handed the form's STORED declaration: a Checkboxes\n * field posts the text of the option that was ticked, and only the\n * declaration says a consent field has exactly one option, whose text is\n * then the tick. An unbound form has none, so its boxes count only by an\n * affirmative value.\n */\n const formFieldDecls = Array.isArray(form?.get('fields'))\n ? (form?.get('fields') as Aglyn.FormFieldDecl[])\n : []\n const declaredMarketingConsent = form?.get('consentFieldName')\n ? Aglyn.readFormDeclaredConsent(\n {\n consentFieldName: String(form.get('consentFieldName')),\n fields: formFieldDecls,\n },\n sanitizedFields,\n )\n : readDeclaredMarketingConsent(payload, fields, formFieldDecls)\n /*\n * THE DISCLOSURE THE FORM SHOWED beside that opt-in (AGL-3320).\n *\n * The Form block renders the site's consent-group sentence under its\n * consent field and posts back the key of what it rendered\n * (`/api/consent/disclosure`). Passed to the record system as it arrived:\n * the writer pools the opt-in across the group only when this is the\n * group's CURRENT key, so a page rendered before a rename or before a\n * site joined records the one site the visitor was on. Trusting it costs\n * nothing — it can only ever narrow what the checkbox above already\n * claims, never widen it past the declaration.\n */\n const disclosedConsentGroup =\n typeof payload['__consentGroup'] === 'string'\n ? payload['__consentGroup'].trim().slice(0, 64)\n : ''\n /*\n * THE CAMPAIGNS THE FORM IS FILED UNDER.\n *\n * Off the form document this route has already read, so it costs nothing,\n * and off the VERIFIED form for the same reason the id is: a membership\n * taken from the request body would let anyone file people into any\n * campaign on any site.\n *\n * ⚠️ NOT the campaign touch resolved below, and the two never write each\n * other's field. The touch is where this visitor came FROM and is\n * browser-supplied; this is which campaigns the merchant put the form in,\n * and it is true of every person who fills that form in — including the\n * one who arrived by typing the address. Recording only the touch is what\n * left three forms assigned to a campaign producing contacts that belonged\n * to none.\n */\n const formCampaignIds = form\n ? Aglyn.readContainerIds(form.data() as Record<string, unknown>, 'campaign')\n : []\n /*\n * THE CAMPAIGNS THE PAGE IS FILED UNDER (AGL-3461), stamped beside the\n * form's for the same reason and with the same caveat: where the merchant\n * filed the page, true of everyone who submits on it, and never a credit.\n *\n * The screen is found in the routing map on the host snapshot this route\n * already holds, from the path the browser sent, so a path naming no page\n * this site serves names no screen and costs nothing. Its campaigns are\n * read off the screen document — one read, only when the path resolves.\n * A failed read stamps nothing rather than failing the submission.\n */\n const pageRoutePath = screenRoutePathFromUrl(typeof path === 'string' ? path : '')\n const pageScreenId = pageRoutePath\n ? findScreenIdByRoutePath(\n hostSnapshot.get('screens') as Record<string, string> | undefined,\n pageRoutePath,\n )\n : undefined\n const pageCampaignIds = pageScreenId\n ? await hostRef\n .collection('screens')\n .doc(pageScreenId)\n .get()\n .then((screen) =>\n screen.exists\n ? Aglyn.readContainerIds(screen.data() as Record<string, unknown>, 'campaign')\n : [],\n )\n .catch((error: unknown) => {\n console.error('form submission page campaigns read failed', error)\n return [] as string[]\n })\n : []\n /*\n * THE CUSTOM CONTACT FIELDS THIS FORM SAVES TO (AGL-2601).\n *\n * Off the VERIFIED form's declaration, like the campaigns above and for\n * the same reason: a mapping taken from the request body would let anyone\n * write any value under any field of any contact on any site. The\n * definitions are read from the owning org — one bounded read of a small\n * collection — and only when the declaration maps at least one field, so\n * a form that saves nowhere pays nothing.\n *\n * Coerced by the definition's TYPE before it goes anywhere near the\n * contact: `collectMappedContactCustom` is the same rule the API applies\n * to a `custom` body, so a number typed into a form and a number sent\n * over the API are stored as the same thing. A blank field contributes no\n * key at all, so a submission never clears a value the merchant set.\n */\n const mappedContactCustom =\n form && owningOrg?.orgId && formFieldDecls.some((decl) => decl?.contactFieldKey)\n ? Aglyn.collectMappedContactCustom({\n fields: sanitizedFields,\n decls: formFieldDecls,\n definitions: (\n await firestore\n .collection('orgs')\n .doc(owningOrg.orgId)\n .collection(Aglyn.CRM_COLLECTIONS.contactFields)\n .limit(Aglyn.CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n ).docs.map((snapshot) => snapshot.data() as Aglyn.ContactFieldDefinition),\n })\n : {}\n // One instant for the whole submission. The attribution window is\n // measured against it in three places below, and three calls to\n // `Date.now()` would let a slow write decide whether a touch was inside\n // the window for the contact and outside it for the lead.\n const submittedAtMs = Date.now()\n /*\n * The organization this site belongs to, off the two documents this\n * route has already read — the host, whose `orgId` only the org APIs\n * write, and the `hostIndex` mirror the plan gate resolved through.\n */\n const hostOrgId = hostSnapshot.get('orgId')\n const submissionOrgId =\n typeof hostOrgId === 'string' && hostOrgId ? hostOrgId : owningOrg?.orgId\n const submissionRef = await hostRef.collection('formSubmissions').add({\n /*\n * WHERE THE ROW LIVES, as fields (AGL-3303). The organization's Inbox\n * reads every site's submissions with ONE collection-group query,\n * `where('orgId', '==', …)` newest first, and the rules admit that\n * read on the org named here; `hostId` is what that list addresses a\n * row's site by. Both are frozen against a client update, and a site\n * with no org carries no `orgId`, so it is in no organization's list.\n */\n ...(submissionOrgId ? { orgId: submissionOrgId } : {}),\n hostId,\n // Stamped only for a form that exists on THIS site. An unverified id\n // never reaches the row, so the per-form list cannot be written into\n // from outside.\n ...(form ? { formId: form.id } : {}),\n /*\n * The form's campaign membership, copied onto the row.\n *\n * A STAMP of what the form said at the moment this arrived, not a live\n * edge: nothing edits a submission's campaigns afterwards, and refiling\n * the form later does not rewrite the submissions it already produced.\n * That is why this collection is not in the Marketing plugin's\n * `CAMPAIGN_MEMBER_HOST_COLLECTIONS` — the deletion pass walks the\n * collections a PICKER writes, and a campaign's removal must not rewrite\n * an unbounded, billed history collection to tidy up a field that is\n * only ever read alongside the campaigns a picker still offers.\n *\n * Written only when the form names one, so the field is absent on the\n * rows that would carry an empty array forever.\n */\n ...(formCampaignIds.length ? { campaignIds: formCampaignIds } : {}),\n // And the page's, beside them rather than merged in: `campaignIds` is\n // the form's membership and is read as exactly that (AGL-3461).\n ...(pageCampaignIds.length ? { pageCampaignIds } : {}),\n formName: resolvedFormName,\n path: String(path ?? '').slice(0, 500),\n fields: sanitizedFields,\n /*\n * WHAT THE INBOX'S LIST ASKS (AGL-3321). Its From filter and its\n * search are `array-contains` on these word-prefix arrays, and Read is\n * an equality on `read` — so every row carries all three from the\n * moment it exists. The fields never change after this write, so no\n * other writer restamps them; `backfill-form-submission-filters.mjs`\n * stamps the rows written before.\n */\n ...messageSearchFields(sanitizedFields),\n read: false,\n createdAt: FieldValue.serverTimestamp(),\n // Accepted while the durable limiter was degraded (AGL-1667).\n //\n // The limiter fails soft by design (AGL-794, reaffirmed in AGL-1679) and\n // that is not reversed here: a Firestore blip must not stop a customer's\n // site collecting leads, and the fallback can only ever allow MORE than\n // 10/min, never fewer, so nothing legitimate is refused by it. What was\n // wrong is that the route read `allowed` and `resetMs` and never\n // `degraded`, so the window left no trace on THIS endpoint at all.\n //\n // The AGL-1679 marker records that the store degraded; it cannot say\n // which submissions came in under the wider cap, and that is the\n // correlation the incident actually needs — these rows are billed\n // (`/api/billing/report-usage` prices `counters/formSubmissions`) and a\n // customer disputing a spike is owed a per-row answer, not \"the limiter\n // was unwell around then\".\n //\n // Stamped rather than counted because it rides a write that is already\n // happening: a separate counter would mean an EXTRA Firestore write per\n // submission, issued precisely while Firestore is the thing that is\n // failing. Written only when true — an absent field is the healthy case\n // and costs nothing on the millions of rows that are fine.\n ...(rate.degraded ? { rateDegraded: true } : {}),\n })\n // Contacts ingestion (AGL-197): forms don't guarantee an email field —\n // best-effort extraction; never blocks the submission.\n const contactEmail = extractEmailFromFields(sanitizedFields)\n /*\n * Whether this submission was filed to the site's Leads.\n *\n * The form counts PEOPLE (AGL-3330). The lead silo keys one person to one\n * document, so a returning visitor's second submission updates the lead\n * it created the first time; the form's lead count moves only when the\n * capture puts this form's source on a lead for the first time\n * (`leadCounted`), which is exactly what a recount of the leads'\n * `sources` finds — so the increment and the recount agree, and a lead\n * erased later can be taken back off. A routed submission by somebody\n * the workspace already holds as a contact lands on the contact and files\n * no lead (AGL-3232), and is not counted.\n */\n let leadStored = false\n /** Whether that lead is one more person on this form's lead count. */\n let leadCounted = false\n /**\n * The lead or contact the person was filed as, for the Inbox to link to\n * (AGL-3461) — by the id the record system answered with, which is the\n * id its own record route addresses.\n */\n let capturedRecord: { kind: 'lead' | 'contact'; id: string } | null = null\n /*\n * WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SUBMISSION.\n *\n * One visitor action lands in three collections here — the submission,\n * the contact and (when the form declares itself a lead surface) the\n * lead — and all three are the same person arriving from the same place.\n * Resolving here and handing the result down is what keeps the lookup at\n * one per submission instead of three, and it is also what guarantees the\n * three records cannot disagree about what to credit.\n *\n * The answer is the crediting plugin's (`plugin-conversion-credit.ts`),\n * asked with what the visitor's browser sent under `campaignTouch` —\n * untrusted, so the creditor re-parses and window-checks it before it\n * can name anything. A submission carrying none — direct traffic, or a\n * visitor whose consent posture never let a touch be remembered — and a\n * workspace whose plugins credit nothing both resolve to null and write\n * no credit at all.\n */\n const arrival = await resolveConversionTouch({\n hostId,\n wire: payload['campaignTouch'],\n email: contactEmail,\n atMs: submittedAtMs,\n })\n // The submission itself is an outcome whether or not it named anybody:\n // an anonymous enquiry from a campaign link is a conversion that campaign\n // caused, and refusing to count it because no email field was filled in\n // would under-report exactly the forms that ask for the least.\n void creditConversion({\n hostId,\n kind: 'form',\n refId: submissionRef.id,\n touch: arrival,\n convertedAtMs: submittedAtMs,\n })\n /*\n * THE CAMPAIGNS IN WORDS, for the managers' alert below (AGL-3461): the\n * one the visitor arrived through and the ones the form and page are\n * filed under, named by the plugin that keeps campaigns. Started here so\n * its reads overlap the capture and the counters; asked only when there\n * is something to name, and it never throws.\n */\n const filedUnder = normalizeContainerIds([...formCampaignIds, ...pageCampaignIds])\n const describing =\n arrival || filedUnder.length\n ? describeConversion({ hostId, touch: arrival, containerIds: filedUnder })\n : Promise.resolve(null)\n if (contactEmail) {\n /*\n * WHICH RECORD THE PERSON LANDS ON is the record system's decision\n * (AGL-3232), and the form says only what kind of surface it is: a\n * lead surface when its author switched lead routing on\n * (`docs/specs/reusable-forms.md` §4a), a touch otherwise. The writer\n * files a lead — through `addHostLead`, keyed on the person and\n * counted against the ceiling — unless the workspace already holds the\n * address as a contact, and answers which record it wrote.\n *\n * AWAITED, unlike the capture of every other door, and only because\n * the answer is counted: the form's `stats.leads` rides the same\n * `update` the submission counters ride below, and a `void` call with\n * the counter chained onto its `then` would put the one figure on this\n * page that is about routing on a promise the runtime is free to\n * abandon once the response is sent. The posture is unchanged —\n * `recordCapturedContact` never throws — so awaiting it cannot fail the\n * submission any more than voiding it could.\n */\n const routed = form?.get('routing')?.lead === true\n const captured = await recordCapturedContact({\n /*\n * Not gated on the org resolving. The record system keys a person on\n * the SITE they were met on, so an org lookup that came back empty\n * costs the capture nothing — and refusing to record the person over\n * it would turn a lookup failure into a lost contact, silently, which\n * is the failure this whole seam is arranged to avoid.\n */\n orgId: owningOrg?.orgId ?? '',\n hostId,\n identity: {\n email: contactEmail,\n name: sanitizedFields['name'] ?? sanitizedFields['fullName'],\n },\n interaction: {\n source: 'form',\n refId: submissionRef.id,\n summary: `Submitted \"${resolvedFormName.slice(0, 60)}\"`,\n },\n surface: routed ? 'lead' : 'touch',\n ...(declaredMarketingConsent ? { marketingConsent: true } : {}),\n ...(declaredMarketingConsent && disclosedConsentGroup\n ? { disclosedConsentGroup }\n : {}),\n // Filed under the form's campaigns, inside this site's own facet on a\n // row the whole org shares. Membership is not consent, and this passes\n // none: `marketingConsent` above is the only input that records one.\n ...(formCampaignIds.length\n ? { containers: { campaign: formCampaignIds } }\n : {}),\n // The mapped custom field values. Absent when nothing mapped, so the\n // owner adds no `custom` key for nothing.\n ...(Object.keys(mappedContactCustom).length\n ? { profile: { custom: mappedContactCustom } }\n : {}),\n detail: {\n /*\n * THE ENTRY POINT, for the owner to put on the person's timeline.\n *\n * Which form and which page a person came in through is a fact\n * about this capture rather than about the person, which is why it\n * rides `detail` and not `profile`. `sources` says only that SOME\n * form produced this contact — every form sets the same flag — and\n * answering \"which one\" by reading the submission back would be a\n * document read per row of a timeline the console renders straight\n * out of the contact.\n *\n * The id only for a VERIFIED form, matching the submission: an\n * unverified id never reaches a stored row on this path.\n */\n ...(form ? { formId: form.id } : {}),\n ...(typeof path === 'string' && path ? { path } : {}),\n // Where the visitor ARRIVED from, already resolved above — never\n // the same fact as `campaignIds`, which is where the merchant filed\n // the form.\n ...(arrival ? { [CONVERSION_TOUCH_DETAIL]: arrival } : {}),\n },\n })\n leadStored = captured?.ok === true && captured.record === 'lead'\n /*\n * A form counts the PEOPLE it filed (AGL-3330): the capture says\n * whether this one was new to the form, and a writer that does not\n * say is read as whether the lead itself was new.\n */\n leadCounted =\n leadStored &&\n captured?.ok === true &&\n captured.record === 'lead' &&\n (captured.sourceAdded ?? captured.created) === true\n if (captured?.ok === true) {\n const id = captured.record === 'lead' ? captured.leadId : captured.contactId\n if (id) capturedRecord = { kind: captured.record, id }\n }\n }\n /*\n * WHERE ELSE THE SUBMISSION IS FILED (AGL-3080): a record in whatever\n * plugin keeps the records a form may write into — a dataset row, today.\n *\n * Asked AFTER the submission is stored, through the platform's contract\n * (`plugin-manager/submission-record-target.ts`), and the target decides from\n * what the page's compose stamped onto this form, never from the body\n * alone: the route is public. It answers the submission's `routing`,\n * which the Inbox reads to say where it went or why it did not. The Inbox\n * copy above is canonical either way, and nothing here can fail the\n * submission.\n */\n const filed = await writeFormRecordTarget({\n hostId,\n orgId: owningOrg?.orgId ?? null,\n orgBilling,\n body: payload,\n fields: sanitizedFields,\n })\n /*\n * What the submission led to, on the row in ONE update: where else it\n * was filed, and the lead or contact it filed (AGL-3461). Written only\n * when there is either, so the ordinary anonymous enquiry pays nothing.\n */\n const outcomeNotes = {\n ...(filed.routing ? { routing: filed.routing } : {}),\n ...(capturedRecord ? { capturedRecord } : {}),\n }\n if (Object.keys(outcomeNotes).length) {\n try {\n await submissionRef.update(outcomeNotes)\n } catch (error) {\n console.error('form submission routing note failed', error)\n }\n }\n await counterRef.set({ [monthKey]: FieldValue.increment(1) }, { merge: true })\n /*\n * Per-form counters (`docs/specs/reusable-forms.md` §5a), through the\n * one writer that retries and REPORTS a failure rather than swallowing\n * it (AGL-3330) — see `incrementFormStats`.\n *\n * `stats.leads` is here rather than beside the lead writer for the same\n * reason the submission counters are: a second write to say a lead was\n * filed would be a second write per submission on the collection the\n * customer is billed against.\n */\n if (form) {\n await incrementFormStats({\n formRef: form.ref,\n hostId,\n monthKey,\n submittedAtMs,\n leadCounted,\n })\n }\n // Event trigger (AGL-128/148): field values join the automation\n // scope; action-produced site alerts ride back to the visitor.\n // In-app notification to the site's managers (AGL-259).\n /*\n * The body names the form and the site on its own (AGL-3432): a manager\n * of several sites reads the email body, not always the subject, and\n * \"Page: /contact\" said neither. `{site}` is filled by\n * `notifyHostManagers` from the host doc it already reads.\n *\n * Then the campaigns (AGL-3461): the one the visitor was credited to and\n * how, and what the form and page are filed under, as the crediting\n * plugin names them.\n *\n * It opens THIS submission, not the list: the address the plugin that\n * shows submissions declares for one record (`submissionsLink`), stored\n * in the host-link shape every host notification uses and rewritten onto\n * the site's address when it is followed, in the console and in the email\n * alike. A notification holds one link, so the lead and the campaign are\n * links inside the reader.\n */\n const alertPath = typeof path === 'string' ? path.slice(0, 500) : ''\n void notifyHostManagers(hostId, {\n type: 'content.formSubmission',\n title: `New form submission — ${resolvedFormName}`,\n body: [\n `Someone submitted “${resolvedFormName}” on {site}` +\n (alertPath ? ` (page ${alertPath}).` : '.'),\n ...conversionDescriptionSentences(await describing),\n ].join(' '),\n ...submissionsLink(hostId, submissionRef.id),\n })\n const submittedEmail =\n typeof sanitizedFields['email'] === 'string' ? sanitizedFields['email'] : ''\n /*\n * `formId` is the VERIFIED form's id (AGL-3458), the way `contactCreated`\n * carries it: a condition keyed on it survives the form being renamed,\n * where one on `formName` silently stops matching. It is written last\n * and only from the form this route read, so a submitted field that\n * calls itself `formId` can neither claim another form's automations\n * nor stand in for a form the site does not have.\n */\n const submittedFields = Object.fromEntries(\n Object.entries(sanitizedFields).filter(([key]) => key !== 'formId'),\n )\n const { alerts } = await emitHostEvent(\n hostId,\n 'formSubmission',\n {\n formName: resolvedFormName,\n path: String(path ?? '').slice(0, 500),\n ...submittedFields,\n ...(form ? { formId: form.id } : {}),\n },\n // The visitor who submitted, by the address they typed if any (AGL-3376).\n { actor: { kind: 'visitor', ...(submittedEmail ? { email: submittedEmail } : {}) } },\n )\n return json({ received: true, alerts })\n } catch (error) {\n console.error(error)\n return json({ error: 'Submission failed' }, 500)\n }\n}\n"],"names":["Aglyn","extractEmailFromFields","consumeRateLimit","firebaseAdmin","getOrgForHost","notifyHostManagers","visitorWriteRefusal","emitHostEvent","recordCapturedContact","CONVERSION_TOUCH_DETAIL","conversionDescriptionSentences","creditConversion","describeConversion","resolveConversionTouch","normalizeContainerIds","findScreenIdByRoutePath","screenRoutePathFromUrl","messageSearchFields","writeFormRecordTarget","pluginRecordPageLink","FieldValue","isCredentialFieldName","FORMS_OFF_FOR_SITE_REFUSAL","formSubmissionDoorPlugin","FORM_ABUSE_CEILING_CODE","incrementFormStats","NO_CLIENT_ADDRESS_BUCKET","readClientIp","MAX_FIELDS","MAX_PAYLOAD_CHARS","RATE_WINDOW_MS","RATE_MAX","json","body","status","Response","secondsUntilNextMonth","now","Date","next","UTC","getUTCFullYear","getUTCMonth","Math","max","ceil","getTime","publishedContactEmail","host","HOST_TOKENS","resolve","undefined","recordAbuseCeilingTrip","hostRef","hostId","monthKey","ceiling","refusedSnapshot","refusedRef","collection","doc","get","alreadyRefused","Number","set","increment","lastRefusedAtMs","merge","reopens","nextUtcMonthStart","toLocaleDateString","month","day","timeZone","type","title","toLocaleString","submissionsLink","error","console","submissionId","link","recordHoneypotHit","length","app","firestore","exists","utcMonthKey","lastSpamAtMs","readDeclaredMarketingConsent","payload","fields","declarations","key","value","Object","entries","isMarketingConsentFieldName","declaration","find","entry","fieldName","isConsentCheckboxTicked","POST","request","method","headers","Allow","catch","formId","formName","path","website","trim","received","Array","isArray","keys","JSON","stringify","ip","rate","limit","windowMs","allowed","String","resetMs","form","hostSnapshot","paused","surface","owningOrg","orgBilling","org","isHostPluginEnabled","data","counterRef","counterSnapshot","used","checkFormSubmissionQuota","checkFormSubmissionAbuseCeiling","exceeded","contact","code","sanitizedFields","slice","boundFormId","formSnapshot","isFormArchived","resolvedFormName","formFieldDecls","declaredMarketingConsent","readFormDeclaredConsent","consentFieldName","disclosedConsentGroup","formCampaignIds","readContainerIds","pageRoutePath","pageScreenId","pageCampaignIds","then","screen","mappedContactCustom","orgId","some","decl","contactFieldKey","collectMappedContactCustom","decls","definitions","CRM_COLLECTIONS","contactFields","CONTACT_FIELDS_MAX_PER_ORG","docs","map","snapshot","submittedAtMs","hostOrgId","submissionOrgId","submissionRef","add","id","campaignIds","read","createdAt","serverTimestamp","degraded","rateDegraded","contactEmail","leadStored","leadCounted","capturedRecord","arrival","wire","email","atMs","kind","refId","touch","convertedAtMs","filedUnder","describing","containerIds","Promise","captured","routed","lead","identity","name","interaction","source","summary","marketingConsent","containers","campaign","profile","custom","detail","ok","record","sourceAdded","created","leadId","contactId","filed","outcomeNotes","routing","update","formRef","ref","alertPath","join","submittedEmail","submittedFields","fromEntries","filter","alerts","actor"],"mappings":"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"}
|