@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.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/dist/action-schema.d.ts +42 -0
  4. package/dist/action-schema.d.ts.map +1 -0
  5. package/dist/action-schema.js +64 -0
  6. package/dist/action-schema.js.map +1 -0
  7. package/dist/actions.d.ts +199 -0
  8. package/dist/actions.d.ts.map +1 -0
  9. package/dist/actions.js +68 -0
  10. package/dist/actions.js.map +1 -0
  11. package/dist/api-version.d.ts +23 -0
  12. package/dist/api-version.d.ts.map +1 -0
  13. package/dist/api-version.js +27 -0
  14. package/dist/api-version.js.map +1 -0
  15. package/dist/catalog.d.ts +315 -0
  16. package/dist/catalog.d.ts.map +1 -0
  17. package/dist/catalog.js +203 -0
  18. package/dist/catalog.js.map +1 -0
  19. package/dist/define-plugin.d.ts +14 -0
  20. package/dist/define-plugin.d.ts.map +1 -0
  21. package/dist/define-plugin.js +15 -0
  22. package/dist/define-plugin.js.map +1 -0
  23. package/dist/findings.d.ts +67 -0
  24. package/dist/findings.d.ts.map +1 -0
  25. package/dist/findings.js +57 -0
  26. package/dist/findings.js.map +1 -0
  27. package/dist/host.d.ts +208 -0
  28. package/dist/host.d.ts.map +1 -0
  29. package/dist/host.js +41 -0
  30. package/dist/host.js.map +1 -0
  31. package/dist/index.d.ts +24 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +22 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/integrity.d.ts +38 -0
  36. package/dist/integrity.d.ts.map +1 -0
  37. package/dist/integrity.js +105 -0
  38. package/dist/integrity.js.map +1 -0
  39. package/dist/manifest.d.ts +392 -0
  40. package/dist/manifest.d.ts.map +1 -0
  41. package/dist/manifest.js +324 -0
  42. package/dist/manifest.js.map +1 -0
  43. package/dist/module.d.ts +35 -0
  44. package/dist/module.d.ts.map +1 -0
  45. package/dist/module.js +2 -0
  46. package/dist/module.js.map +1 -0
  47. package/dist/package-check.d.ts +66 -0
  48. package/dist/package-check.d.ts.map +1 -0
  49. package/dist/package-check.js +755 -0
  50. package/dist/package-check.js.map +1 -0
  51. package/dist/permissions.d.ts +65 -0
  52. package/dist/permissions.d.ts.map +1 -0
  53. package/dist/permissions.js +69 -0
  54. package/dist/permissions.js.map +1 -0
  55. package/dist/settings-schema.d.ts +95 -0
  56. package/dist/settings-schema.d.ts.map +1 -0
  57. package/dist/settings-schema.js +219 -0
  58. package/dist/settings-schema.js.map +1 -0
  59. package/dist/standard-schema.d.ts +43 -0
  60. package/dist/standard-schema.d.ts.map +1 -0
  61. package/dist/standard-schema.js +2 -0
  62. package/dist/standard-schema.js.map +1 -0
  63. package/package.json +55 -3
  64. package/src/action-schema.ts +86 -0
  65. package/src/actions.ts +247 -0
  66. package/src/api-version.ts +28 -0
  67. package/src/catalog.ts +236 -0
  68. package/src/define-plugin.ts +18 -0
  69. package/src/findings.ts +71 -0
  70. package/src/host.ts +240 -0
  71. package/src/index.ts +123 -0
  72. package/src/integrity.ts +125 -0
  73. package/src/manifest.ts +385 -0
  74. package/src/module.ts +38 -0
  75. package/src/package-check.ts +894 -0
  76. package/src/permissions.ts +75 -0
  77. package/src/settings-schema.ts +272 -0
  78. package/src/standard-schema.ts +39 -0
  79. package/README.md +0 -3
@@ -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>;