@firedrill-tools/hubspot 0.1.1
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/LICENSE +201 -0
- package/README.md +263 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +20 -0
- package/firedrill/hubspot-deactivated-user.drill.json +53 -0
- package/firedrill/hubspot-denied.drill.json +74 -0
- package/firedrill/hubspot-fresh-actor.drill.json +128 -0
- package/firedrill/hubspot-invalid-auth.drill.json +433 -0
- package/firedrill/hubspot-mcp-aliases.drill.json +265 -0
- package/firedrill/hubspot-no-scopes.drill.json +433 -0
- package/firedrill/hubspot-owned-visibility.drill.json +113 -0
- package/firedrill/hubspot-rate-limited.drill.json +446 -0
- package/firedrill/hubspot-rest-flow.drill.json +963 -0
- package/firedrill/hubspot-scopes.drill.json +166 -0
- package/firedrill/hubspot-size-bounds.drill.json +156 -0
- package/firedrill/hubspot-write-committed-lost.drill.json +166 -0
- package/firedrill/hubspot-write-unavailable.drill.json +292 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/hubspot/behavior.mjs +1318 -0
- package/firedrill/tools/hubspot/hubspot.tool.json +5524 -0
- package/firedrill/tools/hubspot/lib/bytes.mjs +32 -0
- package/firedrill/tools/hubspot/lib/json-depth.mjs +17 -0
- package/firedrill/tools/hubspot/lib/object-types.mjs +47 -0
- package/firedrill/tools/hubspot/lib/properties.mjs +275 -0
- package/firedrill/tools/hubspot/lib/search-matchers.mjs +85 -0
- package/firedrill/tools/hubspot/lib/search.mjs +301 -0
- package/firedrill/tools/hubspot/lib/state.mjs +248 -0
- package/firedrill/tools/hubspot/lib/wire.mjs +125 -0
- package/firedrill/world.json +3128 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2540 -0
- package/test/conformance.mjs +1007 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// CRM search semantics: filterGroups (OR of ANDs), free-text query and sorting. Pure functions over record property
|
|
2
|
+
// maps and property definitions. Matching is linear in the property values and every request carries a work budget.
|
|
3
|
+
import { clip, parseMillis } from "./state.mjs";
|
|
4
|
+
import { charge, globMatcher, lowerBound } from "./search-matchers.mjs";
|
|
5
|
+
|
|
6
|
+
export { MAX_MATCH_WORK, createWorkBudget } from "./search-matchers.mjs";
|
|
7
|
+
|
|
8
|
+
export const OPERATORS = Object.freeze([
|
|
9
|
+
"EQ",
|
|
10
|
+
"NEQ",
|
|
11
|
+
"LT",
|
|
12
|
+
"LTE",
|
|
13
|
+
"GT",
|
|
14
|
+
"GTE",
|
|
15
|
+
"BETWEEN",
|
|
16
|
+
"IN",
|
|
17
|
+
"NOT_IN",
|
|
18
|
+
"HAS_PROPERTY",
|
|
19
|
+
"NOT_HAS_PROPERTY",
|
|
20
|
+
"CONTAINS_TOKEN",
|
|
21
|
+
"NOT_CONTAINS_TOKEN",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const MAX_FILTER_GROUPS = 5;
|
|
25
|
+
export const MAX_FILTERS_PER_GROUP = 6;
|
|
26
|
+
export const MAX_FILTERS_TOTAL = 18;
|
|
27
|
+
|
|
28
|
+
/** HubSpot caps a search `query` at 3,000 characters; filter values use the same bound. */
|
|
29
|
+
export const MAX_SEARCH_TEXT = 3000;
|
|
30
|
+
|
|
31
|
+
const TOKEN_SEPARATOR = /[^\p{L}\p{N}@._+-]+/u;
|
|
32
|
+
|
|
33
|
+
function isDateType(definition) {
|
|
34
|
+
return definition.type === "datetime" || definition.type === "date";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Comparable form of a raw value (number, epoch ms or lower-cased string); undefined when empty or unparsable. */
|
|
38
|
+
function comparable(definition, value) {
|
|
39
|
+
if (value === null || value === undefined || value === "") return undefined;
|
|
40
|
+
if (definition.type === "number") {
|
|
41
|
+
const number = Number(value);
|
|
42
|
+
return Number.isFinite(number) ? number : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (isDateType(definition)) return parseMillis(String(value));
|
|
45
|
+
return String(value).toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasValue(value) {
|
|
49
|
+
return value !== null && value !== undefined && value !== "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitTokens(folded) {
|
|
53
|
+
return folded.split(TOKEN_SEPARATOR).filter((token) => token.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An own property of a record's property map (never an inherited member such as `constructor`). */
|
|
57
|
+
function own(properties, name) {
|
|
58
|
+
return Object.hasOwn(properties, name) ? properties[name] : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The framework decodes query strings and request bodies leniently, so malformed percent-encoding
|
|
63
|
+
* (`%E0%A4%A`) reaches the Tool as U+FFFD. A search value holding U+FFFD is a mangled request, not
|
|
64
|
+
* search text: reject it instead of running a corrupted filter that silently matches nothing. A
|
|
65
|
+
* correctly encoded U+FFFD (`%EF%BF%BD`) is rejected the same way; `%ZZ` stays literal and matches.
|
|
66
|
+
*/
|
|
67
|
+
export function isMangled(value) {
|
|
68
|
+
return typeof value === "string" && value.includes("\uFFFD");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate one filter and return `{ filter }` (normalised, with its comparison values precomputed) or `{ error }`.
|
|
73
|
+
* `definitions` is a Map of property name → definition for the object type.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeFilter(filter, definitions) {
|
|
76
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) return { error: "Each filter must be an object" };
|
|
77
|
+
const name = filter.propertyName;
|
|
78
|
+
if (typeof name !== "string" || name.length === 0) return { error: "propertyName is required on every filter" };
|
|
79
|
+
const definition = definitions.get(name);
|
|
80
|
+
if (definition === undefined) return { error: `Property "${clip(name)}" does not exist` };
|
|
81
|
+
const operator = filter.operator;
|
|
82
|
+
if (typeof operator !== "string" || !OPERATORS.includes(operator)) {
|
|
83
|
+
return { error: `Invalid operator ${clip(JSON.stringify(operator ?? null))}; supported operators: ${OPERATORS.join(", ")}` };
|
|
84
|
+
}
|
|
85
|
+
const scalar = (value) => (typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : undefined);
|
|
86
|
+
const tooLong = (field) => `Filter ${operator} on "${clip(name)}": ${field} exceeds the maximum length of ${MAX_SEARCH_TEXT} characters`;
|
|
87
|
+
const mangledValue = (field) => `Filter ${operator} on "${clip(name)}": ${field} contains an invalid character (U+FFFD); check the encoding of the request`;
|
|
88
|
+
const isDate = isDateType(definition);
|
|
89
|
+
const invalidDate = (text) => `${clip(JSON.stringify(text))} is not a valid date for "${clip(name)}"; use epoch milliseconds or ISO 8601`;
|
|
90
|
+
if (operator === "IN" || operator === "NOT_IN") {
|
|
91
|
+
if (!Array.isArray(filter.values) || filter.values.length === 0) return { error: `Filter ${operator} on "${clip(name)}" requires a non-empty values array` };
|
|
92
|
+
const values = [];
|
|
93
|
+
for (const item of filter.values) {
|
|
94
|
+
const value = scalar(item);
|
|
95
|
+
if (value === undefined) return { error: `Filter ${operator} on "${clip(name)}" has a non-scalar value` };
|
|
96
|
+
if (value.length > MAX_SEARCH_TEXT) return { error: tooLong("a value in values") };
|
|
97
|
+
if (isMangled(value)) return { error: mangledValue("a value in values") };
|
|
98
|
+
if (isDate && parseMillis(value) === undefined) return { error: invalidDate(value) };
|
|
99
|
+
values.push(value);
|
|
100
|
+
}
|
|
101
|
+
const valueSet = new Set();
|
|
102
|
+
for (const value of values) {
|
|
103
|
+
const key = comparable(definition, value);
|
|
104
|
+
if (key !== undefined) valueSet.add(key);
|
|
105
|
+
}
|
|
106
|
+
return { filter: { definition, operator, values, valueSet } };
|
|
107
|
+
}
|
|
108
|
+
if (operator === "HAS_PROPERTY" || operator === "NOT_HAS_PROPERTY") return { filter: { definition, operator } };
|
|
109
|
+
const value = scalar(filter.value);
|
|
110
|
+
if (value === undefined) return { error: `Filter ${operator} on "${clip(name)}" requires a value` };
|
|
111
|
+
if (value.length > MAX_SEARCH_TEXT) return { error: tooLong("value") };
|
|
112
|
+
if (isMangled(value)) return { error: mangledValue("value") };
|
|
113
|
+
if (isDate && operator !== "CONTAINS_TOKEN" && operator !== "NOT_CONTAINS_TOKEN" && parseMillis(value) === undefined) return { error: invalidDate(value) };
|
|
114
|
+
if (operator === "BETWEEN") {
|
|
115
|
+
const highValue = scalar(filter.highValue);
|
|
116
|
+
if (highValue === undefined) return { error: `Filter BETWEEN on "${clip(name)}" requires a highValue` };
|
|
117
|
+
if (highValue.length > MAX_SEARCH_TEXT) return { error: tooLong("highValue") };
|
|
118
|
+
if (isMangled(highValue)) return { error: mangledValue("highValue") };
|
|
119
|
+
if (isDate && parseMillis(highValue) === undefined) return { error: invalidDate(highValue) };
|
|
120
|
+
return { filter: { definition, operator, value, highValue, expected: comparable(definition, value), high: comparable(definition, highValue) } };
|
|
121
|
+
}
|
|
122
|
+
if (operator === "CONTAINS_TOKEN" || operator === "NOT_CONTAINS_TOKEN") return { filter: { definition, operator, value, match: globMatcher(value) } };
|
|
123
|
+
return { filter: { definition, operator, value, expected: comparable(definition, value) } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validate a whole `filterGroups` array → `{ groups }` or `{ error }`. */
|
|
127
|
+
export function normalizeFilterGroups(filterGroups, definitions) {
|
|
128
|
+
if (filterGroups === undefined) return { groups: [] };
|
|
129
|
+
if (!Array.isArray(filterGroups)) return { error: "filterGroups must be an array" };
|
|
130
|
+
if (filterGroups.length > MAX_FILTER_GROUPS) return { error: `At most ${MAX_FILTER_GROUPS} filterGroups are allowed` };
|
|
131
|
+
const groups = [];
|
|
132
|
+
let total = 0;
|
|
133
|
+
for (const group of filterGroups) {
|
|
134
|
+
const filters = typeof group === "object" && group !== null ? group.filters : undefined;
|
|
135
|
+
if (!Array.isArray(filters)) return { error: "Each filterGroup must carry a filters array" };
|
|
136
|
+
if (filters.length > MAX_FILTERS_PER_GROUP) return { error: `At most ${MAX_FILTERS_PER_GROUP} filters are allowed per filterGroup` };
|
|
137
|
+
total += filters.length;
|
|
138
|
+
if (total > MAX_FILTERS_TOTAL) return { error: `At most ${MAX_FILTERS_TOTAL} filters are allowed across all filterGroups` };
|
|
139
|
+
const normalized = [];
|
|
140
|
+
for (const filter of filters) {
|
|
141
|
+
const result = normalizeFilter(filter, definitions);
|
|
142
|
+
if (result.error !== undefined) return { error: result.error };
|
|
143
|
+
normalized.push(result.filter);
|
|
144
|
+
}
|
|
145
|
+
groups.push(normalized);
|
|
146
|
+
}
|
|
147
|
+
return { groups };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A per-request record predicate: OR across groups, AND within a group (no groups → every record matches), then
|
|
152
|
+
* every free-text query token must be a prefix of some token in one of the `searchable` properties. Each record's
|
|
153
|
+
* property values are folded and tokenised at most once, all work is charged to `budget`, and evaluation stops as
|
|
154
|
+
* soon as the budget is exhausted (the caller must check `budget.exceeded` and fail the request).
|
|
155
|
+
*/
|
|
156
|
+
export function createRecordMatcher(groups, query, searchable, budget) {
|
|
157
|
+
const needles = [...new Set(splitTokens(String(query).toLowerCase()))];
|
|
158
|
+
return (properties) => {
|
|
159
|
+
const entries = new Map();
|
|
160
|
+
const entry = (name) => {
|
|
161
|
+
let found = entries.get(name);
|
|
162
|
+
if (found === undefined) {
|
|
163
|
+
found = { raw: own(properties, name), folded: undefined, tokens: undefined };
|
|
164
|
+
entries.set(name, found);
|
|
165
|
+
}
|
|
166
|
+
return found;
|
|
167
|
+
};
|
|
168
|
+
const folded = (item) => {
|
|
169
|
+
if (item.folded === undefined) {
|
|
170
|
+
const text = String(item.raw);
|
|
171
|
+
charge(budget, text.length);
|
|
172
|
+
item.folded = text.toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
return item.folded;
|
|
175
|
+
};
|
|
176
|
+
const tokensOf = (item) => {
|
|
177
|
+
if (item.tokens === undefined) {
|
|
178
|
+
const text = folded(item);
|
|
179
|
+
charge(budget, text.length);
|
|
180
|
+
item.tokens = splitTokens(text);
|
|
181
|
+
}
|
|
182
|
+
return item.tokens;
|
|
183
|
+
};
|
|
184
|
+
const actualOf = (definition, item) => {
|
|
185
|
+
if (!hasValue(item.raw)) return undefined;
|
|
186
|
+
if (definition.type === "number" || isDateType(definition)) {
|
|
187
|
+
charge(budget, String(item.raw).length);
|
|
188
|
+
return comparable(definition, item.raw);
|
|
189
|
+
}
|
|
190
|
+
return folded(item);
|
|
191
|
+
};
|
|
192
|
+
const matchesFilter = (filter) => {
|
|
193
|
+
const { definition, operator } = filter;
|
|
194
|
+
const item = entry(definition.name);
|
|
195
|
+
switch (operator) {
|
|
196
|
+
case "HAS_PROPERTY":
|
|
197
|
+
return hasValue(item.raw);
|
|
198
|
+
case "NOT_HAS_PROPERTY":
|
|
199
|
+
return !hasValue(item.raw);
|
|
200
|
+
case "IN":
|
|
201
|
+
case "NOT_IN": {
|
|
202
|
+
const actual = actualOf(definition, item);
|
|
203
|
+
const hit = actual !== undefined && filter.valueSet.has(actual);
|
|
204
|
+
return operator === "IN" ? hit : !hit;
|
|
205
|
+
}
|
|
206
|
+
case "CONTAINS_TOKEN":
|
|
207
|
+
case "NOT_CONTAINS_TOKEN": {
|
|
208
|
+
let hit = false;
|
|
209
|
+
if (hasValue(item.raw)) {
|
|
210
|
+
const tokens = tokensOf(item);
|
|
211
|
+
if (!charge(budget, item.folded.length + tokens.length)) return false;
|
|
212
|
+
hit = tokens.some(filter.match);
|
|
213
|
+
}
|
|
214
|
+
return operator === "CONTAINS_TOKEN" ? hit : !hit;
|
|
215
|
+
}
|
|
216
|
+
default: {
|
|
217
|
+
const actual = actualOf(definition, item);
|
|
218
|
+
const expected = filter.expected;
|
|
219
|
+
if (operator === "NEQ") return actual === undefined || expected === undefined || actual !== expected;
|
|
220
|
+
if (actual === undefined || expected === undefined) return false;
|
|
221
|
+
if (operator === "EQ") return actual === expected;
|
|
222
|
+
if (operator === "LT") return actual < expected;
|
|
223
|
+
if (operator === "LTE") return actual <= expected;
|
|
224
|
+
if (operator === "GT") return actual > expected;
|
|
225
|
+
if (operator === "GTE") return actual >= expected;
|
|
226
|
+
if (operator === "BETWEEN") return filter.high !== undefined && actual >= expected && actual <= filter.high;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
if (groups.length > 0 && !groups.some((group) => group.every((filter) => !budget.exceeded && matchesFilter(filter)))) return false;
|
|
232
|
+
if (budget.exceeded || needles.length === 0) return !budget.exceeded;
|
|
233
|
+
const unique = new Set();
|
|
234
|
+
for (const name of searchable) {
|
|
235
|
+
const item = entry(name);
|
|
236
|
+
if (hasValue(item.raw)) for (const token of tokensOf(item)) unique.add(token);
|
|
237
|
+
}
|
|
238
|
+
const haystack = [...unique].sort();
|
|
239
|
+
const steps = Math.ceil(Math.log2(haystack.length + 1)) + 1;
|
|
240
|
+
if (!charge(budget, haystack.length * steps)) return false;
|
|
241
|
+
for (const needle of needles) {
|
|
242
|
+
if (!charge(budget, needle.length * steps)) return false;
|
|
243
|
+
const candidate = haystack[lowerBound(haystack, needle)];
|
|
244
|
+
if (candidate === undefined || !candidate.startsWith(needle)) return false;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Validate `sorts` (objects or `"-name"` strings) → `{ sort }` (or `{ sort: undefined }`) or `{ error }`. */
|
|
251
|
+
export function normalizeSorts(sorts, definitions) {
|
|
252
|
+
if (sorts === undefined) return { sort: undefined };
|
|
253
|
+
if (!Array.isArray(sorts)) return { error: "sorts must be an array" };
|
|
254
|
+
if (sorts.length === 0) return { sort: undefined };
|
|
255
|
+
if (sorts.length > 1) return { error: "Only one sort is supported" };
|
|
256
|
+
const entry = sorts[0];
|
|
257
|
+
let name;
|
|
258
|
+
let direction = "ASCENDING";
|
|
259
|
+
if (typeof entry === "string") {
|
|
260
|
+
name = entry.startsWith("-") ? entry.slice(1) : entry;
|
|
261
|
+
direction = entry.startsWith("-") ? "DESCENDING" : "ASCENDING";
|
|
262
|
+
} else if (typeof entry === "object" && entry !== null) {
|
|
263
|
+
name = entry.propertyName;
|
|
264
|
+
direction = entry.direction ?? "ASCENDING";
|
|
265
|
+
}
|
|
266
|
+
if (typeof name !== "string" || name.length === 0) return { error: "sorts[0].propertyName is required" };
|
|
267
|
+
const definition = definitions.get(name);
|
|
268
|
+
if (definition === undefined) return { error: `Property "${clip(name)}" does not exist` };
|
|
269
|
+
if (direction !== "ASCENDING" && direction !== "DESCENDING") return { error: "sorts[0].direction must be ASCENDING or DESCENDING" };
|
|
270
|
+
return { sort: { definition, direction } };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sort records in place by `sort` (missing values last in both directions, ties by numeric id). Sort keys are
|
|
275
|
+
* computed once per record and charged to `budget`, so comparisons never re-fold property values.
|
|
276
|
+
*/
|
|
277
|
+
export function sortRecords(records, sort, budget) {
|
|
278
|
+
const { definition, direction } = sort;
|
|
279
|
+
const keyed = records.map((record) => {
|
|
280
|
+
const raw = own(record.properties, definition.name);
|
|
281
|
+
if (typeof raw === "string") charge(budget, raw.length);
|
|
282
|
+
return { record, key: comparable(definition, raw), id: Number(record.id) };
|
|
283
|
+
});
|
|
284
|
+
if (budget.exceeded) return records;
|
|
285
|
+
keyed.sort((left, right) => {
|
|
286
|
+
const a = left.key;
|
|
287
|
+
const b = right.key;
|
|
288
|
+
let order = 0;
|
|
289
|
+
if (a === undefined && b !== undefined) order = 1;
|
|
290
|
+
else if (b === undefined && a !== undefined) order = -1;
|
|
291
|
+
else if (a !== undefined && b !== undefined) {
|
|
292
|
+
order = a < b ? -1 : a > b ? 1 : 0;
|
|
293
|
+
if (direction === "DESCENDING") order = -order;
|
|
294
|
+
}
|
|
295
|
+
return order || left.id - right.id;
|
|
296
|
+
});
|
|
297
|
+
keyed.forEach((entry, index) => {
|
|
298
|
+
records[index] = entry.record;
|
|
299
|
+
});
|
|
300
|
+
return records;
|
|
301
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Row ids, counters, bounded scans and virtual-time formatting. Every function is pure or reads
|
|
2
|
+
// context.state; nothing keeps module-level state.
|
|
3
|
+
|
|
4
|
+
export const SCAN_STEP = 500;
|
|
5
|
+
export const SCAN_CAP = 10_000;
|
|
6
|
+
const ID_PATTERN = /^[1-9][0-9]{0,9}$/;
|
|
7
|
+
const DEFINED_AT = "2019-08-06T02:41:09.058Z";
|
|
8
|
+
|
|
9
|
+
/** The framework rejects state row ids longer than 512 characters (a lookup with one throws). */
|
|
10
|
+
export const MAX_ROW_ID = 512;
|
|
11
|
+
|
|
12
|
+
/** True when `namespacePrefix + value` is a row id the state store accepts (non-empty, no `/`, ≤ 512 chars). */
|
|
13
|
+
export function isRowKey(value, prefix = "") {
|
|
14
|
+
return typeof value === "string" && value.length > 0 && !value.includes("/") && prefix.length + value.length <= MAX_ROW_ID;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The framework caps a Tool error message at 1000 characters (longer messages fail response mapping). */
|
|
18
|
+
export const MAX_MESSAGE = 1_000;
|
|
19
|
+
const CLIP = 100;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A caller value shortened for echoing in an error message (`abc…` beyond 100 code points).
|
|
23
|
+
* The cut is on code points, never on UTF-16 units, so an astral character (emoji, rare CJK) is
|
|
24
|
+
* never split into a lone surrogate.
|
|
25
|
+
*/
|
|
26
|
+
export function clip(value, max = CLIP) {
|
|
27
|
+
const text = typeof value === "string" ? value : String(value);
|
|
28
|
+
if (text.length <= max) return text;
|
|
29
|
+
const points = Array.from(text);
|
|
30
|
+
return points.length > max ? `${points.slice(0, max).join("")}…` : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A caller value rendered as JSON for an error message: the value is clipped first and encoded
|
|
35
|
+
* afterwards, so the quoting of a long string stays balanced (`"abc…"`, never `"abc…`).
|
|
36
|
+
*/
|
|
37
|
+
export function clipJson(value, max = CLIP) {
|
|
38
|
+
if (typeof value === "string") return JSON.stringify(clip(value, max));
|
|
39
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") return clip(String(value), max);
|
|
40
|
+
if (Array.isArray(value)) return `an array of ${value.length} values`;
|
|
41
|
+
if (typeof value === "object") return "an object";
|
|
42
|
+
return clip(String(value), max);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Tool error message bounded to the framework's cap. The framework validates the message with a
|
|
47
|
+
* UTF-16 length check (`z.string().max(1000)`), so the bound is 1000 UTF-16 units, but the cut falls on
|
|
48
|
+
* a code point boundary: an astral character that would straddle the cut is dropped whole, never split
|
|
49
|
+
* into a lone surrogate. The result, ellipsis included, always fits the cap.
|
|
50
|
+
*/
|
|
51
|
+
export function clipMessage(message, max = MAX_MESSAGE) {
|
|
52
|
+
const text = typeof message === "string" ? message : String(message);
|
|
53
|
+
if (text.length <= max) return text;
|
|
54
|
+
let end = 0;
|
|
55
|
+
for (const point of text) {
|
|
56
|
+
if (end + point.length > max - 1) break;
|
|
57
|
+
end += point.length;
|
|
58
|
+
}
|
|
59
|
+
return `${text.slice(0, end)}…`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fail(context, code, message, details) {
|
|
63
|
+
const text = clipMessage(message);
|
|
64
|
+
return context.fail(details === undefined ? { code, message: text } : { code, message: text, details });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function notFound(context, message = "resource not found") {
|
|
68
|
+
return fail(context, "NOT_FOUND", message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** HubSpot's validation envelope: one message plus one `errors[]` entry per offending field. */
|
|
72
|
+
export function validationError(context, message, errors) {
|
|
73
|
+
return fail(context, "VALIDATION_ERROR", message, { errors: errors ?? [{ message }] });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function tooLarge(context) {
|
|
77
|
+
return fail(context, "VALIDATION_ERROR", "The result set is too large for this Tool (more than 10000 rows).");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isObjectId(value) {
|
|
81
|
+
return typeof value === "string" && ID_PATTERN.test(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function padId(id) {
|
|
85
|
+
return String(id).padStart(10, "0");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function associationRowId(fromTypeId, fromId, toTypeId, toId) {
|
|
89
|
+
return `${fromTypeId}/${padId(fromId)}/${toTypeId}/${padId(toId)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function associationPrefix(fromTypeId, fromId, toTypeId) {
|
|
93
|
+
return `${fromTypeId}/${padId(fromId)}/${toTypeId}/`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every row whose id starts with `prefix` (in row-id order), through bounded scans. */
|
|
97
|
+
export function prefixRows(context, namespace, prefix) {
|
|
98
|
+
const rows = [];
|
|
99
|
+
let after = prefix;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const batch = context.state.scan(namespace, { afterRowId: after, limit: SCAN_STEP });
|
|
102
|
+
if (batch.length === 0) return rows;
|
|
103
|
+
for (const record of batch) {
|
|
104
|
+
if (!record.rowId.startsWith(prefix)) return rows;
|
|
105
|
+
after = record.rowId;
|
|
106
|
+
rows.push(record.value);
|
|
107
|
+
if (rows.length >= SCAN_CAP) return tooLarge(context);
|
|
108
|
+
}
|
|
109
|
+
if (batch.length < SCAN_STEP) return rows;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every row of a namespace in row-id order (bounded). */
|
|
114
|
+
export function allRows(context, namespace) {
|
|
115
|
+
const rows = [];
|
|
116
|
+
let after;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const batch = context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
|
|
119
|
+
if (batch.length === 0) return rows;
|
|
120
|
+
for (const record of batch) {
|
|
121
|
+
after = record.rowId;
|
|
122
|
+
rows.push(record.value);
|
|
123
|
+
if (rows.length >= SCAN_CAP) return tooLarge(context);
|
|
124
|
+
}
|
|
125
|
+
if (batch.length < SCAN_STEP) return rows;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** ISO-8601 with milliseconds, as HubSpot renders timestamps (`2026-09-14T09:00:00.000Z`). */
|
|
130
|
+
export function isoFromUs(us) {
|
|
131
|
+
return new Date(Math.floor(us / 1_000)).toISOString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isoNow(context) {
|
|
135
|
+
return isoFromUs(context.clock.nowUs());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function definedAt() {
|
|
139
|
+
return DEFINED_AT;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Strict, host-independent datetime parsing. HubSpot accepts epoch milliseconds or ISO 8601; `Date.parse`
|
|
143
|
+
// is never used because it reads zone-less strings in the host time zone and accepts engine-specific formats.
|
|
144
|
+
const EPOCH_MS = /^[0-9]{1,15}$/;
|
|
145
|
+
const ISO_8601 =
|
|
146
|
+
/^([0-9]{4})-([0-9]{2})-([0-9]{2})(?:[Tt ]([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:[.,]([0-9]{1,9}))?)?(Z|z|[+-][0-9]{2}(?::?[0-9]{2})?)?)?$/;
|
|
147
|
+
// HubSpot renders datetimes as four-digit-year ISO strings: accept 0000-01-01T00:00:00.000Z … 9999-12-31T23:59:59.999Z.
|
|
148
|
+
const MIN_EPOCH_MS = -62_167_219_200_000;
|
|
149
|
+
const MAX_EPOCH_MS = 253_402_300_799_999;
|
|
150
|
+
|
|
151
|
+
function daysInMonth(year, month) {
|
|
152
|
+
if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
|
|
153
|
+
return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Epoch milliseconds for an epoch-ms digit string or an ISO-8601 date/datetime; undefined otherwise.
|
|
158
|
+
* A date-only value is midnight UTC and a datetime without a zone designator is read as UTC, so the same
|
|
159
|
+
* input yields the same instant on every host. Impossible calendar values (month 13, 31 April, 24:00) are rejected.
|
|
160
|
+
*/
|
|
161
|
+
export function parseMillis(value) {
|
|
162
|
+
if (typeof value !== "string") return undefined;
|
|
163
|
+
const text = value.trim();
|
|
164
|
+
if (EPOCH_MS.test(text)) {
|
|
165
|
+
const epoch = Number(text);
|
|
166
|
+
return epoch > MAX_EPOCH_MS ? undefined : epoch;
|
|
167
|
+
}
|
|
168
|
+
const match = ISO_8601.exec(text);
|
|
169
|
+
if (match === null) return undefined;
|
|
170
|
+
const [, y, mo, d, h = "00", mi = "00", s = "00", fraction = "", zone = "Z"] = match;
|
|
171
|
+
const year = Number(y);
|
|
172
|
+
const month = Number(mo);
|
|
173
|
+
const day = Number(d);
|
|
174
|
+
const hour = Number(h);
|
|
175
|
+
const minute = Number(mi);
|
|
176
|
+
const second = Number(s);
|
|
177
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month) || hour > 23 || minute > 59 || second > 59) return undefined;
|
|
178
|
+
let offsetMinutes = 0;
|
|
179
|
+
if (zone !== "Z" && zone !== "z") {
|
|
180
|
+
const digits = zone.slice(1).replace(":", "");
|
|
181
|
+
const offsetHours = Number(digits.slice(0, 2));
|
|
182
|
+
const offsetMins = digits.length > 2 ? Number(digits.slice(2, 4)) : 0;
|
|
183
|
+
if (offsetHours > 18 || offsetMins > 59) return undefined;
|
|
184
|
+
offsetMinutes = (zone[0] === "-" ? -1 : 1) * (offsetHours * 60 + offsetMins);
|
|
185
|
+
}
|
|
186
|
+
const millis = Number(fraction.padEnd(3, "0").slice(0, 3));
|
|
187
|
+
const instant = Date.UTC(2000, 0, 1, 0, 0, 0, 0) + utcDayOffset(year, month, day) * 86_400_000 + ((hour * 60 + minute - offsetMinutes) * 60 + second) * 1_000 + millis;
|
|
188
|
+
return instant < MIN_EPOCH_MS || instant > MAX_EPOCH_MS ? undefined : instant;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whole days from 2000-01-01 to the given proleptic Gregorian date (pure arithmetic, no Date parsing). */
|
|
192
|
+
function utcDayOffset(year, month, day) {
|
|
193
|
+
const y = month <= 2 ? year - 1 : year;
|
|
194
|
+
const era = Math.floor(y / 400);
|
|
195
|
+
const yoe = y - era * 400;
|
|
196
|
+
const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
|
|
197
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
198
|
+
return era * 146_097 + doe - 719_468 - 10_957;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Epoch-millisecond digit strings and ISO-8601 dates/datetimes → `YYYY-MM-DDTHH:MM:SS.sssZ`; undefined when invalid. */
|
|
202
|
+
export function normalizeDateTime(value) {
|
|
203
|
+
const ms = parseMillis(value);
|
|
204
|
+
return ms === undefined ? undefined : new Date(ms).toISOString();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The first row of a namespace (row-id order) matching `predicate`, or null. Pages through the whole
|
|
209
|
+
* namespace in bounded scans; it never truncates, because it returns a single row rather than a result set.
|
|
210
|
+
*/
|
|
211
|
+
export function findRow(context, namespace, predicate) {
|
|
212
|
+
let after;
|
|
213
|
+
for (;;) {
|
|
214
|
+
const batch = context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
|
|
215
|
+
for (const record of batch) {
|
|
216
|
+
if (predicate(record.value)) return record.value;
|
|
217
|
+
after = record.rowId;
|
|
218
|
+
}
|
|
219
|
+
if (batch.length < SCAN_STEP) return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const DEFAULT_COUNTERS = { nextObjectId: 1 };
|
|
224
|
+
|
|
225
|
+
export function counters(context) {
|
|
226
|
+
const stored = context.state.get("meta", "counters");
|
|
227
|
+
return stored === null ? { ...DEFAULT_COUNTERS } : { ...stored };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The next free object id. Starter data normally seeds `meta/counters`; when it is absent the counter
|
|
232
|
+
* starts at 1 and skips ids that already exist in any object namespace (bounded probe).
|
|
233
|
+
*/
|
|
234
|
+
export function nextObjectId(context, namespaces) {
|
|
235
|
+
const meta = counters(context);
|
|
236
|
+
let id = meta.nextObjectId;
|
|
237
|
+
for (let probe = 0; probe < 1_000; probe += 1) {
|
|
238
|
+
const taken = namespaces.some((namespace) => context.state.get(namespace, padId(id)) !== null);
|
|
239
|
+
if (!taken) break;
|
|
240
|
+
id += 1;
|
|
241
|
+
}
|
|
242
|
+
context.state.put("meta", "counters", { ...meta, nextObjectId: id + 1 });
|
|
243
|
+
return String(id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function compareStrings(left, right) {
|
|
247
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
248
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// HubSpot REST wire helpers: query parsing, the error envelope and the rate-limit headers. Pure
|
|
2
|
+
// functions only, so the HTTP codecs can use them without state or a clock.
|
|
3
|
+
|
|
4
|
+
import { assertJsonDepth } from "./json-depth.mjs";
|
|
5
|
+
|
|
6
|
+
const OAUTH_DOCS = "https://developers.hubspot.com/docs/methods/auth/oauth-overview";
|
|
7
|
+
|
|
8
|
+
function last(values) {
|
|
9
|
+
return values === undefined || values.length === 0 ? undefined : values[values.length - 1];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function str(query, name) {
|
|
13
|
+
return last(query[name]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function int(query, name) {
|
|
17
|
+
const value = last(query[name]);
|
|
18
|
+
if (value === undefined) return undefined;
|
|
19
|
+
// A malformed value is passed through as its raw string; the handler rejects it with VALIDATION_ERROR.
|
|
20
|
+
return /^-?[0-9]{1,9}$/.test(value) ? Number(value) : value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bool(query, name) {
|
|
24
|
+
const value = last(query[name]);
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
if (value === "true") return true;
|
|
27
|
+
if (value === "false") return false;
|
|
28
|
+
return value; // raw string: the handler rejects it with VALIDATION_ERROR
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Comma-separated (and repeatable) query parameter → string array, or undefined when absent. */
|
|
32
|
+
export function list(query, name) {
|
|
33
|
+
const values = query[name];
|
|
34
|
+
if (values === undefined || values.length === 0) return undefined;
|
|
35
|
+
const items = [];
|
|
36
|
+
for (const value of values) for (const part of value.split(",")) if (part.trim().length > 0) items.push(part.trim());
|
|
37
|
+
return items;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function defined(object) {
|
|
41
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function jsonBody(request) {
|
|
45
|
+
const value = request.body.kind === "json" ? request.body.value : undefined;
|
|
46
|
+
assertJsonDepth(value);
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function jsonArrayBody(request) {
|
|
51
|
+
const value = request.body.kind === "json" ? request.body.value : undefined;
|
|
52
|
+
assertJsonDepth(value);
|
|
53
|
+
return Array.isArray(value) ? value : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mutations accept an optional X-Firedrill-Idempotency-Key header (HubSpot clients never send one). */
|
|
57
|
+
export function operationInput(request, args) {
|
|
58
|
+
const key = last(request.headers["x-firedrill-idempotency-key"]);
|
|
59
|
+
return key === undefined || key.length === 0 ? { arguments: args } : { arguments: args, idempotencyKey: key };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isRateLimited(outcome) {
|
|
63
|
+
return outcome.status === "tool_error" && outcome.error?.code === "tool.RATE_LIMITED";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Constant decoration headers; under the rate-limit fault `Remaining` drops to 0 and Retry-After appears. */
|
|
67
|
+
export function responseHeaders(correlationId, limited) {
|
|
68
|
+
return {
|
|
69
|
+
"x-hubspot-correlation-id": correlationId,
|
|
70
|
+
"x-hubspot-ratelimit-secondly": "100",
|
|
71
|
+
"x-hubspot-ratelimit-secondly-remaining": limited ? "0" : "99",
|
|
72
|
+
"x-hubspot-ratelimit-daily": "250000",
|
|
73
|
+
"x-hubspot-ratelimit-daily-remaining": limited ? "249000" : "249999",
|
|
74
|
+
"x-hubspot-ratelimit-interval-milliseconds": "10000",
|
|
75
|
+
...(limited ? { "retry-after": "1" } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const CATEGORIES = {
|
|
80
|
+
UNAUTHORIZED: "INVALID_AUTHENTICATION",
|
|
81
|
+
MISSING_SCOPES: "MISSING_SCOPES",
|
|
82
|
+
NOT_FOUND: "OBJECT_NOT_FOUND",
|
|
83
|
+
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
84
|
+
CONFLICT: "CONFLICT",
|
|
85
|
+
RATE_LIMITED: "RATE_LIMITS",
|
|
86
|
+
SERVICE_UNAVAILABLE: "INTERNAL_ERROR",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** HubSpot error envelope for a non-ok outcome; status codes are framework-owned (declared per route). */
|
|
90
|
+
export function hubspotError(outcome, correlationId) {
|
|
91
|
+
const error = outcome.error ?? {};
|
|
92
|
+
const message = typeof error.message === "string" ? error.message : "";
|
|
93
|
+
if (outcome.status === "denied") {
|
|
94
|
+
return {
|
|
95
|
+
status: "error",
|
|
96
|
+
message: "This operation is not granted to the calling actor in this Firedrill world.",
|
|
97
|
+
correlationId,
|
|
98
|
+
category: "MISSING_SCOPES",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (outcome.status === "unsupported") {
|
|
102
|
+
return { status: "error", message: "resource not found", correlationId, category: "OBJECT_NOT_FOUND" };
|
|
103
|
+
}
|
|
104
|
+
if (outcome.status === "invalid") {
|
|
105
|
+
return {
|
|
106
|
+
status: "error",
|
|
107
|
+
message: message.length > 0 ? `Invalid input JSON: ${message}` : "Invalid input JSON",
|
|
108
|
+
correlationId,
|
|
109
|
+
category: "VALIDATION_ERROR",
|
|
110
|
+
context: { firedrill: [String(error.code ?? "invalid")] },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const code = String(error.code ?? "").replace(/^tool\./, "");
|
|
114
|
+
const body = {
|
|
115
|
+
status: "error",
|
|
116
|
+
message: message.length > 0 ? message : "internal error",
|
|
117
|
+
correlationId,
|
|
118
|
+
category: Object.hasOwn(CATEGORIES, code) ? CATEGORIES[code] : "INTERNAL_ERROR",
|
|
119
|
+
};
|
|
120
|
+
const details = typeof error.details === "object" && error.details !== null ? error.details : {};
|
|
121
|
+
if (Array.isArray(details.errors)) body.errors = details.errors;
|
|
122
|
+
if (typeof details.context === "object" && details.context !== null) body.context = details.context;
|
|
123
|
+
if (code === "UNAUTHORIZED") body.links = { "oauth-overview": OAUTH_DOCS };
|
|
124
|
+
return body;
|
|
125
|
+
}
|