@assembly-js/app-foundation 0.0.2 → 0.0.4
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/CLAUDE.md +31 -18
- package/bin/check-route-reachability.mjs +129 -0
- package/bin/check-token-usage.mjs +128 -0
- package/bin/push-schema.mjs +70 -0
- package/bin/validate-manifest.mjs +1151 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/pagination.d.ts +4 -0
- package/dist/pagination.js +40 -0
- package/package.json +5 -3
- package/skills/assembly-ai/SKILL.md +1 -1
- package/skills/assembly-automations/SKILL.md +34 -4
- package/skills/assembly-client-users/SKILL.md +7 -3
- package/skills/assembly-companies/SKILL.md +41 -1
- package/skills/assembly-internal-users/SKILL.md +6 -2
- package/skills/assembly-native-resources/SKILL.md +4 -2
- package/skills/assembly-notifications/SKILL.md +6 -8
- package/skills/assembly-user-company-selector/SKILL.md +3 -1
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validates .assembly/app.json shape at build time so malformed or misplaced
|
|
3
|
+
// manifest keys fail the build loudly instead of being silently dropped at
|
|
4
|
+
// deploy (where unknown keys vanish and surface URLs end up empty). Dependency
|
|
5
|
+
// free on purpose: it runs inside the generated app's sandbox before any deps
|
|
6
|
+
// beyond Node are guaranteed.
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const MANIFEST = join(process.cwd(), '.assembly', 'app.json');
|
|
11
|
+
// requiredIntegrations and notificationSettings are parsed by the platform's
|
|
12
|
+
// deploy pipeline; rejecting them here would fail builds for manifests the
|
|
13
|
+
// platform understands.
|
|
14
|
+
const TOP_LEVEL_KEYS = ['surfaces', 'requiredSecrets', 'requiredIntegrations', 'notificationSettings'];
|
|
15
|
+
const SURFACE_KEYS = ['internal', 'client', 'notification'];
|
|
16
|
+
const NOTIFICATION_SURFACES = ['product', 'email'];
|
|
17
|
+
const MAX_NOTIFICATION_SETTINGS = 50;
|
|
18
|
+
// Setting ids are permanent: they key each internal user's saved toggles and
|
|
19
|
+
// are passed as notificationSettingId on createNotification, so the validator
|
|
20
|
+
// requires stable author-chosen ids instead of letting the platform generate
|
|
21
|
+
// ones that would rotate on every deploy.
|
|
22
|
+
const KEBAB_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
23
|
+
|
|
24
|
+
function fail(lines) {
|
|
25
|
+
console.error('');
|
|
26
|
+
console.error('.assembly/app.json is invalid:');
|
|
27
|
+
console.error('');
|
|
28
|
+
for (const l of lines) console.error(' - ' + l);
|
|
29
|
+
console.error('');
|
|
30
|
+
console.error('Expected shape:');
|
|
31
|
+
console.error(' {');
|
|
32
|
+
console.error(' "surfaces": { "internal": { "path": "/" }, "client": { "path": "/c" }, "notification": { "path": "/n" } },');
|
|
33
|
+
console.error(' "requiredSecrets": [{ "key": "STRIPE_KEY", "label": "Stripe secret key" }],');
|
|
34
|
+
console.error(' "requiredIntegrations": [{ "provider": "slack", "scopes": ["chat:write"] }]');
|
|
35
|
+
console.error(' }');
|
|
36
|
+
console.error('Only "internal" is required. "client" and "notification" are optional and MUST live under "surfaces".');
|
|
37
|
+
console.error('Optional top-level keys: "requiredSecrets", "requiredIntegrations", "notificationSettings" (schemas in .claude/skills).');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!existsSync(MANIFEST)) {
|
|
42
|
+
// A missing manifest is handled at deploy time (internal-only fallback); it
|
|
43
|
+
// is not a build error. This check targets present-but-malformed manifests.
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFileSync(MANIFEST, 'utf8');
|
|
50
|
+
} catch (e) {
|
|
51
|
+
fail(['could not read the file: ' + e.message]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let doc;
|
|
55
|
+
try {
|
|
56
|
+
doc = JSON.parse(raw);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
fail(['not valid JSON: ' + e.message]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
62
|
+
fail(['the top level must be a JSON object']);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const errors = [];
|
|
66
|
+
|
|
67
|
+
// Unknown or misplaced top-level keys. A surface name appearing at the top
|
|
68
|
+
// level is the exact mistake that silently broke notification links.
|
|
69
|
+
for (const key of Object.keys(doc)) {
|
|
70
|
+
if (TOP_LEVEL_KEYS.includes(key)) continue;
|
|
71
|
+
if (SURFACE_KEYS.includes(key)) {
|
|
72
|
+
errors.push(
|
|
73
|
+
'"' +
|
|
74
|
+
key +
|
|
75
|
+
'" is at the top level but is a surface — move it under "surfaces" (i.e. surfaces.' +
|
|
76
|
+
key +
|
|
77
|
+
').',
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
errors.push(
|
|
81
|
+
'unknown top-level key "' + key + '" (allowed: ' + TOP_LEVEL_KEYS.join(', ') + ').',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// surfaces
|
|
87
|
+
if (doc.surfaces === undefined) {
|
|
88
|
+
errors.push('"surfaces" is required (must declare at least surfaces.internal).');
|
|
89
|
+
} else {
|
|
90
|
+
const s = doc.surfaces;
|
|
91
|
+
if (typeof s !== 'object' || s === null || Array.isArray(s)) {
|
|
92
|
+
errors.push('"surfaces" must be an object.');
|
|
93
|
+
} else {
|
|
94
|
+
for (const key of Object.keys(s)) {
|
|
95
|
+
if (!SURFACE_KEYS.includes(key)) {
|
|
96
|
+
errors.push(
|
|
97
|
+
'unknown surface "' + key + '" under "surfaces" (allowed: ' + SURFACE_KEYS.join(', ') + ').',
|
|
98
|
+
);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const entry = s[key];
|
|
102
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
103
|
+
errors.push('surfaces.' + key + ' must be an object like { "path": "/..." }.');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
107
|
+
errors.push('surfaces.' + key + '.path is required and must be a non-empty string.');
|
|
108
|
+
} else if (entry.path[0] !== '/') {
|
|
109
|
+
errors.push('surfaces.' + key + '.path must start with "/" (got "' + entry.path + '").');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (s.internal === undefined) {
|
|
113
|
+
errors.push('surfaces.internal is required — every app must declare a dashboard surface.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// requiredSecrets (optional; mirrors the deploy-side key/label requirement)
|
|
119
|
+
if (doc.requiredSecrets !== undefined) {
|
|
120
|
+
const rs = doc.requiredSecrets;
|
|
121
|
+
if (!Array.isArray(rs)) {
|
|
122
|
+
errors.push('"requiredSecrets" must be an array.');
|
|
123
|
+
} else {
|
|
124
|
+
rs.forEach((item, i) => {
|
|
125
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
126
|
+
errors.push('requiredSecrets[' + i + '] must be an object with "key" and "label".');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof item.key !== 'string' || item.key.trim() === '') {
|
|
130
|
+
errors.push('requiredSecrets[' + i + '].key is required and must be a non-empty string.');
|
|
131
|
+
}
|
|
132
|
+
if (typeof item.label !== 'string' || item.label.trim() === '') {
|
|
133
|
+
errors.push('requiredSecrets[' + i + '].label is required and must be a non-empty string.');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// notificationSettings (optional; mirrors POST /v1/installs/{installId}/notification-settings)
|
|
140
|
+
if (doc.notificationSettings !== undefined) {
|
|
141
|
+
const ns = doc.notificationSettings;
|
|
142
|
+
if (typeof ns !== 'object' || ns === null || Array.isArray(ns)) {
|
|
143
|
+
errors.push('"notificationSettings" must be an object like { "notifications": [ ... ] }.');
|
|
144
|
+
} else {
|
|
145
|
+
for (const key of Object.keys(ns)) {
|
|
146
|
+
if (!['notifications', 'actionLabel'].includes(key)) {
|
|
147
|
+
errors.push('unknown key "notificationSettings.' + key + '" (allowed: notifications, actionLabel).');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(ns.notifications) || ns.notifications.length === 0) {
|
|
151
|
+
errors.push('notificationSettings.notifications must be a non-empty array of settings.');
|
|
152
|
+
} else {
|
|
153
|
+
if (ns.notifications.length > MAX_NOTIFICATION_SETTINGS) {
|
|
154
|
+
errors.push(
|
|
155
|
+
'notificationSettings.notifications has ' +
|
|
156
|
+
ns.notifications.length +
|
|
157
|
+
' entries; the platform allows at most ' +
|
|
158
|
+
MAX_NOTIFICATION_SETTINGS +
|
|
159
|
+
'.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
ns.notifications.forEach((setting, i) => {
|
|
164
|
+
const at = 'notificationSettings.notifications[' + i + ']';
|
|
165
|
+
if (typeof setting !== 'object' || setting === null || Array.isArray(setting)) {
|
|
166
|
+
errors.push(at + ' must be an object with "id", "label", and "surfaces".');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (typeof setting.id !== 'string' || !KEBAB_ID.test(setting.id)) {
|
|
170
|
+
errors.push(
|
|
171
|
+
at +
|
|
172
|
+
'.id is required and must be kebab-case (e.g. "task-completed"). Ids are permanent — ' +
|
|
173
|
+
"they key each internal user's saved toggles and are passed as notificationSettingId " +
|
|
174
|
+
'when creating notifications.',
|
|
175
|
+
);
|
|
176
|
+
} else if (seen.has(setting.id)) {
|
|
177
|
+
errors.push(at + '.id "' + setting.id + '" is declared more than once — ids must be unique.');
|
|
178
|
+
} else {
|
|
179
|
+
seen.add(setting.id);
|
|
180
|
+
}
|
|
181
|
+
if (typeof setting.label !== 'string' || setting.label.trim() === '' || setting.label.length > 100) {
|
|
182
|
+
errors.push(at + '.label is required and must be a non-empty string of at most 100 characters.');
|
|
183
|
+
}
|
|
184
|
+
const surfaces = Array.isArray(setting.surfaces) ? setting.surfaces : null;
|
|
185
|
+
if (
|
|
186
|
+
!surfaces ||
|
|
187
|
+
surfaces.length === 0 ||
|
|
188
|
+
surfaces.some((v) => !NOTIFICATION_SURFACES.includes(v)) ||
|
|
189
|
+
new Set(surfaces).size !== surfaces.length
|
|
190
|
+
) {
|
|
191
|
+
errors.push(
|
|
192
|
+
at + '.surfaces must be a non-empty array of unique values from: ' + NOTIFICATION_SURFACES.join(', ') + '.',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (setting.default !== undefined) {
|
|
196
|
+
if (typeof setting.default !== 'object' || setting.default === null || Array.isArray(setting.default)) {
|
|
197
|
+
errors.push(at + '.default must be an object like { "product": true, "email": false }.');
|
|
198
|
+
} else {
|
|
199
|
+
for (const key of Object.keys(setting.default)) {
|
|
200
|
+
if (!NOTIFICATION_SURFACES.includes(key)) {
|
|
201
|
+
errors.push(at + '.default has unknown key "' + key + '" (allowed: product, email).');
|
|
202
|
+
} else if (typeof setting.default[key] !== 'boolean') {
|
|
203
|
+
errors.push(at + '.default.' + key + ' must be a boolean.');
|
|
204
|
+
} else if (setting.default[key] === true && surfaces && !surfaces.includes(key)) {
|
|
205
|
+
errors.push(
|
|
206
|
+
at +
|
|
207
|
+
'.default.' +
|
|
208
|
+
key +
|
|
209
|
+
' is true but "' +
|
|
210
|
+
key +
|
|
211
|
+
'" is not in ' +
|
|
212
|
+
at +
|
|
213
|
+
'.surfaces — a surface can only default on when the setting declares it.',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (ns.actionLabel !== undefined) {
|
|
222
|
+
const al = ns.actionLabel;
|
|
223
|
+
if (typeof al !== 'object' || al === null || Array.isArray(al)) {
|
|
224
|
+
errors.push('notificationSettings.actionLabel must be an object with "verb", "singularNoun", and "pluralNoun".');
|
|
225
|
+
} else {
|
|
226
|
+
for (const field of ['verb', 'singularNoun', 'pluralNoun']) {
|
|
227
|
+
if (typeof al[field] !== 'string' || al[field].trim() === '') {
|
|
228
|
+
errors.push(
|
|
229
|
+
'notificationSettings.actionLabel.' +
|
|
230
|
+
field +
|
|
231
|
+
' is required and must be a non-empty string (or omit actionLabel entirely).',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const key of Object.keys(al)) {
|
|
236
|
+
if (!['verb', 'singularNoun', 'pluralNoun'].includes(key)) {
|
|
237
|
+
errors.push('notificationSettings.actionLabel has unknown key "' + key + '".');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// requiredIntegrations (optional; mirrors the deploy-side structural rules —
|
|
246
|
+
// the provider whitelist itself is enforced at deploy from the live catalog).
|
|
247
|
+
// connectionScope/cardinality are reserved hinges: only their v1 values pass,
|
|
248
|
+
// so a manifest authored today can't change meaning when later phases land.
|
|
249
|
+
if (doc.requiredIntegrations !== undefined) {
|
|
250
|
+
const ri = doc.requiredIntegrations;
|
|
251
|
+
const ENTRY_KEYS = ['provider', 'scopes', 'connectionScope', 'cardinality'];
|
|
252
|
+
if (!Array.isArray(ri)) {
|
|
253
|
+
errors.push('"requiredIntegrations" must be an array.');
|
|
254
|
+
} else {
|
|
255
|
+
ri.forEach((item, i) => {
|
|
256
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
257
|
+
errors.push('requiredIntegrations[' + i + '] must be an object with a "provider".');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const key of Object.keys(item)) {
|
|
261
|
+
if (!ENTRY_KEYS.includes(key)) {
|
|
262
|
+
errors.push('requiredIntegrations[' + i + '] has unknown key "' + key + '" (allowed: ' + ENTRY_KEYS.join(', ') + ').');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (typeof item.provider !== 'string' || item.provider.trim() === '') {
|
|
266
|
+
errors.push('requiredIntegrations[' + i + '].provider is required and must be a non-empty string.');
|
|
267
|
+
}
|
|
268
|
+
if (item.scopes !== undefined) {
|
|
269
|
+
if (!Array.isArray(item.scopes) || item.scopes.some((sc) => typeof sc !== 'string' || sc.trim() === '')) {
|
|
270
|
+
errors.push('requiredIntegrations[' + i + '].scopes must be an array of non-empty strings.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (item.connectionScope !== undefined && item.connectionScope !== 'workspace') {
|
|
274
|
+
errors.push('requiredIntegrations[' + i + '].connectionScope "' + item.connectionScope + '" is not supported: only "workspace" (per-user connections are not yet available).');
|
|
275
|
+
}
|
|
276
|
+
if (item.cardinality !== undefined && item.cardinality !== 'single') {
|
|
277
|
+
errors.push('requiredIntegrations[' + i + '].cardinality "' + item.cardinality + '" is not supported: only "single" (multiple connections per provider are not yet available).');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (errors.length > 0) fail(errors);
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// .assembly/api-resources.json — the app's API contract (automation triggers,
|
|
287
|
+
// actions, webhook events). A missing file means the app registers no API and
|
|
288
|
+
// is always valid. When present, every rule below mirrors the platform's
|
|
289
|
+
// publish-gate parser (backend/lib/resources/app_builder/logic/
|
|
290
|
+
// app_api_resources.go) so the agent gets the same errors in its own build
|
|
291
|
+
// loop instead of at publish. The data tables between the GENERATED markers
|
|
292
|
+
// are rendered from the Go rules (drift fails the platform's unit tests); the
|
|
293
|
+
// procedural checks hand-mirror the parser's logic.
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
// BEGIN GENERATED FROM GO - DO NOT EDIT (rendered by validator_rules_golden_unit_test.go; regenerate: make generate-validator-rules)
|
|
297
|
+
|
|
298
|
+
const API_RESOURCES = join(process.cwd(), '.assembly', 'api-resources.json');
|
|
299
|
+
|
|
300
|
+
const API_RESOURCES_MAX_FILE_BYTES = 131072;
|
|
301
|
+
const MAX_RECORDS = 20;
|
|
302
|
+
const MAX_APIS_PER_RECORD = 25;
|
|
303
|
+
const MAX_WEBHOOKS_PER_RECORD = 25;
|
|
304
|
+
const MAX_SCHEMA_FIELDS = 50;
|
|
305
|
+
const MAX_SCHEMA_NESTING_DEPTH = 3;
|
|
306
|
+
const MAX_QUERY_PARAMS = 20;
|
|
307
|
+
const MAX_NAME_LENGTH = 128;
|
|
308
|
+
const MAX_DESC_LENGTH = 512;
|
|
309
|
+
const MAX_ACTION_DESC_LENGTH = 40;
|
|
310
|
+
const MAX_PATH_LENGTH = 512;
|
|
311
|
+
|
|
312
|
+
const RESOURCE_NAME_RE = /^[a-z][a-zA-Z0-9]{1,63}$/;
|
|
313
|
+
const EVENT_NAME_RE = /^[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
|
|
314
|
+
const API_PATH_RE = /^(\/[a-z0-9\-_]+)+(\/:id)?$/;
|
|
315
|
+
|
|
316
|
+
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
317
|
+
const ALLOWED_OUTPUT_TYPES = ['object', 'list', 'delete', 'void'];
|
|
318
|
+
const ALLOWED_INPUT_TYPES = ['object', 'list'];
|
|
319
|
+
const ALLOWED_QUERY_PARAM_TYPES = ['string', 'int', 'boolean'];
|
|
320
|
+
const ALLOWED_FIELD_TYPES = ['string', 'number', 'bool', 'object', 'array'];
|
|
321
|
+
const ALLOWED_UI_TYPES = ['textInput', 'textArea', 'dropdown', 'numberInput', 'dateInput'];
|
|
322
|
+
const ALLOWED_FIELD_OPERATORS = ['exists', 'notExists', 'equals', 'notEquals', 'startsWith', 'includes'];
|
|
323
|
+
// The proxy's legacy fallback matches these by SUBSTRING, so a path merely
|
|
324
|
+
// containing one would be misrouted to the platform's Tasks app.
|
|
325
|
+
const RESERVED_TASK_SEGMENTS = ['tasks', 'task-templates', 'comments'];
|
|
326
|
+
// Automations read recipient ids from payloads by exact camelCase name.
|
|
327
|
+
const RECIPIENT_FIELD_NAMES = ['clientId', 'companyId', 'internalUserId'];
|
|
328
|
+
const DROPDOWN_SOURCE_INPUT_TYPES = ['client', 'company', 'internalUser', 'allClients', 'allCompanies', 'allInternalUsers'];
|
|
329
|
+
const DROPDOWN_SOURCE_RECIPIENT_FIELDS = {
|
|
330
|
+
client: 'clientId',
|
|
331
|
+
company: 'companyId',
|
|
332
|
+
internalUser: 'internalUserId',
|
|
333
|
+
allClients: 'clientId',
|
|
334
|
+
allCompanies: 'companyId',
|
|
335
|
+
allInternalUsers: 'internalUserId',
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Design-system icon names a trigger.icon may use — the only names the
|
|
339
|
+
// automations UI renders (lib/ai's curated DSIconNames set).
|
|
340
|
+
const TRIGGER_ICON_NAMES = [
|
|
341
|
+
'Info', 'InfoSolid', 'Search', 'ResetZoom', 'FitToWidth',
|
|
342
|
+
'Ellipsis', 'MoreVertical', 'Download', 'Upload', 'Edit',
|
|
343
|
+
'Plus', 'Minus', 'Trash', 'ChevronRight', 'ChevronUp',
|
|
344
|
+
'ChevronDown', 'ChevronLeft', 'Copy', 'Duplicate', 'Automation',
|
|
345
|
+
'Play', 'Pause', 'Scale', 'Form', 'Message',
|
|
346
|
+
'Contract', 'Profile', 'Building', 'InvoicePaid', 'EditSolid',
|
|
347
|
+
'Archive', 'Unarchive', 'Settings', 'SettingsSolid', 'Link',
|
|
348
|
+
'Attachment', 'Close', 'Cancel', 'Smile', 'Share',
|
|
349
|
+
'Reply', 'Calendar', 'At', 'List', 'NumberedList',
|
|
350
|
+
'UnorderedList', 'Checklist', 'Tasks', 'Filter', 'Templates',
|
|
351
|
+
'Star', 'Email', 'EmailUnread', 'EmailRead', 'H1',
|
|
352
|
+
'H2', 'H3', 'Text', 'Bold', 'Italicize',
|
|
353
|
+
'Underline', 'Strikethrough', 'Menu', 'Inbox', 'Home',
|
|
354
|
+
'Notification', 'Billing', 'Dollar', 'Helpdesk', 'AppSetup',
|
|
355
|
+
'Gift', 'Location', 'Number', 'MobileNumber', 'Bank',
|
|
356
|
+
'Puzzle', 'Code', 'ArrowUpSolid', 'ArrowDownSolid', 'ArrowLeft',
|
|
357
|
+
'ArrowRight', 'ArrowNE', 'Dash', 'Compose', 'MassFileShare',
|
|
358
|
+
'Warning', 'WarningSolid', 'Failed', 'FailedSolid', 'Check',
|
|
359
|
+
'Success', 'SuccessSolid', 'New', 'ToDo', 'InProgress',
|
|
360
|
+
'Book', 'Send', 'SendFilled', 'Dot', 'SquareSolid',
|
|
361
|
+
'Eye', 'EyeHidden', 'Invoice', 'Comment', 'Invite',
|
|
362
|
+
'Spinner', 'Repeat', 'Web', 'Time', 'GraphBarSolid',
|
|
363
|
+
'Sidebar', 'SidebarFilled', 'Export', 'File', 'MP4',
|
|
364
|
+
'SVG', 'MOV', 'MP3', 'CSV', 'Excel',
|
|
365
|
+
'PNG', 'PDF', 'JPG', 'Doc', 'ZIP',
|
|
366
|
+
'Movie', 'GIF', 'Store', 'ShoppingBag', 'DragDrop',
|
|
367
|
+
'Image', 'ImageMissing', 'LogOut', 'Lead', 'QuestionMark',
|
|
368
|
+
'API', 'PlansPayments', 'Customization', 'Teams', 'AI',
|
|
369
|
+
'BracketsCurly', 'Callout', 'Table', 'Chart', 'Tag',
|
|
370
|
+
'ThumbsUp', 'ThumbsDown', 'CaretDown', 'CaretUp', 'CaretRight',
|
|
371
|
+
'Disconnect', 'Mention', 'Insert', 'AssemblyLogo', 'Reposition',
|
|
372
|
+
'Marketing', 'Gauge', 'Subtask', 'Compass', 'UnPin',
|
|
373
|
+
'Pin', 'Files', 'FolderLocked', 'Unlock', 'Authentication',
|
|
374
|
+
'LockFilled', 'Reverse', 'AuditLog', 'Subscription', 'Note',
|
|
375
|
+
'Accessibility', 'Plane', 'AlarmClock', 'Football', 'Aperture',
|
|
376
|
+
'BagShoppingPlus', 'BagShoppingMinus', 'Balloon', 'Bandage', 'Dumbbell',
|
|
377
|
+
'BarcodeRead', 'Baseball', 'BasketShoppingSimple', 'Basketball', 'Flask',
|
|
378
|
+
'BedFront', 'BeerMug', 'PersonBiking', 'Ship', 'FireSmoke',
|
|
379
|
+
'Bookmark', 'BowlingBall', 'Briefcase', 'WindowMaximize', 'PaintbrushFine',
|
|
380
|
+
'Bug', 'Wrench', 'Lightbulb', 'Bus', 'Buildings',
|
|
381
|
+
'Mug', 'Calculator', 'CalendarDays', 'Camera', 'CameraRotate',
|
|
382
|
+
'Car', 'CartShopping', 'MoneyBills', 'SignalBars', 'MessageDots',
|
|
383
|
+
'CommentDots', 'Messages', 'Cloud', 'CloudMoon', 'Fill',
|
|
384
|
+
'Palette', 'WandMagicSparkles', 'ScrewdriverWrench', 'Scissors', 'Desktop',
|
|
385
|
+
'DiceFive', 'BullseyeArrow', 'FileLines', 'Ear', 'EarthAmericas',
|
|
386
|
+
'PresentationScreen', 'Egg', 'EyeDropper', 'BurgerFries', 'Venus',
|
|
387
|
+
'InboxFull', 'Inboxes', 'Film', 'Fingerprint', 'FishFins',
|
|
388
|
+
'HeartPulse', 'Flag', 'Fire', 'BoltSlash', 'Flashlight',
|
|
389
|
+
'Flower', 'FolderOpen', 'Soccer', 'ShoePrints', 'GamepadModern',
|
|
390
|
+
'CodeBranch', 'CodeCommit', 'CodeCompare', 'CodeMerge', 'CodeFork',
|
|
391
|
+
'CodePullRequest', 'Glasses', 'GolfFlagHole', 'GridFourSquares', 'Hammer',
|
|
392
|
+
'Hand', 'Microchip', 'Headphones', 'Heart', 'HeartHalfStroke',
|
|
393
|
+
'LifeRing', 'SquareQuestion', 'Question', 'HourglassHalf', 'IceCream',
|
|
394
|
+
'IdBadge', 'ImageNotFilled', 'Images', 'Infinity', 'KeyboardBrightness',
|
|
395
|
+
'Key', 'Dialpad', 'Language', 'Laptop', 'LayerGroup',
|
|
396
|
+
'Leaf', 'LocationCrosshairs', 'Magnet', 'MarsAndVenus', 'Mars',
|
|
397
|
+
'PersonSimple', 'Map', 'Medal', 'Asterisk', 'BriefcaseMedical',
|
|
398
|
+
'Bullhorn', 'Moon', 'MusicNote', 'Music', 'LocationArrow',
|
|
399
|
+
'Newspaper', 'AppleWhole', 'CloudSun', 'SolarSystem', 'Paw',
|
|
400
|
+
'Mobile', 'Glass', 'PizzaSlice', 'PlanetRinged', 'ChartSimpleHorizontal',
|
|
401
|
+
'Tags', 'Print', 'WavePulse', 'Qrcode', 'RecordVinyl',
|
|
402
|
+
'SignalStream', 'Voicemail', 'ForkKnife', 'AwardSimple', 'Rocket',
|
|
403
|
+
'FaceFrownSlight', 'FloppyDisk', 'WeightScale', 'GraduationCap', 'Coins',
|
|
404
|
+
'ShareNodes', 'ShieldCheck', 'Shield', 'ShieldHalf', 'Shirt',
|
|
405
|
+
'Snowflake', 'SquareOutline', 'StarHalf', 'Stopwatch', 'Brightness',
|
|
406
|
+
'Autoresponder', 'RectangleWide', 'TennisBall', 'Telescope', 'TemperatureThreeQuarters',
|
|
407
|
+
'CloudBolt', 'Ticket', 'Timer', 'ToggleOn', 'SignPost',
|
|
408
|
+
'Train', 'Transgender', 'ArrowTrendDown', 'ArrowTrendUp', 'Triangle',
|
|
409
|
+
'Trophy', 'Tv', 'Umbrella', 'Video', 'PersonWalking',
|
|
410
|
+
'Wallet', 'WatchApple', 'Droplet', 'MartiniGlassEmpty', 'PersonDress',
|
|
411
|
+
'ImageStack', 'FontCase', 'HubspotMeetings', 'Notion', 'Loom',
|
|
412
|
+
'Calendly', 'Typeform', 'OneDrive', 'ClickUp', 'YouTube',
|
|
413
|
+
'Monday', 'Databox', 'Figma', 'PowerBi', 'Airtable',
|
|
414
|
+
'Zoom', 'QuickBook', 'Canva', 'Xero', 'AcuityScheduling',
|
|
415
|
+
'Trello', 'Slack', 'Zapier', 'Exporter', 'TokenInspector',
|
|
416
|
+
'Jotform', 'GoogleDrive', 'GoogleCalendar', 'CalCom', 'EmbedsLinks',
|
|
417
|
+
'Miro', 'Dropbox', 'Make', 'CustomApps', 'GoogleSheets',
|
|
418
|
+
'GoogleDocs', 'GoogleSlides', 'Instagram', 'X', 'Facebook',
|
|
419
|
+
'LinkedIn', 'FolderMove', 'Details',
|
|
420
|
+
];
|
|
421
|
+
const TRIGGER_ICON_SET = new Set(TRIGGER_ICON_NAMES);
|
|
422
|
+
|
|
423
|
+
// Native platform event names (and their namespaces) app events must not
|
|
424
|
+
// shadow — emitted from lib/constants' webhook-eligible event lists.
|
|
425
|
+
const NATIVE_EVENT_NAMES = [
|
|
426
|
+
'client.created', 'client.activated', 'client.updated', 'client.customFields.updated', 'client.deleted',
|
|
427
|
+
'company.created', 'company.updated', 'company.customFields.updated', 'company.deleted', 'formResponse.completed',
|
|
428
|
+
'file.created', 'file.updated', 'file.deleted', 'link.created', 'link.updated',
|
|
429
|
+
'link.deleted', 'folder.created', 'folder.updated', 'folder.deleted', 'message.sent',
|
|
430
|
+
'contract.signed', 'notification.created', 'contract.requested', 'messageChannel.created', 'messageChannel.deleted',
|
|
431
|
+
'invoice.paid', 'internalUser.deleted', 'internalUser.created', 'product.created', 'product.updated',
|
|
432
|
+
'price.created', 'invoice.created', 'invoice.voided', 'invoice.deleted', 'invoice.sent',
|
|
433
|
+
'invoice.updated', 'note.created', 'note.updated', 'note.deleted', 'payment.succeeded',
|
|
434
|
+
'payment.failed', 'payout.reconciliation_completed', 'refund.requested', 'refund.succeeded', 'refund.failed',
|
|
435
|
+
'subscription.created', 'subscription.canceled', 'event.created', 'task.created', 'task.archived',
|
|
436
|
+
'task.completed', 'task.updated', 'task.deleted', 'comment.created',
|
|
437
|
+
];
|
|
438
|
+
const NATIVE_EVENTS_LOWER = new Set(NATIVE_EVENT_NAMES.map((n) => n.toLowerCase()));
|
|
439
|
+
const NATIVE_NAMESPACES_LOWER = new Set(NATIVE_EVENT_NAMES.map((n) => n.toLowerCase().split('.')[0]));
|
|
440
|
+
|
|
441
|
+
// The strict-decode mirror: allowed keys per node, from the DTOs' json tags.
|
|
442
|
+
// A config-UI key on a record-level schema field is an error, not dead data —
|
|
443
|
+
// the two positions have different shapes on purpose.
|
|
444
|
+
const API_RESOURCES_TOP_LEVEL_KEYS = ['apiResources'];
|
|
445
|
+
const RECORD_KEYS = ['name', 'resourceName', 'baseUrl', 'schema', 'apis', 'webhooks'];
|
|
446
|
+
const RECORD_FIELD_KEYS = ['fieldName', 'fieldType', 'label', 'description', 'isDate', 'optional'];
|
|
447
|
+
const RECORD_SCHEMA_FIELD_KEYS = [...RECORD_FIELD_KEYS, 'nestedFieldSchema'];
|
|
448
|
+
const INPUT_FIELD_KEYS = [...RECORD_FIELD_KEYS, 'placeholder', 'options', 'dynamicOptions', 'dropdownSource', 'automationSettings'];
|
|
449
|
+
const API_KEYS = ['name', 'description', 'method', 'path', 'paginated', 'queryParams', 'input', 'output', 'exposeToAutomations'];
|
|
450
|
+
const QUERY_PARAM_KEYS = ['type', 'enum', 'min', 'max'];
|
|
451
|
+
const INPUT_KEYS = ['type', 'schema'];
|
|
452
|
+
const OUTPUT_KEYS = ['type'];
|
|
453
|
+
const WEBHOOK_KEYS = ['eventName', 'eventDescription', 'trigger'];
|
|
454
|
+
const TRIGGER_KEYS = ['name', 'description', 'icon', 'configuration', 'snapshotCxt'];
|
|
455
|
+
const TRIGGER_CONFIG_KEYS = ['id', 'label', 'description', 'fieldProperty', 'fieldType', 'fieldOperator', 'type', 'required', 'addAnyOption', 'options', 'dynamicOptions'];
|
|
456
|
+
const SNAPSHOT_KEYS = ['label', 'property'];
|
|
457
|
+
const OPTION_KEYS = ['id', 'name'];
|
|
458
|
+
const DYNAMIC_OPTIONS_KEYS = ['requestUrl', 'nameField'];
|
|
459
|
+
const DROPDOWN_SOURCE_KEYS = ['inputType', 'fieldName'];
|
|
460
|
+
const AUTOMATION_SETTINGS_KEYS = ['isEnabled', 'required', 'uiType'];
|
|
461
|
+
// END GENERATED FROM GO
|
|
462
|
+
|
|
463
|
+
function failApiResources(lines) {
|
|
464
|
+
console.error('');
|
|
465
|
+
console.error('.assembly/api-resources.json is invalid:');
|
|
466
|
+
console.error('');
|
|
467
|
+
for (const l of lines) console.error(' - ' + l);
|
|
468
|
+
console.error('');
|
|
469
|
+
console.error('The whole file is one top-level key: { "apiResources": [ ...records ] }.');
|
|
470
|
+
console.error('Schema, rules, and a worked example: .claude/skills/assembly-automations/SKILL.md.');
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isPlainObject(v) {
|
|
475
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// JSON null mirrors the platform decoder: null decodes into a zero/nil value
|
|
479
|
+
// there, so a null optional key must behave exactly like an absent one here.
|
|
480
|
+
function present(v) {
|
|
481
|
+
return v !== undefined && v !== null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Pushes an error for every key of obj outside allowed — the mirror of the
|
|
485
|
+
// platform parser's DisallowUnknownFields.
|
|
486
|
+
function checkKeys(obj, allowed, where, apiErrors) {
|
|
487
|
+
for (const key of Object.keys(obj)) {
|
|
488
|
+
if (!allowed.includes(key)) {
|
|
489
|
+
apiErrors.push(where + ': unknown key "' + key + '" (allowed: ' + allowed.join(', ') + ').');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function checkString(obj, key, where, apiErrors, { required = false, maxLen = 0 } = {}) {
|
|
495
|
+
const v = obj[key];
|
|
496
|
+
if (!present(v)) {
|
|
497
|
+
if (required) apiErrors.push(where + ': "' + key + '" is required.');
|
|
498
|
+
return '';
|
|
499
|
+
}
|
|
500
|
+
if (typeof v !== 'string') {
|
|
501
|
+
apiErrors.push(where + ': "' + key + '" must be a string.');
|
|
502
|
+
return '';
|
|
503
|
+
}
|
|
504
|
+
if (required && v === '') apiErrors.push(where + ': "' + key + '" is required.');
|
|
505
|
+
// Byte length, not UTF-16 units: the platform caps measure bytes.
|
|
506
|
+
if (maxLen > 0 && Buffer.byteLength(v, 'utf8') > maxLen) apiErrors.push(where + ': "' + key + '" exceeds ' + maxLen + ' characters.');
|
|
507
|
+
return v;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function checkBool(obj, key, where, apiErrors) {
|
|
511
|
+
if (present(obj[key]) && typeof obj[key] !== 'boolean') {
|
|
512
|
+
apiErrors.push(where + ': "' + key + '" must be true or false.');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Keeps only ASCII alphanumerics, uppercasing the first letter after each
|
|
517
|
+
// dropped separator (never the first kept character). Mirrors camelCaseAlnum
|
|
518
|
+
// in app_api_resources.go.
|
|
519
|
+
function camelCaseAlnum(s) {
|
|
520
|
+
let out = '';
|
|
521
|
+
let upperNext = false;
|
|
522
|
+
for (const ch of s) {
|
|
523
|
+
if (!/[a-zA-Z0-9]/.test(ch)) {
|
|
524
|
+
upperNext = out.length > 0;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
out += upperNext ? ch.toUpperCase() : ch;
|
|
528
|
+
upperNext = false;
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Mirrors normalizeAppApiEventName in app_api_resources.go: the publish gate
|
|
534
|
+
// coerces an authored eventName toward the canonical {resourceName}.{verb}
|
|
535
|
+
// form instead of rejecting it ("intake.review_status_changed" →
|
|
536
|
+
// "intake.reviewStatusChanged"; a bare verb gains the record's resourceName;
|
|
537
|
+
// an empty verb becomes "triggered"). Returns the input unchanged when
|
|
538
|
+
// nothing salvageable remains, so the format error still fires.
|
|
539
|
+
function normalizeEventName(resourceName, eventName) {
|
|
540
|
+
let prefix = resourceName;
|
|
541
|
+
let verb = eventName;
|
|
542
|
+
const dot = eventName.indexOf('.');
|
|
543
|
+
if (dot >= 0) {
|
|
544
|
+
prefix = camelCaseAlnum(eventName.slice(0, dot)) || resourceName;
|
|
545
|
+
verb = eventName.slice(dot + 1);
|
|
546
|
+
}
|
|
547
|
+
verb = camelCaseAlnum(verb) || 'triggered';
|
|
548
|
+
const normalized = prefix + '.' + verb;
|
|
549
|
+
return EVENT_NAME_RE.test(normalized) ? normalized : eventName;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Data-shape checks shared by record schema[] fields and apis[].input.schema[]
|
|
553
|
+
// fields (mirrors validateAppApiFieldCore).
|
|
554
|
+
function checkFieldCore(field, where, seenFieldNames, apiErrors) {
|
|
555
|
+
const fieldName = checkString(field, 'fieldName', where, apiErrors, { required: true });
|
|
556
|
+
if (fieldName !== '') {
|
|
557
|
+
const lower = fieldName.toLowerCase();
|
|
558
|
+
if (seenFieldNames.has(lower)) {
|
|
559
|
+
apiErrors.push(where + ': fieldName "' + fieldName + '" duplicates another field (field names are matched case-insensitively).');
|
|
560
|
+
}
|
|
561
|
+
seenFieldNames.add(lower);
|
|
562
|
+
}
|
|
563
|
+
const fieldType = checkString(field, 'fieldType', where, apiErrors, { required: true });
|
|
564
|
+
if (fieldType !== '' && !ALLOWED_FIELD_TYPES.includes(fieldType)) {
|
|
565
|
+
apiErrors.push(where + ': fieldType must be one of ' + ALLOWED_FIELD_TYPES.join(', ') + ' (got "' + fieldType + '").');
|
|
566
|
+
}
|
|
567
|
+
checkBool(field, 'optional', where, apiErrors);
|
|
568
|
+
checkBool(field, 'isDate', where, apiErrors);
|
|
569
|
+
// The platform treats zero values of required fields as missing, so a
|
|
570
|
+
// required bool can never carry false.
|
|
571
|
+
if (fieldType === 'bool' && field.optional !== true) {
|
|
572
|
+
apiErrors.push(where + ': bool fields must be declared "optional": true (the platform treats false on a required field as missing).');
|
|
573
|
+
}
|
|
574
|
+
// The runtime date check only makes sense for string values.
|
|
575
|
+
if (field.isDate === true && fieldType !== 'string') {
|
|
576
|
+
apiErrors.push(where + ': isDate requires fieldType "string" (got "' + fieldType + '").');
|
|
577
|
+
}
|
|
578
|
+
checkString(field, 'label', where, apiErrors, { maxLen: MAX_NAME_LENGTH });
|
|
579
|
+
checkString(field, 'description', where, apiErrors, { maxLen: MAX_DESC_LENGTH });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// One nesting level of record-level schema fields (mirrors
|
|
583
|
+
// validateAppApiRecordFields). The per-level cap and case-insensitive name
|
|
584
|
+
// uniqueness apply independently at every level: a nested field may reuse an
|
|
585
|
+
// outer name — they address different payload objects. nestedFieldSchema uses
|
|
586
|
+
// present(), matching the platform decoder where an explicit null decodes to
|
|
587
|
+
// nil and behaves like an absent key.
|
|
588
|
+
function checkRecordSchemaFields(fields, where, key, depth, apiErrors) {
|
|
589
|
+
if (fields.length > MAX_SCHEMA_FIELDS) {
|
|
590
|
+
apiErrors.push(where + ': ' + fields.length + ' ' + key + ' fields declared; the limit is ' + MAX_SCHEMA_FIELDS + '.');
|
|
591
|
+
}
|
|
592
|
+
const seenFieldNames = new Set();
|
|
593
|
+
fields.forEach((field, j) => {
|
|
594
|
+
let fieldWhere = where + '.' + key + '[' + j + ']';
|
|
595
|
+
if (!isPlainObject(field)) {
|
|
596
|
+
apiErrors.push(fieldWhere + ': must be an object.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (typeof field.fieldName === 'string' && field.fieldName !== '') {
|
|
600
|
+
fieldWhere = where + '.' + key + '[' + j + '] (' + field.fieldName + ')';
|
|
601
|
+
}
|
|
602
|
+
checkKeys(field, RECORD_SCHEMA_FIELD_KEYS, fieldWhere, apiErrors);
|
|
603
|
+
checkFieldCore(field, fieldWhere, seenFieldNames, apiErrors);
|
|
604
|
+
if (!present(field.nestedFieldSchema)) return;
|
|
605
|
+
if (!Array.isArray(field.nestedFieldSchema)) {
|
|
606
|
+
apiErrors.push(fieldWhere + ': nestedFieldSchema must be an array of fields.');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (field.fieldType !== 'object' && field.fieldType !== 'array') {
|
|
610
|
+
apiErrors.push(fieldWhere + ': nestedFieldSchema is only valid on object or array fields (got fieldType "' + field.fieldType + '").');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (depth >= MAX_SCHEMA_NESTING_DEPTH) {
|
|
614
|
+
apiErrors.push(fieldWhere + ': nestedFieldSchema exceeds the maximum schema nesting depth of ' + MAX_SCHEMA_NESTING_DEPTH + '.');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
checkRecordSchemaFields(field.nestedFieldSchema, fieldWhere, 'nestedFieldSchema', depth + 1, apiErrors);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function checkDynamicOptions(dyn, where, getApiPaths, apiErrors, { nameFieldRequired }) {
|
|
622
|
+
if (!isPlainObject(dyn)) {
|
|
623
|
+
apiErrors.push(where + ': dynamicOptions must be an object like { "requestUrl": "/things", "nameField": "name" }.');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
checkKeys(dyn, DYNAMIC_OPTIONS_KEYS, where + '.dynamicOptions', apiErrors);
|
|
627
|
+
const requestUrl = checkString(dyn, 'requestUrl', where, apiErrors, { required: true });
|
|
628
|
+
if (requestUrl !== '' && !getApiPaths.has(requestUrl)) {
|
|
629
|
+
apiErrors.push(where + ': dynamicOptions.requestUrl "' + requestUrl + '" must exactly match the path of a registered GET api in this manifest (a collection api, not a /:id form).');
|
|
630
|
+
}
|
|
631
|
+
if (nameFieldRequired) {
|
|
632
|
+
checkString(dyn, 'nameField', where + '.dynamicOptions', apiErrors, { required: true });
|
|
633
|
+
} else {
|
|
634
|
+
checkString(dyn, 'nameField', where + '.dynamicOptions', apiErrors);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function checkOptions(options, where, apiErrors) {
|
|
639
|
+
if (!Array.isArray(options)) {
|
|
640
|
+
apiErrors.push(where + ': options must be an array of { "id", "name" }.');
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
options.forEach((option, i) => {
|
|
644
|
+
const optionWhere = where + '.options[' + i + ']';
|
|
645
|
+
if (!isPlainObject(option)) {
|
|
646
|
+
apiErrors.push(optionWhere + ': must be an object with "id" and "name".');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
checkKeys(option, OPTION_KEYS, optionWhere, apiErrors);
|
|
650
|
+
// Presence of id/name is not enforced — the publish gate only type-checks
|
|
651
|
+
// option entries, and this mirror must not reject what the gate accepts.
|
|
652
|
+
checkString(option, 'id', optionWhere, apiErrors);
|
|
653
|
+
checkString(option, 'name', optionWhere, apiErrors);
|
|
654
|
+
});
|
|
655
|
+
return options.length > 0;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (existsSync(API_RESOURCES)) {
|
|
659
|
+
const apiErrors = [];
|
|
660
|
+
|
|
661
|
+
let apiRaw;
|
|
662
|
+
try {
|
|
663
|
+
apiRaw = readFileSync(API_RESOURCES, 'utf8');
|
|
664
|
+
} catch (e) {
|
|
665
|
+
failApiResources(['could not read the file: ' + e.message]);
|
|
666
|
+
}
|
|
667
|
+
if (Buffer.byteLength(apiRaw, 'utf8') > API_RESOURCES_MAX_FILE_BYTES) {
|
|
668
|
+
failApiResources(['file is larger than ' + API_RESOURCES_MAX_FILE_BYTES + ' bytes.']);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
let apiDoc;
|
|
672
|
+
try {
|
|
673
|
+
apiDoc = JSON.parse(apiRaw);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
failApiResources(['not valid JSON: ' + e.message]);
|
|
676
|
+
}
|
|
677
|
+
if (!isPlainObject(apiDoc)) {
|
|
678
|
+
failApiResources(['the top level must be a JSON object: { "apiResources": [ ... ] }.']);
|
|
679
|
+
}
|
|
680
|
+
checkKeys(apiDoc, API_RESOURCES_TOP_LEVEL_KEYS, 'top level', apiErrors);
|
|
681
|
+
if (!Array.isArray(apiDoc.apiResources)) {
|
|
682
|
+
// Keep any unknown-key errors already collected: fixing them one build at
|
|
683
|
+
// a time would cost the agent an extra loop.
|
|
684
|
+
apiErrors.push('the "apiResources" key is required and must be an array (declare "apiResources": [] to register nothing).');
|
|
685
|
+
failApiResources(apiErrors);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const records = apiDoc.apiResources;
|
|
689
|
+
if (records.length > MAX_RECORDS) {
|
|
690
|
+
apiErrors.push(records.length + ' apiResources declared; the limit is ' + MAX_RECORDS + '.');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Cross-record indexes: (method, path) and event names are app-global; a
|
|
694
|
+
// dynamicOptions requestUrl may reference a collection GET api on ANY record.
|
|
695
|
+
const resourceNames = new Set();
|
|
696
|
+
const getApiPaths = new Set();
|
|
697
|
+
for (const record of records) {
|
|
698
|
+
if (!isPlainObject(record)) continue;
|
|
699
|
+
if (typeof record.resourceName === 'string' && record.resourceName !== '') resourceNames.add(record.resourceName);
|
|
700
|
+
if (!Array.isArray(record.apis)) continue;
|
|
701
|
+
for (const api of record.apis) {
|
|
702
|
+
if (isPlainObject(api) && api.method === 'GET' && typeof api.path === 'string' && !api.path.endsWith('/:id')) {
|
|
703
|
+
getApiPaths.add(api.path);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const seenResourceNames = new Map();
|
|
709
|
+
const seenMethodPaths = new Map();
|
|
710
|
+
const seenEventNames = new Map();
|
|
711
|
+
|
|
712
|
+
records.forEach((record, i) => {
|
|
713
|
+
let where = 'apiResources[' + i + ']';
|
|
714
|
+
if (!isPlainObject(record)) {
|
|
715
|
+
apiErrors.push(where + ': must be an object.');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (typeof record.resourceName === 'string' && record.resourceName !== '') {
|
|
719
|
+
where = 'apiResources[' + i + '] (' + record.resourceName + ')';
|
|
720
|
+
}
|
|
721
|
+
checkKeys(record, RECORD_KEYS, where, apiErrors);
|
|
722
|
+
checkString(record, 'name', where, apiErrors, { required: true, maxLen: MAX_NAME_LENGTH });
|
|
723
|
+
|
|
724
|
+
const resourceName = checkString(record, 'resourceName', where, apiErrors, { required: true });
|
|
725
|
+
if (resourceName !== '') {
|
|
726
|
+
if (!RESOURCE_NAME_RE.test(resourceName)) {
|
|
727
|
+
apiErrors.push(where + ': resourceName "' + resourceName + '" must be camelCase starting lowercase, 2-64 chars (' + RESOURCE_NAME_RE + ').');
|
|
728
|
+
} else if (seenResourceNames.has(resourceName)) {
|
|
729
|
+
apiErrors.push(where + ': resourceName "' + resourceName + '" is already used by apiResources[' + seenResourceNames.get(resourceName) + '].');
|
|
730
|
+
} else {
|
|
731
|
+
seenResourceNames.set(resourceName, i);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// baseUrl: relative-only — the platform resolves it against the app's
|
|
736
|
+
// deployed origin at publish; the registered record stores the full URL.
|
|
737
|
+
const baseUrl = checkString(record, 'baseUrl', where, apiErrors, { required: true });
|
|
738
|
+
if (baseUrl !== '') {
|
|
739
|
+
if (!baseUrl.startsWith('/')) {
|
|
740
|
+
apiErrors.push(where + ': baseUrl must be a relative path starting with "/" (got "' + baseUrl + '").');
|
|
741
|
+
} else if (baseUrl.startsWith('//') || baseUrl.includes('://')) {
|
|
742
|
+
apiErrors.push(where + ': baseUrl must not carry a scheme or host (got "' + baseUrl + '").');
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Record-level schema: the resource's pure data shape.
|
|
747
|
+
// schemaFieldNames stays top-level only: trigger fieldProperty and
|
|
748
|
+
// snapshotCxt.property read top-level payload keys.
|
|
749
|
+
const schemaFieldNames = new Set();
|
|
750
|
+
if (present(record.schema)) {
|
|
751
|
+
if (!Array.isArray(record.schema)) {
|
|
752
|
+
apiErrors.push(where + ': schema must be an array of fields.');
|
|
753
|
+
} else {
|
|
754
|
+
for (const field of record.schema) {
|
|
755
|
+
if (isPlainObject(field) && typeof field.fieldName === 'string' && field.fieldName !== '') {
|
|
756
|
+
schemaFieldNames.add(field.fieldName);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
checkRecordSchemaFields(record.schema, where, 'schema', 1, apiErrors);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// apis
|
|
764
|
+
if (present(record.apis)) {
|
|
765
|
+
if (!Array.isArray(record.apis)) {
|
|
766
|
+
apiErrors.push(where + ': apis must be an array.');
|
|
767
|
+
} else {
|
|
768
|
+
if (record.apis.length > MAX_APIS_PER_RECORD) {
|
|
769
|
+
apiErrors.push(where + ': ' + record.apis.length + ' apis declared; the limit is ' + MAX_APIS_PER_RECORD + '.');
|
|
770
|
+
}
|
|
771
|
+
record.apis.forEach((api, j) => {
|
|
772
|
+
let apiWhere = where + '.apis[' + j + ']';
|
|
773
|
+
if (!isPlainObject(api)) {
|
|
774
|
+
apiErrors.push(apiWhere + ': must be an object.');
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (typeof api.name === 'string' && api.name !== '') {
|
|
778
|
+
apiWhere = where + '.apis[' + j + '] (' + api.name + ')';
|
|
779
|
+
}
|
|
780
|
+
checkKeys(api, API_KEYS, apiWhere, apiErrors);
|
|
781
|
+
checkString(api, 'name', apiWhere, apiErrors, { required: true, maxLen: MAX_NAME_LENGTH });
|
|
782
|
+
// Tighter than the shared cap: this description is the action's
|
|
783
|
+
// visible label in the automations UI.
|
|
784
|
+
checkString(api, 'description', apiWhere, apiErrors, { maxLen: MAX_ACTION_DESC_LENGTH });
|
|
785
|
+
checkBool(api, 'paginated', apiWhere, apiErrors);
|
|
786
|
+
checkBool(api, 'exposeToAutomations', apiWhere, apiErrors);
|
|
787
|
+
|
|
788
|
+
const method = checkString(api, 'method', apiWhere, apiErrors, { required: true });
|
|
789
|
+
if (method !== '' && !ALLOWED_METHODS.includes(method)) {
|
|
790
|
+
apiErrors.push(apiWhere + ': method must be one of ' + ALLOWED_METHODS.join(', ') + ' (got "' + method + '").');
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const path = checkString(api, 'path', apiWhere, apiErrors, { required: true, maxLen: MAX_PATH_LENGTH });
|
|
794
|
+
if (path !== '') {
|
|
795
|
+
if (!API_PATH_RE.test(path)) {
|
|
796
|
+
apiErrors.push(apiWhere + ': path "' + path + '" must be /segment(/segment)* of [a-z0-9-_] with an optional trailing /:id.');
|
|
797
|
+
} else {
|
|
798
|
+
for (const reserved of RESERVED_TASK_SEGMENTS) {
|
|
799
|
+
if (path.includes(reserved)) {
|
|
800
|
+
apiErrors.push(apiWhere + ': path "' + path + '" contains reserved segment "' + reserved + '" (task-domain routes are owned by the platform Tasks app).');
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const methodPath = method + ' ' + path;
|
|
805
|
+
if (seenMethodPaths.has(methodPath)) {
|
|
806
|
+
apiErrors.push(apiWhere + ': ' + methodPath + ' is already registered by ' + seenMethodPaths.get(methodPath) + ' (the proxy matcher is first-hit).');
|
|
807
|
+
} else {
|
|
808
|
+
seenMethodPaths.set(methodPath, apiWhere);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// queryParams
|
|
814
|
+
if (present(api.queryParams)) {
|
|
815
|
+
if (!isPlainObject(api.queryParams)) {
|
|
816
|
+
apiErrors.push(apiWhere + ': queryParams must be an object of name -> { "type", ... }.');
|
|
817
|
+
} else {
|
|
818
|
+
const paramNames = Object.keys(api.queryParams);
|
|
819
|
+
if (paramNames.length > MAX_QUERY_PARAMS) {
|
|
820
|
+
apiErrors.push(apiWhere + ': ' + paramNames.length + ' queryParams declared; the limit is ' + MAX_QUERY_PARAMS + '.');
|
|
821
|
+
}
|
|
822
|
+
for (const paramName of paramNames) {
|
|
823
|
+
const param = api.queryParams[paramName];
|
|
824
|
+
const paramWhere = apiWhere + '.queryParams[' + paramName + ']';
|
|
825
|
+
if (!isPlainObject(param)) {
|
|
826
|
+
apiErrors.push(paramWhere + ': must be an object with a "type".');
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
checkKeys(param, QUERY_PARAM_KEYS, paramWhere, apiErrors);
|
|
830
|
+
const paramType = checkString(param, 'type', paramWhere, apiErrors, { required: true });
|
|
831
|
+
if (paramType !== '' && !ALLOWED_QUERY_PARAM_TYPES.includes(paramType)) {
|
|
832
|
+
apiErrors.push(paramWhere + ': type must be one of ' + ALLOWED_QUERY_PARAM_TYPES.join(', ') + ' (got "' + paramType + '").');
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
// Mirror of the gate's nil/len semantics: a null or empty enum
|
|
836
|
+
// constrains nothing (even on a non-string type), and empty
|
|
837
|
+
// string values are allowed.
|
|
838
|
+
if (present(param.enum)) {
|
|
839
|
+
if (!Array.isArray(param.enum) || param.enum.some((v) => typeof v !== 'string')) {
|
|
840
|
+
apiErrors.push(paramWhere + ': enum must be an array of strings.');
|
|
841
|
+
} else if (param.enum.length > 0 && paramType !== 'string') {
|
|
842
|
+
apiErrors.push(paramWhere + ': enum is only valid with type "string".');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for (const bound of ['min', 'max']) {
|
|
846
|
+
if (present(param[bound])) {
|
|
847
|
+
if (paramType !== 'int') {
|
|
848
|
+
apiErrors.push(paramWhere + ': min/max are only valid with type "int".');
|
|
849
|
+
} else if (!Number.isSafeInteger(param[bound])) {
|
|
850
|
+
apiErrors.push(paramWhere + ': ' + bound + ' must be an integer.');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (Number.isSafeInteger(param.min) && Number.isSafeInteger(param.max) && param.min > param.max) {
|
|
855
|
+
apiErrors.push(paramWhere + ': min ' + param.min + ' exceeds max ' + param.max + '.');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// input
|
|
862
|
+
const needsInput = method === 'POST' || method === 'PUT' || method === 'PATCH';
|
|
863
|
+
if (needsInput && !present(api.input)) {
|
|
864
|
+
apiErrors.push(apiWhere + ': input is required for ' + method + '.');
|
|
865
|
+
}
|
|
866
|
+
let hasAutomationEnabledInput = false;
|
|
867
|
+
if (present(api.input)) {
|
|
868
|
+
if (!isPlainObject(api.input)) {
|
|
869
|
+
apiErrors.push(apiWhere + ': input must be an object like { "type": "object", "schema": [ ... ] }.');
|
|
870
|
+
} else {
|
|
871
|
+
checkKeys(api.input, INPUT_KEYS, apiWhere + '.input', apiErrors);
|
|
872
|
+
const inputType = checkString(api.input, 'type', apiWhere + '.input', apiErrors, { required: true });
|
|
873
|
+
if (inputType !== '' && !ALLOWED_INPUT_TYPES.includes(inputType)) {
|
|
874
|
+
apiErrors.push(apiWhere + ': input.type must be "object" or "list" (got "' + inputType + '").');
|
|
875
|
+
}
|
|
876
|
+
if (present(api.input.schema)) {
|
|
877
|
+
if (!Array.isArray(api.input.schema)) {
|
|
878
|
+
apiErrors.push(apiWhere + ': input.schema must be an array of fields.');
|
|
879
|
+
} else {
|
|
880
|
+
if (api.input.schema.length > MAX_SCHEMA_FIELDS) {
|
|
881
|
+
apiErrors.push(apiWhere + ': ' + api.input.schema.length + ' input schema fields declared; the limit is ' + MAX_SCHEMA_FIELDS + '.');
|
|
882
|
+
}
|
|
883
|
+
const seenInputFieldNames = new Set();
|
|
884
|
+
api.input.schema.forEach((field, k) => {
|
|
885
|
+
let fieldWhere = apiWhere + '.input.schema[' + k + ']';
|
|
886
|
+
if (!isPlainObject(field)) {
|
|
887
|
+
apiErrors.push(fieldWhere + ': must be an object.');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (typeof field.fieldName === 'string' && field.fieldName !== '') {
|
|
891
|
+
fieldWhere = apiWhere + '.input.schema[' + k + '] (' + field.fieldName + ')';
|
|
892
|
+
}
|
|
893
|
+
checkKeys(field, INPUT_FIELD_KEYS, fieldWhere, apiErrors);
|
|
894
|
+
checkFieldCore(field, fieldWhere, seenInputFieldNames, apiErrors);
|
|
895
|
+
checkString(field, 'placeholder', fieldWhere, apiErrors, { maxLen: MAX_NAME_LENGTH });
|
|
896
|
+
|
|
897
|
+
let hasStaticOptions = false;
|
|
898
|
+
if (present(field.options)) {
|
|
899
|
+
hasStaticOptions = checkOptions(field.options, fieldWhere, apiErrors);
|
|
900
|
+
}
|
|
901
|
+
if (present(field.dynamicOptions)) {
|
|
902
|
+
// Action-field options fetches read nameField, so it is
|
|
903
|
+
// required here (unlike trigger configuration).
|
|
904
|
+
checkDynamicOptions(field.dynamicOptions, fieldWhere, getApiPaths, apiErrors, { nameFieldRequired: true });
|
|
905
|
+
}
|
|
906
|
+
if (present(field.dropdownSource)) {
|
|
907
|
+
const src = field.dropdownSource;
|
|
908
|
+
if (!isPlainObject(src)) {
|
|
909
|
+
apiErrors.push(fieldWhere + ': dropdownSource must be an object like { "inputType": "client", "fieldName": "clientId" }.');
|
|
910
|
+
} else {
|
|
911
|
+
checkKeys(src, DROPDOWN_SOURCE_KEYS, fieldWhere + '.dropdownSource', apiErrors);
|
|
912
|
+
const inputTypeValue = checkString(src, 'inputType', fieldWhere + '.dropdownSource', apiErrors, { required: true });
|
|
913
|
+
if (inputTypeValue !== '' && !DROPDOWN_SOURCE_INPUT_TYPES.includes(inputTypeValue)) {
|
|
914
|
+
apiErrors.push(fieldWhere + ': dropdownSource.inputType must be one of ' + DROPDOWN_SOURCE_INPUT_TYPES.join(', ') + ' (got "' + inputTypeValue + '").');
|
|
915
|
+
}
|
|
916
|
+
const fieldNameValue = checkString(src, 'fieldName', fieldWhere + '.dropdownSource', apiErrors, { required: true });
|
|
917
|
+
const canonical = DROPDOWN_SOURCE_RECIPIENT_FIELDS[inputTypeValue];
|
|
918
|
+
if (canonical !== undefined && fieldNameValue !== '' && fieldNameValue !== canonical) {
|
|
919
|
+
apiErrors.push(fieldWhere + ': dropdownSource.fieldName must be "' + canonical + '" for inputType "' + inputTypeValue + '" — automations map recipients by that exact name.');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const hasOptionsSource = hasStaticOptions || present(field.dynamicOptions) || present(field.dropdownSource);
|
|
925
|
+
if (present(field.automationSettings)) {
|
|
926
|
+
const settings = field.automationSettings;
|
|
927
|
+
if (!isPlainObject(settings)) {
|
|
928
|
+
apiErrors.push(fieldWhere + ': automationSettings must be an object like { "isEnabled": true, "required": false, "uiType": "textInput" }.');
|
|
929
|
+
} else {
|
|
930
|
+
checkKeys(settings, AUTOMATION_SETTINGS_KEYS, fieldWhere + '.automationSettings', apiErrors);
|
|
931
|
+
checkBool(settings, 'isEnabled', fieldWhere + '.automationSettings', apiErrors);
|
|
932
|
+
checkBool(settings, 'required', fieldWhere + '.automationSettings', apiErrors);
|
|
933
|
+
if (settings.isEnabled === true) hasAutomationEnabledInput = true;
|
|
934
|
+
const uiType = checkString(settings, 'uiType', fieldWhere + '.automationSettings', apiErrors);
|
|
935
|
+
// A bool always renders as a toggle, so its widget is
|
|
936
|
+
// settled by fieldType and naming a uiType could only
|
|
937
|
+
// contradict what the builder shows.
|
|
938
|
+
const isBoolField = field.fieldType === 'bool';
|
|
939
|
+
if (uiType === '') {
|
|
940
|
+
// An explicit widget choice is required for exposed
|
|
941
|
+
// fields unless an options source makes it a dropdown.
|
|
942
|
+
if (settings.isEnabled === true && !hasOptionsSource && !isBoolField) {
|
|
943
|
+
apiErrors.push(fieldWhere + ': automationSettings.uiType is required for exposed fields without an options source ("textInput" or "textArea").');
|
|
944
|
+
}
|
|
945
|
+
} else if (!ALLOWED_UI_TYPES.includes(uiType)) {
|
|
946
|
+
apiErrors.push(fieldWhere + ': automationSettings.uiType must be one of ' + ALLOWED_UI_TYPES.join(', ') + ' (got "' + uiType + '").');
|
|
947
|
+
} else if (uiType === 'dropdown' && !hasOptionsSource) {
|
|
948
|
+
apiErrors.push(fieldWhere + ': uiType "dropdown" requires options, dynamicOptions, or dropdownSource.');
|
|
949
|
+
} else if (uiType !== 'dropdown' && hasOptionsSource) {
|
|
950
|
+
apiErrors.push(fieldWhere + ': uiType "' + uiType + '" contradicts the declared options source.');
|
|
951
|
+
} else if (uiType === 'numberInput' && field.fieldType !== 'number') {
|
|
952
|
+
// The widget stores a JSON number, so the declared shape
|
|
953
|
+
// has to be one.
|
|
954
|
+
apiErrors.push(fieldWhere + ': uiType "numberInput" requires fieldType "number" (got "' + field.fieldType + '").');
|
|
955
|
+
} else if (uiType === 'dateInput' && field.isDate !== true) {
|
|
956
|
+
// isDate is what makes the value an RFC 3339 timestamp
|
|
957
|
+
// everywhere else in the platform.
|
|
958
|
+
apiErrors.push(fieldWhere + ': uiType "dateInput" requires isDate: true.');
|
|
959
|
+
}
|
|
960
|
+
if (isBoolField && uiType !== '') {
|
|
961
|
+
apiErrors.push(fieldWhere + ': a bool field renders as a toggle — omit automationSettings.uiType.');
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (api.exposeToAutomations === true && !hasAutomationEnabledInput) {
|
|
972
|
+
apiErrors.push(apiWhere + ': exposeToAutomations requires input with at least one field whose automationSettings.isEnabled is true.');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// output
|
|
976
|
+
if (!present(api.output)) {
|
|
977
|
+
apiErrors.push(apiWhere + ': output is required.');
|
|
978
|
+
} else if (!isPlainObject(api.output)) {
|
|
979
|
+
apiErrors.push(apiWhere + ': output must be an object like { "type": "object" }.');
|
|
980
|
+
} else {
|
|
981
|
+
checkKeys(api.output, OUTPUT_KEYS, apiWhere + '.output', apiErrors);
|
|
982
|
+
const outputType = checkString(api.output, 'type', apiWhere + '.output', apiErrors, { required: true });
|
|
983
|
+
if (outputType !== '' && !ALLOWED_OUTPUT_TYPES.includes(outputType)) {
|
|
984
|
+
apiErrors.push(apiWhere + ': output.type must be one of ' + ALLOWED_OUTPUT_TYPES.join(', ') + ' (got "' + outputType + '").');
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// webhooks
|
|
992
|
+
let recordHasTrigger = false;
|
|
993
|
+
if (present(record.webhooks)) {
|
|
994
|
+
if (!Array.isArray(record.webhooks)) {
|
|
995
|
+
apiErrors.push(where + ': webhooks must be an array.');
|
|
996
|
+
} else {
|
|
997
|
+
if (record.webhooks.length > MAX_WEBHOOKS_PER_RECORD) {
|
|
998
|
+
apiErrors.push(where + ': ' + record.webhooks.length + ' webhooks declared; the limit is ' + MAX_WEBHOOKS_PER_RECORD + '.');
|
|
999
|
+
}
|
|
1000
|
+
record.webhooks.forEach((webhook, j) => {
|
|
1001
|
+
const webhookWhere = where + '.webhooks[' + j + ']';
|
|
1002
|
+
if (!isPlainObject(webhook)) {
|
|
1003
|
+
apiErrors.push(webhookWhere + ': must be an object.');
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
checkKeys(webhook, WEBHOOK_KEYS, webhookWhere, apiErrors);
|
|
1007
|
+
|
|
1008
|
+
const authoredEventName = checkString(webhook, 'eventName', webhookWhere, apiErrors, { required: true });
|
|
1009
|
+
if (authoredEventName !== '') {
|
|
1010
|
+
// The publish gate normalizes before validating (a verb-formatting
|
|
1011
|
+
// slip must not fail a publish), so validate the normalized name.
|
|
1012
|
+
const eventName = normalizeEventName(
|
|
1013
|
+
typeof record.resourceName === 'string' ? record.resourceName : '',
|
|
1014
|
+
authoredEventName,
|
|
1015
|
+
);
|
|
1016
|
+
if (eventName !== authoredEventName) {
|
|
1017
|
+
// Warn (never fail): the registered name won't match a runtime
|
|
1018
|
+
// emission of the authored spelling, so the author should adopt
|
|
1019
|
+
// the canonical form in both places.
|
|
1020
|
+
console.warn(
|
|
1021
|
+
webhookWhere + ': eventName "' + authoredEventName + '" will be registered as "' + eventName +
|
|
1022
|
+
'" — write the camelCase form in the manifest and emit exactly that name at runtime.',
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
if (!EVENT_NAME_RE.test(eventName)) {
|
|
1026
|
+
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be {resourceName}.{verb}.');
|
|
1027
|
+
} else {
|
|
1028
|
+
const prefix = eventName.slice(0, eventName.indexOf('.'));
|
|
1029
|
+
if (!resourceNames.has(prefix)) {
|
|
1030
|
+
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" must be prefixed by one of the manifest\'s resourceNames.');
|
|
1031
|
+
}
|
|
1032
|
+
const lowerEventName = eventName.toLowerCase();
|
|
1033
|
+
if (NATIVE_EVENTS_LOWER.has(lowerEventName)) {
|
|
1034
|
+
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is a native platform event name.');
|
|
1035
|
+
} else if (NATIVE_NAMESPACES_LOWER.has(prefix.toLowerCase())) {
|
|
1036
|
+
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" uses the native "' + prefix + '" event namespace; pick a distinct resourceName.');
|
|
1037
|
+
}
|
|
1038
|
+
if (seenEventNames.has(lowerEventName)) {
|
|
1039
|
+
apiErrors.push(webhookWhere + ': eventName "' + eventName + '" is already declared by ' + seenEventNames.get(lowerEventName) + ' (event names are matched case-insensitively).');
|
|
1040
|
+
} else {
|
|
1041
|
+
seenEventNames.set(lowerEventName, webhookWhere);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
checkString(webhook, 'eventDescription', webhookWhere, apiErrors, { maxLen: MAX_DESC_LENGTH });
|
|
1046
|
+
|
|
1047
|
+
if (present(webhook.trigger)) {
|
|
1048
|
+
const trigger = webhook.trigger;
|
|
1049
|
+
if (!isPlainObject(trigger)) {
|
|
1050
|
+
apiErrors.push(webhookWhere + ': trigger must be an object.');
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
recordHasTrigger = true;
|
|
1054
|
+
checkKeys(trigger, TRIGGER_KEYS, webhookWhere + '.trigger', apiErrors);
|
|
1055
|
+
checkString(trigger, 'name', webhookWhere + ': trigger', apiErrors, { required: true, maxLen: MAX_NAME_LENGTH });
|
|
1056
|
+
checkString(trigger, 'description', webhookWhere + ': trigger', apiErrors, { maxLen: MAX_DESC_LENGTH });
|
|
1057
|
+
const triggerIcon = checkString(trigger, 'icon', webhookWhere + ': trigger', apiErrors);
|
|
1058
|
+
if (triggerIcon !== '' && !TRIGGER_ICON_SET.has(triggerIcon)) {
|
|
1059
|
+
apiErrors.push(webhookWhere + ': trigger.icon "' + triggerIcon + '" is not in the platform icon set (pick a name from the assembly-automations skill\'s icon list).');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Configuration is OPTIONAL: a bare trigger fires on every event
|
|
1063
|
+
// of its type. The builder renders one filter field per trigger.
|
|
1064
|
+
if (present(trigger.configuration)) {
|
|
1065
|
+
if (!Array.isArray(trigger.configuration)) {
|
|
1066
|
+
apiErrors.push(webhookWhere + ': trigger.configuration must be an array.');
|
|
1067
|
+
} else {
|
|
1068
|
+
if (trigger.configuration.length > 1) {
|
|
1069
|
+
apiErrors.push(webhookWhere + ': at most one trigger configuration field is supported.');
|
|
1070
|
+
}
|
|
1071
|
+
trigger.configuration.forEach((configField, k) => {
|
|
1072
|
+
const configWhere = webhookWhere + '.trigger.configuration[' + k + ']';
|
|
1073
|
+
if (!isPlainObject(configField)) {
|
|
1074
|
+
apiErrors.push(configWhere + ': must be an object.');
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
checkKeys(configField, TRIGGER_CONFIG_KEYS, configWhere, apiErrors);
|
|
1078
|
+
checkString(configField, 'id', configWhere, apiErrors, { required: true });
|
|
1079
|
+
checkString(configField, 'label', configWhere, apiErrors, { required: true, maxLen: MAX_NAME_LENGTH });
|
|
1080
|
+
checkString(configField, 'description', configWhere, apiErrors, { maxLen: MAX_DESC_LENGTH });
|
|
1081
|
+
checkBool(configField, 'required', configWhere, apiErrors);
|
|
1082
|
+
checkBool(configField, 'addAnyOption', configWhere, apiErrors);
|
|
1083
|
+
|
|
1084
|
+
const fieldProperty = checkString(configField, 'fieldProperty', configWhere, apiErrors, { required: true });
|
|
1085
|
+
if (fieldProperty !== '' && !schemaFieldNames.has(fieldProperty)) {
|
|
1086
|
+
apiErrors.push(configWhere + ': fieldProperty "' + fieldProperty + '" must exactly match a declared schema fieldName (payload lookups are case-sensitive).');
|
|
1087
|
+
}
|
|
1088
|
+
const configFieldType = checkString(configField, 'fieldType', configWhere, apiErrors, { required: true });
|
|
1089
|
+
if (configFieldType !== '' && !ALLOWED_FIELD_TYPES.includes(configFieldType)) {
|
|
1090
|
+
apiErrors.push(configWhere + ': fieldType must be one of ' + ALLOWED_FIELD_TYPES.join(', ') + ' (got "' + configFieldType + '").');
|
|
1091
|
+
}
|
|
1092
|
+
const widget = checkString(configField, 'type', configWhere, apiErrors, { required: true });
|
|
1093
|
+
if (widget !== 'dropdown') {
|
|
1094
|
+
apiErrors.push(configWhere + ': type must be "dropdown" (the only widget the trigger configuration UI renders; got "' + widget + '").');
|
|
1095
|
+
}
|
|
1096
|
+
const fieldOperator = checkString(configField, 'fieldOperator', configWhere, apiErrors);
|
|
1097
|
+
if (fieldOperator !== '' && !ALLOWED_FIELD_OPERATORS.includes(fieldOperator)) {
|
|
1098
|
+
apiErrors.push(configWhere + ': fieldOperator must be one of ' + ALLOWED_FIELD_OPERATORS.join(', ') + ' (got "' + fieldOperator + '").');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
let hasStaticOptions = false;
|
|
1102
|
+
if (present(configField.options)) {
|
|
1103
|
+
hasStaticOptions = checkOptions(configField.options, configWhere, apiErrors);
|
|
1104
|
+
}
|
|
1105
|
+
// The gate branches on emptiness, not key presence: an empty
|
|
1106
|
+
// options array plus dynamicOptions is a valid single source.
|
|
1107
|
+
const hasDynamicOptions = present(configField.dynamicOptions);
|
|
1108
|
+
if (!hasStaticOptions && !hasDynamicOptions) {
|
|
1109
|
+
apiErrors.push(configWhere + ': a dropdown needs options or dynamicOptions.');
|
|
1110
|
+
} else if (hasStaticOptions && hasDynamicOptions) {
|
|
1111
|
+
apiErrors.push(configWhere + ': declare options or dynamicOptions, not both.');
|
|
1112
|
+
} else if (hasDynamicOptions) {
|
|
1113
|
+
// The trigger UI reads .data[].name directly, so nameField
|
|
1114
|
+
// is optional here.
|
|
1115
|
+
checkDynamicOptions(configField.dynamicOptions, configWhere, getApiPaths, apiErrors, { nameFieldRequired: false });
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (present(trigger.snapshotCxt)) {
|
|
1122
|
+
if (!Array.isArray(trigger.snapshotCxt)) {
|
|
1123
|
+
apiErrors.push(webhookWhere + ': trigger.snapshotCxt must be an array of { "label", "property" }.');
|
|
1124
|
+
} else {
|
|
1125
|
+
trigger.snapshotCxt.forEach((snapshotField, k) => {
|
|
1126
|
+
const snapshotWhere = webhookWhere + '.trigger.snapshotCxt[' + k + ']';
|
|
1127
|
+
if (!isPlainObject(snapshotField)) {
|
|
1128
|
+
apiErrors.push(snapshotWhere + ': must be an object with "label" and "property".');
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
checkKeys(snapshotField, SNAPSHOT_KEYS, snapshotWhere, apiErrors);
|
|
1132
|
+
checkString(snapshotField, 'label', snapshotWhere, apiErrors, { required: true });
|
|
1133
|
+
const property = checkString(snapshotField, 'property', snapshotWhere, apiErrors, { required: true });
|
|
1134
|
+
if (property !== '' && !schemaFieldNames.has(property)) {
|
|
1135
|
+
apiErrors.push(snapshotWhere + ': property "' + property + '" must exactly match a declared schema fieldName (payload lookups are case-sensitive).');
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (recordHasTrigger && !RECIPIENT_FIELD_NAMES.some((n) => schemaFieldNames.has(n))) {
|
|
1146
|
+
apiErrors.push(where + ': records with trigger webhooks must declare at least one of clientId, companyId, or internalUserId in schema — exact camelCase, automations read the payload case-sensitively ("optional": true is fine).');
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
if (apiErrors.length > 0) failApiResources(apiErrors);
|
|
1151
|
+
}
|