@sonordev/contracts 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First release. The rules site-kit 6.x published as `@sonordev/site-kit/*/contract`
6
+ (website, color, sites, seo-pages, seo-meta, llms, forms, fleet, slots,
7
+ portfolio), as a dependency-free package, so sonor-api, signal-api and the
8
+ dashboard import them instead of keeping mirrors.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @sonordev/contracts
2
+
3
+ The rules Sonor's sites, APIs and dashboard have to agree on, as one package.
4
+ Pure functions and types: no dependencies, no DOM, no network.
5
+
6
+ ```bash
7
+ pnpm add @sonordev/contracts
8
+ ```
9
+
10
+ | Entry | What it decides | Who reads it |
11
+ |---|---|---|
12
+ | `@sonordev/contracts/website` | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer) | site-kit renders them, sonor-api validates every write, the dashboard previews them, Signal's agents send them |
13
+ | `@sonordev/contracts/color` | Reading a CSS color, WCAG luminance and contrast | `website`, site-kit's brand extraction |
14
+ | `@sonordev/contracts/sites` | The canonical shape of a site host (the multi-site `site` dimension) | every ingest and read that filters by site |
15
+ | `@sonordev/contracts/seo-pages` | Which `seo_pages` row a path resolves to on a multi-site project | sonor-api, Signal, site-kit's sitemap sync |
16
+ | `@sonordev/contracts/seo-meta` | Whether a title or description is fit to ship | site-kit, Signal's SEO gate |
17
+ | `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, sonor-api, Signal's schema validation |
18
+ | `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, sonor-api intake |
19
+ | `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, sonor-api |
20
+ | `@sonordev/contracts/slots` | Slot payload signing (HMAC; Node only) | sonor-api, site-kit on the server |
21
+ | `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message | sonor-api, Signal, agency-site-kit |
22
+
23
+ ## Why it exists
24
+
25
+ Before this package, each API kept a byte-identical copy of the rules it
26
+ shared with site-kit, plus a drift test to catch the copies diverging. Ten
27
+ parallel copies of a rule is how two of them end up disagreeing. Now there's
28
+ one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
29
+ into its own `*/contract` entries, and this package publishes the same files
30
+ for everyone else.
31
+
32
+ ## Loading it
33
+
34
+ CommonJS and ESM both. `require('@sonordev/contracts/website')` works in a
35
+ CommonJS Nest API (including `moduleResolution: "node"`, through
36
+ `typesVersions`); `import` works everywhere else. `slots` imports Node's
37
+ `crypto`, so keep it out of browser code.
38
+
39
+ ## Changing a rule
40
+
41
+ Edit `src/contracts/<entry>.ts` in the site-kit repo, run
42
+ `pnpm test:contracts`, and bump this package's version. A breaking change to
43
+ a shape bumps the major and that contract's own `*_VERSION` constant.
44
+ `pnpm build:contracts` builds it and checks every entry loads both ways.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @sonordev/contracts/color — CSS colour reading and WCAG contrast
3
+ *
4
+ * Single source of truth for turning a CSS colour string into something the
5
+ * kit can reason about. Used at build time by the brand-profile extractor
6
+ * (`src/brand/extract-brand-profile.ts`, theme detection) and in the browser
7
+ * by Echo (`src/engage/brand-color.ts`, brand text contrast). Pure, no
8
+ * imports, safe in both.
9
+ */
10
+ export type Rgb = {
11
+ r: number;
12
+ g: number;
13
+ b: number;
14
+ };
15
+ export declare const HEX_COLOR: RegExp;
16
+ /**
17
+ * WCAG relative luminance below which a color reads as dark — the point where
18
+ * it contrasts more with white than with black.
19
+ */
20
+ export declare const DARK_LUMINANCE = 0.179;
21
+ /** WCAG 2.x relative luminance of an sRGB colour (0 = black, 1 = white). */
22
+ export declare function relativeLuminance({ r, g, b }: Rgb): number;
23
+ /** WCAG 2.x contrast ratio between two relative luminances (1 to 21). */
24
+ export declare function contrastRatio(a: number, b: number): number;
25
+ /**
26
+ * The sRGB channels (0-255, alpha ignored) of a resolved CSS color, or null
27
+ * when the value isn't one this can read or paints nothing. Covers hex (3, 4,
28
+ * 6 and 8 digits), rgb()/rgba() and hsl()/hsla() in comma or space syntax, a
29
+ * bare shadcn-style HSL triple (`0 0% 100%`), and white/black. oklch(), lab()
30
+ * and named colours beyond those two return null.
31
+ */
32
+ export declare function parseColor(value: string): Rgb | null;
33
+ /**
34
+ * Relative luminance (0 = black, 1 = white) of a resolved CSS color, or null
35
+ * when the value isn't a color this can read. Everything `parseColor` reads,
36
+ * plus oklch()/oklab()/lab()/lch() (from their lightness).
37
+ */
38
+ export declare function colorLuminance(value: string): number | null;
package/dist/color.js ADDED
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/color.ts
4
+ var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
5
+ var DARK_LUMINANCE = 0.179;
6
+ function channel(v) {
7
+ const c = v / 255;
8
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
9
+ }
10
+ function relativeLuminance({ r, g, b }) {
11
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
12
+ }
13
+ function contrastRatio(a, b) {
14
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
15
+ }
16
+ function hexToRgb(hex) {
17
+ if (!HEX_COLOR.test(hex)) return null;
18
+ let c = hex.slice(1);
19
+ if (c.length <= 4) c = c.split("").map((ch) => ch + ch).join("");
20
+ if (c.length === 8 && c.slice(6) === "00") return null;
21
+ return {
22
+ r: parseInt(c.slice(0, 2), 16),
23
+ g: parseInt(c.slice(2, 4), 16),
24
+ b: parseInt(c.slice(4, 6), 16)
25
+ };
26
+ }
27
+ function hslToRgb(h, s, l) {
28
+ const k = (n) => (n + h / 30) % 12;
29
+ const a = s * Math.min(l, 1 - l);
30
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
31
+ return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
32
+ }
33
+ function colorArgs(inner) {
34
+ const [main, alpha] = inner.split("/");
35
+ const parts = main.split(/[\s,]+/).filter(Boolean);
36
+ if (alpha === void 0 && parts.length === 4) return { parts: parts.slice(0, 3), alpha: parts[3] };
37
+ return { parts, alpha: alpha?.trim() };
38
+ }
39
+ function isTransparentAlpha(alpha) {
40
+ if (!alpha) return false;
41
+ const n = alpha.endsWith("%") ? parseFloat(alpha) / 100 : parseFloat(alpha);
42
+ return n === 0;
43
+ }
44
+ function colorFunction(v) {
45
+ const fn = v.match(/^([a-z]+)\((.*)\)$/);
46
+ if (!fn) return null;
47
+ const { parts, alpha } = colorArgs(fn[2]);
48
+ if (isTransparentAlpha(alpha) || parts.length < 3) return null;
49
+ return { name: fn[1], parts };
50
+ }
51
+ var num = (p, scale) => p.endsWith("%") ? parseFloat(p) / 100 * scale : parseFloat(p);
52
+ function parseColor(value) {
53
+ const v = value.trim().toLowerCase();
54
+ if (!v || v === "transparent") return null;
55
+ if (v === "white") return { r: 255, g: 255, b: 255 };
56
+ if (v === "black") return { r: 0, g: 0, b: 0 };
57
+ if (v.startsWith("#")) return hexToRgb(v);
58
+ const fn = colorFunction(v);
59
+ if (fn) {
60
+ const { name, parts } = fn;
61
+ if (name === "rgb" || name === "rgba") {
62
+ const [r, g, b] = parts.map((p) => num(p, 255));
63
+ return [r, g, b].some(Number.isNaN) ? null : { r, g, b };
64
+ }
65
+ if (name === "hsl" || name === "hsla") {
66
+ const h = parseFloat(parts[0]);
67
+ const s = num(parts[1], 1);
68
+ const l = num(parts[2], 1);
69
+ return [h, s, l].some(Number.isNaN) ? null : hslToRgb(h, s, l);
70
+ }
71
+ return null;
72
+ }
73
+ const triple = v.match(/^(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/);
74
+ if (triple) return hslToRgb(parseFloat(triple[1]), parseFloat(triple[2]) / 100, parseFloat(triple[3]) / 100);
75
+ return null;
76
+ }
77
+ function colorLuminance(value) {
78
+ const rgb = parseColor(value);
79
+ if (rgb) return relativeLuminance(rgb);
80
+ const fn = colorFunction(value.trim().toLowerCase());
81
+ if (!fn) return null;
82
+ const { name, parts } = fn;
83
+ if (name === "oklch" || name === "oklab") {
84
+ const L = num(parts[0], 1);
85
+ return Number.isNaN(L) ? null : Math.min(1, Math.max(0, L)) ** 3;
86
+ }
87
+ if (name === "lab" || name === "lch") {
88
+ const L = num(parts[0], 100);
89
+ if (Number.isNaN(L)) return null;
90
+ const f = (L + 16) / 116;
91
+ return Math.min(1, Math.max(0, L > 8 ? f ** 3 : L / 903.3));
92
+ }
93
+ return null;
94
+ }
95
+
96
+ exports.DARK_LUMINANCE = DARK_LUMINANCE;
97
+ exports.HEX_COLOR = HEX_COLOR;
98
+ exports.colorLuminance = colorLuminance;
99
+ exports.contrastRatio = contrastRatio;
100
+ exports.parseColor = parseColor;
101
+ exports.relativeLuminance = relativeLuminance;
package/dist/color.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // src/contracts/color.ts
2
+ var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
3
+ var DARK_LUMINANCE = 0.179;
4
+ function channel(v) {
5
+ const c = v / 255;
6
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
7
+ }
8
+ function relativeLuminance({ r, g, b }) {
9
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
10
+ }
11
+ function contrastRatio(a, b) {
12
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
13
+ }
14
+ function hexToRgb(hex) {
15
+ if (!HEX_COLOR.test(hex)) return null;
16
+ let c = hex.slice(1);
17
+ if (c.length <= 4) c = c.split("").map((ch) => ch + ch).join("");
18
+ if (c.length === 8 && c.slice(6) === "00") return null;
19
+ return {
20
+ r: parseInt(c.slice(0, 2), 16),
21
+ g: parseInt(c.slice(2, 4), 16),
22
+ b: parseInt(c.slice(4, 6), 16)
23
+ };
24
+ }
25
+ function hslToRgb(h, s, l) {
26
+ const k = (n) => (n + h / 30) % 12;
27
+ const a = s * Math.min(l, 1 - l);
28
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
29
+ return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
30
+ }
31
+ function colorArgs(inner) {
32
+ const [main, alpha] = inner.split("/");
33
+ const parts = main.split(/[\s,]+/).filter(Boolean);
34
+ if (alpha === void 0 && parts.length === 4) return { parts: parts.slice(0, 3), alpha: parts[3] };
35
+ return { parts, alpha: alpha?.trim() };
36
+ }
37
+ function isTransparentAlpha(alpha) {
38
+ if (!alpha) return false;
39
+ const n = alpha.endsWith("%") ? parseFloat(alpha) / 100 : parseFloat(alpha);
40
+ return n === 0;
41
+ }
42
+ function colorFunction(v) {
43
+ const fn = v.match(/^([a-z]+)\((.*)\)$/);
44
+ if (!fn) return null;
45
+ const { parts, alpha } = colorArgs(fn[2]);
46
+ if (isTransparentAlpha(alpha) || parts.length < 3) return null;
47
+ return { name: fn[1], parts };
48
+ }
49
+ var num = (p, scale) => p.endsWith("%") ? parseFloat(p) / 100 * scale : parseFloat(p);
50
+ function parseColor(value) {
51
+ const v = value.trim().toLowerCase();
52
+ if (!v || v === "transparent") return null;
53
+ if (v === "white") return { r: 255, g: 255, b: 255 };
54
+ if (v === "black") return { r: 0, g: 0, b: 0 };
55
+ if (v.startsWith("#")) return hexToRgb(v);
56
+ const fn = colorFunction(v);
57
+ if (fn) {
58
+ const { name, parts } = fn;
59
+ if (name === "rgb" || name === "rgba") {
60
+ const [r, g, b] = parts.map((p) => num(p, 255));
61
+ return [r, g, b].some(Number.isNaN) ? null : { r, g, b };
62
+ }
63
+ if (name === "hsl" || name === "hsla") {
64
+ const h = parseFloat(parts[0]);
65
+ const s = num(parts[1], 1);
66
+ const l = num(parts[2], 1);
67
+ return [h, s, l].some(Number.isNaN) ? null : hslToRgb(h, s, l);
68
+ }
69
+ return null;
70
+ }
71
+ const triple = v.match(/^(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/);
72
+ if (triple) return hslToRgb(parseFloat(triple[1]), parseFloat(triple[2]) / 100, parseFloat(triple[3]) / 100);
73
+ return null;
74
+ }
75
+ function colorLuminance(value) {
76
+ const rgb = parseColor(value);
77
+ if (rgb) return relativeLuminance(rgb);
78
+ const fn = colorFunction(value.trim().toLowerCase());
79
+ if (!fn) return null;
80
+ const { name, parts } = fn;
81
+ if (name === "oklch" || name === "oklab") {
82
+ const L = num(parts[0], 1);
83
+ return Number.isNaN(L) ? null : Math.min(1, Math.max(0, L)) ** 3;
84
+ }
85
+ if (name === "lab" || name === "lch") {
86
+ const L = num(parts[0], 100);
87
+ if (Number.isNaN(L)) return null;
88
+ const f = (L + 16) / 116;
89
+ return Math.min(1, Math.max(0, L > 8 ? f ** 3 : L / 903.3));
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export { DARK_LUMINANCE, HEX_COLOR, colorLuminance, contrastRatio, parseColor, relativeLuminance };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @sonordev/contracts/fleet — Fleet heartbeat wire contract (v1)
3
+ *
4
+ * Single source of truth for the heartbeat payload shape, shared by site-kit
5
+ * (send side, `@sonordev/site-kit/fleet/contract`) and sonor-api (ingest
6
+ * side: its DTO is type-checked against FleetHeartbeatPayload).
7
+ *
8
+ * Why it exists: ~24+ client sites run site-kit at wildly drifted versions and
9
+ * the platform could not answer "which version / modules / key state is each
10
+ * site running" without shelling into every local repo. The heartbeat answers
11
+ * that from live traffic. It is fire-and-forget, deferred to idle (never in the
12
+ * hydration path), and carries NO visitor data — only the site's own build
13
+ * fingerprint. Project identity is resolved server-side from the x-api-key
14
+ * header, exactly like every other /api/public/* endpoint; sites never send
15
+ * project IDs.
16
+ *
17
+ * The x-sitekit-version header (set by shared/sonorFetch on every request)
18
+ * already gives the platform a passive "last seen version" for ALL traffic,
19
+ * including server-only module fetches. The heartbeat adds the things a header
20
+ * can't carry: the active module set and the host Next.js version.
21
+ */
22
+ /** Bump on breaking payload changes. Ingest accepts the current version. */
23
+ export declare const FLEET_CONTRACT_VERSION = 1;
24
+ /**
25
+ * Client-observable site-kit modules — the set a site can honestly self-report
26
+ * from a client boot, because SiteKitClientProviders actually mounts them.
27
+ *
28
+ * Server-only modules (seo, article, images, reputation, redirects, llms) are
29
+ * intentionally absent: they render on the server and cannot be observed from
30
+ * the browser. Their usage still surfaces passively through the
31
+ * x-sitekit-version header on their own server fetches.
32
+ */
33
+ export type FleetModule = 'analytics' | 'engage' | 'signal' | 'sitemapSync' | 'forms' | 'commerce' | 'sync' | 'maps' | 'slots';
34
+ /** Canonical order — used to serialize equal module sets identically. */
35
+ export declare const FLEET_MODULES: readonly FleetModule[];
36
+ /** Where a heartbeat originated. */
37
+ export type FleetHeartbeatSource = 'client' | 'build';
38
+ /** Hard cap on modules accepted in one payload (junk guard). */
39
+ export declare const FLEET_MODULES_MAX = 32;
40
+ /** Hard cap on the free-form version strings we accept. */
41
+ export declare const FLEET_VERSION_MAX_LENGTH = 40;
42
+ /** Semver-ish shape. Non-matching values are stored as null, never rejected. */
43
+ export declare const FLEET_VERSION_PATTERN: RegExp;
44
+ /** Max host length per RFC 1035. */
45
+ export declare const FLEET_SITE_MAX_LENGTH = 253;
46
+ /**
47
+ * The heartbeat body a site POSTs to `/api/public/fleet/heartbeat`.
48
+ * All wire fields are snake_case to match the other contract modules and the
49
+ * agent-facing read endpoints.
50
+ */
51
+ export interface FleetHeartbeatPayload {
52
+ contract_version: number;
53
+ /** SITE_KIT_VERSION at runtime, e.g. "3.2.0". */
54
+ kit_version: string;
55
+ /** Installed Next.js version if detectable, else null. */
56
+ next_version?: string | null;
57
+ /** Client-observable active modules — deduped, validated, canonical order. */
58
+ modules: FleetModule[];
59
+ /** Normalized lowercase host (multi-site sub-dimension; see CLAUDE.md). */
60
+ site?: string | null;
61
+ /** 'client' (runtime boot) or 'build' (CI/build step). Default 'client'. */
62
+ source?: FleetHeartbeatSource;
63
+ }
64
+ /** The ingest endpoint's response. `received_at` is server-stamped ISO 8601. */
65
+ export interface FleetHeartbeatResponse {
66
+ ok: boolean;
67
+ contract_version: number;
68
+ received_at: string;
69
+ }
70
+ export declare function isFleetModule(x: unknown): x is FleetModule;
71
+ /**
72
+ * Dedupe + validate a modules array: drop unknowns, cap length, emit in the
73
+ * canonical FLEET_MODULES order so equal sets always serialize identically.
74
+ */
75
+ export declare function normalizeFleetModules(input: unknown): FleetModule[];
76
+ /** Coerce a version-ish string to a clean value or null (never throws). */
77
+ export declare function sanitizeFleetVersion(raw: unknown): string | null;
78
+ /**
79
+ * Light client-side site normalization to the canonical host shape (lowercase,
80
+ * no protocol/path/port). sonor-api's IngestService.normalizeSite is the
81
+ * AUTHORITATIVE normalizer and re-runs on ingest — this just avoids sending
82
+ * obvious noise. Keep the two shapes aligned (see CLAUDE.md "site" convention).
83
+ */
84
+ export declare function normalizeFleetSite(raw: unknown): string | null;
85
+ /**
86
+ * Build a validated heartbeat payload from loose inputs. Used by BOTH the
87
+ * client component and the build-time sender so the wire shape is produced in
88
+ * exactly one place (single source of truth for the heartbeat body).
89
+ */
90
+ export declare function buildFleetHeartbeatPayload(input: {
91
+ kitVersion: string;
92
+ nextVersion?: string | null;
93
+ modules?: unknown;
94
+ site?: string | null;
95
+ source?: FleetHeartbeatSource;
96
+ }): FleetHeartbeatPayload;
package/dist/fleet.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/fleet.ts
4
+ var FLEET_CONTRACT_VERSION = 1;
5
+ var FLEET_MODULES = [
6
+ "analytics",
7
+ "engage",
8
+ "signal",
9
+ "sitemapSync",
10
+ "forms",
11
+ "commerce",
12
+ "sync",
13
+ "maps",
14
+ "slots"
15
+ ];
16
+ var FLEET_MODULES_MAX = 32;
17
+ var FLEET_VERSION_MAX_LENGTH = 40;
18
+ var FLEET_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
19
+ var FLEET_SITE_MAX_LENGTH = 253;
20
+ function isFleetModule(x) {
21
+ return typeof x === "string" && FLEET_MODULES.includes(x);
22
+ }
23
+ function normalizeFleetModules(input) {
24
+ if (!Array.isArray(input)) return [];
25
+ const seen = /* @__PURE__ */ new Set();
26
+ for (const item of input) {
27
+ if (isFleetModule(item)) seen.add(item);
28
+ if (seen.size >= FLEET_MODULES_MAX) break;
29
+ }
30
+ return FLEET_MODULES.filter((m) => seen.has(m));
31
+ }
32
+ function sanitizeFleetVersion(raw) {
33
+ if (typeof raw !== "string") return null;
34
+ const s = raw.trim();
35
+ if (!s || s.length > FLEET_VERSION_MAX_LENGTH) return null;
36
+ return FLEET_VERSION_PATTERN.test(s) ? s : null;
37
+ }
38
+ function normalizeFleetSite(raw) {
39
+ if (typeof raw !== "string") return null;
40
+ let s = raw.trim().toLowerCase();
41
+ if (!s) return null;
42
+ s = s.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
43
+ if (!s || s.length > FLEET_SITE_MAX_LENGTH) return null;
44
+ return s;
45
+ }
46
+ function buildFleetHeartbeatPayload(input) {
47
+ return {
48
+ contract_version: FLEET_CONTRACT_VERSION,
49
+ kit_version: input.kitVersion,
50
+ next_version: sanitizeFleetVersion(input.nextVersion ?? null),
51
+ modules: normalizeFleetModules(input.modules),
52
+ site: normalizeFleetSite(input.site ?? null),
53
+ source: input.source === "build" ? "build" : "client"
54
+ };
55
+ }
56
+
57
+ exports.FLEET_CONTRACT_VERSION = FLEET_CONTRACT_VERSION;
58
+ exports.FLEET_MODULES = FLEET_MODULES;
59
+ exports.FLEET_MODULES_MAX = FLEET_MODULES_MAX;
60
+ exports.FLEET_SITE_MAX_LENGTH = FLEET_SITE_MAX_LENGTH;
61
+ exports.FLEET_VERSION_MAX_LENGTH = FLEET_VERSION_MAX_LENGTH;
62
+ exports.FLEET_VERSION_PATTERN = FLEET_VERSION_PATTERN;
63
+ exports.buildFleetHeartbeatPayload = buildFleetHeartbeatPayload;
64
+ exports.isFleetModule = isFleetModule;
65
+ exports.normalizeFleetModules = normalizeFleetModules;
66
+ exports.normalizeFleetSite = normalizeFleetSite;
67
+ exports.sanitizeFleetVersion = sanitizeFleetVersion;
package/dist/fleet.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // src/contracts/fleet.ts
2
+ var FLEET_CONTRACT_VERSION = 1;
3
+ var FLEET_MODULES = [
4
+ "analytics",
5
+ "engage",
6
+ "signal",
7
+ "sitemapSync",
8
+ "forms",
9
+ "commerce",
10
+ "sync",
11
+ "maps",
12
+ "slots"
13
+ ];
14
+ var FLEET_MODULES_MAX = 32;
15
+ var FLEET_VERSION_MAX_LENGTH = 40;
16
+ var FLEET_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
17
+ var FLEET_SITE_MAX_LENGTH = 253;
18
+ function isFleetModule(x) {
19
+ return typeof x === "string" && FLEET_MODULES.includes(x);
20
+ }
21
+ function normalizeFleetModules(input) {
22
+ if (!Array.isArray(input)) return [];
23
+ const seen = /* @__PURE__ */ new Set();
24
+ for (const item of input) {
25
+ if (isFleetModule(item)) seen.add(item);
26
+ if (seen.size >= FLEET_MODULES_MAX) break;
27
+ }
28
+ return FLEET_MODULES.filter((m) => seen.has(m));
29
+ }
30
+ function sanitizeFleetVersion(raw) {
31
+ if (typeof raw !== "string") return null;
32
+ const s = raw.trim();
33
+ if (!s || s.length > FLEET_VERSION_MAX_LENGTH) return null;
34
+ return FLEET_VERSION_PATTERN.test(s) ? s : null;
35
+ }
36
+ function normalizeFleetSite(raw) {
37
+ if (typeof raw !== "string") return null;
38
+ let s = raw.trim().toLowerCase();
39
+ if (!s) return null;
40
+ s = s.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
41
+ if (!s || s.length > FLEET_SITE_MAX_LENGTH) return null;
42
+ return s;
43
+ }
44
+ function buildFleetHeartbeatPayload(input) {
45
+ return {
46
+ contract_version: FLEET_CONTRACT_VERSION,
47
+ kit_version: input.kitVersion,
48
+ next_version: sanitizeFleetVersion(input.nextVersion ?? null),
49
+ modules: normalizeFleetModules(input.modules),
50
+ site: normalizeFleetSite(input.site ?? null),
51
+ source: input.source === "build" ? "build" : "client"
52
+ };
53
+ }
54
+
55
+ export { FLEET_CONTRACT_VERSION, FLEET_MODULES, FLEET_MODULES_MAX, FLEET_SITE_MAX_LENGTH, FLEET_VERSION_MAX_LENGTH, FLEET_VERSION_PATTERN, buildFleetHeartbeatPayload, isFleetModule, normalizeFleetModules, normalizeFleetSite, sanitizeFleetVersion };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @sonordev/contracts/forms — managed-form honeypot policy (v1)
3
+ *
4
+ * Single source of truth for the honeypot trap-field naming + server-side
5
+ * detection policy, shared by the client (site-kit emits the trap field) and
6
+ * the server (sonor-api names the trap in the form config it returns AND
7
+ * detects hits on intake). Both import this: site-kit as
8
+ * `@sonordev/site-kit/forms/contract`, sonor-api through
9
+ * `src/modules/forms/honeypot-contract.ts`.
10
+ *
11
+ * The default field name (`_sk_hp_url`) is deliberately obscure: a common name
12
+ * like "website" collides with real form fields and flags genuine submissions
13
+ * as spam, so generic names are only trusted when they DON'T match a real
14
+ * field of the form (see `resolveHoneypotFieldNames`).
15
+ */
16
+ /** Bump on breaking changes to the honeypot policy. */
17
+ export declare const FORMS_HONEYPOT_CONTRACT_VERSION: 1;
18
+ /**
19
+ * Default honeypot field name emitted by site-kit and used by the server as the
20
+ * fallback trap name when a form has no explicitly configured `honeypot_field`.
21
+ * Deliberately obscure to avoid colliding with real form fields.
22
+ */
23
+ export declare const DEFAULT_HONEYPOT_FIELD = "_sk_hp_url";
24
+ /**
25
+ * Generic trap names the server also watches on intake — the canonical default
26
+ * plus legacy names still emitted by older deployed forms. Each is only trusted
27
+ * when it doesn't collide with a real configured field of the form.
28
+ */
29
+ export declare const GENERIC_HONEYPOT_FIELDS: readonly string[];
30
+ /**
31
+ * Build the set of field names the server treats as honeypot traps for one form.
32
+ * Generic names that collide with a real field slug are dropped (or every real
33
+ * lead using that field would be flagged); the form's dedicated
34
+ * `honeypot_field` is always honored. Returns an empty set when disabled.
35
+ */
36
+ export declare function resolveHoneypotFieldNames(opts: {
37
+ enabled: boolean;
38
+ formHoneypotField?: string | null;
39
+ realFieldSlugs?: Iterable<string>;
40
+ }): Set<string>;
41
+ /**
42
+ * What a tripped trap looked like. Deliberately carries NO value — an
43
+ * autofilled trap can hold the visitor's real name, URL, or email, and this
44
+ * rides along into stored spam analysis. The field name is the diagnostic that
45
+ * matters; type and length are enough to tell autofill from a bot.
46
+ */
47
+ export interface HoneypotHit {
48
+ /** Which trap fired. Several are watched per form; only this one tripped. */
49
+ field: string;
50
+ valueType: 'string' | 'number' | 'boolean' | 'object' | 'array';
51
+ /** Character length for strings, element count for arrays, else null. */
52
+ length: number | null;
53
+ }
54
+ /**
55
+ * The first watched trap this submission actually filled, or null.
56
+ *
57
+ * Returns the NAME, not a boolean, because "honeypot fired" alone is
58
+ * unfalsifiable after the fact: the trap values are stripped before storage, so
59
+ * a bare flag left no way to tell a bot from a browser autofilling a hidden
60
+ * field. Callers stamp the name into the spam reasons.
61
+ */
62
+ export declare function findTrippedTrap(data: Record<string, unknown> | null | undefined, fieldNames: Iterable<string>): HoneypotHit | null;
63
+ /**
64
+ * True when any watched trap field carries a real value — the marker of an
65
+ * automated submission (real users never see or fill the trap).
66
+ * Thin view over {@link findTrippedTrap}; one detection rule, two shapes.
67
+ */
68
+ export declare function isHoneypotTriggered(data: Record<string, unknown> | null | undefined, fieldNames: Iterable<string>): boolean;
69
+ /**
70
+ * Agent-native contract: JSON Schema for the honeypot slice of the public form
71
+ * config, so services and agents can validate it mechanically.
72
+ */
73
+ export declare const HONEYPOT_CONFIG_JSON_SCHEMA: {
74
+ readonly $schema: 'http://json-schema.org/draft-07/schema#';
75
+ readonly $id: 'https://sonor.io/schemas/honeypot-config.v1.json';
76
+ readonly title: 'HoneypotConfig';
77
+ readonly description: 'The honeypot slice of a public managed-form config.';
78
+ readonly type: 'object';
79
+ readonly properties: {
80
+ readonly honeypot_enabled: {
81
+ readonly type: 'boolean';
82
+ };
83
+ readonly honeypot_field: {
84
+ readonly description: "Trap field name; defaults to \"_sk_hp_url\" when unset.";
85
+ readonly type: 'string';
86
+ };
87
+ };
88
+ readonly additionalProperties: true;
89
+ };
package/dist/forms.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/forms.ts
4
+ var FORMS_HONEYPOT_CONTRACT_VERSION = 1;
5
+ var DEFAULT_HONEYPOT_FIELD = "_sk_hp_url";
6
+ var GENERIC_HONEYPOT_FIELDS = Object.freeze([
7
+ DEFAULT_HONEYPOT_FIELD,
8
+ "website",
9
+ "_hp_field",
10
+ "bot-field",
11
+ "bot_field"
12
+ ]);
13
+ function resolveHoneypotFieldNames(opts) {
14
+ const names = /* @__PURE__ */ new Set();
15
+ if (!opts.enabled) return names;
16
+ const real = new Set(opts.realFieldSlugs ?? []);
17
+ for (const generic of GENERIC_HONEYPOT_FIELDS) {
18
+ if (!real.has(generic)) names.add(generic);
19
+ }
20
+ if (opts.formHoneypotField) names.add(opts.formHoneypotField);
21
+ return names;
22
+ }
23
+ function isTrapFilled(value) {
24
+ if (value == null) return false;
25
+ if (typeof value === "string") return value.trim() !== "";
26
+ if (typeof value === "boolean") return value;
27
+ if (typeof value === "number") return Number.isFinite(value) && value !== 0;
28
+ if (Array.isArray(value)) return value.length > 0;
29
+ return true;
30
+ }
31
+ function findTrippedTrap(data, fieldNames) {
32
+ for (const field of fieldNames) {
33
+ const value = data?.[field];
34
+ if (!isTrapFilled(value)) continue;
35
+ if (Array.isArray(value)) return { field, valueType: "array", length: value.length };
36
+ const valueType = typeof value;
37
+ return {
38
+ field,
39
+ valueType: valueType === "string" ? "string" : valueType,
40
+ length: typeof value === "string" ? value.length : null
41
+ };
42
+ }
43
+ return null;
44
+ }
45
+ function isHoneypotTriggered(data, fieldNames) {
46
+ return findTrippedTrap(data, fieldNames) !== null;
47
+ }
48
+ var HONEYPOT_CONFIG_JSON_SCHEMA = {
49
+ $schema: "http://json-schema.org/draft-07/schema#",
50
+ $id: "https://sonor.io/schemas/honeypot-config.v1.json",
51
+ title: "HoneypotConfig",
52
+ description: "The honeypot slice of a public managed-form config.",
53
+ type: "object",
54
+ properties: {
55
+ honeypot_enabled: { type: "boolean" },
56
+ honeypot_field: {
57
+ description: `Trap field name; defaults to "${DEFAULT_HONEYPOT_FIELD}" when unset.`,
58
+ type: "string"
59
+ }
60
+ },
61
+ additionalProperties: true
62
+ };
63
+
64
+ exports.DEFAULT_HONEYPOT_FIELD = DEFAULT_HONEYPOT_FIELD;
65
+ exports.FORMS_HONEYPOT_CONTRACT_VERSION = FORMS_HONEYPOT_CONTRACT_VERSION;
66
+ exports.GENERIC_HONEYPOT_FIELDS = GENERIC_HONEYPOT_FIELDS;
67
+ exports.HONEYPOT_CONFIG_JSON_SCHEMA = HONEYPOT_CONFIG_JSON_SCHEMA;
68
+ exports.findTrippedTrap = findTrippedTrap;
69
+ exports.isHoneypotTriggered = isHoneypotTriggered;
70
+ exports.resolveHoneypotFieldNames = resolveHoneypotFieldNames;