@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.
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 };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "1.0.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot signing and portfolio metrics. Pure functions, no dependencies.",
3
+ "version": "2.0.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags and portfolio metrics. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -30,6 +30,21 @@
30
30
  "import": "./dist/slots.mjs",
31
31
  "require": "./dist/slots.js"
32
32
  },
33
+ "./slot-content": {
34
+ "types": "./dist/slot-content.d.ts",
35
+ "import": "./dist/slot-content.mjs",
36
+ "require": "./dist/slot-content.js"
37
+ },
38
+ "./site-cache": {
39
+ "types": "./dist/site-cache.d.ts",
40
+ "import": "./dist/site-cache.mjs",
41
+ "require": "./dist/site-cache.js"
42
+ },
43
+ "./site-edit": {
44
+ "types": "./dist/site-edit.d.ts",
45
+ "import": "./dist/site-edit.mjs",
46
+ "require": "./dist/site-edit.js"
47
+ },
33
48
  "./website": {
34
49
  "types": "./dist/website.d.ts",
35
50
  "import": "./dist/website.mjs",