@gravixar/forms 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/fields.d.ts +18 -0
- package/dist/fields.js +18 -0
- package/dist/form.d.ts +73 -0
- package/dist/form.js +150 -0
- package/dist/gate.d.ts +30 -0
- package/dist/gate.js +52 -0
- package/dist/helpers.d.ts +36 -0
- package/dist/helpers.js +80 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +13 -0
- package/dist/standard-schema.d.ts +23 -0
- package/dist/standard-schema.js +3 -0
- package/dist/steps.d.ts +109 -0
- package/dist/steps.js +134 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +2 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gravixar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @gravixar/forms
|
|
2
|
+
|
|
3
|
+
Public form submissions for server actions and route handlers. A form is defined once: its schema, bot gate,
|
|
4
|
+
identity and delivery. `submit` then runs them in the order live forms do:
|
|
5
|
+
|
|
6
|
+
1. **The bot gate, on the raw input.** A honeypot and a time trap, and optionally Vercel BotID. It runs before
|
|
7
|
+
validation, so a bot never sees an error naming the field it should have left empty. A bot gets the same "ok"
|
|
8
|
+
as a person, and every ignored submission is logged with its reason.
|
|
9
|
+
2. **Validation**, with any [Standard Schema](https://standardschema.dev) validator (zod v4 is one), static or
|
|
10
|
+
built per locale for localized messages. The result has the first error per field path.
|
|
11
|
+
3. **An id.** Parts you choose are hashed into a stable id, so a bot replaying one payload makes one record.
|
|
12
|
+
4. **Delivery steps**, in order, each returning evidence. **In production, a required step that isn't configured
|
|
13
|
+
fails the submission**, so a missing API key shows the visitor an error instead of losing what they sent.
|
|
14
|
+
|
|
15
|
+
It was extracted from three live forms: a bilingual enquiry form, a registration form stored for a CRM, and a
|
|
16
|
+
booking endpoint behind BotID.
|
|
17
|
+
|
|
18
|
+
## Two entry points
|
|
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.
|
|
22
|
+
- `@gravixar/forms/server`: everything that runs a submission.
|
|
23
|
+
|
|
24
|
+
## Define
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/forms/enquiry.ts
|
|
28
|
+
import { defineForm, resendMailer } from "@gravixar/forms/server";
|
|
29
|
+
import { Resend } from "resend";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
const mailer = resendMailer({ Resend, from: "Website <website@example.com>", fromEnv: "ENQUIRY_FROM" });
|
|
33
|
+
|
|
34
|
+
export const enquiry = defineForm({
|
|
35
|
+
name: "enquiry", // the record's kind, the dedupe namespace, the log prefix
|
|
36
|
+
schema: ({ locale }) =>
|
|
37
|
+
z.object({
|
|
38
|
+
name: z.string().trim().min(2, msg.name[locale]).max(120),
|
|
39
|
+
email: z.union([z.literal(""), z.email(msg.email[locale])]),
|
|
40
|
+
message: z.string().trim().min(1, msg.message[locale]).max(5000),
|
|
41
|
+
}),
|
|
42
|
+
// Optional. Default: honeypot "website", time trap on "ts" (2 s to 24 h), no BotID.
|
|
43
|
+
gate: { botId: checkBotId },
|
|
44
|
+
// Optional: parts hashed into the id. Omit for a random id.
|
|
45
|
+
dedupe: (data) => [normalizeEmail(data.email), data.name],
|
|
46
|
+
// A function of the step builders, so each step's callbacks see the schema's output.
|
|
47
|
+
deliver: ({ blob, email }) => [
|
|
48
|
+
blob({ store, path: (s) => `enquiries/${s.receivedAt.slice(0, 7)}.jsonl`, required: true }),
|
|
49
|
+
email({
|
|
50
|
+
mailer,
|
|
51
|
+
to: process.env.ENQUIRY_TO,
|
|
52
|
+
subject: (s) => `Enquiry from ${s.data.name}`,
|
|
53
|
+
text: (s) => s.data.message,
|
|
54
|
+
replyTo: (s) => s.data.email || undefined,
|
|
55
|
+
required: false, // stored already, so a failed email is recorded, not fatal
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`defineForm` throws a `FormError` when a definition can't work: no required step, two steps with one name, a
|
|
62
|
+
schema that validates a gate field, a name that isn't a lower-case id. It throws when the module loads, so the
|
|
63
|
+
mistake fails the build.
|
|
64
|
+
|
|
65
|
+
## Submit
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// src/app/actions.ts
|
|
69
|
+
"use server";
|
|
70
|
+
import { fromFormData, metaFromHeaders } from "@gravixar/forms/server";
|
|
71
|
+
import type { FormState } from "@gravixar/forms";
|
|
72
|
+
import { headers } from "next/headers";
|
|
73
|
+
import { enquiry } from "@/forms/enquiry";
|
|
74
|
+
|
|
75
|
+
export async function submitEnquiry(_prev: FormState, fd: FormData): Promise<FormState> {
|
|
76
|
+
const locale = pickLocale(fd);
|
|
77
|
+
const result = await enquiry.submit(fromFormData(fd), { locale, meta: metaFromHeaders(await headers()) });
|
|
78
|
+
if (result.outcome === "failed") return { status: "error", errors: { form: msg.failed[locale] } };
|
|
79
|
+
return result.state;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`result.outcome` is `delivered`, `ignored` (a bot), `invalid` (with `errors`) or `failed` (a required step
|
|
84
|
+
failed). `result.state` is ready for `useActionState`. `fromFormData` turns an absent field into `undefined`,
|
|
85
|
+
never `null`, and treats an empty file input as absent. List repeated fields in `{ multiple: ["files"] }`.
|
|
86
|
+
|
|
87
|
+
## Render
|
|
88
|
+
|
|
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 */}
|
|
96
|
+
```
|
|
97
|
+
|
|
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.
|
|
100
|
+
|
|
101
|
+
## Delivery steps
|
|
102
|
+
|
|
103
|
+
- `email({ mailer, to, subject, text, replyTo?, attachments?, required })`. When `to` is empty, the step isn't
|
|
104
|
+
configured. `toAttachments(files)` turns uploads into attachments.
|
|
105
|
+
- `blob({ store, path, toLine?, required })` appends one JSON line per submission, the format a CRM sync reads.
|
|
106
|
+
`store` is a `LineStore`, `{ configured(), appendLine(path, line) }`, over whatever storage the site uses.
|
|
107
|
+
`toLine` returns the record to write; by default it is the whole submission.
|
|
108
|
+
- A site's own step implements `DeliveryStep<T>`: `{ name, required, configured(), run(submission) }`, where
|
|
109
|
+
`run` returns `{ ok: true, ref? }` or `{ ok: false, error }`. A throw is caught and recorded the same way.
|
|
110
|
+
|
|
111
|
+
Every step runs, so one failing doesn't stop another from delivering. Outside production, a step that isn't
|
|
112
|
+
configured only logs the submission (a dry run). Production is `VERCEL_ENV=production` on Vercel, whose previews
|
|
113
|
+
also set `NODE_ENV=production`, and `NODE_ENV=production` elsewhere.
|
|
114
|
+
|
|
115
|
+
`resendMailer({ Resend, from, fromEnv?, replyTo?, replyToEnv?, apiKeyEnv? })` takes the SDK's class, so this
|
|
116
|
+
package doesn't depend on `resend`. The key is read on each send. The SDK returns API and network failures as
|
|
117
|
+
`{ error }` instead of throwing, so the mailer reads `error` on every send.
|
|
118
|
+
|
|
119
|
+
## Helpers
|
|
120
|
+
|
|
121
|
+
- `normalizeEmail(email)` collapses Gmail's dots and `+tags` to the one inbox they reach. For dedupe only.
|
|
122
|
+
- `stableId(parts)`: SHA-256 of the parts joined by `|`, first 32 hex characters, with Web Crypto.
|
|
123
|
+
- `checkGate(input, options?)`: the gate on its own, for a route handler that isn't a form.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](LICENSE)
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The honeypot. It is rendered off-screen, so a person never fills it and a bot that fills every input does. */
|
|
2
|
+
export declare const HONEYPOT_FIELD = "website";
|
|
3
|
+
/** The time the form was rendered, in Unix milliseconds, set once when the form mounts. */
|
|
4
|
+
export declare const TIMESTAMP_FIELD = "ts";
|
|
5
|
+
/**
|
|
6
|
+
* The honeypot name Gravixar's private backend package used. The gate accepts it as well, so forms that render it
|
|
7
|
+
* keep working when that package re-exports this one.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LEGACY_HONEYPOT_FIELD = "hp_website";
|
|
10
|
+
/**
|
|
11
|
+
* Spread onto the honeypot `<input>`. Keyboard users can't tab into it and autofill leaves it alone. Place it
|
|
12
|
+
* off-screen with CSS, not `display: none`, which some bots skip.
|
|
13
|
+
*/
|
|
14
|
+
export declare const honeypotInputProps: {
|
|
15
|
+
readonly name: "website";
|
|
16
|
+
readonly tabIndex: -1;
|
|
17
|
+
readonly autoComplete: "off";
|
|
18
|
+
};
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The field names a form component renders and the server checks. Both entry points export them from here, so the
|
|
3
|
+
* hidden inputs and the gate can't drift apart. This module imports nothing: it is part of the client entry's graph.
|
|
4
|
+
*/
|
|
5
|
+
/** The honeypot. It is rendered off-screen, so a person never fills it and a bot that fills every input does. */
|
|
6
|
+
export const HONEYPOT_FIELD = "website";
|
|
7
|
+
/** The time the form was rendered, in Unix milliseconds, set once when the form mounts. */
|
|
8
|
+
export const TIMESTAMP_FIELD = "ts";
|
|
9
|
+
/**
|
|
10
|
+
* The honeypot name Gravixar's private backend package used. The gate accepts it as well, so forms that render it
|
|
11
|
+
* keep working when that package re-exports this one.
|
|
12
|
+
*/
|
|
13
|
+
export const LEGACY_HONEYPOT_FIELD = "hp_website";
|
|
14
|
+
/**
|
|
15
|
+
* Spread onto the honeypot `<input>`. Keyboard users can't tab into it and autofill leaves it alone. Place it
|
|
16
|
+
* off-screen with CSS, not `display: none`, which some bots skip.
|
|
17
|
+
*/
|
|
18
|
+
export const honeypotInputProps = { name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off" };
|
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type GateOptions } from "./gate.js";
|
|
2
|
+
import { type BlobStepOptions, type EmailStepOptions } from "./steps.js";
|
|
3
|
+
import type { StandardSchema } from "./standard-schema.js";
|
|
4
|
+
import type { Submission, SubmissionMeta, SubmitResult } from "./types.js";
|
|
5
|
+
/** A form definition that can't work. Thrown when the module loads, so it fails the site's build. */
|
|
6
|
+
export declare class FormError extends Error {
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
/** Vercel BotID's verdict, from `checkBotId()` in `botid/server`. */
|
|
10
|
+
export interface BotIdVerdict {
|
|
11
|
+
isBot?: boolean;
|
|
12
|
+
isVerifiedBot?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** What a step reports. The form adds the step's name and the time to make its `Evidence`. */
|
|
15
|
+
export type StepOutcome = {
|
|
16
|
+
ok: true;
|
|
17
|
+
ref?: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
/** One place a submission goes. `emailStep` and `blobStep` are two; a site can write its own. */
|
|
23
|
+
export interface DeliveryStep<T> {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** A required step that fails makes the submission `failed`. At least one step must be required. */
|
|
26
|
+
readonly required: boolean;
|
|
27
|
+
/** False when a key, token or address is missing. */
|
|
28
|
+
configured(): boolean;
|
|
29
|
+
/** What a missing configuration needs, for the log: `"RESEND_API_KEY"`. */
|
|
30
|
+
readonly needs?: string;
|
|
31
|
+
/** Returns the failure rather than throwing it; a throw is caught and recorded the same way. */
|
|
32
|
+
run(submission: Submission<T>): Promise<StepOutcome>;
|
|
33
|
+
}
|
|
34
|
+
/** The output type of a Standard Schema validator. */
|
|
35
|
+
export type InferOutput<S extends StandardSchema> = NonNullable<S["~standard"]["types"]>["output"];
|
|
36
|
+
export interface FormDefinition<S extends StandardSchema, T = InferOutput<S>> {
|
|
37
|
+
/** Lower-case id: the record's kind, the dedupe namespace and the log prefix. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** A Standard Schema validator (zod v4 is one), or a function that builds one per locale for its messages. */
|
|
40
|
+
schema: S | ((context: {
|
|
41
|
+
locale: string;
|
|
42
|
+
}) => S);
|
|
43
|
+
gate?: GateOptions & {
|
|
44
|
+
/** Blocks unverified bots. For forms that email an address the caller supplies. */
|
|
45
|
+
botId?: () => Promise<BotIdVerdict>;
|
|
46
|
+
};
|
|
47
|
+
/** Parts that identify a submission, hashed with the form's name into its id. Omit for a random id. */
|
|
48
|
+
dedupe?: (data: T) => readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Run in order. Every step runs, so one failing doesn't stop another from delivering.
|
|
51
|
+
*
|
|
52
|
+
* Write it as a function of the step builders, `({ email, blob }) => [...]`: they are bound to the schema's output,
|
|
53
|
+
* so each step's callbacks see typed data. TypeScript can't carry that type into `emailStep(...)` calls in a plain
|
|
54
|
+
* array; a plain array suits steps that are already typed, such as a site's own.
|
|
55
|
+
*/
|
|
56
|
+
deliver: readonly DeliveryStep<T>[] | ((steps: StepBuilders<T>) => readonly DeliveryStep<T>[]);
|
|
57
|
+
}
|
|
58
|
+
/** `emailStep` and `blobStep`, bound to a form's data type. */
|
|
59
|
+
export interface StepBuilders<T> {
|
|
60
|
+
email(options: EmailStepOptions<T>): DeliveryStep<T>;
|
|
61
|
+
blob(options: BlobStepOptions<T>): DeliveryStep<T>;
|
|
62
|
+
}
|
|
63
|
+
export interface SubmitContext {
|
|
64
|
+
locale: string;
|
|
65
|
+
meta?: SubmissionMeta;
|
|
66
|
+
}
|
|
67
|
+
export interface Form<T> {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
/** Gate, then validation, then the id, then every delivery step. Resolves; it doesn't throw for a bad submission. */
|
|
70
|
+
submit(input: Record<string, unknown>, context: SubmitContext): Promise<SubmitResult<T>>;
|
|
71
|
+
}
|
|
72
|
+
/** Defines a form. Throws a `FormError` when the definition can't work, so the mistake fails the build. */
|
|
73
|
+
export declare function defineForm<S extends StandardSchema>(definition: FormDefinition<S>): Form<InferOutput<S>>;
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
|
|
2
|
+
import { checkGate, gateFields } from "./gate.js";
|
|
3
|
+
import { errorMessage, isProduction, randomId, stableId } from "./helpers.js";
|
|
4
|
+
import { blobStep, emailStep } from "./steps.js";
|
|
5
|
+
/** A form definition that can't work. Thrown when the module loads, so it fails the site's build. */
|
|
6
|
+
export class FormError extends Error {
|
|
7
|
+
name = "FormError";
|
|
8
|
+
}
|
|
9
|
+
const OK = { status: "ok" };
|
|
10
|
+
/** Defines a form. Throws a `FormError` when the definition can't work, so the mistake fails the build. */
|
|
11
|
+
export function defineForm(definition) {
|
|
12
|
+
const { name, schema, gate = {}, dedupe } = definition;
|
|
13
|
+
const deliver = typeof definition.deliver === "function"
|
|
14
|
+
? definition.deliver({ email: emailStep, blob: blobStep })
|
|
15
|
+
: definition.deliver;
|
|
16
|
+
const log = `[forms:${name}]`;
|
|
17
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
18
|
+
throw new FormError(`Form name "${name}" must be a lower-case id, such as "enquiry".`);
|
|
19
|
+
}
|
|
20
|
+
if (!deliver.some((step) => step.required)) {
|
|
21
|
+
throw new FormError(`${log} needs at least one required delivery step, or a failed delivery would pass as success.`);
|
|
22
|
+
}
|
|
23
|
+
const stepNames = deliver.map((step) => step.name);
|
|
24
|
+
const repeated = stepNames.find((step, i) => stepNames.indexOf(step) !== i);
|
|
25
|
+
if (repeated)
|
|
26
|
+
throw new FormError(`${log} has two delivery steps named "${repeated}". Give one a \`name\`.`);
|
|
27
|
+
const fields = gateFields(gate);
|
|
28
|
+
if (gate.honeypot !== false && gate.timeTrap !== false) {
|
|
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.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (gate.timeTrap) {
|
|
35
|
+
const { minMs = 0, maxAgeMs = Infinity } = gate.timeTrap;
|
|
36
|
+
if (!(minMs >= 0 && maxAgeMs > minMs)) {
|
|
37
|
+
throw new FormError(`${log} time trap needs 0 <= minMs < maxAgeMs, not ${minMs} and ${maxAgeMs}.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (typeof schema !== "function")
|
|
41
|
+
assertNoClash(schema);
|
|
42
|
+
function assertNoClash(built) {
|
|
43
|
+
const clash = schemaKeys(built)?.find((key) => fields.includes(key));
|
|
44
|
+
if (clash) {
|
|
45
|
+
throw new FormError(`${log} validates "${clash}", which the gate reads and removes. Rename the field, or set the gate's field.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function ignored(reason) {
|
|
49
|
+
// Silent to the bot, never to us: browsers autofill hidden fields, and this is the one path that can drop a
|
|
50
|
+
// real person without an error or a record.
|
|
51
|
+
console.warn(`${log} ignored a submission: ${reason}`);
|
|
52
|
+
return { outcome: "ignored", reason, state: OK };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
async submit(input, context) {
|
|
57
|
+
const verdict = checkGate(input, gate);
|
|
58
|
+
if (!verdict.ok)
|
|
59
|
+
return ignored(verdict.reason);
|
|
60
|
+
if (gate.botId) {
|
|
61
|
+
let bot;
|
|
62
|
+
try {
|
|
63
|
+
bot = await gate.botId();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// Fail open: the static gate has already passed, and dropping a real person silently is the worse error.
|
|
67
|
+
console.error(`${log} BotID check failed, so it was skipped: ${errorMessage(error)}`);
|
|
68
|
+
}
|
|
69
|
+
if (bot?.isBot && !bot.isVerifiedBot)
|
|
70
|
+
return ignored("botid");
|
|
71
|
+
}
|
|
72
|
+
const built = typeof schema === "function" ? schema({ locale: context.locale }) : schema;
|
|
73
|
+
if (typeof schema === "function")
|
|
74
|
+
assertNoClash(built);
|
|
75
|
+
const values = { ...input };
|
|
76
|
+
for (const field of fields)
|
|
77
|
+
delete values[field];
|
|
78
|
+
const result = await built["~standard"].validate(values);
|
|
79
|
+
if (result.issues) {
|
|
80
|
+
const errors = firstErrors(result.issues);
|
|
81
|
+
return { outcome: "invalid", errors, state: { status: "error", errors } };
|
|
82
|
+
}
|
|
83
|
+
const data = result.value;
|
|
84
|
+
const submission = {
|
|
85
|
+
id: dedupe ? await stableId([name, ...dedupe(data)]) : randomId(),
|
|
86
|
+
form: name,
|
|
87
|
+
data,
|
|
88
|
+
locale: context.locale,
|
|
89
|
+
meta: context.meta ?? {},
|
|
90
|
+
receivedAt: new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
const production = isProduction();
|
|
93
|
+
const evidence = [];
|
|
94
|
+
let failed = false;
|
|
95
|
+
for (const step of deliver) {
|
|
96
|
+
const record = await runStep(step, submission, production, log);
|
|
97
|
+
evidence.push(record);
|
|
98
|
+
if (!record.ok && step.required)
|
|
99
|
+
failed = true;
|
|
100
|
+
}
|
|
101
|
+
return failed
|
|
102
|
+
? { outcome: "failed", id: submission.id, evidence, state: { status: "error" } }
|
|
103
|
+
: { outcome: "delivered", id: submission.id, evidence, state: OK };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async function runStep(step, submission, production, log) {
|
|
108
|
+
const at = () => new Date().toISOString();
|
|
109
|
+
const needs = step.needs ? ` (needs ${step.needs})` : "";
|
|
110
|
+
if (!step.configured()) {
|
|
111
|
+
if (!production) {
|
|
112
|
+
console.info(`${log} dry run: "${step.name}" is not configured${needs}, submission ${submission.id}`);
|
|
113
|
+
return { step: step.name, ok: true, dryRun: true, at: at() };
|
|
114
|
+
}
|
|
115
|
+
console.error(`${log} "${step.name}" is not configured${needs}, so submission ${submission.id} was not sent there`);
|
|
116
|
+
return { step: step.name, ok: false, error: `not configured${needs}`, at: at() };
|
|
117
|
+
}
|
|
118
|
+
let outcome;
|
|
119
|
+
try {
|
|
120
|
+
outcome = await step.run(submission);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
outcome = { ok: false, error: errorMessage(error) };
|
|
124
|
+
}
|
|
125
|
+
if (!outcome.ok) {
|
|
126
|
+
console.error(`${log} "${step.name}" failed for submission ${submission.id}: ${outcome.error}`);
|
|
127
|
+
return { step: step.name, ok: false, error: outcome.error, at: at() };
|
|
128
|
+
}
|
|
129
|
+
return outcome.ref === undefined
|
|
130
|
+
? { step: step.name, ok: true, at: at() }
|
|
131
|
+
: { step: step.name, ok: true, ref: outcome.ref, at: at() };
|
|
132
|
+
}
|
|
133
|
+
/** The first message per field path; an issue with no path is keyed `form`. */
|
|
134
|
+
function firstErrors(issues) {
|
|
135
|
+
const errors = {};
|
|
136
|
+
for (const issue of issues) {
|
|
137
|
+
const key = (issue.path ?? []).map((part) => String(typeof part === "object" ? part.key : part)).join(".") || "form";
|
|
138
|
+
errors[key] ??= issue.message;
|
|
139
|
+
}
|
|
140
|
+
return errors;
|
|
141
|
+
}
|
|
142
|
+
/** An object schema's field names, where the validator exposes them: zod's `shape`, valibot's `entries`. */
|
|
143
|
+
function schemaKeys(schema) {
|
|
144
|
+
for (const property of ["shape", "entries"]) {
|
|
145
|
+
const value = schema[property];
|
|
146
|
+
if (typeof value === "object" && value !== null)
|
|
147
|
+
return Object.keys(value);
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GateReason } from "./types.js";
|
|
2
|
+
/** The fastest a person fills a form, in milliseconds. */
|
|
3
|
+
export declare const MIN_FILL_MS = 2000;
|
|
4
|
+
/** A form older than this was cached or replayed, in milliseconds. */
|
|
5
|
+
export declare const MAX_FORM_AGE_MS: number;
|
|
6
|
+
export interface GateOptions {
|
|
7
|
+
/** The honeypot field. Default `"website"`; `"hp_website"` is always checked too. `false` turns it off. */
|
|
8
|
+
honeypot?: string | false;
|
|
9
|
+
/** The time trap. On by default, and a missing timestamp fails it. `false` turns it off. */
|
|
10
|
+
timeTrap?: {
|
|
11
|
+
field?: string;
|
|
12
|
+
minMs?: number;
|
|
13
|
+
maxAgeMs?: number;
|
|
14
|
+
} | false;
|
|
15
|
+
}
|
|
16
|
+
export type GateVerdict = {
|
|
17
|
+
ok: true;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: GateReason;
|
|
21
|
+
};
|
|
22
|
+
/** The field names the gate reads, so they can be kept out of validation. */
|
|
23
|
+
export declare function gateFields(options?: GateOptions): string[];
|
|
24
|
+
/**
|
|
25
|
+
* The bot gate, on the raw input, before validation: a validator that rejected a filled honeypot would name the
|
|
26
|
+
* field in its error, telling a bot exactly what to leave empty. Pure and synchronous; BotID is a form option.
|
|
27
|
+
*
|
|
28
|
+
* An absent value is `undefined` or `null`. Anything else in the honeypot other than blank text trips it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function checkGate(input: Record<string, unknown>, options?: GateOptions, nowMs?: number): GateVerdict;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD } from "./fields.js";
|
|
2
|
+
/** The fastest a person fills a form, in milliseconds. */
|
|
3
|
+
export const MIN_FILL_MS = 2_000;
|
|
4
|
+
/** A form older than this was cached or replayed, in milliseconds. */
|
|
5
|
+
export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
/** The field names the gate reads, so they can be kept out of validation. */
|
|
7
|
+
export function gateFields(options = {}) {
|
|
8
|
+
const fields = new Set();
|
|
9
|
+
if (options.honeypot !== false)
|
|
10
|
+
fields.add(options.honeypot ?? HONEYPOT_FIELD).add(LEGACY_HONEYPOT_FIELD);
|
|
11
|
+
if (options.timeTrap !== false)
|
|
12
|
+
fields.add(options.timeTrap?.field ?? TIMESTAMP_FIELD);
|
|
13
|
+
return [...fields];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The bot gate, on the raw input, before validation: a validator that rejected a filled honeypot would name the
|
|
17
|
+
* field in its error, telling a bot exactly what to leave empty. Pure and synchronous; BotID is a form option.
|
|
18
|
+
*
|
|
19
|
+
* An absent value is `undefined` or `null`. Anything else in the honeypot other than blank text trips it.
|
|
20
|
+
*/
|
|
21
|
+
export function checkGate(input, options = {}, nowMs = Date.now()) {
|
|
22
|
+
if (options.honeypot !== false) {
|
|
23
|
+
for (const field of [options.honeypot ?? HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD]) {
|
|
24
|
+
if (filled(input[field]))
|
|
25
|
+
return { ok: false, reason: "honeypot_filled" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (options.timeTrap !== false) {
|
|
29
|
+
const { field = TIMESTAMP_FIELD, minMs = MIN_FILL_MS, maxAgeMs = MAX_FORM_AGE_MS } = options.timeTrap ?? {};
|
|
30
|
+
const raw = input[field];
|
|
31
|
+
if (raw === undefined || raw === null || raw === "")
|
|
32
|
+
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))
|
|
37
|
+
return { ok: false, reason: "ts_invalid" };
|
|
38
|
+
const age = nowMs - ts;
|
|
39
|
+
if (age < minMs)
|
|
40
|
+
return { ok: false, reason: "ts_too_fast" };
|
|
41
|
+
if (age > maxAgeMs)
|
|
42
|
+
return { ok: false, reason: "ts_stale" };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
46
|
+
function filled(value) {
|
|
47
|
+
if (value === undefined || value === null)
|
|
48
|
+
return false;
|
|
49
|
+
if (typeof value === "string")
|
|
50
|
+
return value.trim().length > 0;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SubmissionMeta } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Collapses an address to the inbox it reaches. Gmail ignores dots and everything after `+`, so a bot can mint
|
|
4
|
+
* endless unique-looking addresses for one mailbox. For dedupe only: never reject with it, and never store it in
|
|
5
|
+
* place of what the person typed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeEmail(email: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* SHA-256 of the parts joined by `|`, as its first 32 hex characters. A bot replaying one payload makes one id, so
|
|
10
|
+
* a store keyed on it keeps one record. Web Crypto, never `node:crypto`, so no Node builtin can reach a client chunk.
|
|
11
|
+
*/
|
|
12
|
+
export declare function stableId(parts: readonly string[]): Promise<string>;
|
|
13
|
+
/** A random id in the same shape as `stableId`. */
|
|
14
|
+
export declare function randomId(): string;
|
|
15
|
+
export type FormValue = string | File;
|
|
16
|
+
/**
|
|
17
|
+
* Turns `FormData` into a plain object for `form.submit`. An absent field is `undefined`, never `null`: a `null`
|
|
18
|
+
* from `fd.get()` once read as a malformed honeypot, and as a timestamp 56 years old. An empty file input (a
|
|
19
|
+
* zero-byte `File`, which browsers send when nothing is chosen) counts as absent. A field listed in `multiple`
|
|
20
|
+
* is always an array. Strings are passed as typed; trimming is the schema's job.
|
|
21
|
+
*/
|
|
22
|
+
export declare function fromFormData(fd: FormData, options?: {
|
|
23
|
+
multiple?: readonly string[];
|
|
24
|
+
}): Record<string, FormValue | FormValue[] | undefined>;
|
|
25
|
+
/** The IP and user agent of a request, from its headers (Next's `await headers()`, or `request.headers`). */
|
|
26
|
+
export declare function metaFromHeaders(headers: {
|
|
27
|
+
get(name: string): string | null;
|
|
28
|
+
}): SubmissionMeta;
|
|
29
|
+
/** Reads an environment variable where there is one: Node, and the server runtimes that provide `process.env`. */
|
|
30
|
+
export declare function env(name: string): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Production means a submission that isn't delivered is lost. On Vercel that is `VERCEL_ENV=production` (previews
|
|
33
|
+
* run with `NODE_ENV=production` too, so it can't be used there); elsewhere, `NODE_ENV=production`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isProduction(): boolean;
|
|
36
|
+
export declare function errorMessage(error: unknown): string;
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapses an address to the inbox it reaches. Gmail ignores dots and everything after `+`, so a bot can mint
|
|
3
|
+
* endless unique-looking addresses for one mailbox. For dedupe only: never reject with it, and never store it in
|
|
4
|
+
* place of what the person typed.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeEmail(email) {
|
|
7
|
+
const lower = email.trim().toLowerCase();
|
|
8
|
+
const at = lower.lastIndexOf("@");
|
|
9
|
+
if (at < 1)
|
|
10
|
+
return lower;
|
|
11
|
+
const local = lower.slice(0, at);
|
|
12
|
+
const domain = lower.slice(at + 1);
|
|
13
|
+
if (domain !== "gmail.com" && domain !== "googlemail.com")
|
|
14
|
+
return lower;
|
|
15
|
+
return `${(local.split("+")[0] ?? "").replace(/\./g, "")}@gmail.com`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* SHA-256 of the parts joined by `|`, as its first 32 hex characters. A bot replaying one payload makes one id, so
|
|
19
|
+
* a store keyed on it keeps one record. Web Crypto, never `node:crypto`, so no Node builtin can reach a client chunk.
|
|
20
|
+
*/
|
|
21
|
+
export async function stableId(parts) {
|
|
22
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("|")));
|
|
23
|
+
return hex(new Uint8Array(digest)).slice(0, 32);
|
|
24
|
+
}
|
|
25
|
+
/** A random id in the same shape as `stableId`. */
|
|
26
|
+
export function randomId() {
|
|
27
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
28
|
+
}
|
|
29
|
+
function hex(bytes) {
|
|
30
|
+
let out = "";
|
|
31
|
+
for (const byte of bytes)
|
|
32
|
+
out += byte.toString(16).padStart(2, "0");
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Turns `FormData` into a plain object for `form.submit`. An absent field is `undefined`, never `null`: a `null`
|
|
37
|
+
* from `fd.get()` once read as a malformed honeypot, and as a timestamp 56 years old. An empty file input (a
|
|
38
|
+
* zero-byte `File`, which browsers send when nothing is chosen) counts as absent. A field listed in `multiple`
|
|
39
|
+
* is always an array. Strings are passed as typed; trimming is the schema's job.
|
|
40
|
+
*/
|
|
41
|
+
export function fromFormData(fd, options = {}) {
|
|
42
|
+
const multiple = new Set(options.multiple ?? []);
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const key of new Set(fd.keys())) {
|
|
45
|
+
const values = fd.getAll(key).filter((value) => typeof value === "string" || value.size > 0);
|
|
46
|
+
if (multiple.has(key))
|
|
47
|
+
out[key] = values;
|
|
48
|
+
else if (values.length > 0)
|
|
49
|
+
out[key] = values[0];
|
|
50
|
+
}
|
|
51
|
+
for (const key of multiple)
|
|
52
|
+
out[key] ??= [];
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** The IP and user agent of a request, from its headers (Next's `await headers()`, or `request.headers`). */
|
|
56
|
+
export function metaFromHeaders(headers) {
|
|
57
|
+
const meta = {};
|
|
58
|
+
const ip = headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip")?.trim();
|
|
59
|
+
const userAgent = headers.get("user-agent");
|
|
60
|
+
if (ip)
|
|
61
|
+
meta.ip = ip;
|
|
62
|
+
if (userAgent)
|
|
63
|
+
meta.userAgent = userAgent;
|
|
64
|
+
return meta;
|
|
65
|
+
}
|
|
66
|
+
/** Reads an environment variable where there is one: Node, and the server runtimes that provide `process.env`. */
|
|
67
|
+
export function env(name) {
|
|
68
|
+
return globalThis.process?.env?.[name];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Production means a submission that isn't delivered is lost. On Vercel that is `VERCEL_ENV=production` (previews
|
|
72
|
+
* run with `NODE_ENV=production` too, so it can't be used there); elsewhere, `NODE_ENV=production`.
|
|
73
|
+
*/
|
|
74
|
+
export function isProduction() {
|
|
75
|
+
const vercel = env("VERCEL_ENV");
|
|
76
|
+
return vercel ? vercel === "production" : env("NODE_ENV") === "production";
|
|
77
|
+
}
|
|
78
|
+
export function errorMessage(error) {
|
|
79
|
+
return error instanceof Error ? error.message : String(error);
|
|
80
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export { HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { defineForm, FormError } from "./form.js";
|
|
2
|
+
export type { BotIdVerdict, DeliveryStep, Form, FormDefinition, InferOutput, StepBuilders, StepOutcome, SubmitContext } from "./form.js";
|
|
3
|
+
export { checkGate, MAX_FORM_AGE_MS, MIN_FILL_MS } from "./gate.js";
|
|
4
|
+
export type { GateOptions, GateVerdict } from "./gate.js";
|
|
5
|
+
export { fromFormData, metaFromHeaders, normalizeEmail, stableId } from "./helpers.js";
|
|
6
|
+
export type { FormValue } from "./helpers.js";
|
|
7
|
+
export { blobStep, emailStep, resendMailer, toAttachments } from "./steps.js";
|
|
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";
|
|
10
|
+
export type { StandardIssue, StandardResult, StandardSchema } from "./standard-schema.js";
|
|
11
|
+
export type { Evidence, FormState, GateReason, Submission, SubmissionMeta, SubmitResult } from "./types.js";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @gravixar/forms/server: everything that runs a submission. Import it from server actions and route handlers only.
|
|
3
|
+
*
|
|
4
|
+
* A form defines its schema, gate, identity and delivery once; `submit` then runs them in the order the live forms
|
|
5
|
+
* do: the bot gate on the raw input, validation, the id, then every delivery step. In production a required step
|
|
6
|
+
* that isn't configured fails the submission, so a missing key shows the visitor an error instead of losing what
|
|
7
|
+
* they sent.
|
|
8
|
+
*/
|
|
9
|
+
export { defineForm, FormError } from "./form.js";
|
|
10
|
+
export { checkGate, MAX_FORM_AGE_MS, MIN_FILL_MS } from "./gate.js";
|
|
11
|
+
export { fromFormData, metaFromHeaders, normalizeEmail, stableId } from "./helpers.js";
|
|
12
|
+
export { blobStep, emailStep, resendMailer, toAttachments } from "./steps.js";
|
|
13
|
+
export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, TIMESTAMP_FIELD, honeypotInputProps } from "./fields.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface StandardSchema<Input = unknown, Output = Input> {
|
|
2
|
+
readonly "~standard": {
|
|
3
|
+
readonly version: 1;
|
|
4
|
+
readonly vendor: string;
|
|
5
|
+
readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
|
|
6
|
+
readonly types?: {
|
|
7
|
+
readonly input: Input;
|
|
8
|
+
readonly output: Output;
|
|
9
|
+
} | undefined;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export type StandardResult<Output> = {
|
|
13
|
+
readonly value: Output;
|
|
14
|
+
readonly issues?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
readonly issues: readonly StandardIssue[];
|
|
17
|
+
};
|
|
18
|
+
export interface StandardIssue {
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly path?: readonly (PropertyKey | {
|
|
21
|
+
readonly key: PropertyKey;
|
|
22
|
+
})[] | undefined;
|
|
23
|
+
}
|
package/dist/steps.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { DeliveryStep, StepOutcome } from "./form.js";
|
|
2
|
+
import type { Submission } from "./types.js";
|
|
3
|
+
type Maybe<T> = T | Promise<T>;
|
|
4
|
+
export interface Attachment {
|
|
5
|
+
filename: string;
|
|
6
|
+
/** Bytes, or a base64 string. */
|
|
7
|
+
content: Uint8Array | string;
|
|
8
|
+
}
|
|
9
|
+
export interface MailMessage {
|
|
10
|
+
to: string | readonly string[];
|
|
11
|
+
subject: string;
|
|
12
|
+
text: string;
|
|
13
|
+
replyTo?: string;
|
|
14
|
+
attachments?: readonly Attachment[];
|
|
15
|
+
}
|
|
16
|
+
/** Sends one message. `resendMailer` is one; a test or another provider can be another. */
|
|
17
|
+
export interface Mailer {
|
|
18
|
+
configured(): boolean;
|
|
19
|
+
/** What a missing configuration needs, for the log. */
|
|
20
|
+
readonly needs?: string;
|
|
21
|
+
/** Returns the failure rather than throwing it. */
|
|
22
|
+
send(message: MailMessage): Promise<StepOutcome>;
|
|
23
|
+
}
|
|
24
|
+
export interface EmailStepOptions<T> {
|
|
25
|
+
/** Default `"email"`. Set it when a form has two email steps. */
|
|
26
|
+
name?: string;
|
|
27
|
+
mailer: Mailer;
|
|
28
|
+
/** Usually an environment variable. When it is empty, the step isn't configured. */
|
|
29
|
+
to: string | readonly string[] | undefined;
|
|
30
|
+
subject: (submission: Submission<T>) => string;
|
|
31
|
+
text: (submission: Submission<T>) => string;
|
|
32
|
+
replyTo?: (submission: Submission<T>) => string | undefined;
|
|
33
|
+
attachments?: (submission: Submission<T>) => Maybe<readonly Attachment[]>;
|
|
34
|
+
required: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Emails each submission. */
|
|
37
|
+
export declare function emailStep<T>(options: EmailStepOptions<T>): DeliveryStep<T>;
|
|
38
|
+
/** The part of the Resend SDK the mailer uses. */
|
|
39
|
+
export interface ResendClient {
|
|
40
|
+
emails: {
|
|
41
|
+
send(payload: {
|
|
42
|
+
from: string;
|
|
43
|
+
to: string | string[];
|
|
44
|
+
subject: string;
|
|
45
|
+
text: string;
|
|
46
|
+
replyTo?: string;
|
|
47
|
+
attachments?: {
|
|
48
|
+
filename: string;
|
|
49
|
+
content: string;
|
|
50
|
+
}[];
|
|
51
|
+
}): Promise<{
|
|
52
|
+
data: {
|
|
53
|
+
id: string;
|
|
54
|
+
} | null;
|
|
55
|
+
error: {
|
|
56
|
+
name?: string;
|
|
57
|
+
message: string;
|
|
58
|
+
} | null;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A mailer over the Resend SDK. Pass the SDK's class (`import { Resend } from "resend"`), so this package doesn't
|
|
64
|
+
* depend on it. The key is read on every send, so one added after boot works without a restart.
|
|
65
|
+
*
|
|
66
|
+
* Resend returns API and network failures as `{ error }` instead of throwing, so a `try/catch` alone sees none of
|
|
67
|
+
* them. This reads `error` on every send, and catches a throw as well.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resendMailer(options: {
|
|
70
|
+
Resend: new (key: string) => ResendClient;
|
|
71
|
+
/** Default `"RESEND_API_KEY"`. */
|
|
72
|
+
apiKeyEnv?: string;
|
|
73
|
+
/** The sender, `"Name <address@domain>"`. */
|
|
74
|
+
from: string;
|
|
75
|
+
/** An environment variable that overrides `from` when set. */
|
|
76
|
+
fromEnv?: string;
|
|
77
|
+
/** Used when a step doesn't set its own. */
|
|
78
|
+
replyTo?: string;
|
|
79
|
+
replyToEnv?: string;
|
|
80
|
+
}): Mailer & {
|
|
81
|
+
client(): ResendClient | null;
|
|
82
|
+
readonly from: string;
|
|
83
|
+
readonly replyTo: string | undefined;
|
|
84
|
+
};
|
|
85
|
+
/** Uploaded files as attachments, with names reduced to safe characters. */
|
|
86
|
+
export declare function toAttachments(files: readonly File[]): Promise<Attachment[]>;
|
|
87
|
+
/** Appends one line to a file. A site adapts its storage to this: Vercel Blob, a bucket, a database table. */
|
|
88
|
+
export interface LineStore {
|
|
89
|
+
configured(): boolean;
|
|
90
|
+
/** What a missing configuration needs, for the log. */
|
|
91
|
+
readonly needs?: string;
|
|
92
|
+
/** Throws when the line wasn't written. */
|
|
93
|
+
appendLine(path: string, line: string): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
export interface BlobStepOptions<T> {
|
|
96
|
+
/** Default `"blob"`. */
|
|
97
|
+
name?: string;
|
|
98
|
+
store: LineStore;
|
|
99
|
+
path: (submission: Submission<T>) => string;
|
|
100
|
+
/** The record to write. Default: the whole submission. */
|
|
101
|
+
toLine?: (submission: Submission<T>) => unknown;
|
|
102
|
+
required: boolean;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Appends each submission as one JSON line: the format a CRM sync reads month by month. By default the line is
|
|
106
|
+
* the whole submission; `toLine` returns the record to write instead. Files in the data don't survive JSON.
|
|
107
|
+
*/
|
|
108
|
+
export declare function blobStep<T>(options: BlobStepOptions<T>): DeliveryStep<T>;
|
|
109
|
+
export {};
|
package/dist/steps.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { env, errorMessage } from "./helpers.js";
|
|
2
|
+
/** Emails each submission. */
|
|
3
|
+
export function emailStep(options) {
|
|
4
|
+
const { mailer, to } = options;
|
|
5
|
+
const recipients = typeof to === "string" ? [to] : (to ?? []);
|
|
6
|
+
const hasRecipient = recipients.some((address) => address.trim() !== "");
|
|
7
|
+
return {
|
|
8
|
+
name: options.name ?? "email",
|
|
9
|
+
required: options.required,
|
|
10
|
+
configured: () => hasRecipient && mailer.configured(),
|
|
11
|
+
get needs() {
|
|
12
|
+
const missing = [];
|
|
13
|
+
if (!mailer.configured())
|
|
14
|
+
missing.push(mailer.needs ?? "a mailer");
|
|
15
|
+
if (!hasRecipient)
|
|
16
|
+
missing.push("a recipient");
|
|
17
|
+
return missing.length > 0 ? missing.join(" and ") : undefined;
|
|
18
|
+
},
|
|
19
|
+
async run(submission) {
|
|
20
|
+
const message = {
|
|
21
|
+
to: recipients,
|
|
22
|
+
subject: options.subject(submission),
|
|
23
|
+
text: options.text(submission),
|
|
24
|
+
};
|
|
25
|
+
const replyTo = options.replyTo?.(submission);
|
|
26
|
+
if (replyTo)
|
|
27
|
+
message.replyTo = replyTo;
|
|
28
|
+
const attachments = await options.attachments?.(submission);
|
|
29
|
+
if (attachments?.length)
|
|
30
|
+
message.attachments = attachments;
|
|
31
|
+
return mailer.send(message);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A mailer over the Resend SDK. Pass the SDK's class (`import { Resend } from "resend"`), so this package doesn't
|
|
37
|
+
* depend on it. The key is read on every send, so one added after boot works without a restart.
|
|
38
|
+
*
|
|
39
|
+
* Resend returns API and network failures as `{ error }` instead of throwing, so a `try/catch` alone sees none of
|
|
40
|
+
* them. This reads `error` on every send, and catches a throw as well.
|
|
41
|
+
*/
|
|
42
|
+
export function resendMailer(options) {
|
|
43
|
+
const apiKeyEnv = options.apiKeyEnv ?? "RESEND_API_KEY";
|
|
44
|
+
let client = null;
|
|
45
|
+
let clientKey;
|
|
46
|
+
const mailer = {
|
|
47
|
+
needs: apiKeyEnv,
|
|
48
|
+
configured: () => Boolean(env(apiKeyEnv)),
|
|
49
|
+
/** The SDK client, built once per key. `null` without a key. */
|
|
50
|
+
client() {
|
|
51
|
+
const key = env(apiKeyEnv);
|
|
52
|
+
if (!key)
|
|
53
|
+
return null;
|
|
54
|
+
if (!client || clientKey !== key) {
|
|
55
|
+
client = new options.Resend(key);
|
|
56
|
+
clientKey = key;
|
|
57
|
+
}
|
|
58
|
+
return client;
|
|
59
|
+
},
|
|
60
|
+
get from() {
|
|
61
|
+
return (options.fromEnv && env(options.fromEnv)) || options.from;
|
|
62
|
+
},
|
|
63
|
+
get replyTo() {
|
|
64
|
+
return (options.replyToEnv && env(options.replyToEnv)) || options.replyTo;
|
|
65
|
+
},
|
|
66
|
+
async send(message) {
|
|
67
|
+
const resend = mailer.client();
|
|
68
|
+
if (!resend)
|
|
69
|
+
return { ok: false, error: `${apiKeyEnv} is not set` };
|
|
70
|
+
const replyTo = message.replyTo ?? mailer.replyTo;
|
|
71
|
+
try {
|
|
72
|
+
const { data, error } = await resend.emails.send({
|
|
73
|
+
from: mailer.from,
|
|
74
|
+
to: typeof message.to === "string" ? message.to : [...message.to],
|
|
75
|
+
subject: message.subject,
|
|
76
|
+
text: message.text,
|
|
77
|
+
...(replyTo ? { replyTo } : {}),
|
|
78
|
+
...(message.attachments?.length
|
|
79
|
+
? {
|
|
80
|
+
attachments: message.attachments.map(({ filename, content }) => ({
|
|
81
|
+
filename,
|
|
82
|
+
// The SDK puts content into a JSON body as it is, where bytes would not survive; base64 does.
|
|
83
|
+
content: typeof content === "string" ? content : base64(content),
|
|
84
|
+
})),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
87
|
+
});
|
|
88
|
+
if (error)
|
|
89
|
+
return { ok: false, error: `${error.name ?? "error"}: ${error.message}` };
|
|
90
|
+
if (!data?.id)
|
|
91
|
+
return { ok: false, error: "Resend returned neither an id nor an error" };
|
|
92
|
+
return { ok: true, ref: data.id };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { ok: false, error: errorMessage(error) };
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return mailer;
|
|
100
|
+
}
|
|
101
|
+
/** Uploaded files as attachments, with names reduced to safe characters. */
|
|
102
|
+
export async function toAttachments(files) {
|
|
103
|
+
return Promise.all(files.map(async (file) => ({
|
|
104
|
+
filename: file.name.replace(/[^\w.\- ]+/g, "_").slice(0, 120),
|
|
105
|
+
content: new Uint8Array(await file.arrayBuffer()),
|
|
106
|
+
})));
|
|
107
|
+
}
|
|
108
|
+
function base64(bytes) {
|
|
109
|
+
let binary = "";
|
|
110
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
111
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
112
|
+
return btoa(binary);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Appends each submission as one JSON line: the format a CRM sync reads month by month. By default the line is
|
|
116
|
+
* the whole submission; `toLine` returns the record to write instead. Files in the data don't survive JSON.
|
|
117
|
+
*/
|
|
118
|
+
export function blobStep(options) {
|
|
119
|
+
const { store } = options;
|
|
120
|
+
const step = {
|
|
121
|
+
name: options.name ?? "blob",
|
|
122
|
+
required: options.required,
|
|
123
|
+
configured: () => store.configured(),
|
|
124
|
+
async run(submission) {
|
|
125
|
+
const path = options.path(submission);
|
|
126
|
+
const line = JSON.stringify(options.toLine ? options.toLine(submission) : submission);
|
|
127
|
+
if (line === undefined)
|
|
128
|
+
return { ok: false, error: "toLine returned nothing to write" };
|
|
129
|
+
await store.appendLine(path, line);
|
|
130
|
+
return { ok: true, ref: path };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return store.needs ? { ...step, needs: store.needs } : step;
|
|
134
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** What a server action returns to `useActionState`. A bot and a person get the same "ok". */
|
|
2
|
+
export type FormState = {
|
|
3
|
+
status: "idle" | "ok" | "error";
|
|
4
|
+
errors?: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
/** Why the gate ignored a submission. For logs only: never tell the client which check tripped. */
|
|
7
|
+
export type GateReason = "honeypot_filled" | "ts_missing" | "ts_invalid" | "ts_too_fast" | "ts_stale" | "botid";
|
|
8
|
+
/** What one delivery step did with a submission. */
|
|
9
|
+
export interface Evidence {
|
|
10
|
+
step: string;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
/** Where it went: an email id, a file path. */
|
|
13
|
+
ref?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
/** The step wasn't configured outside production, so it only logged the submission. */
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
at: string;
|
|
18
|
+
}
|
|
19
|
+
/** Request details kept with a submission, for forensics. */
|
|
20
|
+
export interface SubmissionMeta {
|
|
21
|
+
ip?: string;
|
|
22
|
+
userAgent?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A validated submission, as the delivery steps receive it. */
|
|
25
|
+
export interface Submission<T> {
|
|
26
|
+
/** 32 hex characters: derived from the form's `dedupe` parts, or random. */
|
|
27
|
+
id: string;
|
|
28
|
+
form: string;
|
|
29
|
+
data: T;
|
|
30
|
+
locale: string;
|
|
31
|
+
meta: SubmissionMeta;
|
|
32
|
+
receivedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export type SubmitResult<T> =
|
|
35
|
+
/** Every required step delivered. */
|
|
36
|
+
{
|
|
37
|
+
outcome: "delivered";
|
|
38
|
+
id: string;
|
|
39
|
+
evidence: Evidence[];
|
|
40
|
+
state: FormState;
|
|
41
|
+
}
|
|
42
|
+
/** The gate stopped a bot. Its state is the same "ok" a person gets. */
|
|
43
|
+
| {
|
|
44
|
+
outcome: "ignored";
|
|
45
|
+
reason: GateReason;
|
|
46
|
+
state: FormState;
|
|
47
|
+
}
|
|
48
|
+
/** The first error per field path, in the locale's messages. */
|
|
49
|
+
| {
|
|
50
|
+
outcome: "invalid";
|
|
51
|
+
errors: Record<string, string>;
|
|
52
|
+
state: FormState;
|
|
53
|
+
}
|
|
54
|
+
/** A required step failed. Show the localized error and a fallback contact. */
|
|
55
|
+
| {
|
|
56
|
+
outcome: "failed";
|
|
57
|
+
id: string;
|
|
58
|
+
evidence: Evidence[];
|
|
59
|
+
state: FormState;
|
|
60
|
+
};
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravixar/forms",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"types": "./dist/server.d.ts",
|
|
15
|
+
"default": "./dist/server.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"typecheck": "tsc -p test/tsconfig.json",
|
|
28
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
29
|
+
"prepack": "tsc -p tsconfig.json",
|
|
30
|
+
"prepublishOnly": "tsc -p tsconfig.json && tsc -p test/tsconfig.json && node --test \"test/*.test.mjs\""
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/gravixar-sv/gravixar-kit.git",
|
|
35
|
+
"directory": "packages/forms"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"resend": "^6.30.0",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"zod": "^4.6.5"
|
|
44
|
+
}
|
|
45
|
+
}
|