@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.
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/sites-normalize.ts
4
+ function normalizeSiteHost(site) {
5
+ if (!site || typeof site !== "string") return null;
6
+ let s = site.trim().toLowerCase();
7
+ if (!s) return null;
8
+ s = s.replace(/^https?:\/\//, "");
9
+ const slash = s.indexOf("/");
10
+ if (slash >= 0) s = s.slice(0, slash);
11
+ const colon = s.indexOf(":");
12
+ if (colon >= 0) s = s.slice(0, colon);
13
+ s = s.replace(/\.+$/, "");
14
+ return s || null;
15
+ }
16
+
17
+ // src/contracts/sites.ts
18
+ function stripWww(host) {
19
+ return host.startsWith("www.") ? host.slice(4) : host;
20
+ }
21
+ function sameSite(a, b) {
22
+ if (!a || !b) return false;
23
+ return stripWww(a) === stripWww(b);
24
+ }
25
+
26
+ // src/contracts/seo-pages.ts
27
+ var SEO_PAGES_CONTRACT_VERSION = 1;
28
+ var LIVE_SEO_PAGE_FILTER = "status.is.null,status.eq.active";
29
+ function pickSeoPageRow(rows, opts = {}) {
30
+ if (!rows.length) return null;
31
+ if (rows.length === 1) return rows[0];
32
+ const wanted = normalizeSiteHost(opts.site);
33
+ if (wanted) {
34
+ const exact = rows.find((r) => sameSite(r.site, wanted));
35
+ if (exact) return exact;
36
+ }
37
+ const unattributed = rows.find((r) => !r.site);
38
+ if (unattributed) return unattributed;
39
+ const primary = normalizeSiteHost(opts.primaryHost);
40
+ if (primary) {
41
+ const main = rows.find((r) => sameSite(r.site, primary));
42
+ if (main) return main;
43
+ }
44
+ return [...rows].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0];
45
+ }
46
+ function matchSitemapEntryRow(candidates, site, primaryHost) {
47
+ if (!candidates.length) return null;
48
+ if (site) {
49
+ return candidates.find((r) => sameSite(r.site, site)) ?? candidates.find((r) => !r.site) ?? null;
50
+ }
51
+ return pickSeoPageRow(candidates, { primaryHost });
52
+ }
53
+ function scopeSeoPagesToSite(rows, site, primaryHost) {
54
+ const wanted = normalizeSiteHost(site);
55
+ const host = normalizeSiteHost(primaryHost);
56
+ const groups = /* @__PURE__ */ new Map();
57
+ for (const row of rows) {
58
+ const path = row.path ?? "";
59
+ const key = path.replace(/\/$/, "") || path;
60
+ const bucket = groups.get(key);
61
+ if (bucket) bucket.push(row);
62
+ else groups.set(key, [row]);
63
+ }
64
+ const scoped = [];
65
+ for (const candidates of groups.values()) {
66
+ const picked = matchSitemapEntryRow(candidates, wanted, host);
67
+ if (picked) scoped.push(picked);
68
+ }
69
+ return scoped;
70
+ }
71
+ var SEO_PAGE_ROW_JSON_SCHEMA = {
72
+ $schema: "http://json-schema.org/draft-07/schema#",
73
+ $id: "https://sonor.io/schemas/seo-page-row.v1.json",
74
+ title: "SeoPageRowLike",
75
+ description: "The subset of an seo_pages row that determines per-site resolution.",
76
+ type: "object",
77
+ properties: {
78
+ site: {
79
+ description: "Normalized sub-site host, or null for a project-wide row.",
80
+ type: ["string", "null"]
81
+ },
82
+ updated_at: {
83
+ description: "ISO 8601 timestamp; ties in resolution break toward the most recent.",
84
+ type: ["string", "null"]
85
+ }
86
+ },
87
+ additionalProperties: true
88
+ };
89
+
90
+ exports.LIVE_SEO_PAGE_FILTER = LIVE_SEO_PAGE_FILTER;
91
+ exports.SEO_PAGES_CONTRACT_VERSION = SEO_PAGES_CONTRACT_VERSION;
92
+ exports.SEO_PAGE_ROW_JSON_SCHEMA = SEO_PAGE_ROW_JSON_SCHEMA;
93
+ exports.matchSitemapEntryRow = matchSitemapEntryRow;
94
+ exports.pickSeoPageRow = pickSeoPageRow;
95
+ exports.scopeSeoPagesToSite = scopeSeoPagesToSite;
@@ -0,0 +1,88 @@
1
+ // src/contracts/sites-normalize.ts
2
+ function normalizeSiteHost(site) {
3
+ if (!site || typeof site !== "string") return null;
4
+ let s = site.trim().toLowerCase();
5
+ if (!s) return null;
6
+ s = s.replace(/^https?:\/\//, "");
7
+ const slash = s.indexOf("/");
8
+ if (slash >= 0) s = s.slice(0, slash);
9
+ const colon = s.indexOf(":");
10
+ if (colon >= 0) s = s.slice(0, colon);
11
+ s = s.replace(/\.+$/, "");
12
+ return s || null;
13
+ }
14
+
15
+ // src/contracts/sites.ts
16
+ function stripWww(host) {
17
+ return host.startsWith("www.") ? host.slice(4) : host;
18
+ }
19
+ function sameSite(a, b) {
20
+ if (!a || !b) return false;
21
+ return stripWww(a) === stripWww(b);
22
+ }
23
+
24
+ // src/contracts/seo-pages.ts
25
+ var SEO_PAGES_CONTRACT_VERSION = 1;
26
+ var LIVE_SEO_PAGE_FILTER = "status.is.null,status.eq.active";
27
+ function pickSeoPageRow(rows, opts = {}) {
28
+ if (!rows.length) return null;
29
+ if (rows.length === 1) return rows[0];
30
+ const wanted = normalizeSiteHost(opts.site);
31
+ if (wanted) {
32
+ const exact = rows.find((r) => sameSite(r.site, wanted));
33
+ if (exact) return exact;
34
+ }
35
+ const unattributed = rows.find((r) => !r.site);
36
+ if (unattributed) return unattributed;
37
+ const primary = normalizeSiteHost(opts.primaryHost);
38
+ if (primary) {
39
+ const main = rows.find((r) => sameSite(r.site, primary));
40
+ if (main) return main;
41
+ }
42
+ return [...rows].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0];
43
+ }
44
+ function matchSitemapEntryRow(candidates, site, primaryHost) {
45
+ if (!candidates.length) return null;
46
+ if (site) {
47
+ return candidates.find((r) => sameSite(r.site, site)) ?? candidates.find((r) => !r.site) ?? null;
48
+ }
49
+ return pickSeoPageRow(candidates, { primaryHost });
50
+ }
51
+ function scopeSeoPagesToSite(rows, site, primaryHost) {
52
+ const wanted = normalizeSiteHost(site);
53
+ const host = normalizeSiteHost(primaryHost);
54
+ const groups = /* @__PURE__ */ new Map();
55
+ for (const row of rows) {
56
+ const path = row.path ?? "";
57
+ const key = path.replace(/\/$/, "") || path;
58
+ const bucket = groups.get(key);
59
+ if (bucket) bucket.push(row);
60
+ else groups.set(key, [row]);
61
+ }
62
+ const scoped = [];
63
+ for (const candidates of groups.values()) {
64
+ const picked = matchSitemapEntryRow(candidates, wanted, host);
65
+ if (picked) scoped.push(picked);
66
+ }
67
+ return scoped;
68
+ }
69
+ var SEO_PAGE_ROW_JSON_SCHEMA = {
70
+ $schema: "http://json-schema.org/draft-07/schema#",
71
+ $id: "https://sonor.io/schemas/seo-page-row.v1.json",
72
+ title: "SeoPageRowLike",
73
+ description: "The subset of an seo_pages row that determines per-site resolution.",
74
+ type: "object",
75
+ properties: {
76
+ site: {
77
+ description: "Normalized sub-site host, or null for a project-wide row.",
78
+ type: ["string", "null"]
79
+ },
80
+ updated_at: {
81
+ description: "ISO 8601 timestamp; ties in resolution break toward the most recent.",
82
+ type: ["string", "null"]
83
+ }
84
+ },
85
+ additionalProperties: true
86
+ };
87
+
88
+ export { LIVE_SEO_PAGE_FILTER, SEO_PAGES_CONTRACT_VERSION, SEO_PAGE_ROW_JSON_SCHEMA, matchSitemapEntryRow, pickSeoPageRow, scopeSeoPagesToSite };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @sonordev/contracts/sites — normalizeSiteHost (leaf module)
3
+ *
4
+ * Deliberately its own file, not part of `./sites`, so browser code can pull
5
+ * in the normalizer WITHOUT the rest of the contract surface.
6
+ *
7
+ * `publishCredential` (which every client entry that publishes a credential
8
+ * imports) needs exactly this one function. When it lived in `./contract`, the
9
+ * bundler emitted that whole module as one shared chunk — `SITE_HOST_JSON_SCHEMA`,
10
+ * `SITE_HOST_CONTRACT_VERSION`, `stripWww`, `sameSite`, `hostFromUrl`,
11
+ * `isNormalizedSiteHost` and all — into `client`, `layout/client` and every
12
+ * other entry that touched it. That was ~750B gzipped of agent-facing contract
13
+ * metadata shipped to browsers for a 14-line string normalizer, and it is what
14
+ * pushed those entries over their bundle budgets.
15
+ *
16
+ * `./sites` re-exports this, so `@sonordev/contracts/sites` (and site-kit's
17
+ * `sites/contract`) carry it too. The Nest APIs import it from there
18
+ * (sonor-api `src/common/utils/site-host.ts`, signal-api
19
+ * `src/shared/utils/seo-page-pick.util.ts`).
20
+ *
21
+ * Pure string logic only (no Node built-ins) so it is safe in the browser.
22
+ */
23
+ /**
24
+ * normalizeSiteHost — canonical normalizer for a sub-site host value before it
25
+ * is persisted or used as a filter.
26
+ *
27
+ * `https://OhioPowerStudies.com:3000/path` → `ohiopowerstudies.com`
28
+ *
29
+ * Lowercases, strips protocol, keeps host only (drops path + query), strips
30
+ * port (rollups span dev/prod) and the FQDN root dot. Returns null for
31
+ * empty/non-string input.
32
+ * Note: `www.` is intentionally preserved here — use `sameSite`/`stripWww`
33
+ * for www-insensitive equality.
34
+ */
35
+ export declare function normalizeSiteHost(site?: string | null): string | null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @sonordev/contracts/sites — Multi-site "site" dimension host contract (v1)
3
+ *
4
+ * Single source of truth for normalizing and comparing sub-site host values
5
+ * (the multi-site `site` dimension, see CLAUDE.md). site-kit, sonor-api
6
+ * (`src/common/utils/site-host.ts`) and signal-api
7
+ * (`src/shared/utils/seo-page-pick.util.ts`) all import it, so there's no
8
+ * copy to drift.
9
+ *
10
+ * Pure string logic only (no Node built-ins) so it is safe in the browser:
11
+ * site-kit itself normalizes `__SITE_KIT_SITE__` with `normalizeSiteHost`.
12
+ */
13
+ /** Bump on breaking changes to the normalized host shape. */
14
+ export declare const SITE_HOST_CONTRACT_VERSION: 1;
15
+ /**
16
+ * The normalizer itself lives in `./sites-normalize`, a leaf module, so browser code
17
+ * (`shared/publishCredential`) can import it without dragging this whole module
18
+ * — JSON schema included — into client bundles. Re-exported here so the public
19
+ * contract surface is unchanged.
20
+ */
21
+ export { normalizeSiteHost } from './sites-normalize';
22
+ /** Strip a leading `www.` from a host ("www.tps.com" → "tps.com"). */
23
+ export declare function stripWww(host: string): string;
24
+ /**
25
+ * www-insensitive sub-site host equality — `www.a.com` and `a.com` are the
26
+ * same site. Operands are expected already-normalized (lowercase host);
27
+ * returns false if either side is missing.
28
+ */
29
+ export declare function sameSite(a: string | null | undefined, b: string | null | undefined): boolean;
30
+ /**
31
+ * Host of a full URL ("https://ohiopowerstudies.com/x" → "ohiopowerstudies.com"),
32
+ * null for site-relative paths ("/services") and unparseable input.
33
+ */
34
+ export declare function hostFromUrl(value: string | null | undefined): string | null;
35
+ /** True when `value` is a non-empty normalized host (lowercase, no protocol/path/port). */
36
+ export declare function isNormalizedSiteHost(value: unknown): value is string;
37
+ /**
38
+ * Agent-native contract: JSON Schema for a normalized `site` value, so services
39
+ * and agents can validate the dimension mechanically without importing the code.
40
+ */
41
+ export declare const SITE_HOST_JSON_SCHEMA: {
42
+ readonly $schema: 'http://json-schema.org/draft-07/schema#';
43
+ readonly $id: 'https://sonor.io/schemas/site-host.v1.json';
44
+ readonly title: 'NormalizedSiteHost';
45
+ readonly description: 'A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.';
46
+ readonly type: readonly ['string', 'null'];
47
+ readonly pattern: '^[a-z0-9.-]+$';
48
+ };
package/dist/sites.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/sites-normalize.ts
4
+ function normalizeSiteHost(site) {
5
+ if (!site || typeof site !== "string") return null;
6
+ let s = site.trim().toLowerCase();
7
+ if (!s) return null;
8
+ s = s.replace(/^https?:\/\//, "");
9
+ const slash = s.indexOf("/");
10
+ if (slash >= 0) s = s.slice(0, slash);
11
+ const colon = s.indexOf(":");
12
+ if (colon >= 0) s = s.slice(0, colon);
13
+ s = s.replace(/\.+$/, "");
14
+ return s || null;
15
+ }
16
+
17
+ // src/contracts/sites.ts
18
+ var SITE_HOST_CONTRACT_VERSION = 1;
19
+ function stripWww(host) {
20
+ return host.startsWith("www.") ? host.slice(4) : host;
21
+ }
22
+ function sameSite(a, b) {
23
+ if (!a || !b) return false;
24
+ return stripWww(a) === stripWww(b);
25
+ }
26
+ function hostFromUrl(value) {
27
+ if (!value || !value.startsWith("http://") && !value.startsWith("https://")) return null;
28
+ try {
29
+ return normalizeSiteHost(new URL(value).host);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ function isNormalizedSiteHost(value) {
35
+ return typeof value === "string" && value.length > 0 && normalizeSiteHost(value) === value;
36
+ }
37
+ var SITE_HOST_JSON_SCHEMA = {
38
+ $schema: "http://json-schema.org/draft-07/schema#",
39
+ $id: "https://sonor.io/schemas/site-host.v1.json",
40
+ title: "NormalizedSiteHost",
41
+ description: "A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.",
42
+ type: ["string", "null"],
43
+ pattern: "^[a-z0-9.-]+$"
44
+ };
45
+
46
+ exports.SITE_HOST_CONTRACT_VERSION = SITE_HOST_CONTRACT_VERSION;
47
+ exports.SITE_HOST_JSON_SCHEMA = SITE_HOST_JSON_SCHEMA;
48
+ exports.hostFromUrl = hostFromUrl;
49
+ exports.isNormalizedSiteHost = isNormalizedSiteHost;
50
+ exports.normalizeSiteHost = normalizeSiteHost;
51
+ exports.sameSite = sameSite;
52
+ exports.stripWww = stripWww;
package/dist/sites.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // src/contracts/sites-normalize.ts
2
+ function normalizeSiteHost(site) {
3
+ if (!site || typeof site !== "string") return null;
4
+ let s = site.trim().toLowerCase();
5
+ if (!s) return null;
6
+ s = s.replace(/^https?:\/\//, "");
7
+ const slash = s.indexOf("/");
8
+ if (slash >= 0) s = s.slice(0, slash);
9
+ const colon = s.indexOf(":");
10
+ if (colon >= 0) s = s.slice(0, colon);
11
+ s = s.replace(/\.+$/, "");
12
+ return s || null;
13
+ }
14
+
15
+ // src/contracts/sites.ts
16
+ var SITE_HOST_CONTRACT_VERSION = 1;
17
+ function stripWww(host) {
18
+ return host.startsWith("www.") ? host.slice(4) : host;
19
+ }
20
+ function sameSite(a, b) {
21
+ if (!a || !b) return false;
22
+ return stripWww(a) === stripWww(b);
23
+ }
24
+ function hostFromUrl(value) {
25
+ if (!value || !value.startsWith("http://") && !value.startsWith("https://")) return null;
26
+ try {
27
+ return normalizeSiteHost(new URL(value).host);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function isNormalizedSiteHost(value) {
33
+ return typeof value === "string" && value.length > 0 && normalizeSiteHost(value) === value;
34
+ }
35
+ var SITE_HOST_JSON_SCHEMA = {
36
+ $schema: "http://json-schema.org/draft-07/schema#",
37
+ $id: "https://sonor.io/schemas/site-host.v1.json",
38
+ title: "NormalizedSiteHost",
39
+ description: "A normalized sub-site host (the multi-site `site` dimension): lowercase, no protocol, no path, no port. null denotes a project-wide (unattributed) row.",
40
+ type: ["string", "null"],
41
+ pattern: "^[a-z0-9.-]+$"
42
+ };
43
+
44
+ export { SITE_HOST_CONTRACT_VERSION, SITE_HOST_JSON_SCHEMA, hostFromUrl, isNormalizedSiteHost, normalizeSiteHost, sameSite, stripWww };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @sonordev/contracts/slots — Managed Slots wire contract (v1)
3
+ *
4
+ * Single source of truth for the slot payload shape and content signing,
5
+ * shared by site-kit (verify side) and sonor-api (sign side). Node only: it
6
+ * signs with `crypto`.
7
+ *
8
+ * Security model: every SlotContent is HMAC-signed with the project API key.
9
+ * site-kit verifies before rendering and falls back to the developer's static
10
+ * content on any mismatch, so platform-originated content carries provenance
11
+ * end to end. See docs/SITE-KIT-3.0-SPEC-SLOTS.md.
12
+ */
13
+ /** Bump on breaking payload changes. */
14
+ export declare const SLOTS_CONTRACT_VERSION = 1;
15
+ /**
16
+ * v1 supports plain text only — rendered as React text (escaped), never
17
+ * dangerouslySetInnerHTML. 'markdown' / 'rich' are reserved for a future
18
+ * contract version behind a real sanitizer decision.
19
+ */
20
+ export type SlotContentType = 'text';
21
+ export declare const SLOT_CONTENT_TYPES: readonly SlotContentType[];
22
+ /** Hard cap on managed content length; longer payloads are rejected → fallback. */
23
+ export declare const SLOT_CONTENT_MAX_LENGTH = 10000;
24
+ /** Slot ids: lowercase, page-scoped by convention, e.g. "home-hero-headline". */
25
+ export declare const SLOT_ID_PATTERN: RegExp;
26
+ export interface SlotContent {
27
+ slot_id: string;
28
+ type: SlotContentType;
29
+ /** The managed text. Rendered escaped — never as HTML. */
30
+ content: string;
31
+ /** Forward-compat: experiment/personalization variant that produced this content. */
32
+ variant_id?: string | null;
33
+ experiment_id?: string | null;
34
+ /** ISO timestamp of the last content change. */
35
+ updated_at?: string | null;
36
+ /** Hex HMAC-SHA256 over the canonical payload, keyed with the project API key. */
37
+ signature: string;
38
+ }
39
+ export interface SlotResolveRequestEntry {
40
+ id: string;
41
+ /**
42
+ * The developer's current static text for this slot (when statically
43
+ * extractable). Stored as `fallback_text` on registration so the dashboard
44
+ * editor shows the live copy instead of a bare slot id. Never rendered by
45
+ * the API anywhere — display + drafting context only.
46
+ */
47
+ fallback?: string;
48
+ /**
49
+ * The route this slot renders on (e.g. "/" or "/pricing"). A static literal,
50
+ * never derived from the request — it feeds the Copy Evidence Loop's
51
+ * slot↔page mapping so Signal can judge the slot against its page's metrics.
52
+ */
53
+ page?: string;
54
+ }
55
+ export interface SlotsResolveRequest {
56
+ contract_version: number;
57
+ slots: SlotResolveRequestEntry[];
58
+ /** Multi-site sub-dimension: normalized lowercase host (see CLAUDE.md "site" convention). */
59
+ site?: string;
60
+ }
61
+ export interface SlotsResolveResponse {
62
+ contract_version: number;
63
+ slots: Record<string, SlotContent | null>;
64
+ }
65
+ export declare function isValidSlotId(id: string): boolean;
66
+ /**
67
+ * Canonical string both sides sign/verify. Exported separately from the HMAC
68
+ * helpers so an edge-runtime resolver can reproduce it with WebCrypto.
69
+ */
70
+ export declare function slotSigningPayload(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>): string;
71
+ /**
72
+ * Sign slot content with the project API key (sonor-api side).
73
+ * Returns the hex HMAC-SHA256 signature.
74
+ */
75
+ export declare function signSlotContent(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>, apiKey: string): string;
76
+ /**
77
+ * Verify a slot payload's shape + signature (site-kit side).
78
+ * Returns true only when the payload is well-formed, within limits, and the
79
+ * signature matches the project API key. Constant-time comparison.
80
+ */
81
+ export declare function verifySlotContent(content: unknown, apiKey: string): content is SlotContent;
package/dist/slots.js ADDED
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+
5
+ // src/contracts/slots.ts
6
+ var SLOTS_CONTRACT_VERSION = 1;
7
+ var SLOT_CONTENT_TYPES = ["text"];
8
+ var SLOT_CONTENT_MAX_LENGTH = 1e4;
9
+ var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
10
+ function isValidSlotId(id) {
11
+ return SLOT_ID_PATTERN.test(id);
12
+ }
13
+ function slotSigningPayload(content) {
14
+ return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
15
+ ${content.slot_id}
16
+ ${content.type}
17
+ ${content.content}`;
18
+ }
19
+ function signSlotContent(content, apiKey) {
20
+ return crypto.createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
21
+ }
22
+ function verifySlotContent(content, apiKey) {
23
+ if (!content || typeof content !== "object") return false;
24
+ const c = content;
25
+ if (typeof c.slot_id !== "string" || !isValidSlotId(c.slot_id) || typeof c.content !== "string" || c.content.length === 0 || c.content.length > SLOT_CONTENT_MAX_LENGTH || typeof c.signature !== "string" || !SLOT_CONTENT_TYPES.includes(c.type)) {
26
+ return false;
27
+ }
28
+ const expected = signSlotContent(
29
+ { slot_id: c.slot_id, type: c.type, content: c.content },
30
+ apiKey
31
+ );
32
+ const a = Buffer.from(expected, "utf8");
33
+ const b = Buffer.from(c.signature, "utf8");
34
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
35
+ }
36
+
37
+ exports.SLOTS_CONTRACT_VERSION = SLOTS_CONTRACT_VERSION;
38
+ exports.SLOT_CONTENT_MAX_LENGTH = SLOT_CONTENT_MAX_LENGTH;
39
+ exports.SLOT_CONTENT_TYPES = SLOT_CONTENT_TYPES;
40
+ exports.SLOT_ID_PATTERN = SLOT_ID_PATTERN;
41
+ exports.isValidSlotId = isValidSlotId;
42
+ exports.signSlotContent = signSlotContent;
43
+ exports.slotSigningPayload = slotSigningPayload;
44
+ exports.verifySlotContent = verifySlotContent;
package/dist/slots.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ // src/contracts/slots.ts
4
+ var SLOTS_CONTRACT_VERSION = 1;
5
+ var SLOT_CONTENT_TYPES = ["text"];
6
+ var SLOT_CONTENT_MAX_LENGTH = 1e4;
7
+ var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
8
+ function isValidSlotId(id) {
9
+ return SLOT_ID_PATTERN.test(id);
10
+ }
11
+ function slotSigningPayload(content) {
12
+ return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
13
+ ${content.slot_id}
14
+ ${content.type}
15
+ ${content.content}`;
16
+ }
17
+ function signSlotContent(content, apiKey) {
18
+ return createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
19
+ }
20
+ function verifySlotContent(content, apiKey) {
21
+ if (!content || typeof content !== "object") return false;
22
+ const c = content;
23
+ if (typeof c.slot_id !== "string" || !isValidSlotId(c.slot_id) || typeof c.content !== "string" || c.content.length === 0 || c.content.length > SLOT_CONTENT_MAX_LENGTH || typeof c.signature !== "string" || !SLOT_CONTENT_TYPES.includes(c.type)) {
24
+ return false;
25
+ }
26
+ const expected = signSlotContent(
27
+ { slot_id: c.slot_id, type: c.type, content: c.content },
28
+ apiKey
29
+ );
30
+ const a = Buffer.from(expected, "utf8");
31
+ const b = Buffer.from(c.signature, "utf8");
32
+ return a.length === b.length && timingSafeEqual(a, b);
33
+ }
34
+
35
+ export { SLOTS_CONTRACT_VERSION, SLOT_CONTENT_MAX_LENGTH, SLOT_CONTENT_TYPES, SLOT_ID_PATTERN, isValidSlotId, signSlotContent, slotSigningPayload, verifySlotContent };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @sonordev/contracts/website — what a site's design is, and what a
3
+ * popup is made of. Pure: no DOM, no network, no React.
4
+ *
5
+ * SINGLE SOURCE OF TRUTH shared by every side of the Website module's popups:
6
+ * - the site (site-kit measures its own rendered design and reports it,
7
+ * publishes it as `--sk-site-*` CSS variables, and renders popup blocks),
8
+ * - sonor-api (validates the design report and every popup write with these
9
+ * normalizers, so a popup can't be saved in a shape the site can't draw),
10
+ * - the dashboard (the builder's preview reads the same tokens through the
11
+ * same CSS-variable mapping, so it looks like the site),
12
+ * - Signal's agents (the block shapes an MCP `propose_site_popup` sends).
13
+ *
14
+ * All four import it: site-kit as `@sonordev/site-kit/website/contract`,
15
+ * sonor-api and the dashboard as `@sonordev/contracts/website`.
16
+ *
17
+ * Why popups carry blocks and the site carries tokens: a popup should look
18
+ * like the site it's on. It says WHAT (a heading, some formatted text, an
19
+ * image, a button); the site's measured design says HOW (colors, fonts,
20
+ * radius). Nothing in a block chooses a color or a font.
21
+ */
22
+ /** Bumped when the token shape changes; sent with every report. */
23
+ export declare const DESIGN_TOKENS_VERSION = 1;
24
+ export interface SiteDesignTokens {
25
+ version: typeof DESIGN_TOKENS_VERSION;
26
+ colors: {
27
+ /** The page background. */
28
+ background: string;
29
+ /** A card or popup surface (the page background when the site has none). */
30
+ surface: string;
31
+ /** Body text. */
32
+ text: string;
33
+ /** Secondary text. */
34
+ muted: string;
35
+ /** The accent: buttons and links. */
36
+ primary: string;
37
+ /** Text on the accent, chosen for contrast when the site doesn't say. */
38
+ onPrimary: string;
39
+ /** Hairlines and card borders. */
40
+ border: string;
41
+ };
42
+ fonts: {
43
+ /** CSS font-family stacks, as the site declares them. */
44
+ body: string;
45
+ heading: string;
46
+ };
47
+ radius: {
48
+ /** Buttons and inputs. */
49
+ control: string;
50
+ /** Cards and popups. */
51
+ card: string;
52
+ };
53
+ /** How the site's buttons set text. */
54
+ button: {
55
+ textTransform: 'none' | 'uppercase';
56
+ fontWeight: string;
57
+ };
58
+ /** Light or dark, from the background's luminance. */
59
+ scheme: 'light' | 'dark';
60
+ /** Where the tokens came from: declared on SiteKitLayout, measured from the page, or both. */
61
+ source: 'declared' | 'measured' | 'mixed';
62
+ }
63
+ /** A CSS color (hex, rgb(), hsl()) as `#rrggbb`, or null for anything else or transparent. */
64
+ export declare function toHexColor(value: unknown): string | null;
65
+ /** `a` moved `t` (0-1) of the way toward `b`, both `#rrggbb`. */
66
+ export declare function mixHex(a: string, b: string, t: number): string;
67
+ /** '#ffffff' or '#111827', whichever reads better on `hex` (WCAG contrast). */
68
+ export declare function readableOn(hex: string): '#ffffff' | '#111827';
69
+ /**
70
+ * A design report (from the site, or anything claiming to be one) as
71
+ * SiteDesignTokens, or null when there isn't enough to draw with (no usable
72
+ * background, text or primary color). Missing pieces fall back to
73
+ * reasonable values derived from the ones present.
74
+ */
75
+ export declare function normalizeDesignTokens(input: unknown): SiteDesignTokens | null;
76
+ /** A stable hash of the tokens (FNV-1a over sorted JSON), to report only on change. */
77
+ export declare function designTokensHash(tokens: SiteDesignTokens): string;
78
+ /**
79
+ * The tokens as CSS custom properties. SINGLE SOURCE OF TRUTH for the names:
80
+ * the site sets them on :root (so popups render in its design) and the
81
+ * dashboard sets them on the preview frame (so the preview matches).
82
+ */
83
+ export declare function designTokenCssVars(tokens: SiteDesignTokens): Record<string, string>;
84
+ export type PopupBlock = {
85
+ type: 'heading';
86
+ text: string;
87
+ } | {
88
+ type: 'text';
89
+ html: string;
90
+ } | {
91
+ type: 'image';
92
+ url: string;
93
+ alt: string;
94
+ } | {
95
+ type: 'button';
96
+ text: string;
97
+ url: string;
98
+ variant: 'primary' | 'secondary';
99
+ } | {
100
+ type: 'divider';
101
+ };
102
+ export declare const POPUP_BLOCK_TYPES: readonly ['heading', 'text', 'image', 'button', 'divider'];
103
+ export declare const POPUP_BLOCK_LIMITS: {
104
+ readonly blocks: 12;
105
+ readonly heading: 120;
106
+ /** Sanitized HTML length for one text block. */
107
+ readonly textHtml: 4000;
108
+ /** Visible characters across one text block. */
109
+ readonly textPlain: 1200;
110
+ readonly buttonText: 40;
111
+ readonly imageAlt: 200;
112
+ readonly buttons: 2;
113
+ readonly images: 3;
114
+ };
115
+ /**
116
+ * A link or image target a popup may use. Links: a path on the site (`/sale`,
117
+ * never `//host` or `/\host`), an anchor (`#offer`), http(s), mailto: or
118
+ * tel:. Images: https or a path on the site (an http image is blocked on an
119
+ * https page). Anything carrying a space or control character is refused,
120
+ * since browsers strip those before parsing ("java\tscript:" runs).
121
+ */
122
+ export declare function isSafePopupUrl(value: unknown, kind?: 'link' | 'image'): boolean;
123
+ /**
124
+ * A popup text block's HTML, reduced to the formatting a popup allows:
125
+ * paragraphs, line breaks, bold, italic, underline, strikethrough, lists and
126
+ * links (safe targets only; external links open in a new tab with
127
+ * rel="noopener noreferrer"). Every other tag is removed (its text kept,
128
+ * except script/style and friends, which go with their content); every
129
+ * attribute except a link's href is removed. Server-safe: no DOM.
130
+ */
131
+ export declare function sanitizePopupHtml(html: unknown): string;
132
+ /** The visible text of sanitized popup HTML (tags removed, common entities decoded). */
133
+ export declare function popupHtmlText(html: string): string;
134
+ /**
135
+ * Popup blocks from any input (the editor, an agent, a stored row), sanitized
136
+ * and checked against POPUP_BLOCK_LIMITS. `errors` says, in words a person
137
+ * can act on, what was wrong; a caller saving a popup refuses on any error,
138
+ * a renderer just draws `blocks`.
139
+ */
140
+ export declare function normalizePopupBlocks(input: unknown): {
141
+ blocks: PopupBlock[];
142
+ errors: string[];
143
+ };
144
+ /** A popup's words in reading order: the accessible name source and the legacy plain-text fallback. */
145
+ export declare function popupBlocksText(blocks: PopupBlock[]): {
146
+ heading: string;
147
+ body: string;
148
+ };