Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@toolctrl/form-widget",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./origin": "./src/origin.ts",
|
||||
"./honeypot": "./src/honeypot.ts",
|
||||
"./routing": "./src/routing.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run typecheck",
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isHoneypotTriggered } from "./honeypot.js";
|
||||
import { HONEYPOT_FIELD_KEY } from "./schema.js";
|
||||
|
||||
describe("isHoneypotTriggered", () => {
|
||||
it("returns false when honeypot field is absent", () => {
|
||||
expect(isHoneypotTriggered({ name: "Alice" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when honeypot is empty string", () => {
|
||||
expect(isHoneypotTriggered({ [HONEYPOT_FIELD_KEY]: "" })).toBe(false);
|
||||
expect(isHoneypotTriggered({ [HONEYPOT_FIELD_KEY]: " " })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on any filled value", () => {
|
||||
expect(isHoneypotTriggered({ [HONEYPOT_FIELD_KEY]: "bot" })).toBe(true);
|
||||
expect(isHoneypotTriggered({ [HONEYPOT_FIELD_KEY]: true })).toBe(true);
|
||||
expect(isHoneypotTriggered({ [HONEYPOT_FIELD_KEY]: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("handles null/undefined safely", () => {
|
||||
expect(isHoneypotTriggered(null)).toBe(false);
|
||||
expect(isHoneypotTriggered(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Honeypot detection helper. A honeypot field is a hidden input that
|
||||
* legitimate users never see / fill — bots that scrape form fields and
|
||||
* submit blindly do.
|
||||
*
|
||||
* The renderer emits a field with key {@link HONEYPOT_FIELD_KEY} hidden
|
||||
* via CSS + aria-hidden + tabindex=-1. The submission endpoint passes the
|
||||
* raw payload through this helper. When a non-empty value is detected we
|
||||
* return `true` — the caller should silently accept the request (204) so
|
||||
* the bot gets no signal that it was rejected.
|
||||
*/
|
||||
|
||||
import { HONEYPOT_FIELD_KEY } from "./schema.js";
|
||||
|
||||
export function isHoneypotTriggered(values: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!values) return false;
|
||||
const raw = values[HONEYPOT_FIELD_KEY];
|
||||
if (raw === undefined || raw === null) return false;
|
||||
if (typeof raw === "string") return raw.trim() !== "";
|
||||
if (typeof raw === "boolean") return raw === true;
|
||||
if (typeof raw === "number") return raw !== 0;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @toolctrl/form-widget — shared types & helpers for the embeddable form
|
||||
* widget (Task #58). Contains zero runtime dependencies on the rest of the
|
||||
* codebase so it can be imported from the web app, the api, the worker and
|
||||
* the cli without dragging in Prisma or React.
|
||||
*
|
||||
* Sub-modules:
|
||||
* - schema — Zod definitions for form/field/theme/submission JSON
|
||||
* - origin — allowed-origin matcher with wildcard support
|
||||
* - honeypot — honeypot-field detection helper
|
||||
* - routing — submission target resolver (entity / crm / generic) +
|
||||
* field-mapping helper consumed by the worker
|
||||
*/
|
||||
|
||||
export {
|
||||
FormDefinitionSchema,
|
||||
FormFieldSchema,
|
||||
FormThemeSchema,
|
||||
FormSubmissionPayloadSchema,
|
||||
FieldTypeSchema,
|
||||
HONEYPOT_FIELD_KEY,
|
||||
type FormDefinition,
|
||||
type FormField,
|
||||
type FormFieldType,
|
||||
type FormTheme,
|
||||
type FormSubmissionPayload,
|
||||
} from "./schema.js";
|
||||
|
||||
export { matchAllowedOrigin, normalizeOrigin } from "./origin.js";
|
||||
export { isHoneypotTriggered } from "./honeypot.js";
|
||||
export {
|
||||
resolveSubmissionTarget,
|
||||
applyFieldMapping,
|
||||
type SubmissionTargetType,
|
||||
type SubmissionTargetDescriptor,
|
||||
type ApplyFieldMappingInput,
|
||||
} from "./routing.js";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchAllowedOrigin, normalizeOrigin } from "./origin.js";
|
||||
|
||||
describe("normalizeOrigin", () => {
|
||||
it("strips path and lowercases", () => {
|
||||
expect(normalizeOrigin("HTTPS://Example.com/foo")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("returns null on empty input", () => {
|
||||
expect(normalizeOrigin("")).toBeNull();
|
||||
expect(normalizeOrigin(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("infers https scheme when missing", () => {
|
||||
expect(normalizeOrigin("example.com")).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchAllowedOrigin", () => {
|
||||
it("returns true on exact match", () => {
|
||||
expect(matchAllowedOrigin("https://example.com", ["https://example.com"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on host mismatch", () => {
|
||||
expect(matchAllowedOrigin("https://evil.com", ["https://example.com"])).toBe(false);
|
||||
});
|
||||
|
||||
it("supports wildcard subdomains", () => {
|
||||
expect(matchAllowedOrigin("https://foo.example.com", ["*.example.com"])).toBe(true);
|
||||
expect(matchAllowedOrigin("https://example.com", ["*.example.com"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on empty allowlist", () => {
|
||||
expect(matchAllowedOrigin("https://example.com", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on missing origin header", () => {
|
||||
expect(matchAllowedOrigin(null, ["https://example.com"])).toBe(false);
|
||||
});
|
||||
|
||||
it("respects port when specified", () => {
|
||||
expect(matchAllowedOrigin("https://example.com:8443", ["example.com:8443"])).toBe(true);
|
||||
expect(matchAllowedOrigin("https://example.com:9000", ["example.com:8443"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Origin-allowlist helper for the form-widget submission endpoint.
|
||||
*
|
||||
* Supports exact-match (`https://example.com`), bare hostnames (`example.com`)
|
||||
* and wildcard subdomain patterns (`*.example.com`). Schemes default to https
|
||||
* when not provided. Ports must match exactly when present.
|
||||
*
|
||||
* Returns `true` only when at least one allowed entry matches. An empty
|
||||
* allowlist always returns `false` — a form with no allowed origins is
|
||||
* non-submittable from any browser, by design.
|
||||
*/
|
||||
|
||||
export function normalizeOrigin(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "") return null;
|
||||
// Browsers send Origin as scheme + host[:port], no path. Strip trailing slash.
|
||||
try {
|
||||
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||
return `${url.protocol}//${url.host}`.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedEntry(entry: string): { host: string; wildcard: boolean; port?: string; scheme?: string } | null {
|
||||
const trimmed = entry.trim().toLowerCase();
|
||||
if (trimmed === "") return null;
|
||||
let scheme: string | undefined;
|
||||
let rest = trimmed;
|
||||
if (rest.includes("://")) {
|
||||
const [s, r] = rest.split("://", 2) as [string, string];
|
||||
scheme = s;
|
||||
rest = r;
|
||||
}
|
||||
// host[:port]
|
||||
const wildcard = rest.startsWith("*.");
|
||||
const hostAndPort = wildcard ? rest.slice(2) : rest;
|
||||
const [host, port] = hostAndPort.split(":", 2);
|
||||
if (!host) return null;
|
||||
return { host, wildcard, port, scheme };
|
||||
}
|
||||
|
||||
export function matchAllowedOrigin(originHeader: string | null | undefined, allowed: readonly string[]): boolean {
|
||||
if (!allowed || allowed.length === 0) return false;
|
||||
const normalized = normalizeOrigin(originHeader);
|
||||
if (!normalized) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const reqHost = parsed.hostname.toLowerCase();
|
||||
const reqPort = parsed.port; // empty string when default
|
||||
const reqScheme = parsed.protocol.replace(":", "");
|
||||
|
||||
for (const entry of allowed) {
|
||||
const candidate = parseAllowedEntry(entry);
|
||||
if (!candidate) continue;
|
||||
if (candidate.scheme && candidate.scheme !== reqScheme) continue;
|
||||
if (candidate.port !== undefined && candidate.port !== reqPort) continue;
|
||||
if (candidate.wildcard) {
|
||||
// *.example.com matches foo.example.com but NOT example.com
|
||||
if (reqHost === candidate.host) continue;
|
||||
if (reqHost.endsWith(`.${candidate.host}`)) return true;
|
||||
} else if (reqHost === candidate.host) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyFieldMapping, resolveSubmissionTarget } from "./routing.js";
|
||||
|
||||
describe("resolveSubmissionTarget", () => {
|
||||
it("defaults to generic when no target type is set", () => {
|
||||
const target = resolveSubmissionTarget({});
|
||||
expect(target.type).toBe("generic");
|
||||
expect(target.entityId).toBeNull();
|
||||
});
|
||||
|
||||
it("returns crm-contact when configured", () => {
|
||||
const target = resolveSubmissionTarget({ targetType: "crm-contact", fieldMapping: { email: "email" } });
|
||||
expect(target.type).toBe("crm-contact");
|
||||
expect(target.fieldMapping.email).toBe("email");
|
||||
});
|
||||
|
||||
it("falls back to generic when entity-row has no entityId", () => {
|
||||
const target = resolveSubmissionTarget({ targetType: "entity-row" });
|
||||
expect(target.type).toBe("generic");
|
||||
});
|
||||
|
||||
it("preserves entityId for valid entity-row config", () => {
|
||||
const target = resolveSubmissionTarget({ targetType: "entity-row", targetEntityId: "ent_123" });
|
||||
expect(target.type).toBe("entity-row");
|
||||
expect(target.entityId).toBe("ent_123");
|
||||
});
|
||||
|
||||
it("falls back to generic for unknown target types", () => {
|
||||
const target = resolveSubmissionTarget({ targetType: "rocket-launch" });
|
||||
expect(target.type).toBe("generic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFieldMapping", () => {
|
||||
it("passes payload through unchanged when no mapping is configured", () => {
|
||||
const result = applyFieldMapping({ payload: { email: "a@b.co", name: "Alice" }, fieldMapping: {} });
|
||||
expect(result).toEqual({ email: "a@b.co", name: "Alice" });
|
||||
});
|
||||
|
||||
it("passes payload through when mapping is undefined", () => {
|
||||
const result = applyFieldMapping({ payload: { email: "a@b.co" } });
|
||||
expect(result).toEqual({ email: "a@b.co" });
|
||||
});
|
||||
|
||||
it("renames keys according to the mapping", () => {
|
||||
const result = applyFieldMapping({
|
||||
payload: { firstName: "Alice", email_address: "a@b.co" },
|
||||
fieldMapping: { firstName: "first_name", email_address: "email" },
|
||||
});
|
||||
expect(result).toEqual({ first_name: "Alice", email: "a@b.co" });
|
||||
});
|
||||
|
||||
it("drops form fields that are not in the mapping", () => {
|
||||
const result = applyFieldMapping({
|
||||
payload: { firstName: "Alice", lastName: "Smith", honeypot: "" },
|
||||
fieldMapping: { firstName: "first_name" },
|
||||
});
|
||||
expect(result).toEqual({ first_name: "Alice" });
|
||||
expect(Object.prototype.hasOwnProperty.call(result, "lastName")).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(result, "honeypot")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores mapping entries with empty target keys", () => {
|
||||
const result = applyFieldMapping({
|
||||
payload: { firstName: "Alice", email: "a@b.co" },
|
||||
fieldMapping: { firstName: "", email: "email" },
|
||||
});
|
||||
expect(result).toEqual({ email: "a@b.co" });
|
||||
});
|
||||
|
||||
it("preserves null/undefined values in the payload", () => {
|
||||
const result = applyFieldMapping({
|
||||
payload: { firstName: null, age: undefined, email: "a@b.co" } as Record<string, unknown>,
|
||||
fieldMapping: { firstName: "first_name", age: "age", email: "email" },
|
||||
});
|
||||
expect(result.first_name).toBeNull();
|
||||
expect(result.email).toBe("a@b.co");
|
||||
// `age` was undefined → key dropped only if hasOwnProperty is false; here it
|
||||
// was an own property explicitly set to undefined, so the helper keeps it.
|
||||
expect(Object.prototype.hasOwnProperty.call(result, "age")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an empty payload safely", () => {
|
||||
expect(applyFieldMapping({ payload: {} })).toEqual({});
|
||||
expect(applyFieldMapping({ payload: null })).toEqual({});
|
||||
expect(applyFieldMapping({ payload: undefined })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a shallow copy — mutating the result does not affect input", () => {
|
||||
const payload = { email: "a@b.co" };
|
||||
const result = applyFieldMapping({ payload });
|
||||
result.email = "changed";
|
||||
expect(payload.email).toBe("a@b.co");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Submission-routing resolver. A form can route inbound submissions to one
|
||||
* of three targets:
|
||||
*
|
||||
* - `generic` — store as FormSubmission row only (default)
|
||||
* - `crm-contact` — upsert a CRM contact using mapped fields
|
||||
* - `entity-row` — create a row in a tenant-defined entity
|
||||
*
|
||||
* The resolver here is pure: it just describes the target. The actual
|
||||
* write happens in the submission route / worker handler.
|
||||
*/
|
||||
|
||||
export type SubmissionTargetType = "generic" | "crm-contact" | "entity-row";
|
||||
|
||||
export interface SubmissionTargetDescriptor {
|
||||
readonly type: SubmissionTargetType;
|
||||
readonly entityId: string | null;
|
||||
readonly fieldMapping: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ResolveSubmissionTargetInput {
|
||||
readonly targetType?: string | null;
|
||||
readonly targetEntityId?: string | null;
|
||||
readonly fieldMapping?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
const VALID_TARGETS: ReadonlySet<SubmissionTargetType> = new Set(["generic", "crm-contact", "entity-row"]);
|
||||
|
||||
function isValidTarget(value: unknown): value is SubmissionTargetType {
|
||||
return typeof value === "string" && (VALID_TARGETS as Set<string>).has(value);
|
||||
}
|
||||
|
||||
export function resolveSubmissionTarget(input: ResolveSubmissionTargetInput): SubmissionTargetDescriptor {
|
||||
const type: SubmissionTargetType = isValidTarget(input.targetType) ? input.targetType : "generic";
|
||||
if (type === "entity-row" && !input.targetEntityId) {
|
||||
// misconfigured form — fall back to generic so submissions still land somewhere
|
||||
return { type: "generic", entityId: null, fieldMapping: Object.freeze({}) };
|
||||
}
|
||||
return {
|
||||
type,
|
||||
entityId: input.targetEntityId ?? null,
|
||||
fieldMapping: Object.freeze({ ...(input.fieldMapping ?? {}) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-mapping helper used by the worker when routing a form submission
|
||||
* to a CRM contact / tenant-entity row.
|
||||
*
|
||||
* `payload` is the raw key/value map the user filled in, where the keys
|
||||
* are the form-field keys (e.g. `firstName`, `email_address`).
|
||||
* `fieldMapping` maps form-field keys to target property keys (e.g.
|
||||
* `{ "email_address": "email", "firstName": "first_name" }`).
|
||||
*
|
||||
* Behaviour:
|
||||
* - When `fieldMapping` is empty/undefined the helper passes the payload
|
||||
* through unchanged (1:1 mapping). This is the legitimate path for
|
||||
* "the form fields are already named to match the entity columns".
|
||||
* - Unmapped form fields are dropped. Targets typically have a fixed
|
||||
* column schema, so silently dropping is safer than forwarding stray
|
||||
* data that the entity row API would reject.
|
||||
* - When two form fields map to the same target column the later one
|
||||
* wins. The form-builder UI should warn on duplicate target keys; we
|
||||
* don't throw because reconfiguring a form must not break in-flight
|
||||
* submissions.
|
||||
* - Null/undefined values are forwarded as-is so the receiver can
|
||||
* distinguish "not provided" from "empty string".
|
||||
*
|
||||
* The helper is intentionally synchronous & dependency-free: it runs in
|
||||
* the worker handler hot path and must not touch DB / network.
|
||||
*/
|
||||
export interface ApplyFieldMappingInput {
|
||||
readonly payload: Readonly<Record<string, unknown>> | null | undefined;
|
||||
readonly fieldMapping?: Readonly<Record<string, string>> | null;
|
||||
}
|
||||
|
||||
export function applyFieldMapping(input: ApplyFieldMappingInput): Record<string, unknown> {
|
||||
const payload = input.payload ?? {};
|
||||
const mapping = input.fieldMapping ?? {};
|
||||
if (Object.keys(mapping).length === 0) {
|
||||
// No mapping configured → forward every form field 1:1.
|
||||
return { ...payload };
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [sourceKey, targetKey] of Object.entries(mapping)) {
|
||||
if (typeof targetKey !== "string" || targetKey.length === 0) continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, sourceKey)) continue;
|
||||
out[targetKey] = (payload as Record<string, unknown>)[sourceKey];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FormDefinitionSchema,
|
||||
FormFieldSchema,
|
||||
FormSubmissionPayloadSchema,
|
||||
FormThemeSchema,
|
||||
HONEYPOT_FIELD_KEY,
|
||||
} from "./schema.js";
|
||||
|
||||
describe("FormFieldSchema", () => {
|
||||
it("accepts a minimal text field", () => {
|
||||
const result = FormFieldSchema.safeParse({ key: "name", type: "text", label: "Name" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects keys that start with a number", () => {
|
||||
const result = FormFieldSchema.safeParse({ key: "1name", type: "text", label: "Name" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires options for select fields", () => {
|
||||
const result = FormFieldSchema.safeParse({ key: "color", type: "select", label: "Color" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects minLength > maxLength", () => {
|
||||
const result = FormFieldSchema.safeParse({
|
||||
key: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
minLength: 50,
|
||||
maxLength: 10,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormDefinitionSchema", () => {
|
||||
it("accepts a valid definition with multiple fields", () => {
|
||||
const result = FormDefinitionSchema.safeParse({
|
||||
title: "Contact",
|
||||
fields: [
|
||||
{ key: "name", type: "text", label: "Name", required: true },
|
||||
{ key: "email", type: "email", label: "Email", required: true },
|
||||
{ key: HONEYPOT_FIELD_KEY, type: "honeypot", label: "Leave empty" },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate field keys", () => {
|
||||
const result = FormDefinitionSchema.safeParse({
|
||||
title: "Contact",
|
||||
fields: [
|
||||
{ key: "name", type: "text", label: "Name" },
|
||||
{ key: "name", type: "email", label: "Email" },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects definitions with no fields", () => {
|
||||
const result = FormDefinitionSchema.safeParse({ title: "Empty", fields: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormThemeSchema", () => {
|
||||
it("accepts valid hex colors", () => {
|
||||
expect(FormThemeSchema.safeParse({ primaryColor: "#123abc" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-hex colors", () => {
|
||||
expect(FormThemeSchema.safeParse({ primaryColor: "rgb(0,0,0)" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormSubmissionPayloadSchema", () => {
|
||||
it("accepts mixed value types", () => {
|
||||
const result = FormSubmissionPayloadSchema.safeParse({
|
||||
values: { name: "Alice", age: 30, opt_in: true, note: null },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects oversized string values", () => {
|
||||
const huge = "a".repeat(20000);
|
||||
const result = FormSubmissionPayloadSchema.safeParse({ values: { note: huge } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Zod schemas for form definitions, themes and submission payloads.
|
||||
*
|
||||
* The Form-Builder produces JSON that conforms to {@link FormDefinitionSchema}.
|
||||
* The embedded renderer + submission API both parse against the same schemas
|
||||
* so the contract is enforced at every boundary.
|
||||
*
|
||||
* Field-type set is intentionally conservative for Phase-1: text, email,
|
||||
* number, textarea, select, radio, checkbox, honeypot. File-upload is
|
||||
* out-of-scope (see security notes in the detail-md).
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/** Field-types accepted by the Phase-1 form-builder. */
|
||||
export const FieldTypeSchema = z.enum([
|
||||
"text",
|
||||
"email",
|
||||
"number",
|
||||
"textarea",
|
||||
"select",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"honeypot",
|
||||
]);
|
||||
|
||||
/** Stable key used for honeypot fields in payloads / UIs. */
|
||||
export const HONEYPOT_FIELD_KEY = "hp_extra_data" as const;
|
||||
|
||||
export type FormFieldType = z.infer<typeof FieldTypeSchema>;
|
||||
|
||||
const FieldOption = z.object({
|
||||
value: z.string().min(1).max(120),
|
||||
label: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export const FormFieldSchema = z
|
||||
.object({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "key must start with a letter and contain only alphanumerics or underscores"),
|
||||
type: FieldTypeSchema,
|
||||
label: z.string().min(1).max(200),
|
||||
helpText: z.string().max(500).optional(),
|
||||
placeholder: z.string().max(200).optional(),
|
||||
required: z.boolean().default(false),
|
||||
options: z.array(FieldOption).max(50).optional(),
|
||||
minLength: z.number().int().min(0).max(10000).optional(),
|
||||
maxLength: z.number().int().min(1).max(10000).optional(),
|
||||
})
|
||||
.superRefine((field, ctx) => {
|
||||
if ((field.type === "select" || field.type === "radio") && (!field.options || field.options.length === 0)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `field ${field.key}: type ${field.type} requires at least one option`,
|
||||
});
|
||||
}
|
||||
if (field.minLength !== undefined && field.maxLength !== undefined && field.minLength > field.maxLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `field ${field.key}: minLength must be <= maxLength`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FormField = z.infer<typeof FormFieldSchema>;
|
||||
|
||||
export const FormThemeSchema = z.object({
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{3,8}$/u, "primaryColor must be a hex color")
|
||||
.optional(),
|
||||
backgroundColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{3,8}$/u, "backgroundColor must be a hex color")
|
||||
.optional(),
|
||||
textColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{3,8}$/u, "textColor must be a hex color")
|
||||
.optional(),
|
||||
fontFamily: z.string().max(200).optional(),
|
||||
borderRadius: z.string().max(40).optional(),
|
||||
});
|
||||
|
||||
export type FormTheme = z.infer<typeof FormThemeSchema>;
|
||||
|
||||
export const FormDefinitionSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
submitLabel: z.string().min(1).max(100).default("Submit"),
|
||||
successMessage: z.string().min(1).max(500).default("Thanks — we received your submission."),
|
||||
fields: z.array(FormFieldSchema).min(1).max(50),
|
||||
})
|
||||
.superRefine((def, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
for (const field of def.fields) {
|
||||
if (seen.has(field.key)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate field key: ${field.key}` });
|
||||
}
|
||||
seen.add(field.key);
|
||||
}
|
||||
});
|
||||
|
||||
export type FormDefinition = z.infer<typeof FormDefinitionSchema>;
|
||||
|
||||
/** Payload accepted by the submission API. Field-values are typed loosely
|
||||
* here because each form has a different shape; the route handler validates
|
||||
* required-ness + length against the form's stored definition. */
|
||||
export const FormSubmissionPayloadSchema = z.object({
|
||||
values: z.record(z.string().max(64), z.union([z.string().max(10000), z.boolean(), z.number(), z.null()])),
|
||||
origin: z.string().max(500).optional(),
|
||||
turnstileToken: z.string().max(4096).optional(),
|
||||
});
|
||||
|
||||
export type FormSubmissionPayload = z.infer<typeof FormSubmissionPayloadSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user