@ingram-tech/nk-forms 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 ADDED
@@ -0,0 +1,106 @@
1
+ # @ingram-tech/nk-forms
2
+
3
+ The contact/signup **submission pipeline** for Ingram's Next.js sites: bot
4
+ protection, validation, rate-limit hook, and escaped email notifications behind
5
+ one server handler and one client hook.
6
+
7
+ It is a thin layer over [`@ingram-tech/bot-protection`](../bot-protection)
8
+ (the security primitive) and [`@ingram-tech/nk-email`](../nk-email) (sending +
9
+ `escapeHtml`). Reach for those directly when you need bot detection somewhere
10
+ that isn't a public form (a checkout, an authed endpoint) — this package is for
11
+ the "validate a public submission and notify a human / subscribe an address"
12
+ shape, and deliberately does **not** try to own Stripe checkout or auth flows.
13
+
14
+ ## What it gives you
15
+
16
+ - `handleFormSubmission(request, options)` — the fixed pipeline: rate-limit →
17
+ parse → bot gate → validate → deliver → uniform response. Bot hits and honest
18
+ submissions return the **same** 200 body, so a bot never learns it was
19
+ dropped. Returns a web-standard `Response` (no `next` dependency).
20
+ - `renderNotificationEmail({ heading, fields, message, footer })` — builds
21
+ `{ html, text }` with **every** value escaped. Use it so escaping is the
22
+ default, not a per-site coin flip.
23
+ - `mintFormToken()` — a ready-made GET handler for the signed timing token.
24
+ - `useFormSubmit(endpoint)` / re-exported `useBotProtection` + `HoneypotInput`
25
+ from `@ingram-tech/nk-forms/react`.
26
+
27
+ You still own your **schema** (a Zod schema — accepted structurally, no Zod
28
+ version pin), your **fields/branding**, and your **delivery** (`onSubmit`).
29
+
30
+ ## Route
31
+
32
+ ```ts
33
+ // app/api/contact/route.ts
34
+ import { handleFormSubmission, renderNotificationEmail } from "@ingram-tech/nk-forms";
35
+ import { fromAddress, sendEmail } from "@ingram-tech/nk-email";
36
+ import { z } from "zod";
37
+
38
+ export { mintFormToken as GET } from "@ingram-tech/nk-forms";
39
+
40
+ const schema = z.object({
41
+ name: z.string().trim().min(1).max(200),
42
+ email: z.string().trim().email().max(320),
43
+ message: z.string().trim().min(1).max(5000),
44
+ });
45
+
46
+ export const POST = (request: Request) =>
47
+ handleFormSubmission(request, {
48
+ schema,
49
+ label: "contact",
50
+ onSubmit: async ({ name, email, message }) => {
51
+ const { html, text } = renderNotificationEmail({
52
+ heading: "New contact form submission",
53
+ fields: [
54
+ { label: "Name", value: name },
55
+ { label: "Email", value: email },
56
+ ],
57
+ message,
58
+ footer: "Sent from the acme.test contact form.",
59
+ });
60
+ await sendEmail({
61
+ to: "studio@acme.test",
62
+ from: fromAddress("Acme"),
63
+ replyTo: email,
64
+ subject: `[Contact] ${name}`,
65
+ html,
66
+ text,
67
+ });
68
+ },
69
+ });
70
+ ```
71
+
72
+ Add a rate limiter by passing `rateLimit: () => ({ ok, retryAfterMs })` — plug in
73
+ whatever store you already use; nk-forms owns none.
74
+
75
+ ## Client
76
+
77
+ ```tsx
78
+ "use client";
79
+ import { HoneypotInput, useFormSubmit } from "@ingram-tech/nk-forms/react";
80
+
81
+ export function ContactForm() {
82
+ const { honeypotRef, submit, status, error } = useFormSubmit("/api/contact");
83
+
84
+ return (
85
+ <form
86
+ onSubmit={async (e) => {
87
+ e.preventDefault();
88
+ const data = new FormData(e.currentTarget);
89
+ await submit(Object.fromEntries(data));
90
+ }}
91
+ >
92
+ <HoneypotInput inputRef={honeypotRef} />
93
+ {/* your fields */}
94
+ <button disabled={status === "submitting"}>Send</button>
95
+ {status === "success" && <p>Thanks — we'll be in touch.</p>}
96
+ {error && <p role="alert">{error}</p>}
97
+ </form>
98
+ );
99
+ }
100
+ ```
101
+
102
+ ## Environment
103
+
104
+ Inherits `BOT_PROTECTION_SECRET` from `@ingram-tech/bot-protection` (the timing
105
+ layer; honeypot + BotID run without it) and the mail transport env from
106
+ `@ingram-tech/nk-email`. This package reads no env of its own.
@@ -0,0 +1,30 @@
1
+ /** One labelled row in a notification email. Empty/blank values are dropped. */
2
+ export interface NotificationField {
3
+ label: string;
4
+ value: string | null | undefined;
5
+ }
6
+ export interface NotificationEmailOptions {
7
+ /** The email's heading, e.g. "New contact form submission". */
8
+ heading: string;
9
+ /** Labelled key/value rows rendered as a table. */
10
+ fields?: NotificationField[];
11
+ /** Free-text body rendered in a pre-wrap block (the user's message). */
12
+ message?: string | null;
13
+ /** Small footer line, e.g. "Sent from the acme.test contact form.". */
14
+ footer?: string;
15
+ }
16
+ export interface RenderedEmail {
17
+ html: string;
18
+ text: string;
19
+ }
20
+ /**
21
+ * Render a submission into `{ html, text }` for `sendEmail`, with **every**
22
+ * interpolated value escaped via `@ingram-tech/nk-email`'s `escapeHtml`.
23
+ *
24
+ * This exists because each site hand-rolled its own notification HTML and most
25
+ * interpolated `name`/`email`/`message` straight into the markup unescaped — an
26
+ * HTML-injection footgun in the inbox. Rendering through here makes escaping the
27
+ * default instead of a per-site coin flip.
28
+ */
29
+ export declare const renderNotificationEmail: (options: NotificationEmailOptions) => RenderedEmail;
30
+ //# sourceMappingURL=email.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../src/email.ts"],"names":[],"mappings":"AAEA,gFAAgF;AAChF,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,wBAAwB;IACxC,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC7B,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAC1B,wBAAwB,KAC/B,aAgDF,CAAC"}
package/dist/email.js ADDED
@@ -0,0 +1,47 @@
1
+ import { escapeHtml } from "@ingram-tech/nk-email";
2
+ /**
3
+ * Render a submission into `{ html, text }` for `sendEmail`, with **every**
4
+ * interpolated value escaped via `@ingram-tech/nk-email`'s `escapeHtml`.
5
+ *
6
+ * This exists because each site hand-rolled its own notification HTML and most
7
+ * interpolated `name`/`email`/`message` straight into the markup unescaped — an
8
+ * HTML-injection footgun in the inbox. Rendering through here makes escaping the
9
+ * default instead of a per-site coin flip.
10
+ */
11
+ export const renderNotificationEmail = (options) => {
12
+ const fields = (options.fields ?? []).filter((field) => typeof field.value === "string" && field.value.trim() !== "");
13
+ const rows = fields
14
+ .map((field) => `<tr><td style="padding:4px 12px 4px 0;color:#666;">${escapeHtml(field.label)}</td><td style="padding:4px 0;">${escapeHtml(field.value)}</td></tr>`)
15
+ .join("");
16
+ const table = rows
17
+ ? `<table style="border-collapse:collapse;font-size:14px;">${rows}</table>`
18
+ : "";
19
+ const messageBlock = options.message
20
+ ? `<div style="background:#f5f5f5;padding:16px;border-radius:8px;margin:16px 0;"><p style="white-space:pre-wrap;margin:0;">${escapeHtml(options.message)}</p></div>`
21
+ : "";
22
+ const footerBlock = options.footer
23
+ ? `<p style="color:#888;font-size:13px;margin-top:20px;">${escapeHtml(options.footer)}</p>`
24
+ : "";
25
+ const html = [
26
+ `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">`,
27
+ `<h2 style="color:#333;">${escapeHtml(options.heading)}</h2>`,
28
+ table,
29
+ messageBlock,
30
+ footerBlock,
31
+ `</div>`,
32
+ ]
33
+ .filter((part) => part !== "")
34
+ .join("\n");
35
+ const textLines = [options.heading, ""];
36
+ for (const field of fields) {
37
+ textLines.push(`${field.label}: ${field.value}`);
38
+ }
39
+ if (options.message) {
40
+ textLines.push("", "Message:", options.message);
41
+ }
42
+ if (options.footer) {
43
+ textLines.push("", "---", options.footer);
44
+ }
45
+ return { html, text: textLines.join("\n") };
46
+ };
47
+ //# sourceMappingURL=email.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.js","sourceRoot":"","sources":["../src/email.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAwBnD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CACtC,OAAiC,EACjB,EAAE;IAClB,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAC3C,CAAC,KAAK,EAA6C,EAAE,CACpD,OAAO,KAAK,CAAC,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAC7D,CAAC;IAEF,MAAM,IAAI,GAAG,MAAM;SACjB,GAAG,CACH,CAAC,KAAK,EAAE,EAAE,CACT,sDAAsD,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,mCAAmC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,YAAY,CACpJ;SACA,IAAI,CAAC,EAAE,CAAC,CAAC;IAEX,MAAM,KAAK,GAAG,IAAI;QACjB,CAAC,CAAC,2DAA2D,IAAI,UAAU;QAC3E,CAAC,CAAC,EAAE,CAAC;IAEN,MAAM,YAAY,GAAG,OAAO,CAAC,OAAO;QACnC,CAAC,CAAC,2HAA2H,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,YAAY;QACpK,CAAC,CAAC,EAAE,CAAC;IAEN,MAAM,WAAW,GAAG,OAAO,CAAC,MAAM;QACjC,CAAC,CAAC,yDAAyD,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM;QAC3F,CAAC,CAAC,EAAE,CAAC;IAEN,MAAM,IAAI,GAAG;QACZ,qEAAqE;QACrE,2BAA2B,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO;QAC7D,KAAK;QACL,YAAY;QACZ,WAAW;QACX,QAAQ;KACR;SACC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC;SAC7B,IAAI,CAAC,IAAI,CAAC,CAAC;IAEb,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACxC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC5B,SAAS,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;IAClD,CAAC;IACD,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QACrB,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACjD,CAAC;IACD,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACpB,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3C,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AAC7C,CAAC,CAAC"}
@@ -0,0 +1,58 @@
1
+ import { type VerifyOptions } from "@ingram-tech/bot-protection";
2
+ /**
3
+ * Decision returned by a caller-supplied rate limiter. nk-forms owns no store —
4
+ * you plug in your own (per-IP, per-user, Redis, in-memory) and just tell the
5
+ * handler whether this request may proceed.
6
+ */
7
+ export interface RateLimitDecision {
8
+ ok: boolean;
9
+ /** Milliseconds until the caller may retry; surfaced as a Retry-After header. */
10
+ retryAfterMs?: number;
11
+ }
12
+ /**
13
+ * The one method the handler needs from a validator. Any Zod schema satisfies
14
+ * this structurally, so nk-forms never depends on (or pins) a Zod version.
15
+ */
16
+ export interface FormSchema<T> {
17
+ safeParse(input: unknown): {
18
+ success: true;
19
+ data: T;
20
+ } | {
21
+ success: false;
22
+ };
23
+ }
24
+ interface Loggerish {
25
+ warn(message: string): void;
26
+ error(message: string, error?: unknown): void;
27
+ }
28
+ export interface FormHandlerOptions<T> {
29
+ /** Validates (and narrows) the submission. Zod strips bot-protection fields. */
30
+ schema: FormSchema<T>;
31
+ /** Deliver the validated submission: send an email, subscribe an address, … */
32
+ onSubmit: (data: T) => void | Promise<void>;
33
+ /** Optional gate run before the body is read. Return `ok: false` to 429. */
34
+ rateLimit?: () => RateLimitDecision | Promise<RateLimitDecision>;
35
+ /** Bot-protection layers, forwarded to `verifyHuman` (minus `formData`). */
36
+ verify?: Omit<VerifyOptions, "formData">;
37
+ /** Where dropped/failed submissions are logged. Defaults to no logging. */
38
+ logger?: Loggerish;
39
+ /** Label used in log lines (e.g. "contact", "newsletter"). */
40
+ label?: string;
41
+ }
42
+ /**
43
+ * The contact/signup submission pipeline, in fixed order:
44
+ *
45
+ * 1. rate limit → 429 (optional; cheapest, no body needed)
46
+ * 2. parse JSON body → 400 on malformed input
47
+ * 3. bot gate → silent 200 (honeypot + timing token + Vercel BotID)
48
+ * 4. schema validate → 400 on invalid fields
49
+ * 5. deliver → 500 if `onSubmit` throws
50
+ * 6. success → 200 { success: true }
51
+ *
52
+ * Returns a web-standard `Response`, which Next.js route handlers accept
53
+ * directly — nk-forms takes no `next` dependency. Pair with `mintFormToken` as
54
+ * the route's GET and `useFormSubmit`/`useBotProtection` on the client.
55
+ */
56
+ export declare const handleFormSubmission: <T>(request: Request, options: FormHandlerOptions<T>) => Promise<Response>;
57
+ export {};
58
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAE9E;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,CAAC,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,KAAK,CAAA;KAAE,CAAC;CAC3E;AAED,UAAU,SAAS;IAClB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9C;AAED,MAAM,WAAW,kBAAkB,CAAC,CAAC;IACpC,gFAAgF;IAChF,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC;IACtB,+EAA+E;IAC/E,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACjE,4EAA4E;IAC5E,MAAM,CAAC,EAAE,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACzC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAYD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAAU,CAAC,WAClC,OAAO,WACP,kBAAkB,CAAC,CAAC,CAAC,KAC5B,OAAO,CAAC,QAAQ,CA8DlB,CAAC"}
@@ -0,0 +1,77 @@
1
+ import { verifyHuman } from "@ingram-tech/bot-protection";
2
+ const json = (data, status = 200) => new Response(JSON.stringify(data), {
3
+ status,
4
+ headers: { "content-type": "application/json" },
5
+ });
6
+ // Bot hits and honest submissions return the SAME 200 body — a dropped bot must
7
+ // never be able to tell it was dropped, and a real user is never shown an error.
8
+ const SUCCESS = { success: true };
9
+ /**
10
+ * The contact/signup submission pipeline, in fixed order:
11
+ *
12
+ * 1. rate limit → 429 (optional; cheapest, no body needed)
13
+ * 2. parse JSON body → 400 on malformed input
14
+ * 3. bot gate → silent 200 (honeypot + timing token + Vercel BotID)
15
+ * 4. schema validate → 400 on invalid fields
16
+ * 5. deliver → 500 if `onSubmit` throws
17
+ * 6. success → 200 { success: true }
18
+ *
19
+ * Returns a web-standard `Response`, which Next.js route handlers accept
20
+ * directly — nk-forms takes no `next` dependency. Pair with `mintFormToken` as
21
+ * the route's GET and `useFormSubmit`/`useBotProtection` on the client.
22
+ */
23
+ export const handleFormSubmission = async (request, options) => {
24
+ const label = options.label ?? "form";
25
+ const log = options.logger;
26
+ // 1. Rate limit — before touching the body so floods are cheap to reject.
27
+ if (options.rateLimit) {
28
+ const decision = await options.rateLimit();
29
+ if (!decision.ok) {
30
+ const headers = {
31
+ "content-type": "application/json",
32
+ };
33
+ if (decision.retryAfterMs !== undefined) {
34
+ headers["retry-after"] = Math.ceil(decision.retryAfterMs / 1000).toString();
35
+ }
36
+ return new Response(JSON.stringify({
37
+ error: "Too many requests. Please try again later.",
38
+ }), { status: 429, headers });
39
+ }
40
+ }
41
+ // 2. Read the JSON body.
42
+ let body;
43
+ try {
44
+ body = await request.json();
45
+ }
46
+ catch {
47
+ return json({ error: "Invalid request." }, 400);
48
+ }
49
+ if (typeof body !== "object" || body === null) {
50
+ return json({ error: "Invalid request." }, 400);
51
+ }
52
+ // 3. Bot gate. On failure, *pretend success* — never tell a bot what tripped
53
+ // it, never punish a real user with a visible error.
54
+ const verdict = await verifyHuman({
55
+ ...options.verify,
56
+ formData: body,
57
+ });
58
+ if (!verdict.ok) {
59
+ log?.warn(`${label}: dropped by bot protection (${verdict.reason})`);
60
+ return json(SUCCESS);
61
+ }
62
+ // 4. Validate. A well-behaved schema strips the bot-protection fields.
63
+ const parsed = options.schema.safeParse(body);
64
+ if (!parsed.success) {
65
+ return json({ error: "Invalid form data. Please check your input." }, 400);
66
+ }
67
+ // 5. Deliver.
68
+ try {
69
+ await options.onSubmit(parsed.data);
70
+ }
71
+ catch (error) {
72
+ log?.error(`${label}: delivery failed`, error);
73
+ return json({ error: "Failed to submit. Please try again later." }, 500);
74
+ }
75
+ return json(SUCCESS);
76
+ };
77
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export { type FormHandlerOptions, type FormSchema, handleFormSubmission, type RateLimitDecision, } from "./handler.js";
2
+ export { type NotificationEmailOptions, type NotificationField, type RenderedEmail, renderNotificationEmail, } from "./email.js";
3
+ export { mintFormToken } from "./token.js";
4
+ export { createFormToken, verifyHuman } from "@ingram-tech/bot-protection";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,oBAAoB,EACpB,KAAK,iBAAiB,GACtB,MAAM,cAAc,CAAC;AACtB,OAAO,EACN,KAAK,wBAAwB,EAC7B,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,uBAAuB,GACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAG3C,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // Server entry — no React. The client hook and components live at
2
+ // "@ingram-tech/nk-forms/react" so importing the handler never pulls in React.
3
+ export { handleFormSubmission, } from "./handler.js";
4
+ export { renderNotificationEmail, } from "./email.js";
5
+ export { mintFormToken } from "./token.js";
6
+ // Re-export the underlying primitives so a form route is a single import.
7
+ export { createFormToken, verifyHuman } from "@ingram-tech/bot-protection";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,+EAA+E;AAC/E,OAAO,EAGN,oBAAoB,GAEpB,MAAM,cAAc,CAAC;AACtB,OAAO,EAIN,uBAAuB,GACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,0EAA0E;AAC1E,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC"}
@@ -0,0 +1,30 @@
1
+ export { HoneypotInput, useBotProtection } from "@ingram-tech/bot-protection/react";
2
+ export type FormSubmitStatus = "idle" | "submitting" | "success" | "error";
3
+ export interface FormSubmitResult {
4
+ ok: boolean;
5
+ data?: unknown;
6
+ }
7
+ /**
8
+ * One-import client wiring for a form that POSTs JSON to `endpoint`.
9
+ *
10
+ * Wraps {@link useBotProtection} (timing token + honeypot ref) and owns submit
11
+ * status so the component only renders markup. The route's GET mints the token
12
+ * (`mintFormToken`) and its POST runs `handleFormSubmission`.
13
+ *
14
+ * @example
15
+ * const { honeypotRef, submit, status, error } = useFormSubmit("/api/contact");
16
+ * // in the form: <HoneypotInput inputRef={honeypotRef} />
17
+ * // on submit: const res = await submit({ name, email, message });
18
+ * // if (res.ok) setSent(true);
19
+ */
20
+ export declare function useFormSubmit(endpoint: string, options?: {
21
+ honeypotField?: string;
22
+ }): {
23
+ honeypotRef: import("react").RefObject<HTMLInputElement | null>;
24
+ botFields: () => Record<string, string>;
25
+ ready: boolean;
26
+ status: FormSubmitStatus;
27
+ error: string | null;
28
+ submit: (values: Record<string, unknown>) => Promise<FormSubmitResult>;
29
+ };
30
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAEpF,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,YAAY,GAAG,SAAS,GAAG,OAAO,CAAC;AAE3E,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAcD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE;;;;;;qBAM1E,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,OAAO,CAAC,gBAAgB,CAAC;EA6B5B"}
package/dist/react.js ADDED
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { useBotProtection } from "@ingram-tech/bot-protection/react";
4
+ // Re-export the primitives so a form component needs a single import.
5
+ export { HoneypotInput, useBotProtection } from "@ingram-tech/bot-protection/react";
6
+ const getErrorMessage = (data) => {
7
+ if (typeof data === "object" &&
8
+ data !== null &&
9
+ "error" in data &&
10
+ typeof data.error === "string") {
11
+ return data.error;
12
+ }
13
+ return null;
14
+ };
15
+ /**
16
+ * One-import client wiring for a form that POSTs JSON to `endpoint`.
17
+ *
18
+ * Wraps {@link useBotProtection} (timing token + honeypot ref) and owns submit
19
+ * status so the component only renders markup. The route's GET mints the token
20
+ * (`mintFormToken`) and its POST runs `handleFormSubmission`.
21
+ *
22
+ * @example
23
+ * const { honeypotRef, submit, status, error } = useFormSubmit("/api/contact");
24
+ * // in the form: <HoneypotInput inputRef={honeypotRef} />
25
+ * // on submit: const res = await submit({ name, email, message });
26
+ * // if (res.ok) setSent(true);
27
+ */
28
+ export function useFormSubmit(endpoint, options) {
29
+ const { honeypotRef, botFields, ready } = useBotProtection(endpoint, options);
30
+ const [status, setStatus] = useState("idle");
31
+ const [error, setError] = useState(null);
32
+ const submit = async (values) => {
33
+ setStatus("submitting");
34
+ setError(null);
35
+ try {
36
+ const res = await fetch(endpoint, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ ...values, ...botFields() }),
40
+ });
41
+ const data = await res.json().catch(() => ({}));
42
+ if (!res.ok) {
43
+ throw new Error(getErrorMessage(data) ?? "Something went wrong. Please try again.");
44
+ }
45
+ setStatus("success");
46
+ return { ok: true, data };
47
+ }
48
+ catch (submitError) {
49
+ setStatus("error");
50
+ setError(submitError instanceof Error
51
+ ? submitError.message
52
+ : "Something went wrong. Please try again.");
53
+ return { ok: false };
54
+ }
55
+ };
56
+ return { honeypotRef, botFields, ready, status, error, submit };
57
+ }
58
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACjC,OAAO,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAErE,sEAAsE;AACtE,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AASpF,MAAM,eAAe,GAAG,CAAC,IAAa,EAAiB,EAAE;IACxD,IACC,OAAO,IAAI,KAAK,QAAQ;QACxB,IAAI,KAAK,IAAI;QACb,OAAO,IAAI,IAAI;QACf,OAAQ,IAA2B,CAAC,KAAK,KAAK,QAAQ,EACrD,CAAC;QACF,OAAQ,IAA0B,CAAC,KAAK,CAAC;IAC1C,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB,EAAE,OAAoC;IACnF,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9E,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAmB,MAAM,CAAC,CAAC;IAC/D,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IAExD,MAAM,MAAM,GAAG,KAAK,EACnB,MAA+B,EACH,EAAE;QAC9B,SAAS,CAAC,YAAY,CAAC,CAAC;QACxB,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,IAAI,CAAC;YACJ,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,QAAQ,EAAE;gBACjC,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,EAAE,CAAC;aACnD,CAAC,CAAC;YACH,MAAM,IAAI,GAAY,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACzD,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;gBACb,MAAM,IAAI,KAAK,CACd,eAAe,CAAC,IAAI,CAAC,IAAI,yCAAyC,CAClE,CAAC;YACH,CAAC;YACD,SAAS,CAAC,SAAS,CAAC,CAAC;YACrB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,WAAW,EAAE,CAAC;YACtB,SAAS,CAAC,OAAO,CAAC,CAAC;YACnB,QAAQ,CACP,WAAW,YAAY,KAAK;gBAC3B,CAAC,CAAC,WAAW,CAAC,OAAO;gBACrB,CAAC,CAAC,yCAAyC,CAC5C,CAAC;YACF,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;QACtB,CAAC;IACF,CAAC,CAAC;IAEF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AACjE,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Ready-made GET handler that mints the signed timing token the client hook
3
+ * fetches on mount. Wire it into any form route with one line:
4
+ *
5
+ * export { mintFormToken as GET } from "@ingram-tech/nk-forms";
6
+ *
7
+ * or alongside other GET logic: `export const GET = () => mintFormToken();`
8
+ *
9
+ * Returns `{ token: "" }` when `BOT_PROTECTION_SECRET` is unset (the timing
10
+ * layer is simply disabled — honeypot + BotID still run).
11
+ */
12
+ export declare const mintFormToken: () => Response;
13
+ //# sourceMappingURL=token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,QAAO,QAG9B,CAAC"}
package/dist/token.js ADDED
@@ -0,0 +1,16 @@
1
+ import { createFormToken } from "@ingram-tech/bot-protection";
2
+ /**
3
+ * Ready-made GET handler that mints the signed timing token the client hook
4
+ * fetches on mount. Wire it into any form route with one line:
5
+ *
6
+ * export { mintFormToken as GET } from "@ingram-tech/nk-forms";
7
+ *
8
+ * or alongside other GET logic: `export const GET = () => mintFormToken();`
9
+ *
10
+ * Returns `{ token: "" }` when `BOT_PROTECTION_SECRET` is unset (the timing
11
+ * layer is simply disabled — honeypot + BotID still run).
12
+ */
13
+ export const mintFormToken = () => new Response(JSON.stringify({ token: createFormToken() }), {
14
+ headers: { "content-type": "application/json" },
15
+ });
16
+ //# sourceMappingURL=token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,GAAa,EAAE,CAC3C,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,CAAC,EAAE;IAC1D,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;CAC/C,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ingram-tech/nk-forms",
3
+ "version": "0.2.0",
4
+ "description": "Contact/signup form pipeline for Next.js sites — bot protection, validation, and escaped email notifications in one handler.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ingram-technologies/nextkit.git",
10
+ "directory": "packages/nk-forms"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./react": {
24
+ "types": "./dist/react.d.ts",
25
+ "import": "./dist/react.js"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "type-check": "tsc -p tsconfig.json --noEmit",
31
+ "test": "vitest run"
32
+ },
33
+ "dependencies": {
34
+ "@ingram-tech/bot-protection": "0.4.0",
35
+ "@ingram-tech/nk-email": "0.3.1"
36
+ },
37
+ "devDependencies": {
38
+ "@ingram-tech/nk-dev": "0.5.0",
39
+ "@types/node": "^26.1.1",
40
+ "@types/react": "^19.2.17",
41
+ "react": "^19.2.7",
42
+ "typescript": "^7.0.2",
43
+ "vitest": "^4.1.10"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^18.0.0 || ^19.0.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "react": {
50
+ "optional": true
51
+ }
52
+ }
53
+ }