@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 +8 -0
- package/README.md +44 -0
- package/dist/color.d.ts +38 -0
- package/dist/color.js +101 -0
- package/dist/color.mjs +94 -0
- package/dist/fleet.d.ts +96 -0
- package/dist/fleet.js +67 -0
- package/dist/fleet.mjs +55 -0
- package/dist/forms.d.ts +89 -0
- package/dist/forms.js +70 -0
- package/dist/forms.mjs +62 -0
- package/dist/llms.d.ts +54 -0
- package/dist/llms.js +107 -0
- package/dist/llms.mjs +97 -0
- package/dist/portfolio.d.ts +158 -0
- package/dist/portfolio.js +129 -0
- package/dist/portfolio.mjs +114 -0
- package/dist/seo-meta.d.ts +56 -0
- package/dist/seo-meta.js +155 -0
- package/dist/seo-meta.mjs +146 -0
- package/dist/seo-pages.d.ts +124 -0
- package/dist/seo-pages.js +95 -0
- package/dist/seo-pages.mjs +88 -0
- package/dist/sites-normalize.d.ts +35 -0
- package/dist/sites.d.ts +48 -0
- package/dist/sites.js +52 -0
- package/dist/sites.mjs +44 -0
- package/dist/slots.d.ts +81 -0
- package/dist/slots.js +44 -0
- package/dist/slots.mjs +35 -0
- package/dist/website.d.ts +148 -0
- package/dist/website.js +370 -0
- package/dist/website.mjs +355 -0
- package/package.json +93 -0
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.
|
package/dist/color.d.ts
ADDED
|
@@ -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 };
|
package/dist/fleet.d.ts
ADDED
|
@@ -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 };
|
package/dist/forms.d.ts
ADDED
|
@@ -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;
|