@openbrf/plugin-sdk 0.0.0-stage → 1.2.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 (134) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +94 -2
  4. package/dist/abort-signal.d.ts +23 -0
  5. package/dist/abort-signal.d.ts.map +1 -0
  6. package/dist/abort-signal.js +2 -0
  7. package/dist/abort-signal.js.map +1 -0
  8. package/dist/action-refusal.d.ts +101 -0
  9. package/dist/action-refusal.d.ts.map +1 -0
  10. package/dist/action-refusal.js +96 -0
  11. package/dist/action-refusal.js.map +1 -0
  12. package/dist/action-schema.d.ts +42 -0
  13. package/dist/action-schema.d.ts.map +1 -0
  14. package/dist/action-schema.js +67 -0
  15. package/dist/action-schema.js.map +1 -0
  16. package/dist/actions.d.ts +287 -0
  17. package/dist/actions.d.ts.map +1 -0
  18. package/dist/actions.js +68 -0
  19. package/dist/actions.js.map +1 -0
  20. package/dist/api-version.d.ts +23 -0
  21. package/dist/api-version.d.ts.map +1 -0
  22. package/dist/api-version.js +27 -0
  23. package/dist/api-version.js.map +1 -0
  24. package/dist/blocks.d.ts +310 -0
  25. package/dist/blocks.d.ts.map +1 -0
  26. package/dist/blocks.js +185 -0
  27. package/dist/blocks.js.map +1 -0
  28. package/dist/catalog.d.ts +707 -0
  29. package/dist/catalog.d.ts.map +1 -0
  30. package/dist/catalog.js +567 -0
  31. package/dist/catalog.js.map +1 -0
  32. package/dist/define-plugin.d.ts +14 -0
  33. package/dist/define-plugin.d.ts.map +1 -0
  34. package/dist/define-plugin.js +15 -0
  35. package/dist/define-plugin.js.map +1 -0
  36. package/dist/entitlements.d.ts +117 -0
  37. package/dist/entitlements.d.ts.map +1 -0
  38. package/dist/entitlements.js +62 -0
  39. package/dist/entitlements.js.map +1 -0
  40. package/dist/extensions.d.ts +183 -0
  41. package/dist/extensions.d.ts.map +1 -0
  42. package/dist/extensions.js +67 -0
  43. package/dist/extensions.js.map +1 -0
  44. package/dist/finances.d.ts +294 -0
  45. package/dist/finances.d.ts.map +1 -0
  46. package/dist/finances.js +56 -0
  47. package/dist/finances.js.map +1 -0
  48. package/dist/findings.d.ts +156 -0
  49. package/dist/findings.d.ts.map +1 -0
  50. package/dist/findings.js +146 -0
  51. package/dist/findings.js.map +1 -0
  52. package/dist/host.d.ts +827 -0
  53. package/dist/host.d.ts.map +1 -0
  54. package/dist/host.js +80 -0
  55. package/dist/host.js.map +1 -0
  56. package/dist/imports.d.ts +104 -0
  57. package/dist/imports.d.ts.map +1 -0
  58. package/dist/imports.js +70 -0
  59. package/dist/imports.js.map +1 -0
  60. package/dist/index.d.ts +34 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +31 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/integrity.d.ts +38 -0
  65. package/dist/integrity.d.ts.map +1 -0
  66. package/dist/integrity.js +105 -0
  67. package/dist/integrity.js.map +1 -0
  68. package/dist/localized-text.d.ts +18 -0
  69. package/dist/localized-text.d.ts.map +1 -0
  70. package/dist/localized-text.js +17 -0
  71. package/dist/localized-text.js.map +1 -0
  72. package/dist/manifest.d.ts +1122 -0
  73. package/dist/manifest.d.ts.map +1 -0
  74. package/dist/manifest.js +1118 -0
  75. package/dist/manifest.js.map +1 -0
  76. package/dist/module.d.ts +35 -0
  77. package/dist/module.d.ts.map +1 -0
  78. package/dist/module.js +2 -0
  79. package/dist/module.js.map +1 -0
  80. package/dist/package-check.d.ts +66 -0
  81. package/dist/package-check.d.ts.map +1 -0
  82. package/dist/package-check.js +755 -0
  83. package/dist/package-check.js.map +1 -0
  84. package/dist/permissions.d.ts +224 -0
  85. package/dist/permissions.d.ts.map +1 -0
  86. package/dist/permissions.js +277 -0
  87. package/dist/permissions.js.map +1 -0
  88. package/dist/recipients.d.ts +139 -0
  89. package/dist/recipients.d.ts.map +1 -0
  90. package/dist/recipients.js +223 -0
  91. package/dist/recipients.js.map +1 -0
  92. package/dist/records.d.ts +130 -0
  93. package/dist/records.d.ts.map +1 -0
  94. package/dist/records.js +85 -0
  95. package/dist/records.js.map +1 -0
  96. package/dist/require-capability.d.ts +37 -0
  97. package/dist/require-capability.d.ts.map +1 -0
  98. package/dist/require-capability.js +88 -0
  99. package/dist/require-capability.js.map +1 -0
  100. package/dist/settings-schema.d.ts +130 -0
  101. package/dist/settings-schema.d.ts.map +1 -0
  102. package/dist/settings-schema.js +272 -0
  103. package/dist/settings-schema.js.map +1 -0
  104. package/dist/standard-schema.d.ts +43 -0
  105. package/dist/standard-schema.d.ts.map +1 -0
  106. package/dist/standard-schema.js +2 -0
  107. package/dist/standard-schema.js.map +1 -0
  108. package/package.json +55 -3
  109. package/src/abort-signal.ts +22 -0
  110. package/src/action-refusal.ts +178 -0
  111. package/src/action-schema.ts +89 -0
  112. package/src/actions.ts +344 -0
  113. package/src/api-version.ts +28 -0
  114. package/src/blocks.ts +289 -0
  115. package/src/catalog.ts +679 -0
  116. package/src/define-plugin.ts +18 -0
  117. package/src/entitlements.ts +150 -0
  118. package/src/extensions.ts +212 -0
  119. package/src/finances.ts +317 -0
  120. package/src/findings.ts +163 -0
  121. package/src/host.ts +894 -0
  122. package/src/imports.ts +135 -0
  123. package/src/index.ts +316 -0
  124. package/src/integrity.ts +125 -0
  125. package/src/localized-text.ts +19 -0
  126. package/src/manifest.ts +1412 -0
  127. package/src/module.ts +38 -0
  128. package/src/package-check.ts +894 -0
  129. package/src/permissions.ts +295 -0
  130. package/src/recipients.ts +294 -0
  131. package/src/records.ts +172 -0
  132. package/src/require-capability.ts +106 -0
  133. package/src/settings-schema.ts +334 -0
  134. package/src/standard-schema.ts +39 -0
@@ -0,0 +1,1412 @@
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 { pluginBlocksSchema } from "./blocks.ts";
11
+ import {
12
+ EXTENSION_POINT_ID_PATTERN,
13
+ MAX_EXTENSION_POINTS,
14
+ } from "./extensions.ts";
15
+ import { PLUGIN_MANIFEST_RULES, type PluginManifestRule } from "./findings.ts";
16
+ import {
17
+ PLUGIN_PERMISSIONS,
18
+ PLUGIN_PERSONAL_DATA_CATEGORIES,
19
+ PROPOSE_IMPORT_ONLY_PERSONAL_DATA,
20
+ PROPOSE_IMPORT_PERSONAL_DATA,
21
+ readsRegister,
22
+ REGISTER_READING_PERMISSIONS,
23
+ } from "./permissions.ts";
24
+ import {
25
+ pluginRecipientsSchema,
26
+ recipientCategoryIssues,
27
+ } from "./recipients.ts";
28
+ import { pluginSettingsSchema } from "./settings-schema.ts";
29
+
30
+ /**
31
+ * The plugin manifest.
32
+ *
33
+ * It lives in the `openbrf` field of the plugin's own package.json rather
34
+ * than in a separate file: npm already installs package.json, already
35
+ * validates its name and version, and already refuses to install a package
36
+ * without one. A second manifest file could go missing, disagree with the
37
+ * package it sits in, or be left behind by a partial extraction, and the
38
+ * loader would have to decide which of the two to believe.
39
+ */
40
+
41
+ /**
42
+ * A plugin id.
43
+ *
44
+ * Used as a URL segment (`/api/plugin/<id>/...`), an i18n namespace
45
+ * (`plugin-<id>`), a database key and a directory name, so it is restricted to
46
+ * what is safe in all four. In particular it may not contain a dot or a
47
+ * slash: a plugin id is resolved against paths, and `..` must not be
48
+ * expressible.
49
+ */
50
+ export const pluginIdSchema = z
51
+ .string()
52
+ .min(2)
53
+ .max(48)
54
+ .regex(
55
+ /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
56
+ "must be lowercase letters, digits and single hyphens, starting with a letter",
57
+ );
58
+
59
+ /**
60
+ * A path inside the plugin package.
61
+ *
62
+ * Relative and normalized by the schema's own rules rather than by the
63
+ * loader: an entry point is read from a manifest that arrived over the
64
+ * network, so "../../../etc/passwd" has to be rejected where the shape is
65
+ * defined and not at each of the places that later join it onto a directory.
66
+ */
67
+ const packageRelativePathSchema = z
68
+ .string()
69
+ .min(1)
70
+ .max(200)
71
+ .refine((value) => !value.startsWith("/"), "must be relative")
72
+ .refine(
73
+ (value) => !value.split("/").includes(".."),
74
+ "must not step outside the package",
75
+ )
76
+ .refine((value) => !value.includes("\0"), "must not contain a null byte");
77
+
78
+ export const pluginEntrySchema = z
79
+ .object({
80
+ /**
81
+ * Prebuilt CJS bundle whose only externals are host packages and Node's
82
+ * built-in modules (ADR 0003).
83
+ * Optional: a plugin may contribute a view and no backend behaviour.
84
+ */
85
+ server: packageRelativePathSchema.optional(),
86
+ /**
87
+ * Module Federation remote entry, served to the browser and loaded at
88
+ * runtime. Optional: a plugin may contribute a background job and no view.
89
+ */
90
+ client: packageRelativePathSchema.optional(),
91
+ })
92
+ .refine(
93
+ (entry) => entry.server !== undefined || entry.client !== undefined,
94
+ "a plugin must declare at least one entry point",
95
+ );
96
+
97
+ /**
98
+ * A route under the plugin's own mount at `/api/plugin/<id>/`.
99
+ *
100
+ * Written without a leading slash and joined onto that mount by the host, for
101
+ * the reason the package-relative schema exists: the value arrives in a
102
+ * manifest fetched over the network, and it decides a URL an unauthenticated
103
+ * caller will be pointed at, so what it may contain is settled here rather
104
+ * than at each place that joins it.
105
+ */
106
+ const pluginRoutePathSchema = z
107
+ .string()
108
+ .min(1)
109
+ .max(100)
110
+ .regex(
111
+ /^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/,
112
+ "must be lowercase path segments separated by single slashes, with no leading or trailing slash",
113
+ );
114
+
115
+ /**
116
+ * One action the plugin proposes.
117
+ *
118
+ * Declared in the manifest rather than at registration, because the board reads
119
+ * it on the install consent screen before anything has been downloaded, and
120
+ * nothing that can refuse a plugin may run after the code that executes it. The
121
+ * plugin's bundle supplies the schemas and the handler; what it may ask for is
122
+ * settled here.
123
+ *
124
+ * `capability` is a free string because this package cannot import the core
125
+ * capability union - it is published to plugin authors and the union is the
126
+ * instance's. It is resolved against the real list at the boot gate, which also
127
+ * refuses the ones no action of any kind may hold.
128
+ */
129
+ export const pluginActionSchema = z.object({
130
+ id: z.string().regex(/^[a-z][a-z0-9_]{2,31}$/),
131
+ capability: z.string().min(1).max(64),
132
+ effect: z.enum(ACTION_EFFECTS),
133
+ personalData: z.array(z.enum(ACTION_PERSONAL_DATA)).max(14).default([]),
134
+ surfaces: z.array(z.enum(ACTION_SURFACES)).max(3).default(["ui"]),
135
+ });
136
+
137
+ /**
138
+ * The declared actions, as every boundary that reads them must read them.
139
+ *
140
+ * One schema rather than the same array written twice, because the invariant
141
+ * below has to hold at both: the catalog entry the consent screen renders from,
142
+ * and the manifest the loader enforces against it.
143
+ *
144
+ * An id appears once. Two declarations sharing one id are not a redundancy that
145
+ * sorts itself out - each of the three places that reads them resolves the
146
+ * collision differently. The binder takes the LAST, through a Map; consent
147
+ * compares canonical strings as a SET, so reordering the pair leaves the
148
+ * comparison equal and asks the board nothing; and arming stores the bare id,
149
+ * which names both. A republished version that only swapped the order of a
150
+ * duplicated pair would therefore keep its arming and go live at the OTHER
151
+ * declaration's capability, personal data and surfaces, with nobody having
152
+ * decided that. An action is one operation with one capability; two of them
153
+ * wearing one id is not a declaration this can read.
154
+ *
155
+ * Undefaulted, so each use site says for itself whether an absent list means
156
+ * "none declared" or "you did not answer". A manifest and a catalog entry mean
157
+ * the first and add `.default([])`; the install request means the second, since
158
+ * a board's echo that silently became an empty list would read as consent to no
159
+ * action at all - and a plugin that declares one would then be refused with a
160
+ * mismatch nothing the board could do would satisfy.
161
+ */
162
+ export const pluginActionsSchema = declaredActionsSchema(pluginActionSchema);
163
+
164
+ /**
165
+ * The declared actions over a given reading of one action.
166
+ *
167
+ * The manifest reads an action as `pluginActionSchema` does; the catalog index
168
+ * reads the same fields strictly, because the index refuses a field its
169
+ * version does not define. Both keep the rule above, which is why the list is
170
+ * built here rather than written again.
171
+ */
172
+ export function declaredActionsSchema<T extends z.ZodType<{ id: string }>>(
173
+ action: T,
174
+ ) {
175
+ return z
176
+ .array(action)
177
+ .max(16)
178
+ .superRefine((actions, ctx) => {
179
+ const seen = new Set<string>();
180
+ for (const [index, declared] of actions.entries()) {
181
+ if (seen.has(declared.id)) {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: [index, "id"],
185
+ message: `two actions are declared with the id "${declared.id}"`,
186
+ });
187
+ }
188
+ seen.add(declared.id);
189
+ }
190
+ });
191
+ }
192
+
193
+ /**
194
+ * The public name a declared action is offered under.
195
+ *
196
+ * The plugin's id with dashes folded to underscores, then the action's id. The
197
+ * folding has a consequence worth knowing: a plugin `a-b` declaring `c` and a
198
+ * plugin `a` declaring `b_c` compose the same name, so the registry refuses the
199
+ * second one to arrive and docs/plugin-contract.md says so.
200
+ */
201
+ export function composedActionName(pluginId: string, actionId: string): string {
202
+ return `${pluginId.replaceAll("-", "_")}_${actionId}`;
203
+ }
204
+
205
+ /**
206
+ * The sections of the application's navigation a plugin's view may stand in.
207
+ *
208
+ * The application's own four, in the order its band shows them: the
209
+ * association and its life, the building and the apartments in it, the board's
210
+ * own desk, and the settings. A plugin names one of them rather than a section
211
+ * of its own, so a view is found where a destination of its kind already is.
212
+ */
213
+ export const PLUGIN_VIEW_SECTIONS = [
214
+ "association",
215
+ "building",
216
+ "board",
217
+ "settings",
218
+ ] as const;
219
+
220
+ export type PluginViewSection = (typeof PLUGIN_VIEW_SECTIONS)[number];
221
+
222
+ export const pluginViewSchema = z.object({
223
+ /** Named export of the remote module, per Module Federation. */
224
+ module: z.string().min(1).max(100).default("./View"),
225
+ titleKey: z.string().min(1).max(200),
226
+ /**
227
+ * The section of the navigation the view is offered in, under its title.
228
+ *
229
+ * Optional, and a view without one is reachable by its address alone, as
230
+ * every view was before the field existed. Naming a section asks for an
231
+ * entry rather than granting one: the host offers it only to an account that
232
+ * holds what the plugin's routes require, and in the board's section only to
233
+ * the board, because that section holds nothing a resident is offered.
234
+ */
235
+ section: z.enum(PLUGIN_VIEW_SECTIONS).optional(),
236
+ });
237
+
238
+ /** The id of an extension point, unique within the plugin that declares it. */
239
+ export const extensionPointIdSchema = z
240
+ .string()
241
+ .min(2)
242
+ .max(32)
243
+ .regex(
244
+ EXTENSION_POINT_ID_PATTERN,
245
+ "must be lowercase letters, digits and single hyphens, starting with a letter",
246
+ );
247
+
248
+ /**
249
+ * One extension point the plugin offers other plugins (ADR 0030).
250
+ *
251
+ * Declared here rather than at registration for the reason an action is: the
252
+ * board reads what the point accepts on the consent screen before anything is
253
+ * downloaded. The bundle supplies the schemas; what may cross is settled here.
254
+ */
255
+ export const pluginExtensionPointSchema = z.object({
256
+ id: extensionPointIdSchema,
257
+ /**
258
+ * The point's major version, raised when a change to its schemas would break
259
+ * an addon written for the last one.
260
+ */
261
+ version: z.int().min(1),
262
+ /**
263
+ * The categories of personal data a contribution may hand the plugin
264
+ * through this point. Among the plugin's own `personalData`, because what an
265
+ * addon hands it is handled by it as much as what it reads itself.
266
+ */
267
+ personalData: z
268
+ .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
269
+ .max(16)
270
+ .default([]),
271
+ });
272
+
273
+ /** One extension point of another plugin that this plugin extends. */
274
+ export const pluginExtensionSchema = z.object({
275
+ /** The id of the host plugin. */
276
+ plugin: pluginIdSchema,
277
+ /** The id of the extension point. */
278
+ point: extensionPointIdSchema,
279
+ /** The point's major version the addon was written against. */
280
+ version: z.int().min(1),
281
+ /**
282
+ * The categories the contribution hands the host plugin. Among the addon's
283
+ * own `personalData`, and among what the point accepts, which the host
284
+ * checks against the host plugin's manifest when both are loaded.
285
+ */
286
+ personalData: z
287
+ .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
288
+ .max(16)
289
+ .default([]),
290
+ });
291
+
292
+ /**
293
+ * The declared extension points over a given reading of one point.
294
+ *
295
+ * The manifest reads a point as `pluginExtensionPointSchema` does; the catalog
296
+ * index reads it strictly and with the curator's title and description. Both
297
+ * keep the rule that an id appears once, which is why the list is built here.
298
+ */
299
+ export function declaredExtensionPointsSchema<
300
+ T extends z.ZodType<{ id: string }>,
301
+ >(point: T) {
302
+ return z
303
+ .array(point)
304
+ .max(MAX_EXTENSION_POINTS)
305
+ .superRefine((points, ctx) => {
306
+ const seen = new Set<string>();
307
+ for (const [index, declared] of points.entries()) {
308
+ if (seen.has(declared.id)) {
309
+ ctx.addIssue({
310
+ code: "custom",
311
+ path: [index, "id"],
312
+ message: `two extension points are declared with the id "${declared.id}"`,
313
+ });
314
+ }
315
+ seen.add(declared.id);
316
+ }
317
+ });
318
+ }
319
+
320
+ /**
321
+ * The declared extensions over a given reading of one, each pair of plugin and
322
+ * point once: an addon contributes once to each point it extends.
323
+ */
324
+ export function declaredExtensionsSchema<
325
+ T extends z.ZodType<{ plugin: string; point: string }>,
326
+ >(extension: T) {
327
+ return z
328
+ .array(extension)
329
+ .max(MAX_EXTENSION_POINTS)
330
+ .superRefine((extensions, ctx) => {
331
+ const seen = new Set<string>();
332
+ for (const [index, declared] of extensions.entries()) {
333
+ const key = `${declared.plugin}/${declared.point}`;
334
+ if (seen.has(key)) {
335
+ ctx.addIssue({
336
+ code: "custom",
337
+ path: [index, "point"],
338
+ message: `"${declared.point}" of "${declared.plugin}" is extended twice`,
339
+ });
340
+ }
341
+ seen.add(key);
342
+ }
343
+ });
344
+ }
345
+
346
+ /** What the rules between a plugin's extension declarations read. */
347
+ export interface ExtensionDeclarations {
348
+ readonly id: string;
349
+ readonly permissions: readonly string[];
350
+ readonly personalData: readonly string[];
351
+ readonly extensionPoints: readonly {
352
+ readonly personalData: readonly string[];
353
+ }[];
354
+ readonly extends: readonly {
355
+ readonly plugin: string;
356
+ readonly personalData: readonly string[];
357
+ }[];
358
+ }
359
+
360
+ /**
361
+ * The rules between a plugin's extension declarations and the rest of its
362
+ * declaration (ADR 0030), as issues with the path each one is at.
363
+ *
364
+ * One function for the manifest and the catalog entry, so an entry the loader
365
+ * would refuse is never offered for consent.
366
+ */
367
+ export function extensionRuleIssues(
368
+ declaration: ExtensionDeclarations,
369
+ ): { path: (string | number)[]; message: string }[] {
370
+ const issues: { path: (string | number)[]; message: string }[] = [];
371
+ const own = new Set(declaration.personalData);
372
+
373
+ /*
374
+ * One level. An addon that could be extended in turn would make "what can
375
+ * reach this screen" a question the board could not answer from the two
376
+ * consent screens in front of it.
377
+ */
378
+ if (
379
+ declaration.extends.length > 0 &&
380
+ declaration.extensionPoints.length > 0
381
+ ) {
382
+ issues.push({
383
+ path: ["extensionPoints"],
384
+ message:
385
+ "a plugin that extends another declares no extension points of its own",
386
+ });
387
+ }
388
+
389
+ for (const [index, point] of declaration.extensionPoints.entries()) {
390
+ const foreign = point.personalData.filter((category) => !own.has(category));
391
+ if (foreign.length > 0) {
392
+ issues.push({
393
+ path: ["extensionPoints", index, "personalData"],
394
+ message: `${foreign.join(", ")} must be among the plugin's own personalData: what an addon hands a plugin is handled by it`,
395
+ });
396
+ }
397
+ /*
398
+ * What a website block renders is published to the street, and
399
+ * `site:blocks` already keeps the address book away from such a plugin
400
+ * (ADR 0006). A point that accepted names would be the same path through a
401
+ * second plugin.
402
+ */
403
+ if (
404
+ declaration.permissions.includes("site:blocks") &&
405
+ point.personalData.length > 0
406
+ ) {
407
+ issues.push({
408
+ path: ["extensionPoints", index, "personalData"],
409
+ message:
410
+ 'a plugin that asks for "site:blocks" declares extension points that accept no personal data',
411
+ });
412
+ }
413
+ }
414
+
415
+ for (const [index, extension] of declaration.extends.entries()) {
416
+ if (extension.plugin === declaration.id) {
417
+ issues.push({
418
+ path: ["extends", index, "plugin"],
419
+ message: "a plugin does not extend itself",
420
+ });
421
+ }
422
+ const foreign = extension.personalData.filter(
423
+ (category) => !own.has(category),
424
+ );
425
+ if (foreign.length > 0) {
426
+ issues.push({
427
+ path: ["extends", index, "personalData"],
428
+ message: `${foreign.join(", ")} must be among the plugin's own personalData`,
429
+ });
430
+ }
431
+ }
432
+
433
+ return issues;
434
+ }
435
+
436
+ /**
437
+ * The permissions in a list that reach the members' details, which a plugin
438
+ * that puts blocks on the website may not hold, and may not be handed data
439
+ * from (ADR 0006): those that read the register, following
440
+ * `PERMISSION_REGISTER_ACCESS`, and `addressBook:proposeImport`. That one reads
441
+ * nothing in the register, so it is not classified as a read, but the plugin
442
+ * holds the member list it proposes, identity numbers and protected persons
443
+ * included (ADR 0043).
444
+ */
445
+ function memberDataPermissions(permissions: readonly string[]): string[] {
446
+ return permissions.filter(
447
+ (permission) =>
448
+ (REGISTER_READING_PERMISSIONS as readonly string[]).includes(
449
+ permission,
450
+ ) || permission === "addressBook:proposeImport",
451
+ );
452
+ }
453
+
454
+ /**
455
+ * The permissions that keep an addon and the plugin it extends apart, or an
456
+ * empty list when there are none (ADR 0030): those of whichever of the two
457
+ * does not ask for `site:blocks` that read the register or propose an import,
458
+ * when the other does.
459
+ *
460
+ * A plugin that asks for `site:blocks` reads nothing in the register and holds
461
+ * no member list, so what it publishes cannot name a resident. An extension is
462
+ * a path between two plugins in both directions: the host hands the addon the
463
+ * point's input, and the addon hands the host its output. A website host's
464
+ * points accept no personal data, but an addon that reads the register, or
465
+ * holds the member list it proposes, could still return a name in a field the
466
+ * point types as text, and only the addon's own `personalData` would say it
467
+ * does not. A website addon extending such a host could be handed names as the
468
+ * point's input, keep them, and render them in a block. So the rule reads what
469
+ * each side can reach, from the two manifests, rather than what either
470
+ * declares it hands over. Like the rule on a single plugin, it holds an honest
471
+ * pair to the website's argument rather than a hostile one.
472
+ */
473
+ export function extensionPermissionsRefused(
474
+ addon: { readonly permissions: readonly string[] },
475
+ host: { readonly permissions: readonly string[] },
476
+ ): string[] {
477
+ return [
478
+ ...(host.permissions.includes("site:blocks")
479
+ ? memberDataPermissions(addon.permissions)
480
+ : []),
481
+ ...(addon.permissions.includes("site:blocks")
482
+ ? memberDataPermissions(host.permissions)
483
+ : []),
484
+ ];
485
+ }
486
+
487
+ /**
488
+ * The personal data a plugin handles that keeps an addon asking for
489
+ * `site:blocks` off it, or an empty list when there is none (ADR 0030): the
490
+ * host plugin's own `personalData`, when the addon asks for `site:blocks`.
491
+ *
492
+ * The point's input is the one thing that crosses an extension and that no
493
+ * manifest declares. A host plugin that reads nothing in the register can
494
+ * still be handed names by another of its addons, and then hand them on as a
495
+ * point's input. A host plugin that handles no personal data has none to hand
496
+ * on, so a website addon extends only such a plugin.
497
+ */
498
+ export function extensionHostPersonalDataRefused(
499
+ addon: { readonly permissions: readonly string[] },
500
+ host: { readonly personalData: readonly string[] },
501
+ ): string[] {
502
+ return addon.permissions.includes("site:blocks")
503
+ ? [...host.personalData]
504
+ : [];
505
+ }
506
+
507
+ /** Why a plugin's point cannot serve an addon's extension of it. */
508
+ export type ExtensionPointRefusal =
509
+ | { readonly reason: "extension-point-unknown" }
510
+ | {
511
+ readonly reason: "extension-version-unsupported";
512
+ readonly supported: number;
513
+ }
514
+ | {
515
+ readonly reason: "extension-personal-data-refused";
516
+ readonly categories: string[];
517
+ };
518
+
519
+ /**
520
+ * Why the host plugin's points cannot serve an addon's extension, or null when
521
+ * they can (ADR 0030): the host declares no such point, declares it at another
522
+ * major version, or the point does not accept a personal-data category the
523
+ * addon hands over.
524
+ *
525
+ * Both the loader and the catalog read this, so an index that lists an addon
526
+ * beside the plugin it extends is refused for the three refusals the loader
527
+ * would give those two declarations. What the installed packages declare is
528
+ * the loader's to read.
529
+ */
530
+ export function extensionPointRefusal(
531
+ extension: {
532
+ readonly point: string;
533
+ readonly version: number;
534
+ readonly personalData: readonly string[];
535
+ },
536
+ hostPoints: readonly {
537
+ readonly id: string;
538
+ readonly version: number;
539
+ readonly personalData: readonly string[];
540
+ }[],
541
+ ): ExtensionPointRefusal | null {
542
+ const point = hostPoints.find((declared) => declared.id === extension.point);
543
+ if (point === undefined) {
544
+ return { reason: "extension-point-unknown" };
545
+ }
546
+ if (point.version !== extension.version) {
547
+ return {
548
+ reason: "extension-version-unsupported",
549
+ supported: point.version,
550
+ };
551
+ }
552
+ const accepted = new Set(point.personalData);
553
+ const categories = extension.personalData.filter(
554
+ (category) => !accepted.has(category),
555
+ );
556
+ return categories.length > 0
557
+ ? { reason: "extension-personal-data-refused", categories }
558
+ : null;
559
+ }
560
+
561
+ /** What the rule on a group of plugins joined by extensions reads. */
562
+ export interface ExtensionGroupMember {
563
+ readonly id: string;
564
+ readonly permissions: readonly string[];
565
+ readonly extends: readonly { readonly plugin: string }[];
566
+ }
567
+
568
+ /** Why an addon's group keeps it from serving. */
569
+ export interface ExtensionGroupRefusal {
570
+ /** The plugins in its group that ask for `site:blocks`. */
571
+ readonly website: string[];
572
+ /**
573
+ * The plugins in its group that read the register or propose an import,
574
+ * and so reach the members' details.
575
+ */
576
+ readonly register: string[];
577
+ }
578
+
579
+ /**
580
+ * The plugins joined to one another by extensions, each group in the order
581
+ * of `members`. An extension joins an addon to its host plugin whichever way
582
+ * data crosses it, and an addon that extends two plugins joins both groups.
583
+ */
584
+ function extensionGroups<Member extends ExtensionGroupMember>(
585
+ members: readonly Member[],
586
+ ): Member[][] {
587
+ const root = new Map(members.map((member) => [member.id, member.id]));
588
+ const find = (id: string): string => {
589
+ let current = id;
590
+ let parent = root.get(current);
591
+ while (parent !== undefined && parent !== current) {
592
+ current = parent;
593
+ parent = root.get(current);
594
+ }
595
+ return current;
596
+ };
597
+ for (const member of members) {
598
+ for (const extension of member.extends) {
599
+ if (root.has(extension.plugin)) {
600
+ root.set(find(member.id), find(extension.plugin));
601
+ }
602
+ }
603
+ }
604
+ const groups = new Map<string, Member[]>();
605
+ for (const member of members) {
606
+ const group = groups.get(find(member.id)) ?? [];
607
+ group.push(member);
608
+ groups.set(find(member.id), group);
609
+ }
610
+ return [...groups.values()];
611
+ }
612
+
613
+ /**
614
+ * Which addons give way, in turn, while a group still holds both a plugin
615
+ * that asks for `site:blocks` and one that reads the register or proposes an
616
+ * import: the website addons first, then the addons that read the register or
617
+ * propose an import, then the addons that join two of the group's plugins. The
618
+ * host plugins never do, because every other addon of theirs would go with
619
+ * them; once the addons of the first two kinds are gone, the two sides are
620
+ * host plugins, and what still joins them is an addon that extends both.
621
+ */
622
+ const GROUP_YIELDS: readonly ((
623
+ member: ExtensionGroupMember,
624
+ standing: ReadonlySet<string>,
625
+ ) => boolean)[] = [
626
+ (member) => member.permissions.includes("site:blocks"),
627
+ (member) => memberDataPermissions(member.permissions).length > 0,
628
+ (member, standing) =>
629
+ new Set(
630
+ member.extends
631
+ .map((extension) => extension.plugin)
632
+ .filter((plugin) => standing.has(plugin)),
633
+ ).size > 1,
634
+ ];
635
+
636
+ /**
637
+ * The addons refused because the plugins joined to them by extensions hold
638
+ * both a plugin that asks for `site:blocks` and one that reads the register or
639
+ * proposes an import, by id (ADR 0030).
640
+ *
641
+ * The rule between an addon and its host plugin reads two manifests. Data
642
+ * also crosses between two addons of one host plugin, and further: an addon
643
+ * that reads the register, or holds the member list it proposes, hands the
644
+ * host plugin a name while declaring nothing, the host plugin hands it to a
645
+ * website addon as a point's input, and the website addon renders it. Neither manifest of any one pair says so.
646
+ * So the plugins joined by extensions, through one host plugin or several,
647
+ * are read as one group, and a group never holds both kinds.
648
+ *
649
+ * The answer depends only on the set of members, never on their order, so it
650
+ * is the same whichever order the loader finds the packages in. Addons give
651
+ * way in the order of `GROUP_YIELDS`, which keeps the plugin that reads the
652
+ * register or proposes an import serving and refuses the website addon beside
653
+ * it.
654
+ */
655
+ export function extensionGroupRefusals(
656
+ members: readonly ExtensionGroupMember[],
657
+ ): Map<string, ExtensionGroupRefusal> {
658
+ const refused = new Map<string, ExtensionGroupRefusal>();
659
+ for (const yields of GROUP_YIELDS) {
660
+ const standing = members.filter((member) => !refused.has(member.id));
661
+ const ids = new Set(standing.map((member) => member.id));
662
+ for (const group of extensionGroups(standing)) {
663
+ const website = group
664
+ .filter((member) => member.permissions.includes("site:blocks"))
665
+ .map((member) => member.id);
666
+ const register = group
667
+ .filter(
668
+ (member) => memberDataPermissions(member.permissions).length > 0,
669
+ )
670
+ .map((member) => member.id);
671
+ if (website.length === 0 || register.length === 0) {
672
+ continue;
673
+ }
674
+ for (const member of group) {
675
+ if (member.extends.length > 0 && yields(member, ids)) {
676
+ refused.set(member.id, { website, register });
677
+ }
678
+ }
679
+ }
680
+ }
681
+ return refused;
682
+ }
683
+
684
+ /**
685
+ * One device route: a path under the plugin's own mount that answers to a
686
+ * device token and to nothing else (ADR 0031).
687
+ *
688
+ * Declared in the manifest because the board reads it on the install consent
689
+ * screen, and because the guard has to know which paths take no session cookie
690
+ * before any of the plugin's code has run.
691
+ */
692
+ export const pluginDeviceRouteSchema = z.object({
693
+ /** The handle a token is issued for. */
694
+ id: z
695
+ .string()
696
+ .min(2)
697
+ .max(32)
698
+ .regex(
699
+ /^[a-z0-9]+(-[a-z0-9]+)*$/,
700
+ "must be lowercase letters, digits and single hyphens",
701
+ ),
702
+ /** The path, which covers itself and everything below it. */
703
+ path: pluginRoutePathSchema,
704
+ });
705
+
706
+ /** At most this many device routes per plugin. */
707
+ export const MAX_DEVICE_ROUTES = 4;
708
+
709
+ /**
710
+ * Whether one route path is the other or lies below it, segment by segment.
711
+ *
712
+ * `screen` and `screen/pictures` overlap; `screen` and `screens` do not.
713
+ */
714
+ export function routePathsOverlap(left: string, right: string): boolean {
715
+ return (
716
+ left === right ||
717
+ left.startsWith(`${right}/`) ||
718
+ right.startsWith(`${left}/`)
719
+ );
720
+ }
721
+
722
+ /**
723
+ * The declared device routes, as every boundary that reads them must read
724
+ * them: the manifest and the catalog entry the consent screen renders from.
725
+ *
726
+ * An id names one route, and no two routes overlap. A token is issued for one
727
+ * route by its id, and a request is matched to one route by its path, so a
728
+ * duplicated id or a path inside another route's would leave the guard two
729
+ * answers to choose from.
730
+ */
731
+ export function declaredDeviceRoutesSchema<
732
+ T extends z.ZodType<{ id: string; path: string }>,
733
+ >(route: T) {
734
+ return z
735
+ .array(route)
736
+ .max(MAX_DEVICE_ROUTES)
737
+ .superRefine((routes, ctx) => {
738
+ for (const [index, declared] of routes.entries()) {
739
+ for (const earlier of routes.slice(0, index)) {
740
+ if (earlier.id === declared.id) {
741
+ ctx.addIssue({
742
+ code: "custom",
743
+ path: [index, "id"],
744
+ message: `two device routes are declared with the id "${declared.id}"`,
745
+ });
746
+ }
747
+ if (routePathsOverlap(earlier.path, declared.path)) {
748
+ ctx.addIssue({
749
+ code: "custom",
750
+ path: [index, "path"],
751
+ message: `the device route "${declared.path}" overlaps "${earlier.path}"`,
752
+ });
753
+ }
754
+ }
755
+ }
756
+ });
757
+ }
758
+
759
+ export const pluginDeviceRoutesSchema = declaredDeviceRoutesSchema(
760
+ pluginDeviceRouteSchema,
761
+ );
762
+
763
+ /**
764
+ * The rules a declaration of device routes has to keep beside the rest of the
765
+ * manifest, for the manifest and the catalog entry alike.
766
+ *
767
+ * A device route may not overlap the OAuth protected resource, which takes a
768
+ * different token on its own branch of the guard, and a plugin with a device
769
+ * route reads nothing from the register: a device is a screen on a wall that
770
+ * anyone passing reads (ADR 0031).
771
+ */
772
+ export function deviceRouteRuleIssues(declaration: {
773
+ deviceRoutes: readonly { id: string; path: string }[];
774
+ oauthProtectedResource?: string | undefined;
775
+ permissions: readonly string[];
776
+ }): {
777
+ path: (string | number)[];
778
+ message: string;
779
+ rule: PluginManifestRule;
780
+ }[] {
781
+ const issues: {
782
+ path: (string | number)[];
783
+ message: string;
784
+ rule: PluginManifestRule;
785
+ }[] = [];
786
+ if (declaration.deviceRoutes.length === 0) {
787
+ return issues;
788
+ }
789
+ const resource = declaration.oauthProtectedResource;
790
+ for (const [index, route] of declaration.deviceRoutes.entries()) {
791
+ if (resource !== undefined && routePathsOverlap(route.path, resource)) {
792
+ issues.push({
793
+ path: ["deviceRoutes", index, "path"],
794
+ message: `the device route "${route.path}" overlaps the oauthProtectedResource "${resource}"`,
795
+ rule: "device-route-overlaps-oauth-resource",
796
+ });
797
+ }
798
+ }
799
+ // The finances too: a fee notice and a charge carry the names of the
800
+ // people they are for, as they do for `site:blocks`.
801
+ if (readsRegister(declaration.permissions)) {
802
+ issues.push({
803
+ path: ["permissions"],
804
+ message:
805
+ "a plugin that declares deviceRoutes may not also read the address book or the finances: what a device shows, anyone passing it reads",
806
+ rule: "device-route-reads-register",
807
+ });
808
+ }
809
+ // And the member list a plugin that proposes an import holds, for the same
810
+ // reason.
811
+ if (declaration.permissions.includes("addressBook:proposeImport")) {
812
+ issues.push({
813
+ path: ["permissions"],
814
+ message:
815
+ "a plugin that declares deviceRoutes may not also propose imports: it holds the member list, and what a device shows, anyone passing it reads",
816
+ rule: "device-route-proposes-import",
817
+ });
818
+ }
819
+ return issues;
820
+ }
821
+
822
+ /**
823
+ * What a plugin that proposes an import declares about the data it carries,
824
+ * for the manifest and the catalog entry alike (ADR 0043).
825
+ *
826
+ * The file is the association's member list, and the plugin carries it
827
+ * whole: every name, contact detail and personal identity number, and rows
828
+ * about persons with protected personal data, which the file does not mark.
829
+ * So the board is told so on the consent screen. And no other plugin can reach
830
+ * an identity number or a protected person, so for any other plugin either
831
+ * category would describe something that cannot happen.
832
+ */
833
+ export function proposeImportPersonalDataIssues(declaration: {
834
+ permissions: readonly string[];
835
+ personalData: readonly string[];
836
+ }): { message: string }[] {
837
+ const proposes = declaration.permissions.includes(
838
+ "addressBook:proposeImport",
839
+ );
840
+ if (proposes) {
841
+ const missing = PROPOSE_IMPORT_PERSONAL_DATA.filter(
842
+ (category) => !declaration.personalData.includes(category),
843
+ );
844
+ return missing.length === 0
845
+ ? []
846
+ : [
847
+ {
848
+ message: `a plugin that asks for "addressBook:proposeImport" carries the member list and must declare ${missing.map((category) => `"${category}"`).join(", ")}`,
849
+ },
850
+ ];
851
+ }
852
+ return PROPOSE_IMPORT_ONLY_PERSONAL_DATA.filter((category) =>
853
+ declaration.personalData.includes(category),
854
+ ).map((category) => ({
855
+ message: `only a plugin that asks for "addressBook:proposeImport" may declare "${category}": no other plugin can reach it`,
856
+ }));
857
+ }
858
+
859
+ /**
860
+ * A calendar date written `YYYY-MM-DD`, and a real one: `2027-02-30` is
861
+ * refused rather than rolled over into March.
862
+ */
863
+ export function isCalendarDay(text: string): boolean {
864
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
865
+ if (match === null) {
866
+ return false;
867
+ }
868
+ const [year, month, day] = [match[1], match[2], match[3]].map(Number) as [
869
+ number,
870
+ number,
871
+ number,
872
+ ];
873
+ const instant = new Date(Date.UTC(year, month - 1, day));
874
+ return (
875
+ instant.getUTCFullYear() === year &&
876
+ instant.getUTCMonth() === month - 1 &&
877
+ instant.getUTCDate() === day
878
+ );
879
+ }
880
+
881
+ const calendarDaySchema = z
882
+ .string()
883
+ .refine(isCalendarDay, "must be a calendar date, YYYY-MM-DD");
884
+
885
+ /**
886
+ * Which of an issuer's keys signed a licence (ADR 0042).
887
+ *
888
+ * It selects the keys to try and asserts nothing: two plugins may declare one
889
+ * key id with different keys, and a key is known by its fingerprint.
890
+ */
891
+ export const licenceKeyIdSchema = z
892
+ .string()
893
+ .regex(
894
+ /^[a-z0-9-]{1,64}$/,
895
+ "must be 1 to 64 lowercase letters, digits and hyphens",
896
+ );
897
+
898
+ /**
899
+ * An Ed25519 public key: the base64 of its 32 raw bytes, padded, and in the
900
+ * one spelling that decodes to them. The last character before the padding
901
+ * carries two bits no key has, which is what the class of letters it may be
902
+ * from says.
903
+ */
904
+ export const licencePublicKeySchema = z
905
+ .string()
906
+ .regex(
907
+ /^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/,
908
+ "must be the base64 of a 32-byte Ed25519 public key",
909
+ );
910
+
911
+ /**
912
+ * A service a plugin may present the instance's licence to: an `https` origin
913
+ * as the WHATWG URL standard serialises one, so it compares exactly.
914
+ *
915
+ * A lower-case domain in punycode, a port only when it is not 443, and no
916
+ * userinfo, path, query or fragment. A host whose last label is a number is
917
+ * an IPv4 address to that standard, which serialises it otherwise, so it is
918
+ * refused rather than compared in a form no browser would print. Plain `http`
919
+ * is refused too: a presentation sent in the clear could be replayed before
920
+ * the service saw its nonce.
921
+ */
922
+ export const licenceAudienceSchema = z
923
+ .string()
924
+ .max(256)
925
+ .refine(isCanonicalHttpsOrigin, {
926
+ error:
927
+ "must be an https origin in its canonical form, such as https://themes.example.org",
928
+ });
929
+
930
+ function isCanonicalHttpsOrigin(value: string): boolean {
931
+ const match =
932
+ /^https:\/\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)(?::([1-9][0-9]{0,4}))?$/.exec(
933
+ value,
934
+ );
935
+ if (match === null) {
936
+ return false;
937
+ }
938
+ const host = match[1] ?? "";
939
+ const lastLabel = host.slice(host.lastIndexOf(".") + 1);
940
+ if (/^(?:[0-9]+|0x[0-9a-f]*)$/.test(lastLabel) || host.length > 253) {
941
+ return false;
942
+ }
943
+ const port = match[2];
944
+ return port === undefined || (Number(port) <= 65535 && port !== "443");
945
+ }
946
+
947
+ /**
948
+ * The licence a paid plugin reads (ADR 0042), and whom it trusts to issue it.
949
+ *
950
+ * The keys sit here rather than in the core, so the core holds no vendor's
951
+ * key and anyone who sells a plugin can issue licences for it in the same
952
+ * format. The host verifies; a plugin reads the result through
953
+ * `host.entitlements`.
954
+ *
955
+ * Strict, because what it grants is consented to: a misspelled field read as
956
+ * absent would be a declaration the board never saw.
957
+ */
958
+ export const pluginLicenceSchema = z.strictObject({
959
+ /**
960
+ * The one entitlement the plugin reads, named as a plugin id is. A licence
961
+ * may carry several; the host reports this one only.
962
+ */
963
+ entitlement: pluginIdSchema,
964
+ /**
965
+ * Who issues the licence. The licence screen and the audit log name the
966
+ * issuer from here, never from text in a licence file.
967
+ */
968
+ issuer: z.strictObject({
969
+ name: z.string().min(1).max(100),
970
+ url: z
971
+ .string()
972
+ .max(2000)
973
+ .regex(/^https:\/\/[^\s/?#]+(?:\/\S*)?$/, "must be an https address"),
974
+ }),
975
+ /**
976
+ * The issuer's public keys by key id. At most two at once: a new key is
977
+ * declared in a release before any licence is signed with it, and the old
978
+ * one leaves in a later release.
979
+ */
980
+ keys: z.record(licenceKeyIdSchema, licencePublicKeySchema).refine((keys) => {
981
+ const count = Object.keys(keys).length;
982
+ return count >= 1 && count <= 2;
983
+ }, "must declare one or two keys"),
984
+ /**
985
+ * The services of the issuer's the plugin may present the licence to.
986
+ * None for a plugin that uses no service of its issuer's.
987
+ */
988
+ audiences: z
989
+ .array(licenceAudienceSchema)
990
+ .max(8)
991
+ .refine(
992
+ (audiences) => new Set(audiences).size === audiences.length,
993
+ "must not name an audience twice",
994
+ )
995
+ .default([]),
996
+ });
997
+
998
+ export type PluginLicenceDeclaration = z.infer<typeof pluginLicenceSchema>;
999
+
1000
+ export const pluginManifestSchema = z
1001
+ .object({
1002
+ /**
1003
+ * The contract version this plugin was built against. The loader refuses
1004
+ * anything it does not implement rather than loading it and hoping.
1005
+ */
1006
+ apiVersion: z.int().min(1),
1007
+ id: pluginIdSchema,
1008
+ entry: pluginEntrySchema,
1009
+ permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(16).default([]),
1010
+ personalData: z
1011
+ .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
1012
+ .max(16)
1013
+ .default([]),
1014
+ settingsSchema: pluginSettingsSchema.optional(),
1015
+ /**
1016
+ * The plugin's own screen in the application, when it has one, at
1017
+ * `/plugin/<id>`. The title is an i18n key in the plugin's own namespace.
1018
+ */
1019
+ view: pluginViewSchema.optional(),
1020
+ /** What this plugin proposes the platform be able to do. */
1021
+ actions: pluginActionsSchema.default([]),
1022
+ /**
1023
+ * The route, under this plugin's own mount, that serves MCP.
1024
+ *
1025
+ * Declaring it makes that route the instance's OAuth protected resource:
1026
+ * its full URL becomes the audience every issued token is bound to, the
1027
+ * discovery documents point at it, and the route stops accepting the
1028
+ * browser's session cookie and accepts only a Bearer token issued for it.
1029
+ *
1030
+ * At most one installed plugin may declare it, because the audience is a
1031
+ * single URL and moving it would strand every token already issued. A
1032
+ * second one is refused at install.
1033
+ */
1034
+ oauthProtectedResource: pluginRoutePathSchema.optional(),
1035
+ /**
1036
+ * The routes, under this plugin's own mount, that a device reaches with a
1037
+ * device token the board issued (ADR 0031).
1038
+ *
1039
+ * Such a route answers `GET` and `HEAD` with `Authorization: Bearer
1040
+ * <device token>` and nothing else: no session cookie, no person and no
1041
+ * capability floor. What stands in for the floor is that a plugin declaring
1042
+ * one may not read the register.
1043
+ */
1044
+ deviceRoutes: pluginDeviceRoutesSchema.default([]),
1045
+ /**
1046
+ * That this plugin is an AI package: a language model chooses the actions
1047
+ * it dispatches, for the person whose request its route is serving.
1048
+ *
1049
+ * Declaring it moves the plugin's dispatch onto the `ai` audit channel and
1050
+ * the `ai` surface. Its writes are recorded as made through the AI package
1051
+ * rather than as any plugin's; it reaches only the actions offered on `ai`;
1052
+ * and a plugin action is reached only once an administrator has armed it,
1053
+ * as on `mcp`. A connected app's token on its route keeps `mcp`.
1054
+ *
1055
+ * It narrows what the plugin can reach and never widens it, and it is the
1056
+ * only say a plugin has in its channel. The host derives the channel from
1057
+ * this declaration and from how the request arrived; nothing a plugin
1058
+ * passes to `host.actions` names one. The consent screen shows it.
1059
+ */
1060
+ ai: z.boolean().default(false),
1061
+ /**
1062
+ * The blocks this plugin adds to the association's website.
1063
+ *
1064
+ * Declared here rather than at registration for the reason actions are:
1065
+ * the board consents to what a plugin may put on the website before any of
1066
+ * its code has run. A plugin declaring one asks for `site:blocks`.
1067
+ */
1068
+ blocks: pluginBlocksSchema.default([]),
1069
+ /**
1070
+ * The extension points this plugin offers other plugins (ADR 0030). At
1071
+ * most eight.
1072
+ */
1073
+ extensionPoints: declaredExtensionPointsSchema(
1074
+ pluginExtensionPointSchema,
1075
+ ).default([]),
1076
+ /**
1077
+ * The extension points of other plugins this plugin extends, which makes
1078
+ * it an addon (ADR 0030). At most eight.
1079
+ */
1080
+ extends: declaredExtensionsSchema(pluginExtensionSchema).default([]),
1081
+ /**
1082
+ * Where the plugin connects to outside the instance, and which personal
1083
+ * data each place receives (ADR 0037).
1084
+ *
1085
+ * Shown on the install consent screen, where it fills in the question
1086
+ * about where the plugin sends personal data, and consented to as a
1087
+ * snapshot: a republished version that adds a recipient, moves one or
1088
+ * sends one more is refused at load until the board consents again.
1089
+ */
1090
+ recipients: pluginRecipientsSchema.default([]),
1091
+ /**
1092
+ * The licence this plugin reads, for a paid plugin (ADR 0042).
1093
+ *
1094
+ * Declaring it gives the plugin `host.entitlements`. Nothing else in the
1095
+ * host reads it: the plugin loads and serves either way, and what it
1096
+ * withholds without an active entitlement is its own decision.
1097
+ */
1098
+ licence: pluginLicenceSchema.optional(),
1099
+ /**
1100
+ * The day this release was published, written by the release workflow and
1101
+ * compared with a licence's `updatesUntil`. Required with `licence`.
1102
+ */
1103
+ released: calendarDaySchema.optional(),
1104
+ })
1105
+ .superRefine((manifest, ctx) => {
1106
+ for (const issue of extensionRuleIssues(manifest)) {
1107
+ ctx.addIssue({
1108
+ code: "custom",
1109
+ path: issue.path,
1110
+ message: issue.message,
1111
+ });
1112
+ }
1113
+
1114
+ /*
1115
+ * A licence sold with a year of updates covers the releases published in
1116
+ * that year, and a release that does not say when it was published cannot
1117
+ * be judged. Refused here rather than read as covered, which would make
1118
+ * every licence for updates cover every release.
1119
+ */
1120
+ if (manifest.licence !== undefined && manifest.released === undefined) {
1121
+ ctx.addIssue({
1122
+ code: "custom",
1123
+ path: ["released"],
1124
+ message:
1125
+ "a plugin that declares a licence must say when it was released",
1126
+ });
1127
+ }
1128
+ // What a recipient receives is part of what the plugin handles.
1129
+ for (const issue of recipientCategoryIssues(
1130
+ manifest.recipients,
1131
+ manifest.personalData,
1132
+ )) {
1133
+ ctx.addIssue({ code: "custom", ...issue });
1134
+ }
1135
+
1136
+ /*
1137
+ * A block is published on the website, to readers with no account, so it
1138
+ * is something the board consents to by name rather than something a
1139
+ * plugin acquires by declaring it. The permission is what the install
1140
+ * consent screen shows; the declarations are what the host registers.
1141
+ */
1142
+ if (
1143
+ manifest.blocks.length > 0 &&
1144
+ !manifest.permissions.includes("site:blocks")
1145
+ ) {
1146
+ ctx.addIssue({
1147
+ code: "custom",
1148
+ path: ["blocks"],
1149
+ message:
1150
+ 'a plugin that declares blocks must ask for the "site:blocks" permission',
1151
+ });
1152
+ }
1153
+ /*
1154
+ * And a plugin that puts blocks on the website does not read the
1155
+ * register. The website's argument that it cannot publish a resident's
1156
+ * details rests on there being no path from it to the register (ADR 0006),
1157
+ * and a block that could ask the address book what to print would be one.
1158
+ * So would one that could ask the finances: a fee notice and a charge carry
1159
+ * the names of the people they are for. Permissions are not a sandbox, so
1160
+ * this holds an honest plugin to that argument rather than a hostile one;
1161
+ * curation does the rest.
1162
+ */
1163
+ if (
1164
+ manifest.permissions.includes("site:blocks") &&
1165
+ readsRegister(manifest.permissions)
1166
+ ) {
1167
+ ctx.addIssue({
1168
+ code: "custom",
1169
+ path: ["permissions"],
1170
+ message:
1171
+ 'a plugin that asks for "site:blocks" may not also read the address book or the finances: what a block renders is published to anybody',
1172
+ params: { rule: "blocks-read-register" satisfies PluginManifestRule },
1173
+ });
1174
+ }
1175
+ /*
1176
+ * Nor one that proposes an import. It reads nothing from the register, but
1177
+ * it holds the member list itself, identity numbers and protected persons
1178
+ * included, and a block is published to anybody (ADR 0043).
1179
+ */
1180
+ if (
1181
+ manifest.permissions.includes("site:blocks") &&
1182
+ manifest.permissions.includes("addressBook:proposeImport")
1183
+ ) {
1184
+ ctx.addIssue({
1185
+ code: "custom",
1186
+ path: ["permissions"],
1187
+ message:
1188
+ 'a plugin that asks for "site:blocks" may not also propose imports: it holds the member list, and what a block renders is published to anybody',
1189
+ params: { rule: "blocks-propose-import" satisfies PluginManifestRule },
1190
+ });
1191
+ }
1192
+ for (const issue of proposeImportPersonalDataIssues(manifest)) {
1193
+ ctx.addIssue({ code: "custom", path: ["personalData"], ...issue });
1194
+ }
1195
+ /*
1196
+ * A news item and an event are text the board wrote, and published text
1197
+ * may name anybody. `personalData` names everything a plugin handles, not
1198
+ * only what it reads from the register, so a plugin reading that text says
1199
+ * so, and the record of processing carries it (ADR 0032).
1200
+ */
1201
+ for (const permission of ["news:read", "calendar:read"] as const) {
1202
+ if (
1203
+ manifest.permissions.includes(permission) &&
1204
+ !manifest.personalData.includes("freeText")
1205
+ ) {
1206
+ ctx.addIssue({
1207
+ code: "custom",
1208
+ path: ["personalData"],
1209
+ message: `a plugin that asks for "${permission}" must declare "freeText": published text may name anybody`,
1210
+ });
1211
+ }
1212
+ }
1213
+ /*
1214
+ * The same for a picture. The host opens only one whose upload declared
1215
+ * that it shows no identifiable person, but that is somebody's declaration
1216
+ * and not a fact about the picture, and a photograph is a category of its
1217
+ * own in the record of processing whoever is in it (ADR 0032).
1218
+ */
1219
+ if (
1220
+ manifest.permissions.includes("media:read") &&
1221
+ !manifest.personalData.includes("photograph")
1222
+ ) {
1223
+ ctx.addIssue({
1224
+ code: "custom",
1225
+ path: ["personalData"],
1226
+ message:
1227
+ 'a plugin that asks for "media:read" must declare "photograph": a stored picture is a photograph whoever is in it',
1228
+ });
1229
+ }
1230
+
1231
+ for (const { rule, ...issue } of deviceRouteRuleIssues(manifest)) {
1232
+ ctx.addIssue({ code: "custom", ...issue, params: { rule } });
1233
+ }
1234
+
1235
+ /*
1236
+ * The composed name has to fit the public pattern, and only the pair can
1237
+ * know whether it does: a plugin id runs to 48 characters and an action id to
1238
+ * 32, which composes to 81 against a limit of 64. Checked pairwise rather
1239
+ * than by shortening the action id to a worst case, because that would charge
1240
+ * every plugin for the longest possible plugin id and forbid
1241
+ * `monthly_occupancy_report` to a plugin called `brf`, where the composed
1242
+ * name is 28 characters.
1243
+ *
1244
+ * Refused here rather than at registration so that it is manifest-invalid
1245
+ * before the install consent screen, instead of a finding after the plugin's
1246
+ * code has already run.
1247
+ */
1248
+ for (const [index, action] of manifest.actions.entries()) {
1249
+ const composed = composedActionName(manifest.id, action.id);
1250
+ if (!ACTION_NAME_PATTERN.test(composed)) {
1251
+ ctx.addIssue({
1252
+ code: "custom",
1253
+ path: ["actions", index, "id"],
1254
+ message: `the public name "${composed}" is ${String(composed.length)} characters; a plugin id and an action id compose to at most 64`,
1255
+ });
1256
+ }
1257
+ }
1258
+ });
1259
+
1260
+ export type PluginManifest = z.infer<typeof pluginManifestSchema>;
1261
+ export type PluginActionDeclaration = z.infer<typeof pluginActionSchema>;
1262
+ export type PluginEntry = z.infer<typeof pluginEntrySchema>;
1263
+ export type PluginView = z.infer<typeof pluginViewSchema>;
1264
+ export type PluginExtensionPointDeclaration = z.infer<
1265
+ typeof pluginExtensionPointSchema
1266
+ >;
1267
+ export type PluginExtensionDeclaration = z.infer<typeof pluginExtensionSchema>;
1268
+ export type PluginDeviceRoute = z.infer<typeof pluginDeviceRouteSchema>;
1269
+
1270
+ /**
1271
+ * Dependency fields npm installs at install time.
1272
+ *
1273
+ * The installer omits peer, dev and optional dependencies and runs no scripts,
1274
+ * but it still hands npm the package: anything listed here would send that npm
1275
+ * to a registry, which an instance never contacts. optionalDependencies is
1276
+ * listed although it is omitted, because a package relying on one being
1277
+ * present works in development and quietly not on an instance.
1278
+ */
1279
+ export const RUNTIME_DEPENDENCY_FIELDS = [
1280
+ "dependencies",
1281
+ "optionalDependencies",
1282
+ "bundleDependencies",
1283
+ "bundledDependencies",
1284
+ ] as const;
1285
+
1286
+ /** Package names in a dependency field, whether a map or npm's bundle list. */
1287
+ export function dependencyNames(value: unknown): string[] {
1288
+ if (Array.isArray(value)) {
1289
+ return value.filter((name): name is string => typeof name === "string");
1290
+ }
1291
+ return typeof value === "object" && value !== null ? Object.keys(value) : [];
1292
+ }
1293
+
1294
+ /** Whether a dependency field is an empty map or an empty list. */
1295
+ export function isEmptyDependencyField(value: unknown): boolean {
1296
+ if (Array.isArray(value)) {
1297
+ return value.length === 0;
1298
+ }
1299
+ return (
1300
+ typeof value === "object" &&
1301
+ value !== null &&
1302
+ Object.keys(value).length === 0
1303
+ );
1304
+ }
1305
+
1306
+ /**
1307
+ * A dependency field that names nothing: absent, an empty map or an empty
1308
+ * list. Anything else is refused, a string included, since npm reads even
1309
+ * that as something to install. See RUNTIME_DEPENDENCY_FIELDS for why.
1310
+ *
1311
+ * Part of the schema rather than a check beside it, so the loader refuses such
1312
+ * a package at boot as well as the catalog refusing to list it.
1313
+ */
1314
+ const noRuntimeDependencies = z
1315
+ .unknown()
1316
+ .superRefine((value, ctx) => {
1317
+ const names = dependencyNames(value);
1318
+ if (names.length > 0) {
1319
+ ctx.addIssue({
1320
+ code: "custom",
1321
+ message: `names ${names.join(", ")}; a plugin declares no runtime dependencies, because the installer resolves nothing from a registry`,
1322
+ });
1323
+ } else if (!isEmptyDependencyField(value)) {
1324
+ ctx.addIssue({
1325
+ code: "custom",
1326
+ message:
1327
+ "is neither an empty map nor an empty list; a plugin declares no runtime dependencies, because the installer resolves nothing from a registry",
1328
+ });
1329
+ }
1330
+ })
1331
+ .optional();
1332
+
1333
+ /**
1334
+ * The subset of package.json the loader reads. Everything else in the file is
1335
+ * npm's business, apart from the dependency fields, which must name nothing.
1336
+ */
1337
+ export const pluginPackageSchema = z.object({
1338
+ name: z.string().min(1),
1339
+ version: z.string().min(1),
1340
+ openbrf: pluginManifestSchema,
1341
+ dependencies: noRuntimeDependencies,
1342
+ optionalDependencies: noRuntimeDependencies,
1343
+ bundleDependencies: noRuntimeDependencies,
1344
+ bundledDependencies: noRuntimeDependencies,
1345
+ });
1346
+
1347
+ export type PluginPackage = z.infer<typeof pluginPackageSchema>;
1348
+
1349
+ export interface ManifestParseFailure {
1350
+ ok: false;
1351
+ /** One line per problem, already naming the field. */
1352
+ issues: string[];
1353
+ /**
1354
+ * The same problems, each with the code of the rule it broke when it broke
1355
+ * one a board reads in its own language (`PLUGIN_MANIFEST_RULES`), and null
1356
+ * when it is a field's own problem.
1357
+ */
1358
+ diagnostics: { rule: PluginManifestRule | null; issue: string }[];
1359
+ }
1360
+
1361
+ export type ManifestParseResult =
1362
+ { ok: true; value: PluginPackage } | ManifestParseFailure;
1363
+
1364
+ /**
1365
+ * Parses a plugin's package.json.
1366
+ *
1367
+ * Returns a result rather than throwing, because the only caller that matters
1368
+ * is the boot-time scan, and a malformed plugin directory must be skipped and
1369
+ * reported rather than being allowed to take the register offline (ADR 0003).
1370
+ * A thrown error would make "skip and report" the caller's discipline instead
1371
+ * of the function's contract.
1372
+ */
1373
+ export function parsePluginPackage(input: unknown): ManifestParseResult {
1374
+ const result = pluginPackageSchema.safeParse(input);
1375
+ if (result.success) {
1376
+ return { ok: true, value: result.data };
1377
+ }
1378
+ const diagnostics = result.error.issues.map((issue) => ({
1379
+ rule: manifestRuleOf(issue),
1380
+ issue: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
1381
+ }));
1382
+ return {
1383
+ ok: false,
1384
+ issues: diagnostics.map((diagnostic) => diagnostic.issue),
1385
+ diagnostics,
1386
+ };
1387
+ }
1388
+
1389
+ function manifestRuleOf(issue: {
1390
+ code: string;
1391
+ params?: unknown;
1392
+ }): PluginManifestRule | null {
1393
+ if (issue.code !== "custom" || typeof issue.params !== "object") {
1394
+ return null;
1395
+ }
1396
+ const rule = (issue.params as { rule?: unknown } | null)?.rule;
1397
+ return PLUGIN_MANIFEST_RULES.find((known) => known === rule) ?? null;
1398
+ }
1399
+
1400
+ /** Convenience for a plugin's own CI: throws with every problem at once. */
1401
+ export function assertPluginPackage(input: unknown): PluginPackage {
1402
+ const result = parsePluginPackage(input);
1403
+ if (!result.ok) {
1404
+ throw new Error(
1405
+ `Invalid Open BRF plugin manifest:\n ${result.issues.join("\n ")}`,
1406
+ );
1407
+ }
1408
+ return result.value;
1409
+ }
1410
+
1411
+ /** The api version a plugin authored against this SDK should declare. */
1412
+ export const CURRENT_PLUGIN_API_VERSION = PLUGIN_API_VERSION;