initiative-plugin-sdk 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +656 -0
- package/dist/build.d.ts +52 -0
- package/dist/build.js +229 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +160 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +288 -0
- package/dist/client.js +539 -0
- package/dist/client.js.map +1 -0
- package/dist/contract.d.ts +1247 -0
- package/dist/contract.js +167 -0
- package/dist/contract.js.map +1 -0
- package/dist/define.d.ts +336 -0
- package/dist/define.js +120 -0
- package/dist/define.js.map +1 -0
- package/dist/dev.d.ts +28 -0
- package/dist/dev.js +147 -0
- package/dist/dev.js.map +1 -0
- package/dist/expression.d.ts +28 -0
- package/dist/expression.js +67 -0
- package/dist/expression.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.js +52 -0
- package/dist/init.js.map +1 -0
- package/dist/keys.d.ts +85 -0
- package/dist/keys.js +109 -0
- package/dist/keys.js.map +1 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/manifest.js +8 -0
- package/dist/manifest.js.map +1 -0
- package/dist/pack.d.ts +35 -0
- package/dist/pack.js +84 -0
- package/dist/pack.js.map +1 -0
- package/dist/plugin-api.generated.d.ts +4263 -0
- package/dist/plugin-api.generated.js +1019 -0
- package/dist/plugin-api.generated.js.map +1 -0
- package/dist/server.d.ts +75 -0
- package/dist/server.js +408 -0
- package/dist/server.js.map +1 -0
- package/dist/testing.d.ts +116 -0
- package/dist/testing.js +386 -0
- package/dist/testing.js.map +1 -0
- package/dist/tokens.d.ts +161 -0
- package/dist/tokens.js +215 -0
- package/dist/tokens.js.map +1 -0
- package/dist/validate.d.ts +86 -0
- package/dist/validate.js +997 -0
- package/dist/validate.js.map +1 -0
- package/dist/widget.d.ts +235 -0
- package/dist/widget.js +26 -0
- package/dist/widget.js.map +1 -0
- package/examples/minimal/README.md +48 -0
- package/examples/minimal/assets/avatar.png +0 -0
- package/examples/minimal/package.json +22 -0
- package/examples/minimal/src/plugin.ts +77 -0
- package/examples/minimal/src/widgets/open-issues.ts +11 -0
- package/examples/minimal/test/fixtures/issues-statistics.json +9 -0
- package/examples/minimal/test/issue-counts.test.ts +48 -0
- package/examples/minimal/tsconfig.json +14 -0
- package/manifest.contract.json +1886 -0
- package/package.json +92 -0
- package/schemas/plugin-manifest.json +1747 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checking a manifest before a deployment does.
|
|
3
|
+
*
|
|
4
|
+
* **Schema-valid is necessary, not sufficient.** Some rules are not expressible
|
|
5
|
+
* in JSON Schema and are checked by the platform on publish: cross-references
|
|
6
|
+
* (the endpoint a widget binds, a `requires` term's connection, an endpoint's
|
|
7
|
+
* service prefix), the features/blocks cross-check in both directions, UTF-8
|
|
8
|
+
* byte-size caps, the rules tying a connection's `flow` and `token` to its
|
|
9
|
+
* scope and fields, what a vendor `setup` writes to, and the bounds and unique
|
|
10
|
+
* ids of `schedules`.
|
|
11
|
+
*
|
|
12
|
+
* {@link validateManifest} runs the schema and then every one of those except
|
|
13
|
+
* the byte caps. It also reports every term the contract does not declare: a
|
|
14
|
+
* deployment drops such a term rather than refusing the manifest, so a
|
|
15
|
+
* misspelt or retired field would otherwise do nothing without saying so. And
|
|
16
|
+
* it adds one rule the platform does not check: an endpoint's `identity` must
|
|
17
|
+
* name returns that endpoint actually sends.
|
|
18
|
+
*
|
|
19
|
+
* For a declarative plug-in it also parses every JSONata expression, and checks
|
|
20
|
+
* that a plug-in is one kind or the other, what its requests and steps name, and
|
|
21
|
+
* the hosts it calls.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
27
|
+
import { CAPS, FEATURES, PLATFORM_CODES, } from "./contract.js";
|
|
28
|
+
import { parseExpression } from "./expression.js";
|
|
29
|
+
/** Where a plug-in serves its manifest document. */
|
|
30
|
+
export const MANIFEST_PATH = "/.well-known/initiative-plugin.json";
|
|
31
|
+
/** The wire protocol this SDK speaks. */
|
|
32
|
+
export const PLUGIN_PROTOCOL_VERSION = 1;
|
|
33
|
+
/**
|
|
34
|
+
* The document to serve at {@link MANIFEST_PATH}. Serve the same bytes every
|
|
35
|
+
* time: a deployment hashes what it fetches and re-checks it.
|
|
36
|
+
*/
|
|
37
|
+
export function pluginDocument(manifest, options = {}) {
|
|
38
|
+
return {
|
|
39
|
+
protocol_version: manifest.service?.protocol ?? PLUGIN_PROTOCOL_VERSION,
|
|
40
|
+
public_id: manifest.service?.public_id ?? "",
|
|
41
|
+
kind: "plugin",
|
|
42
|
+
...(options.uid ? { uid: options.uid } : {}),
|
|
43
|
+
...(options.name ? { name: options.name } : {}),
|
|
44
|
+
definition: manifest,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check a whole served document — the envelope, then the manifest inside it.
|
|
49
|
+
*
|
|
50
|
+
* {@link validateManifest} checks what a plug-in declares; this checks what a
|
|
51
|
+
* registrar will actually fetch. Use it on the bytes you serve.
|
|
52
|
+
*/
|
|
53
|
+
export function validateDocument(document) {
|
|
54
|
+
if (typeof document !== "object" || document === null) {
|
|
55
|
+
return [{ where: "", message: "a manifest document is a JSON object" }];
|
|
56
|
+
}
|
|
57
|
+
const body = document;
|
|
58
|
+
const problems = [];
|
|
59
|
+
if (body.protocol_version !== PLUGIN_PROTOCOL_VERSION) {
|
|
60
|
+
problems.push({
|
|
61
|
+
where: "/protocol_version",
|
|
62
|
+
message: `must be ${PLUGIN_PROTOCOL_VERSION} — a registrar refuses a protocol it does not speak`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (typeof body.public_id !== "string" || !body.public_id.trim()) {
|
|
66
|
+
problems.push({ where: "/public_id", message: "a served document must name its plug-in" });
|
|
67
|
+
}
|
|
68
|
+
if (body.kind !== "plugin") {
|
|
69
|
+
problems.push({ where: "/kind", message: `must be '${"plugin"}'` });
|
|
70
|
+
}
|
|
71
|
+
if (body.definition === undefined) {
|
|
72
|
+
problems.push({
|
|
73
|
+
where: "/definition",
|
|
74
|
+
message: "the manifest goes here — a document without one declares nothing",
|
|
75
|
+
});
|
|
76
|
+
// Nothing further to say: every check below reads the definition.
|
|
77
|
+
return problems;
|
|
78
|
+
}
|
|
79
|
+
// The two ids are the same id written twice, and a registration matched by
|
|
80
|
+
// one while the capabilities are namespaced under the other is a mismatch
|
|
81
|
+
// nothing downstream would report.
|
|
82
|
+
const declared = body.definition?.service?.public_id;
|
|
83
|
+
if (typeof body.public_id === "string" && declared && declared !== body.public_id) {
|
|
84
|
+
problems.push({
|
|
85
|
+
where: "/public_id",
|
|
86
|
+
message: `names '${body.public_id}' but the definition declares '${declared}'`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
...problems,
|
|
91
|
+
...validateManifest(body.definition, { publicId: body.public_id }).map((problem) => ({
|
|
92
|
+
where: `/definition${problem.where}`,
|
|
93
|
+
message: problem.message,
|
|
94
|
+
})),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Which manifest block backs each declared feature.
|
|
99
|
+
*
|
|
100
|
+
* Derived from the contract's feature list rather than restated: a feature and
|
|
101
|
+
* its block share a name, and a second list could only ever be missing one —
|
|
102
|
+
* which is what left this package unable to declare `dashboards` for a release.
|
|
103
|
+
*/
|
|
104
|
+
const FEATURE_BLOCKS = Object.fromEntries(FEATURES.map((feature) => [feature, feature]));
|
|
105
|
+
/** The generated schema, read from disk once. */
|
|
106
|
+
export function manifestSchema() {
|
|
107
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
108
|
+
// Resolved relative to the built module so it works from `dist/` and `src/`.
|
|
109
|
+
for (const candidate of ["../schemas/plugin-manifest.json", "../../schemas/plugin-manifest.json"]) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(join(here, candidate), "utf-8"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw new Error("plugin-manifest.json is not packaged beside this module");
|
|
118
|
+
}
|
|
119
|
+
/** Compiled once — Ajv's compile step is the expensive part, not validation. */
|
|
120
|
+
let compiled;
|
|
121
|
+
function schemaValidator() {
|
|
122
|
+
if (!compiled) {
|
|
123
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
124
|
+
compiled = ajv.compile(manifestSchema());
|
|
125
|
+
}
|
|
126
|
+
return compiled;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Everything this side can check: the schema, then the two cross-cutting rules
|
|
130
|
+
* it cannot express.
|
|
131
|
+
*
|
|
132
|
+
* The schema runs first and short-circuits. A manifest whose shape is wrong
|
|
133
|
+
* produces cascading nonsense from the reference checks — "binds 'undefined',
|
|
134
|
+
* which is not a declared read endpoint" when the real answer is "endpoints
|
|
135
|
+
* must be an array" — so the structural answer is worth giving alone.
|
|
136
|
+
*
|
|
137
|
+
* An empty array does not promise the platform will accept it — see the module
|
|
138
|
+
* note — but a non-empty one is a definite refusal, so this is worth running in
|
|
139
|
+
* CI and before a publish.
|
|
140
|
+
*
|
|
141
|
+
* A declarative plug-in's manifest has no `service` block to name the plug-in, so its
|
|
142
|
+
* endpoint ids are checked against `publicId` when one is given.
|
|
143
|
+
*/
|
|
144
|
+
export function validateManifest(manifest, options = {}) {
|
|
145
|
+
if (typeof manifest !== "object" || manifest === null) {
|
|
146
|
+
return [{ where: "", message: "a manifest is a JSON object" }];
|
|
147
|
+
}
|
|
148
|
+
const validate = schemaValidator();
|
|
149
|
+
if (!validate(manifest)) {
|
|
150
|
+
return (validate.errors ?? []).map((error) =>
|
|
151
|
+
// A closed object's unknown key, said the way an open one's is.
|
|
152
|
+
error.keyword === "additionalProperties"
|
|
153
|
+
? {
|
|
154
|
+
where: `${error.instancePath}/${error.params.additionalProperty}`,
|
|
155
|
+
message: `'${error.params.additionalProperty}' is not a term of the manifest contract`,
|
|
156
|
+
}
|
|
157
|
+
: {
|
|
158
|
+
where: error.instancePath,
|
|
159
|
+
message: `${error.message ?? "is invalid"}${error.params && "allowedValues" in error.params
|
|
160
|
+
? ` (${error.params.allowedValues.join(", ")})`
|
|
161
|
+
: ""}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const body = manifest;
|
|
165
|
+
return [
|
|
166
|
+
...undeclaredProblems(body),
|
|
167
|
+
...featureProblems(body),
|
|
168
|
+
...referenceProblems(body, options.publicId),
|
|
169
|
+
...connectionProblems(body),
|
|
170
|
+
...webhookProblems(body),
|
|
171
|
+
...setupProblems(body),
|
|
172
|
+
...scheduleProblems(body),
|
|
173
|
+
...automationProblems(body),
|
|
174
|
+
...summaryProblems(body),
|
|
175
|
+
...kindProblems(body),
|
|
176
|
+
...declarativeProblems(body),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether {@link Manifest.community_summary} names something that can be rendered.
|
|
181
|
+
*
|
|
182
|
+
* Separate from {@link referenceProblems} for the same reason
|
|
183
|
+
* {@link automationProblems} is: nothing downstream refuses this. A deployment
|
|
184
|
+
* that cannot resolve the endpoint draws nothing and says nothing, which looks
|
|
185
|
+
* exactly like a deployment that chose not to render it.
|
|
186
|
+
*/
|
|
187
|
+
function summaryProblems(body) {
|
|
188
|
+
const id = body.community_summary;
|
|
189
|
+
if (!id)
|
|
190
|
+
return [];
|
|
191
|
+
const endpoint = (body.endpoints ?? []).find((candidate) => candidate.id === id);
|
|
192
|
+
if (!endpoint) {
|
|
193
|
+
return [
|
|
194
|
+
{
|
|
195
|
+
where: "/community_summary",
|
|
196
|
+
message: `'${id}' is not one of this plug-in's endpoints`,
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
const problems = [];
|
|
201
|
+
if (endpoint.direction !== "read") {
|
|
202
|
+
problems.push({
|
|
203
|
+
where: "/community_summary",
|
|
204
|
+
message: `'${id}' is not a read — a summary is drawn, not performed`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if ((endpoint.returns ?? []).length === 0) {
|
|
208
|
+
problems.push({
|
|
209
|
+
where: "/community_summary",
|
|
210
|
+
message: `'${id}' declares no returns, so there is nothing to draw`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const required = (endpoint.params ?? []).filter((param) => param.required);
|
|
214
|
+
if (required.length > 0) {
|
|
215
|
+
problems.push({
|
|
216
|
+
where: "/community_summary",
|
|
217
|
+
message: `'${id}' requires ${required.map((p) => `'${p.key}'`).join(", ")} — a summary is ` +
|
|
218
|
+
"read for a community, and there is no form to answer a parameter in",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return problems;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Every identity that names something its endpoint does not carry.
|
|
225
|
+
*
|
|
226
|
+
* What is left of a larger set of checks, and the reduction is the point: the
|
|
227
|
+
* others covered terms that told a consumer how to DRAW a parameter, and those
|
|
228
|
+
* terms are gone. An identity is not one of them. It says what an operation
|
|
229
|
+
* TOUCHED, which only you can know, and it is what lets a consumer keep a
|
|
230
|
+
* change your plug-in made from firing the automation that made it.
|
|
231
|
+
*
|
|
232
|
+
* Kept apart from {@link referenceProblems} because those are early copies of
|
|
233
|
+
* platform refusals and this is not: nothing downstream refuses an identity
|
|
234
|
+
* naming a return you do not send. It resolves to nothing, the suppression it
|
|
235
|
+
* feeds looks configured, and a fire somebody was waiting on is silently
|
|
236
|
+
* dropped. This is where that surfaces.
|
|
237
|
+
*/
|
|
238
|
+
function automationProblems(body) {
|
|
239
|
+
const problems = [];
|
|
240
|
+
(body.endpoints ?? []).forEach((endpoint, index) => {
|
|
241
|
+
if (!endpoint.identity)
|
|
242
|
+
return;
|
|
243
|
+
const where = `/endpoints/${index}`;
|
|
244
|
+
if (endpoint.direction === "read") {
|
|
245
|
+
problems.push({
|
|
246
|
+
where: `${where}/identity`,
|
|
247
|
+
message: "a read touched nothing, so there is no echo to suppress",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const single = new Set((endpoint.returns ?? []).filter((value) => !value.list).map((value) => value.key));
|
|
251
|
+
for (const part of endpoint.identity.key) {
|
|
252
|
+
if (!single.has(part)) {
|
|
253
|
+
problems.push({
|
|
254
|
+
where: `${where}/identity/key`,
|
|
255
|
+
message: `'${part}' is not a single-valued return of this endpoint — an address built from ` +
|
|
256
|
+
"the parts that happen to be there matches the wrong thing",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
return problems;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Every key the contract does not declare, depth first.
|
|
265
|
+
*
|
|
266
|
+
* Walks the schema itself: a node names a `$ref`, carries `items`, or carries
|
|
267
|
+
* `properties`, and each is followed the same way at every depth. A tagged
|
|
268
|
+
* union is read as the member its value's `kind` names. An object the
|
|
269
|
+
* contract leaves open (localized text, a widget's `meta`, a binding's
|
|
270
|
+
* `params`) declares no properties, and nothing inside it is checked.
|
|
271
|
+
*/
|
|
272
|
+
function undeclaredProblems(body) {
|
|
273
|
+
const schema = manifestSchema();
|
|
274
|
+
const defs = schema.$defs ?? {};
|
|
275
|
+
const problems = [];
|
|
276
|
+
const resolve = (node) => node?.$ref ? defs[node.$ref.slice("#/$defs/".length)] : node;
|
|
277
|
+
const walk = (value, node, where) => {
|
|
278
|
+
let shape = resolve(node);
|
|
279
|
+
if (shape?.oneOf && typeof value === "object" && value !== null) {
|
|
280
|
+
const kind = value.kind;
|
|
281
|
+
shape = shape.oneOf.map(resolve).find((member) => member?.properties?.kind?.const === kind);
|
|
282
|
+
}
|
|
283
|
+
if (!shape)
|
|
284
|
+
return;
|
|
285
|
+
if (shape.items) {
|
|
286
|
+
if (Array.isArray(value)) {
|
|
287
|
+
value.forEach((item, index) => walk(item, shape.items, `${where}/${index}`));
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!shape.properties || typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
for (const [key, child] of Object.entries(value)) {
|
|
295
|
+
const declared = shape.properties[key];
|
|
296
|
+
if (!declared) {
|
|
297
|
+
problems.push({
|
|
298
|
+
where: `${where}/${key}`,
|
|
299
|
+
message: `'${key}' is not a term of the manifest contract, and a deployment discards it`,
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
walk(child, declared, `${where}/${key}`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
walk(body, schema, "");
|
|
307
|
+
return problems;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Every declared feature backed by a block, and every block declared.
|
|
311
|
+
*
|
|
312
|
+
* **An empty block is no block**, and testing for the key's presence instead is
|
|
313
|
+
* the mistake this note exists to stop. The platform's normalizer drops empty
|
|
314
|
+
* blocks *before* it runs this cross-check, so `"automation": {}` never reaches
|
|
315
|
+
* it and the feature reads as declared over nothing — refused. A manifest with
|
|
316
|
+
* one validates locally under a presence test and is turned away at
|
|
317
|
+
* registration, which has happened to a real plug-in.
|
|
318
|
+
*
|
|
319
|
+
* So an empty block is reported twice over, deliberately: once as the feature it
|
|
320
|
+
* fails to back, and once on its own, because leaving it out is the fix either
|
|
321
|
+
* way and a block that is never sent cannot be misread.
|
|
322
|
+
*/
|
|
323
|
+
function featureProblems(body) {
|
|
324
|
+
const problems = [];
|
|
325
|
+
const declared = new Set(body.features ?? []);
|
|
326
|
+
for (const [feature, block] of Object.entries(FEATURE_BLOCKS)) {
|
|
327
|
+
const value = body[block];
|
|
328
|
+
// What survives the normalizer: present, and carrying something.
|
|
329
|
+
const present = value !== undefined &&
|
|
330
|
+
value !== null &&
|
|
331
|
+
(Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0);
|
|
332
|
+
if (declared.has(feature) && !present) {
|
|
333
|
+
problems.push({
|
|
334
|
+
where: "/features",
|
|
335
|
+
message: `the '${feature}' feature is declared but ${String(block)} is missing or empty — ` +
|
|
336
|
+
"an empty block is dropped before the platform checks, so it reads as absent",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (present && !declared.has(feature)) {
|
|
340
|
+
problems.push({
|
|
341
|
+
where: `/${String(block)}`,
|
|
342
|
+
message: `${String(block)} is present but the '${feature}' feature is not declared`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (value !== undefined && !present) {
|
|
346
|
+
problems.push({
|
|
347
|
+
where: `/${String(block)}`,
|
|
348
|
+
message: `${String(block)} is empty — leave it out instead`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return problems;
|
|
353
|
+
}
|
|
354
|
+
/** Ids that must name something the manifest itself declares. */
|
|
355
|
+
function referenceProblems(body, publicId) {
|
|
356
|
+
const problems = [];
|
|
357
|
+
const connectionIds = new Set((body.connections ?? []).map((c) => c.id));
|
|
358
|
+
const checkRequires = (requires, where) => {
|
|
359
|
+
if (!requires)
|
|
360
|
+
return;
|
|
361
|
+
// The schema already refused anything but exactly one operator, so this is
|
|
362
|
+
// a cheap invariant rather than a second gate — it keeps the loop below
|
|
363
|
+
// from reading a key that is not there if this is ever called directly.
|
|
364
|
+
const named = ["all_of", "any_of"].filter((key) => key in requires);
|
|
365
|
+
if (named.length !== 1)
|
|
366
|
+
return;
|
|
367
|
+
for (const id of requires[named[0]] ?? []) {
|
|
368
|
+
if (!connectionIds.has(id)) {
|
|
369
|
+
problems.push({ where, message: `requires names unknown connection '${id}'` });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
// One namespace across every direction, which is what lets a caller resolve
|
|
374
|
+
// an id without being told which kind of thing it is first.
|
|
375
|
+
const owner = body.service?.public_id ?? publicId;
|
|
376
|
+
const prefix = `plugin.${owner}.`;
|
|
377
|
+
const readable = new Set();
|
|
378
|
+
const declared = new Set();
|
|
379
|
+
// Kept by id so a parameter naming a source can be checked against what that
|
|
380
|
+
// endpoint actually returns, not merely against its existence.
|
|
381
|
+
const byId = new Map();
|
|
382
|
+
(body.endpoints ?? []).forEach((endpoint, index) => {
|
|
383
|
+
const where = `/endpoints/${index}`;
|
|
384
|
+
if (owner !== undefined && (!endpoint.id.startsWith(prefix) || endpoint.id.length === prefix.length)) {
|
|
385
|
+
problems.push({
|
|
386
|
+
where: `${where}/id`,
|
|
387
|
+
message: `endpoint ids are namespaced under your service id — '${prefix}…'`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (declared.has(endpoint.id)) {
|
|
391
|
+
problems.push({ where: `${where}/id`, message: `'${endpoint.id}' is declared twice` });
|
|
392
|
+
}
|
|
393
|
+
declared.add(endpoint.id);
|
|
394
|
+
if (endpoint.direction === "read")
|
|
395
|
+
readable.add(endpoint.id);
|
|
396
|
+
byId.set(endpoint.id, endpoint);
|
|
397
|
+
checkRequires(endpoint.requires, `${where}/requires`);
|
|
398
|
+
// An emission travels the other way — nobody calls it — so a caller side on
|
|
399
|
+
// one describes a call that never happens: a form nobody fills, a gate
|
|
400
|
+
// nobody passes, a cache with nothing to hold. The platform refuses it, and
|
|
401
|
+
// the schema cannot say so because the rule is conditional on `direction`.
|
|
402
|
+
//
|
|
403
|
+
// `label`, `description`, `returns` and `group` are deliberately NOT in
|
|
404
|
+
// this list: an emission is the one endpoint chosen without ever being
|
|
405
|
+
// called, so describing it matters more here than anywhere.
|
|
406
|
+
if (endpoint.direction === "emit") {
|
|
407
|
+
for (const key of ["params", "requires", "cache_ttl_seconds", "actors", "public"]) {
|
|
408
|
+
if (endpoint[key] !== undefined) {
|
|
409
|
+
problems.push({
|
|
410
|
+
where: `${where}/${key}`,
|
|
411
|
+
message: `an emit endpoint has no ${key} — nobody calls it`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Two returns under one name is a value a consumer cannot address: it binds
|
|
417
|
+
// by name, and one of the two would silently never be reachable.
|
|
418
|
+
const returned = new Set();
|
|
419
|
+
(endpoint.returns ?? []).forEach((value, position) => {
|
|
420
|
+
if (returned.has(value.key)) {
|
|
421
|
+
problems.push({
|
|
422
|
+
where: `${where}/returns/${position}`,
|
|
423
|
+
message: `'${value.key}' is returned twice — a consumer binds by name`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
returned.add(value.key);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
// A parameter that names where its values come from, checked against the
|
|
430
|
+
// endpoint it names. Nothing downstream refuses a bad one: a consumer asks
|
|
431
|
+
// the deployment to resolve it, the deployment finds no such return, and the
|
|
432
|
+
// form quietly offers nothing — which looks exactly like a vendor being slow.
|
|
433
|
+
// This is where an author finds out instead.
|
|
434
|
+
(body.endpoints ?? []).forEach((endpoint, index) => {
|
|
435
|
+
(endpoint.params ?? []).forEach((param, position) => {
|
|
436
|
+
const source = param.options_from;
|
|
437
|
+
if (!source)
|
|
438
|
+
return;
|
|
439
|
+
const where = `/endpoints/${index}/params/${position}/options_from`;
|
|
440
|
+
const named = byId.get(source.endpoint);
|
|
441
|
+
if (!named) {
|
|
442
|
+
problems.push({
|
|
443
|
+
where,
|
|
444
|
+
message: `names '${source.endpoint}', which this manifest does not declare`,
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (named.direction !== "read") {
|
|
449
|
+
// Filling in a form must not be able to change anything.
|
|
450
|
+
problems.push({
|
|
451
|
+
where,
|
|
452
|
+
message: `names '${source.endpoint}', which is a ${named.direction} endpoint`,
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
for (const [field, key] of [
|
|
457
|
+
["key", source.key],
|
|
458
|
+
["label_key", source.label_key],
|
|
459
|
+
]) {
|
|
460
|
+
if (key === undefined)
|
|
461
|
+
continue;
|
|
462
|
+
const value = (named.returns ?? []).find((one) => one.key === key);
|
|
463
|
+
if (!value) {
|
|
464
|
+
problems.push({
|
|
465
|
+
where: `${where}/${field}`,
|
|
466
|
+
message: `'${key}' is not returned by '${source.endpoint}'`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
else if (value.list !== true) {
|
|
470
|
+
// A menu comes from a list. `returns` is how an endpoint says which
|
|
471
|
+
// of its values hold several, and a consumer reading a scalar where
|
|
472
|
+
// it expected a column has nowhere to put it.
|
|
473
|
+
problems.push({
|
|
474
|
+
where: `${where}/${field}`,
|
|
475
|
+
message: `'${key}' is a single value — options come from a list`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// What the source is told, checked at both ends: the name it is sent
|
|
480
|
+
// under has to be a parameter that endpoint takes, and the answer sent
|
|
481
|
+
// under it has to be one this endpoint collects. Either half wrong and
|
|
482
|
+
// the source is called with a parameter it ignores or never called at
|
|
483
|
+
// all, both of which reach a person as an empty menu.
|
|
484
|
+
for (const [sends, answer] of Object.entries(source.needs ?? {})) {
|
|
485
|
+
const at = `${where}/needs/${sends}`;
|
|
486
|
+
if (!(named.params ?? []).some((one) => one.key === sends)) {
|
|
487
|
+
problems.push({
|
|
488
|
+
where: at,
|
|
489
|
+
message: `'${source.endpoint}' takes no parameter '${sends}'`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (answer === param.key) {
|
|
493
|
+
// It would have to be answered before it could offer an answer.
|
|
494
|
+
problems.push({
|
|
495
|
+
where: at,
|
|
496
|
+
message: `'${answer}' is this parameter — it cannot be told its own answer`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
else if (!(endpoint.params ?? []).some((one) => one.key === answer)) {
|
|
500
|
+
problems.push({
|
|
501
|
+
where: at,
|
|
502
|
+
message: `'${answer}' is not a parameter of this endpoint`,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
(body.embeds ?? []).forEach((embed, index) => checkRequires(embed.requires, `/embeds/${index}/requires`));
|
|
509
|
+
(body.widgets ?? []).forEach((widget, index) => {
|
|
510
|
+
checkRequires(widget.requires, `/widgets/${index}/requires`);
|
|
511
|
+
for (const id of widget.endpoints ?? []) {
|
|
512
|
+
// The restriction is the widget's, not the endpoint's: a widget draws
|
|
513
|
+
// what it is given, so it can only bind one that answers. An automation
|
|
514
|
+
// reaching the same endpoint is under no such rule.
|
|
515
|
+
if (!readable.has(id)) {
|
|
516
|
+
problems.push({
|
|
517
|
+
where: `/widgets/${index}/endpoints`,
|
|
518
|
+
message: `binds '${id}', which is not a declared read endpoint`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
return problems;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The rules tying a connection's flow and token to its scope and fields, and
|
|
527
|
+
* every `{…}` a flow or token names.
|
|
528
|
+
*
|
|
529
|
+
* The platform refuses each of these on publish; they are repeated here so an
|
|
530
|
+
* author finds out before that.
|
|
531
|
+
*/
|
|
532
|
+
function connectionProblems(body) {
|
|
533
|
+
const problems = [];
|
|
534
|
+
const vendorKeys = new Set((body.vendor?.fields ?? []).map((field) => field.key));
|
|
535
|
+
(body.connections ?? []).forEach((connection, index) => {
|
|
536
|
+
const where = `/connections/${index}`;
|
|
537
|
+
const fieldKeys = new Set(connection.fields.map((field) => field.key));
|
|
538
|
+
const flow = connection.flow;
|
|
539
|
+
if (connection.scope === "interactive" && !flow) {
|
|
540
|
+
problems.push({
|
|
541
|
+
where,
|
|
542
|
+
message: "an interactive connection declares a flow — each member authorizes their own account",
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (connection.scope === "static" && !flow && connection.fields.length === 0) {
|
|
546
|
+
problems.push({
|
|
547
|
+
where: `${where}/fields`,
|
|
548
|
+
message: "a static connection without a flow declares at least one field for an admin to fill in",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (flow) {
|
|
552
|
+
connection.fields.forEach((field, position) => {
|
|
553
|
+
if (field.managed !== true) {
|
|
554
|
+
problems.push({
|
|
555
|
+
where: `${where}/fields/${position}`,
|
|
556
|
+
message: "a connection with a flow holds only managed values — mark the field managed",
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
if (connection.fields.length > 0 && !flow.after_connect) {
|
|
561
|
+
problems.push({
|
|
562
|
+
where: `${where}/flow/after_connect`,
|
|
563
|
+
message: "managed values come from the after_connect hook, which this flow does not call",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (flow.install_url !== undefined) {
|
|
567
|
+
if (connection.scope !== "static") {
|
|
568
|
+
problems.push({
|
|
569
|
+
where: `${where}/flow/install_url`,
|
|
570
|
+
message: "an install page is for a static connection, which an organization installs",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (!flow.after_connect) {
|
|
574
|
+
problems.push({
|
|
575
|
+
where: `${where}/flow/install_url`,
|
|
576
|
+
message: "an installation-style flow calls after_connect, which checks who installed it",
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if ((flow.revoke === "rfc7009" || flow.revoke === "github_grant") && !flow.revoke_url) {
|
|
581
|
+
problems.push({
|
|
582
|
+
where: `${where}/flow/revoke_url`,
|
|
583
|
+
message: `${flow.revoke} revocation is sent to revoke_url, which is missing`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (connection.token && connection.scope !== "static") {
|
|
588
|
+
problems.push({
|
|
589
|
+
where: `${where}/token`,
|
|
590
|
+
message: "a minted token belongs to a static connection",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
const templated = [];
|
|
594
|
+
if (flow) {
|
|
595
|
+
for (const key of [
|
|
596
|
+
"authorize_url",
|
|
597
|
+
"token_url",
|
|
598
|
+
"client_id",
|
|
599
|
+
"client_secret",
|
|
600
|
+
"install_url",
|
|
601
|
+
"revoke_url",
|
|
602
|
+
]) {
|
|
603
|
+
templated.push([`${where}/flow/${key}`, flow[key]]);
|
|
604
|
+
}
|
|
605
|
+
for (const [name, value] of Object.entries(flow.authorize_params ?? {})) {
|
|
606
|
+
templated.push([`${where}/flow/authorize_params/${name}`, value]);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (connection.token) {
|
|
610
|
+
for (const key of ["exchange_url", "iss", "key"]) {
|
|
611
|
+
templated.push([`${where}/token/${key}`, connection.token[key]]);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const [at, value] of templated) {
|
|
615
|
+
for (const name of templateNames(value ?? "")) {
|
|
616
|
+
if (name.startsWith("vendor.")) {
|
|
617
|
+
if (!vendorKeys.has(name.slice("vendor.".length))) {
|
|
618
|
+
problems.push({ where: at, message: `'{${name}}' is not a field of the vendor block` });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else if (!fieldKeys.has(name)) {
|
|
622
|
+
problems.push({ where: at, message: `'{${name}}' is not a field of this connection` });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
return problems;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* What a webhooks block names: its secret is one vendor value, and its route
|
|
631
|
+
* is a field of a static connection.
|
|
632
|
+
*/
|
|
633
|
+
function webhookProblems(body) {
|
|
634
|
+
const webhooks = body.webhooks;
|
|
635
|
+
if (!webhooks)
|
|
636
|
+
return [];
|
|
637
|
+
const problems = [];
|
|
638
|
+
const vendorKeys = new Set((body.vendor?.fields ?? []).map((field) => field.key));
|
|
639
|
+
const secret = webhooks.verify.secret;
|
|
640
|
+
const names = templateNames(secret);
|
|
641
|
+
const key = names.length === 1 && names[0]?.startsWith("vendor.") ? names[0].slice(7) : null;
|
|
642
|
+
if (key === null || secret !== `{vendor.${key}}`) {
|
|
643
|
+
problems.push({
|
|
644
|
+
where: "/webhooks/verify/secret",
|
|
645
|
+
message: "the signing secret is one vendor value, written '{vendor.<key>}'",
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
else if (!vendorKeys.has(key)) {
|
|
649
|
+
problems.push({
|
|
650
|
+
where: "/webhooks/verify/secret",
|
|
651
|
+
message: `'{vendor.${key}}' is not a field of the vendor block`,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if ((webhooks.route.path === undefined) === (webhooks.route.header === undefined)) {
|
|
655
|
+
problems.push({
|
|
656
|
+
where: "/webhooks/route",
|
|
657
|
+
message: "a delivery is routed by exactly one of 'path' and 'header'",
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const { connection: connectionId, field } = webhooks.route;
|
|
661
|
+
const connection = (body.connections ?? []).find((entry) => entry.id === connectionId);
|
|
662
|
+
if (!connection || connection.scope !== "static") {
|
|
663
|
+
problems.push({
|
|
664
|
+
where: "/webhooks/route/connection",
|
|
665
|
+
message: `'${connectionId}' is not a static connection this plug-in declares`,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
else if (!connection.fields.some((entry) => entry.key === field)) {
|
|
669
|
+
problems.push({
|
|
670
|
+
where: "/webhooks/route/field",
|
|
671
|
+
message: `'${field}' is not a field of the connection '${connectionId}'`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return problems;
|
|
675
|
+
}
|
|
676
|
+
/** GitHub's answers that are kept as secrets. */
|
|
677
|
+
const SECRET_VALUES = new Set(["client_secret", "pem", "webhook_secret"]);
|
|
678
|
+
/**
|
|
679
|
+
* What a vendor setup writes to: each value is a field of the vendor block, an
|
|
680
|
+
* answer that is a secret goes to a secret field, and no answer is written
|
|
681
|
+
* twice.
|
|
682
|
+
*/
|
|
683
|
+
function setupProblems(body) {
|
|
684
|
+
const setup = body.vendor?.setup;
|
|
685
|
+
if (!setup)
|
|
686
|
+
return [];
|
|
687
|
+
const problems = [];
|
|
688
|
+
const fields = new Map(body.vendor.fields.map((field) => [field.key, field]));
|
|
689
|
+
const written = new Set();
|
|
690
|
+
for (const [key, value] of Object.entries(setup.values)) {
|
|
691
|
+
const where = `/vendor/setup/values/${key}`;
|
|
692
|
+
const field = fields.get(key);
|
|
693
|
+
if (!field) {
|
|
694
|
+
problems.push({ where, message: `'${key}' is not a field of the vendor block` });
|
|
695
|
+
}
|
|
696
|
+
else if (SECRET_VALUES.has(value) && field.type !== "secret") {
|
|
697
|
+
problems.push({ where, message: `'${value}' is a secret, and '${key}' is not a secret field` });
|
|
698
|
+
}
|
|
699
|
+
if (written.has(value)) {
|
|
700
|
+
problems.push({ where, message: `'${value}' is written to more than one field` });
|
|
701
|
+
}
|
|
702
|
+
written.add(value);
|
|
703
|
+
}
|
|
704
|
+
return problems;
|
|
705
|
+
}
|
|
706
|
+
/** Each schedule's interval is within the bounds, and no two share an id. */
|
|
707
|
+
function scheduleProblems(body) {
|
|
708
|
+
const problems = [];
|
|
709
|
+
const seen = new Set();
|
|
710
|
+
(body.schedules ?? []).forEach((schedule, index) => {
|
|
711
|
+
const where = `/schedules/${index}`;
|
|
712
|
+
if (seen.has(schedule.id)) {
|
|
713
|
+
problems.push({ where: `${where}/id`, message: `'${schedule.id}' is declared twice` });
|
|
714
|
+
}
|
|
715
|
+
seen.add(schedule.id);
|
|
716
|
+
problems.push(...intervalProblems(schedule.every, `${where}/every`));
|
|
717
|
+
});
|
|
718
|
+
return problems;
|
|
719
|
+
}
|
|
720
|
+
/** An interval, a schedule's or a health check's, within the schedule bounds. */
|
|
721
|
+
function intervalProblems(every, where) {
|
|
722
|
+
const count = Number(every.slice(0, -1));
|
|
723
|
+
const minutes = every.endsWith("h") ? count * 60 : count;
|
|
724
|
+
if (minutes >= CAPS.scheduleMinMinutes && minutes <= CAPS.scheduleMaxMinutes)
|
|
725
|
+
return [];
|
|
726
|
+
return [{ where, message: `every is at least ${CAPS.scheduleMinMinutes}m and at most ${CAPS.scheduleMaxMinutes / 60}h` }];
|
|
727
|
+
}
|
|
728
|
+
/** The endpoint terms that make it declarative. */
|
|
729
|
+
const DECLARATIVE_TERMS = ["request", "steps", "map", "errors"];
|
|
730
|
+
/**
|
|
731
|
+
* A plug-in is one kind or the other. A declarative plug-in — no `service` block —
|
|
732
|
+
* names its hosts, and every read and write it offers is a request and a map:
|
|
733
|
+
* there is no container for a handler, hook, schedule or surface to run in. A
|
|
734
|
+
* container plug-in uses none of the declarative terms.
|
|
735
|
+
*/
|
|
736
|
+
function kindProblems(body) {
|
|
737
|
+
const problems = [];
|
|
738
|
+
const push = (where, message) => problems.push({ where, message });
|
|
739
|
+
const endpoints = body.endpoints ?? [];
|
|
740
|
+
const connections = body.connections ?? [];
|
|
741
|
+
if (body.service === undefined) {
|
|
742
|
+
if (!body.hosts)
|
|
743
|
+
push("/hosts", "a declarative plug-in (one with no service block) names the hosts it calls");
|
|
744
|
+
if (body.schedules)
|
|
745
|
+
push("/schedules", "a declarative plug-in has no schedules: they call a container's hook");
|
|
746
|
+
if (body.embeds)
|
|
747
|
+
push("/embeds", "a declarative plug-in has no surfaces: a surface is a container's page");
|
|
748
|
+
endpoints.forEach((endpoint, index) => {
|
|
749
|
+
const where = `/endpoints/${index}`;
|
|
750
|
+
if (endpoint.direction === "emit") {
|
|
751
|
+
for (const key of DECLARATIVE_TERMS) {
|
|
752
|
+
if (endpoint[key] !== undefined)
|
|
753
|
+
push(`${where}/${key}`, "an emit endpoint makes no call: a webhook event emits it");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else if ((endpoint.request === undefined) === (endpoint.steps === undefined)) {
|
|
757
|
+
push(where, "a declarative endpoint gives exactly one of 'request' and 'steps'");
|
|
758
|
+
}
|
|
759
|
+
else if (endpoint.map === undefined) {
|
|
760
|
+
push(`${where}/map`, "a declarative endpoint maps its answer");
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
connections.forEach((connection, index) => {
|
|
764
|
+
const where = `/connections/${index}/flow`;
|
|
765
|
+
if (connection.flow?.after_connect === true) {
|
|
766
|
+
push(`${where}/after_connect`, "a declarative plug-in gives after_connect's request and map: there is no hook to call");
|
|
767
|
+
}
|
|
768
|
+
if (connection.flow?.revoke === "hook")
|
|
769
|
+
push(`${where}/revoke`, "a declarative plug-in has no revoke hook");
|
|
770
|
+
});
|
|
771
|
+
if (body.webhooks && !body.webhooks.events && !body.webhooks.status) {
|
|
772
|
+
push("/webhooks", "a declarative plug-in maps deliveries with 'events' or 'status': there is no hook to forward them to");
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
for (const key of ["hosts", "auth"]) {
|
|
777
|
+
if (body[key] !== undefined)
|
|
778
|
+
push(`/${key}`, `'${key}' is a declarative plug-in's term; a container plug-in makes its own calls`);
|
|
779
|
+
}
|
|
780
|
+
endpoints.forEach((endpoint, index) => {
|
|
781
|
+
for (const key of DECLARATIVE_TERMS) {
|
|
782
|
+
if (endpoint[key] !== undefined) {
|
|
783
|
+
push(`/endpoints/${index}/${key}`, "a container plug-in's endpoint is answered by its handler");
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
connections.forEach((connection, index) => {
|
|
788
|
+
if (typeof connection.flow?.after_connect === "object") {
|
|
789
|
+
push(`/connections/${index}/flow/after_connect`, "a container plug-in sets after_connect true and answers it in its hook");
|
|
790
|
+
}
|
|
791
|
+
if (connection.health)
|
|
792
|
+
push(`/connections/${index}/health`, "health is a declarative plug-in's: a container checks its own connections");
|
|
793
|
+
});
|
|
794
|
+
for (const key of ["events", "status"]) {
|
|
795
|
+
if (body.webhooks?.[key])
|
|
796
|
+
push(`/webhooks/${key}`, "a container plug-in's webhook hook receives each delivery");
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return problems;
|
|
800
|
+
}
|
|
801
|
+
/** The step names an expression reads at its root, as `steps.<name>` or `$$.steps.<name>`. */
|
|
802
|
+
function stepReads(node, names) {
|
|
803
|
+
if (Array.isArray(node)) {
|
|
804
|
+
for (const child of node)
|
|
805
|
+
stepReads(child, names);
|
|
806
|
+
}
|
|
807
|
+
else if (node && typeof node === "object") {
|
|
808
|
+
const { type, steps } = node;
|
|
809
|
+
if (type === "path" && Array.isArray(steps)) {
|
|
810
|
+
const start = steps[0]?.type === "variable" && steps[0].value === "$" ? 1 : 0;
|
|
811
|
+
if (steps[start]?.type === "name" && steps[start].value === "steps" && steps[start + 1]?.type === "name") {
|
|
812
|
+
names.add(String(steps[start + 1].value));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const child of Object.values(node))
|
|
816
|
+
stepReads(child, names);
|
|
817
|
+
}
|
|
818
|
+
return names;
|
|
819
|
+
}
|
|
820
|
+
/** True when a host is well formed: labels of 1 to 63 characters, not edged with '-', and a name rather than an address. */
|
|
821
|
+
function wellFormedHost(host) {
|
|
822
|
+
const labels = (host.startsWith("*.") ? host.slice(2) : host).split(".");
|
|
823
|
+
return (labels.every((label) => label.length <= 63 && !label.startsWith("-") && !label.endsWith("-")) &&
|
|
824
|
+
!/^[0-9]+$/.test(labels[labels.length - 1]));
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* What a declarative plug-in's terms say: every expression parses, every request
|
|
828
|
+
* names what it may, a request on a member's connection is named in the
|
|
829
|
+
* endpoint's `requires`, steps read only the steps before them, every event emits
|
|
830
|
+
* a declared emission, and each refusal names a code the endpoint has.
|
|
831
|
+
*/
|
|
832
|
+
function declarativeProblems(body) {
|
|
833
|
+
const problems = [];
|
|
834
|
+
const push = (where, message) => problems.push({ where, message });
|
|
835
|
+
const connectionIds = new Set((body.connections ?? []).map((connection) => connection.id));
|
|
836
|
+
const interactive = new Set((body.connections ?? []).filter((connection) => connection.scope === "interactive").map((connection) => connection.id));
|
|
837
|
+
const authHeader = (body.auth?.header ?? "Authorization").toLowerCase();
|
|
838
|
+
/** Parses, and with `steps` given, reads only those steps. */
|
|
839
|
+
const expression = (text, where, steps) => {
|
|
840
|
+
if (text === undefined)
|
|
841
|
+
return;
|
|
842
|
+
let ast;
|
|
843
|
+
try {
|
|
844
|
+
ast = parseExpression(text);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
const { message, position } = error;
|
|
848
|
+
push(where, `does not parse: ${message}${position === undefined ? "" : ` (at character ${position})`}`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (!steps)
|
|
852
|
+
return;
|
|
853
|
+
for (const name of stepReads(ast, new Set())) {
|
|
854
|
+
if (!steps.has(name))
|
|
855
|
+
push(where, `reads steps.${name}, which is not a step before it`);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
const request = (value, where, owned, steps) => {
|
|
859
|
+
expression(value.url, `${where}/url`, steps);
|
|
860
|
+
for (const [name, text] of Object.entries(value.query ?? {}))
|
|
861
|
+
expression(text, `${where}/query/${name}`, steps);
|
|
862
|
+
for (const [name, text] of Object.entries(value.headers ?? {})) {
|
|
863
|
+
expression(text, `${where}/headers/${name}`, steps);
|
|
864
|
+
if (name.toLowerCase() === authHeader) {
|
|
865
|
+
push(`${where}/headers/${name}`, "the credential's header is Initiative's to set");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
expression(value.body, `${where}/body`, steps);
|
|
869
|
+
expression(value.graphql?.variables, `${where}/graphql/variables`, steps);
|
|
870
|
+
if (value.body !== undefined && value.graphql !== undefined) {
|
|
871
|
+
push(where, "a request sends a body or a GraphQL query, not both");
|
|
872
|
+
}
|
|
873
|
+
if (value.graphql !== undefined && value.method !== "POST") {
|
|
874
|
+
push(`${where}/method`, "a GraphQL request is sent by POST");
|
|
875
|
+
}
|
|
876
|
+
if (owned && value.connection !== undefined) {
|
|
877
|
+
push(`${where}/connection`, "carries the credential of the connection it belongs to, and names none");
|
|
878
|
+
}
|
|
879
|
+
else if (!owned && value.connection === undefined) {
|
|
880
|
+
push(where, "names the connection whose credential it carries");
|
|
881
|
+
}
|
|
882
|
+
else if (value.connection !== undefined && !connectionIds.has(value.connection)) {
|
|
883
|
+
push(`${where}/connection`, `'${value.connection}' is not a connection this plug-in declares`);
|
|
884
|
+
}
|
|
885
|
+
const paging = value.paging;
|
|
886
|
+
if (paging) {
|
|
887
|
+
expression(paging.items, `${where}/paging/items`, steps);
|
|
888
|
+
if (paging.kind === "cursor") {
|
|
889
|
+
expression(paging.next, `${where}/paging/next`, steps);
|
|
890
|
+
expression(paging.more, `${where}/paging/more`, steps);
|
|
891
|
+
if ((paging.param === undefined) === (paging.variable === undefined)) {
|
|
892
|
+
push(`${where}/paging`, "a cursor is sent in exactly one of 'param' and 'variable'");
|
|
893
|
+
}
|
|
894
|
+
else if (paging.variable !== undefined && value.graphql === undefined) {
|
|
895
|
+
push(`${where}/paging/variable`, "a cursor is sent in a variable only of a GraphQL request");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
(body.hosts ?? []).forEach((host, index) => {
|
|
901
|
+
if (!wellFormedHost(host))
|
|
902
|
+
push(`/hosts/${index}`, `'${host}' is not a host name`);
|
|
903
|
+
});
|
|
904
|
+
const emits = new Set((body.endpoints ?? []).filter((endpoint) => endpoint.direction === "emit").map((endpoint) => endpoint.id));
|
|
905
|
+
(body.endpoints ?? []).forEach((endpoint, index) => {
|
|
906
|
+
const where = `/endpoints/${index}`;
|
|
907
|
+
const names = new Set();
|
|
908
|
+
// A member's own connection is resolved for the caller by `requires`, so a
|
|
909
|
+
// request carrying one names it there too.
|
|
910
|
+
const required = new Set(Object.values(endpoint.requires ?? {}).flat());
|
|
911
|
+
const memberConnection = (value, at) => {
|
|
912
|
+
const named = value.connection;
|
|
913
|
+
if (named !== undefined && interactive.has(named) && !required.has(named)) {
|
|
914
|
+
push(`${at}/connection`, `its request uses the member connection '${named}', which requires does not name`);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
if (endpoint.request) {
|
|
918
|
+
request(endpoint.request, `${where}/request`, false, names);
|
|
919
|
+
memberConnection(endpoint.request, `${where}/request`);
|
|
920
|
+
}
|
|
921
|
+
(endpoint.steps ?? []).forEach((step, position) => {
|
|
922
|
+
const at = `${where}/steps/${position}`;
|
|
923
|
+
request(step.request, `${at}/request`, false, new Set(names));
|
|
924
|
+
memberConnection(step.request, `${at}/request`);
|
|
925
|
+
if (names.has(step.name))
|
|
926
|
+
push(`${at}/name`, `'${step.name}' names two steps`);
|
|
927
|
+
names.add(step.name);
|
|
928
|
+
});
|
|
929
|
+
expression(endpoint.map, `${where}/map`, names);
|
|
930
|
+
const codes = new Set([...(endpoint.unavailable ?? []), ...PLATFORM_CODES, "transient"]);
|
|
931
|
+
(endpoint.errors ?? []).forEach((rule, position) => {
|
|
932
|
+
expression(rule.when, `${where}/errors/${position}/when`, names);
|
|
933
|
+
if (!codes.has(rule.code)) {
|
|
934
|
+
push(`${where}/errors/${position}/code`, `'${rule.code}' is not one of this endpoint's unavailable codes`);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
(body.connections ?? []).forEach((connection, index) => {
|
|
939
|
+
const where = `/connections/${index}`;
|
|
940
|
+
const after = connection.flow?.after_connect;
|
|
941
|
+
if (typeof after === "object") {
|
|
942
|
+
const at = `${where}/flow/after_connect`;
|
|
943
|
+
const names = new Set();
|
|
944
|
+
if (after.request)
|
|
945
|
+
request(after.request, `${at}/request`, true, names);
|
|
946
|
+
(after.steps ?? []).forEach((step, position) => {
|
|
947
|
+
request(step.request, `${at}/steps/${position}/request`, true, new Set(names));
|
|
948
|
+
if (names.has(step.name))
|
|
949
|
+
push(`${at}/steps/${position}/name`, `'${step.name}' names two steps`);
|
|
950
|
+
names.add(step.name);
|
|
951
|
+
});
|
|
952
|
+
expression(after.map, `${at}/map`, names);
|
|
953
|
+
expression(after.refuse_when, `${at}/refuse_when`, names);
|
|
954
|
+
if ((after.refuse_when === undefined) !== (after.code === undefined)) {
|
|
955
|
+
push(at, "a refusal gives both 'refuse_when' and the 'code' it answers");
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const health = connection.health;
|
|
959
|
+
if (health) {
|
|
960
|
+
request(health.request, `${where}/health/request`, true);
|
|
961
|
+
health.states.forEach((row, position) => expression(row.when, `${where}/health/states/${position}/when`));
|
|
962
|
+
problems.push(...intervalProblems(health.every, `${where}/health/every`));
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
(body.webhooks?.events ?? []).forEach((event, index) => {
|
|
966
|
+
const where = `/webhooks/events/${index}`;
|
|
967
|
+
expression(event.when, `${where}/when`);
|
|
968
|
+
expression(event.map, `${where}/map`);
|
|
969
|
+
if (!emits.has(event.emit))
|
|
970
|
+
push(`${where}/emit`, `'${event.emit}' is not an emit endpoint this plug-in declares`);
|
|
971
|
+
});
|
|
972
|
+
(body.webhooks?.status ?? []).forEach((row, index) => {
|
|
973
|
+
const where = `/webhooks/status/${index}`;
|
|
974
|
+
expression(row.when, `${where}/when`);
|
|
975
|
+
if (!connectionIds.has(row.connection)) {
|
|
976
|
+
push(`${where}/connection`, `'${row.connection}' is not a connection this plug-in declares`);
|
|
977
|
+
}
|
|
978
|
+
if (row.state === "unavailable") {
|
|
979
|
+
push(`${where}/state`, "a delivery says a connection is ok, suspended or removed");
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
return problems;
|
|
983
|
+
}
|
|
984
|
+
/** Every `{name}` in a template, in order. */
|
|
985
|
+
export function templateNames(value) {
|
|
986
|
+
const names = [];
|
|
987
|
+
let start = value.indexOf("{");
|
|
988
|
+
while (start !== -1) {
|
|
989
|
+
const end = value.indexOf("}", start + 1);
|
|
990
|
+
if (end === -1)
|
|
991
|
+
break;
|
|
992
|
+
names.push(value.slice(start + 1, end));
|
|
993
|
+
start = value.indexOf("{", end + 1);
|
|
994
|
+
}
|
|
995
|
+
return names;
|
|
996
|
+
}
|
|
997
|
+
//# sourceMappingURL=validate.js.map
|