@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/CHANGELOG.md +76 -0
- package/README.md +117 -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/dist/voice.d.ts +56 -0
- package/dist/voice.js +200 -0
- package/dist/voice.mjs +195 -0
- package/package.json +22 -2
package/dist/slots.d.ts
CHANGED
|
@@ -1,33 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @sonordev/contracts/slots — Managed Slots wire contract (
|
|
2
|
+
* @sonordev/contracts/slots — Managed Slots wire contract (v2)
|
|
3
3
|
*
|
|
4
|
-
* Single source of truth for the slot payload shape
|
|
5
|
-
* shared by site-kit (verify
|
|
6
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
export
|
|
22
|
-
/**
|
|
23
|
-
export declare const
|
|
24
|
-
/**
|
|
25
|
-
export declare const
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
79
|
-
* signature matches the project API key. Constant-time
|
|
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
|
-
|
|
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/dist/voice.d.ts
ADDED
|
@@ -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[];
|