@gravixar/forms 0.1.0 → 0.2.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/README.md CHANGED
@@ -17,8 +17,8 @@ booking endpoint behind BotID.
17
17
 
18
18
  ## Two entry points
19
19
 
20
- - `@gravixar/forms`: client-safe. The field names a form renders, and the types. A test walks this entry's module
21
- graph and fails if it reaches server code, because a client component ships everything it imports.
20
+ - `@gravixar/forms`: client-safe. The field names a form renders, the form clock, and the types. A test walks this
21
+ entry's module graph and fails if it reaches server code, because a client component ships everything it imports.
22
22
  - `@gravixar/forms/server`: everything that runs a submission.
23
23
 
24
24
  ## Define
@@ -39,7 +39,7 @@ export const enquiry = defineForm({
39
39
  email: z.union([z.literal(""), z.email(msg.email[locale])]),
40
40
  message: z.string().trim().min(1, msg.message[locale]).max(5000),
41
41
  }),
42
- // Optional. Default: honeypot "website", time trap on "ts" (2 s to 24 h), no BotID.
42
+ // Optional. Default: honeypot "website", time trap on "te", else "ts" (2 s min; 24 h max for "ts"), no BotID.
43
43
  gate: { botId: checkBotId },
44
44
  // Optional: parts hashed into the id. Omit for a random id.
45
45
  dedupe: (data) => [normalizeEmail(data.email), data.name],
@@ -87,16 +87,58 @@ never `null`, and treats an empty file input as absent. List repeated fields in
87
87
  ## Render
88
88
 
89
89
  ```tsx
90
- import { HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "@gravixar/forms";
91
-
92
- <div className="hp" aria-hidden="true">
93
- <label>Website<input {...honeypotInputProps} /></label>
94
- </div>
95
- <input type="hidden" name={TIMESTAMP_FIELD} value={renderedAt} /> {/* Date.now(), set once on mount */}
90
+ "use client";
91
+ import { useActionState, useEffect, useRef } from "react";
92
+ import {
93
+ ELAPSED_FIELD,
94
+ TIMESTAMP_FIELD,
95
+ createFormClock,
96
+ honeypotInputProps,
97
+ type FormClock,
98
+ type FormState,
99
+ } from "@gravixar/forms";
100
+ import { submitEnquiry } from "@/app/actions";
101
+
102
+ export function EnquiryForm() {
103
+ const [state, action] = useActionState(submitEnquiry, { status: "idle" } as FormState);
104
+ const clock = useRef<FormClock | null>(null);
105
+ useEffect(() => {
106
+ clock.current = createFormClock(); // when the form mounts
107
+ }, []);
108
+ useEffect(() => {
109
+ if (state.status === "ok") clock.current?.reset(); // a second enquiry from this tab is timed from now
110
+ }, [state]);
111
+
112
+ return (
113
+ <form action={action} onSubmit={(e) => clock.current?.stamp(e.currentTarget)}>
114
+ {/* the form's own fields */}
115
+ <div className="hp" aria-hidden="true">
116
+ <label>Website<input {...honeypotInputProps} /></label>
117
+ </div>
118
+ <input type="hidden" name={TIMESTAMP_FIELD} />
119
+ <input type="hidden" name={ELAPSED_FIELD} />
120
+ </form>
121
+ );
122
+ }
96
123
  ```
97
124
 
98
- Put the honeypot off-screen with CSS, not `display: none`, which some bots skip. **A form without the timestamp is
99
- ignored**, so add it before switching a live form over.
125
+ Put the honeypot off-screen with CSS, not `display: none`, which some bots skip.
126
+
127
+ `createFormClock()` has no dependencies and no React in it. `stamp(form)` writes both time fields into the form's
128
+ inputs when it is submitted, adding a hidden input for either one the form doesn't render. React builds a form
129
+ action's `FormData` after `onSubmit` runs, so the stamped values are the ones sent. A form that posts with `fetch`
130
+ calls `clock.stamp(formData)` instead, which sets both fields on its `FormData`. `clock.fields()` returns them as
131
+ `{ ts, te }`.
132
+
133
+ **Why two time fields.** `ts` is the time the form opened by the device's clock, and the gate compares it with the
134
+ server's clock. A phone whose clock runs a few minutes fast makes a person look faster than two seconds, and one
135
+ whose tab stayed open for more than a day looks stale. Either way the gate ignores them while they see "sent".
136
+ `te` is how long the form was open, measured in the browser from start to finish, so a wrong clock cancels out, and
137
+ it has no age limit. **When a form sends a usable `te` (a finite, non-negative number), the gate uses it and
138
+ ignores `ts`. Otherwise, whether `te` is missing or unusable, `ts` is checked as before.** An unusable `te` is
139
+ never rejected on its own: a hand-rolled timer can go negative when the device's clock is corrected, and a bot gains
140
+ nothing because it could leave `te` out. A form that sends neither field is ignored, so add them before switching a
141
+ live form over. Send both: `ts` keeps the form working against a server still on 0.1.
100
142
 
101
143
  ## Delivery steps
102
144
 
@@ -0,0 +1,23 @@
1
+ /** Times a form in the browser, for the gate's time trap. Create it when the form mounts. */
2
+ export interface FormClock {
3
+ /**
4
+ * Both time fields, as strings: `ts`, the time the form opened by the device's clock, and `te`, how long it has
5
+ * been open in milliseconds.
6
+ */
7
+ fields(): {
8
+ ts: string;
9
+ te: string;
10
+ };
11
+ /**
12
+ * Writes both fields into `FormData`, replacing any value, or into a form's inputs of those names, adding a hidden
13
+ * input for any that is missing. Call it in the form's `submit` handler.
14
+ */
15
+ stamp(target: FormData | HTMLFormElement): void;
16
+ /** Starts timing from now. Call it after a successful submission, so the next one from the same tab is timed anew. */
17
+ reset(): void;
18
+ }
19
+ /**
20
+ * A clock for one form. It measures how long the form was open with `performance.now()`, which never jumps when the
21
+ * device's clock is corrected, so the elapsed time is never negative and doesn't depend on the clock being right.
22
+ */
23
+ export declare function createFormClock(): FormClock;
package/dist/clock.js ADDED
@@ -0,0 +1,37 @@
1
+ import { ELAPSED_FIELD, TIMESTAMP_FIELD } from "./fields.js";
2
+ /**
3
+ * A clock for one form. It measures how long the form was open with `performance.now()`, which never jumps when the
4
+ * device's clock is corrected, so the elapsed time is never negative and doesn't depend on the clock being right.
5
+ */
6
+ export function createFormClock() {
7
+ let openedAt = 0;
8
+ let start = 0;
9
+ function reset() {
10
+ openedAt = Date.now();
11
+ start = monotonic();
12
+ }
13
+ function fields() {
14
+ return { [TIMESTAMP_FIELD]: String(openedAt), [ELAPSED_FIELD]: String(Math.round(monotonic() - start)) };
15
+ }
16
+ function stamp(target) {
17
+ for (const [name, value] of Object.entries(fields())) {
18
+ if (!("elements" in target)) {
19
+ target.set(name, value);
20
+ continue;
21
+ }
22
+ let input = target.elements.namedItem(name);
23
+ if (!input) {
24
+ input = target.ownerDocument.createElement("input");
25
+ input.type = "hidden";
26
+ input.name = name;
27
+ target.append(input);
28
+ }
29
+ input.value = value;
30
+ }
31
+ }
32
+ reset();
33
+ return { fields, stamp, reset };
34
+ }
35
+ function monotonic() {
36
+ return typeof performance === "undefined" ? Date.now() : performance.now();
37
+ }
package/dist/fields.d.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  /** The honeypot. It is rendered off-screen, so a person never fills it and a bot that fills every input does. */
2
2
  export declare const HONEYPOT_FIELD = "website";
3
- /** The time the form was rendered, in Unix milliseconds, set once when the form mounts. */
3
+ /**
4
+ * The time the form was rendered, in Unix milliseconds, set once when the form mounts. It is the browser's clock
5
+ * compared with the server's, so a device clock that is minutes off skews it. The gate uses it only when
6
+ * `ELAPSED_FIELD` is absent or unusable.
7
+ */
4
8
  export declare const TIMESTAMP_FIELD = "ts";
9
+ /**
10
+ * How long the form was open, in milliseconds, measured in the browser at submit. Both ends of the measurement are
11
+ * the browser's clock, so a device clock that is off doesn't change it. When it is a finite, non-negative number
12
+ * the gate uses it instead of `TIMESTAMP_FIELD`; anything else is ignored. `createFormClock()` sets both.
13
+ */
14
+ export declare const ELAPSED_FIELD = "te";
5
15
  /**
6
16
  * The honeypot name Gravixar's private backend package used. The gate accepts it as well, so forms that render it
7
17
  * keep working when that package re-exports this one.
package/dist/fields.js CHANGED
@@ -4,8 +4,18 @@
4
4
  */
5
5
  /** The honeypot. It is rendered off-screen, so a person never fills it and a bot that fills every input does. */
6
6
  export const HONEYPOT_FIELD = "website";
7
- /** The time the form was rendered, in Unix milliseconds, set once when the form mounts. */
7
+ /**
8
+ * The time the form was rendered, in Unix milliseconds, set once when the form mounts. It is the browser's clock
9
+ * compared with the server's, so a device clock that is minutes off skews it. The gate uses it only when
10
+ * `ELAPSED_FIELD` is absent or unusable.
11
+ */
8
12
  export const TIMESTAMP_FIELD = "ts";
13
+ /**
14
+ * How long the form was open, in milliseconds, measured in the browser at submit. Both ends of the measurement are
15
+ * the browser's clock, so a device clock that is off doesn't change it. When it is a finite, non-negative number
16
+ * the gate uses it instead of `TIMESTAMP_FIELD`; anything else is ignored. `createFormClock()` sets both.
17
+ */
18
+ export const ELAPSED_FIELD = "te";
9
19
  /**
10
20
  * The honeypot name Gravixar's private backend package used. The gate accepts it as well, so forms that render it
11
21
  * keep working when that package re-exports this one.
package/dist/form.js CHANGED
@@ -1,4 +1,4 @@
1
- import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
1
+ import { ELAPSED_FIELD, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
2
2
  import { checkGate, gateFields } from "./gate.js";
3
3
  import { errorMessage, isProduction, randomId, stableId } from "./helpers.js";
4
4
  import { blobStep, emailStep } from "./steps.js";
@@ -25,10 +25,20 @@ export function defineForm(definition) {
25
25
  if (repeated)
26
26
  throw new FormError(`${log} has two delivery steps named "${repeated}". Give one a \`name\`.`);
27
27
  const fields = gateFields(gate);
28
- if (gate.honeypot !== false && gate.timeTrap !== false) {
28
+ if (gate.timeTrap !== false) {
29
29
  const timestamp = gate.timeTrap?.field ?? TIMESTAMP_FIELD;
30
- if ([gate.honeypot ?? HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD].includes(timestamp)) {
31
- throw new FormError(`${log} uses "${timestamp}" for both the honeypot and the timestamp.`);
30
+ const elapsed = gate.timeTrap?.elapsedField ?? ELAPSED_FIELD;
31
+ if (timestamp === elapsed) {
32
+ throw new FormError(`${log} uses "${timestamp}" for both the timestamp and the elapsed time.`);
33
+ }
34
+ if (gate.honeypot !== false) {
35
+ const honeypots = [gate.honeypot ?? HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD];
36
+ if (honeypots.includes(timestamp)) {
37
+ throw new FormError(`${log} uses "${timestamp}" for both the honeypot and the timestamp.`);
38
+ }
39
+ if (honeypots.includes(elapsed)) {
40
+ throw new FormError(`${log} uses "${elapsed}" for both the honeypot and the elapsed time.`);
41
+ }
32
42
  }
33
43
  }
34
44
  if (gate.timeTrap) {
package/dist/gate.d.ts CHANGED
@@ -1,15 +1,22 @@
1
1
  import type { GateReason } from "./types.js";
2
2
  /** The fastest a person fills a form, in milliseconds. */
3
3
  export declare const MIN_FILL_MS = 2000;
4
- /** A form older than this was cached or replayed, in milliseconds. */
4
+ /** A form older than this was cached or replayed, in milliseconds. Checked only on the timestamp path. */
5
5
  export declare const MAX_FORM_AGE_MS: number;
6
6
  export interface GateOptions {
7
7
  /** The honeypot field. Default `"website"`; `"hp_website"` is always checked too. `false` turns it off. */
8
8
  honeypot?: string | false;
9
- /** The time trap. On by default, and a missing timestamp fails it. `false` turns it off. */
9
+ /**
10
+ * The time trap. On by default. It reads the elapsed field when the form sends a usable one (a finite,
11
+ * non-negative number), and the timestamp otherwise; a form that sends neither fails it. `false` turns it off.
12
+ */
10
13
  timeTrap?: {
14
+ /** The timestamp field. Default `"ts"`. */
11
15
  field?: string;
16
+ /** The elapsed-time field. Default `"te"`. */
17
+ elapsedField?: string;
12
18
  minMs?: number;
19
+ /** Applies to the timestamp only: an elapsed time measured in the browser has no age to check. */
13
20
  maxAgeMs?: number;
14
21
  } | false;
15
22
  }
@@ -25,6 +32,13 @@ export declare function gateFields(options?: GateOptions): string[];
25
32
  * The bot gate, on the raw input, before validation: a validator that rejected a filled honeypot would name the
26
33
  * field in its error, telling a bot exactly what to leave empty. Pure and synchronous; BotID is a form option.
27
34
  *
28
- * An absent value is `undefined` or `null`. Anything else in the honeypot other than blank text trips it.
35
+ * An absent value is `undefined`, `null` or `""`. Anything else in the honeypot other than blank text trips it.
36
+ *
37
+ * The time trap prefers the elapsed field, which the browser measures against its own clock, so a device clock that
38
+ * is minutes off can't make a person look too fast or too old. It has no age limit: a tab left open for days is a
39
+ * person, and a replayed request would carry its old elapsed time anyway. An elapsed value that isn't a finite,
40
+ * non-negative number is ignored rather than rejected: a hand-rolled `Date.now() - mount` goes negative when the
41
+ * device's clock is corrected mid-fill, and a bot gains nothing, since it could leave the field out. Without a usable
42
+ * one, the timestamp is compared with the server's clock, as before.
29
43
  */
30
44
  export declare function checkGate(input: Record<string, unknown>, options?: GateOptions, nowMs?: number): GateVerdict;
package/dist/gate.js CHANGED
@@ -1,22 +1,30 @@
1
- import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
1
+ import { ELAPSED_FIELD, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
2
2
  /** The fastest a person fills a form, in milliseconds. */
3
3
  export const MIN_FILL_MS = 2_000;
4
- /** A form older than this was cached or replayed, in milliseconds. */
4
+ /** A form older than this was cached or replayed, in milliseconds. Checked only on the timestamp path. */
5
5
  export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
6
6
  /** The field names the gate reads, so they can be kept out of validation. */
7
7
  export function gateFields(options = {}) {
8
8
  const fields = new Set();
9
9
  if (options.honeypot !== false)
10
10
  fields.add(options.honeypot ?? HONEYPOT_FIELD).add(LEGACY_HONEYPOT_FIELD);
11
- if (options.timeTrap !== false)
12
- fields.add(options.timeTrap?.field ?? TIMESTAMP_FIELD);
11
+ if (options.timeTrap !== false) {
12
+ fields.add(options.timeTrap?.field ?? TIMESTAMP_FIELD).add(options.timeTrap?.elapsedField ?? ELAPSED_FIELD);
13
+ }
13
14
  return [...fields];
14
15
  }
15
16
  /**
16
17
  * The bot gate, on the raw input, before validation: a validator that rejected a filled honeypot would name the
17
18
  * field in its error, telling a bot exactly what to leave empty. Pure and synchronous; BotID is a form option.
18
19
  *
19
- * An absent value is `undefined` or `null`. Anything else in the honeypot other than blank text trips it.
20
+ * An absent value is `undefined`, `null` or `""`. Anything else in the honeypot other than blank text trips it.
21
+ *
22
+ * The time trap prefers the elapsed field, which the browser measures against its own clock, so a device clock that
23
+ * is minutes off can't make a person look too fast or too old. It has no age limit: a tab left open for days is a
24
+ * person, and a replayed request would carry its old elapsed time anyway. An elapsed value that isn't a finite,
25
+ * non-negative number is ignored rather than rejected: a hand-rolled `Date.now() - mount` goes negative when the
26
+ * device's clock is corrected mid-fill, and a bot gains nothing, since it could leave the field out. Without a usable
27
+ * one, the timestamp is compared with the server's clock, as before.
20
28
  */
21
29
  export function checkGate(input, options = {}, nowMs = Date.now()) {
22
30
  if (options.honeypot !== false) {
@@ -26,14 +34,15 @@ export function checkGate(input, options = {}, nowMs = Date.now()) {
26
34
  }
27
35
  }
28
36
  if (options.timeTrap !== false) {
29
- const { field = TIMESTAMP_FIELD, minMs = MIN_FILL_MS, maxAgeMs = MAX_FORM_AGE_MS } = options.timeTrap ?? {};
37
+ const { field = TIMESTAMP_FIELD, elapsedField = ELAPSED_FIELD, minMs = MIN_FILL_MS, maxAgeMs = MAX_FORM_AGE_MS, } = options.timeTrap ?? {};
38
+ const elapsed = elapsedMs(input[elapsedField]);
39
+ if (elapsed !== undefined)
40
+ return elapsed < minMs ? { ok: false, reason: "ts_too_fast" } : { ok: true };
30
41
  const raw = input[field];
31
- if (raw === undefined || raw === null || raw === "")
42
+ if (absent(raw))
32
43
  return { ok: false, reason: "ts_missing" };
33
- if (typeof raw !== "string" && typeof raw !== "number")
34
- return { ok: false, reason: "ts_invalid" };
35
- const ts = Number(raw);
36
- if (!Number.isFinite(ts))
44
+ const ts = toNumber(raw);
45
+ if (ts === undefined)
37
46
  return { ok: false, reason: "ts_invalid" };
38
47
  const age = nowMs - ts;
39
48
  if (age < minMs)
@@ -43,6 +52,23 @@ export function checkGate(input, options = {}, nowMs = Date.now()) {
43
52
  }
44
53
  return { ok: true };
45
54
  }
55
+ function absent(value) {
56
+ return value === undefined || value === null || value === "";
57
+ }
58
+ /** A usable elapsed time: a finite, non-negative number or numeric string. Anything else, blank included, is unusable. */
59
+ function elapsedMs(value) {
60
+ if (typeof value === "string" && value.trim() === "")
61
+ return undefined;
62
+ const n = toNumber(value);
63
+ return n !== undefined && n >= 0 ? n : undefined;
64
+ }
65
+ /** A finite number, from a number or a numeric string; otherwise undefined. */
66
+ function toNumber(value) {
67
+ if (typeof value !== "string" && typeof value !== "number")
68
+ return undefined;
69
+ const n = Number(value);
70
+ return Number.isFinite(n) ? n : undefined;
71
+ }
46
72
  function filled(value) {
47
73
  if (value === undefined || value === null)
48
74
  return false;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
- export { HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
1
+ export { createFormClock } from "./clock.js";
2
+ export type { FormClock } from "./clock.js";
3
+ export { ELAPSED_FIELD, HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
2
4
  export type { Evidence, FormState, GateReason, SubmitResult } from "./types.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /*
2
- * @gravixar/forms: the client-safe entry. A form component imports the field names and types from here. Everything
3
- * that runs a submission is in `@gravixar/forms/server`: a client component that imports server code ships it to
4
- * the browser, so nothing reachable from this file may import a Node builtin or server code. A test walks this
5
- * entry's module graph to hold that.
2
+ * @gravixar/forms: the client-safe entry. A form component imports the field names, the form clock and the types
3
+ * from here. Everything that runs a submission is in `@gravixar/forms/server`: a client component that imports
4
+ * server code ships it to the browser, so nothing reachable from this file may import a Node builtin or server code.
5
+ * A test walks this entry's module graph to hold that.
6
6
  */
7
- export { HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
7
+ export { createFormClock } from "./clock.js";
8
+ export { ELAPSED_FIELD, HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
package/dist/server.d.ts CHANGED
@@ -6,6 +6,6 @@ export { fromFormData, metaFromHeaders, normalizeEmail, stableId } from "./helpe
6
6
  export type { FormValue } from "./helpers.js";
7
7
  export { blobStep, emailStep, resendMailer, toAttachments } from "./steps.js";
8
8
  export type { Attachment, BlobStepOptions, EmailStepOptions, LineStore, MailMessage, Mailer, ResendClient } from "./steps.js";
9
- export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
9
+ export { ELAPSED_FIELD, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
10
10
  export type { StandardIssue, StandardResult, StandardSchema } from "./standard-schema.js";
11
11
  export type { Evidence, FormState, GateReason, Submission, SubmissionMeta, SubmitResult } from "./types.js";
package/dist/server.js CHANGED
@@ -10,4 +10,4 @@ export { defineForm, FormError } from "./form.js";
10
10
  export { checkGate, MAX_FORM_AGE_MS, MIN_FILL_MS } from "./gate.js";
11
11
  export { fromFormData, metaFromHeaders, normalizeEmail, stableId } from "./helpers.js";
12
12
  export { blobStep, emailStep, resendMailer, toAttachments } from "./steps.js";
13
- export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
13
+ export { ELAPSED_FIELD, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravixar/forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Public form submissions for server actions: a bot gate that runs before validation, any Standard Schema validator, stable ids for dedupe, and delivery steps that fail closed in production.",
5
5
  "license": "MIT",
6
6
  "type": "module",