@usefillo/core 0.7.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;
@@ -67,6 +79,8 @@ interface RatingField extends BaseField {
67
79
  kind: "rating";
68
80
  /** Number of steps, default 5. */
69
81
  max?: number;
82
+ /** Optional analysis meaning. CSAT requires a 1–5 rating. */
83
+ insightsMetric?: "csat";
70
84
  }
71
85
  interface DateField extends BaseField {
72
86
  kind: "date";
@@ -79,6 +93,8 @@ interface LinearScaleField extends BaseField {
79
93
  max?: number;
80
94
  minLabel?: string;
81
95
  maxLabel?: string;
96
+ /** Optional analysis meaning. NPS requires 0–10; CSAT requires 1–5. */
97
+ insightsMetric?: "csat" | "nps";
82
98
  }
83
99
  interface RankingField extends BaseField {
84
100
  kind: "ranking";
@@ -147,6 +163,10 @@ interface FormPage {
147
163
  id: string;
148
164
  title?: string;
149
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[];
150
170
  }
151
171
  /**
152
172
  * Limit repeat responses. Absent = no limit (submit as often as you like).
@@ -181,6 +201,22 @@ interface ResponseLimit {
181
201
  */
182
202
  onRepeat: "keep" | "update";
183
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
+ }
184
220
  interface FormSettings {
185
221
  /**
186
222
  * Default "button": respondents submit with the footer button. "auto" hides
@@ -196,6 +232,9 @@ interface FormSettings {
196
232
  /** Limit repeat responses (who counts as the same responder, and what a
197
233
  * repeat does). Absent = no limit. See {@link ResponseLimit}. */
198
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;
199
238
  /** Notify this address on every submission. */
200
239
  notifyEmail?: string;
201
240
  /** Send respondents a receipt (to the first answered email field). */
@@ -291,6 +330,10 @@ type UploadStatus = "pending" | "uploading" | "complete" | "aborted";
291
330
  * - "gdrive": direct to a Google Drive resumable session URL (Content-Range
292
331
  * protocol).
293
332
  * - "s3-put": a single PUT to a presigned S3/R2 URL — straight to the bucket.
333
+ * Retained for sessions created by older Fillo servers.
334
+ * - "s3-multipart": resumable S3/R2 multipart upload. The client asks Fillo
335
+ * for one short-lived UploadPart URL at a time; only the server can assemble
336
+ * the parts into an object.
294
337
  * - "box": direct to Box with a folder-scoped upload token — small files via a
295
338
  * single multipart POST, large files via Box's chunked session (the client
296
339
  * computes the SHA-1 digests Box requires). The server commits/verifies in the
@@ -302,6 +345,8 @@ type UploadTransport = {
302
345
  } | {
303
346
  type: "s3-put";
304
347
  uploadUrl: string;
348
+ } | {
349
+ type: "s3-multipart";
305
350
  } | {
306
351
  type: "box";
307
352
  mode: "simple";
@@ -341,9 +386,29 @@ interface UploadSession {
341
386
  file?: FileValue;
342
387
  }
343
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;
344
396
  /** All conditions must hold (AND). No conditions = visible. */
345
397
  declare function isBlockVisible(block: Block, data: ResponseData): boolean;
346
398
  declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
399
+ /**
400
+ * Blocks to render on `page`, resolved against the WHOLE-FORM visibility
401
+ * fixpoint rather than just this page's own fields. A field whose `visibleIf`
402
+ * references an answer on another page must appear/validate here exactly when
403
+ * the server would keep that answer — and the server (validateResponse →
404
+ * visibleFields) uses the whole-form fixpoint, hiding any controlling field
405
+ * that is itself logic-hidden. Scoping per page instead reads a stale answer
406
+ * behind a hidden cross-page trigger as still-answered, so the client would
407
+ * render (and validate) a field whose answer the server then silently drops.
408
+ * Use this for anything that must agree with validateResponse; the per-page
409
+ * `visibleBlocks` remains for callers holding only a single page.
410
+ */
411
+ declare function visiblePageBlocks(form: FormSchema, page: FormPage, data: ResponseData): Block[];
347
412
  /** Every field in the form (across pages), in order. */
348
413
  declare function allFields(form: FormSchema): Field[];
349
414
  /**
@@ -355,8 +420,57 @@ declare function allFields(form: FormSchema): Field[];
355
420
  * per-visitor key and the server's per-person dedup so both scope identically.
356
421
  */
357
422
  declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
358
- /** Fields currently visible given the response data — the set that gets validated. */
423
+ /** Fields currently visible given the response data. */
359
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[];
360
474
 
361
475
  /** Validate a single answered value for a field. Returns an error message or null. */
362
476
  declare function validateField(field: Field, value: FieldValue): string | null;
@@ -364,18 +478,42 @@ interface ValidationResult {
364
478
  ok: boolean;
365
479
  /** fieldId -> message for every failing field. */
366
480
  errors: Record<string, string>;
367
- /** 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). */
368
483
  data: ResponseData;
369
484
  }
370
485
  /**
371
- * Validate a full submission against the schema. Only currently-visible fields
372
- * 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.
373
493
  */
374
494
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
375
495
 
376
496
  declare const FILLO_SCHEMA_VERSION: 1;
377
497
  /** Injected from package.json at build time (tsup define) — never hand-edited. */
378
498
  declare const FILLO_SDK_VERSION: string;
499
+ /**
500
+ * The oldest published @usefillo/* SDK that can still render a form the current
501
+ * server serves. This is a DELIBERATE floor — bump it BY HAND only when a
502
+ * genuinely wire-breaking change ships (a new required request/response field an
503
+ * old SDK can't produce or read). It must never be tied to FILLO_SDK_VERSION:
504
+ * the server used to serve its own build version as the min, so every release
505
+ * 426'd every customer still on an older pinned SDK. Field-kind/schema-shape
506
+ * breaks are gated separately by FILLO_SCHEMA_VERSION, so this floor stays low.
507
+ */
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";
379
517
  declare function normalizeSettings(value: unknown): FormSettings;
380
518
  interface SchemaValidationResult {
381
519
  ok: boolean;
@@ -457,6 +595,18 @@ declare function parsePhone(value: string, fallback?: PhoneCountry): ParsedPhone
457
595
  * validation. `country` (when known) tightens the accepted lengths.
458
596
  */
459
597
  declare function isPossiblePhone(value: string): boolean;
598
+ /** Whether the country-picker popover opens below or above its trigger. */
599
+ type PhonePopoverPlacement = "below" | "above";
600
+ /** Minimum gap kept between the popover and the viewport edge, in px. */
601
+ declare const PHONE_POPOVER_VIEWPORT_GAP = 8;
602
+ /**
603
+ * Decide whether the country-picker popover should open below or above its
604
+ * anchor and size it to fit the viewport, writing the result to CSS custom
605
+ * properties on the popover. Pure DOM math with no framework assumptions, so
606
+ * the React and vanilla renderers share one implementation instead of drifting
607
+ * copies. Returns "below" when there is nothing to position (SSR / detached).
608
+ */
609
+ declare function positionPhonePopover(anchor: HTMLElement | null, popover: HTMLElement | null): PhonePopoverPlacement;
460
610
 
461
611
  /** Display metadata for every block kind — drives the builder palette. */
462
612
  declare const BLOCK_KIND_META: Record<BlockKind, {
@@ -491,6 +641,7 @@ interface FieldSpec {
491
641
  max?: number;
492
642
  minLabel?: string;
493
643
  maxLabel?: string;
644
+ insightsMetric?: "csat" | "nps";
494
645
  rows?: string[];
495
646
  columns?: string[];
496
647
  placeholder?: string;
@@ -532,6 +683,18 @@ interface FilloClientOptions {
532
683
  baseUrl?: string;
533
684
  fetch?: typeof fetch;
534
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
+ }
535
698
  interface PublishedForm {
536
699
  id: string;
537
700
  slug: string;
@@ -547,6 +710,9 @@ interface PublishedForm {
547
710
  closed?: boolean;
548
711
  /** Workspace branding state — absent means show the badge (default). */
549
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;
550
716
  }
551
717
  type SubmitResult = {
552
718
  ok: true;
@@ -592,6 +758,13 @@ interface SubmitMeta {
592
758
  * stable user/account id.
593
759
  */
594
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;
595
768
  }
596
769
  /**
597
770
  * The host app's account context for the person filling the form. Passed as
@@ -656,14 +829,23 @@ interface UploadFileOptions {
656
829
  sessionId?: string;
657
830
  /** Ownership token for the resumed session (from the original create call). */
658
831
  uploadToken?: string;
832
+ /** Persist this handle if the host wants reload-safe resumability. */
833
+ onSession?: (handle: {
834
+ sessionId: string;
835
+ uploadToken?: string;
836
+ }) => void;
659
837
  }
660
838
  declare class FilloError extends Error {
661
839
  status?: number | undefined;
662
840
  /** Server-suggested wait (from a 429's Retry-After header), seconds. */
663
841
  retryAfterSec?: number | undefined;
842
+ /** Stable machine-readable API error code, when the server provides one. */
843
+ code?: string | undefined;
664
844
  constructor(message: string, status?: number | undefined,
665
845
  /** Server-suggested wait (from a 429's Retry-After header), seconds. */
666
- retryAfterSec?: number | undefined);
846
+ retryAfterSec?: number | undefined,
847
+ /** Stable machine-readable API error code, when the server provides one. */
848
+ code?: string | undefined);
667
849
  }
668
850
  /** Duck-typed — `instanceof` breaks when two SDK copies end up in one bundle. */
669
851
  declare function isFilloError(err: unknown): err is FilloError;
@@ -671,10 +853,25 @@ interface SyncFormResult {
671
853
  formId: string;
672
854
  slug: string;
673
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;
674
860
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
675
861
  status?: "draft" | "published";
676
862
  /** Changes were staged as a draft for a human to publish. */
677
863
  staged?: boolean;
864
+ /**
865
+ * Server-authoritative live snapshot. Present when the incoming code schema
866
+ * is not the version respondents may submit against yet.
867
+ */
868
+ resolvedSchema?: FormSchema;
869
+ resolvedTheme?: FormTheme | null;
870
+ /** Non-fatal integration problem; the resolved live snapshot remains usable. */
871
+ syncError?: {
872
+ code: string;
873
+ message: string;
874
+ };
678
875
  warning?: string;
679
876
  }
680
877
  declare class FilloClient {
@@ -689,8 +886,10 @@ declare class FilloClient {
689
886
  /** Fetch a published form definition by id or slug. */
690
887
  getForm(idOrSlug: string): Promise<PublishedForm>;
691
888
  /**
692
- * Upsert a code-defined form into the workspace identified by the client's
693
- * publishable key. Returns the canonical form id used for submissions.
889
+ * Resolve a code-defined form through the workspace identified by the
890
+ * client's publishable key. Depending on workspace policy, changed content
891
+ * may be staged for review or resolved to the authoritative live snapshot.
892
+ * Returns the canonical form id used for submissions.
694
893
  */
695
894
  syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<SyncFormResult>;
696
895
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
@@ -700,8 +899,14 @@ declare class FilloClient {
700
899
  * used to prefill their answers for editing. Requires a VERIFIED identity
701
900
  * ({id, hash}); anything else 404s (returned as null), because an
702
901
  * unverified read would hand anyone's answers to any page script.
902
+ *
903
+ * `scopeValue` scopes the lookup for forms whose response limit is keyed to
904
+ * a field (responseLimit.scopeField) — e.g. one response per article. When
905
+ * the form is scoped, the server returns 404 unless the matching scope value
906
+ * is sent, so a page loaded for article B never prefills article A's answers.
907
+ * Optional and back-compatible: omit it for unscoped forms.
703
908
  */
704
- fetchOwnResponse(formId: string, respondent: FilloRespondent): Promise<{
909
+ fetchOwnResponse(formId: string, respondent: FilloRespondent, scopeValue?: string): Promise<{
705
910
  responseId: string;
706
911
  data: ResponseData;
707
912
  } | null>;
@@ -743,13 +948,12 @@ declare class FilloClient {
743
948
  /** Discard a draft (the "Start over" path). */
744
949
  deleteDraft(draftId: string, token: string): Promise<void>;
745
950
  /** Current state of an upload session — used to resume after interruption. */
746
- getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
951
+ getUploadSession(sessionId: string, token?: string, signal?: AbortSignal): Promise<UploadSession>;
747
952
  /**
748
- * Resumable chunked upload. Creates (or resumes) a session, streams the file
749
- * chunk by chunk with progress callbacks, and finalizes into a FileValue that
750
- * goes into the response data. Survives flaky connections: each chunk is
751
- * retried, and on repeated failure the true offset is re-queried so no byte
752
- * is sent twice or skipped.
953
+ * Provider-aware browser-direct upload. Creates a session, uses the storage
954
+ * transport selected by the server, reports progress, and finalizes into a
955
+ * FileValue. Resumable providers can continue an existing session; one-shot
956
+ * transports are retried according to their own safe semantics.
753
957
  *
754
958
  * Depending on the form's storage settings the server picks a transport:
755
959
  * direct-to-Google-Drive resumable
@@ -768,7 +972,7 @@ declare class FilloClient {
768
972
  /**
769
973
  * Caller signal (if any) combined with a fresh per-request timeout. Created per
770
974
  * fetch so each retry attempt gets its own deadline; the caller's own abort
771
- * still drives the resumable-upload cancel semantics.
975
+ * still drives upload cancellation across every provider transport.
772
976
  */
773
977
  private uploadSignal;
774
978
  /** Retry a request with exponential backoff; never retries an aborted upload. */
@@ -780,6 +984,13 @@ declare class FilloClient {
780
984
  * queries Drive for the authoritative offset).
781
985
  */
782
986
  private driveUploadLoop;
987
+ /**
988
+ * S3-compatible multipart protocol. Fillo owns the provider upload id and is
989
+ * the only party allowed to complete it; the browser receives only narrowly
990
+ * scoped UploadPart URLs. That keeps uploads resumable and means an old or
991
+ * slow browser request cannot materialize an object after server-side abort.
992
+ */
993
+ private s3MultipartUploadLoop;
783
994
  /**
784
995
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
785
996
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
@@ -791,13 +1002,13 @@ interface ProvisionWorkspaceResult {
791
1002
  /** Publishable key (pk_…) for the new workspace. Pass to createClient({ key }). */
792
1003
  key: string;
793
1004
  organizationId: string;
794
- /** A claim link was emailed here so the owner can take ownership later. */
1005
+ /** The private workspace link is emailed directly; `url` stays null. */
795
1006
  claim: {
796
1007
  url: string | null;
797
1008
  email: string | null;
798
1009
  sent: boolean;
799
1010
  };
800
- /** Caps applied until the workspace is claimed. */
1011
+ /** Caps applied until the workspace is saved to an account. */
801
1012
  limits: {
802
1013
  responses: number;
803
1014
  expiresAt: string;
@@ -805,9 +1016,9 @@ interface ProvisionWorkspaceResult {
805
1016
  }
806
1017
  /**
807
1018
  * Provision a Fillo workspace from an email — no signup — and get a publishable
808
- * key back, so a form can collect real responses immediately. A claim link is
809
- * emailed to `email`; until someone claims it the workspace runs as a capped
810
- * preview (limited responses, for a limited time). Built for setup automation,
1019
+ * key back, so a form can collect real responses immediately. A private
1020
+ * workspace link is emailed to `email`; until someone saves it to an account,
1021
+ * the workspace runs as a capped preview (limited responses, for a limited time). Built for setup automation,
811
1022
  * e.g. a coding agent wiring Fillo into an app during integration.
812
1023
  *
813
1024
  * const { key } = await provisionWorkspace({ email: "you@co.com" });
@@ -853,10 +1064,10 @@ interface FormControllerOptions {
853
1064
  */
854
1065
  surface?: "default" | "headless";
855
1066
  /**
856
- * Resolve the submission target at submit time when `formId` is still
857
- * unset — e.g. re-run a code-form sync that failed at mount. Answers are
858
- * held (never dropped) while it runs; failure sets `submitError` and the
859
- * respondent can retry.
1067
+ * Resolve/verify the submission target immediately before submit. Code-form
1068
+ * renderers use this to recover a missing target and to detect a live schema
1069
+ * change after a cached mount. Answers are held (never dropped) while it
1070
+ * runs; failure sets `submitError` and the respondent can retry.
860
1071
  */
861
1072
  resolveFormId?: () => Promise<string>;
862
1073
  /**
@@ -866,6 +1077,24 @@ interface FormControllerOptions {
866
1077
  * the respondent can do.
867
1078
  */
868
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;
869
1098
  }
870
1099
  interface FormControllerState {
871
1100
  data: ResponseData;
@@ -904,6 +1133,27 @@ interface FormControllerState {
904
1133
  * show an "updating your earlier response" notice.
905
1134
  */
906
1135
  editingPrevious: boolean;
1136
+ /**
1137
+ * True when the last submit was accepted as an already-recorded response
1138
+ * rather than a new one (the server returns this only for a VERIFIED
1139
+ * identify() repeat on a keep-mode form). Renderers show an "already
1140
+ * answered" message on the success screen instead of implying a fresh
1141
+ * submission — otherwise a repeat visitor's new answers look saved when the
1142
+ * server kept the original.
1143
+ */
1144
+ duplicateSubmission: boolean;
1145
+ /**
1146
+ * True when the last submit UPDATED the person's existing response in place
1147
+ * (responseLimit onRepeat "update"), rather than creating a new one.
1148
+ */
1149
+ updatedSubmission: boolean;
1150
+ /**
1151
+ * True when a resume link (#fillo-draft=…) could not be adopted because it
1152
+ * was expired, already used, or not this browser's — so no progress was
1153
+ * restored. Renderers surface a "that link expired — start again" notice
1154
+ * instead of showing a silently blank form.
1155
+ */
1156
+ resumeLinkFailed: boolean;
907
1157
  }
908
1158
  interface FormController {
909
1159
  /** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
@@ -963,6 +1213,8 @@ interface AutoSubmitContext {
963
1213
  form: FormSchema;
964
1214
  data: ResponseData;
965
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). */
966
1218
  isLastPage: boolean;
967
1219
  uploading: boolean;
968
1220
  }
@@ -974,7 +1226,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
974
1226
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
975
1227
  * names are public API — renames are breaking; additions are minors.
976
1228
  */
977
- 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"];
978
1230
  type FilloSlot = (typeof FILLO_SLOTS)[number];
979
1231
  /** data-* names emitted alongside the stable fillo-* classes. */
980
1232
  declare const FILLO_DATA_ATTRS: {
@@ -1061,10 +1313,23 @@ declare const FILLO_THEME_VARS: readonly [{
1061
1313
  }];
1062
1314
 
1063
1315
  /**
1064
- * Every visitor-facing string the default renderers emit, overridable as a
1065
- * unit so a localized site never shows stray English at the submit moment.
1316
+ * The default English validation message for a required field left empty.
1317
+ * `validateField` returns this exact sentinel (core has no strings context),
1318
+ * and the React render layer swaps it for `strings.required` so a localized
1319
+ * site can translate it. Keep it identical to `DEFAULT_FIELD_STRINGS.required`.
1320
+ */
1321
+ declare const REQUIRED_FIELD_MESSAGE = "This field is required";
1322
+ /**
1323
+ * The form-chrome strings the default renderers emit, overridable as a unit so
1324
+ * a localized site never shows stray English at the submit moment.
1066
1325
  * Schema-authored text (labels, descriptions, success copy set in settings)
1067
1326
  * always wins over these defaults.
1327
+ *
1328
+ * Field-level and validation copy (required message, upload field, duplicate/
1329
+ * resume notices) lives in {@link FilloFieldStrings} — split out so this
1330
+ * documented, all-`string` surface stays stable while parametrized field
1331
+ * strings can be functions. The renderers resolve both together
1332
+ * ({@link FilloRendererStrings}); the `strings` prop overrides either.
1068
1333
  */
1069
1334
  interface FilloStrings {
1070
1335
  back: string;
@@ -1097,25 +1362,78 @@ interface FilloStrings {
1097
1362
  editNotice: string;
1098
1363
  /** The discard action next to the resume notice. */
1099
1364
  resumeStartOver: string;
1365
+ /** Shown when the human-verification widget can't load (script blocked). */
1366
+ challengeUnavailable: string;
1100
1367
  }
1101
1368
  declare const DEFAULT_STRINGS: FilloStrings;
1102
- declare function resolveStrings(overrides?: Partial<FilloStrings>): FilloStrings;
1369
+ /**
1370
+ * Field-level and validation strings the default renderers emit. Kept apart
1371
+ * from {@link FilloStrings} so parametrized entries can be functions (a
1372
+ * translation places the value where its grammar needs it) without widening
1373
+ * the documented all-`string` chrome surface.
1374
+ */
1375
+ interface FilloFieldStrings {
1376
+ /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
1377
+ required: string;
1378
+ /** Notice when a spent/expired resume link couldn't restore progress. */
1379
+ resumeLinkExpired: string;
1380
+ /** Success-screen message when a verified identity re-submits and the form
1381
+ * keeps the first answer (a visible duplicate, not a fresh response). */
1382
+ alreadyAnswered: string;
1383
+ /** Retry control on a failed upload row. */
1384
+ uploadRetry: string;
1385
+ /** Dropzone copy when uploads can't run (no client / preview). */
1386
+ uploadsDisabled: string;
1387
+ /** An upload attempt failed with no actionable server message. */
1388
+ uploadFailed: string;
1389
+ /** Dropzone call to action; `multiple` is true when several files are allowed. */
1390
+ dropzoneTitle: (multiple: boolean) => string;
1391
+ /** Dropzone hint stating the per-file size limit in MB. */
1392
+ dropzoneHint: (maxMb: number) => string;
1393
+ /** A file exceeded the per-file MB limit before upload started. */
1394
+ fileTooLarge: (maxMb: number) => string;
1395
+ /** Screen-reader status: N uploads in progress. */
1396
+ filesUploading: (count: number) => string;
1397
+ /** Screen-reader status: N uploads failed. */
1398
+ uploadsFailed: (count: number) => string;
1399
+ /** Screen-reader status: N uploads completed. */
1400
+ filesUploaded: (count: number) => string;
1401
+ }
1402
+ declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1403
+ /** Everything the default renderers can localize — chrome + field/validation. */
1404
+ type FilloRendererStrings = FilloStrings & FilloFieldStrings;
1405
+ /** Merge overrides over the built-in chrome + field defaults. Accepts a partial
1406
+ * of the full renderer surface so the `strings` prop can override either set. */
1407
+ declare function resolveStrings(overrides?: Partial<FilloRendererStrings>): FilloRendererStrings;
1103
1408
 
1104
1409
  /** Result of syncing a code-defined form: its canonical id, slug, and branding. */
1105
1410
  interface SyncedForm {
1106
1411
  formId: string;
1107
1412
  slug: string;
1108
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;
1109
1418
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
1110
1419
  status?: "draft" | "published";
1111
1420
  /** Changes were staged as a draft for a human to publish. */
1112
1421
  staged?: boolean;
1422
+ /** Server-authoritative live snapshot when local code is not live yet. */
1423
+ resolvedSchema?: FormSchema;
1424
+ resolvedTheme?: FormTheme | null;
1425
+ /** Non-fatal integration problem; resolvedSchema remains safe to render. */
1426
+ syncError?: {
1427
+ code: string;
1428
+ message: string;
1429
+ };
1113
1430
  warning?: string;
1114
1431
  }
1115
1432
  /**
1116
- * A form whose structure lives in user code. Framework renderers can show it
1117
- * immediately, then sync it into a Fillo workspace when a publishable key is
1118
- * present so responses, uploads, webhooks, and exports work normally.
1433
+ * A form whose structure lives in user code. Development and explicit
1434
+ * render-only usage can show it immediately; production renderers with a
1435
+ * publishable key first resolve the canonical form from Fillo so responses,
1436
+ * uploads, webhooks, and exports stay bound to the published version.
1119
1437
  */
1120
1438
  interface CodeForm {
1121
1439
  /** Stable handle, unique in the workspace. */
@@ -1136,10 +1454,16 @@ declare function isCodeForm(form: unknown): form is CodeForm;
1136
1454
  /** Stable djb2 hash of a string — used to key code-form sync by content. */
1137
1455
  declare function contentHash(input: string): string;
1138
1456
  /**
1139
- * Sync a code-defined form into a workspace at most once per session for the
1140
- * same client, handle, schema, and theme. `bypassCache` forces the network —
1141
- * used by submit-time resolution, where a stale cached formId must not be
1142
- * trusted over a fresh sync.
1457
+ * Compare normalized schema JSON while ignoring object-key order (Postgres
1458
+ * jsonb and custom API proxies may reorder keys). Array order remains part of
1459
+ * the form definition because it controls pages, blocks, and options.
1460
+ */
1461
+ declare function formSchemasEqual(left: FormSchema, right: FormSchema): boolean;
1462
+ /**
1463
+ * Resolve a code-defined form with in-flight dedupe and bounded stable-result
1464
+ * caching (published 1h, draft 60s). Staged/live-fallback results are evicted
1465
+ * once settled so an SPA can observe publication without a hard reload.
1466
+ * `bypassCache` forces the network for submit-time compatibility checks.
1143
1467
  */
1144
1468
  declare function syncCodeForm(client: FilloClient, form: CodeForm, opts?: {
1145
1469
  bypassCache?: boolean;
@@ -1263,4 +1587,4 @@ declare class Sha1 {
1263
1587
  }
1264
1588
  declare const sha1Base64: (bytes: Uint8Array) => string;
1265
1589
 
1266
- 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_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, 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, FilloJsxError, 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, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, 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, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, 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 };