@openbrf/plugin-sdk 0.0.0-stage → 1.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 +661 -0
- package/LICENSE-EXCEPTION.md +28 -0
- package/dist/action-schema.d.ts +42 -0
- package/dist/action-schema.d.ts.map +1 -0
- package/dist/action-schema.js +64 -0
- package/dist/action-schema.js.map +1 -0
- package/dist/actions.d.ts +199 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +68 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-version.d.ts +23 -0
- package/dist/api-version.d.ts.map +1 -0
- package/dist/api-version.js +27 -0
- package/dist/api-version.js.map +1 -0
- package/dist/catalog.d.ts +315 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +203 -0
- package/dist/catalog.js.map +1 -0
- package/dist/define-plugin.d.ts +14 -0
- package/dist/define-plugin.d.ts.map +1 -0
- package/dist/define-plugin.js +15 -0
- package/dist/define-plugin.js.map +1 -0
- package/dist/findings.d.ts +67 -0
- package/dist/findings.d.ts.map +1 -0
- package/dist/findings.js +57 -0
- package/dist/findings.js.map +1 -0
- package/dist/host.d.ts +208 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +41 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +38 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +105 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +392 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +324 -0
- package/dist/manifest.js.map +1 -0
- package/dist/module.d.ts +35 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +2 -0
- package/dist/module.js.map +1 -0
- package/dist/package-check.d.ts +66 -0
- package/dist/package-check.d.ts.map +1 -0
- package/dist/package-check.js +755 -0
- package/dist/package-check.js.map +1 -0
- package/dist/permissions.d.ts +65 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +69 -0
- package/dist/permissions.js.map +1 -0
- package/dist/settings-schema.d.ts +95 -0
- package/dist/settings-schema.d.ts.map +1 -0
- package/dist/settings-schema.js +219 -0
- package/dist/settings-schema.js.map +1 -0
- package/dist/standard-schema.d.ts +43 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +2 -0
- package/dist/standard-schema.js.map +1 -0
- package/package.json +55 -3
- package/src/action-schema.ts +86 -0
- package/src/actions.ts +247 -0
- package/src/api-version.ts +28 -0
- package/src/catalog.ts +236 -0
- package/src/define-plugin.ts +18 -0
- package/src/findings.ts +71 -0
- package/src/host.ts +240 -0
- package/src/index.ts +123 -0
- package/src/integrity.ts +125 -0
- package/src/manifest.ts +385 -0
- package/src/module.ts +38 -0
- package/src/package-check.ts +894 -0
- package/src/permissions.ts +75 -0
- package/src/settings-schema.ts +272 -0
- package/src/standard-schema.ts +39 -0
- package/README.md +0 -3
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ACTION_EFFECTS,
|
|
5
|
+
ACTION_NAME_PATTERN,
|
|
6
|
+
ACTION_PERSONAL_DATA,
|
|
7
|
+
ACTION_SURFACES,
|
|
8
|
+
} from "./actions.ts";
|
|
9
|
+
import { PLUGIN_API_VERSION } from "./api-version.ts";
|
|
10
|
+
import {
|
|
11
|
+
PLUGIN_PERMISSIONS,
|
|
12
|
+
PLUGIN_PERSONAL_DATA_CATEGORIES,
|
|
13
|
+
} from "./permissions.ts";
|
|
14
|
+
import { pluginSettingsSchema } from "./settings-schema.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The plugin manifest.
|
|
18
|
+
*
|
|
19
|
+
* It lives in the `openbrf` field of the plugin's own package.json rather
|
|
20
|
+
* than in a separate file: npm already installs package.json, already
|
|
21
|
+
* validates its name and version, and already refuses to install a package
|
|
22
|
+
* without one. A second manifest file could go missing, disagree with the
|
|
23
|
+
* package it sits in, or be left behind by a partial extraction, and the
|
|
24
|
+
* loader would have to decide which of the two to believe.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A plugin id.
|
|
29
|
+
*
|
|
30
|
+
* Used as a URL segment (`/api/plugin/<id>/...`), an i18n namespace
|
|
31
|
+
* (`plugin-<id>`), a database key and a directory name, so it is restricted to
|
|
32
|
+
* what is safe in all four. In particular it may not contain a dot or a
|
|
33
|
+
* slash: a plugin id is resolved against paths, and `..` must not be
|
|
34
|
+
* expressible.
|
|
35
|
+
*/
|
|
36
|
+
export const pluginIdSchema = z
|
|
37
|
+
.string()
|
|
38
|
+
.min(2)
|
|
39
|
+
.max(48)
|
|
40
|
+
.regex(
|
|
41
|
+
/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
|
|
42
|
+
"must be lowercase letters, digits and single hyphens, starting with a letter",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A path inside the plugin package.
|
|
47
|
+
*
|
|
48
|
+
* Relative and normalized by the schema's own rules rather than by the
|
|
49
|
+
* loader: an entry point is read from a manifest that arrived over the
|
|
50
|
+
* network, so "../../../etc/passwd" has to be rejected where the shape is
|
|
51
|
+
* defined and not at each of the places that later join it onto a directory.
|
|
52
|
+
*/
|
|
53
|
+
const packageRelativePathSchema = z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1)
|
|
56
|
+
.max(200)
|
|
57
|
+
.refine((value) => !value.startsWith("/"), "must be relative")
|
|
58
|
+
.refine(
|
|
59
|
+
(value) => !value.split("/").includes(".."),
|
|
60
|
+
"must not step outside the package",
|
|
61
|
+
)
|
|
62
|
+
.refine((value) => !value.includes("\0"), "must not contain a null byte");
|
|
63
|
+
|
|
64
|
+
export const pluginEntrySchema = z
|
|
65
|
+
.object({
|
|
66
|
+
/**
|
|
67
|
+
* Prebuilt CJS bundle whose only externals are host packages and Node's
|
|
68
|
+
* built-in modules (ADR 0003).
|
|
69
|
+
* Optional: a plugin may contribute a view and no backend behaviour.
|
|
70
|
+
*/
|
|
71
|
+
server: packageRelativePathSchema.optional(),
|
|
72
|
+
/**
|
|
73
|
+
* Module Federation remote entry, served to the browser and loaded at
|
|
74
|
+
* runtime. Optional: a plugin may contribute a background job and no view.
|
|
75
|
+
*/
|
|
76
|
+
client: packageRelativePathSchema.optional(),
|
|
77
|
+
})
|
|
78
|
+
.refine(
|
|
79
|
+
(entry) => entry.server !== undefined || entry.client !== undefined,
|
|
80
|
+
"a plugin must declare at least one entry point",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A route under the plugin's own mount at `/api/plugin/<id>/`.
|
|
85
|
+
*
|
|
86
|
+
* Written without a leading slash and joined onto that mount by the host, for
|
|
87
|
+
* the reason the package-relative schema exists: the value arrives in a
|
|
88
|
+
* manifest fetched over the network, and it decides a URL an unauthenticated
|
|
89
|
+
* caller will be pointed at, so what it may contain is settled here rather
|
|
90
|
+
* than at each place that joins it.
|
|
91
|
+
*/
|
|
92
|
+
const pluginRoutePathSchema = z
|
|
93
|
+
.string()
|
|
94
|
+
.min(1)
|
|
95
|
+
.max(100)
|
|
96
|
+
.regex(
|
|
97
|
+
/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/,
|
|
98
|
+
"must be lowercase path segments separated by single slashes, with no leading or trailing slash",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One action the plugin proposes.
|
|
103
|
+
*
|
|
104
|
+
* Declared in the manifest rather than at registration, because the board reads
|
|
105
|
+
* it on the install consent screen before anything has been downloaded, and
|
|
106
|
+
* nothing that can refuse a plugin may run after the code that executes it. The
|
|
107
|
+
* plugin's bundle supplies the schemas and the handler; what it may ask for is
|
|
108
|
+
* settled here.
|
|
109
|
+
*
|
|
110
|
+
* `capability` is a free string because this package cannot import the core
|
|
111
|
+
* capability union - it is published to plugin authors and the union is the
|
|
112
|
+
* instance's. It is resolved against the real list at the boot gate, which also
|
|
113
|
+
* refuses the ones no action of any kind may hold.
|
|
114
|
+
*/
|
|
115
|
+
export const pluginActionSchema = z.object({
|
|
116
|
+
id: z.string().regex(/^[a-z][a-z0-9_]{2,31}$/),
|
|
117
|
+
capability: z.string().min(1).max(64),
|
|
118
|
+
effect: z.enum(ACTION_EFFECTS),
|
|
119
|
+
personalData: z.array(z.enum(ACTION_PERSONAL_DATA)).max(14).default([]),
|
|
120
|
+
surfaces: z.array(z.enum(ACTION_SURFACES)).max(3).default(["ui"]),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The declared actions, as every boundary that reads them must read them.
|
|
125
|
+
*
|
|
126
|
+
* One schema rather than the same array written twice, because the invariant
|
|
127
|
+
* below has to hold at both: the catalog entry the consent screen renders from,
|
|
128
|
+
* and the manifest the loader enforces against it.
|
|
129
|
+
*
|
|
130
|
+
* An id appears once. Two declarations sharing one id are not a redundancy that
|
|
131
|
+
* sorts itself out - each of the three places that reads them resolves the
|
|
132
|
+
* collision differently. The binder takes the LAST, through a Map; consent
|
|
133
|
+
* compares canonical strings as a SET, so reordering the pair leaves the
|
|
134
|
+
* comparison equal and asks the board nothing; and arming stores the bare id,
|
|
135
|
+
* which names both. A republished version that only swapped the order of a
|
|
136
|
+
* duplicated pair would therefore keep its arming and go live at the OTHER
|
|
137
|
+
* declaration's capability, personal data and surfaces, with nobody having
|
|
138
|
+
* decided that. An action is one operation with one capability; two of them
|
|
139
|
+
* wearing one id is not a declaration this can read.
|
|
140
|
+
*
|
|
141
|
+
* Undefaulted, so each use site says for itself whether an absent list means
|
|
142
|
+
* "none declared" or "you did not answer". A manifest and a catalog entry mean
|
|
143
|
+
* the first and add `.default([])`; the install request means the second, since
|
|
144
|
+
* a board's echo that silently became an empty list would read as consent to no
|
|
145
|
+
* action at all - and a plugin that declares one would then be refused with a
|
|
146
|
+
* mismatch nothing the board could do would satisfy.
|
|
147
|
+
*/
|
|
148
|
+
export const pluginActionsSchema = declaredActionsSchema(pluginActionSchema);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The declared actions over a given reading of one action.
|
|
152
|
+
*
|
|
153
|
+
* The manifest reads an action as `pluginActionSchema` does; the catalog index
|
|
154
|
+
* reads the same fields strictly, because the index refuses a field its
|
|
155
|
+
* version does not define. Both keep the rule above, which is why the list is
|
|
156
|
+
* built here rather than written again.
|
|
157
|
+
*/
|
|
158
|
+
export function declaredActionsSchema<T extends z.ZodType<{ id: string }>>(
|
|
159
|
+
action: T,
|
|
160
|
+
) {
|
|
161
|
+
return z
|
|
162
|
+
.array(action)
|
|
163
|
+
.max(16)
|
|
164
|
+
.superRefine((actions, ctx) => {
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
for (const [index, declared] of actions.entries()) {
|
|
167
|
+
if (seen.has(declared.id)) {
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: "custom",
|
|
170
|
+
path: [index, "id"],
|
|
171
|
+
message: `two actions are declared with the id "${declared.id}"`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
seen.add(declared.id);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The public name a declared action is offered under.
|
|
181
|
+
*
|
|
182
|
+
* The plugin's id with dashes folded to underscores, then the action's id. The
|
|
183
|
+
* folding has a consequence worth knowing: a plugin `a-b` declaring `c` and a
|
|
184
|
+
* plugin `a` declaring `b_c` compose the same name, so the registry refuses the
|
|
185
|
+
* second one to arrive and docs/plugin-contract.md says so.
|
|
186
|
+
*/
|
|
187
|
+
export function composedActionName(pluginId: string, actionId: string): string {
|
|
188
|
+
return `${pluginId.replaceAll("-", "_")}_${actionId}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const pluginManifestSchema = z
|
|
192
|
+
.object({
|
|
193
|
+
/**
|
|
194
|
+
* The contract version this plugin was built against. The loader refuses
|
|
195
|
+
* anything it does not implement rather than loading it and hoping.
|
|
196
|
+
*/
|
|
197
|
+
apiVersion: z.int().min(1),
|
|
198
|
+
id: pluginIdSchema,
|
|
199
|
+
entry: pluginEntrySchema,
|
|
200
|
+
permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(16).default([]),
|
|
201
|
+
personalData: z
|
|
202
|
+
.array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
|
|
203
|
+
.max(16)
|
|
204
|
+
.default([]),
|
|
205
|
+
settingsSchema: pluginSettingsSchema.optional(),
|
|
206
|
+
/**
|
|
207
|
+
* Where the view is mounted in the admin interface, when the plugin has
|
|
208
|
+
* one. The label is an i18n key in the plugin's own namespace.
|
|
209
|
+
*/
|
|
210
|
+
view: z
|
|
211
|
+
.object({
|
|
212
|
+
/** Named export of the remote module, per Module Federation. */
|
|
213
|
+
module: z.string().min(1).max(100).default("./View"),
|
|
214
|
+
titleKey: z.string().min(1).max(200),
|
|
215
|
+
})
|
|
216
|
+
.optional(),
|
|
217
|
+
/** What this plugin proposes the platform be able to do. */
|
|
218
|
+
actions: pluginActionsSchema.default([]),
|
|
219
|
+
/**
|
|
220
|
+
* The route, under this plugin's own mount, that serves MCP.
|
|
221
|
+
*
|
|
222
|
+
* Declaring it makes that route the instance's OAuth protected resource:
|
|
223
|
+
* its full URL becomes the audience every issued token is bound to, the
|
|
224
|
+
* discovery documents point at it, and the route stops accepting the
|
|
225
|
+
* browser's session cookie and accepts only a Bearer token issued for it.
|
|
226
|
+
*
|
|
227
|
+
* At most one installed plugin may declare it, because the audience is a
|
|
228
|
+
* single URL and moving it would strand every token already issued. A
|
|
229
|
+
* second one is refused at install.
|
|
230
|
+
*/
|
|
231
|
+
oauthProtectedResource: pluginRoutePathSchema.optional(),
|
|
232
|
+
})
|
|
233
|
+
.superRefine((manifest, ctx) => {
|
|
234
|
+
/*
|
|
235
|
+
* The composed name has to fit the public pattern, and only the pair can
|
|
236
|
+
* know whether it does: a plugin id runs to 48 characters and an action id to
|
|
237
|
+
* 32, which composes to 81 against a limit of 64. Checked pairwise rather
|
|
238
|
+
* than by shortening the action id to a worst case, because that would charge
|
|
239
|
+
* every plugin for the longest possible plugin id and forbid
|
|
240
|
+
* `monthly_occupancy_report` to a plugin called `brf`, where the composed
|
|
241
|
+
* name is 28 characters.
|
|
242
|
+
*
|
|
243
|
+
* Refused here rather than at registration so that it is manifest-invalid
|
|
244
|
+
* before the install consent screen, instead of a finding after the plugin's
|
|
245
|
+
* code has already run.
|
|
246
|
+
*/
|
|
247
|
+
for (const [index, action] of manifest.actions.entries()) {
|
|
248
|
+
const composed = composedActionName(manifest.id, action.id);
|
|
249
|
+
if (!ACTION_NAME_PATTERN.test(composed)) {
|
|
250
|
+
ctx.addIssue({
|
|
251
|
+
code: "custom",
|
|
252
|
+
path: ["actions", index, "id"],
|
|
253
|
+
message: `the public name "${composed}" is ${String(composed.length)} characters; a plugin id and an action id compose to at most 64`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export type PluginManifest = z.infer<typeof pluginManifestSchema>;
|
|
260
|
+
export type PluginActionDeclaration = z.infer<typeof pluginActionSchema>;
|
|
261
|
+
export type PluginEntry = z.infer<typeof pluginEntrySchema>;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Dependency fields npm installs at install time.
|
|
265
|
+
*
|
|
266
|
+
* The installer omits peer, dev and optional dependencies and runs no scripts,
|
|
267
|
+
* but it still hands npm the package: anything listed here would send that npm
|
|
268
|
+
* to a registry, which an instance never contacts. optionalDependencies is
|
|
269
|
+
* listed although it is omitted, because a package relying on one being
|
|
270
|
+
* present works in development and quietly not on an instance.
|
|
271
|
+
*/
|
|
272
|
+
export const RUNTIME_DEPENDENCY_FIELDS = [
|
|
273
|
+
"dependencies",
|
|
274
|
+
"optionalDependencies",
|
|
275
|
+
"bundleDependencies",
|
|
276
|
+
"bundledDependencies",
|
|
277
|
+
] as const;
|
|
278
|
+
|
|
279
|
+
/** Package names in a dependency field, whether a map or npm's bundle list. */
|
|
280
|
+
export function dependencyNames(value: unknown): string[] {
|
|
281
|
+
if (Array.isArray(value)) {
|
|
282
|
+
return value.filter((name): name is string => typeof name === "string");
|
|
283
|
+
}
|
|
284
|
+
return typeof value === "object" && value !== null ? Object.keys(value) : [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Whether a dependency field is an empty map or an empty list. */
|
|
288
|
+
export function isEmptyDependencyField(value: unknown): boolean {
|
|
289
|
+
if (Array.isArray(value)) {
|
|
290
|
+
return value.length === 0;
|
|
291
|
+
}
|
|
292
|
+
return (
|
|
293
|
+
typeof value === "object" &&
|
|
294
|
+
value !== null &&
|
|
295
|
+
Object.keys(value).length === 0
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A dependency field that names nothing: absent, an empty map or an empty
|
|
301
|
+
* list. Anything else is refused, a string included, since npm reads even
|
|
302
|
+
* that as something to install. See RUNTIME_DEPENDENCY_FIELDS for why.
|
|
303
|
+
*
|
|
304
|
+
* Part of the schema rather than a check beside it, so the loader refuses such
|
|
305
|
+
* a package at boot as well as the catalog refusing to list it.
|
|
306
|
+
*/
|
|
307
|
+
const noRuntimeDependencies = z
|
|
308
|
+
.unknown()
|
|
309
|
+
.superRefine((value, ctx) => {
|
|
310
|
+
const names = dependencyNames(value);
|
|
311
|
+
if (names.length > 0) {
|
|
312
|
+
ctx.addIssue({
|
|
313
|
+
code: "custom",
|
|
314
|
+
message: `names ${names.join(", ")}; a plugin declares no runtime dependencies, because the installer resolves nothing from a registry`,
|
|
315
|
+
});
|
|
316
|
+
} else if (!isEmptyDependencyField(value)) {
|
|
317
|
+
ctx.addIssue({
|
|
318
|
+
code: "custom",
|
|
319
|
+
message:
|
|
320
|
+
"is neither an empty map nor an empty list; a plugin declares no runtime dependencies, because the installer resolves nothing from a registry",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
.optional();
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The subset of package.json the loader reads. Everything else in the file is
|
|
328
|
+
* npm's business, apart from the dependency fields, which must name nothing.
|
|
329
|
+
*/
|
|
330
|
+
export const pluginPackageSchema = z.object({
|
|
331
|
+
name: z.string().min(1),
|
|
332
|
+
version: z.string().min(1),
|
|
333
|
+
openbrf: pluginManifestSchema,
|
|
334
|
+
dependencies: noRuntimeDependencies,
|
|
335
|
+
optionalDependencies: noRuntimeDependencies,
|
|
336
|
+
bundleDependencies: noRuntimeDependencies,
|
|
337
|
+
bundledDependencies: noRuntimeDependencies,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
export type PluginPackage = z.infer<typeof pluginPackageSchema>;
|
|
341
|
+
|
|
342
|
+
export interface ManifestParseFailure {
|
|
343
|
+
ok: false;
|
|
344
|
+
/** One line per problem, already naming the field. */
|
|
345
|
+
issues: string[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type ManifestParseResult =
|
|
349
|
+
{ ok: true; value: PluginPackage } | ManifestParseFailure;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parses a plugin's package.json.
|
|
353
|
+
*
|
|
354
|
+
* Returns a result rather than throwing, because the only caller that matters
|
|
355
|
+
* is the boot-time scan, and a malformed plugin directory must be skipped and
|
|
356
|
+
* reported rather than being allowed to take the register offline (ADR 0003).
|
|
357
|
+
* A thrown error would make "skip and report" the caller's discipline instead
|
|
358
|
+
* of the function's contract.
|
|
359
|
+
*/
|
|
360
|
+
export function parsePluginPackage(input: unknown): ManifestParseResult {
|
|
361
|
+
const result = pluginPackageSchema.safeParse(input);
|
|
362
|
+
if (result.success) {
|
|
363
|
+
return { ok: true, value: result.data };
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
issues: result.error.issues.map(
|
|
368
|
+
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
|
369
|
+
),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Convenience for a plugin's own CI: throws with every problem at once. */
|
|
374
|
+
export function assertPluginPackage(input: unknown): PluginPackage {
|
|
375
|
+
const result = parsePluginPackage(input);
|
|
376
|
+
if (!result.ok) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Invalid Open BRF plugin manifest:\n ${result.issues.join("\n ")}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
return result.value;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The api version a plugin authored against this SDK should declare. */
|
|
385
|
+
export const CURRENT_PLUGIN_API_VERSION = PLUGIN_API_VERSION;
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DynamicModule } from "@nestjs/common";
|
|
2
|
+
|
|
3
|
+
import type { PluginHost } from "./host.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a plugin's server entry point returns.
|
|
7
|
+
*
|
|
8
|
+
* A plugin's backend is a NestJS module. Its controllers, providers, guards,
|
|
9
|
+
* interceptors and lifecycle hooks are the framework's own and behave exactly
|
|
10
|
+
* as they do in the application itself, which is what lets a plugin be written
|
|
11
|
+
* the way any NestJS code is written rather than against a second, narrower
|
|
12
|
+
* interface that has to grow a feature at a time.
|
|
13
|
+
*
|
|
14
|
+
* The host registers the module in the application's graph while the process
|
|
15
|
+
* starts, so the plugin's controllers are part of the router the application
|
|
16
|
+
* builds. Four things the host does to that module are not negotiable, and a
|
|
17
|
+
* plugin cannot opt out of any of them:
|
|
18
|
+
*
|
|
19
|
+
* Its controllers are mounted under `/api/plugin/<id>/`. A path a controller
|
|
20
|
+
* declares is relative to that prefix, whatever it says.
|
|
21
|
+
*
|
|
22
|
+
* Its routes sit inside the application's own authorization guard, with the
|
|
23
|
+
* capability each one requires raised to the floor the plugin's declared
|
|
24
|
+
* permissions imply. A plugin cannot mark a route public.
|
|
25
|
+
*
|
|
26
|
+
* Its module may not register application-wide behaviour: a global guard,
|
|
27
|
+
* interceptor, filter or pipe, middleware, or a `@Global()` module. Those
|
|
28
|
+
* would apply to the core's routes as well as its own.
|
|
29
|
+
*
|
|
30
|
+
* Its routes stop answering the moment the board switches the plugin off,
|
|
31
|
+
* without waiting for a restart.
|
|
32
|
+
*
|
|
33
|
+
* A module that breaks one of the last two is refused at load and reported on
|
|
34
|
+
* the admin screen, the same way a malformed manifest is.
|
|
35
|
+
*/
|
|
36
|
+
export type PluginModuleFactory = (
|
|
37
|
+
host: PluginHost,
|
|
38
|
+
) => DynamicModule | Promise<DynamicModule>;
|