@usefillo/core 0.8.0 → 0.9.0

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.d.ts CHANGED
@@ -8,6 +8,18 @@ interface Condition {
8
8
  op: ConditionOp;
9
9
  value?: string | number | boolean;
10
10
  }
11
+ /**
12
+ * A single conditional page-flow rule (P1 logic depth). When every condition in
13
+ * `when` matches (AND — the same evaluator as `visibleIf`), navigation leaves
14
+ * the current page for `to`: another page's id, or the literal `"end"` to finish
15
+ * the form early. An empty `when` is an unconditional jump. Rules are evaluated
16
+ * top-to-bottom; the first match wins. See {@link FormPage.next}.
17
+ */
18
+ interface JumpRule {
19
+ when: Condition[];
20
+ /** Target page id, or "end" to finish the form. */
21
+ to: string | "end";
22
+ }
11
23
  type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "custom";
12
24
  type ContentKind = "heading" | "paragraph" | "divider";
13
25
  type BlockKind = FieldKind | ContentKind;
@@ -151,6 +163,10 @@ interface FormPage {
151
163
  id: string;
152
164
  title?: string;
153
165
  blocks: Block[];
166
+ /** Conditional page flow (P1 logic depth). Rules are evaluated top-to-bottom;
167
+ * the first whose conditions all match decides the next step. No rule matches
168
+ * → default linear next page. Absent → today's linear behavior. */
169
+ next?: JumpRule[];
154
170
  }
155
171
  /**
156
172
  * Limit repeat responses. Absent = no limit (submit as often as you like).
@@ -185,6 +201,22 @@ interface ResponseLimit {
185
201
  */
186
202
  onRepeat: "keep" | "update";
187
203
  }
204
+ /**
205
+ * Per-form submission-trust policy. Absent = accept everything (today's
206
+ * behavior). See docs/roadmap/07-submission-trust.md.
207
+ */
208
+ interface TrustPolicy {
209
+ /** What to do with a submission whose respondent is NOT HMAC-verified.
210
+ * "allow" (default) accepts it normally; "quarantine" stores it withheld
211
+ * from every downstream consumer until an owner releases it. Declared-agent
212
+ * classes come in a later phase. */
213
+ unverified?: "allow" | "quarantine";
214
+ /** Require a human-verification challenge before accepting a submission.
215
+ * "off" (default) = no challenge; "turnstile" = a Cloudflare Turnstile widget
216
+ * that the SDK renders and the server verifies. Provider-agnostic on purpose.
217
+ * Independent of `unverified` — a form can require both. */
218
+ challenge?: "off" | "turnstile";
219
+ }
188
220
  interface FormSettings {
189
221
  /**
190
222
  * Default "button": respondents submit with the footer button. "auto" hides
@@ -200,6 +232,9 @@ interface FormSettings {
200
232
  /** Limit repeat responses (who counts as the same responder, and what a
201
233
  * repeat does). Absent = no limit. See {@link ResponseLimit}. */
202
234
  responseLimit?: ResponseLimit;
235
+ /** Per-form submission-trust policy. Absent = accept everything (today's
236
+ * behavior). See docs/roadmap/07-submission-trust.md. */
237
+ trust?: TrustPolicy;
203
238
  /** Notify this address on every submission. */
204
239
  notifyEmail?: string;
205
240
  /** Send respondents a receipt (to the first answered email field). */
@@ -351,6 +386,13 @@ interface UploadSession {
351
386
  file?: FileValue;
352
387
  }
353
388
 
389
+ /**
390
+ * All conditions must hold (AND); no conditions = true. THE shared evaluator —
391
+ * both block visibility (`visibleIf`) and page jumps (`JumpRule.when`) run
392
+ * through this over the SAME whole-form fixpoint resolver, so a jump gated by a
393
+ * logic-hidden controller field behaves identically to a visibility rule.
394
+ */
395
+ declare function conditionsMet(conds: Condition[], resolve: (fieldId: string) => FieldValue): boolean;
354
396
  /** All conditions must hold (AND). No conditions = visible. */
355
397
  declare function isBlockVisible(block: Block, data: ResponseData): boolean;
356
398
  declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
@@ -378,8 +420,57 @@ declare function allFields(form: FormSchema): Field[];
378
420
  * per-visitor key and the server's per-person dedup so both scope identically.
379
421
  */
380
422
  declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
381
- /** Fields currently visible given the response data — the set that gets validated. */
423
+ /** Fields currently visible given the response data. */
382
424
  declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
425
+ /** Where navigation goes when leaving a page. */
426
+ type NextPage = {
427
+ to: string;
428
+ } | {
429
+ end: true;
430
+ } | {
431
+ linear: true;
432
+ };
433
+ /**
434
+ * Evaluate a page's `next` jump rules top-to-bottom against the current data;
435
+ * the first whose conditions all match decides the step ("end" → finish the
436
+ * form). No rule (or no `next`) → the default linear next page. THE single
437
+ * function the client renderer, the server validator, and the funnel share so
438
+ * they always agree on flow.
439
+ */
440
+ declare function resolveNextPage(form: FormSchema, currentPageId: string, data: ResponseData): NextPage;
441
+ /**
442
+ * The ORDERED list of reachable page ids for the given data: walk from
443
+ * `pages[0]` following `resolveNextPage` (linear → next index; jump → target id;
444
+ * end → stop). A rule that points backward could loop, so stop on the first
445
+ * revisit and cap the walk at `pages.length` steps. THE single ordered engine
446
+ * the client renderer, the server validator, and navigation all agree through —
447
+ * a no-jump form yields `[pages[0].id, …, pages[N].id]`, so everything built on
448
+ * it reduces to today's linear behavior. {@link reachablePageIds} is the set of
449
+ * this exact walk (one walk, no divergence).
450
+ */
451
+ declare function reachablePageSequence(form: FormSchema, data: ResponseData): string[];
452
+ /**
453
+ * The page ids reachable for the given data. The unordered set of
454
+ * {@link reachablePageSequence} — one shared walk keeps the ordered navigation
455
+ * and the reachability the validator uses provably in agreement. THE function
456
+ * the client renderer and server validator both use to decide which pages/fields
457
+ * are in play.
458
+ */
459
+ declare function reachablePageIds(form: FormSchema, data: ResponseData): Set<string>;
460
+ /**
461
+ * Whether `pageId` is terminal for the given data — pressing the footer button
462
+ * there submits rather than advancing. True when a matched jump rule resolves to
463
+ * "end", or when the page is the LAST element of the reachable sequence (the
464
+ * last reachable page, including a cycle broken at its revisit, so a backward
465
+ * jump can never loop — the pre-revisit page becomes terminal and Submit
466
+ * appears). For a no-jump form this is exactly "the last page".
467
+ */
468
+ declare function isTerminalPage(form: FormSchema, pageId: string, data: ResponseData): boolean;
469
+ /** Reachable field ids: fields on reachable pages, intersected with visibility. */
470
+ declare function reachableFieldIds(form: FormSchema, data: ResponseData): Set<string>;
471
+ /** Fields that are both reachable AND visible — the set that gets validated and
472
+ * kept on submit. Equals {@link visibleFields} for a form with no jumps. */
473
+ declare function reachableFields(form: FormSchema, data: ResponseData): Field[];
383
474
 
384
475
  /** Validate a single answered value for a field. Returns an error message or null. */
385
476
  declare function validateField(field: Field, value: FieldValue): string | null;
@@ -387,12 +478,18 @@ interface ValidationResult {
387
478
  ok: boolean;
388
479
  /** fieldId -> message for every failing field. */
389
480
  errors: Record<string, string>;
390
- /** Data trimmed to the fields that are visible (hidden answers are dropped). */
481
+ /** Data trimmed to the fields that are reachable + visible (answers to fields
482
+ * hidden by logic OR on pages skipped by a jump/early-end are dropped). */
391
483
  data: ResponseData;
392
484
  }
393
485
  /**
394
- * Validate a full submission against the schema. Only currently-visible fields
395
- * are validated and kept — answers to fields hidden by logic are discarded.
486
+ * Validate a full submission against the schema. Only reachable + currently-
487
+ * visible fields are validated and kept — answers to fields hidden by logic, or
488
+ * on pages a page-jump/early-end skipped, are discarded. A legitimately
489
+ * early-ended submission therefore does NOT 422 on a skipped page's required
490
+ * field. Reachability is computed by the SAME shared engine the client renderer
491
+ * navigates with, so render and validate always agree. A form with no jumps has
492
+ * every page reachable, so this validates exactly as before.
396
493
  */
397
494
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
398
495
 
@@ -409,6 +506,14 @@ declare const FILLO_SDK_VERSION: string;
409
506
  * breaks are gated separately by FILLO_SCHEMA_VERSION, so this floor stays low.
410
507
  */
411
508
  declare const FILLO_MIN_SDK_VERSION = "0.4.0";
509
+ /**
510
+ * The floor served INSTEAD of FILLO_MIN_SDK_VERSION for challenge-enabled
511
+ * forms: the first release that ships the Turnstile widget. An older SDK passes
512
+ * the base floor but renders no widget, so the server would reject its every
513
+ * submit — the raised floor makes it fail fast with the clear "update
514
+ * @usefillo/*" error instead of a form that silently can't submit.
515
+ */
516
+ declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
412
517
  declare function normalizeSettings(value: unknown): FormSettings;
413
518
  interface SchemaValidationResult {
414
519
  ok: boolean;
@@ -578,6 +683,18 @@ interface FilloClientOptions {
578
683
  baseUrl?: string;
579
684
  fetch?: typeof fetch;
580
685
  }
686
+ /**
687
+ * Public human-verification challenge config the SDK needs to render a widget.
688
+ * Delivered as a TOP-LEVEL field on the form GET (never inside the schema): the
689
+ * schema's trust policy is server-only and stripped, but the widget needs the
690
+ * PUBLIC site key. Injected server-side from Fillo's env — the SECRET key never
691
+ * leaves the server. Absent = no challenge (render nothing, load no script).
692
+ */
693
+ interface ChallengeConfig {
694
+ provider: "turnstile";
695
+ /** Cloudflare Turnstile PUBLIC site key. Safe to ship to the browser. */
696
+ siteKey: string;
697
+ }
581
698
  interface PublishedForm {
582
699
  id: string;
583
700
  slug: string;
@@ -593,6 +710,9 @@ interface PublishedForm {
593
710
  closed?: boolean;
594
711
  /** Workspace branding state — absent means show the badge (default). */
595
712
  branding?: FormBranding;
713
+ /** Human-verification challenge to render before submit, when the form
714
+ * requires one. Absent = no challenge. Carries only the PUBLIC site key. */
715
+ challenge?: ChallengeConfig;
596
716
  }
597
717
  type SubmitResult = {
598
718
  ok: true;
@@ -638,6 +758,13 @@ interface SubmitMeta {
638
758
  * stable user/account id.
639
759
  */
640
760
  respondent?: FilloRespondent;
761
+ /**
762
+ * Human-verification challenge token (e.g. from the Cloudflare Turnstile
763
+ * widget). Present only when the form requires a challenge; the server
764
+ * verifies it and rejects the submit if it is missing or invalid. Never a
765
+ * secret — it is a single-use, server-verifiable proof of the widget solve.
766
+ */
767
+ challengeToken?: string;
641
768
  }
642
769
  /**
643
770
  * The host app's account context for the person filling the form. Passed as
@@ -726,6 +853,10 @@ interface SyncFormResult {
726
853
  formId: string;
727
854
  slug: string;
728
855
  branding?: FormBranding;
856
+ /** Human-verification challenge to render before submit, when the LIVE form
857
+ * requires one (staged changes don't gate until published). Absent = no
858
+ * challenge. Carries only the PUBLIC site key. */
859
+ challenge?: ChallengeConfig;
729
860
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
730
861
  status?: "draft" | "published";
731
862
  /** Changes were staged as a draft for a human to publish. */
@@ -946,6 +1077,24 @@ interface FormControllerOptions {
946
1077
  * the respondent can do.
947
1078
  */
948
1079
  respondent?: FilloRespondent;
1080
+ /**
1081
+ * True when the form requires a human-verification challenge (Turnstile).
1082
+ * When set, submit refuses to send until a token is available and always
1083
+ * attaches the token from {@link getChallengeToken}. The server is the real
1084
+ * gate — this only avoids firing a submit the server would reject.
1085
+ */
1086
+ challengeRequired?: boolean;
1087
+ /**
1088
+ * Read the current challenge token (from the rendered widget) at submit time.
1089
+ * Returns undefined until the challenge is solved. Read lazily so an expired
1090
+ * token that was refreshed just before submit is picked up fresh.
1091
+ */
1092
+ getChallengeToken?: () => string | undefined;
1093
+ /**
1094
+ * The server rejected the submission's challenge (stale/replayed/invalid
1095
+ * token). The renderer resets the widget so the human can solve a fresh one.
1096
+ */
1097
+ onChallengeFailed?: () => void;
949
1098
  }
950
1099
  interface FormControllerState {
951
1100
  data: ResponseData;
@@ -1064,6 +1213,8 @@ interface AutoSubmitContext {
1064
1213
  form: FormSchema;
1065
1214
  data: ResponseData;
1066
1215
  status: FormStatus;
1216
+ /** Terminal-aware last-page flag from the controller. Advisory here —
1217
+ * shouldAutoSubmit re-derives terminal from the RESULTING data (see below). */
1067
1218
  isLastPage: boolean;
1068
1219
  uploading: boolean;
1069
1220
  }
@@ -1075,7 +1226,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
1075
1226
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
1076
1227
  * names are public API — renames are breaking; additions are minors.
1077
1228
  */
1078
- declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume"];
1229
+ declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume", "turnstile"];
1079
1230
  type FilloSlot = (typeof FILLO_SLOTS)[number];
1080
1231
  /** data-* names emitted alongside the stable fillo-* classes. */
1081
1232
  declare const FILLO_DATA_ATTRS: {
@@ -1211,6 +1362,8 @@ interface FilloStrings {
1211
1362
  editNotice: string;
1212
1363
  /** The discard action next to the resume notice. */
1213
1364
  resumeStartOver: string;
1365
+ /** Shown when the human-verification widget can't load (script blocked). */
1366
+ challengeUnavailable: string;
1214
1367
  }
1215
1368
  declare const DEFAULT_STRINGS: FilloStrings;
1216
1369
  /**
@@ -1258,6 +1411,10 @@ interface SyncedForm {
1258
1411
  formId: string;
1259
1412
  slug: string;
1260
1413
  branding?: FormBranding;
1414
+ /** Human-verification challenge to render before submit, when the LIVE form
1415
+ * requires one (staged changes don't gate until published). Absent = no
1416
+ * challenge. Carries only the PUBLIC site key. */
1417
+ challenge?: ChallengeConfig;
1261
1418
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
1262
1419
  status?: "draft" | "published";
1263
1420
  /** Changes were staged as a draft for a human to publish. */
@@ -1430,4 +1587,4 @@ declare class Sha1 {
1430
1587
  }
1431
1588
  declare const sha1Base64: (bytes: Uint8Array) => string;
1432
1589
 
1433
- export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
1590
+ export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, isTerminalPage, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
package/dist/index.js CHANGED
@@ -54,9 +54,13 @@ function evalCondition(cond, resolve) {
54
54
  }
55
55
  }
56
56
  }
57
+ function conditionsMet(conds, resolve) {
58
+ if (conds.length === 0) return true;
59
+ return conds.every((cond) => evalCondition(cond, resolve));
60
+ }
57
61
  function blockVisibleWith(block, resolve) {
58
62
  if (!block.visibleIf || block.visibleIf.length === 0) return true;
59
- return block.visibleIf.every((cond) => evalCondition(cond, resolve));
63
+ return conditionsMet(block.visibleIf, resolve);
60
64
  }
61
65
  function makeResolver(data, scoped, visible) {
62
66
  return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
@@ -108,6 +112,70 @@ function visibleFields(form, data) {
108
112
  const visible = visibleFieldIds(fields, data);
109
113
  return fields.filter((f) => visible.has(f.id));
110
114
  }
115
+ function formResolver(form, data) {
116
+ const fields = allFields(form);
117
+ const scoped = new Set(fields.map((f) => f.id));
118
+ return makeResolver(data, scoped, visibleFieldIds(fields, data));
119
+ }
120
+ function resolveNextPage(form, currentPageId, data) {
121
+ const page = form.pages.find((p) => p.id === currentPageId);
122
+ const rules = page?.next;
123
+ if (!rules || rules.length === 0) return { linear: true };
124
+ const resolve = formResolver(form, data);
125
+ for (const rule of rules) {
126
+ if (conditionsMet(rule.when, resolve)) {
127
+ return rule.to === "end" ? { end: true } : { to: rule.to };
128
+ }
129
+ }
130
+ return { linear: true };
131
+ }
132
+ function reachablePageSequence(form, data) {
133
+ const seq = [];
134
+ if (form.pages.length === 0) return seq;
135
+ const seen = /* @__PURE__ */ new Set();
136
+ let index = 0;
137
+ for (let steps = 0; steps <= form.pages.length; steps++) {
138
+ const page = form.pages[index];
139
+ if (!page || seen.has(page.id)) break;
140
+ seen.add(page.id);
141
+ seq.push(page.id);
142
+ const nav = resolveNextPage(form, page.id, data);
143
+ if ("end" in nav) break;
144
+ if ("to" in nav) {
145
+ const target = form.pages.findIndex((p) => p.id === nav.to);
146
+ if (target < 0) break;
147
+ index = target;
148
+ } else {
149
+ index += 1;
150
+ if (index >= form.pages.length) break;
151
+ }
152
+ }
153
+ return seq;
154
+ }
155
+ function reachablePageIds(form, data) {
156
+ return new Set(reachablePageSequence(form, data));
157
+ }
158
+ function isTerminalPage(form, pageId, data) {
159
+ if ("end" in resolveNextPage(form, pageId, data)) return true;
160
+ const seq = reachablePageSequence(form, data);
161
+ return seq.length > 0 && seq[seq.length - 1] === pageId;
162
+ }
163
+ function reachableFieldIds(form, data) {
164
+ const reachablePages = reachablePageIds(form, data);
165
+ const visible = visibleFieldIds(allFields(form), data);
166
+ const ids = /* @__PURE__ */ new Set();
167
+ for (const page of form.pages) {
168
+ if (!reachablePages.has(page.id)) continue;
169
+ for (const block of page.blocks) {
170
+ if (isField(block) && visible.has(block.id)) ids.add(block.id);
171
+ }
172
+ }
173
+ return ids;
174
+ }
175
+ function reachableFields(form, data) {
176
+ const ids = reachableFieldIds(form, data);
177
+ return allFields(form).filter((f) => ids.has(f.id));
178
+ }
111
179
 
112
180
  // src/validation.ts
113
181
  import * as z from "zod/mini";
@@ -440,7 +508,8 @@ var DEFAULT_STRINGS = {
440
508
  renderFailed: "This form could not be rendered.",
441
509
  resumeNotice: "Picked up where you left off.",
442
510
  editNotice: "You're updating your earlier response.",
443
- resumeStartOver: "Start over"
511
+ resumeStartOver: "Start over",
512
+ challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
444
513
  };
445
514
  var DEFAULT_FIELD_STRINGS = {
446
515
  required: REQUIRED_FIELD_MESSAGE,
@@ -617,7 +686,7 @@ function validateField(field2, value) {
617
686
  }
618
687
  }
619
688
  function validateResponse(form, data) {
620
- const fields = visibleFields(form, data);
689
+ const fields = reachableFields(form, data);
621
690
  const errors = {};
622
691
  const cleaned = {};
623
692
  for (const field2 of fields) {
@@ -688,7 +757,11 @@ var blockSchema = z2.looseObject({
688
757
  var pageSchema = z2.object({
689
758
  id: idSchema,
690
759
  title: z2.optional(z2.string().check(z2.maxLength(500))),
691
- blocks: z2.array(blockSchema).check(z2.maxLength(500))
760
+ blocks: z2.array(blockSchema).check(z2.maxLength(500)),
761
+ // Optional conditional page flow. Kept as unknown here (this schema is strict,
762
+ // so it would otherwise be dropped at parse) and normalized by hand below once
763
+ // the page-id set is known — a jump to a missing page is dropped, never kept.
764
+ next: z2.optional(z2.unknown())
692
765
  });
693
766
  var schemaShape = z2.object({
694
767
  version: z2.literal(1),
@@ -699,8 +772,9 @@ var schemaShape = z2.object({
699
772
  });
700
773
  var MAX_SCHEMA_VERSION = 1;
701
774
  var FILLO_SCHEMA_VERSION = 1;
702
- var FILLO_SDK_VERSION = true ? "0.8.0" : "0.0.0-dev";
775
+ var FILLO_SDK_VERSION = true ? "0.9.0" : "0.0.0-dev";
703
776
  var FILLO_MIN_SDK_VERSION = "0.4.0";
777
+ var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
704
778
  function str(value, max, fallback = "") {
705
779
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
706
780
  }
@@ -755,6 +829,23 @@ function conditions(value) {
755
829
  });
756
830
  return normalized.length ? normalized : void 0;
757
831
  }
832
+ function jumpRules(value, pageIds, sourceId, allowedFieldIds) {
833
+ if (!Array.isArray(value)) return void 0;
834
+ const rules = value.slice(0, 50).flatMap((item) => {
835
+ if (!item || typeof item !== "object") return [];
836
+ const rec = item;
837
+ const to = typeof rec.to === "string" ? rec.to : void 0;
838
+ if (!to || to !== "end" && !pageIds.has(to)) return [];
839
+ if (to === sourceId) return [];
840
+ const rawWhen = rec.when;
841
+ if (rawWhen !== void 0 && !Array.isArray(rawWhen)) return [];
842
+ const when2 = conditions(rawWhen);
843
+ if (Array.isArray(rawWhen) && rawWhen.length > 0 && when2 === void 0) return [];
844
+ if (when2 && when2.some((c) => !allowedFieldIds.has(c.fieldId))) return [];
845
+ return [{ when: when2 ?? [], to }];
846
+ });
847
+ return rules.length ? rules : void 0;
848
+ }
758
849
  function baseBlock(rec) {
759
850
  return {
760
851
  id: str(rec.id, 128),
@@ -798,6 +889,17 @@ function normalizeResponseLimit(value) {
798
889
  onRepeat
799
890
  };
800
891
  }
892
+ function normalizeTrust(value) {
893
+ if (!value || typeof value !== "object") return void 0;
894
+ const rec = value;
895
+ const unverified = rec.unverified === "allow" || rec.unverified === "quarantine" ? rec.unverified : void 0;
896
+ const challenge = rec.challenge === "turnstile" ? "turnstile" : void 0;
897
+ if (!unverified && !challenge) return void 0;
898
+ return {
899
+ ...unverified ? { unverified } : {},
900
+ ...challenge ? { challenge } : {}
901
+ };
902
+ }
801
903
  function normalizeSettings(value) {
802
904
  const rec = value && typeof value === "object" ? value : {};
803
905
  const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
@@ -809,6 +911,7 @@ function normalizeSettings(value) {
809
911
  redirectUrl: normalizeUrl(rec.redirectUrl),
810
912
  showProgress: bool(rec.showProgress),
811
913
  responseLimit: normalizeResponseLimit(rec.responseLimit),
914
+ trust: normalizeTrust(rec.trust),
812
915
  notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
813
916
  sendReceipt: bool(rec.sendReceipt),
814
917
  saveProgress: bool(rec.saveProgress),
@@ -948,15 +1051,24 @@ function normalizeFormSchema(input) {
948
1051
  if (parsed.data.version > MAX_SCHEMA_VERSION) {
949
1052
  return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
950
1053
  }
951
- const pages = parsed.data.pages.map((page) => {
1054
+ const rebuilt = parsed.data.pages.map((page) => {
952
1055
  const blocks = page.blocks.flatMap((raw) => {
953
1056
  const block = normalizeBlock(raw);
954
1057
  return block ? [block] : [];
955
1058
  });
1059
+ return { id: str(page.id, 128), title: optionalStr(page.title, 500), blocks, next: page.next };
1060
+ });
1061
+ const targetIds = new Set(rebuilt.map((p) => p.id));
1062
+ const priorFieldIds = /* @__PURE__ */ new Set();
1063
+ const pages = rebuilt.map((page) => {
1064
+ for (const block of page.blocks) {
1065
+ if (isField(block)) priorFieldIds.add(block.id);
1066
+ }
956
1067
  return cleanObject({
957
- id: str(page.id, 128),
958
- title: optionalStr(page.title, 500),
959
- blocks
1068
+ id: page.id,
1069
+ title: page.title,
1070
+ blocks: page.blocks,
1071
+ next: jumpRules(page.next, targetIds, page.id, new Set(priorFieldIds))
960
1072
  });
961
1073
  });
962
1074
  const pageIds = /* @__PURE__ */ new Set();
@@ -2353,6 +2465,8 @@ function isDraftGone(err) {
2353
2465
  return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
2354
2466
  }
2355
2467
  var DRAFT_DEBOUNCE_MS = 1500;
2468
+ var CHALLENGE_INCOMPLETE_MESSAGE = "Please complete the verification check, then submit.";
2469
+ var CHALLENGE_RETRY_MESSAGE = "That verification didn't go through. Please complete the check again and resubmit.";
2356
2470
  function submitFailureMessage(err) {
2357
2471
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
2358
2472
  return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
@@ -2458,7 +2572,10 @@ function createFormController(options2) {
2458
2572
  // keeps it — never a field whose answer submit would silently drop.
2459
2573
  blocks: visiblePageBlocks(form, page, data),
2460
2574
  isFirstPage: clamped === 0,
2461
- isLastPage: clamped === pageCount - 1,
2575
+ // Terminal-aware: true when the current page is the last reachable page OR
2576
+ // a matched jump rule ends the form here — so the footer reads Submit and
2577
+ // its handler submits. Same shared engine the validator reaches with.
2578
+ isLastPage: isTerminalPage(form, page.id, data),
2462
2579
  uploading: uploadingFields.size > 0,
2463
2580
  submitError,
2464
2581
  restoredSubmission,
@@ -2659,6 +2776,20 @@ function createFormController(options2) {
2659
2776
  else uploadingFields.delete(fieldId);
2660
2777
  notify();
2661
2778
  }
2779
+ function currentSeqPosition(seq) {
2780
+ if (seq.length === 0) return -1;
2781
+ const pageCount = form.pages.length;
2782
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
2783
+ const currentId = form.pages[clamped]?.id;
2784
+ const exact = currentId === void 0 ? -1 : seq.indexOf(currentId);
2785
+ if (exact >= 0) return exact;
2786
+ let fallback = 0;
2787
+ for (let i = 0; i < seq.length; i++) {
2788
+ const idx = form.pages.findIndex((p) => p.id === seq[i]);
2789
+ if (idx >= 0 && idx <= clamped) fallback = i;
2790
+ }
2791
+ return fallback;
2792
+ }
2662
2793
  function next() {
2663
2794
  const pageCount = form.pages.length;
2664
2795
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
@@ -2670,14 +2801,31 @@ function createFormController(options2) {
2670
2801
  return;
2671
2802
  }
2672
2803
  }
2673
- pageIndex = Math.min(clamped + 1, pageCount - 1);
2804
+ const current = form.pages[clamped];
2805
+ const seq = reachablePageSequence(form, data);
2806
+ const pos = currentSeqPosition(seq);
2807
+ const terminal = (current ? isTerminalPage(form, current.id, data) : true) || pos < 0 || pos + 1 >= seq.length;
2808
+ if (terminal) {
2809
+ void submit();
2810
+ return;
2811
+ }
2812
+ const nextIndex = form.pages.findIndex((p) => p.id === seq[pos + 1]);
2813
+ pageIndex = nextIndex >= 0 ? nextIndex : Math.min(clamped + 1, pageCount - 1);
2674
2814
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
2675
2815
  notify();
2676
2816
  checkpointDraft();
2677
2817
  }
2678
2818
  function back() {
2679
2819
  errors = {};
2680
- pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2820
+ const seq = reachablePageSequence(form, data);
2821
+ const pos = currentSeqPosition(seq);
2822
+ if (pos > 0) {
2823
+ const prevIndex = form.pages.findIndex((p) => p.id === seq[pos - 1]);
2824
+ pageIndex = prevIndex >= 0 ? prevIndex : Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2825
+ } else if (seq.length > 0) {
2826
+ const firstIndex = form.pages.findIndex((p) => p.id === seq[0]);
2827
+ pageIndex = firstIndex >= 0 ? firstIndex : 0;
2828
+ }
2681
2829
  notify();
2682
2830
  checkpointDraft();
2683
2831
  }
@@ -2710,6 +2858,13 @@ function createFormController(options2) {
2710
2858
  const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
2711
2859
  const submissionKey = visitorKey ?? idempotencyKey;
2712
2860
  const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
2861
+ const challengeToken = options2.challengeRequired ? options2.getChallengeToken?.() : void 0;
2862
+ if (options2.challengeRequired && !challengeToken) {
2863
+ status = "idle";
2864
+ submitError = CHALLENGE_INCOMPLETE_MESSAGE;
2865
+ notify();
2866
+ return;
2867
+ }
2713
2868
  status = "submitting";
2714
2869
  submitError = void 0;
2715
2870
  notify();
@@ -2721,10 +2876,17 @@ function createFormController(options2) {
2721
2876
  surface: options2.surface ?? "headless",
2722
2877
  submissionKey,
2723
2878
  draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
2724
- respondent
2879
+ respondent,
2880
+ challengeToken
2725
2881
  });
2726
2882
  } catch (err) {
2727
2883
  status = "idle";
2884
+ if (isFilloError(err) && err.code === "challenge_failed") {
2885
+ submitError = CHALLENGE_RETRY_MESSAGE;
2886
+ options2.onChallengeFailed?.();
2887
+ notify();
2888
+ return;
2889
+ }
2728
2890
  submitError = submitFailureMessage(err);
2729
2891
  notify();
2730
2892
  throw err;
@@ -2833,7 +2995,7 @@ function needsExplicitSubmit(visible) {
2833
2995
  }
2834
2996
  function shouldAutoSubmit(field2, value, ctx) {
2835
2997
  if (ctx.form.settings.submitMode !== "auto") return false;
2836
- if (!ctx.isLastPage || ctx.status !== "idle" || ctx.uploading) return false;
2998
+ if (ctx.status !== "idle" || ctx.uploading) return false;
2837
2999
  const hasDiscreteAnswer = (() => {
2838
3000
  switch (field2.kind) {
2839
3001
  case "select":
@@ -2851,7 +3013,9 @@ function shouldAutoSubmit(field2, value, ctx) {
2851
3013
  if (!hasDiscreteAnswer) return false;
2852
3014
  if (value === ctx.data[field2.id]) return false;
2853
3015
  const nextData = { ...ctx.data, [field2.id]: value };
2854
- const interactive = visibleFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
3016
+ const page = ctx.form.pages.find((p) => p.blocks.some((b) => b.id === field2.id));
3017
+ if (!page || !isTerminalPage(ctx.form, page.id, nextData)) return false;
3018
+ const interactive = reachableFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
2855
3019
  if (interactive.length !== 1 || interactive[0]?.id !== field2.id) return false;
2856
3020
  return validateResponse(ctx.form, nextData).ok;
2857
3021
  }
@@ -2877,7 +3041,8 @@ var FILLO_SLOTS = [
2877
3041
  "footer",
2878
3042
  "button",
2879
3043
  "success",
2880
- "resume"
3044
+ "resume",
3045
+ "turnstile"
2881
3046
  ];
2882
3047
  var FILLO_DATA_ATTRS = {
2883
3048
  /** Slot name, on every slot element: `data-fillo="control"`. */
@@ -2985,7 +3150,7 @@ function formSchemasEqual(left, right) {
2985
3150
  var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
2986
3151
  var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
2987
3152
  function storageKey(client, handle) {
2988
- return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
3153
+ return `fillo:sync:v3:${client.baseUrl}|${client.key}|${handle}`;
2989
3154
  }
2990
3155
  function readStoredSync(client, handle, hash) {
2991
3156
  try {
@@ -3003,6 +3168,7 @@ function readStoredSync(client, handle, hash) {
3003
3168
  }
3004
3169
  function writeStoredSync(client, handle, hash, r) {
3005
3170
  if (r.staged || r.resolvedSchema || r.syncError) return;
3171
+ if (r.challenge) return;
3006
3172
  try {
3007
3173
  globalThis.localStorage?.setItem(
3008
3174
  storageKey(client, handle),
@@ -3415,6 +3581,7 @@ export {
3415
3581
  DEFAULT_FIELD_STRINGS,
3416
3582
  DEFAULT_STRINGS,
3417
3583
  DRAFT_KINDS,
3584
+ FILLO_CHALLENGE_MIN_SDK_VERSION,
3418
3585
  FILLO_DATA_ATTRS,
3419
3586
  FILLO_MIN_SDK_VERSION,
3420
3587
  FILLO_SCHEMA_VERSION,
@@ -3433,6 +3600,7 @@ export {
3433
3600
  allFields,
3434
3601
  assembleForm,
3435
3602
  codeFormFromJsx,
3603
+ conditionsMet,
3436
3604
  contentHash,
3437
3605
  countryByDialCode,
3438
3606
  countryByIso,
@@ -3454,6 +3622,7 @@ export {
3454
3622
  isField,
3455
3623
  isFilloError,
3456
3624
  isPossiblePhone,
3625
+ isTerminalPage,
3457
3626
  needsExplicitSubmit,
3458
3627
  normalizeFormSchema,
3459
3628
  normalizeFormTheme,
@@ -3463,6 +3632,11 @@ export {
3463
3632
  positionPhonePopover,
3464
3633
  prefillFromParams,
3465
3634
  provisionWorkspace,
3635
+ reachableFieldIds,
3636
+ reachableFields,
3637
+ reachablePageIds,
3638
+ reachablePageSequence,
3639
+ resolveNextPage,
3466
3640
  resolveSlotClass,
3467
3641
  resolveStrings,
3468
3642
  resolveText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [