@nextlyhq/plugin-form-builder 0.0.2-alpha.65 → 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];
@@ -1671,7 +2401,7 @@ function submittedPayload(raw) {
1671
2401
  } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1672
2402
  return raw;
1673
2403
  }
1674
- throw NextlyError2.validation({
2404
+ throw NextlyError4.validation({
1675
2405
  errors: [
1676
2406
  {
1677
2407
  path: "data",
@@ -1701,30 +2431,39 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
1701
2431
  submission.data !== void 0 ? submission.data : stored?.data
1702
2432
  );
1703
2433
  const marks = submissionMarks(submission);
1704
- 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
+ }
1705
2440
  const prepared = prepareSubmission({
1706
2441
  data: incoming,
1707
2442
  fields,
1708
- validate: marks?.keepAsEvidence !== true
2443
+ validate: marks?.keepAsEvidence !== true && spam === void 0
1709
2444
  });
1710
2445
  if (prepared.validationErrors) {
1711
- throw NextlyError2.validation({
2446
+ throw NextlyError4.validation({
1712
2447
  errors: Object.entries(prepared.validationErrors).map(
1713
2448
  ([path, message]) => ({ path, code: "INVALID", message })
1714
2449
  )
1715
2450
  });
1716
2451
  }
1717
2452
  ctx.data = { ...submission, data: prepared.data };
2453
+ if (spam) {
2454
+ ctx.data.status = "spam";
2455
+ ctx.data.spamReason = spam.reason ?? null;
2456
+ }
1718
2457
  if (ctx.operation !== "create" && submission.data === void 0) {
1719
2458
  stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
1720
2459
  }
1721
2460
  return ctx.data;
1722
2461
  }
1723
- async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
2462
+ async function formToCheckAgainst(marks, formsSlug, formId, nextly) {
1724
2463
  const handedOver = marks?.form?.id === formId ? marks.form : null;
1725
2464
  const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
1726
2465
  if (!form || !Array.isArray(form.fields)) {
1727
- throw NextlyError2.validation({
2466
+ throw NextlyError4.validation({
1728
2467
  errors: [
1729
2468
  {
1730
2469
  path: "form",
@@ -1734,7 +2473,32 @@ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
1734
2473
  ]
1735
2474
  });
1736
2475
  }
1737
- 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;
1738
2502
  }
1739
2503
  function stampDerivedEdit(row, user, before, after) {
1740
2504
  if (sameSubmittedPayload(before, after)) return;
@@ -1852,7 +2616,7 @@ async function fetchParentForm(formsSlug, formId, nextly) {
1852
2616
  );
1853
2617
  return form;
1854
2618
  } catch (err) {
1855
- if (!NextlyError2.isNotFound(err)) throw err;
2619
+ if (!NextlyError4.isNotFound(err)) throw err;
1856
2620
  nextly.logger.error?.("Form Builder: form not found", {
1857
2621
  formId,
1858
2622
  error: err instanceof Error ? err.message : String(err)
@@ -1916,834 +2680,383 @@ function validateSlug(slug, errors) {
1916
2680
  code: "SLUG_TOO_LONG"
1917
2681
  });
1918
2682
  }
1919
- if (!SLUG_PATTERN.test(slug)) {
1920
- errors.push({
1921
- path,
1922
- message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1923
- code: "SLUG_INVALID_FORMAT"
1924
- });
1925
- }
1926
- if (isReservedSlug(slug)) {
1927
- errors.push({
1928
- path,
1929
- message: `Form slug '${slug}' is reserved and cannot be used`,
1930
- code: "SLUG_RESERVED"
1931
- });
1932
- }
1933
- }
1934
- function validateFieldName(name, path, errors, seenNames) {
1935
- if (!name) {
1936
- errors.push({
1937
- path: `${path}.name`,
1938
- message: "Field name is required",
1939
- code: "FIELD_NAME_REQUIRED"
1940
- });
1941
- return;
1942
- }
1943
- if (typeof name !== "string") {
1944
- errors.push({
1945
- path: `${path}.name`,
1946
- message: "Field name must be a string",
1947
- code: "FIELD_NAME_REQUIRED"
1948
- });
1949
- return;
1950
- }
1951
- if (!FIELD_NAME_PATTERN.test(name)) {
1952
- errors.push({
1953
- path: `${path}.name`,
1954
- message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1955
- code: "FIELD_NAME_INVALID_FORMAT"
1956
- });
1957
- }
1958
- const nameLower = name.toLowerCase();
1959
- if (seenNames.has(nameLower)) {
1960
- errors.push({
1961
- path: `${path}.name`,
1962
- message: `Duplicate field name '${name}'`,
1963
- code: "FIELD_NAME_DUPLICATE"
1964
- });
1965
- } else {
1966
- seenNames.add(nameLower);
1967
- }
1968
- }
1969
- function validateFieldLabel(label, path, errors) {
1970
- if (!label || typeof label === "string" && label.trim() === "") {
1971
- errors.push({
1972
- path: `${path}.label`,
1973
- message: "Field label is required",
1974
- code: "FIELD_LABEL_REQUIRED"
1975
- });
1976
- }
1977
- }
1978
- function validateOptions(field, path, errors) {
1979
- const fieldWithOptions = field;
1980
- const options = fieldWithOptions.options;
1981
- if (!options) {
1982
- errors.push({
1983
- path: `${path}.options`,
1984
- message: `${field.type} field must have an 'options' array`,
1985
- code: "OPTIONS_REQUIRED"
1986
- });
1987
- return;
1988
- }
1989
- if (!Array.isArray(options)) {
1990
- errors.push({
1991
- path: `${path}.options`,
1992
- message: `${field.type} field 'options' must be an array`,
1993
- code: "OPTIONS_REQUIRED"
1994
- });
1995
- return;
1996
- }
1997
- if (options.length === 0) {
1998
- errors.push({
1999
- path: `${path}.options`,
2000
- message: `${field.type} field must have at least one option`,
2001
- code: "OPTIONS_EMPTY"
2002
- });
2003
- }
2004
- }
2005
- function validateConditionalLogic(field, path, errors) {
2006
- const { conditionalLogic } = field;
2007
- if (!conditionalLogic || !conditionalLogic.enabled) {
2008
- return;
2009
- }
2010
- if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
2011
- errors.push({
2012
- path: `${path}.conditionalLogic.conditions`,
2013
- message: "Conditional logic must have at least one condition",
2014
- code: "CONDITIONS_REQUIRED"
2015
- });
2016
- return;
2017
- }
2018
- conditionalLogic.conditions.forEach((condition, index) => {
2019
- const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
2020
- if (!condition.field) {
2021
- errors.push({
2022
- path: `${conditionPath}.field`,
2023
- message: "Condition must specify a field name",
2024
- code: "CONDITION_FIELD_REQUIRED"
2025
- });
2026
- }
2027
- if (!condition.comparison) {
2028
- errors.push({
2029
- path: `${conditionPath}.comparison`,
2030
- message: "Condition must specify a comparison operator",
2031
- code: "CONDITION_COMPARISON_REQUIRED"
2032
- });
2033
- }
2034
- });
2035
- }
2036
- function validateFieldByType(field, path, errors) {
2037
- switch (field.type) {
2038
- case "select":
2039
- case "radio":
2040
- validateOptions(field, path, errors);
2041
- break;
2042
- case "number": {
2043
- const numberField = field;
2044
- if (numberField.validation) {
2045
- const { min, max } = numberField.validation;
2046
- if (min !== void 0 && max !== void 0 && min > max) {
2047
- errors.push({
2048
- path: `${path}.validation`,
2049
- message: "Min value cannot be greater than max value",
2050
- code: "MIN_GREATER_THAN_MAX"
2051
- });
2052
- }
2053
- }
2054
- break;
2055
- }
2056
- case "text":
2057
- case "textarea": {
2058
- const textField = field;
2059
- if (textField.validation) {
2060
- const { minLength, maxLength } = textField.validation;
2061
- if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
2062
- errors.push({
2063
- path: `${path}.validation`,
2064
- message: "Min length cannot be greater than max length",
2065
- code: "MIN_LENGTH_GREATER_THAN_MAX"
2066
- });
2067
- }
2068
- }
2069
- break;
2070
- }
2071
- case "file": {
2072
- const fileField = field;
2073
- if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
2074
- errors.push({
2075
- path: `${path}.maxFileSize`,
2076
- message: "File maxFileSize must be a positive number",
2077
- code: "FILE_MAX_SIZE_INVALID"
2078
- });
2079
- }
2080
- break;
2081
- }
2082
- }
2083
- }
2084
- function validateField(field, path, errors, seenNames) {
2085
- if (!field || typeof field !== "object") {
2086
- return;
2087
- }
2088
- const f = field;
2089
- const fieldType = f.type;
2090
- if (!fieldType) {
2091
- errors.push({
2092
- path: `${path}.type`,
2093
- message: "Field type is required",
2094
- code: "FIELD_TYPE_REQUIRED"
2095
- });
2096
- return;
2097
- }
2098
- const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
2099
- if (!isValidType) {
2100
- let fieldTypeRepr;
2101
- if (typeof fieldType === "object" && fieldType !== null) {
2102
- fieldTypeRepr = JSON.stringify(fieldType);
2103
- } else {
2104
- fieldTypeRepr = String(fieldType);
2105
- }
2106
- errors.push({
2107
- path: `${path}.type`,
2108
- message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
2109
- code: "FIELD_TYPE_INVALID"
2110
- });
2111
- return;
2112
- }
2113
- validateFieldName(f.name, path, errors, seenNames);
2114
- if (fieldType !== "hidden") {
2115
- validateFieldLabel(f.label, path, errors);
2116
- }
2117
- validateFieldByType(field, path, errors);
2118
- if (f.conditionalLogic) {
2119
- validateConditionalLogic(field, path, errors);
2120
- }
2121
- }
2122
- function validateFieldsArray(fields, basePath, errors) {
2123
- const seenNames = /* @__PURE__ */ new Set();
2124
- fields.forEach((field, index) => {
2125
- const fieldPath = `${basePath}[${index}]`;
2126
- validateField(field, fieldPath, errors, seenNames);
2127
- });
2128
- }
2129
- function validateFields(fields, errors) {
2130
- const path = "fields";
2131
- if (!fields) {
2132
- errors.push({
2133
- path,
2134
- message: "Form must have at least one field",
2135
- code: "FIELDS_REQUIRED"
2136
- });
2137
- return;
2138
- }
2139
- if (!Array.isArray(fields)) {
2683
+ if (!SLUG_PATTERN.test(slug)) {
2140
2684
  errors.push({
2141
2685
  path,
2142
- message: "Form fields must be an array",
2143
- 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"
2144
2688
  });
2145
- return;
2146
2689
  }
2147
- if (fields.length === 0) {
2690
+ if (isReservedSlug(slug)) {
2148
2691
  errors.push({
2149
2692
  path,
2150
- message: "Form must have at least one field",
2151
- code: "FIELDS_EMPTY"
2693
+ message: `Form slug '${slug}' is reserved and cannot be used`,
2694
+ code: "SLUG_RESERVED"
2152
2695
  });
2153
- return;
2154
2696
  }
2155
- validateFieldsArray(fields, path, errors);
2156
2697
  }
2157
- function validateSettings(settings, errors) {
2158
- 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
+ });
2159
2705
  return;
2160
2706
  }
2161
- if (typeof settings !== "object" || Array.isArray(settings)) {
2707
+ if (typeof name !== "string") {
2162
2708
  errors.push({
2163
- path: "settings",
2164
- message: "Form settings must be an object",
2165
- code: "SETTINGS_INVALID_TYPE"
2709
+ path: `${path}.name`,
2710
+ message: "Field name must be a string",
2711
+ code: "FIELD_NAME_REQUIRED"
2166
2712
  });
2167
2713
  return;
2168
2714
  }
2169
- const s = settings;
2170
- if (s.confirmationType === "redirect") {
2171
- if (!s.redirectUrl && !s.redirectRelation) {
2172
- errors.push({
2173
- path: "settings.redirectUrl",
2174
- message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
2175
- code: "REDIRECT_URL_REQUIRED"
2176
- });
2177
- }
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
+ });
2178
2721
  }
2179
- if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
2722
+ const nameLower = name.toLowerCase();
2723
+ if (seenNames.has(nameLower)) {
2180
2724
  errors.push({
2181
- path: "settings.redirectPage",
2182
- message: "A page is required when confirmation type is 'relationship'",
2183
- code: "REDIRECT_PAGE_REQUIRED"
2725
+ path: `${path}.name`,
2726
+ message: `Duplicate field name '${name}'`,
2727
+ code: "FIELD_NAME_DUPLICATE"
2184
2728
  });
2729
+ } else {
2730
+ seenNames.add(nameLower);
2185
2731
  }
2186
2732
  }
2187
- function namesADocument(value) {
2188
- if (typeof value === "string") return value.length > 0;
2189
- 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
+ }
2190
2741
  }
2191
- function validateAccess(access, errors) {
2192
- 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
+ });
2193
2751
  return;
2194
2752
  }
2195
- if (typeof access !== "object" || Array.isArray(access)) {
2753
+ if (!Array.isArray(options)) {
2196
2754
  errors.push({
2197
- path: "access",
2198
- message: "Access control must be an object",
2199
- code: "ACCESS_INVALID_TYPE"
2755
+ path: `${path}.options`,
2756
+ message: `${field.type} field 'options' must be an array`,
2757
+ code: "OPTIONS_REQUIRED"
2200
2758
  });
2201
2759
  return;
2202
2760
  }
2203
- const accessObj = access;
2204
- const validAccessKeys = ["submit", "read"];
2205
- for (const key of validAccessKeys) {
2206
- const fn = accessObj[key];
2207
- if (fn !== void 0 && typeof fn !== "function") {
2208
- errors.push({
2209
- path: `access.${key}`,
2210
- message: `Access control '${key}' must be a function`,
2211
- code: "ACCESS_FUNCTION_INVALID"
2212
- });
2213
- }
2214
- }
2215
- }
2216
- function validateFormConfig(config) {
2217
- const errors = [];
2218
- validateSlug(config.slug, errors);
2219
- validateFields(config.fields, errors);
2220
- validateSettings(config.settings, errors);
2221
- validateAccess(config.access, errors);
2222
- return {
2223
- valid: errors.length === 0,
2224
- errors
2225
- };
2226
- }
2227
- function assertValidFormConfig(config) {
2228
- const result = validateFormConfig(config);
2229
- if (!result.valid) {
2230
- const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
2231
- throw new Error(
2232
- `Invalid form config for '${config.slug || "unknown"}':
2233
- ${errorMessages}`
2234
- );
2235
- }
2236
- }
2237
-
2238
- // src/config/field-helpers.ts
2239
- var option = (label, value) => ({
2240
- label,
2241
- value: value ?? label.toLowerCase().replace(/\s+/g, "_")
2242
- });
2243
- var text3 = (config) => ({
2244
- ...config,
2245
- type: "text"
2246
- });
2247
- var email = (config) => ({
2248
- ...config,
2249
- type: "email"
2250
- });
2251
- var phone = (config) => ({
2252
- ...config,
2253
- type: "phone"
2254
- });
2255
- var url = (config) => ({
2256
- ...config,
2257
- type: "url"
2258
- });
2259
- var textarea3 = (config) => ({
2260
- ...config,
2261
- type: "textarea"
2262
- });
2263
- var number = (config) => ({
2264
- ...config,
2265
- type: "number"
2266
- });
2267
- var select3 = (config) => ({
2268
- ...config,
2269
- type: "select"
2270
- });
2271
- var checkbox2 = (config) => ({
2272
- ...config,
2273
- type: "checkbox"
2274
- });
2275
- var radio = (config) => ({
2276
- ...config,
2277
- type: "radio"
2278
- });
2279
- var file = (config) => ({
2280
- ...config,
2281
- type: "file"
2282
- });
2283
- var date3 = (config) => ({
2284
- ...config,
2285
- type: "date"
2286
- });
2287
- var time = (config) => ({
2288
- ...config,
2289
- type: "time"
2290
- });
2291
- var hidden = (config) => ({
2292
- ...config,
2293
- type: "hidden"
2294
- });
2295
-
2296
- // src/handlers/submit-form.ts
2297
- import { formAvailability, NO_SUCH_FORM } from "nextly";
2298
-
2299
- // src/handlers/spam-detection.ts
2300
- var rateLimitStore = /* @__PURE__ */ new Map();
2301
- async function checkSpam(options) {
2302
- const { data, ipAddress, formSlug, config } = options;
2303
- if (config.honeypot) {
2304
- const honeypotResult = checkHoneypot(data);
2305
- if (honeypotResult.isSpam) {
2306
- return honeypotResult;
2307
- }
2308
- }
2309
- if (config.rateLimit && ipAddress) {
2310
- const rateLimitResult = checkRateLimit(
2311
- ipAddress,
2312
- formSlug,
2313
- config.rateLimit
2314
- );
2315
- if (rateLimitResult.isSpam) {
2316
- return rateLimitResult;
2317
- }
2318
- }
2319
- return { isSpam: false };
2320
- }
2321
- var HONEYPOT_FIELDS = [
2322
- "__honeypot",
2323
- "_honeypot",
2324
- "honeypot",
2325
- "__hp",
2326
- "_hp",
2327
- "website",
2328
- // Common honeypot field name
2329
- "url_field",
2330
- // Another common honeypot
2331
- "fax_number"
2332
- // Rarely used by humans
2333
- ];
2334
- function checkHoneypot(data) {
2335
- for (const fieldName of HONEYPOT_FIELDS) {
2336
- const value = data[fieldName];
2337
- if (value !== void 0 && value !== null && value !== "") {
2338
- let raw;
2339
- if (typeof value === "object") {
2340
- raw = JSON.stringify(value);
2341
- } else {
2342
- raw = String(value);
2343
- }
2344
- const stringValue = raw.trim();
2345
- if (stringValue.length > 0) {
2346
- return {
2347
- isSpam: true,
2348
- reason: "honeypot",
2349
- details: `Honeypot field "${fieldName}" was filled`
2350
- };
2351
- }
2352
- }
2353
- }
2354
- return { isSpam: false };
2355
- }
2356
- function checkRateLimit(ipAddress, formSlug, config) {
2357
- const key = `${formSlug}:${ipAddress}`;
2358
- const now = Date.now();
2359
- const entry = rateLimitStore.get(key);
2360
- if (entry) {
2361
- if (now - entry.timestamp > config.windowMs) {
2362
- rateLimitStore.set(key, { count: 1, timestamp: now });
2363
- return { isSpam: false };
2364
- }
2365
- if (entry.count >= config.maxSubmissions) {
2366
- return {
2367
- isSpam: true,
2368
- reason: "rate_limit",
2369
- details: `Rate limit exceeded: ${entry.count}/${config.maxSubmissions} in ${config.windowMs}ms`
2370
- };
2371
- }
2372
- rateLimitStore.set(key, {
2373
- count: entry.count + 1,
2374
- 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"
2375
2766
  });
2376
- } else {
2377
- rateLimitStore.set(key, { count: 1, timestamp: now });
2378
2767
  }
2379
- return { isSpam: false };
2380
- }
2381
- function cleanupRateLimitStore(maxAgeMs = 5 * 60 * 1e3) {
2382
- const now = Date.now();
2383
- let removed = 0;
2384
- for (const [key, entry] of rateLimitStore.entries()) {
2385
- if (now - entry.timestamp > maxAgeMs) {
2386
- rateLimitStore.delete(key);
2387
- removed++;
2388
- }
2389
- }
2390
- return removed;
2391
2768
  }
2392
- function getRateLimitStoreSize() {
2393
- return rateLimitStore.size;
2394
- }
2395
- function clearRateLimitStore() {
2396
- rateLimitStore.clear();
2397
- }
2398
- function isRateLimited(ipAddress, formSlug, config) {
2399
- const key = `${formSlug}:${ipAddress}`;
2400
- const now = Date.now();
2401
- const entry = rateLimitStore.get(key);
2402
- if (!entry) {
2403
- return false;
2769
+ function validateConditionalLogic(field, path, errors) {
2770
+ const { conditionalLogic } = field;
2771
+ if (!conditionalLogic || !conditionalLogic.enabled) {
2772
+ return;
2404
2773
  }
2405
- if (now - entry.timestamp > config.windowMs) {
2406
- 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;
2407
2781
  }
2408
- return entry.count >= config.maxSubmissions;
2409
- }
2410
-
2411
- // src/handlers/submit-form.ts
2412
- async function submitForm(options, context) {
2413
- const { formSlug, data, metadata } = options;
2414
- const { pluginContext, pluginConfig } = context;
2415
- const { collections } = pluginContext.services;
2416
- const { logger } = pluginContext;
2417
- try {
2418
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2419
- if (!form) {
2420
- logger.warn?.("Form submission attempted for non-existent form", {
2421
- 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"
2422
2789
  });
2423
- return { success: false, error: NO_SUCH_FORM };
2424
2790
  }
2425
- const availability = formAvailability(form);
2426
- if (availability.kind !== "open") {
2427
- logger.info?.("Form submission rejected - form not accepting", {
2428
- formSlug,
2429
- status: form.status,
2430
- 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"
2431
2796
  });
2432
- return {
2433
- success: false,
2434
- error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
2435
- };
2436
- }
2437
- const settings = normalizeFormSettings(form.settings);
2438
- if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
2439
- logger.warn?.(
2440
- "Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
2441
- { formSlug }
2442
- );
2443
2797
  }
2444
- if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
2445
- const existing = await collections.count(
2446
- pluginConfig.formSubmissionOverrides.slug,
2447
- {
2448
- where: {
2449
- form: { equals: form.id },
2450
- ipAddress: { equals: metadata.ipAddress },
2451
- status: { not_equals: "spam" }
2452
- }
2453
- },
2454
- { as: "system" }
2455
- );
2456
- if (existing > 0) {
2457
- logger.info?.("Repeat submission rejected (single-submission form)", {
2458
- formSlug,
2459
- ipAddress: metadata.ipAddress
2460
- });
2461
- return {
2462
- success: false,
2463
- error: "You have already submitted this form."
2464
- };
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
+ }
2465
2817
  }
2818
+ break;
2466
2819
  }
2467
- const declaredFieldNames = new Set(form.fields.map((field) => field.name));
2468
- const undeclaredData = Object.fromEntries(
2469
- Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
2470
- );
2471
- const spamResult = await checkSpam({
2472
- data: undeclaredData,
2473
- ipAddress: metadata?.ipAddress,
2474
- formSlug,
2475
- config: {
2476
- // Per-form overrides win where set; blank inherits the plugin config.
2477
- honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
2478
- rateLimit: pluginConfig.spamProtection.rateLimit,
2479
- recaptcha: {
2480
- ...pluginConfig.spamProtection.recaptcha,
2481
- 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
+ });
2482
2831
  }
2483
2832
  }
2484
- });
2485
- if (spamResult.isSpam && spamResult.reason === "rate_limit") {
2486
- logger.info?.("Spam submission rejected (rate limit)", {
2487
- formSlug,
2488
- ipAddress: metadata?.ipAddress
2489
- });
2490
- return { success: true };
2491
- }
2492
- const isContentSpam = spamResult.isSpam;
2493
- if (isContentSpam) {
2494
- logger.info?.("Spam submission detected \u2014 storing flagged", {
2495
- formSlug,
2496
- reason: spamResult.reason,
2497
- ipAddress: metadata?.ipAddress
2498
- });
2833
+ break;
2499
2834
  }
2500
- const prepared = prepareSubmission({
2501
- data,
2502
- fields: form.fields,
2503
- validate: !isContentSpam
2504
- });
2505
- if (prepared.validationErrors) {
2506
- logger.debug?.("Form validation failed", {
2507
- formSlug,
2508
- errors: prepared.validationErrors
2509
- });
2510
- return {
2511
- success: false,
2512
- error: "Validation failed",
2513
- validationErrors: prepared.validationErrors
2514
- };
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;
2515
2845
  }
2516
- const storedData = prepared.data;
2517
- const submissionData = {
2518
- form: form.id,
2519
- data: storedData,
2520
- status: isContentSpam ? "spam" : "new",
2521
- spamReason: isContentSpam ? spamResult.reason ?? null : null,
2522
- ipAddress: metadata?.ipAddress || null,
2523
- userAgent: metadata?.userAgent || null,
2524
- submittedAt: /* @__PURE__ */ new Date()
2525
- };
2526
- const submission = await collections.createEntry(
2527
- pluginConfig.formSubmissionOverrides.slug,
2528
- asPluginSubmission(submissionData, {
2529
- keepAsEvidence: isContentSpam,
2530
- form: { id: form.id, fields: form.fields }
2531
- }),
2532
- // Public form submission — create as system. No ambient user; an
2533
- // empty context already resolves to system, but be explicit.
2534
- { as: "system" }
2535
- );
2536
- logger.info?.("Form submission created successfully", {
2537
- formSlug,
2538
- submissionId: submission.item.id,
2539
- 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"
2540
2859
  });
2541
- if (submission.warnings) {
2542
- logger.warn?.("Form submission side effects failed", {
2543
- formSlug,
2544
- submissionId: submission.item.id,
2545
- warnings: submission.warnings
2546
- });
2547
- }
2548
- const redirect = await resolveRedirectUrl(
2549
- form,
2550
- pluginConfig,
2551
- pluginContext
2552
- );
2553
- if (isContentSpam) {
2554
- 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);
2555
2869
  }
2556
- return {
2557
- success: true,
2558
- // The created ROW, not the envelope around it. Through the shared
2559
- // conversion, which is where the unchecked step lives and says so; the
2560
- // integration test reads `submission.id` off the result because nothing
2561
- // at compile time will.
2562
- submission: asSubmissionDocument(submission.item),
2563
- redirect
2564
- };
2565
- } catch (error) {
2566
- logger.error?.("Form submission error", {
2567
- formSlug,
2568
- 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"
2569
2874
  });
2570
- return {
2571
- success: false,
2572
- error: "An error occurred processing your submission. Please try again."
2573
- };
2875
+ return;
2574
2876
  }
2575
- }
2576
- async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2577
- try {
2578
- const { collections } = pluginContext.services;
2579
- const result = await collections.listEntries(
2580
- pluginConfig.formOverrides.slug,
2581
- { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
2582
- { as: "system" }
2583
- );
2584
- const form = result.data?.[0];
2585
- return form ? asFormDocument(form) : null;
2586
- } catch {
2587
- return null;
2877
+ validateFieldName(f.name, path, errors, seenNames);
2878
+ if (fieldType !== "hidden") {
2879
+ validateFieldLabel(f.label, path, errors);
2588
2880
  }
2589
- }
2590
- async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
2591
- const settings = form.settings;
2592
- if (!settings) return void 0;
2593
- if (settings.confirmationType === "redirect" && settings.redirectUrl) {
2594
- return settings.redirectUrl;
2881
+ validateFieldByType(field, path, errors);
2882
+ if (f.conditionalLogic) {
2883
+ validateConditionalLogic(field, path, errors);
2595
2884
  }
2596
- const field = pickedDocumentField(settings);
2597
- if (!field) return void 0;
2598
- return urlForPickedDocument(
2599
- settings[field],
2600
- form,
2601
- pluginConfig,
2602
- pluginContext
2603
- );
2604
2885
  }
2605
- async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
2606
- const { logger } = pluginContext;
2607
- const patterns = pluginConfig.redirectRelationships;
2608
- const reference = parseRedirectReference(stored, Object.keys(patterns));
2609
- if (!reference) {
2610
- logger.warn?.("Form redirects to a page, but names no readable document", {
2611
- 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"
2612
2900
  });
2613
- return void 0;
2901
+ return;
2614
2902
  }
2615
- const pattern = patterns[reference.collection];
2616
- if (!pattern) {
2617
- logger.warn?.(
2618
- "Form redirects to a collection that is not in redirectRelationships",
2619
- { form: form.slug, collection: reference.collection }
2620
- );
2621
- 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;
2622
2910
  }
2623
- const target = await readTarget(reference, form, pluginContext);
2624
- if (!target) return void 0;
2625
- if (documentReachability(
2626
- target,
2627
- pluginConfig.redirectTargetLocalization[reference.collection]
2628
- ) === "unreachable") {
2629
- logger.warn?.("Form redirects to a page that has never been published", {
2630
- form: form.slug,
2631
- collection: reference.collection,
2632
- 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"
2633
2916
  });
2634
- return void 0;
2917
+ return;
2635
2918
  }
2636
- return buildUrl(pattern, target, reference.collection, form, pluginContext);
2919
+ validateFieldsArray(fields, path, errors);
2637
2920
  }
2638
- function buildUrl(pattern, target, collection, form, pluginContext) {
2639
- const { logger } = pluginContext;
2640
- try {
2641
- const url2 = applyRedirectPattern(pattern, target);
2642
- if (!url2) {
2643
- logger.warn?.(
2644
- "Form redirect pattern could not be filled from the target document",
2645
- { form: form.slug, collection }
2646
- );
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
+ });
2647
2941
  }
2648
- return url2;
2649
- } catch (error) {
2650
- logger.warn?.("Form redirect pattern threw while building a URL", {
2651
- form: form.slug,
2652
- collection,
2653
- 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"
2654
2948
  });
2655
- return void 0;
2656
2949
  }
2657
2950
  }
2658
- async function readTarget(reference, form, pluginContext) {
2659
- const { logger } = pluginContext;
2660
- const context = {
2661
- form: form.slug,
2662
- collection: reference.collection,
2663
- id: reference.id
2664
- };
2665
- let row;
2666
- try {
2667
- row = await pluginContext.services.collections.findEntryById(
2668
- reference.collection,
2669
- reference.id,
2670
- { as: "system" }
2671
- );
2672
- } catch (error) {
2673
- logger.warn?.("Form redirect target could not be read", {
2674
- ...context,
2675
- 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"
2676
2964
  });
2677
- return void 0;
2965
+ return;
2678
2966
  }
2679
- if (!row || typeof row !== "object") {
2680
- logger.warn?.("Form redirect target no longer exists", context);
2681
- 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
+ }
2682
2978
  }
2683
- return row;
2684
2979
  }
2685
- async function validateSubmission(formSlug, data, context) {
2686
- const { pluginContext, pluginConfig } = context;
2687
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2688
- if (!form) {
2689
- return { valid: false, errors: { _form: "Form not found" } };
2690
- }
2691
- const prepared = prepareSubmission({
2692
- data,
2693
- fields: form.fields,
2694
- validate: true
2695
- });
2696
- if (!prepared.validationErrors) {
2697
- return { valid: true };
2698
- }
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);
2699
2986
  return {
2700
- valid: false,
2701
- errors: prepared.validationErrors
2987
+ valid: errors.length === 0,
2988
+ errors
2702
2989
  };
2703
2990
  }
2704
- async function isFormAcceptingSubmissions(formSlug, context) {
2705
- const { pluginContext, pluginConfig } = context;
2706
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2707
- if (!form) {
2708
- return false;
2709
- }
2710
- return form.status === "published";
2711
- }
2712
- async function getFormSubmissionStats(formSlug, context) {
2713
- const { pluginContext, pluginConfig } = context;
2714
- const { collections } = pluginContext.services;
2715
- const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2716
- if (!form) {
2717
- return null;
2718
- }
2719
- try {
2720
- const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
2721
- const sys = { as: "system" };
2722
- const base = { form: { equals: form.id } };
2723
- const [total, newCount, read, archived] = await Promise.all([
2724
- collections.count(submissionsSlug, { where: base }, sys),
2725
- collections.count(
2726
- submissionsSlug,
2727
- { where: { ...base, status: { equals: "new" } } },
2728
- sys
2729
- ),
2730
- collections.count(
2731
- submissionsSlug,
2732
- { where: { ...base, status: { equals: "read" } } },
2733
- sys
2734
- ),
2735
- collections.count(
2736
- submissionsSlug,
2737
- { where: { ...base, status: { equals: "archived" } } },
2738
- sys
2739
- )
2740
- ]);
2741
- return { total, new: newCount, read, archived };
2742
- } catch {
2743
- 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
+ );
2744
2999
  }
2745
3000
  }
2746
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
+
2747
3060
  // src/handlers/webhooks.ts
2748
3061
  import { safeFetch, ExternalUrlError } from "nextly";
2749
3062
  var WEBHOOK_TIMEOUT_MS = 1e4;
@@ -2934,8 +3247,6 @@ export {
2934
3247
  assertValidFormConfig,
2935
3248
  checkSpam,
2936
3249
  checkbox2 as checkbox,
2937
- cleanupRateLimitStore,
2938
- clearRateLimitStore,
2939
3250
  createFormConfig,
2940
3251
  date3 as date,
2941
3252
  downloadFile,
@@ -2954,7 +3265,6 @@ export {
2954
3265
  generateZodSchema,
2955
3266
  getFormBuilderConfig,
2956
3267
  getFormSubmissionStats,
2957
- getRateLimitStoreSize,
2958
3268
  getSupportedComparisonOperators,
2959
3269
  getSupportedWebhookEvents,
2960
3270
  getValidationErrors,
@@ -2966,7 +3276,6 @@ export {
2966
3276
  isFormField,
2967
3277
  isFormFieldType,
2968
3278
  isKnownFormField,
2969
- isRateLimited,
2970
3279
  isSelectFormField,
2971
3280
  isTextFormField,
2972
3281
  isValidComparisonOperator,