@sonordev/contracts 1.0.0 → 2.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,111 @@
1
+ /**
2
+ * @sonordev/contracts/slot-content — what a managed slot may contain.
3
+ *
4
+ * The pure half of the slots contract: content types, value validation
5
+ * (normalizeSlotValue), safe links, and the rich-text subset parser. No
6
+ * crypto, so the dashboard can import it in the browser to validate and
7
+ * preview exactly what sonor-api will accept and site-kit will render.
8
+ * `@sonordev/contracts/slots` re-exports all of it next to the signing.
9
+ */
10
+ /**
11
+ * - `text`: plain text, rendered escaped.
12
+ * - `rich`: the restricted markdown subset parseSlotRichText understands,
13
+ * rendered to elements, never HTML.
14
+ * - `link`: `{ label, href }`.
15
+ * - `list`: `{ items: [{ title?, body?, href?, image? }] }`.
16
+ */
17
+ export type SlotContentType = 'text' | 'rich' | 'link' | 'list';
18
+ export declare const SLOT_CONTENT_TYPES: readonly SlotContentType[];
19
+ /** What a v1 client can render. */
20
+ export declare const SLOT_V1_CONTENT_TYPES: readonly SlotContentType[];
21
+ /** Hard cap on managed content length; longer payloads are rejected → fallback. */
22
+ export declare const SLOT_CONTENT_MAX_LENGTH = 10000;
23
+ /** Caps for structured values. */
24
+ export declare const SLOT_LIST_MAX_ITEMS = 50;
25
+ export declare const SLOT_FIELD_MAX_LENGTH = 2000;
26
+ export declare const SLOT_HREF_MAX_LENGTH = 2048;
27
+ /** Slot ids: lowercase, page-scoped by convention, e.g. "home-hero-headline". */
28
+ export declare const SLOT_ID_PATTERN: RegExp;
29
+ export interface SlotLinkValue {
30
+ label: string;
31
+ href: string;
32
+ }
33
+ export interface SlotListImage {
34
+ /** https only. */
35
+ src: string;
36
+ alt: string;
37
+ width?: number;
38
+ height?: number;
39
+ }
40
+ export interface SlotListItem {
41
+ title?: string;
42
+ /** Plain text. */
43
+ body?: string;
44
+ href?: string;
45
+ image?: SlotListImage;
46
+ }
47
+ export interface SlotListValue {
48
+ items: SlotListItem[];
49
+ }
50
+ /** The structured value for `link` and `list`; `text` and `rich` carry none. */
51
+ export type SlotValue = SlotLinkValue | SlotListValue;
52
+ export declare function isValidSlotId(id: string): boolean;
53
+ export declare function isSlotContentType(type: unknown): type is SlotContentType;
54
+ /** Links a slot may carry: https, http, mailto, tel, #anchor, or a root-relative path. */
55
+ export declare function isSafeSlotHref(href: unknown): href is string;
56
+ /** Images a list item may carry: https only. */
57
+ export declare function isSafeSlotImageSrc(src: unknown): src is string;
58
+ export type SlotValueResult = {
59
+ ok: true;
60
+ content: string;
61
+ value: SlotValue | null;
62
+ } | {
63
+ ok: false;
64
+ error: string;
65
+ };
66
+ /**
67
+ * Validate and normalize what an editor (or Signal) submits for a slot of
68
+ * `type`, returning the canonical `content` + `value` to store and sign.
69
+ * The single rule for "what a valid slot of this type is": sonor-api runs it
70
+ * on every write and verifySlotContent re-runs it on every read.
71
+ */
72
+ export declare function normalizeSlotValue(type: SlotContentType, input: {
73
+ content?: unknown;
74
+ value?: unknown;
75
+ }): SlotValueResult;
76
+ /** The plain-text rendering of a list: its titles (or bodies, or alts), one per line. */
77
+ export declare function slotListPlainText(items: SlotListItem[]): string;
78
+ export type SlotRichInline = {
79
+ t: 'text';
80
+ v: string;
81
+ } | {
82
+ t: 'strong';
83
+ c: SlotRichInline[];
84
+ } | {
85
+ t: 'em';
86
+ c: SlotRichInline[];
87
+ } | {
88
+ t: 'link';
89
+ href: string;
90
+ c: SlotRichInline[];
91
+ } | {
92
+ t: 'br';
93
+ };
94
+ export type SlotRichBlock = {
95
+ t: 'p';
96
+ c: SlotRichInline[];
97
+ } | {
98
+ t: 'ul';
99
+ items: SlotRichInline[][];
100
+ } | {
101
+ t: 'ol';
102
+ items: SlotRichInline[][];
103
+ };
104
+ /**
105
+ * Parse the rich-text subset into a tiny AST: paragraphs (blank-line
106
+ * separated; single newlines become line breaks), `-`/`*` and `1.` lists,
107
+ * `**bold**`, `*italic*` / `_italic_`, and `[text](href)` with a safe href.
108
+ * Anything else is literal text. No HTML, ever: a renderer maps these nodes
109
+ * to elements, so there's nothing to sanitize.
110
+ */
111
+ export declare function parseSlotRichText(markdown: string): SlotRichBlock[];
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/slot-content.ts
4
+ var SLOT_CONTENT_TYPES = ["text", "rich", "link", "list"];
5
+ var SLOT_V1_CONTENT_TYPES = ["text"];
6
+ var SLOT_CONTENT_MAX_LENGTH = 1e4;
7
+ var SLOT_LIST_MAX_ITEMS = 50;
8
+ var SLOT_FIELD_MAX_LENGTH = 2e3;
9
+ var SLOT_HREF_MAX_LENGTH = 2048;
10
+ var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
11
+ function isValidSlotId(id) {
12
+ return SLOT_ID_PATTERN.test(id);
13
+ }
14
+ function isSlotContentType(type) {
15
+ return typeof type === "string" && SLOT_CONTENT_TYPES.includes(type);
16
+ }
17
+ function isSafeSlotHref(href) {
18
+ if (typeof href !== "string") return false;
19
+ const h = href.trim();
20
+ if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
21
+ if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
22
+ if (h.startsWith("#")) return !/\s/.test(h);
23
+ return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
24
+ }
25
+ function isSafeSlotImageSrc(src) {
26
+ return typeof src === "string" && src.length <= SLOT_HREF_MAX_LENGTH && /^https:\/\/[^\s]+$/i.test(src);
27
+ }
28
+ function cleanField(v) {
29
+ if (typeof v !== "string") return void 0;
30
+ const t = v.trim();
31
+ return t ? t.slice(0, SLOT_FIELD_MAX_LENGTH) : void 0;
32
+ }
33
+ function normalizeSlotValue(type, input) {
34
+ if (type === "text" || type === "rich") {
35
+ const content = typeof input.content === "string" ? input.content.trim() : "";
36
+ if (!content) return { ok: false, error: `${type} slot needs non-empty content` };
37
+ if (content.length > SLOT_CONTENT_MAX_LENGTH) {
38
+ return { ok: false, error: `content is over ${SLOT_CONTENT_MAX_LENGTH} characters` };
39
+ }
40
+ return { ok: true, content, value: null };
41
+ }
42
+ const raw = input.value ?? null;
43
+ if (!raw || typeof raw !== "object") return { ok: false, error: `${type} slot needs a value` };
44
+ if (type === "link") {
45
+ const label = cleanField(raw.label);
46
+ const href = typeof raw.href === "string" ? raw.href.trim() : "";
47
+ if (!label) return { ok: false, error: "link needs a label" };
48
+ if (!isSafeSlotHref(href)) {
49
+ return { ok: false, error: "link href must be https, http, mailto, tel, #anchor or a /path" };
50
+ }
51
+ return { ok: true, content: label, value: { label, href } };
52
+ }
53
+ if (type !== "list") return { ok: false, error: `unknown slot type ${String(type)}` };
54
+ const itemsIn = Array.isArray(raw.items) ? raw.items : null;
55
+ if (!itemsIn) return { ok: false, error: "list needs items" };
56
+ if (itemsIn.length > SLOT_LIST_MAX_ITEMS) {
57
+ return { ok: false, error: `list is over ${SLOT_LIST_MAX_ITEMS} items` };
58
+ }
59
+ const items = [];
60
+ for (const [i, it] of itemsIn.entries()) {
61
+ if (!it || typeof it !== "object") return { ok: false, error: `item ${i + 1} isn't an object` };
62
+ const src = it;
63
+ const item = {};
64
+ const title = cleanField(src.title);
65
+ const body = cleanField(src.body);
66
+ if (title) item.title = title;
67
+ if (body) item.body = body;
68
+ if (src.href !== void 0 && src.href !== null && src.href !== "") {
69
+ if (!isSafeSlotHref(src.href)) return { ok: false, error: `item ${i + 1} has an unsafe href` };
70
+ item.href = src.href.trim();
71
+ }
72
+ if (src.image !== void 0 && src.image !== null) {
73
+ const img = src.image;
74
+ if (!isSafeSlotImageSrc(img.src)) return { ok: false, error: `item ${i + 1} image needs an https src` };
75
+ const alt = typeof img.alt === "string" ? img.alt.trim().slice(0, SLOT_FIELD_MAX_LENGTH) : "";
76
+ const image = { src: img.src, alt };
77
+ if (Number.isInteger(img.width) && img.width > 0) image.width = img.width;
78
+ if (Number.isInteger(img.height) && img.height > 0) image.height = img.height;
79
+ item.image = image;
80
+ }
81
+ if (!item.title && !item.body && !item.image) {
82
+ return { ok: false, error: `item ${i + 1} needs a title, body or image` };
83
+ }
84
+ items.push(item);
85
+ }
86
+ if (items.length === 0) return { ok: false, error: "list needs at least one item" };
87
+ return { ok: true, content: slotListPlainText(items), value: { items } };
88
+ }
89
+ function slotListPlainText(items) {
90
+ const text = items.map((it) => it.title ?? it.body ?? it.image?.alt ?? "").filter(Boolean).join("\n");
91
+ return (text || `${items.length} items`).slice(0, SLOT_CONTENT_MAX_LENGTH);
92
+ }
93
+ var UL_ITEM = /^\s*[-*]\s+(.*)$/;
94
+ var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
95
+ var MAX_INLINE_DEPTH = 4;
96
+ function parseSlotRichText(markdown) {
97
+ const blocks = [];
98
+ const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
99
+ for (const chunk of chunks) {
100
+ const lines = chunk.split("\n").filter((l) => l.trim() !== "");
101
+ if (lines.length === 0) continue;
102
+ if (lines.every((l) => UL_ITEM.test(l))) {
103
+ blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
104
+ } else if (lines.every((l) => OL_ITEM.test(l))) {
105
+ blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
106
+ } else {
107
+ const c = [];
108
+ lines.forEach((line, i) => {
109
+ if (i > 0) c.push({ t: "br" });
110
+ c.push(...parseInline(line.trim()));
111
+ });
112
+ blocks.push({ t: "p", c });
113
+ }
114
+ }
115
+ return blocks;
116
+ }
117
+ function parseInline(src, depth = 0) {
118
+ const out = [];
119
+ let text = "";
120
+ const flush = () => {
121
+ if (text) out.push({ t: "text", v: text });
122
+ text = "";
123
+ };
124
+ const nest = depth < MAX_INLINE_DEPTH;
125
+ let i = 0;
126
+ while (i < src.length) {
127
+ const ch = src[i];
128
+ if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
129
+ text += src[i + 1];
130
+ i += 2;
131
+ continue;
132
+ }
133
+ if (nest && src.startsWith("**", i)) {
134
+ const end = src.indexOf("**", i + 2);
135
+ if (end > i + 2) {
136
+ flush();
137
+ out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
138
+ i = end + 2;
139
+ continue;
140
+ }
141
+ }
142
+ if (nest && (ch === "*" || ch === "_")) {
143
+ const end = src.indexOf(ch, i + 1);
144
+ if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
145
+ flush();
146
+ out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
147
+ i = end + 1;
148
+ continue;
149
+ }
150
+ }
151
+ if (nest && ch === "[") {
152
+ const close = src.indexOf("](", i + 1);
153
+ const end = close === -1 ? -1 : src.indexOf(")", close + 2);
154
+ if (close > i + 1 && end > close + 2) {
155
+ const href = src.slice(close + 2, end).trim();
156
+ if (isSafeSlotHref(href)) {
157
+ flush();
158
+ out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
159
+ i = end + 1;
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+ text += ch;
165
+ i += 1;
166
+ }
167
+ flush();
168
+ return out;
169
+ }
170
+
171
+ exports.SLOT_CONTENT_MAX_LENGTH = SLOT_CONTENT_MAX_LENGTH;
172
+ exports.SLOT_CONTENT_TYPES = SLOT_CONTENT_TYPES;
173
+ exports.SLOT_FIELD_MAX_LENGTH = SLOT_FIELD_MAX_LENGTH;
174
+ exports.SLOT_HREF_MAX_LENGTH = SLOT_HREF_MAX_LENGTH;
175
+ exports.SLOT_ID_PATTERN = SLOT_ID_PATTERN;
176
+ exports.SLOT_LIST_MAX_ITEMS = SLOT_LIST_MAX_ITEMS;
177
+ exports.SLOT_V1_CONTENT_TYPES = SLOT_V1_CONTENT_TYPES;
178
+ exports.isSafeSlotHref = isSafeSlotHref;
179
+ exports.isSafeSlotImageSrc = isSafeSlotImageSrc;
180
+ exports.isSlotContentType = isSlotContentType;
181
+ exports.isValidSlotId = isValidSlotId;
182
+ exports.normalizeSlotValue = normalizeSlotValue;
183
+ exports.parseSlotRichText = parseSlotRichText;
184
+ exports.slotListPlainText = slotListPlainText;
@@ -0,0 +1,169 @@
1
+ // src/contracts/slot-content.ts
2
+ var SLOT_CONTENT_TYPES = ["text", "rich", "link", "list"];
3
+ var SLOT_V1_CONTENT_TYPES = ["text"];
4
+ var SLOT_CONTENT_MAX_LENGTH = 1e4;
5
+ var SLOT_LIST_MAX_ITEMS = 50;
6
+ var SLOT_FIELD_MAX_LENGTH = 2e3;
7
+ var SLOT_HREF_MAX_LENGTH = 2048;
8
+ var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
9
+ function isValidSlotId(id) {
10
+ return SLOT_ID_PATTERN.test(id);
11
+ }
12
+ function isSlotContentType(type) {
13
+ return typeof type === "string" && SLOT_CONTENT_TYPES.includes(type);
14
+ }
15
+ function isSafeSlotHref(href) {
16
+ if (typeof href !== "string") return false;
17
+ const h = href.trim();
18
+ if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
19
+ if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
20
+ if (h.startsWith("#")) return !/\s/.test(h);
21
+ return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
22
+ }
23
+ function isSafeSlotImageSrc(src) {
24
+ return typeof src === "string" && src.length <= SLOT_HREF_MAX_LENGTH && /^https:\/\/[^\s]+$/i.test(src);
25
+ }
26
+ function cleanField(v) {
27
+ if (typeof v !== "string") return void 0;
28
+ const t = v.trim();
29
+ return t ? t.slice(0, SLOT_FIELD_MAX_LENGTH) : void 0;
30
+ }
31
+ function normalizeSlotValue(type, input) {
32
+ if (type === "text" || type === "rich") {
33
+ const content = typeof input.content === "string" ? input.content.trim() : "";
34
+ if (!content) return { ok: false, error: `${type} slot needs non-empty content` };
35
+ if (content.length > SLOT_CONTENT_MAX_LENGTH) {
36
+ return { ok: false, error: `content is over ${SLOT_CONTENT_MAX_LENGTH} characters` };
37
+ }
38
+ return { ok: true, content, value: null };
39
+ }
40
+ const raw = input.value ?? null;
41
+ if (!raw || typeof raw !== "object") return { ok: false, error: `${type} slot needs a value` };
42
+ if (type === "link") {
43
+ const label = cleanField(raw.label);
44
+ const href = typeof raw.href === "string" ? raw.href.trim() : "";
45
+ if (!label) return { ok: false, error: "link needs a label" };
46
+ if (!isSafeSlotHref(href)) {
47
+ return { ok: false, error: "link href must be https, http, mailto, tel, #anchor or a /path" };
48
+ }
49
+ return { ok: true, content: label, value: { label, href } };
50
+ }
51
+ if (type !== "list") return { ok: false, error: `unknown slot type ${String(type)}` };
52
+ const itemsIn = Array.isArray(raw.items) ? raw.items : null;
53
+ if (!itemsIn) return { ok: false, error: "list needs items" };
54
+ if (itemsIn.length > SLOT_LIST_MAX_ITEMS) {
55
+ return { ok: false, error: `list is over ${SLOT_LIST_MAX_ITEMS} items` };
56
+ }
57
+ const items = [];
58
+ for (const [i, it] of itemsIn.entries()) {
59
+ if (!it || typeof it !== "object") return { ok: false, error: `item ${i + 1} isn't an object` };
60
+ const src = it;
61
+ const item = {};
62
+ const title = cleanField(src.title);
63
+ const body = cleanField(src.body);
64
+ if (title) item.title = title;
65
+ if (body) item.body = body;
66
+ if (src.href !== void 0 && src.href !== null && src.href !== "") {
67
+ if (!isSafeSlotHref(src.href)) return { ok: false, error: `item ${i + 1} has an unsafe href` };
68
+ item.href = src.href.trim();
69
+ }
70
+ if (src.image !== void 0 && src.image !== null) {
71
+ const img = src.image;
72
+ if (!isSafeSlotImageSrc(img.src)) return { ok: false, error: `item ${i + 1} image needs an https src` };
73
+ const alt = typeof img.alt === "string" ? img.alt.trim().slice(0, SLOT_FIELD_MAX_LENGTH) : "";
74
+ const image = { src: img.src, alt };
75
+ if (Number.isInteger(img.width) && img.width > 0) image.width = img.width;
76
+ if (Number.isInteger(img.height) && img.height > 0) image.height = img.height;
77
+ item.image = image;
78
+ }
79
+ if (!item.title && !item.body && !item.image) {
80
+ return { ok: false, error: `item ${i + 1} needs a title, body or image` };
81
+ }
82
+ items.push(item);
83
+ }
84
+ if (items.length === 0) return { ok: false, error: "list needs at least one item" };
85
+ return { ok: true, content: slotListPlainText(items), value: { items } };
86
+ }
87
+ function slotListPlainText(items) {
88
+ const text = items.map((it) => it.title ?? it.body ?? it.image?.alt ?? "").filter(Boolean).join("\n");
89
+ return (text || `${items.length} items`).slice(0, SLOT_CONTENT_MAX_LENGTH);
90
+ }
91
+ var UL_ITEM = /^\s*[-*]\s+(.*)$/;
92
+ var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
93
+ var MAX_INLINE_DEPTH = 4;
94
+ function parseSlotRichText(markdown) {
95
+ const blocks = [];
96
+ const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
97
+ for (const chunk of chunks) {
98
+ const lines = chunk.split("\n").filter((l) => l.trim() !== "");
99
+ if (lines.length === 0) continue;
100
+ if (lines.every((l) => UL_ITEM.test(l))) {
101
+ blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
102
+ } else if (lines.every((l) => OL_ITEM.test(l))) {
103
+ blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
104
+ } else {
105
+ const c = [];
106
+ lines.forEach((line, i) => {
107
+ if (i > 0) c.push({ t: "br" });
108
+ c.push(...parseInline(line.trim()));
109
+ });
110
+ blocks.push({ t: "p", c });
111
+ }
112
+ }
113
+ return blocks;
114
+ }
115
+ function parseInline(src, depth = 0) {
116
+ const out = [];
117
+ let text = "";
118
+ const flush = () => {
119
+ if (text) out.push({ t: "text", v: text });
120
+ text = "";
121
+ };
122
+ const nest = depth < MAX_INLINE_DEPTH;
123
+ let i = 0;
124
+ while (i < src.length) {
125
+ const ch = src[i];
126
+ if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
127
+ text += src[i + 1];
128
+ i += 2;
129
+ continue;
130
+ }
131
+ if (nest && src.startsWith("**", i)) {
132
+ const end = src.indexOf("**", i + 2);
133
+ if (end > i + 2) {
134
+ flush();
135
+ out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
136
+ i = end + 2;
137
+ continue;
138
+ }
139
+ }
140
+ if (nest && (ch === "*" || ch === "_")) {
141
+ const end = src.indexOf(ch, i + 1);
142
+ if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
143
+ flush();
144
+ out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
145
+ i = end + 1;
146
+ continue;
147
+ }
148
+ }
149
+ if (nest && ch === "[") {
150
+ const close = src.indexOf("](", i + 1);
151
+ const end = close === -1 ? -1 : src.indexOf(")", close + 2);
152
+ if (close > i + 1 && end > close + 2) {
153
+ const href = src.slice(close + 2, end).trim();
154
+ if (isSafeSlotHref(href)) {
155
+ flush();
156
+ out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
157
+ i = end + 1;
158
+ continue;
159
+ }
160
+ }
161
+ }
162
+ text += ch;
163
+ i += 1;
164
+ }
165
+ flush();
166
+ return out;
167
+ }
168
+
169
+ export { SLOT_CONTENT_MAX_LENGTH, SLOT_CONTENT_TYPES, SLOT_FIELD_MAX_LENGTH, SLOT_HREF_MAX_LENGTH, SLOT_ID_PATTERN, SLOT_LIST_MAX_ITEMS, SLOT_V1_CONTENT_TYPES, isSafeSlotHref, isSafeSlotImageSrc, isSlotContentType, isValidSlotId, normalizeSlotValue, parseSlotRichText, slotListPlainText };
package/dist/slots.d.ts CHANGED
@@ -1,33 +1,37 @@
1
1
  /**
2
- * @sonordev/contracts/slots — Managed Slots wire contract (v1)
2
+ * @sonordev/contracts/slots — Managed Slots wire contract (v2)
3
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`.
4
+ * Single source of truth for the slot payload shape, value validation, the
5
+ * rich-text subset, and content signing, shared by site-kit (verify + render),
6
+ * sonor-api (validate + sign) and the dashboard (preview). Node only: it signs
7
+ * with `crypto`.
7
8
  *
8
9
  * Security model: every SlotContent is HMAC-signed with the project API key.
9
10
  * site-kit verifies before rendering and falls back to the developer's static
10
11
  * 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.
12
+ * end to end. See docs/SITE-KIT-3.0-SPEC-SLOTS.md and docs/WEBSITE-CMS.md.
13
+ *
14
+ * v1 (site-kit ≤ 7.0) is plain text only. v2 adds rich text, links and lists,
15
+ * and signs the structured value too. sonor-api serves both: a v1 request gets
16
+ * text slots signed the v1 way and null for everything else.
19
17
  */
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;
18
+ import { type SlotContentType, type SlotValue } from './slot-content';
19
+ export * from './slot-content';
20
+ /** The version this package speaks. Bump on breaking payload changes. */
21
+ export declare const SLOTS_CONTRACT_VERSION = 2;
22
+ /** Versions sonor-api still serves. */
23
+ export declare const SUPPORTED_SLOTS_CONTRACT_VERSIONS: readonly number[];
26
24
  export interface SlotContent {
27
25
  slot_id: string;
28
26
  type: SlotContentType;
29
- /** The managed text. Rendered escaped — never as HTML. */
27
+ /**
28
+ * Always a non-empty plain-text rendering: the text (text), the markdown
29
+ * (rich), the label (link), the item titles (list). Rendered escaped for
30
+ * `text`; the Copy Evidence Loop and Signal read it for every type.
31
+ */
30
32
  content: string;
33
+ /** Structured value for `link` / `list` (v2). */
34
+ value?: SlotValue | null;
31
35
  /** Forward-compat: experiment/personalization variant that produced this content. */
32
36
  variant_id?: string | null;
33
37
  experiment_id?: string | null;
@@ -38,6 +42,8 @@ export interface SlotContent {
38
42
  }
39
43
  export interface SlotResolveRequestEntry {
40
44
  id: string;
45
+ /** v2: what the site renders this slot as. Absent = 'text' (v1). */
46
+ type?: SlotContentType;
41
47
  /**
42
48
  * The developer's current static text for this slot (when statically
43
49
  * extractable). Stored as `fallback_text` on registration so the dashboard
@@ -45,6 +51,8 @@ export interface SlotResolveRequestEntry {
45
51
  * the API anywhere — display + drafting context only.
46
52
  */
47
53
  fallback?: string;
54
+ /** v2: the structured fallback (a link or list the site ships), for the editor. */
55
+ fallback_value?: SlotValue;
48
56
  /**
49
57
  * The route this slot renders on (e.g. "/" or "/pricing"). A static literal,
50
58
  * never derived from the request — it feeds the Copy Evidence Loop's
@@ -62,20 +70,26 @@ export interface SlotsResolveResponse {
62
70
  contract_version: number;
63
71
  slots: Record<string, SlotContent | null>;
64
72
  }
65
- export declare function isValidSlotId(id: string): boolean;
73
+ /** JSON with object keys sorted at every level, so both sides sign the same bytes. */
74
+ export declare function canonicalJson(value: unknown): string;
75
+ type Signable = Pick<SlotContent, 'slot_id' | 'type' | 'content'> & {
76
+ value?: SlotValue | null;
77
+ };
66
78
  /**
67
79
  * Canonical string both sides sign/verify. Exported separately from the HMAC
68
80
  * helpers so an edge-runtime resolver can reproduce it with WebCrypto.
81
+ * `version` 1 is the pre-v2 text-only form, kept byte-for-byte so sites on
82
+ * older site-kit keep verifying.
69
83
  */
70
- export declare function slotSigningPayload(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>): string;
84
+ export declare function slotSigningPayload(content: Signable, version?: number): string;
71
85
  /**
72
86
  * Sign slot content with the project API key (sonor-api side).
73
87
  * Returns the hex HMAC-SHA256 signature.
74
88
  */
75
- export declare function signSlotContent(content: Pick<SlotContent, 'slot_id' | 'type' | 'content'>, apiKey: string): string;
89
+ export declare function signSlotContent(content: Signable, apiKey: string, version?: number): string;
76
90
  /**
77
91
  * 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.
92
+ * Returns true only when the payload is well-formed for its type, within
93
+ * limits, and the signature matches the project API key. Constant-time.
80
94
  */
81
- export declare function verifySlotContent(content: unknown, apiKey: string): content is SlotContent;
95
+ export declare function verifySlotContent(content: unknown, apiKey: string, version?: number): content is SlotContent;