@sonordev/contracts 1.0.0 → 2.1.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/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;
package/dist/slots.js CHANGED
@@ -3,31 +3,215 @@
3
3
  var crypto = require('crypto');
4
4
 
5
5
  // src/contracts/slots.ts
6
- var SLOTS_CONTRACT_VERSION = 1;
7
- var SLOT_CONTENT_TYPES = ["text"];
6
+
7
+ // src/contracts/slot-content.ts
8
+ var SLOT_CONTENT_TYPES = ["text", "rich", "link", "list"];
9
+ var SLOT_V1_CONTENT_TYPES = ["text"];
8
10
  var SLOT_CONTENT_MAX_LENGTH = 1e4;
11
+ var SLOT_LIST_MAX_ITEMS = 50;
12
+ var SLOT_FIELD_MAX_LENGTH = 2e3;
13
+ var SLOT_HREF_MAX_LENGTH = 2048;
9
14
  var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
10
15
  function isValidSlotId(id) {
11
16
  return SLOT_ID_PATTERN.test(id);
12
17
  }
13
- function slotSigningPayload(content) {
14
- return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
18
+ function isSlotContentType(type) {
19
+ return typeof type === "string" && SLOT_CONTENT_TYPES.includes(type);
20
+ }
21
+ function isSafeSlotHref(href) {
22
+ if (typeof href !== "string") return false;
23
+ const h = href.trim();
24
+ if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
25
+ if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
26
+ if (h.startsWith("#")) return !/\s/.test(h);
27
+ return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
28
+ }
29
+ function isSafeSlotImageSrc(src) {
30
+ return typeof src === "string" && src.length <= SLOT_HREF_MAX_LENGTH && /^https:\/\/[^\s]+$/i.test(src);
31
+ }
32
+ function cleanField(v) {
33
+ if (typeof v !== "string") return void 0;
34
+ const t = v.trim();
35
+ return t ? t.slice(0, SLOT_FIELD_MAX_LENGTH) : void 0;
36
+ }
37
+ function normalizeSlotValue(type, input) {
38
+ if (type === "text" || type === "rich") {
39
+ const content = typeof input.content === "string" ? input.content.trim() : "";
40
+ if (!content) return { ok: false, error: `${type} slot needs non-empty content` };
41
+ if (content.length > SLOT_CONTENT_MAX_LENGTH) {
42
+ return { ok: false, error: `content is over ${SLOT_CONTENT_MAX_LENGTH} characters` };
43
+ }
44
+ return { ok: true, content, value: null };
45
+ }
46
+ const raw = input.value ?? null;
47
+ if (!raw || typeof raw !== "object") return { ok: false, error: `${type} slot needs a value` };
48
+ if (type === "link") {
49
+ const label = cleanField(raw.label);
50
+ const href = typeof raw.href === "string" ? raw.href.trim() : "";
51
+ if (!label) return { ok: false, error: "link needs a label" };
52
+ if (!isSafeSlotHref(href)) {
53
+ return { ok: false, error: "link href must be https, http, mailto, tel, #anchor or a /path" };
54
+ }
55
+ return { ok: true, content: label, value: { label, href } };
56
+ }
57
+ if (type !== "list") return { ok: false, error: `unknown slot type ${String(type)}` };
58
+ const itemsIn = Array.isArray(raw.items) ? raw.items : null;
59
+ if (!itemsIn) return { ok: false, error: "list needs items" };
60
+ if (itemsIn.length > SLOT_LIST_MAX_ITEMS) {
61
+ return { ok: false, error: `list is over ${SLOT_LIST_MAX_ITEMS} items` };
62
+ }
63
+ const items = [];
64
+ for (const [i, it] of itemsIn.entries()) {
65
+ if (!it || typeof it !== "object") return { ok: false, error: `item ${i + 1} isn't an object` };
66
+ const src = it;
67
+ const item = {};
68
+ const title = cleanField(src.title);
69
+ const body = cleanField(src.body);
70
+ if (title) item.title = title;
71
+ if (body) item.body = body;
72
+ if (src.href !== void 0 && src.href !== null && src.href !== "") {
73
+ if (!isSafeSlotHref(src.href)) return { ok: false, error: `item ${i + 1} has an unsafe href` };
74
+ item.href = src.href.trim();
75
+ }
76
+ if (src.image !== void 0 && src.image !== null) {
77
+ const img = src.image;
78
+ if (!isSafeSlotImageSrc(img.src)) return { ok: false, error: `item ${i + 1} image needs an https src` };
79
+ const alt = typeof img.alt === "string" ? img.alt.trim().slice(0, SLOT_FIELD_MAX_LENGTH) : "";
80
+ const image = { src: img.src, alt };
81
+ if (Number.isInteger(img.width) && img.width > 0) image.width = img.width;
82
+ if (Number.isInteger(img.height) && img.height > 0) image.height = img.height;
83
+ item.image = image;
84
+ }
85
+ if (!item.title && !item.body && !item.image) {
86
+ return { ok: false, error: `item ${i + 1} needs a title, body or image` };
87
+ }
88
+ items.push(item);
89
+ }
90
+ if (items.length === 0) return { ok: false, error: "list needs at least one item" };
91
+ return { ok: true, content: slotListPlainText(items), value: { items } };
92
+ }
93
+ function slotListPlainText(items) {
94
+ const text = items.map((it) => it.title ?? it.body ?? it.image?.alt ?? "").filter(Boolean).join("\n");
95
+ return (text || `${items.length} items`).slice(0, SLOT_CONTENT_MAX_LENGTH);
96
+ }
97
+ var UL_ITEM = /^\s*[-*]\s+(.*)$/;
98
+ var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
99
+ var MAX_INLINE_DEPTH = 4;
100
+ function parseSlotRichText(markdown) {
101
+ const blocks = [];
102
+ const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
103
+ for (const chunk of chunks) {
104
+ const lines = chunk.split("\n").filter((l) => l.trim() !== "");
105
+ if (lines.length === 0) continue;
106
+ if (lines.every((l) => UL_ITEM.test(l))) {
107
+ blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
108
+ } else if (lines.every((l) => OL_ITEM.test(l))) {
109
+ blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
110
+ } else {
111
+ const c = [];
112
+ lines.forEach((line, i) => {
113
+ if (i > 0) c.push({ t: "br" });
114
+ c.push(...parseInline(line.trim()));
115
+ });
116
+ blocks.push({ t: "p", c });
117
+ }
118
+ }
119
+ return blocks;
120
+ }
121
+ function parseInline(src, depth = 0) {
122
+ const out = [];
123
+ let text = "";
124
+ const flush = () => {
125
+ if (text) out.push({ t: "text", v: text });
126
+ text = "";
127
+ };
128
+ const nest = depth < MAX_INLINE_DEPTH;
129
+ let i = 0;
130
+ while (i < src.length) {
131
+ const ch = src[i];
132
+ if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
133
+ text += src[i + 1];
134
+ i += 2;
135
+ continue;
136
+ }
137
+ if (nest && src.startsWith("**", i)) {
138
+ const end = src.indexOf("**", i + 2);
139
+ if (end > i + 2) {
140
+ flush();
141
+ out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
142
+ i = end + 2;
143
+ continue;
144
+ }
145
+ }
146
+ if (nest && (ch === "*" || ch === "_")) {
147
+ const end = src.indexOf(ch, i + 1);
148
+ if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
149
+ flush();
150
+ out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
151
+ i = end + 1;
152
+ continue;
153
+ }
154
+ }
155
+ if (nest && ch === "[") {
156
+ const close = src.indexOf("](", i + 1);
157
+ const end = close === -1 ? -1 : src.indexOf(")", close + 2);
158
+ if (close > i + 1 && end > close + 2) {
159
+ const href = src.slice(close + 2, end).trim();
160
+ if (isSafeSlotHref(href)) {
161
+ flush();
162
+ out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
163
+ i = end + 1;
164
+ continue;
165
+ }
166
+ }
167
+ }
168
+ text += ch;
169
+ i += 1;
170
+ }
171
+ flush();
172
+ return out;
173
+ }
174
+
175
+ // src/contracts/slots.ts
176
+ var SLOTS_CONTRACT_VERSION = 2;
177
+ var SUPPORTED_SLOTS_CONTRACT_VERSIONS = [1, 2];
178
+ function canonicalJson(value) {
179
+ if (value === null || value === void 0) return "null";
180
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
181
+ if (typeof value === "object") {
182
+ const obj = value;
183
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
184
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
185
+ }
186
+ return JSON.stringify(value);
187
+ }
188
+ function slotSigningPayload(content, version = SLOTS_CONTRACT_VERSION) {
189
+ const head = `sonor-slot.v${version}
15
190
  ${content.slot_id}
16
191
  ${content.type}
17
192
  ${content.content}`;
193
+ return version === 1 ? head : `${head}
194
+ ${canonicalJson(content.value ?? null)}`;
18
195
  }
19
- function signSlotContent(content, apiKey) {
20
- return crypto.createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
196
+ function signSlotContent(content, apiKey, version = SLOTS_CONTRACT_VERSION) {
197
+ return crypto.createHmac("sha256", apiKey).update(slotSigningPayload(content, version), "utf8").digest("hex");
21
198
  }
22
- function verifySlotContent(content, apiKey) {
199
+ function verifySlotContent(content, apiKey, version = SLOTS_CONTRACT_VERSION) {
23
200
  if (!content || typeof content !== "object") return false;
24
201
  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)) {
202
+ const allowed = version === 1 ? SLOT_V1_CONTENT_TYPES : SLOT_CONTENT_TYPES;
203
+ 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" || !allowed.includes(c.type)) {
26
204
  return false;
27
205
  }
206
+ if (version !== 1) {
207
+ const check = normalizeSlotValue(c.type, { content: c.content, value: c.value });
208
+ if (!check.ok || check.content !== c.content) return false;
209
+ if (canonicalJson(check.value) !== canonicalJson(c.value ?? null)) return false;
210
+ }
28
211
  const expected = signSlotContent(
29
- { slot_id: c.slot_id, type: c.type, content: c.content },
30
- apiKey
212
+ { slot_id: c.slot_id, type: c.type, content: c.content, value: c.value ?? null },
213
+ apiKey,
214
+ version
31
215
  );
32
216
  const a = Buffer.from(expected, "utf8");
33
217
  const b = Buffer.from(c.signature, "utf8");
@@ -37,8 +221,20 @@ function verifySlotContent(content, apiKey) {
37
221
  exports.SLOTS_CONTRACT_VERSION = SLOTS_CONTRACT_VERSION;
38
222
  exports.SLOT_CONTENT_MAX_LENGTH = SLOT_CONTENT_MAX_LENGTH;
39
223
  exports.SLOT_CONTENT_TYPES = SLOT_CONTENT_TYPES;
224
+ exports.SLOT_FIELD_MAX_LENGTH = SLOT_FIELD_MAX_LENGTH;
225
+ exports.SLOT_HREF_MAX_LENGTH = SLOT_HREF_MAX_LENGTH;
40
226
  exports.SLOT_ID_PATTERN = SLOT_ID_PATTERN;
227
+ exports.SLOT_LIST_MAX_ITEMS = SLOT_LIST_MAX_ITEMS;
228
+ exports.SLOT_V1_CONTENT_TYPES = SLOT_V1_CONTENT_TYPES;
229
+ exports.SUPPORTED_SLOTS_CONTRACT_VERSIONS = SUPPORTED_SLOTS_CONTRACT_VERSIONS;
230
+ exports.canonicalJson = canonicalJson;
231
+ exports.isSafeSlotHref = isSafeSlotHref;
232
+ exports.isSafeSlotImageSrc = isSafeSlotImageSrc;
233
+ exports.isSlotContentType = isSlotContentType;
41
234
  exports.isValidSlotId = isValidSlotId;
235
+ exports.normalizeSlotValue = normalizeSlotValue;
236
+ exports.parseSlotRichText = parseSlotRichText;
42
237
  exports.signSlotContent = signSlotContent;
238
+ exports.slotListPlainText = slotListPlainText;
43
239
  exports.slotSigningPayload = slotSigningPayload;
44
240
  exports.verifySlotContent = verifySlotContent;
package/dist/slots.mjs CHANGED
@@ -1,35 +1,219 @@
1
1
  import { createHmac, timingSafeEqual } from 'crypto';
2
2
 
3
3
  // src/contracts/slots.ts
4
- var SLOTS_CONTRACT_VERSION = 1;
5
- var SLOT_CONTENT_TYPES = ["text"];
4
+
5
+ // src/contracts/slot-content.ts
6
+ var SLOT_CONTENT_TYPES = ["text", "rich", "link", "list"];
7
+ var SLOT_V1_CONTENT_TYPES = ["text"];
6
8
  var SLOT_CONTENT_MAX_LENGTH = 1e4;
9
+ var SLOT_LIST_MAX_ITEMS = 50;
10
+ var SLOT_FIELD_MAX_LENGTH = 2e3;
11
+ var SLOT_HREF_MAX_LENGTH = 2048;
7
12
  var SLOT_ID_PATTERN = /^[a-z0-9][a-z0-9-_.:]{0,127}$/;
8
13
  function isValidSlotId(id) {
9
14
  return SLOT_ID_PATTERN.test(id);
10
15
  }
11
- function slotSigningPayload(content) {
12
- return `sonor-slot.v${SLOTS_CONTRACT_VERSION}
16
+ function isSlotContentType(type) {
17
+ return typeof type === "string" && SLOT_CONTENT_TYPES.includes(type);
18
+ }
19
+ function isSafeSlotHref(href) {
20
+ if (typeof href !== "string") return false;
21
+ const h = href.trim();
22
+ if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
23
+ if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
24
+ if (h.startsWith("#")) return !/\s/.test(h);
25
+ return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
26
+ }
27
+ function isSafeSlotImageSrc(src) {
28
+ return typeof src === "string" && src.length <= SLOT_HREF_MAX_LENGTH && /^https:\/\/[^\s]+$/i.test(src);
29
+ }
30
+ function cleanField(v) {
31
+ if (typeof v !== "string") return void 0;
32
+ const t = v.trim();
33
+ return t ? t.slice(0, SLOT_FIELD_MAX_LENGTH) : void 0;
34
+ }
35
+ function normalizeSlotValue(type, input) {
36
+ if (type === "text" || type === "rich") {
37
+ const content = typeof input.content === "string" ? input.content.trim() : "";
38
+ if (!content) return { ok: false, error: `${type} slot needs non-empty content` };
39
+ if (content.length > SLOT_CONTENT_MAX_LENGTH) {
40
+ return { ok: false, error: `content is over ${SLOT_CONTENT_MAX_LENGTH} characters` };
41
+ }
42
+ return { ok: true, content, value: null };
43
+ }
44
+ const raw = input.value ?? null;
45
+ if (!raw || typeof raw !== "object") return { ok: false, error: `${type} slot needs a value` };
46
+ if (type === "link") {
47
+ const label = cleanField(raw.label);
48
+ const href = typeof raw.href === "string" ? raw.href.trim() : "";
49
+ if (!label) return { ok: false, error: "link needs a label" };
50
+ if (!isSafeSlotHref(href)) {
51
+ return { ok: false, error: "link href must be https, http, mailto, tel, #anchor or a /path" };
52
+ }
53
+ return { ok: true, content: label, value: { label, href } };
54
+ }
55
+ if (type !== "list") return { ok: false, error: `unknown slot type ${String(type)}` };
56
+ const itemsIn = Array.isArray(raw.items) ? raw.items : null;
57
+ if (!itemsIn) return { ok: false, error: "list needs items" };
58
+ if (itemsIn.length > SLOT_LIST_MAX_ITEMS) {
59
+ return { ok: false, error: `list is over ${SLOT_LIST_MAX_ITEMS} items` };
60
+ }
61
+ const items = [];
62
+ for (const [i, it] of itemsIn.entries()) {
63
+ if (!it || typeof it !== "object") return { ok: false, error: `item ${i + 1} isn't an object` };
64
+ const src = it;
65
+ const item = {};
66
+ const title = cleanField(src.title);
67
+ const body = cleanField(src.body);
68
+ if (title) item.title = title;
69
+ if (body) item.body = body;
70
+ if (src.href !== void 0 && src.href !== null && src.href !== "") {
71
+ if (!isSafeSlotHref(src.href)) return { ok: false, error: `item ${i + 1} has an unsafe href` };
72
+ item.href = src.href.trim();
73
+ }
74
+ if (src.image !== void 0 && src.image !== null) {
75
+ const img = src.image;
76
+ if (!isSafeSlotImageSrc(img.src)) return { ok: false, error: `item ${i + 1} image needs an https src` };
77
+ const alt = typeof img.alt === "string" ? img.alt.trim().slice(0, SLOT_FIELD_MAX_LENGTH) : "";
78
+ const image = { src: img.src, alt };
79
+ if (Number.isInteger(img.width) && img.width > 0) image.width = img.width;
80
+ if (Number.isInteger(img.height) && img.height > 0) image.height = img.height;
81
+ item.image = image;
82
+ }
83
+ if (!item.title && !item.body && !item.image) {
84
+ return { ok: false, error: `item ${i + 1} needs a title, body or image` };
85
+ }
86
+ items.push(item);
87
+ }
88
+ if (items.length === 0) return { ok: false, error: "list needs at least one item" };
89
+ return { ok: true, content: slotListPlainText(items), value: { items } };
90
+ }
91
+ function slotListPlainText(items) {
92
+ const text = items.map((it) => it.title ?? it.body ?? it.image?.alt ?? "").filter(Boolean).join("\n");
93
+ return (text || `${items.length} items`).slice(0, SLOT_CONTENT_MAX_LENGTH);
94
+ }
95
+ var UL_ITEM = /^\s*[-*]\s+(.*)$/;
96
+ var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
97
+ var MAX_INLINE_DEPTH = 4;
98
+ function parseSlotRichText(markdown) {
99
+ const blocks = [];
100
+ const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
101
+ for (const chunk of chunks) {
102
+ const lines = chunk.split("\n").filter((l) => l.trim() !== "");
103
+ if (lines.length === 0) continue;
104
+ if (lines.every((l) => UL_ITEM.test(l))) {
105
+ blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
106
+ } else if (lines.every((l) => OL_ITEM.test(l))) {
107
+ blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
108
+ } else {
109
+ const c = [];
110
+ lines.forEach((line, i) => {
111
+ if (i > 0) c.push({ t: "br" });
112
+ c.push(...parseInline(line.trim()));
113
+ });
114
+ blocks.push({ t: "p", c });
115
+ }
116
+ }
117
+ return blocks;
118
+ }
119
+ function parseInline(src, depth = 0) {
120
+ const out = [];
121
+ let text = "";
122
+ const flush = () => {
123
+ if (text) out.push({ t: "text", v: text });
124
+ text = "";
125
+ };
126
+ const nest = depth < MAX_INLINE_DEPTH;
127
+ let i = 0;
128
+ while (i < src.length) {
129
+ const ch = src[i];
130
+ if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
131
+ text += src[i + 1];
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (nest && src.startsWith("**", i)) {
136
+ const end = src.indexOf("**", i + 2);
137
+ if (end > i + 2) {
138
+ flush();
139
+ out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
140
+ i = end + 2;
141
+ continue;
142
+ }
143
+ }
144
+ if (nest && (ch === "*" || ch === "_")) {
145
+ const end = src.indexOf(ch, i + 1);
146
+ if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
147
+ flush();
148
+ out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
149
+ i = end + 1;
150
+ continue;
151
+ }
152
+ }
153
+ if (nest && ch === "[") {
154
+ const close = src.indexOf("](", i + 1);
155
+ const end = close === -1 ? -1 : src.indexOf(")", close + 2);
156
+ if (close > i + 1 && end > close + 2) {
157
+ const href = src.slice(close + 2, end).trim();
158
+ if (isSafeSlotHref(href)) {
159
+ flush();
160
+ out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
161
+ i = end + 1;
162
+ continue;
163
+ }
164
+ }
165
+ }
166
+ text += ch;
167
+ i += 1;
168
+ }
169
+ flush();
170
+ return out;
171
+ }
172
+
173
+ // src/contracts/slots.ts
174
+ var SLOTS_CONTRACT_VERSION = 2;
175
+ var SUPPORTED_SLOTS_CONTRACT_VERSIONS = [1, 2];
176
+ function canonicalJson(value) {
177
+ if (value === null || value === void 0) return "null";
178
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
179
+ if (typeof value === "object") {
180
+ const obj = value;
181
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
182
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
183
+ }
184
+ return JSON.stringify(value);
185
+ }
186
+ function slotSigningPayload(content, version = SLOTS_CONTRACT_VERSION) {
187
+ const head = `sonor-slot.v${version}
13
188
  ${content.slot_id}
14
189
  ${content.type}
15
190
  ${content.content}`;
191
+ return version === 1 ? head : `${head}
192
+ ${canonicalJson(content.value ?? null)}`;
16
193
  }
17
- function signSlotContent(content, apiKey) {
18
- return createHmac("sha256", apiKey).update(slotSigningPayload(content), "utf8").digest("hex");
194
+ function signSlotContent(content, apiKey, version = SLOTS_CONTRACT_VERSION) {
195
+ return createHmac("sha256", apiKey).update(slotSigningPayload(content, version), "utf8").digest("hex");
19
196
  }
20
- function verifySlotContent(content, apiKey) {
197
+ function verifySlotContent(content, apiKey, version = SLOTS_CONTRACT_VERSION) {
21
198
  if (!content || typeof content !== "object") return false;
22
199
  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)) {
200
+ const allowed = version === 1 ? SLOT_V1_CONTENT_TYPES : SLOT_CONTENT_TYPES;
201
+ 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" || !allowed.includes(c.type)) {
24
202
  return false;
25
203
  }
204
+ if (version !== 1) {
205
+ const check = normalizeSlotValue(c.type, { content: c.content, value: c.value });
206
+ if (!check.ok || check.content !== c.content) return false;
207
+ if (canonicalJson(check.value) !== canonicalJson(c.value ?? null)) return false;
208
+ }
26
209
  const expected = signSlotContent(
27
- { slot_id: c.slot_id, type: c.type, content: c.content },
28
- apiKey
210
+ { slot_id: c.slot_id, type: c.type, content: c.content, value: c.value ?? null },
211
+ apiKey,
212
+ version
29
213
  );
30
214
  const a = Buffer.from(expected, "utf8");
31
215
  const b = Buffer.from(c.signature, "utf8");
32
216
  return a.length === b.length && timingSafeEqual(a, b);
33
217
  }
34
218
 
35
- export { SLOTS_CONTRACT_VERSION, SLOT_CONTENT_MAX_LENGTH, SLOT_CONTENT_TYPES, SLOT_ID_PATTERN, isValidSlotId, signSlotContent, slotSigningPayload, verifySlotContent };
219
+ export { SLOTS_CONTRACT_VERSION, 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, SUPPORTED_SLOTS_CONTRACT_VERSIONS, canonicalJson, isSafeSlotHref, isSafeSlotImageSrc, isSlotContentType, isValidSlotId, normalizeSlotValue, parseSlotRichText, signSlotContent, slotListPlainText, slotSigningPayload, verifySlotContent };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stands in for every character that isn't prose (code, quotations, link
3
+ * destinations). It's neither whitespace nor a word character, so a masked
4
+ * span still separates words and no pattern can match it.
5
+ */
6
+ export declare const VOICE_MASK = "\u2800";
7
+ export interface VoiceHit {
8
+ /** UTF-16 offset into the whole string you passed in, never line-relative. */
9
+ readonly index: number;
10
+ /** Length of the span to replace. May include a run of spaces or tabs. */
11
+ readonly length: number;
12
+ /** The text at [index, index + length) of the string you passed in. */
13
+ readonly match: string;
14
+ /** What to put there, first-letter case preserved. Always a straight apostrophe. */
15
+ readonly suggestion: string;
16
+ }
17
+ export interface VoiceOptions {
18
+ /**
19
+ * The text is a heading or title, so one that opens with "What Is" keeps it.
20
+ * Applies to every line. Pass the heading text, not the Markdown line: `#`
21
+ * and list markers must already be blanked.
22
+ */
23
+ readonly titleLike?: boolean | undefined;
24
+ /**
25
+ * Half-open [start, end) UTF-16 ranges to leave alone, computed from the
26
+ * exact string passed in. Any order, overlaps and touching ranges allowed.
27
+ * Start is floored and clamped to 0 (so -Infinity means "from the start"),
28
+ * end is ceiled and clamped to the text length (so Infinity means "to the
29
+ * end"), and an empty or out-of-text range is ignored. Every protected
30
+ * character is masked except "\n" and "\r", which stay as they are (a
31
+ * protected line break still ends its line). A malformed range (NaN, a
32
+ * non-number, or start after end) or a truthy value that isn't an array
33
+ * protects the whole text: protection never fails open. A falsy value means
34
+ * no ranges.
35
+ */
36
+ readonly protectedRanges?: ReadonlyArray<readonly [number, number]> | undefined;
37
+ }
38
+ /**
39
+ * Full forms the house voice contracts ("do not" -> "don't"). Read one line at
40
+ * a time: split on "\n"; a "\r" right before it, or at the very end of the
41
+ * text, ends the line with it; any other "\r" is text.
42
+ * Sorted by index, never overlapping, [] for ''.
43
+ */
44
+ export declare function findUncontracted(text: string, options?: VoiceOptions): VoiceHit[];
45
+ /**
46
+ * `text` with every findUncontracted hit applied. Returns `text` itself when
47
+ * nothing applies, and for anything that isn't a string. Protected characters
48
+ * are never rewritten.
49
+ */
50
+ export declare function contractText(text: string, options?: VoiceOptions): string;
51
+ /**
52
+ * Contractions a question-form heading or FAQ question must expand
53
+ * ("What's" -> "What is"). Sorted by index, never overlapping. `titleLike` is
54
+ * not part of this direction.
55
+ */
56
+ export declare function findContracted(text: string, options?: Pick<VoiceOptions, 'protectedRanges'>): VoiceHit[];