Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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"]
}