@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/CHANGELOG.md +28 -0
- package/README.md +5 -2
- package/dist/site-cache.d.ts +55 -0
- package/dist/site-cache.js +23 -0
- package/dist/site-cache.mjs +19 -0
- package/dist/site-edit-param.d.ts +7 -0
- package/dist/site-edit.d.ts +77 -0
- package/dist/site-edit.js +179 -0
- package/dist/site-edit.mjs +170 -0
- package/dist/slot-content.d.ts +111 -0
- package/dist/slot-content.js +184 -0
- package/dist/slot-content.mjs +169 -0
- package/dist/slots.d.ts +39 -25
- package/dist/slots.js +206 -10
- package/dist/slots.mjs +195 -11
- package/package.json +17 -2
package/dist/slots.js
CHANGED
|
@@ -3,31 +3,215 @@
|
|
|
3
3
|
var crypto = require('crypto');
|
|
4
4
|
|
|
5
5
|
// src/contracts/slots.ts
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
14
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
12
|
-
return
|
|
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
|
-
|
|
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": "
|
|
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",
|