@nextlyhq/plugin-form-builder 0.0.2-alpha.64 → 0.0.2-alpha.66

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/dist/index.js CHANGED
@@ -31,13 +31,13 @@ import {
31
31
  pickedDocumentField,
32
32
  pluralize,
33
33
  toTitleCase
34
- } from "./chunk-W6NLZFHH.js";
34
+ } from "./chunk-VIGKSRSH.js";
35
35
 
36
36
  // src/plugin.ts
37
37
  import { createRequire } from "module";
38
38
  import {
39
39
  definePlugin,
40
- NextlyError as NextlyError2
40
+ NextlyError as NextlyError4
41
41
  } from "@nextlyhq/plugin-sdk";
42
42
 
43
43
  // src/collections/forms.ts
@@ -1134,18 +1134,32 @@ function validateFormData(data, fields) {
1134
1134
  const schema = generateZodSchema(fields);
1135
1135
  return schema.safeParse(transformed);
1136
1136
  }
1137
- function getValidationErrors(result) {
1138
- if (result.success) {
1139
- return {};
1140
- }
1141
- const errors = {};
1142
- for (const error of result.error.issues) {
1143
- const path = error.path.join(".");
1144
- if (!errors[path]) {
1145
- errors[path] = error.message;
1146
- }
1137
+ function isBlank(value) {
1138
+ return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
1139
+ }
1140
+ function getValidationIssues(result, submitted = {}) {
1141
+ if (result.success) return [];
1142
+ const seen = /* @__PURE__ */ new Set();
1143
+ const issues = [];
1144
+ for (const issue of result.error.issues) {
1145
+ const path = issue.path.join(".");
1146
+ if (seen.has(path)) continue;
1147
+ seen.add(path);
1148
+ issues.push({
1149
+ path,
1150
+ // Read at the top level, which is the only depth a form field has: a
1151
+ // declared field is one key, so a dotted path can only come from inside a
1152
+ // value the visitor did supply.
1153
+ code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
1154
+ message: issue.message
1155
+ });
1147
1156
  }
1148
- return errors;
1157
+ return issues;
1158
+ }
1159
+ function getValidationErrors(result) {
1160
+ return Object.fromEntries(
1161
+ getValidationIssues(result).map((issue) => [issue.path, issue.message])
1162
+ );
1149
1163
  }
1150
1164
 
1151
1165
  // src/handlers/prepare-submission.ts
@@ -1203,167 +1217,883 @@ function prepareSubmission({
1203
1217
  }
1204
1218
  const result = generateZodSchema(fields).safeParse(transformed);
1205
1219
  if (!result.success) {
1206
- return { data: transformed, validationErrors: getValidationErrors(result) };
1220
+ return {
1221
+ data: transformed,
1222
+ validationErrors: getValidationErrors(result),
1223
+ // The RAW submission, not the transformed payload. Transformation
1224
+ // normalises a value before the schema sees it, so a required
1225
+ // multi-select sent a scalar `0` arrives here as `[]` and would read as
1226
+ // blank: the visitor answered, and would be told they had not.
1227
+ validationIssues: getValidationIssues(result, data)
1228
+ };
1207
1229
  }
1208
1230
  return { data: result.data };
1209
1231
  }
1210
1232
 
1211
- // src/plugin.ts
1212
- var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
1213
- "../package.json"
1214
- );
1215
- function resolveConfig(options) {
1216
- const formOverrides = options.formOverrides || {};
1217
- const submissionOverrides = options.formSubmissionOverrides || {};
1218
- return {
1219
- formOverrides: {
1220
- ...formOverrides,
1221
- slug: formOverrides.slug || "forms",
1222
- labels: {
1223
- singular: formOverrides.labels?.singular || "Form",
1224
- plural: formOverrides.labels?.plural || "Forms"
1225
- }
1226
- },
1227
- formSubmissionOverrides: {
1228
- ...submissionOverrides,
1229
- slug: submissionOverrides.slug || "form-submissions",
1230
- labels: {
1231
- singular: submissionOverrides.labels?.singular || "Submission",
1232
- plural: submissionOverrides.labels?.plural || "Submissions"
1233
+ // src/handlers/spam-detection.ts
1234
+ import { RateLimiter, resolveRateLimitStore } from "nextly";
1235
+ async function checkSpam(options) {
1236
+ const { data, ipAddress, formSlug, config } = options;
1237
+ if (config.honeypot) {
1238
+ const honeypotResult = checkHoneypot(data);
1239
+ if (honeypotResult.isSpam) {
1240
+ return honeypotResult;
1241
+ }
1242
+ }
1243
+ if (config.rateLimit && ipAddress) {
1244
+ const rateLimitResult = await checkRateLimit(
1245
+ ipAddress,
1246
+ formSlug,
1247
+ config.rateLimit
1248
+ );
1249
+ if (rateLimitResult.isSpam) {
1250
+ return rateLimitResult;
1251
+ }
1252
+ }
1253
+ return { isSpam: false };
1254
+ }
1255
+ var HONEYPOT_FIELDS = [
1256
+ "__honeypot",
1257
+ "_honeypot",
1258
+ "honeypot",
1259
+ "__hp",
1260
+ "_hp",
1261
+ "website",
1262
+ // Common honeypot field name
1263
+ "url_field",
1264
+ // Another common honeypot
1265
+ "fax_number"
1266
+ // Rarely used by humans
1267
+ ];
1268
+ function checkHoneypot(data) {
1269
+ for (const fieldName of HONEYPOT_FIELDS) {
1270
+ const value = data[fieldName];
1271
+ if (value !== void 0 && value !== null && value !== "") {
1272
+ let raw;
1273
+ if (typeof value === "object") {
1274
+ raw = JSON.stringify(value);
1275
+ } else {
1276
+ raw = String(value);
1233
1277
  }
1234
- },
1235
- fields: {
1236
- text: options.fields?.text ?? true,
1237
- email: options.fields?.email ?? true,
1238
- number: options.fields?.number ?? true,
1239
- phone: options.fields?.phone ?? true,
1240
- url: options.fields?.url ?? true,
1241
- textarea: options.fields?.textarea ?? true,
1242
- select: options.fields?.select ?? true,
1243
- checkbox: options.fields?.checkbox ?? true,
1244
- radio: options.fields?.radio ?? true,
1245
- file: options.fields?.file ?? true,
1246
- date: options.fields?.date ?? true,
1247
- time: options.fields?.time ?? true,
1248
- hidden: options.fields?.hidden ?? true
1249
- },
1250
- redirectRelationships: normalizeRedirectRelationships(
1251
- options.redirectRelationships
1252
- ),
1253
- // Filled at `init` from each collection's own metadata. Empty until then,
1254
- // and a slug that stays absent reads as unknown rather than as not
1255
- // localized — see `ResolvedFormBuilderConfig`.
1256
- redirectTargetLocalization: {},
1257
- beforeEmail: options.beforeEmail,
1258
- notifications: {
1259
- defaultFrom: options.notifications?.defaultFrom,
1260
- defaultToEmail: options.notifications?.defaultToEmail,
1261
- enabled: options.notifications?.enabled ?? true
1262
- },
1263
- spamProtection: {
1264
- honeypot: options.spamProtection?.honeypot ?? true,
1265
- recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
1266
- rateLimit: {
1267
- maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
1268
- windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
1278
+ const stringValue = raw.trim();
1279
+ if (stringValue.length > 0) {
1280
+ return {
1281
+ isSpam: true,
1282
+ reason: "honeypot",
1283
+ details: `Honeypot field "${fieldName}" was filled`
1284
+ };
1269
1285
  }
1270
- },
1271
- uploads: {
1272
- maxFileSize: options.uploads?.maxFileSize ?? 10485760,
1273
- // 10 MB
1274
- allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
1275
- "image/*",
1276
- "application/pdf",
1277
- "text/*"
1278
- ],
1279
- uploadCollection: options.uploads?.uploadCollection ?? "media"
1280
- },
1281
- features: {
1282
- builder: options.features?.builder ?? true,
1283
- conditionalLogic: options.features?.conditionalLogic ?? true,
1284
- fileUploads: options.features?.fileUploads ?? true
1285
1286
  }
1287
+ }
1288
+ return { isSpam: false };
1289
+ }
1290
+ async function checkRateLimit(ipAddress, formSlug, config) {
1291
+ const limiter = new RateLimiter(resolveRateLimitStore());
1292
+ const result = await limiter.check(
1293
+ `form-submit:${formSlug}:${ipAddress}`,
1294
+ config.maxSubmissions,
1295
+ config.windowMs
1296
+ );
1297
+ if (result.allowed) return { isSpam: false };
1298
+ return {
1299
+ isSpam: true,
1300
+ reason: "rate_limit",
1301
+ details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`
1286
1302
  };
1287
1303
  }
1288
- function formBuilder(options = {}) {
1289
- const resolvedConfig = resolveConfig(options);
1290
- const formsCol = formsCollection(resolvedConfig);
1291
- const submissionsCol = submissionsCollection(resolvedConfig);
1292
- const plugin = definePlugin({
1293
- name: "@nextlyhq/plugin-form-builder",
1294
- version: PLUGIN_VERSION,
1295
- nextly: ">=0.0.2-alpha.21",
1296
- // Identity metadata for the admin plugins page, mirroring package.json.
1297
- author: "Nextly",
1298
- homepage: "https://nextlyhq.com",
1299
- repository: "https://github.com/nextlyhq/nextly",
1300
- license: "MIT",
1301
- category: "forms",
1302
- tags: ["forms", "submissions", "email-notifications"],
1303
- // Declarative schema: the merged pipeline folds these into the
1304
- // app schema — no manual `setup()` append needed. Just register the plugin.
1305
- contributes: {
1306
- collections: [formsCol, submissionsCol],
1307
- // Custom permission — gates submission export beyond CRUD. The
1308
- // canonical example third-party plugin authors copy.
1309
- permissions: [
1310
- {
1311
- action: "export",
1312
- resource: "submissions",
1313
- label: "Export Submissions",
1314
- description: "Export form submissions as CSV/JSON",
1315
- // No `group`: the admin already files this under the plugin that
1316
- // declared it, so naming the plugin again would nest it inside
1317
- // itself. `group` is for a plugin with enough permissions to sort
1318
- // its own into headings, which one is not.
1319
- //
1320
- // `danger` because the point of the permission is to take
1321
- // submissions — names, emails, whatever a form asked for — out of
1322
- // the site in a file.
1323
- danger: true
1324
- }
1325
- ],
1326
- // HTTP route — exported at
1327
- // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
1328
- // default: gated by the custom `export-submissions` permission. Reads
1329
- // via the secure-by-default service path as the authed user and
1330
- // resolves its OWN slug through `ctx.self`. The canonical
1331
- // contributes.routes example for third-party authors.
1332
- routes: [
1304
+
1305
+ // src/routes/public-forms.ts
1306
+ import {
1307
+ buildPaginatedResponse,
1308
+ clampLimit,
1309
+ formAvailability as formAvailability2,
1310
+ NextlyError as NextlyError3,
1311
+ NO_SUCH_FORM as NO_SUCH_FORM2,
1312
+ respondAction,
1313
+ respondDoc,
1314
+ respondList,
1315
+ trustedClientIp
1316
+ } from "nextly";
1317
+
1318
+ // src/handlers/submit-form.ts
1319
+ import { isLocaleSelector } from "@nextlyhq/plugin-sdk";
1320
+ import { formAvailability, NextlyError as NextlyError2, NO_SUCH_FORM } from "nextly";
1321
+ async function submitForm(options, context) {
1322
+ const { formSlug, data, metadata, request } = options;
1323
+ const access = options.access ?? { as: "system" };
1324
+ const locale = visitorLocale(options.locale);
1325
+ const targetRead = { access, locale, request };
1326
+ const { pluginContext, pluginConfig } = context;
1327
+ const { collections } = pluginContext.services;
1328
+ const { logger } = pluginContext;
1329
+ try {
1330
+ const form = await fetchFormBySlug(
1331
+ formSlug,
1332
+ pluginConfig,
1333
+ pluginContext,
1334
+ access
1335
+ );
1336
+ if (!form) {
1337
+ logger.warn?.("Form submission attempted for non-existent form", {
1338
+ formSlug
1339
+ });
1340
+ return { success: false, outcome: "no-such-form", error: NO_SUCH_FORM };
1341
+ }
1342
+ const redirectFor = () => resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);
1343
+ const availability = formAvailability(form);
1344
+ if (availability.kind !== "open") {
1345
+ logger.info?.("Form submission rejected - form not accepting", {
1346
+ formSlug,
1347
+ status: form.status,
1348
+ availability: availability.kind
1349
+ });
1350
+ return {
1351
+ success: false,
1352
+ outcome: availability.kind === "closed" ? "closed" : "no-such-form",
1353
+ error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
1354
+ };
1355
+ }
1356
+ const settings = normalizeFormSettings(form.settings);
1357
+ if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
1358
+ logger.warn?.(
1359
+ "Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
1360
+ { formSlug }
1361
+ );
1362
+ }
1363
+ if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
1364
+ const existing = await collections.count(
1365
+ pluginConfig.formSubmissionOverrides.slug,
1333
1366
  {
1334
- method: "GET",
1335
- path: "/submissions/export",
1336
- requiredPermission: "export-submissions",
1337
- handler: async (req, ctx) => {
1338
- const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
1339
- const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
1340
- const url2 = new URL(req.url);
1341
- const format = url2.searchParams.get("format") ?? "json";
1342
- const formId = url2.searchParams.get("form");
1343
- const status = url2.searchParams.get("status");
1344
- const where = {};
1345
- if (formId) where.form = { equals: formId };
1346
- if (status) where.status = { equals: status };
1347
- else where.status = { not_equals: "spam" };
1348
- const opts = { as: "user", user: ctx.user ?? void 0 };
1349
- let form;
1350
- if (format === "csv") {
1351
- if (!formId) {
1352
- return Response.json(
1353
- {
1354
- error: {
1355
- code: "VALIDATION_ERROR",
1356
- message: "CSV export requires a form parameter."
1357
- }
1358
- },
1359
- { status: 400 }
1360
- );
1361
- }
1362
- const formsSlugDeclared = resolvedConfig.formOverrides.slug;
1363
- const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
1364
- const formResult = await ctx.services.collections.listEntries(
1365
- formsSlug,
1366
- { where: { id: { equals: formId } } },
1367
+ where: {
1368
+ form: { equals: form.id },
1369
+ ipAddress: { equals: metadata.ipAddress },
1370
+ status: { not_equals: "spam" }
1371
+ }
1372
+ },
1373
+ // Deliberately elevated, unlike the write below. This counts rows the
1374
+ // visitor is not entitled to see in order to refuse their second
1375
+ // submission; judged as the public it would count zero on any install
1376
+ // that hides submissions and the restriction would never apply.
1377
+ { as: "system" }
1378
+ );
1379
+ if (existing > 0) {
1380
+ logger.info?.("Repeat submission rejected (single-submission form)", {
1381
+ formSlug,
1382
+ ipAddress: metadata.ipAddress
1383
+ });
1384
+ return {
1385
+ success: false,
1386
+ outcome: "duplicate",
1387
+ error: "You have already submitted this form."
1388
+ };
1389
+ }
1390
+ }
1391
+ const declaredFieldNames = new Set(form.fields.map((field) => field.name));
1392
+ const undeclaredData = Object.fromEntries(
1393
+ Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
1394
+ );
1395
+ const spamResult = await checkSpam({
1396
+ data: undeclaredData,
1397
+ formSlug,
1398
+ config: {
1399
+ // Per-form overrides win where set; blank inherits the plugin config.
1400
+ honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot
1401
+ }
1402
+ });
1403
+ const isContentSpam = spamResult.isSpam;
1404
+ if (isContentSpam) {
1405
+ logger.info?.("Spam submission detected \u2014 storing flagged", {
1406
+ formSlug,
1407
+ reason: spamResult.reason,
1408
+ ipAddress: metadata?.ipAddress
1409
+ });
1410
+ }
1411
+ const prepared = prepareSubmission({
1412
+ data,
1413
+ fields: form.fields,
1414
+ validate: !isContentSpam
1415
+ });
1416
+ if (prepared.validationErrors) {
1417
+ logger.debug?.("Form validation failed", {
1418
+ formSlug,
1419
+ errors: prepared.validationErrors
1420
+ });
1421
+ return {
1422
+ success: false,
1423
+ outcome: "invalid",
1424
+ error: "Validation failed",
1425
+ validationErrors: prepared.validationErrors,
1426
+ validationIssues: prepared.validationIssues
1427
+ };
1428
+ }
1429
+ const storedData = prepared.data;
1430
+ const submissionData = {
1431
+ form: form.id,
1432
+ data: storedData,
1433
+ status: isContentSpam ? "spam" : "new",
1434
+ spamReason: isContentSpam ? spamResult.reason ?? null : null,
1435
+ ipAddress: metadata?.ipAddress || null,
1436
+ userAgent: metadata?.userAgent || null,
1437
+ submittedAt: /* @__PURE__ */ new Date()
1438
+ };
1439
+ let submission;
1440
+ try {
1441
+ submission = await collections.createEntry(
1442
+ pluginConfig.formSubmissionOverrides.slug,
1443
+ asPluginSubmission(submissionData, {
1444
+ keepAsEvidence: isContentSpam,
1445
+ form: { id: form.id, fields: form.fields }
1446
+ }),
1447
+ // Public form submission — create as system. No ambient user; an
1448
+ // empty context already resolves to system, but be explicit.
1449
+ //
1450
+ // The request travels with it so the seam judges this submission on the
1451
+ // same facts as one arriving through a Nextly route. Omitted by a caller
1452
+ // that has none, and then the seam correctly reads the write as
1453
+ // server-side work.
1454
+ //
1455
+ // `access` rather than a fixed `system`, so a route serving an anonymous
1456
+ // visitor is still judged against the collection's own create rule. A
1457
+ // host that closed public submissions configured that on the
1458
+ // collection, and a write elevated past it reports a 201 for a
1459
+ // submission they had refused.
1460
+ { ...access, ...request ? { request } : {} }
1461
+ );
1462
+ } catch (error) {
1463
+ if (NextlyError2.isRateLimited(error)) {
1464
+ logger.info?.("Form submission refused (rate limit)", { formSlug });
1465
+ return {
1466
+ success: true,
1467
+ outcome: "accepted",
1468
+ successMessage: settings.successMessage,
1469
+ // The same destination an accepted submission gets. Returning early
1470
+ // without it left a throttled visitor on the form instead of the
1471
+ // page its author sends people to, and made the one response this
1472
+ // whole branch exists to disguise the only accepted one carrying no
1473
+ // redirect.
1474
+ redirect: await redirectFor()
1475
+ };
1476
+ }
1477
+ throw error;
1478
+ }
1479
+ logger.info?.("Form submission created successfully", {
1480
+ formSlug,
1481
+ submissionId: submission.item.id,
1482
+ flaggedAsSpam: isContentSpam
1483
+ });
1484
+ if (submission.warnings) {
1485
+ logger.warn?.("Form submission side effects failed", {
1486
+ formSlug,
1487
+ submissionId: submission.item.id,
1488
+ warnings: submission.warnings
1489
+ });
1490
+ }
1491
+ const redirect = await redirectFor();
1492
+ if (isContentSpam) {
1493
+ return {
1494
+ success: true,
1495
+ outcome: "accepted",
1496
+ successMessage: settings.successMessage,
1497
+ redirect
1498
+ };
1499
+ }
1500
+ return {
1501
+ success: true,
1502
+ outcome: "accepted",
1503
+ successMessage: settings.successMessage,
1504
+ // The created ROW, not the envelope around it. Through the shared
1505
+ // conversion, which is where the unchecked step lives and says so; the
1506
+ // integration test reads `submission.id` off the result because nothing
1507
+ // at compile time will.
1508
+ submission: asSubmissionDocument(submission.item),
1509
+ redirect
1510
+ };
1511
+ } catch (error) {
1512
+ logger.error?.("Form submission error", {
1513
+ formSlug,
1514
+ error: error instanceof Error ? error.message : String(error)
1515
+ });
1516
+ return {
1517
+ success: false,
1518
+ outcome: "failed",
1519
+ error: "An error occurred processing your submission. Please try again.",
1520
+ cause: error
1521
+ };
1522
+ }
1523
+ }
1524
+ async function fetchFormBySlug(slug, pluginConfig, pluginContext, access = { as: "system" }) {
1525
+ const { collections } = pluginContext.services;
1526
+ const result = await collections.listEntries(
1527
+ pluginConfig.formOverrides.slug,
1528
+ { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
1529
+ access
1530
+ );
1531
+ const form = result.data?.[0];
1532
+ return form ? asFormDocument(form) : null;
1533
+ }
1534
+ async function resolveRedirectUrl(form, pluginConfig, pluginContext, read) {
1535
+ const settings = form.settings;
1536
+ if (!settings) return void 0;
1537
+ if (settings.confirmationType === "redirect" && settings.redirectUrl) {
1538
+ return settings.redirectUrl;
1539
+ }
1540
+ const field = pickedDocumentField(settings);
1541
+ if (!field) return void 0;
1542
+ return urlForPickedDocument(
1543
+ settings[field],
1544
+ form,
1545
+ pluginConfig,
1546
+ pluginContext,
1547
+ read
1548
+ );
1549
+ }
1550
+ async function urlForPickedDocument(stored, form, pluginConfig, pluginContext, read) {
1551
+ const { logger } = pluginContext;
1552
+ const patterns = pluginConfig.redirectRelationships;
1553
+ const reference = parseRedirectReference(stored, Object.keys(patterns));
1554
+ if (!reference) {
1555
+ logger.warn?.("Form redirects to a page, but names no readable document", {
1556
+ form: form.slug
1557
+ });
1558
+ return void 0;
1559
+ }
1560
+ const pattern = patterns[reference.collection];
1561
+ if (!pattern) {
1562
+ logger.warn?.(
1563
+ "Form redirects to a collection that is not in redirectRelationships",
1564
+ { form: form.slug, collection: reference.collection }
1565
+ );
1566
+ return void 0;
1567
+ }
1568
+ const target = await readTarget(reference, form, pluginContext, read);
1569
+ if (!target) return void 0;
1570
+ if (documentReachability(
1571
+ target,
1572
+ pluginConfig.redirectTargetLocalization[reference.collection]
1573
+ ) === "unreachable") {
1574
+ logger.warn?.("Form redirects to a page that has never been published", {
1575
+ form: form.slug,
1576
+ collection: reference.collection,
1577
+ target: reference.id
1578
+ });
1579
+ return void 0;
1580
+ }
1581
+ return buildUrl(pattern, target, reference.collection, form, pluginContext);
1582
+ }
1583
+ function buildUrl(pattern, target, collection, form, pluginContext) {
1584
+ const { logger } = pluginContext;
1585
+ try {
1586
+ const url2 = applyRedirectPattern(pattern, target);
1587
+ if (!url2) {
1588
+ logger.warn?.(
1589
+ "Form redirect pattern could not be filled from the target document",
1590
+ { form: form.slug, collection }
1591
+ );
1592
+ }
1593
+ return url2;
1594
+ } catch (error) {
1595
+ logger.warn?.("Form redirect pattern threw while building a URL", {
1596
+ form: form.slug,
1597
+ collection,
1598
+ error: error instanceof Error ? error.message : String(error)
1599
+ });
1600
+ return void 0;
1601
+ }
1602
+ }
1603
+ function visitorLocale(locale) {
1604
+ if (locale === void 0 || isLocaleSelector(locale)) return void 0;
1605
+ return locale;
1606
+ }
1607
+ async function readTarget(reference, form, pluginContext, read) {
1608
+ const { logger } = pluginContext;
1609
+ const context = {
1610
+ form: form.slug,
1611
+ collection: reference.collection,
1612
+ id: reference.id
1613
+ };
1614
+ let row;
1615
+ try {
1616
+ row = await pluginContext.services.collections.findEntryById(
1617
+ reference.collection,
1618
+ reference.id,
1619
+ { ...read.access, locale: read.locale, request: read.request }
1620
+ );
1621
+ } catch (error) {
1622
+ logger.warn?.("Form redirect target could not be read", {
1623
+ ...context,
1624
+ error: error instanceof Error ? error.message : String(error)
1625
+ });
1626
+ return void 0;
1627
+ }
1628
+ if (!row || typeof row !== "object") {
1629
+ logger.warn?.("Form redirect target no longer exists", context);
1630
+ return void 0;
1631
+ }
1632
+ return row;
1633
+ }
1634
+ async function validateSubmission(formSlug, data, context) {
1635
+ const { pluginContext, pluginConfig } = context;
1636
+ const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
1637
+ if (!form) {
1638
+ return { valid: false, errors: { _form: "Form not found" } };
1639
+ }
1640
+ const prepared = prepareSubmission({
1641
+ data,
1642
+ fields: form.fields,
1643
+ validate: true
1644
+ });
1645
+ if (!prepared.validationErrors) {
1646
+ return { valid: true };
1647
+ }
1648
+ return {
1649
+ valid: false,
1650
+ errors: prepared.validationErrors
1651
+ };
1652
+ }
1653
+ async function isFormAcceptingSubmissions(formSlug, context) {
1654
+ const { pluginContext, pluginConfig } = context;
1655
+ const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
1656
+ if (!form) {
1657
+ return false;
1658
+ }
1659
+ return form.status === "published";
1660
+ }
1661
+ async function getFormSubmissionStats(formSlug, context) {
1662
+ const { pluginContext, pluginConfig } = context;
1663
+ const { collections } = pluginContext.services;
1664
+ const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
1665
+ if (!form) {
1666
+ return null;
1667
+ }
1668
+ try {
1669
+ const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
1670
+ const sys = { as: "system" };
1671
+ const base = { form: { equals: form.id } };
1672
+ const [total, newCount, read, archived] = await Promise.all([
1673
+ collections.count(submissionsSlug, { where: base }, sys),
1674
+ collections.count(
1675
+ submissionsSlug,
1676
+ { where: { ...base, status: { equals: "new" } } },
1677
+ sys
1678
+ ),
1679
+ collections.count(
1680
+ submissionsSlug,
1681
+ { where: { ...base, status: { equals: "read" } } },
1682
+ sys
1683
+ ),
1684
+ collections.count(
1685
+ submissionsSlug,
1686
+ { where: { ...base, status: { equals: "archived" } } },
1687
+ sys
1688
+ )
1689
+ ]);
1690
+ return { total, new: newCount, read, archived };
1691
+ } catch {
1692
+ return null;
1693
+ }
1694
+ }
1695
+
1696
+ // src/routes/public-forms.ts
1697
+ var PUBLIC = { as: "public" };
1698
+ var DEFAULT_LIST_LIMIT = 100;
1699
+ var DEFAULT_SUCCESS_MESSAGE = "Thank you for your submission!";
1700
+ function installedSlugs(config, self) {
1701
+ const declaredForms = config.formOverrides.slug;
1702
+ const declaredSubmissions = config.formSubmissionOverrides.slug;
1703
+ return {
1704
+ forms: self.collections[declaredForms] ?? declaredForms,
1705
+ submissions: self.collections[declaredSubmissions] ?? declaredSubmissions
1706
+ };
1707
+ }
1708
+ function configForInstall(config, self) {
1709
+ const slugs = installedSlugs(config, self);
1710
+ return {
1711
+ ...config,
1712
+ formOverrides: { ...config.formOverrides, slug: slugs.forms },
1713
+ formSubmissionOverrides: {
1714
+ ...config.formSubmissionOverrides,
1715
+ slug: slugs.submissions
1716
+ }
1717
+ };
1718
+ }
1719
+ function positiveInt(raw) {
1720
+ if (raw === null) return void 0;
1721
+ const value = Number(raw);
1722
+ return Number.isInteger(value) && value > 0 ? value : void 0;
1723
+ }
1724
+ function toPageMeta(pagination) {
1725
+ const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;
1726
+ const built = buildPaginatedResponse([], {
1727
+ total: pagination.total,
1728
+ page: Math.floor(pagination.offset / limit) + 1,
1729
+ limit
1730
+ });
1731
+ return {
1732
+ total: built.totalDocs,
1733
+ page: built.page,
1734
+ limit: built.limit,
1735
+ totalPages: built.totalPages,
1736
+ hasNext: built.hasNextPage,
1737
+ hasPrev: built.hasPrevPage
1738
+ };
1739
+ }
1740
+ async function listPublishedForms(req, ctx, config) {
1741
+ const url2 = new URL(req.url);
1742
+ const slugs = installedSlugs(config, ctx.self);
1743
+ const result = await ctx.services.collections.listEntries(
1744
+ slugs.forms,
1745
+ {
1746
+ where: { status: { equals: "published" } },
1747
+ pagination: {
1748
+ // Clamped HERE, with the canonical bound, so the limit that computes
1749
+ // the offset is the limit that comes back. The query service clamps to
1750
+ // 500 internally and reports the clamped value, while the offset is
1751
+ // built from what was asked for, so `?limit=1000&page=2` returned the
1752
+ // second 500-row slice and dividing the two reported page 3.
1753
+ limit: clampLimit(
1754
+ positiveInt(url2.searchParams.get("limit")) ?? DEFAULT_LIST_LIMIT
1755
+ ),
1756
+ page: positiveInt(url2.searchParams.get("page")) ?? 1
1757
+ }
1758
+ },
1759
+ PUBLIC
1760
+ );
1761
+ return respondList(result.data ?? [], toPageMeta(result.pagination));
1762
+ }
1763
+ async function readFormBySlug(slug, ctx, config) {
1764
+ const slugs = installedSlugs(config, ctx.self);
1765
+ const result = await ctx.services.collections.listEntries(
1766
+ slugs.forms,
1767
+ { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
1768
+ PUBLIC
1769
+ );
1770
+ const doc = result.data?.[0];
1771
+ const availability = formAvailability2(doc);
1772
+ if (availability.kind === "absent") {
1773
+ throw NextlyError3.notFound({
1774
+ message: NO_SUCH_FORM2,
1775
+ logContext: { entity: "form", slug, reason: availability.reason }
1776
+ });
1777
+ }
1778
+ if (availability.kind === "closed") {
1779
+ return respondDoc({
1780
+ slug,
1781
+ status: "closed",
1782
+ closedMessage: availability.message
1783
+ });
1784
+ }
1785
+ return respondDoc(doc);
1786
+ }
1787
+ async function readSubmittedData(req, slug) {
1788
+ let parsed;
1789
+ try {
1790
+ parsed = await req.json();
1791
+ } catch {
1792
+ parsed = void 0;
1793
+ }
1794
+ const body = parsed;
1795
+ if (!body?.data || typeof body.data !== "object") {
1796
+ throw NextlyError3.validation({
1797
+ errors: [
1798
+ {
1799
+ path: "data",
1800
+ code: "MISSING_FIELD",
1801
+ message: "Request body must contain a 'data' object."
1802
+ }
1803
+ ],
1804
+ logContext: { slug }
1805
+ });
1806
+ }
1807
+ return body.data;
1808
+ }
1809
+ function refusalFor(result, slug) {
1810
+ const entity = { entity: "form", slug };
1811
+ switch (result.outcome) {
1812
+ case "no-such-form":
1813
+ return NextlyError3.notFound({
1814
+ message: NO_SUCH_FORM2,
1815
+ logContext: entity
1816
+ });
1817
+ // A state conflict, not a validation failure. `validation` fixes the
1818
+ // canonical `error.message` to "Validation failed." and nests the real one
1819
+ // under `error.data.errors[0]`, so a client reading the documented envelope
1820
+ // would be handed the author's explanation and never show it.
1821
+ case "closed":
1822
+ return NextlyError3.conflict({
1823
+ reason: "state",
1824
+ message: result.error ?? NO_SUCH_FORM2,
1825
+ logContext: { ...entity, reason: "closed" }
1826
+ });
1827
+ case "duplicate":
1828
+ return NextlyError3.conflict({
1829
+ reason: "state",
1830
+ message: result.error ?? "You have already submitted this form.",
1831
+ logContext: { ...entity, reason: "duplicate" }
1832
+ });
1833
+ // The codes come from the issue list, not from the message map beside it.
1834
+ // A client tells "you left this blank" from "this is not an email" by the
1835
+ // code, and reporting every failure as `INVALID` stops that working while
1836
+ // looking correct in a status-only test.
1837
+ case "invalid":
1838
+ return NextlyError3.validation({
1839
+ errors: (result.validationIssues ?? []).map((issue) => ({
1840
+ path: issue.path,
1841
+ code: issue.code,
1842
+ message: issue.message
1843
+ })),
1844
+ logContext: { slug }
1845
+ });
1846
+ // A typed refusal is re-thrown as itself. The collection may refuse a write
1847
+ // for a reason its host chose, and those carry their own status and field
1848
+ // detail; answering all of them with one internal error tells a visitor the
1849
+ // server broke when their submission was in fact answered deliberately.
1850
+ // Anything untyped stays a 500, which is what an unexpected failure is.
1851
+ default:
1852
+ if (result.cause instanceof NextlyError3) return result.cause;
1853
+ return NextlyError3.internal({
1854
+ logContext: { ...entity, reason: "submission-failed" }
1855
+ });
1856
+ }
1857
+ }
1858
+ async function acceptSubmission(req, ctx, config, slug) {
1859
+ const data = await readSubmittedData(req, slug);
1860
+ const result = await submitForm(
1861
+ {
1862
+ formSlug: slug,
1863
+ data,
1864
+ // The visitor's language, from `?locale=` on the submit URL — the same
1865
+ // place core's own routes read a locale on the wire — so a picked
1866
+ // redirect page answers with its URL in that language. A form on a
1867
+ // French page posts to `/api/forms/contact/submit?locale=fr` and is
1868
+ // sent to `/merci`; one that names no locale is answered in the
1869
+ // default, as before. An unconfigured code reads as the default rather
1870
+ // than refusing the submission: the visitor filled the form in, and a
1871
+ // wrong query string is not a reason to lose what they wrote.
1872
+ locale: new URL(req.url).searchParams.get("locale") || void 0,
1873
+ // The request itself, so the write seam resolves the client under this
1874
+ // deployment's proxy-trust settings and judges this submission on the
1875
+ // same facts as one arriving through any other door.
1876
+ request: req,
1877
+ // The address for the audit column and the single-submission check,
1878
+ // resolved by the core rather than read off a header here, which would
1879
+ // record whatever the sender chose to claim and let one visitor pass as
1880
+ // many.
1881
+ metadata: {
1882
+ ipAddress: trustedClientIp(req) ?? void 0,
1883
+ userAgent: req.headers.get("user-agent") ?? void 0
1884
+ },
1885
+ access: PUBLIC
1886
+ },
1887
+ {
1888
+ pluginContext: ctx,
1889
+ pluginConfig: configForInstall(config, ctx.self)
1890
+ }
1891
+ );
1892
+ if (result.outcome !== "accepted") throw refusalFor(result, slug);
1893
+ return respondAction(
1894
+ result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,
1895
+ {
1896
+ ...result.submission ? { submissionId: result.submission.id } : {},
1897
+ // Forwarded because this route is the only thing that can act on it. A
1898
+ // form configured to redirect on success resolves one here, and core's
1899
+ // endpoint dropped it, so the setting did nothing for any client posting
1900
+ // over HTTP. Additive, and present on a flagged submission too, so it
1901
+ // cannot be diffed against an accepted one.
1902
+ ...result.redirect ? { redirect: result.redirect } : {}
1903
+ },
1904
+ { status: 201 }
1905
+ );
1906
+ }
1907
+ function publicFormRoutes(config) {
1908
+ return [
1909
+ {
1910
+ method: "GET",
1911
+ path: "/forms",
1912
+ mount: "root",
1913
+ public: true,
1914
+ // Collection documents, so their stored timestamps are presented in the
1915
+ // installation's timezone exactly as the built-in read did.
1916
+ formatTimestamps: true,
1917
+ handler: (req, ctx) => listPublishedForms(req, ctx, config)
1918
+ },
1919
+ {
1920
+ method: "GET",
1921
+ path: "/forms/:slug",
1922
+ mount: "root",
1923
+ public: true,
1924
+ formatTimestamps: true,
1925
+ handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config)
1926
+ },
1927
+ {
1928
+ method: "POST",
1929
+ path: "/forms/:slug/submit",
1930
+ mount: "root",
1931
+ public: true,
1932
+ handler: (req, ctx) => acceptSubmission(req, ctx, config, ctx.params.slug)
1933
+ }
1934
+ ];
1935
+ }
1936
+
1937
+ // src/plugin.ts
1938
+ var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
1939
+ "../package.json"
1940
+ );
1941
+ function resolveConfig(options) {
1942
+ const formOverrides = options.formOverrides || {};
1943
+ const submissionOverrides = options.formSubmissionOverrides || {};
1944
+ return {
1945
+ formOverrides: {
1946
+ ...formOverrides,
1947
+ slug: formOverrides.slug || "forms",
1948
+ labels: {
1949
+ singular: formOverrides.labels?.singular || "Form",
1950
+ plural: formOverrides.labels?.plural || "Forms"
1951
+ }
1952
+ },
1953
+ formSubmissionOverrides: {
1954
+ ...submissionOverrides,
1955
+ slug: submissionOverrides.slug || "form-submissions",
1956
+ labels: {
1957
+ singular: submissionOverrides.labels?.singular || "Submission",
1958
+ plural: submissionOverrides.labels?.plural || "Submissions"
1959
+ }
1960
+ },
1961
+ fields: {
1962
+ text: options.fields?.text ?? true,
1963
+ email: options.fields?.email ?? true,
1964
+ number: options.fields?.number ?? true,
1965
+ phone: options.fields?.phone ?? true,
1966
+ url: options.fields?.url ?? true,
1967
+ textarea: options.fields?.textarea ?? true,
1968
+ select: options.fields?.select ?? true,
1969
+ checkbox: options.fields?.checkbox ?? true,
1970
+ radio: options.fields?.radio ?? true,
1971
+ file: options.fields?.file ?? true,
1972
+ date: options.fields?.date ?? true,
1973
+ time: options.fields?.time ?? true,
1974
+ hidden: options.fields?.hidden ?? true
1975
+ },
1976
+ redirectRelationships: normalizeRedirectRelationships(
1977
+ options.redirectRelationships
1978
+ ),
1979
+ // Filled at `init` from each collection's own metadata. Empty until then,
1980
+ // and a slug that stays absent reads as unknown rather than as not
1981
+ // localized — see `ResolvedFormBuilderConfig`.
1982
+ redirectTargetLocalization: {},
1983
+ beforeEmail: options.beforeEmail,
1984
+ notifications: {
1985
+ defaultFrom: options.notifications?.defaultFrom,
1986
+ defaultToEmail: options.notifications?.defaultToEmail,
1987
+ enabled: options.notifications?.enabled ?? true
1988
+ },
1989
+ spamProtection: {
1990
+ honeypot: options.spamProtection?.honeypot ?? true,
1991
+ recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
1992
+ rateLimit: {
1993
+ maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
1994
+ windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
1995
+ }
1996
+ },
1997
+ uploads: {
1998
+ maxFileSize: options.uploads?.maxFileSize ?? 10485760,
1999
+ // 10 MB
2000
+ allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
2001
+ "image/*",
2002
+ "application/pdf",
2003
+ "text/*"
2004
+ ],
2005
+ uploadCollection: options.uploads?.uploadCollection ?? "media"
2006
+ },
2007
+ features: {
2008
+ builder: options.features?.builder ?? true,
2009
+ conditionalLogic: options.features?.conditionalLogic ?? true,
2010
+ fileUploads: options.features?.fileUploads ?? true
2011
+ }
2012
+ };
2013
+ }
2014
+ function formBuilder(options = {}) {
2015
+ const resolvedConfig = resolveConfig(options);
2016
+ const formsCol = formsCollection(resolvedConfig);
2017
+ const submissionsCol = submissionsCollection(resolvedConfig);
2018
+ const plugin = definePlugin({
2019
+ name: "@nextlyhq/plugin-form-builder",
2020
+ version: PLUGIN_VERSION,
2021
+ nextly: ">=0.0.2-alpha.21",
2022
+ // Identity metadata for the admin plugins page, mirroring package.json.
2023
+ author: "Nextly",
2024
+ homepage: "https://nextlyhq.com",
2025
+ repository: "https://github.com/nextlyhq/nextly",
2026
+ license: "MIT",
2027
+ category: "forms",
2028
+ tags: ["forms", "submissions", "email-notifications"],
2029
+ // Declarative schema: the merged pipeline folds these into the
2030
+ // app schema — no manual `setup()` append needed. Just register the plugin.
2031
+ contributes: {
2032
+ collections: [formsCol, submissionsCol],
2033
+ // Custom permission — gates submission export beyond CRUD. The
2034
+ // canonical example third-party plugin authors copy.
2035
+ permissions: [
2036
+ {
2037
+ action: "export",
2038
+ resource: "submissions",
2039
+ label: "Export Submissions",
2040
+ description: "Export form submissions as CSV/JSON",
2041
+ // No `group`: the admin already files this under the plugin that
2042
+ // declared it, so naming the plugin again would nest it inside
2043
+ // itself. `group` is for a plugin with enough permissions to sort
2044
+ // its own into headings, which one is not.
2045
+ //
2046
+ // `danger` because the point of the permission is to take
2047
+ // submissions — names, emails, whatever a form asked for — out of
2048
+ // the site in a file.
2049
+ danger: true
2050
+ }
2051
+ ],
2052
+ // HTTP route — exported at
2053
+ // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
2054
+ // default: gated by the custom `export-submissions` permission. Reads
2055
+ // via the secure-by-default service path as the authed user and
2056
+ // resolves its OWN slug through `ctx.self`. The canonical
2057
+ // contributes.routes example for third-party authors.
2058
+ routes: [
2059
+ // The public form endpoints, at the addresses core used to serve
2060
+ // before this plugin took them over. Root-mounted, so no caller's URL
2061
+ // changes; see `routes/public-forms`.
2062
+ ...publicFormRoutes(resolvedConfig),
2063
+ {
2064
+ method: "GET",
2065
+ path: "/submissions/export",
2066
+ requiredPermission: "export-submissions",
2067
+ handler: async (req, ctx) => {
2068
+ const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
2069
+ const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
2070
+ const url2 = new URL(req.url);
2071
+ const format = url2.searchParams.get("format") ?? "json";
2072
+ const formId = url2.searchParams.get("form");
2073
+ const status = url2.searchParams.get("status");
2074
+ const where = {};
2075
+ if (formId) where.form = { equals: formId };
2076
+ if (status) where.status = { equals: status };
2077
+ else where.status = { not_equals: "spam" };
2078
+ const opts = { as: "user", user: ctx.user ?? void 0 };
2079
+ let form;
2080
+ if (format === "csv") {
2081
+ if (!formId) {
2082
+ return Response.json(
2083
+ {
2084
+ error: {
2085
+ code: "VALIDATION_ERROR",
2086
+ message: "CSV export requires a form parameter."
2087
+ }
2088
+ },
2089
+ { status: 400 }
2090
+ );
2091
+ }
2092
+ const formsSlugDeclared = resolvedConfig.formOverrides.slug;
2093
+ const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
2094
+ const formResult = await ctx.services.collections.listEntries(
2095
+ formsSlug,
2096
+ { where: { id: { equals: formId } } },
1367
2097
  opts
1368
2098
  );
1369
2099
  form = formResult.data[0];
@@ -1541,30 +2271,11 @@ function formBuilder(options = {}) {
1541
2271
  }
1542
2272
  return ctx.data;
1543
2273
  });
1544
- nextly.hooks.on("afterRead", formsSlug, async (context) => {
1545
- const data = context.data;
1546
- const records = Array.isArray(data) ? data : data ? [data] : [];
1547
- await Promise.all(
1548
- records.map(async (record) => {
1549
- const form = record;
1550
- if (typeof form.id !== "string") return;
1551
- try {
1552
- form.submissionCount = await nextly.services.collections.count(
1553
- submissionSlug,
1554
- {
1555
- where: {
1556
- form: { equals: form.id },
1557
- status: { not_equals: "spam" }
1558
- }
1559
- },
1560
- { as: "system" }
1561
- );
1562
- } catch {
1563
- form.submissionCount = 0;
1564
- }
1565
- })
1566
- );
1567
- });
2274
+ nextly.hooks.on(
2275
+ "afterRead",
2276
+ formsSlug,
2277
+ (context) => injectSubmissionCount(context, submissionSlug, nextly)
2278
+ );
1568
2279
  }
1569
2280
  });
1570
2281
  return {
@@ -1690,7 +2401,7 @@ function submittedPayload(raw) {
1690
2401
  } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1691
2402
  return raw;
1692
2403
  }
1693
- throw NextlyError2.validation({
2404
+ throw NextlyError4.validation({
1694
2405
  errors: [
1695
2406
  {
1696
2407
  path: "data",
@@ -1720,30 +2431,39 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
1720
2431
  submission.data !== void 0 ? submission.data : stored?.data
1721
2432
  );
1722
2433
  const marks = submissionMarks(submission);
1723
- const fields = await fieldsToCheckAgainst(marks, formsSlug, formId, nextly);
2434
+ const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
2435
+ const fields = form.fields;
2436
+ const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly) : void 0;
2437
+ if (spam?.reason === "rate_limit") {
2438
+ throw NextlyError4.rateLimited();
2439
+ }
1724
2440
  const prepared = prepareSubmission({
1725
2441
  data: incoming,
1726
2442
  fields,
1727
- validate: marks?.keepAsEvidence !== true
2443
+ validate: marks?.keepAsEvidence !== true && spam === void 0
1728
2444
  });
1729
2445
  if (prepared.validationErrors) {
1730
- throw NextlyError2.validation({
2446
+ throw NextlyError4.validation({
1731
2447
  errors: Object.entries(prepared.validationErrors).map(
1732
2448
  ([path, message]) => ({ path, code: "INVALID", message })
1733
2449
  )
1734
2450
  });
1735
2451
  }
1736
2452
  ctx.data = { ...submission, data: prepared.data };
2453
+ if (spam) {
2454
+ ctx.data.status = "spam";
2455
+ ctx.data.spamReason = spam.reason ?? null;
2456
+ }
1737
2457
  if (ctx.operation !== "create" && submission.data === void 0) {
1738
2458
  stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
1739
2459
  }
1740
2460
  return ctx.data;
1741
2461
  }
1742
- async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
2462
+ async function formToCheckAgainst(marks, formsSlug, formId, nextly) {
1743
2463
  const handedOver = marks?.form?.id === formId ? marks.form : null;
1744
2464
  const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
1745
2465
  if (!form || !Array.isArray(form.fields)) {
1746
- throw NextlyError2.validation({
2466
+ throw NextlyError4.validation({
1747
2467
  errors: [
1748
2468
  {
1749
2469
  path: "form",
@@ -1753,7 +2473,32 @@ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
1753
2473
  ]
1754
2474
  });
1755
2475
  }
1756
- return form.fields;
2476
+ return form;
2477
+ }
2478
+ function rateLimitKeyFor(form) {
2479
+ const { id } = form;
2480
+ return typeof id === "string" && id.length > 0 ? id : "unidentified-form";
2481
+ }
2482
+ async function judgeSubmission(payload, form, http, nextly) {
2483
+ const config = getFormBuilderConfig(nextly);
2484
+ if (!config) return void 0;
2485
+ const declared = new Set(
2486
+ (Array.isArray(form.fields) ? form.fields : []).map((field) => field.name).filter((name) => typeof name === "string")
2487
+ );
2488
+ const undeclared = Object.fromEntries(
2489
+ Object.entries(payload).filter(([key]) => !declared.has(key))
2490
+ );
2491
+ const settings = form.settings ?? {};
2492
+ const verdict = await checkSpam({
2493
+ data: undeclared,
2494
+ ipAddress: http.ip ?? void 0,
2495
+ formSlug: rateLimitKeyFor(form),
2496
+ config: {
2497
+ honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,
2498
+ rateLimit: config.spamProtection.rateLimit
2499
+ }
2500
+ });
2501
+ return verdict.isSpam ? verdict : void 0;
1757
2502
  }
1758
2503
  function stampDerivedEdit(row, user, before, after) {
1759
2504
  if (sameSubmittedPayload(before, after)) return;
@@ -1836,16 +2581,42 @@ async function handleSubmissionCreated(context, config, nextly) {
1836
2581
  }
1837
2582
  }
1838
2583
  }
2584
+ async function injectSubmissionCount(context, submissionSlug, nextly) {
2585
+ const hook = context;
2586
+ if (hook.context?.[SCHEMA_ONLY_READ] === true) return;
2587
+ const records = Array.isArray(hook.data) ? hook.data : hook.data ? [hook.data] : [];
2588
+ await Promise.all(
2589
+ records.map(async (record) => {
2590
+ const form = record;
2591
+ if (typeof form.id !== "string") return;
2592
+ try {
2593
+ form.submissionCount = await nextly.services.collections.count(
2594
+ submissionSlug,
2595
+ {
2596
+ where: {
2597
+ form: { equals: form.id },
2598
+ status: { not_equals: "spam" }
2599
+ }
2600
+ },
2601
+ { as: "system" }
2602
+ );
2603
+ } catch {
2604
+ form.submissionCount = 0;
2605
+ }
2606
+ })
2607
+ );
2608
+ }
2609
+ var SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
1839
2610
  async function fetchParentForm(formsSlug, formId, nextly) {
1840
2611
  try {
1841
2612
  const form = await nextly.services.collections.findEntryById(
1842
2613
  formsSlug,
1843
2614
  formId,
1844
- { as: "system" }
2615
+ { as: "system", context: { [SCHEMA_ONLY_READ]: true } }
1845
2616
  );
1846
2617
  return form;
1847
2618
  } catch (err) {
1848
- if (!NextlyError2.isNotFound(err)) throw err;
2619
+ if (!NextlyError4.isNotFound(err)) throw err;
1849
2620
  nextly.logger.error?.("Form Builder: form not found", {
1850
2621
  formId,
1851
2622
  error: err instanceof Error ? err.message : String(err)
@@ -1909,834 +2680,383 @@ function validateSlug(slug, errors) {
1909
2680
  code: "SLUG_TOO_LONG"
1910
2681
  });
1911
2682
  }
1912
- if (!SLUG_PATTERN.test(slug)) {
1913
- errors.push({
1914
- path,
1915
- message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1916
- code: "SLUG_INVALID_FORMAT"
1917
- });
1918
- }
1919
- if (isReservedSlug(slug)) {
1920
- errors.push({
1921
- path,
1922
- message: `Form slug '${slug}' is reserved and cannot be used`,
1923
- code: "SLUG_RESERVED"
1924
- });
1925
- }
1926
- }
1927
- function validateFieldName(name, path, errors, seenNames) {
1928
- if (!name) {
1929
- errors.push({
1930
- path: `${path}.name`,
1931
- message: "Field name is required",
1932
- code: "FIELD_NAME_REQUIRED"
1933
- });
1934
- return;
1935
- }
1936
- if (typeof name !== "string") {
1937
- errors.push({
1938
- path: `${path}.name`,
1939
- message: "Field name must be a string",
1940
- code: "FIELD_NAME_REQUIRED"
1941
- });
1942
- return;
1943
- }
1944
- if (!FIELD_NAME_PATTERN.test(name)) {
1945
- errors.push({
1946
- path: `${path}.name`,
1947
- message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1948
- code: "FIELD_NAME_INVALID_FORMAT"
1949
- });
1950
- }
1951
- const nameLower = name.toLowerCase();
1952
- if (seenNames.has(nameLower)) {
1953
- errors.push({
1954
- path: `${path}.name`,
1955
- message: `Duplicate field name '${name}'`,
1956
- code: "FIELD_NAME_DUPLICATE"
1957
- });
1958
- } else {
1959
- seenNames.add(nameLower);
1960
- }
1961
- }
1962
- function validateFieldLabel(label, path, errors) {
1963
- if (!label || typeof label === "string" && label.trim() === "") {
1964
- errors.push({
1965
- path: `${path}.label`,
1966
- message: "Field label is required",
1967
- code: "FIELD_LABEL_REQUIRED"
1968
- });
1969
- }
1970
- }
1971
- function validateOptions(field, path, errors) {
1972
- const fieldWithOptions = field;
1973
- const options = fieldWithOptions.options;
1974
- if (!options) {
1975
- errors.push({
1976
- path: `${path}.options`,
1977
- message: `${field.type} field must have an 'options' array`,
1978
- code: "OPTIONS_REQUIRED"
1979
- });
1980
- return;
1981
- }
1982
- if (!Array.isArray(options)) {
1983
- errors.push({
1984
- path: `${path}.options`,
1985
- message: `${field.type} field 'options' must be an array`,
1986
- code: "OPTIONS_REQUIRED"
1987
- });
1988
- return;
1989
- }
1990
- if (options.length === 0) {
1991
- errors.push({
1992
- path: `${path}.options`,
1993
- message: `${field.type} field must have at least one option`,
1994
- code: "OPTIONS_EMPTY"
1995
- });
1996
- }
1997
- }
1998
- function validateConditionalLogic(field, path, errors) {
1999
- const { conditionalLogic } = field;
2000
- if (!conditionalLogic || !conditionalLogic.enabled) {
2001
- return;
2002
- }
2003
- if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
2004
- errors.push({
2005
- path: `${path}.conditionalLogic.conditions`,
2006
- message: "Conditional logic must have at least one condition",
2007
- code: "CONDITIONS_REQUIRED"
2008
- });
2009
- return;
2010
- }
2011
- conditionalLogic.conditions.forEach((condition, index) => {
2012
- const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
2013
- if (!condition.field) {
2014
- errors.push({
2015
- path: `${conditionPath}.field`,
2016
- message: "Condition must specify a field name",
2017
- code: "CONDITION_FIELD_REQUIRED"
2018
- });
2019
- }
2020
- if (!condition.comparison) {
2021
- errors.push({
2022
- path: `${conditionPath}.comparison`,
2023
- message: "Condition must specify a comparison operator",
2024
- code: "CONDITION_COMPARISON_REQUIRED"
2025
- });
2026
- }
2027
- });
2028
- }
2029
- function validateFieldByType(field, path, errors) {
2030
- switch (field.type) {
2031
- case "select":
2032
- case "radio":
2033
- validateOptions(field, path, errors);
2034
- break;
2035
- case "number": {
2036
- const numberField = field;
2037
- if (numberField.validation) {
2038
- const { min, max } = numberField.validation;
2039
- if (min !== void 0 && max !== void 0 && min > max) {
2040
- errors.push({
2041
- path: `${path}.validation`,
2042
- message: "Min value cannot be greater than max value",
2043
- code: "MIN_GREATER_THAN_MAX"
2044
- });
2045
- }
2046
- }
2047
- break;
2048
- }
2049
- case "text":
2050
- case "textarea": {
2051
- const textField = field;
2052
- if (textField.validation) {
2053
- const { minLength, maxLength } = textField.validation;
2054
- if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
2055
- errors.push({
2056
- path: `${path}.validation`,
2057
- message: "Min length cannot be greater than max length",
2058
- code: "MIN_LENGTH_GREATER_THAN_MAX"
2059
- });
2060
- }
2061
- }
2062
- break;
2063
- }
2064
- case "file": {
2065
- const fileField = field;
2066
- if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
2067
- errors.push({
2068
- path: `${path}.maxFileSize`,
2069
- message: "File maxFileSize must be a positive number",
2070
- code: "FILE_MAX_SIZE_INVALID"
2071
- });
2072
- }
2073
- break;
2074
- }
2075
- }
2076
- }
2077
- function validateField(field, path, errors, seenNames) {
2078
- if (!field || typeof field !== "object") {
2079
- return;
2080
- }
2081
- const f = field;
2082
- const fieldType = f.type;
2083
- if (!fieldType) {
2084
- errors.push({
2085
- path: `${path}.type`,
2086
- message: "Field type is required",
2087
- code: "FIELD_TYPE_REQUIRED"
2088
- });
2089
- return;
2090
- }
2091
- const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
2092
- if (!isValidType) {
2093
- let fieldTypeRepr;
2094
- if (typeof fieldType === "object" && fieldType !== null) {
2095
- fieldTypeRepr = JSON.stringify(fieldType);
2096
- } else {
2097
- fieldTypeRepr = String(fieldType);
2098
- }
2099
- errors.push({
2100
- path: `${path}.type`,
2101
- message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
2102
- code: "FIELD_TYPE_INVALID"
2103
- });
2104
- return;
2105
- }
2106
- validateFieldName(f.name, path, errors, seenNames);
2107
- if (fieldType !== "hidden") {
2108
- validateFieldLabel(f.label, path, errors);
2109
- }
2110
- validateFieldByType(field, path, errors);
2111
- if (f.conditionalLogic) {
2112
- validateConditionalLogic(field, path, errors);
2113
- }
2114
- }
2115
- function validateFieldsArray(fields, basePath, errors) {
2116
- const seenNames = /* @__PURE__ */ new Set();
2117
- fields.forEach((field, index) => {
2118
- const fieldPath = `${basePath}[${index}]`;
2119
- validateField(field, fieldPath, errors, seenNames);
2120
- });
2121
- }
2122
- function validateFields(fields, errors) {
2123
- const path = "fields";
2124
- if (!fields) {
2125
- errors.push({
2126
- path,
2127
- message: "Form must have at least one field",
2128
- code: "FIELDS_REQUIRED"
2129
- });
2130
- return;
2131
- }
2132
- if (!Array.isArray(fields)) {
2683
+ if (!SLUG_PATTERN.test(slug)) {
2133
2684
  errors.push({
2134
2685
  path,
2135
- message: "Form fields must be an array",
2136
- code: "FIELDS_INVALID_TYPE"
2686
+ message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
2687
+ code: "SLUG_INVALID_FORMAT"
2137
2688
  });
2138
- return;
2139
2689
  }
2140
- if (fields.length === 0) {
2690
+ if (isReservedSlug(slug)) {
2141
2691
  errors.push({
2142
2692
  path,
2143
- message: "Form must have at least one field",
2144
- code: "FIELDS_EMPTY"
2693
+ message: `Form slug '${slug}' is reserved and cannot be used`,
2694
+ code: "SLUG_RESERVED"
2145
2695
  });
2146
- return;
2147
2696
  }
2148
- validateFieldsArray(fields, path, errors);
2149
2697
  }
2150
- function validateSettings(settings, errors) {
2151
- if (!settings) {
2698
+ function validateFieldName(name, path, errors, seenNames) {
2699
+ if (!name) {
2700
+ errors.push({
2701
+ path: `${path}.name`,
2702
+ message: "Field name is required",
2703
+ code: "FIELD_NAME_REQUIRED"
2704
+ });
2152
2705
  return;
2153
2706
  }
2154
- if (typeof settings !== "object" || Array.isArray(settings)) {
2707
+ if (typeof name !== "string") {
2155
2708
  errors.push({
2156
- path: "settings",
2157
- message: "Form settings must be an object",
2158
- code: "SETTINGS_INVALID_TYPE"
2709
+ path: `${path}.name`,
2710
+ message: "Field name must be a string",
2711
+ code: "FIELD_NAME_REQUIRED"
2159
2712
  });
2160
2713
  return;
2161
2714
  }
2162
- const s = settings;
2163
- if (s.confirmationType === "redirect") {
2164
- if (!s.redirectUrl && !s.redirectRelation) {
2165
- errors.push({
2166
- path: "settings.redirectUrl",
2167
- message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
2168
- code: "REDIRECT_URL_REQUIRED"
2169
- });
2170
- }
2715
+ if (!FIELD_NAME_PATTERN.test(name)) {
2716
+ errors.push({
2717
+ path: `${path}.name`,
2718
+ message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
2719
+ code: "FIELD_NAME_INVALID_FORMAT"
2720
+ });
2171
2721
  }
2172
- if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
2722
+ const nameLower = name.toLowerCase();
2723
+ if (seenNames.has(nameLower)) {
2173
2724
  errors.push({
2174
- path: "settings.redirectPage",
2175
- message: "A page is required when confirmation type is 'relationship'",
2176
- code: "REDIRECT_PAGE_REQUIRED"
2725
+ path: `${path}.name`,
2726
+ message: `Duplicate field name '${name}'`,
2727
+ code: "FIELD_NAME_DUPLICATE"
2177
2728
  });
2729
+ } else {
2730
+ seenNames.add(nameLower);
2178
2731
  }
2179
2732
  }
2180
- function namesADocument(value) {
2181
- if (typeof value === "string") return value.length > 0;
2182
- return parseRedirectReference(value, []) !== null;
2733
+ function validateFieldLabel(label, path, errors) {
2734
+ if (!label || typeof label === "string" && label.trim() === "") {
2735
+ errors.push({
2736
+ path: `${path}.label`,
2737
+ message: "Field label is required",
2738
+ code: "FIELD_LABEL_REQUIRED"
2739
+ });
2740
+ }
2183
2741
  }
2184
- function validateAccess(access, errors) {
2185
- if (!access) {
2742
+ function validateOptions(field, path, errors) {
2743
+ const fieldWithOptions = field;
2744
+ const options = fieldWithOptions.options;
2745
+ if (!options) {
2746
+ errors.push({
2747
+ path: `${path}.options`,
2748
+ message: `${field.type} field must have an 'options' array`,
2749
+ code: "OPTIONS_REQUIRED"
2750
+ });
2186
2751
  return;
2187
2752
  }
2188
- if (typeof access !== "object" || Array.isArray(access)) {
2753
+ if (!Array.isArray(options)) {
2189
2754
  errors.push({
2190
- path: "access",
2191
- message: "Access control must be an object",
2192
- code: "ACCESS_INVALID_TYPE"
2755
+ path: `${path}.options`,
2756
+ message: `${field.type} field 'options' must be an array`,
2757
+ code: "OPTIONS_REQUIRED"
2193
2758
  });
2194
2759
  return;
2195
2760
  }
2196
- const accessObj = access;
2197
- const validAccessKeys = ["submit", "read"];
2198
- for (const key of validAccessKeys) {
2199
- const fn = accessObj[key];
2200
- if (fn !== void 0 && typeof fn !== "function") {
2201
- errors.push({
2202
- path: `access.${key}`,
2203
- message: `Access control '${key}' must be a function`,
2204
- code: "ACCESS_FUNCTION_INVALID"
2205
- });
2206
- }
2207
- }
2208
- }
2209
- function validateFormConfig(config) {
2210
- const errors = [];
2211
- validateSlug(config.slug, errors);
2212
- validateFields(config.fields, errors);
2213
- validateSettings(config.settings, errors);
2214
- validateAccess(config.access, errors);
2215
- return {
2216
- valid: errors.length === 0,
2217
- errors
2218
- };
2219
- }
2220
- function assertValidFormConfig(config) {
2221
- const result = validateFormConfig(config);
2222
- if (!result.valid) {
2223
- const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
2224
- throw new Error(
2225
- `Invalid form config for '${config.slug || "unknown"}':
2226
- ${errorMessages}`
2227
- );
2228
- }
2229
- }
2230
-
2231
- // src/config/field-helpers.ts
2232
- var option = (label, value) => ({
2233
- label,
2234
- value: value ?? label.toLowerCase().replace(/\s+/g, "_")
2235
- });
2236
- var text3 = (config) => ({
2237
- ...config,
2238
- type: "text"
2239
- });
2240
- var email = (config) => ({
2241
- ...config,
2242
- type: "email"
2243
- });
2244
- var phone = (config) => ({
2245
- ...config,
2246
- type: "phone"
2247
- });
2248
- var url = (config) => ({
2249
- ...config,
2250
- type: "url"
2251
- });
2252
- var textarea3 = (config) => ({
2253
- ...config,
2254
- type: "textarea"
2255
- });
2256
- var number = (config) => ({
2257
- ...config,
2258
- type: "number"
2259
- });
2260
- var select3 = (config) => ({
2261
- ...config,
2262
- type: "select"
2263
- });
2264
- var checkbox2 = (config) => ({
2265
- ...config,
2266
- type: "checkbox"
2267
- });
2268
- var radio = (config) => ({
2269
- ...config,
2270
- type: "radio"
2271
- });
2272
- var file = (config) => ({
2273
- ...config,
2274
- type: "file"
2275
- });
2276
- var date3 = (config) => ({
2277
- ...config,
2278
- type: "date"
2279
- });
2280
- var time = (config) => ({
2281
- ...config,
2282
- type: "time"
2283
- });
2284
- var hidden = (config) => ({
2285
- ...config,
2286
- type: "hidden"
2287
- });
2288
-
2289
- // src/handlers/submit-form.ts
2290
- import { formAvailability, NO_SUCH_FORM } from "nextly";
2291
-
2292
- // src/handlers/spam-detection.ts
2293
- var rateLimitStore = /* @__PURE__ */ new Map();
2294
- async function checkSpam(options) {
2295
- const { data, ipAddress, formSlug, config } = options;
2296
- if (config.honeypot) {
2297
- const honeypotResult = checkHoneypot(data);
2298
- if (honeypotResult.isSpam) {
2299
- return honeypotResult;
2300
- }
2301
- }
2302
- if (config.rateLimit && ipAddress) {
2303
- const rateLimitResult = checkRateLimit(
2304
- ipAddress,
2305
- formSlug,
2306
- config.rateLimit
2307
- );
2308
- if (rateLimitResult.isSpam) {
2309
- return rateLimitResult;
2310
- }
2311
- }
2312
- return { isSpam: false };
2313
- }
2314
- var HONEYPOT_FIELDS = [
2315
- "__honeypot",
2316
- "_honeypot",
2317
- "honeypot",
2318
- "__hp",
2319
- "_hp",
2320
- "website",
2321
- // Common honeypot field name
2322
- "url_field",
2323
- // Another common honeypot
2324
- "fax_number"
2325
- // Rarely used by humans
2326
- ];
2327
- function checkHoneypot(data) {
2328
- for (const fieldName of HONEYPOT_FIELDS) {
2329
- const value = data[fieldName];
2330
- if (value !== void 0 && value !== null && value !== "") {
2331
- let raw;
2332
- if (typeof value === "object") {
2333
- raw = JSON.stringify(value);
2334
- } else {
2335
- raw = String(value);
2336
- }
2337
- const stringValue = raw.trim();
2338
- if (stringValue.length > 0) {
2339
- return {
2340
- isSpam: true,
2341
- reason: "honeypot",
2342
- details: `Honeypot field "${fieldName}" was filled`
2343
- };
2344
- }
2345
- }
2346
- }
2347
- return { isSpam: false };
2348
- }
2349
- function checkRateLimit(ipAddress, formSlug, config) {
2350
- const key = `${formSlug}:${ipAddress}`;
2351
- const now = Date.now();
2352
- const entry = rateLimitStore.get(key);
2353
- if (entry) {
2354
- if (now - entry.timestamp > config.windowMs) {
2355
- rateLimitStore.set(key, { count: 1, timestamp: now });
2356
- return { isSpam: false };
2357
- }
2358
- if (entry.count >= config.maxSubmissions) {
2359
- return {
2360
- isSpam: true,
2361
- reason: "rate_limit",
2362
- details: `Rate limit exceeded: ${entry.count}/${config.maxSubmissions} in ${config.windowMs}ms`
2363
- };
2364
- }
2365
- rateLimitStore.set(key, {
2366
- count: entry.count + 1,
2367
- timestamp: entry.timestamp
2761
+ if (options.length === 0) {
2762
+ errors.push({
2763
+ path: `${path}.options`,
2764
+ message: `${field.type} field must have at least one option`,
2765
+ code: "OPTIONS_EMPTY"
2368
2766
  });
2369
- } else {
2370
- rateLimitStore.set(key, { count: 1, timestamp: now });
2371
2767
  }
2372
- return { isSpam: false };
2373
- }
2374
- function cleanupRateLimitStore(maxAgeMs = 5 * 60 * 1e3) {
2375
- const now = Date.now();
2376
- let removed = 0;
2377
- for (const [key, entry] of rateLimitStore.entries()) {
2378
- if (now - entry.timestamp > maxAgeMs) {
2379
- rateLimitStore.delete(key);
2380
- removed++;
2381
- }
2382
- }
2383
- return removed;
2384
2768
  }
2385
- function getRateLimitStoreSize() {
2386
- return rateLimitStore.size;
2387
- }
2388
- function clearRateLimitStore() {
2389
- rateLimitStore.clear();
2390
- }
2391
- function isRateLimited(ipAddress, formSlug, config) {
2392
- const key = `${formSlug}:${ipAddress}`;
2393
- const now = Date.now();
2394
- const entry = rateLimitStore.get(key);
2395
- if (!entry) {
2396
- return false;
2769
+ function validateConditionalLogic(field, path, errors) {
2770
+ const { conditionalLogic } = field;
2771
+ if (!conditionalLogic || !conditionalLogic.enabled) {
2772
+ return;
2397
2773
  }
2398
- if (now - entry.timestamp > config.windowMs) {
2399
- return false;
2774
+ if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
2775
+ errors.push({
2776
+ path: `${path}.conditionalLogic.conditions`,
2777
+ message: "Conditional logic must have at least one condition",
2778
+ code: "CONDITIONS_REQUIRED"
2779
+ });
2780
+ return;
2400
2781
  }
2401
- return entry.count >= config.maxSubmissions;
2402
- }
2403
-
2404
- // src/handlers/submit-form.ts
2405
- async function submitForm(options, context) {
2406
- const { formSlug, data, metadata } = options;
2407
- const { pluginContext, pluginConfig } = context;
2408
- const { collections } = pluginContext.services;
2409
- const { logger } = pluginContext;
2410
- try {
2411
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2412
- if (!form) {
2413
- logger.warn?.("Form submission attempted for non-existent form", {
2414
- formSlug
2782
+ conditionalLogic.conditions.forEach((condition, index) => {
2783
+ const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
2784
+ if (!condition.field) {
2785
+ errors.push({
2786
+ path: `${conditionPath}.field`,
2787
+ message: "Condition must specify a field name",
2788
+ code: "CONDITION_FIELD_REQUIRED"
2415
2789
  });
2416
- return { success: false, error: NO_SUCH_FORM };
2417
2790
  }
2418
- const availability = formAvailability(form);
2419
- if (availability.kind !== "open") {
2420
- logger.info?.("Form submission rejected - form not accepting", {
2421
- formSlug,
2422
- status: form.status,
2423
- availability: availability.kind
2791
+ if (!condition.comparison) {
2792
+ errors.push({
2793
+ path: `${conditionPath}.comparison`,
2794
+ message: "Condition must specify a comparison operator",
2795
+ code: "CONDITION_COMPARISON_REQUIRED"
2424
2796
  });
2425
- return {
2426
- success: false,
2427
- error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
2428
- };
2429
- }
2430
- const settings = normalizeFormSettings(form.settings);
2431
- if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
2432
- logger.warn?.(
2433
- "Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
2434
- { formSlug }
2435
- );
2436
2797
  }
2437
- if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
2438
- const existing = await collections.count(
2439
- pluginConfig.formSubmissionOverrides.slug,
2440
- {
2441
- where: {
2442
- form: { equals: form.id },
2443
- ipAddress: { equals: metadata.ipAddress },
2444
- status: { not_equals: "spam" }
2445
- }
2446
- },
2447
- { as: "system" }
2448
- );
2449
- if (existing > 0) {
2450
- logger.info?.("Repeat submission rejected (single-submission form)", {
2451
- formSlug,
2452
- ipAddress: metadata.ipAddress
2453
- });
2454
- return {
2455
- success: false,
2456
- error: "You have already submitted this form."
2457
- };
2798
+ });
2799
+ }
2800
+ function validateFieldByType(field, path, errors) {
2801
+ switch (field.type) {
2802
+ case "select":
2803
+ case "radio":
2804
+ validateOptions(field, path, errors);
2805
+ break;
2806
+ case "number": {
2807
+ const numberField = field;
2808
+ if (numberField.validation) {
2809
+ const { min, max } = numberField.validation;
2810
+ if (min !== void 0 && max !== void 0 && min > max) {
2811
+ errors.push({
2812
+ path: `${path}.validation`,
2813
+ message: "Min value cannot be greater than max value",
2814
+ code: "MIN_GREATER_THAN_MAX"
2815
+ });
2816
+ }
2458
2817
  }
2818
+ break;
2459
2819
  }
2460
- const declaredFieldNames = new Set(form.fields.map((field) => field.name));
2461
- const undeclaredData = Object.fromEntries(
2462
- Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
2463
- );
2464
- const spamResult = await checkSpam({
2465
- data: undeclaredData,
2466
- ipAddress: metadata?.ipAddress,
2467
- formSlug,
2468
- config: {
2469
- // Per-form overrides win where set; blank inherits the plugin config.
2470
- honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
2471
- rateLimit: pluginConfig.spamProtection.rateLimit,
2472
- recaptcha: {
2473
- ...pluginConfig.spamProtection.recaptcha,
2474
- enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
2820
+ case "text":
2821
+ case "textarea": {
2822
+ const textField = field;
2823
+ if (textField.validation) {
2824
+ const { minLength, maxLength } = textField.validation;
2825
+ if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
2826
+ errors.push({
2827
+ path: `${path}.validation`,
2828
+ message: "Min length cannot be greater than max length",
2829
+ code: "MIN_LENGTH_GREATER_THAN_MAX"
2830
+ });
2475
2831
  }
2476
2832
  }
2477
- });
2478
- if (spamResult.isSpam && spamResult.reason === "rate_limit") {
2479
- logger.info?.("Spam submission rejected (rate limit)", {
2480
- formSlug,
2481
- ipAddress: metadata?.ipAddress
2482
- });
2483
- return { success: true };
2484
- }
2485
- const isContentSpam = spamResult.isSpam;
2486
- if (isContentSpam) {
2487
- logger.info?.("Spam submission detected \u2014 storing flagged", {
2488
- formSlug,
2489
- reason: spamResult.reason,
2490
- ipAddress: metadata?.ipAddress
2491
- });
2833
+ break;
2492
2834
  }
2493
- const prepared = prepareSubmission({
2494
- data,
2495
- fields: form.fields,
2496
- validate: !isContentSpam
2497
- });
2498
- if (prepared.validationErrors) {
2499
- logger.debug?.("Form validation failed", {
2500
- formSlug,
2501
- errors: prepared.validationErrors
2502
- });
2503
- return {
2504
- success: false,
2505
- error: "Validation failed",
2506
- validationErrors: prepared.validationErrors
2507
- };
2835
+ case "file": {
2836
+ const fileField = field;
2837
+ if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
2838
+ errors.push({
2839
+ path: `${path}.maxFileSize`,
2840
+ message: "File maxFileSize must be a positive number",
2841
+ code: "FILE_MAX_SIZE_INVALID"
2842
+ });
2843
+ }
2844
+ break;
2508
2845
  }
2509
- const storedData = prepared.data;
2510
- const submissionData = {
2511
- form: form.id,
2512
- data: storedData,
2513
- status: isContentSpam ? "spam" : "new",
2514
- spamReason: isContentSpam ? spamResult.reason ?? null : null,
2515
- ipAddress: metadata?.ipAddress || null,
2516
- userAgent: metadata?.userAgent || null,
2517
- submittedAt: /* @__PURE__ */ new Date()
2518
- };
2519
- const submission = await collections.createEntry(
2520
- pluginConfig.formSubmissionOverrides.slug,
2521
- asPluginSubmission(submissionData, {
2522
- keepAsEvidence: isContentSpam,
2523
- form: { id: form.id, fields: form.fields }
2524
- }),
2525
- // Public form submission — create as system. No ambient user; an
2526
- // empty context already resolves to system, but be explicit.
2527
- { as: "system" }
2528
- );
2529
- logger.info?.("Form submission created successfully", {
2530
- formSlug,
2531
- submissionId: submission.item.id,
2532
- flaggedAsSpam: isContentSpam
2846
+ }
2847
+ }
2848
+ function validateField(field, path, errors, seenNames) {
2849
+ if (!field || typeof field !== "object") {
2850
+ return;
2851
+ }
2852
+ const f = field;
2853
+ const fieldType = f.type;
2854
+ if (!fieldType) {
2855
+ errors.push({
2856
+ path: `${path}.type`,
2857
+ message: "Field type is required",
2858
+ code: "FIELD_TYPE_REQUIRED"
2533
2859
  });
2534
- if (submission.warnings) {
2535
- logger.warn?.("Form submission side effects failed", {
2536
- formSlug,
2537
- submissionId: submission.item.id,
2538
- warnings: submission.warnings
2539
- });
2540
- }
2541
- const redirect = await resolveRedirectUrl(
2542
- form,
2543
- pluginConfig,
2544
- pluginContext
2545
- );
2546
- if (isContentSpam) {
2547
- return { success: true, redirect };
2860
+ return;
2861
+ }
2862
+ const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
2863
+ if (!isValidType) {
2864
+ let fieldTypeRepr;
2865
+ if (typeof fieldType === "object" && fieldType !== null) {
2866
+ fieldTypeRepr = JSON.stringify(fieldType);
2867
+ } else {
2868
+ fieldTypeRepr = String(fieldType);
2548
2869
  }
2549
- return {
2550
- success: true,
2551
- // The created ROW, not the envelope around it. Through the shared
2552
- // conversion, which is where the unchecked step lives and says so; the
2553
- // integration test reads `submission.id` off the result because nothing
2554
- // at compile time will.
2555
- submission: asSubmissionDocument(submission.item),
2556
- redirect
2557
- };
2558
- } catch (error) {
2559
- logger.error?.("Form submission error", {
2560
- formSlug,
2561
- error: error instanceof Error ? error.message : String(error)
2870
+ errors.push({
2871
+ path: `${path}.type`,
2872
+ message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
2873
+ code: "FIELD_TYPE_INVALID"
2562
2874
  });
2563
- return {
2564
- success: false,
2565
- error: "An error occurred processing your submission. Please try again."
2566
- };
2875
+ return;
2567
2876
  }
2568
- }
2569
- async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2570
- try {
2571
- const { collections } = pluginContext.services;
2572
- const result = await collections.listEntries(
2573
- pluginConfig.formOverrides.slug,
2574
- { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
2575
- { as: "system" }
2576
- );
2577
- const form = result.data?.[0];
2578
- return form ? asFormDocument(form) : null;
2579
- } catch {
2580
- return null;
2877
+ validateFieldName(f.name, path, errors, seenNames);
2878
+ if (fieldType !== "hidden") {
2879
+ validateFieldLabel(f.label, path, errors);
2581
2880
  }
2582
- }
2583
- async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
2584
- const settings = form.settings;
2585
- if (!settings) return void 0;
2586
- if (settings.confirmationType === "redirect" && settings.redirectUrl) {
2587
- return settings.redirectUrl;
2881
+ validateFieldByType(field, path, errors);
2882
+ if (f.conditionalLogic) {
2883
+ validateConditionalLogic(field, path, errors);
2588
2884
  }
2589
- const field = pickedDocumentField(settings);
2590
- if (!field) return void 0;
2591
- return urlForPickedDocument(
2592
- settings[field],
2593
- form,
2594
- pluginConfig,
2595
- pluginContext
2596
- );
2597
2885
  }
2598
- async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
2599
- const { logger } = pluginContext;
2600
- const patterns = pluginConfig.redirectRelationships;
2601
- const reference = parseRedirectReference(stored, Object.keys(patterns));
2602
- if (!reference) {
2603
- logger.warn?.("Form redirects to a page, but names no readable document", {
2604
- form: form.slug
2886
+ function validateFieldsArray(fields, basePath, errors) {
2887
+ const seenNames = /* @__PURE__ */ new Set();
2888
+ fields.forEach((field, index) => {
2889
+ const fieldPath = `${basePath}[${index}]`;
2890
+ validateField(field, fieldPath, errors, seenNames);
2891
+ });
2892
+ }
2893
+ function validateFields(fields, errors) {
2894
+ const path = "fields";
2895
+ if (!fields) {
2896
+ errors.push({
2897
+ path,
2898
+ message: "Form must have at least one field",
2899
+ code: "FIELDS_REQUIRED"
2605
2900
  });
2606
- return void 0;
2901
+ return;
2607
2902
  }
2608
- const pattern = patterns[reference.collection];
2609
- if (!pattern) {
2610
- logger.warn?.(
2611
- "Form redirects to a collection that is not in redirectRelationships",
2612
- { form: form.slug, collection: reference.collection }
2613
- );
2614
- return void 0;
2903
+ if (!Array.isArray(fields)) {
2904
+ errors.push({
2905
+ path,
2906
+ message: "Form fields must be an array",
2907
+ code: "FIELDS_INVALID_TYPE"
2908
+ });
2909
+ return;
2615
2910
  }
2616
- const target = await readTarget(reference, form, pluginContext);
2617
- if (!target) return void 0;
2618
- if (documentReachability(
2619
- target,
2620
- pluginConfig.redirectTargetLocalization[reference.collection]
2621
- ) === "unreachable") {
2622
- logger.warn?.("Form redirects to a page that has never been published", {
2623
- form: form.slug,
2624
- collection: reference.collection,
2625
- target: reference.id
2911
+ if (fields.length === 0) {
2912
+ errors.push({
2913
+ path,
2914
+ message: "Form must have at least one field",
2915
+ code: "FIELDS_EMPTY"
2626
2916
  });
2627
- return void 0;
2917
+ return;
2628
2918
  }
2629
- return buildUrl(pattern, target, reference.collection, form, pluginContext);
2919
+ validateFieldsArray(fields, path, errors);
2630
2920
  }
2631
- function buildUrl(pattern, target, collection, form, pluginContext) {
2632
- const { logger } = pluginContext;
2633
- try {
2634
- const url2 = applyRedirectPattern(pattern, target);
2635
- if (!url2) {
2636
- logger.warn?.(
2637
- "Form redirect pattern could not be filled from the target document",
2638
- { form: form.slug, collection }
2639
- );
2921
+ function validateSettings(settings, errors) {
2922
+ if (!settings) {
2923
+ return;
2924
+ }
2925
+ if (typeof settings !== "object" || Array.isArray(settings)) {
2926
+ errors.push({
2927
+ path: "settings",
2928
+ message: "Form settings must be an object",
2929
+ code: "SETTINGS_INVALID_TYPE"
2930
+ });
2931
+ return;
2932
+ }
2933
+ const s = settings;
2934
+ if (s.confirmationType === "redirect") {
2935
+ if (!s.redirectUrl && !s.redirectRelation) {
2936
+ errors.push({
2937
+ path: "settings.redirectUrl",
2938
+ message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
2939
+ code: "REDIRECT_URL_REQUIRED"
2940
+ });
2640
2941
  }
2641
- return url2;
2642
- } catch (error) {
2643
- logger.warn?.("Form redirect pattern threw while building a URL", {
2644
- form: form.slug,
2645
- collection,
2646
- error: error instanceof Error ? error.message : String(error)
2942
+ }
2943
+ if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
2944
+ errors.push({
2945
+ path: "settings.redirectPage",
2946
+ message: "A page is required when confirmation type is 'relationship'",
2947
+ code: "REDIRECT_PAGE_REQUIRED"
2647
2948
  });
2648
- return void 0;
2649
2949
  }
2650
2950
  }
2651
- async function readTarget(reference, form, pluginContext) {
2652
- const { logger } = pluginContext;
2653
- const context = {
2654
- form: form.slug,
2655
- collection: reference.collection,
2656
- id: reference.id
2657
- };
2658
- let row;
2659
- try {
2660
- row = await pluginContext.services.collections.findEntryById(
2661
- reference.collection,
2662
- reference.id,
2663
- { as: "system" }
2664
- );
2665
- } catch (error) {
2666
- logger.warn?.("Form redirect target could not be read", {
2667
- ...context,
2668
- error: error instanceof Error ? error.message : String(error)
2951
+ function namesADocument(value) {
2952
+ if (typeof value === "string") return value.length > 0;
2953
+ return parseRedirectReference(value, []) !== null;
2954
+ }
2955
+ function validateAccess(access, errors) {
2956
+ if (!access) {
2957
+ return;
2958
+ }
2959
+ if (typeof access !== "object" || Array.isArray(access)) {
2960
+ errors.push({
2961
+ path: "access",
2962
+ message: "Access control must be an object",
2963
+ code: "ACCESS_INVALID_TYPE"
2669
2964
  });
2670
- return void 0;
2965
+ return;
2671
2966
  }
2672
- if (!row || typeof row !== "object") {
2673
- logger.warn?.("Form redirect target no longer exists", context);
2674
- return void 0;
2967
+ const accessObj = access;
2968
+ const validAccessKeys = ["submit", "read"];
2969
+ for (const key of validAccessKeys) {
2970
+ const fn = accessObj[key];
2971
+ if (fn !== void 0 && typeof fn !== "function") {
2972
+ errors.push({
2973
+ path: `access.${key}`,
2974
+ message: `Access control '${key}' must be a function`,
2975
+ code: "ACCESS_FUNCTION_INVALID"
2976
+ });
2977
+ }
2675
2978
  }
2676
- return row;
2677
2979
  }
2678
- async function validateSubmission(formSlug, data, context) {
2679
- const { pluginContext, pluginConfig } = context;
2680
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2681
- if (!form) {
2682
- return { valid: false, errors: { _form: "Form not found" } };
2683
- }
2684
- const prepared = prepareSubmission({
2685
- data,
2686
- fields: form.fields,
2687
- validate: true
2688
- });
2689
- if (!prepared.validationErrors) {
2690
- return { valid: true };
2691
- }
2980
+ function validateFormConfig(config) {
2981
+ const errors = [];
2982
+ validateSlug(config.slug, errors);
2983
+ validateFields(config.fields, errors);
2984
+ validateSettings(config.settings, errors);
2985
+ validateAccess(config.access, errors);
2692
2986
  return {
2693
- valid: false,
2694
- errors: prepared.validationErrors
2987
+ valid: errors.length === 0,
2988
+ errors
2695
2989
  };
2696
2990
  }
2697
- async function isFormAcceptingSubmissions(formSlug, context) {
2698
- const { pluginContext, pluginConfig } = context;
2699
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2700
- if (!form) {
2701
- return false;
2702
- }
2703
- return form.status === "published";
2704
- }
2705
- async function getFormSubmissionStats(formSlug, context) {
2706
- const { pluginContext, pluginConfig } = context;
2707
- const { collections } = pluginContext.services;
2708
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2709
- if (!form) {
2710
- return null;
2711
- }
2712
- try {
2713
- const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
2714
- const sys = { as: "system" };
2715
- const base = { form: { equals: form.id } };
2716
- const [total, newCount, read, archived] = await Promise.all([
2717
- collections.count(submissionsSlug, { where: base }, sys),
2718
- collections.count(
2719
- submissionsSlug,
2720
- { where: { ...base, status: { equals: "new" } } },
2721
- sys
2722
- ),
2723
- collections.count(
2724
- submissionsSlug,
2725
- { where: { ...base, status: { equals: "read" } } },
2726
- sys
2727
- ),
2728
- collections.count(
2729
- submissionsSlug,
2730
- { where: { ...base, status: { equals: "archived" } } },
2731
- sys
2732
- )
2733
- ]);
2734
- return { total, new: newCount, read, archived };
2735
- } catch {
2736
- return null;
2991
+ function assertValidFormConfig(config) {
2992
+ const result = validateFormConfig(config);
2993
+ if (!result.valid) {
2994
+ const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
2995
+ throw new Error(
2996
+ `Invalid form config for '${config.slug || "unknown"}':
2997
+ ${errorMessages}`
2998
+ );
2737
2999
  }
2738
3000
  }
2739
3001
 
3002
+ // src/config/field-helpers.ts
3003
+ var option = (label, value) => ({
3004
+ label,
3005
+ value: value ?? label.toLowerCase().replace(/\s+/g, "_")
3006
+ });
3007
+ var text3 = (config) => ({
3008
+ ...config,
3009
+ type: "text"
3010
+ });
3011
+ var email = (config) => ({
3012
+ ...config,
3013
+ type: "email"
3014
+ });
3015
+ var phone = (config) => ({
3016
+ ...config,
3017
+ type: "phone"
3018
+ });
3019
+ var url = (config) => ({
3020
+ ...config,
3021
+ type: "url"
3022
+ });
3023
+ var textarea3 = (config) => ({
3024
+ ...config,
3025
+ type: "textarea"
3026
+ });
3027
+ var number = (config) => ({
3028
+ ...config,
3029
+ type: "number"
3030
+ });
3031
+ var select3 = (config) => ({
3032
+ ...config,
3033
+ type: "select"
3034
+ });
3035
+ var checkbox2 = (config) => ({
3036
+ ...config,
3037
+ type: "checkbox"
3038
+ });
3039
+ var radio = (config) => ({
3040
+ ...config,
3041
+ type: "radio"
3042
+ });
3043
+ var file = (config) => ({
3044
+ ...config,
3045
+ type: "file"
3046
+ });
3047
+ var date3 = (config) => ({
3048
+ ...config,
3049
+ type: "date"
3050
+ });
3051
+ var time = (config) => ({
3052
+ ...config,
3053
+ type: "time"
3054
+ });
3055
+ var hidden = (config) => ({
3056
+ ...config,
3057
+ type: "hidden"
3058
+ });
3059
+
2740
3060
  // src/handlers/webhooks.ts
2741
3061
  import { safeFetch, ExternalUrlError } from "nextly";
2742
3062
  var WEBHOOK_TIMEOUT_MS = 1e4;
@@ -2927,8 +3247,6 @@ export {
2927
3247
  assertValidFormConfig,
2928
3248
  checkSpam,
2929
3249
  checkbox2 as checkbox,
2930
- cleanupRateLimitStore,
2931
- clearRateLimitStore,
2932
3250
  createFormConfig,
2933
3251
  date3 as date,
2934
3252
  downloadFile,
@@ -2947,7 +3265,6 @@ export {
2947
3265
  generateZodSchema,
2948
3266
  getFormBuilderConfig,
2949
3267
  getFormSubmissionStats,
2950
- getRateLimitStoreSize,
2951
3268
  getSupportedComparisonOperators,
2952
3269
  getSupportedWebhookEvents,
2953
3270
  getValidationErrors,
@@ -2959,7 +3276,6 @@ export {
2959
3276
  isFormField,
2960
3277
  isFormFieldType,
2961
3278
  isKnownFormField,
2962
- isRateLimited,
2963
3279
  isSelectFormField,
2964
3280
  isTextFormField,
2965
3281
  isValidComparisonOperator,