@openbrf/plugin-sdk 1.0.0 → 1.4.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/README.md +95 -0
- package/dist/abort-signal.d.ts +23 -0
- package/dist/abort-signal.d.ts.map +1 -0
- package/dist/abort-signal.js +2 -0
- package/dist/abort-signal.js.map +1 -0
- package/dist/action-refusal.d.ts +101 -0
- package/dist/action-refusal.d.ts.map +1 -0
- package/dist/action-refusal.js +96 -0
- package/dist/action-refusal.js.map +1 -0
- package/dist/action-schema.d.ts.map +1 -1
- package/dist/action-schema.js +4 -1
- package/dist/action-schema.js.map +1 -1
- package/dist/actions.d.ts +94 -6
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js.map +1 -1
- package/dist/blocks.d.ts +310 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +185 -0
- package/dist/blocks.js.map +1 -0
- package/dist/catalog.d.ts +1092 -41
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +472 -15
- package/dist/catalog.js.map +1 -1
- package/dist/entitlements.d.ts +117 -0
- package/dist/entitlements.d.ts.map +1 -0
- package/dist/entitlements.js +62 -0
- package/dist/entitlements.js.map +1 -0
- package/dist/extensions.d.ts +183 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +67 -0
- package/dist/extensions.js.map +1 -0
- package/dist/finances.d.ts +294 -0
- package/dist/finances.d.ts.map +1 -0
- package/dist/finances.js +56 -0
- package/dist/finances.js.map +1 -0
- package/dist/findings.d.ts +92 -1
- package/dist/findings.d.ts.map +1 -1
- package/dist/findings.js +91 -0
- package/dist/findings.js.map +1 -1
- package/dist/host.d.ts +729 -4
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +54 -0
- package/dist/host.js.map +1 -1
- package/dist/imports.d.ts +104 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +70 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -6
- package/dist/index.js.map +1 -1
- package/dist/localized-text.d.ts +18 -0
- package/dist/localized-text.d.ts.map +1 -0
- package/dist/localized-text.js +17 -0
- package/dist/localized-text.js.map +1 -0
- package/dist/manifest.d.ts +859 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +957 -12
- package/dist/manifest.js.map +1 -1
- package/dist/package-check.d.ts.map +1 -1
- package/dist/package-check.js +9 -1
- package/dist/package-check.js.map +1 -1
- package/dist/permissions.d.ts +170 -11
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +210 -2
- package/dist/permissions.js.map +1 -1
- package/dist/recipients.d.ts +139 -0
- package/dist/recipients.d.ts.map +1 -0
- package/dist/recipients.js +223 -0
- package/dist/recipients.js.map +1 -0
- package/dist/records.d.ts +130 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +85 -0
- package/dist/records.js.map +1 -0
- package/dist/require-capability.d.ts +37 -0
- package/dist/require-capability.d.ts.map +1 -0
- package/dist/require-capability.js +88 -0
- package/dist/require-capability.js.map +1 -0
- package/dist/settings-schema.d.ts +35 -0
- package/dist/settings-schema.d.ts.map +1 -1
- package/dist/settings-schema.js +55 -2
- package/dist/settings-schema.js.map +1 -1
- package/package.json +4 -4
- package/src/abort-signal.ts +22 -0
- package/src/action-refusal.ts +178 -0
- package/src/action-schema.ts +4 -1
- package/src/actions.ts +103 -6
- package/src/blocks.ts +289 -0
- package/src/catalog.ts +630 -72
- package/src/entitlements.ts +150 -0
- package/src/extensions.ts +212 -0
- package/src/finances.ts +317 -0
- package/src/findings.ts +94 -0
- package/src/host.ts +768 -4
- package/src/imports.ts +135 -0
- package/src/index.ts +209 -0
- package/src/localized-text.ts +19 -0
- package/src/manifest.ts +1231 -12
- package/src/package-check.ts +15 -0
- package/src/permissions.ts +223 -3
- package/src/recipients.ts +294 -0
- package/src/records.ts +172 -0
- package/src/require-capability.ts +106 -0
- package/src/settings-schema.ts +64 -2
package/dist/manifest.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ACTION_EFFECTS, ACTION_NAME_PATTERN, ACTION_PERSONAL_DATA, ACTION_SURFACES, } from "./actions.js";
|
|
3
3
|
import { PLUGIN_API_VERSION } from "./api-version.js";
|
|
4
|
-
import {
|
|
4
|
+
import { pluginBlocksSchema } from "./blocks.js";
|
|
5
|
+
import { EXTENSION_POINT_ID_PATTERN, MAX_EXTENSION_POINTS, } from "./extensions.js";
|
|
6
|
+
import { PLUGIN_MANIFEST_RULES } from "./findings.js";
|
|
7
|
+
import { PLUGIN_PERMISSIONS, PLUGIN_PERSONAL_DATA_CATEGORIES, PROPOSE_IMPORT_ONLY_PERSONAL_DATA, PROPOSE_IMPORT_PERSONAL_DATA, readsRegister, REGISTER_READING_PERMISSIONS, } from "./permissions.js";
|
|
8
|
+
import { pluginRecipientsSchema, recipientCategoryIssues, } from "./recipients.js";
|
|
5
9
|
import { pluginSettingsSchema } from "./settings-schema.js";
|
|
6
10
|
/**
|
|
7
11
|
* The plugin manifest.
|
|
@@ -155,6 +159,705 @@ export function declaredActionsSchema(action) {
|
|
|
155
159
|
export function composedActionName(pluginId, actionId) {
|
|
156
160
|
return `${pluginId.replaceAll("-", "_")}_${actionId}`;
|
|
157
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The sections of the application's navigation a plugin's view may stand in.
|
|
164
|
+
*
|
|
165
|
+
* The application's own four, in the order its band shows them: the
|
|
166
|
+
* association and its life, the building and the apartments in it, the board's
|
|
167
|
+
* own desk, and the settings. A plugin names one of them rather than a section
|
|
168
|
+
* of its own, so a view is found where a destination of its kind already is.
|
|
169
|
+
*/
|
|
170
|
+
export const PLUGIN_VIEW_SECTIONS = [
|
|
171
|
+
"association",
|
|
172
|
+
"building",
|
|
173
|
+
"board",
|
|
174
|
+
"settings",
|
|
175
|
+
];
|
|
176
|
+
/**
|
|
177
|
+
* Text a plugin author writes in both languages the interface is offered in.
|
|
178
|
+
*
|
|
179
|
+
* Strict, as everything in the processing declaration is: a field the
|
|
180
|
+
* declaration does not define is refused rather than dropped, so a misspelled
|
|
181
|
+
* key cannot leave a row of the record of processing activities without the
|
|
182
|
+
* text its author meant to put there.
|
|
183
|
+
*/
|
|
184
|
+
const pluginLocalizedTextSchema = z.strictObject({
|
|
185
|
+
sv: z.string().trim().min(1).max(500),
|
|
186
|
+
en: z.string().trim().min(1).max(500),
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* The art. 6(1) bases a plugin may declare its processing on.
|
|
190
|
+
*
|
|
191
|
+
* Four of the six. Vital interests and public task are not bases a housing
|
|
192
|
+
* cooperative's plugin has any business with, and a declaration naming one
|
|
193
|
+
* would be a claim the board then has to correct rather than a statement it can
|
|
194
|
+
* publish.
|
|
195
|
+
*/
|
|
196
|
+
export const PLUGIN_LEGAL_BASES = [
|
|
197
|
+
"consent",
|
|
198
|
+
"contract",
|
|
199
|
+
"legalObligation",
|
|
200
|
+
"legitimateInterest",
|
|
201
|
+
];
|
|
202
|
+
/** Whose personal data a plugin's processing concerns. */
|
|
203
|
+
export const PLUGIN_DATA_SUBJECTS = ["members", "residents"];
|
|
204
|
+
/**
|
|
205
|
+
* What a plugin's processing of personal data is for, on what basis, for how
|
|
206
|
+
* long, and about whom (ADR 0035).
|
|
207
|
+
*
|
|
208
|
+
* The install seeds the plugin's row in the record of processing activities
|
|
209
|
+
* from it, in the association's language, and the consent screen shows it
|
|
210
|
+
* before anything is downloaded. It grants nothing: what a plugin may read is
|
|
211
|
+
* its permissions.
|
|
212
|
+
*/
|
|
213
|
+
export const pluginProcessingSchema = z
|
|
214
|
+
.strictObject({
|
|
215
|
+
purpose: pluginLocalizedTextSchema,
|
|
216
|
+
legalBasis: z.enum(PLUGIN_LEGAL_BASES),
|
|
217
|
+
/** The interest relied on, which art. 13(1)(d) requires on the notice. */
|
|
218
|
+
legitimateInterest: pluginLocalizedTextSchema.optional(),
|
|
219
|
+
/** How long the data is kept and when it goes. */
|
|
220
|
+
retention: pluginLocalizedTextSchema,
|
|
221
|
+
dataSubjects: z
|
|
222
|
+
.array(z.enum(PLUGIN_DATA_SUBJECTS))
|
|
223
|
+
.min(1)
|
|
224
|
+
.max(PLUGIN_DATA_SUBJECTS.length)
|
|
225
|
+
.refine((subjects) => new Set(subjects).size === subjects.length, "names a category of data subjects twice"),
|
|
226
|
+
})
|
|
227
|
+
.superRefine((processing, ctx) => {
|
|
228
|
+
/*
|
|
229
|
+
* Both ways. A processing on legitimate interest names the interest,
|
|
230
|
+
* because the privacy notice has to; one on another basis names none,
|
|
231
|
+
* because the row would then carry an interest for a processing that does
|
|
232
|
+
* not rest on one.
|
|
233
|
+
*/
|
|
234
|
+
if (processing.legalBasis === "legitimateInterest" &&
|
|
235
|
+
processing.legitimateInterest === undefined) {
|
|
236
|
+
ctx.addIssue({
|
|
237
|
+
code: "custom",
|
|
238
|
+
path: ["legitimateInterest"],
|
|
239
|
+
message: "a processing on legitimate interest names the interest",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (processing.legalBasis !== "legitimateInterest" &&
|
|
243
|
+
processing.legitimateInterest !== undefined) {
|
|
244
|
+
ctx.addIssue({
|
|
245
|
+
code: "custom",
|
|
246
|
+
path: ["legitimateInterest"],
|
|
247
|
+
message: 'only a processing on "legitimateInterest" names a legitimate interest',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
export const pluginViewSchema = z.object({
|
|
252
|
+
/** Named export of the remote module, per Module Federation. */
|
|
253
|
+
module: z.string().min(1).max(100).default("./View"),
|
|
254
|
+
titleKey: z.string().min(1).max(200),
|
|
255
|
+
/**
|
|
256
|
+
* The section of the navigation the view is offered in, under its title.
|
|
257
|
+
*
|
|
258
|
+
* Optional, and a view without one is reachable by its address alone, as
|
|
259
|
+
* every view was before the field existed. Naming a section asks for an
|
|
260
|
+
* entry rather than granting one: the host offers it only to an account that
|
|
261
|
+
* holds what the plugin's routes require, and in the board's section only to
|
|
262
|
+
* the board, because that section holds nothing a resident is offered.
|
|
263
|
+
*/
|
|
264
|
+
section: z.enum(PLUGIN_VIEW_SECTIONS).optional(),
|
|
265
|
+
});
|
|
266
|
+
/** The id of an extension point, unique within the plugin that declares it. */
|
|
267
|
+
export const extensionPointIdSchema = z
|
|
268
|
+
.string()
|
|
269
|
+
.min(2)
|
|
270
|
+
.max(32)
|
|
271
|
+
.regex(EXTENSION_POINT_ID_PATTERN, "must be lowercase letters, digits and single hyphens, starting with a letter");
|
|
272
|
+
/**
|
|
273
|
+
* One extension point the plugin offers other plugins (ADR 0030).
|
|
274
|
+
*
|
|
275
|
+
* Declared here rather than at registration for the reason an action is: the
|
|
276
|
+
* board reads what the point accepts on the consent screen before anything is
|
|
277
|
+
* downloaded. The bundle supplies the schemas; what may cross is settled here.
|
|
278
|
+
*/
|
|
279
|
+
export const pluginExtensionPointSchema = z.object({
|
|
280
|
+
id: extensionPointIdSchema,
|
|
281
|
+
/**
|
|
282
|
+
* The point's major version, raised when a change to its schemas would break
|
|
283
|
+
* an addon written for the last one.
|
|
284
|
+
*/
|
|
285
|
+
version: z.int().min(1),
|
|
286
|
+
/**
|
|
287
|
+
* The categories of personal data a contribution may hand the plugin
|
|
288
|
+
* through this point. Among the plugin's own `personalData`, because what an
|
|
289
|
+
* addon hands it is handled by it as much as what it reads itself.
|
|
290
|
+
*/
|
|
291
|
+
personalData: z
|
|
292
|
+
.array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
|
|
293
|
+
.max(16)
|
|
294
|
+
.default([]),
|
|
295
|
+
});
|
|
296
|
+
/** One extension point of another plugin that this plugin extends. */
|
|
297
|
+
export const pluginExtensionSchema = z.object({
|
|
298
|
+
/** The id of the host plugin. */
|
|
299
|
+
plugin: pluginIdSchema,
|
|
300
|
+
/** The id of the extension point. */
|
|
301
|
+
point: extensionPointIdSchema,
|
|
302
|
+
/** The point's major version the addon was written against. */
|
|
303
|
+
version: z.int().min(1),
|
|
304
|
+
/**
|
|
305
|
+
* The categories the contribution hands the host plugin. Among the addon's
|
|
306
|
+
* own `personalData`, and among what the point accepts, which the host
|
|
307
|
+
* checks against the host plugin's manifest when both are loaded.
|
|
308
|
+
*/
|
|
309
|
+
personalData: z
|
|
310
|
+
.array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
|
|
311
|
+
.max(16)
|
|
312
|
+
.default([]),
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* The declared extension points over a given reading of one point.
|
|
316
|
+
*
|
|
317
|
+
* The manifest reads a point as `pluginExtensionPointSchema` does; the catalog
|
|
318
|
+
* index reads it strictly and with the curator's title and description. Both
|
|
319
|
+
* keep the rule that an id appears once, which is why the list is built here.
|
|
320
|
+
*/
|
|
321
|
+
export function declaredExtensionPointsSchema(point) {
|
|
322
|
+
return z
|
|
323
|
+
.array(point)
|
|
324
|
+
.max(MAX_EXTENSION_POINTS)
|
|
325
|
+
.superRefine((points, ctx) => {
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
for (const [index, declared] of points.entries()) {
|
|
328
|
+
if (seen.has(declared.id)) {
|
|
329
|
+
ctx.addIssue({
|
|
330
|
+
code: "custom",
|
|
331
|
+
path: [index, "id"],
|
|
332
|
+
message: `two extension points are declared with the id "${declared.id}"`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
seen.add(declared.id);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The declared extensions over a given reading of one, each pair of plugin and
|
|
341
|
+
* point once: an addon contributes once to each point it extends.
|
|
342
|
+
*/
|
|
343
|
+
export function declaredExtensionsSchema(extension) {
|
|
344
|
+
return z
|
|
345
|
+
.array(extension)
|
|
346
|
+
.max(MAX_EXTENSION_POINTS)
|
|
347
|
+
.superRefine((extensions, ctx) => {
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
for (const [index, declared] of extensions.entries()) {
|
|
350
|
+
const key = `${declared.plugin}/${declared.point}`;
|
|
351
|
+
if (seen.has(key)) {
|
|
352
|
+
ctx.addIssue({
|
|
353
|
+
code: "custom",
|
|
354
|
+
path: [index, "point"],
|
|
355
|
+
message: `"${declared.point}" of "${declared.plugin}" is extended twice`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
seen.add(key);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The rules between a plugin's extension declarations and the rest of its
|
|
364
|
+
* declaration (ADR 0030), as issues with the path each one is at.
|
|
365
|
+
*
|
|
366
|
+
* One function for the manifest and the catalog entry, so an entry the loader
|
|
367
|
+
* would refuse is never offered for consent.
|
|
368
|
+
*/
|
|
369
|
+
export function extensionRuleIssues(declaration) {
|
|
370
|
+
const issues = [];
|
|
371
|
+
const own = new Set(declaration.personalData);
|
|
372
|
+
/*
|
|
373
|
+
* One level. An addon that could be extended in turn would make "what can
|
|
374
|
+
* reach this screen" a question the board could not answer from the two
|
|
375
|
+
* consent screens in front of it.
|
|
376
|
+
*/
|
|
377
|
+
if (declaration.extends.length > 0 &&
|
|
378
|
+
declaration.extensionPoints.length > 0) {
|
|
379
|
+
issues.push({
|
|
380
|
+
path: ["extensionPoints"],
|
|
381
|
+
message: "a plugin that extends another declares no extension points of its own",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
for (const [index, point] of declaration.extensionPoints.entries()) {
|
|
385
|
+
const foreign = point.personalData.filter((category) => !own.has(category));
|
|
386
|
+
if (foreign.length > 0) {
|
|
387
|
+
issues.push({
|
|
388
|
+
path: ["extensionPoints", index, "personalData"],
|
|
389
|
+
message: `${foreign.join(", ")} must be among the plugin's own personalData: what an addon hands a plugin is handled by it`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/*
|
|
393
|
+
* What a website block renders is published to the street, and
|
|
394
|
+
* `site:blocks` already keeps the address book away from such a plugin
|
|
395
|
+
* (ADR 0006). A point that accepted names would be the same path through a
|
|
396
|
+
* second plugin.
|
|
397
|
+
*/
|
|
398
|
+
if (declaration.permissions.includes("site:blocks") &&
|
|
399
|
+
point.personalData.length > 0) {
|
|
400
|
+
issues.push({
|
|
401
|
+
path: ["extensionPoints", index, "personalData"],
|
|
402
|
+
message: 'a plugin that asks for "site:blocks" declares extension points that accept no personal data',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/*
|
|
406
|
+
* A device is a screen on a wall, and a device token reaches a plugin's
|
|
407
|
+
* routes. A point that accepted contact details would let it reach them
|
|
408
|
+
* through an addon, so a plugin with device routes declares none that does
|
|
409
|
+
* (ADR 0030, ADR 0031).
|
|
410
|
+
*/
|
|
411
|
+
const contact = point.personalData.filter((category) => category === "email" || category === "phone");
|
|
412
|
+
if ((declaration.deviceRoutes ?? []).length > 0 && contact.length > 0) {
|
|
413
|
+
issues.push({
|
|
414
|
+
path: ["extensionPoints", index, "personalData"],
|
|
415
|
+
message: `a plugin that declares deviceRoutes declares no extension point that accepts ${contact.join(" or ")}: contact details have no business on a device`,
|
|
416
|
+
rule: "device-route-extension-point-contact",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const [index, extension] of declaration.extends.entries()) {
|
|
421
|
+
if (extension.plugin === declaration.id) {
|
|
422
|
+
issues.push({
|
|
423
|
+
path: ["extends", index, "plugin"],
|
|
424
|
+
message: "a plugin does not extend itself",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const foreign = extension.personalData.filter((category) => !own.has(category));
|
|
428
|
+
if (foreign.length > 0) {
|
|
429
|
+
issues.push({
|
|
430
|
+
path: ["extends", index, "personalData"],
|
|
431
|
+
message: `${foreign.join(", ")} must be among the plugin's own personalData`,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return issues;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The permissions in a list that reach the members' details, which a plugin
|
|
439
|
+
* that puts blocks on the website may not hold, and may not be handed data
|
|
440
|
+
* from (ADR 0006): those that read the register, following
|
|
441
|
+
* `PERMISSION_REGISTER_ACCESS`, and `addressBook:proposeImport`. That one reads
|
|
442
|
+
* nothing in the register, so it is not classified as a read, but the plugin
|
|
443
|
+
* holds the member list it proposes, identity numbers and protected persons
|
|
444
|
+
* included (ADR 0043).
|
|
445
|
+
*/
|
|
446
|
+
function memberDataPermissions(permissions) {
|
|
447
|
+
return permissions.filter((permission) => REGISTER_READING_PERMISSIONS.includes(permission) || permission === "addressBook:proposeImport");
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The permissions that keep an addon and the plugin it extends apart, or an
|
|
451
|
+
* empty list when there are none (ADR 0030): those of whichever of the two
|
|
452
|
+
* does not ask for `site:blocks` that read the register or propose an import,
|
|
453
|
+
* when the other does.
|
|
454
|
+
*
|
|
455
|
+
* A plugin that asks for `site:blocks` reads nothing in the register and holds
|
|
456
|
+
* no member list, so what it publishes cannot name a resident. An extension is
|
|
457
|
+
* a path between two plugins in both directions: the host hands the addon the
|
|
458
|
+
* point's input, and the addon hands the host its output. A website host's
|
|
459
|
+
* points accept no personal data, but an addon that reads the register, or
|
|
460
|
+
* holds the member list it proposes, could still return a name in a field the
|
|
461
|
+
* point types as text, and only the addon's own `personalData` would say it
|
|
462
|
+
* does not. A website addon extending such a host could be handed names as the
|
|
463
|
+
* point's input, keep them, and render them in a block. So the rule reads what
|
|
464
|
+
* each side can reach, from the two manifests, rather than what either
|
|
465
|
+
* declares it hands over. Like the rule on a single plugin, it holds an honest
|
|
466
|
+
* pair to the website's argument rather than a hostile one.
|
|
467
|
+
*/
|
|
468
|
+
export function extensionPermissionsRefused(addon, host) {
|
|
469
|
+
return [
|
|
470
|
+
...(host.permissions.includes("site:blocks")
|
|
471
|
+
? memberDataPermissions(addon.permissions)
|
|
472
|
+
: []),
|
|
473
|
+
...(addon.permissions.includes("site:blocks")
|
|
474
|
+
? memberDataPermissions(host.permissions)
|
|
475
|
+
: []),
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The personal data a plugin handles that keeps an addon asking for
|
|
480
|
+
* `site:blocks` off it, or an empty list when there is none (ADR 0030): the
|
|
481
|
+
* host plugin's own `personalData`, when the addon asks for `site:blocks`.
|
|
482
|
+
*
|
|
483
|
+
* The point's input is the one thing that crosses an extension and that no
|
|
484
|
+
* manifest declares. A host plugin that reads nothing in the register can
|
|
485
|
+
* still be handed names by another of its addons, and then hand them on as a
|
|
486
|
+
* point's input. A host plugin that handles no personal data has none to hand
|
|
487
|
+
* on, so a website addon extends only such a plugin.
|
|
488
|
+
*/
|
|
489
|
+
export function extensionHostPersonalDataRefused(addon, host) {
|
|
490
|
+
return addon.permissions.includes("site:blocks")
|
|
491
|
+
? [...host.personalData]
|
|
492
|
+
: [];
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Why the host plugin's points cannot serve an addon's extension, or null when
|
|
496
|
+
* they can (ADR 0030): the host declares no such point, declares it at another
|
|
497
|
+
* major version, or the point does not accept a personal-data category the
|
|
498
|
+
* addon hands over.
|
|
499
|
+
*
|
|
500
|
+
* Both the loader and the catalog read this, so an index that lists an addon
|
|
501
|
+
* beside the plugin it extends is refused for the three refusals the loader
|
|
502
|
+
* would give those two declarations. What the installed packages declare is
|
|
503
|
+
* the loader's to read.
|
|
504
|
+
*/
|
|
505
|
+
export function extensionPointRefusal(extension, hostPoints) {
|
|
506
|
+
const point = hostPoints.find((declared) => declared.id === extension.point);
|
|
507
|
+
if (point === undefined) {
|
|
508
|
+
return { reason: "extension-point-unknown" };
|
|
509
|
+
}
|
|
510
|
+
if (point.version !== extension.version) {
|
|
511
|
+
return {
|
|
512
|
+
reason: "extension-version-unsupported",
|
|
513
|
+
supported: point.version,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const accepted = new Set(point.personalData);
|
|
517
|
+
const categories = extension.personalData.filter((category) => !accepted.has(category));
|
|
518
|
+
return categories.length > 0
|
|
519
|
+
? { reason: "extension-personal-data-refused", categories }
|
|
520
|
+
: null;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The plugins joined to one another by extensions, each group in the order
|
|
524
|
+
* of `members`. An extension joins an addon to its host plugin whichever way
|
|
525
|
+
* data crosses it, and an addon that extends two plugins joins both groups.
|
|
526
|
+
*/
|
|
527
|
+
function extensionGroups(members) {
|
|
528
|
+
const root = new Map(members.map((member) => [member.id, member.id]));
|
|
529
|
+
const find = (id) => {
|
|
530
|
+
let current = id;
|
|
531
|
+
let parent = root.get(current);
|
|
532
|
+
while (parent !== undefined && parent !== current) {
|
|
533
|
+
current = parent;
|
|
534
|
+
parent = root.get(current);
|
|
535
|
+
}
|
|
536
|
+
return current;
|
|
537
|
+
};
|
|
538
|
+
for (const member of members) {
|
|
539
|
+
for (const extension of member.extends) {
|
|
540
|
+
if (root.has(extension.plugin)) {
|
|
541
|
+
root.set(find(member.id), find(extension.plugin));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const groups = new Map();
|
|
546
|
+
for (const member of members) {
|
|
547
|
+
const group = groups.get(find(member.id)) ?? [];
|
|
548
|
+
group.push(member);
|
|
549
|
+
groups.set(find(member.id), group);
|
|
550
|
+
}
|
|
551
|
+
return [...groups.values()];
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Which addons give way, in turn, while a group still holds both a plugin
|
|
555
|
+
* that asks for `site:blocks` and one that reads the register or proposes an
|
|
556
|
+
* import: the website addons first, then the addons that read the register or
|
|
557
|
+
* propose an import, then the addons that join two of the group's plugins. The
|
|
558
|
+
* host plugins never do, because every other addon of theirs would go with
|
|
559
|
+
* them; once the addons of the first two kinds are gone, the two sides are
|
|
560
|
+
* host plugins, and what still joins them is an addon that extends both.
|
|
561
|
+
*/
|
|
562
|
+
const GROUP_YIELDS = [
|
|
563
|
+
(member) => member.permissions.includes("site:blocks"),
|
|
564
|
+
(member) => memberDataPermissions(member.permissions).length > 0,
|
|
565
|
+
(member, standing) => new Set(member.extends
|
|
566
|
+
.map((extension) => extension.plugin)
|
|
567
|
+
.filter((plugin) => standing.has(plugin))).size > 1,
|
|
568
|
+
];
|
|
569
|
+
/**
|
|
570
|
+
* The addons refused because the plugins joined to them by extensions hold
|
|
571
|
+
* both a plugin that asks for `site:blocks` and one that reads the register or
|
|
572
|
+
* proposes an import, by id (ADR 0030).
|
|
573
|
+
*
|
|
574
|
+
* The rule between an addon and its host plugin reads two manifests. Data
|
|
575
|
+
* also crosses between two addons of one host plugin, and further: an addon
|
|
576
|
+
* that reads the register, or holds the member list it proposes, hands the
|
|
577
|
+
* host plugin a name while declaring nothing, the host plugin hands it to a
|
|
578
|
+
* website addon as a point's input, and the website addon renders it. Neither manifest of any one pair says so.
|
|
579
|
+
* So the plugins joined by extensions, through one host plugin or several,
|
|
580
|
+
* are read as one group, and a group never holds both kinds.
|
|
581
|
+
*
|
|
582
|
+
* The answer depends only on the set of members, never on their order, so it
|
|
583
|
+
* is the same whichever order the loader finds the packages in. Addons give
|
|
584
|
+
* way in the order of `GROUP_YIELDS`, which keeps the plugin that reads the
|
|
585
|
+
* register or proposes an import serving and refuses the website addon beside
|
|
586
|
+
* it.
|
|
587
|
+
*/
|
|
588
|
+
export function extensionGroupRefusals(members) {
|
|
589
|
+
const refused = new Map();
|
|
590
|
+
for (const yields of GROUP_YIELDS) {
|
|
591
|
+
const standing = members.filter((member) => !refused.has(member.id));
|
|
592
|
+
const ids = new Set(standing.map((member) => member.id));
|
|
593
|
+
for (const group of extensionGroups(standing)) {
|
|
594
|
+
const website = group
|
|
595
|
+
.filter((member) => member.permissions.includes("site:blocks"))
|
|
596
|
+
.map((member) => member.id);
|
|
597
|
+
const register = group
|
|
598
|
+
.filter((member) => memberDataPermissions(member.permissions).length > 0)
|
|
599
|
+
.map((member) => member.id);
|
|
600
|
+
if (website.length === 0 || register.length === 0) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
for (const member of group) {
|
|
604
|
+
if (member.extends.length > 0 && yields(member, ids)) {
|
|
605
|
+
refused.set(member.id, { website, register });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return refused;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* One device route: a path under the plugin's own mount that answers to a
|
|
614
|
+
* device token and to nothing else (ADR 0031).
|
|
615
|
+
*
|
|
616
|
+
* Declared in the manifest because the board reads it on the install consent
|
|
617
|
+
* screen, and because the guard has to know which paths take no session cookie
|
|
618
|
+
* before any of the plugin's code has run.
|
|
619
|
+
*/
|
|
620
|
+
export const pluginDeviceRouteSchema = z.object({
|
|
621
|
+
/** The handle a token is issued for. */
|
|
622
|
+
id: z
|
|
623
|
+
.string()
|
|
624
|
+
.min(2)
|
|
625
|
+
.max(32)
|
|
626
|
+
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "must be lowercase letters, digits and single hyphens"),
|
|
627
|
+
/** The path, which covers itself and everything below it. */
|
|
628
|
+
path: pluginRoutePathSchema,
|
|
629
|
+
});
|
|
630
|
+
/** At most this many device routes per plugin. */
|
|
631
|
+
export const MAX_DEVICE_ROUTES = 4;
|
|
632
|
+
/**
|
|
633
|
+
* Whether one route path is the other or lies below it, segment by segment.
|
|
634
|
+
*
|
|
635
|
+
* `screen` and `screen/pictures` overlap; `screen` and `screens` do not.
|
|
636
|
+
*/
|
|
637
|
+
export function routePathsOverlap(left, right) {
|
|
638
|
+
return (left === right ||
|
|
639
|
+
left.startsWith(`${right}/`) ||
|
|
640
|
+
right.startsWith(`${left}/`));
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The declared device routes, as every boundary that reads them must read
|
|
644
|
+
* them: the manifest and the catalog entry the consent screen renders from.
|
|
645
|
+
*
|
|
646
|
+
* An id names one route, and no two routes overlap. A token is issued for one
|
|
647
|
+
* route by its id, and a request is matched to one route by its path, so a
|
|
648
|
+
* duplicated id or a path inside another route's would leave the guard two
|
|
649
|
+
* answers to choose from.
|
|
650
|
+
*/
|
|
651
|
+
export function declaredDeviceRoutesSchema(route) {
|
|
652
|
+
return z
|
|
653
|
+
.array(route)
|
|
654
|
+
.max(MAX_DEVICE_ROUTES)
|
|
655
|
+
.superRefine((routes, ctx) => {
|
|
656
|
+
for (const [index, declared] of routes.entries()) {
|
|
657
|
+
for (const earlier of routes.slice(0, index)) {
|
|
658
|
+
if (earlier.id === declared.id) {
|
|
659
|
+
ctx.addIssue({
|
|
660
|
+
code: "custom",
|
|
661
|
+
path: [index, "id"],
|
|
662
|
+
message: `two device routes are declared with the id "${declared.id}"`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (routePathsOverlap(earlier.path, declared.path)) {
|
|
666
|
+
ctx.addIssue({
|
|
667
|
+
code: "custom",
|
|
668
|
+
path: [index, "path"],
|
|
669
|
+
message: `the device route "${declared.path}" overlaps "${earlier.path}"`,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
export const pluginDeviceRoutesSchema = declaredDeviceRoutesSchema(pluginDeviceRouteSchema);
|
|
677
|
+
/**
|
|
678
|
+
* The rules a declaration of device routes has to keep beside the rest of the
|
|
679
|
+
* manifest, for the manifest and the catalog entry alike.
|
|
680
|
+
*
|
|
681
|
+
* A device route may not overlap the OAuth protected resource, which takes a
|
|
682
|
+
* different token on its own branch of the guard, and a plugin with a device
|
|
683
|
+
* route reads nothing from the register: a device is a screen on a wall that
|
|
684
|
+
* anyone passing reads (ADR 0031).
|
|
685
|
+
*/
|
|
686
|
+
export function deviceRouteRuleIssues(declaration) {
|
|
687
|
+
const issues = [];
|
|
688
|
+
if (declaration.deviceRoutes.length === 0) {
|
|
689
|
+
return issues;
|
|
690
|
+
}
|
|
691
|
+
const resource = declaration.oauthProtectedResource;
|
|
692
|
+
for (const [index, route] of declaration.deviceRoutes.entries()) {
|
|
693
|
+
if (resource !== undefined && routePathsOverlap(route.path, resource)) {
|
|
694
|
+
issues.push({
|
|
695
|
+
path: ["deviceRoutes", index, "path"],
|
|
696
|
+
message: `the device route "${route.path}" overlaps the oauthProtectedResource "${resource}"`,
|
|
697
|
+
rule: "device-route-overlaps-oauth-resource",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// The finances too: a fee notice and a charge carry the names of the
|
|
702
|
+
// people they are for, as they do for `site:blocks`.
|
|
703
|
+
if (readsRegister(declaration.permissions)) {
|
|
704
|
+
issues.push({
|
|
705
|
+
path: ["permissions"],
|
|
706
|
+
message: "a plugin that declares deviceRoutes may not also read the address book or the finances: what a device shows, anyone passing it reads",
|
|
707
|
+
rule: "device-route-reads-register",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
// And the member list a plugin that proposes an import holds, for the same
|
|
711
|
+
// reason.
|
|
712
|
+
if (declaration.permissions.includes("addressBook:proposeImport")) {
|
|
713
|
+
issues.push({
|
|
714
|
+
path: ["permissions"],
|
|
715
|
+
message: "a plugin that declares deviceRoutes may not also propose imports: it holds the member list, and what a device shows, anyone passing it reads",
|
|
716
|
+
rule: "device-route-proposes-import",
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return issues;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* What a plugin that proposes an import declares about the data it carries,
|
|
723
|
+
* for the manifest and the catalog entry alike (ADR 0043).
|
|
724
|
+
*
|
|
725
|
+
* The file is the association's member list, and the plugin carries it
|
|
726
|
+
* whole: every name, contact detail and personal identity number, and rows
|
|
727
|
+
* about persons with protected personal data, which the file does not mark.
|
|
728
|
+
* So the board is told so on the consent screen. And no other plugin can reach
|
|
729
|
+
* an identity number or a protected person, so for any other plugin either
|
|
730
|
+
* category would describe something that cannot happen.
|
|
731
|
+
*/
|
|
732
|
+
export function proposeImportPersonalDataIssues(declaration) {
|
|
733
|
+
const proposes = declaration.permissions.includes("addressBook:proposeImport");
|
|
734
|
+
if (proposes) {
|
|
735
|
+
const missing = PROPOSE_IMPORT_PERSONAL_DATA.filter((category) => !declaration.personalData.includes(category));
|
|
736
|
+
return missing.length === 0
|
|
737
|
+
? []
|
|
738
|
+
: [
|
|
739
|
+
{
|
|
740
|
+
message: `a plugin that asks for "addressBook:proposeImport" carries the member list and must declare ${missing.map((category) => `"${category}"`).join(", ")}`,
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
}
|
|
744
|
+
return PROPOSE_IMPORT_ONLY_PERSONAL_DATA.filter((category) => declaration.personalData.includes(category)).map((category) => ({
|
|
745
|
+
message: `only a plugin that asks for "addressBook:proposeImport" may declare "${category}": no other plugin can reach it`,
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* A calendar date written `YYYY-MM-DD`, and a real one: `2027-02-30` is
|
|
750
|
+
* refused rather than rolled over into March.
|
|
751
|
+
*/
|
|
752
|
+
export function isCalendarDay(text) {
|
|
753
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
754
|
+
if (match === null) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
const [year, month, day] = [match[1], match[2], match[3]].map(Number);
|
|
758
|
+
const instant = new Date(Date.UTC(year, month - 1, day));
|
|
759
|
+
return (instant.getUTCFullYear() === year &&
|
|
760
|
+
instant.getUTCMonth() === month - 1 &&
|
|
761
|
+
instant.getUTCDate() === day);
|
|
762
|
+
}
|
|
763
|
+
const calendarDaySchema = z
|
|
764
|
+
.string()
|
|
765
|
+
.refine(isCalendarDay, "must be a calendar date, YYYY-MM-DD");
|
|
766
|
+
/**
|
|
767
|
+
* Which of an issuer's keys signed a licence (ADR 0042).
|
|
768
|
+
*
|
|
769
|
+
* It selects the keys to try and asserts nothing: two plugins may declare one
|
|
770
|
+
* key id with different keys, and a key is known by its fingerprint.
|
|
771
|
+
*/
|
|
772
|
+
export const licenceKeyIdSchema = z
|
|
773
|
+
.string()
|
|
774
|
+
.regex(/^[a-z0-9-]{1,64}$/, "must be 1 to 64 lowercase letters, digits and hyphens");
|
|
775
|
+
/**
|
|
776
|
+
* An Ed25519 public key: the base64 of its 32 raw bytes, padded, and in the
|
|
777
|
+
* one spelling that decodes to them. The last character before the padding
|
|
778
|
+
* carries two bits no key has, which is what the class of letters it may be
|
|
779
|
+
* from says.
|
|
780
|
+
*/
|
|
781
|
+
export const licencePublicKeySchema = z
|
|
782
|
+
.string()
|
|
783
|
+
.regex(/^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/, "must be the base64 of a 32-byte Ed25519 public key");
|
|
784
|
+
/**
|
|
785
|
+
* A service a plugin may present the instance's licence to: an `https` origin
|
|
786
|
+
* as the WHATWG URL standard serialises one, so it compares exactly.
|
|
787
|
+
*
|
|
788
|
+
* A lower-case domain in punycode, a port only when it is not 443, and no
|
|
789
|
+
* userinfo, path, query or fragment. A host whose last label is a number is
|
|
790
|
+
* an IPv4 address to that standard, which serialises it otherwise, so it is
|
|
791
|
+
* refused rather than compared in a form no browser would print. Plain `http`
|
|
792
|
+
* is refused too: a presentation sent in the clear could be replayed before
|
|
793
|
+
* the service saw its nonce.
|
|
794
|
+
*/
|
|
795
|
+
export const licenceAudienceSchema = z
|
|
796
|
+
.string()
|
|
797
|
+
.max(256)
|
|
798
|
+
.refine(isCanonicalHttpsOrigin, {
|
|
799
|
+
error: "must be an https origin in its canonical form, such as https://themes.example.org",
|
|
800
|
+
});
|
|
801
|
+
function isCanonicalHttpsOrigin(value) {
|
|
802
|
+
const match = /^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(value);
|
|
803
|
+
if (match === null) {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
const host = match[1] ?? "";
|
|
807
|
+
const lastLabel = host.slice(host.lastIndexOf(".") + 1);
|
|
808
|
+
if (/^(?:[0-9]+|0x[0-9a-f]*)$/.test(lastLabel) || host.length > 253) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const port = match[2];
|
|
812
|
+
return port === undefined || (Number(port) <= 65535 && port !== "443");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* The licence a paid plugin reads (ADR 0042), and whom it trusts to issue it.
|
|
816
|
+
*
|
|
817
|
+
* The keys sit here rather than in the core, so the core holds no vendor's
|
|
818
|
+
* key and anyone who sells a plugin can issue licences for it in the same
|
|
819
|
+
* format. The host verifies; a plugin reads the result through
|
|
820
|
+
* `host.entitlements`.
|
|
821
|
+
*
|
|
822
|
+
* Strict, because what it grants is consented to: a misspelled field read as
|
|
823
|
+
* absent would be a declaration the board never saw.
|
|
824
|
+
*/
|
|
825
|
+
export const pluginLicenceSchema = z.strictObject({
|
|
826
|
+
/**
|
|
827
|
+
* The one entitlement the plugin reads, named as a plugin id is. A licence
|
|
828
|
+
* may carry several; the host reports this one only.
|
|
829
|
+
*/
|
|
830
|
+
entitlement: pluginIdSchema,
|
|
831
|
+
/**
|
|
832
|
+
* Who issues the licence. The licence screen and the audit log name the
|
|
833
|
+
* issuer from here, never from text in a licence file.
|
|
834
|
+
*/
|
|
835
|
+
issuer: z.strictObject({
|
|
836
|
+
name: z.string().min(1).max(100),
|
|
837
|
+
url: z
|
|
838
|
+
.string()
|
|
839
|
+
.max(2000)
|
|
840
|
+
.regex(/^https:\/\/[^\s/?#]+(?:\/\S*)?$/, "must be an https address"),
|
|
841
|
+
}),
|
|
842
|
+
/**
|
|
843
|
+
* The issuer's public keys by key id. At most two at once: a new key is
|
|
844
|
+
* declared in a release before any licence is signed with it, and the old
|
|
845
|
+
* one leaves in a later release.
|
|
846
|
+
*/
|
|
847
|
+
keys: z.record(licenceKeyIdSchema, licencePublicKeySchema).refine((keys) => {
|
|
848
|
+
const count = Object.keys(keys).length;
|
|
849
|
+
return count >= 1 && count <= 2;
|
|
850
|
+
}, "must declare one or two keys"),
|
|
851
|
+
/**
|
|
852
|
+
* The services of the issuer's the plugin may present the licence to.
|
|
853
|
+
* None for a plugin that uses no service of its issuer's.
|
|
854
|
+
*/
|
|
855
|
+
audiences: z
|
|
856
|
+
.array(licenceAudienceSchema)
|
|
857
|
+
.max(8)
|
|
858
|
+
.refine((audiences) => new Set(audiences).size === audiences.length, "must not name an audience twice")
|
|
859
|
+
.default([]),
|
|
860
|
+
});
|
|
158
861
|
export const pluginManifestSchema = z
|
|
159
862
|
.object({
|
|
160
863
|
/**
|
|
@@ -169,18 +872,22 @@ export const pluginManifestSchema = z
|
|
|
169
872
|
.array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
|
|
170
873
|
.max(16)
|
|
171
874
|
.default([]),
|
|
875
|
+
/**
|
|
876
|
+
* What the processing of that personal data is for (ADR 0035).
|
|
877
|
+
*
|
|
878
|
+
* Declared by a plugin that declares personal data, and by no other: a
|
|
879
|
+
* plugin with none has no row in the record of processing activities.
|
|
880
|
+
* Optional here, so a plugin published before the field existed keeps
|
|
881
|
+
* loading; the package check, and so the catalog, refuses a package that
|
|
882
|
+
* declares personal data without it.
|
|
883
|
+
*/
|
|
884
|
+
processing: pluginProcessingSchema.optional(),
|
|
172
885
|
settingsSchema: pluginSettingsSchema.optional(),
|
|
173
886
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*/
|
|
177
|
-
view:
|
|
178
|
-
.object({
|
|
179
|
-
/** Named export of the remote module, per Module Federation. */
|
|
180
|
-
module: z.string().min(1).max(100).default("./View"),
|
|
181
|
-
titleKey: z.string().min(1).max(200),
|
|
182
|
-
})
|
|
183
|
-
.optional(),
|
|
887
|
+
* The plugin's own screen in the application, when it has one, at
|
|
888
|
+
* `/plugin/<id>`. The title is an i18n key in the plugin's own namespace.
|
|
889
|
+
*/
|
|
890
|
+
view: pluginViewSchema.optional(),
|
|
184
891
|
/** What this plugin proposes the platform be able to do. */
|
|
185
892
|
actions: pluginActionsSchema.default([]),
|
|
186
893
|
/**
|
|
@@ -196,8 +903,195 @@ export const pluginManifestSchema = z
|
|
|
196
903
|
* second one is refused at install.
|
|
197
904
|
*/
|
|
198
905
|
oauthProtectedResource: pluginRoutePathSchema.optional(),
|
|
906
|
+
/**
|
|
907
|
+
* The routes, under this plugin's own mount, that a device reaches with a
|
|
908
|
+
* device token the board issued (ADR 0031).
|
|
909
|
+
*
|
|
910
|
+
* Such a route answers `GET` and `HEAD` with `Authorization: Bearer
|
|
911
|
+
* <device token>` and nothing else: no session cookie, no person and no
|
|
912
|
+
* capability floor. What stands in for the floor is that a plugin declaring
|
|
913
|
+
* one may not read the register.
|
|
914
|
+
*/
|
|
915
|
+
deviceRoutes: pluginDeviceRoutesSchema.default([]),
|
|
916
|
+
/**
|
|
917
|
+
* That this plugin is an AI package: a language model chooses the actions
|
|
918
|
+
* it dispatches, for the person whose request its route is serving.
|
|
919
|
+
*
|
|
920
|
+
* Declaring it moves the plugin's dispatch onto the `ai` audit channel and
|
|
921
|
+
* the `ai` surface. Its writes are recorded as made through the AI package
|
|
922
|
+
* rather than as any plugin's; it reaches only the actions offered on `ai`;
|
|
923
|
+
* and a plugin action is reached only once an administrator has armed it,
|
|
924
|
+
* as on `mcp`. A connected app's token on its route keeps `mcp`.
|
|
925
|
+
*
|
|
926
|
+
* It narrows what the plugin can reach and never widens it, and it is the
|
|
927
|
+
* only say a plugin has in its channel. The host derives the channel from
|
|
928
|
+
* this declaration and from how the request arrived; nothing a plugin
|
|
929
|
+
* passes to `host.actions` names one. The consent screen shows it.
|
|
930
|
+
*/
|
|
931
|
+
ai: z.boolean().default(false),
|
|
932
|
+
/**
|
|
933
|
+
* The blocks this plugin adds to the association's website.
|
|
934
|
+
*
|
|
935
|
+
* Declared here rather than at registration for the reason actions are:
|
|
936
|
+
* the board consents to what a plugin may put on the website before any of
|
|
937
|
+
* its code has run. A plugin declaring one asks for `site:blocks`.
|
|
938
|
+
*/
|
|
939
|
+
blocks: pluginBlocksSchema.default([]),
|
|
940
|
+
/**
|
|
941
|
+
* The extension points this plugin offers other plugins (ADR 0030). At
|
|
942
|
+
* most eight.
|
|
943
|
+
*/
|
|
944
|
+
extensionPoints: declaredExtensionPointsSchema(pluginExtensionPointSchema).default([]),
|
|
945
|
+
/**
|
|
946
|
+
* The extension points of other plugins this plugin extends, which makes
|
|
947
|
+
* it an addon (ADR 0030). At most eight.
|
|
948
|
+
*/
|
|
949
|
+
extends: declaredExtensionsSchema(pluginExtensionSchema).default([]),
|
|
950
|
+
/**
|
|
951
|
+
* Where the plugin connects to outside the instance, and which personal
|
|
952
|
+
* data each place receives (ADR 0037).
|
|
953
|
+
*
|
|
954
|
+
* Shown on the install consent screen, where it fills in the question
|
|
955
|
+
* about where the plugin sends personal data, and consented to as a
|
|
956
|
+
* snapshot: a republished version that adds a recipient, moves one or
|
|
957
|
+
* sends one more is refused at load until the board consents again.
|
|
958
|
+
*/
|
|
959
|
+
recipients: pluginRecipientsSchema.default([]),
|
|
960
|
+
/**
|
|
961
|
+
* The licence this plugin reads, for a paid plugin (ADR 0042).
|
|
962
|
+
*
|
|
963
|
+
* Declaring it gives the plugin `host.entitlements`. Nothing else in the
|
|
964
|
+
* host reads it: the plugin loads and serves either way, and what it
|
|
965
|
+
* withholds without an active entitlement is its own decision.
|
|
966
|
+
*/
|
|
967
|
+
licence: pluginLicenceSchema.optional(),
|
|
968
|
+
/**
|
|
969
|
+
* The day this release was published, written by the release workflow and
|
|
970
|
+
* compared with a licence's `updatesUntil`. Required with `licence`.
|
|
971
|
+
*/
|
|
972
|
+
released: calendarDaySchema.optional(),
|
|
199
973
|
})
|
|
200
974
|
.superRefine((manifest, ctx) => {
|
|
975
|
+
for (const { rule, ...issue } of extensionRuleIssues(manifest)) {
|
|
976
|
+
ctx.addIssue({
|
|
977
|
+
code: "custom",
|
|
978
|
+
...issue,
|
|
979
|
+
...(rule === undefined ? {} : { params: { rule } }),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
/*
|
|
983
|
+
* A processing with no personal data in it would seed a row of the record
|
|
984
|
+
* of processing activities claiming the plugin processes somebody's data,
|
|
985
|
+
* which is the placeholder this declaration exists to remove.
|
|
986
|
+
*/
|
|
987
|
+
if (manifest.processing !== undefined &&
|
|
988
|
+
manifest.personalData.length === 0) {
|
|
989
|
+
ctx.addIssue({
|
|
990
|
+
code: "custom",
|
|
991
|
+
path: ["processing"],
|
|
992
|
+
message: "a plugin that declares no personal data declares no processing",
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
/*
|
|
996
|
+
* A licence sold with a year of updates covers the releases published in
|
|
997
|
+
* that year, and a release that does not say when it was published cannot
|
|
998
|
+
* be judged. Refused here rather than read as covered, which would make
|
|
999
|
+
* every licence for updates cover every release.
|
|
1000
|
+
*/
|
|
1001
|
+
if (manifest.licence !== undefined && manifest.released === undefined) {
|
|
1002
|
+
ctx.addIssue({
|
|
1003
|
+
code: "custom",
|
|
1004
|
+
path: ["released"],
|
|
1005
|
+
message: "a plugin that declares a licence must say when it was released",
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
// What a recipient receives is part of what the plugin handles.
|
|
1009
|
+
for (const issue of recipientCategoryIssues(manifest.recipients, manifest.personalData)) {
|
|
1010
|
+
ctx.addIssue({ code: "custom", ...issue });
|
|
1011
|
+
}
|
|
1012
|
+
/*
|
|
1013
|
+
* A block is published on the website, to readers with no account, so it
|
|
1014
|
+
* is something the board consents to by name rather than something a
|
|
1015
|
+
* plugin acquires by declaring it. The permission is what the install
|
|
1016
|
+
* consent screen shows; the declarations are what the host registers.
|
|
1017
|
+
*/
|
|
1018
|
+
if (manifest.blocks.length > 0 &&
|
|
1019
|
+
!manifest.permissions.includes("site:blocks")) {
|
|
1020
|
+
ctx.addIssue({
|
|
1021
|
+
code: "custom",
|
|
1022
|
+
path: ["blocks"],
|
|
1023
|
+
message: 'a plugin that declares blocks must ask for the "site:blocks" permission',
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/*
|
|
1027
|
+
* And a plugin that puts blocks on the website does not read the
|
|
1028
|
+
* register. The website's argument that it cannot publish a resident's
|
|
1029
|
+
* details rests on there being no path from it to the register (ADR 0006),
|
|
1030
|
+
* and a block that could ask the address book what to print would be one.
|
|
1031
|
+
* So would one that could ask the finances: a fee notice and a charge carry
|
|
1032
|
+
* the names of the people they are for. Permissions are not a sandbox, so
|
|
1033
|
+
* this holds an honest plugin to that argument rather than a hostile one;
|
|
1034
|
+
* curation does the rest.
|
|
1035
|
+
*/
|
|
1036
|
+
if (manifest.permissions.includes("site:blocks") &&
|
|
1037
|
+
readsRegister(manifest.permissions)) {
|
|
1038
|
+
ctx.addIssue({
|
|
1039
|
+
code: "custom",
|
|
1040
|
+
path: ["permissions"],
|
|
1041
|
+
message: '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',
|
|
1042
|
+
params: { rule: "blocks-read-register" },
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
/*
|
|
1046
|
+
* Nor one that proposes an import. It reads nothing from the register, but
|
|
1047
|
+
* it holds the member list itself, identity numbers and protected persons
|
|
1048
|
+
* included, and a block is published to anybody (ADR 0043).
|
|
1049
|
+
*/
|
|
1050
|
+
if (manifest.permissions.includes("site:blocks") &&
|
|
1051
|
+
manifest.permissions.includes("addressBook:proposeImport")) {
|
|
1052
|
+
ctx.addIssue({
|
|
1053
|
+
code: "custom",
|
|
1054
|
+
path: ["permissions"],
|
|
1055
|
+
message: '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',
|
|
1056
|
+
params: { rule: "blocks-propose-import" },
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
for (const issue of proposeImportPersonalDataIssues(manifest)) {
|
|
1060
|
+
ctx.addIssue({ code: "custom", path: ["personalData"], ...issue });
|
|
1061
|
+
}
|
|
1062
|
+
/*
|
|
1063
|
+
* A news item and an event are text the board wrote, and published text
|
|
1064
|
+
* may name anybody. `personalData` names everything a plugin handles, not
|
|
1065
|
+
* only what it reads from the register, so a plugin reading that text says
|
|
1066
|
+
* so, and the record of processing carries it (ADR 0032).
|
|
1067
|
+
*/
|
|
1068
|
+
for (const permission of ["news:read", "calendar:read"]) {
|
|
1069
|
+
if (manifest.permissions.includes(permission) &&
|
|
1070
|
+
!manifest.personalData.includes("freeText")) {
|
|
1071
|
+
ctx.addIssue({
|
|
1072
|
+
code: "custom",
|
|
1073
|
+
path: ["personalData"],
|
|
1074
|
+
message: `a plugin that asks for "${permission}" must declare "freeText": published text may name anybody`,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/*
|
|
1079
|
+
* The same for a picture. The host opens only one whose upload declared
|
|
1080
|
+
* that it shows no identifiable person, but that is somebody's declaration
|
|
1081
|
+
* and not a fact about the picture, and a photograph is a category of its
|
|
1082
|
+
* own in the record of processing whoever is in it (ADR 0032).
|
|
1083
|
+
*/
|
|
1084
|
+
if (manifest.permissions.includes("media:read") &&
|
|
1085
|
+
!manifest.personalData.includes("photograph")) {
|
|
1086
|
+
ctx.addIssue({
|
|
1087
|
+
code: "custom",
|
|
1088
|
+
path: ["personalData"],
|
|
1089
|
+
message: 'a plugin that asks for "media:read" must declare "photograph": a stored picture is a photograph whoever is in it',
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
for (const { rule, ...issue } of deviceRouteRuleIssues(manifest)) {
|
|
1093
|
+
ctx.addIssue({ code: "custom", ...issue, params: { rule } });
|
|
1094
|
+
}
|
|
201
1095
|
/*
|
|
202
1096
|
* The composed name has to fit the public pattern, and only the pair can
|
|
203
1097
|
* know whether it does: a plugin id runs to 48 characters and an action id to
|
|
@@ -222,6 +1116,45 @@ export const pluginManifestSchema = z
|
|
|
222
1116
|
}
|
|
223
1117
|
}
|
|
224
1118
|
});
|
|
1119
|
+
/**
|
|
1120
|
+
* The problem with a declaration that names personal data and no processing,
|
|
1121
|
+
* or null when there is none.
|
|
1122
|
+
*
|
|
1123
|
+
* Not part of the manifest schema, which the loader also reads: a plugin
|
|
1124
|
+
* published before the field existed has to keep loading, and its row keeps
|
|
1125
|
+
* the placeholder it was seeded with. The package check and version 2 of the
|
|
1126
|
+
* catalog index refuse it, so nothing listed from now on lacks one.
|
|
1127
|
+
*/
|
|
1128
|
+
export function missingProcessingProblem(declaration) {
|
|
1129
|
+
return declaration.personalData.length > 0 &&
|
|
1130
|
+
declaration.processing === undefined
|
|
1131
|
+
? "a plugin that declares personal data declares its processing"
|
|
1132
|
+
: null;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Whether these permissions read the address book: a plugin holding one
|
|
1136
|
+
* processes the residents' personal data, whatever it declares.
|
|
1137
|
+
*/
|
|
1138
|
+
export function readsAddressBook(permissions) {
|
|
1139
|
+
return (permissions.includes("addressBook:read") ||
|
|
1140
|
+
permissions.includes("addressBook:readContact"));
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The problem with a declaration that reads the address book and names no
|
|
1144
|
+
* personal data, or null when there is none.
|
|
1145
|
+
*
|
|
1146
|
+
* Such a plugin would have no processing declaration and no row in the record
|
|
1147
|
+
* of processing activities, so nobody could object to what it reads. Held
|
|
1148
|
+
* where `missingProcessingProblem` is, and for the same reason not in the
|
|
1149
|
+
* manifest schema: a plugin published before this rule keeps loading, and its
|
|
1150
|
+
* install keeps a row (`readsAddressBook`).
|
|
1151
|
+
*/
|
|
1152
|
+
export function undeclaredPersonalDataProblem(declaration) {
|
|
1153
|
+
return readsAddressBook(declaration.permissions) &&
|
|
1154
|
+
declaration.personalData.length === 0
|
|
1155
|
+
? "a plugin that reads the address book declares the personal data it reads"
|
|
1156
|
+
: null;
|
|
1157
|
+
}
|
|
225
1158
|
/**
|
|
226
1159
|
* Dependency fields npm installs at install time.
|
|
227
1160
|
*
|
|
@@ -306,11 +1239,23 @@ export function parsePluginPackage(input) {
|
|
|
306
1239
|
if (result.success) {
|
|
307
1240
|
return { ok: true, value: result.data };
|
|
308
1241
|
}
|
|
1242
|
+
const diagnostics = result.error.issues.map((issue) => ({
|
|
1243
|
+
rule: manifestRuleOf(issue),
|
|
1244
|
+
issue: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
|
1245
|
+
}));
|
|
309
1246
|
return {
|
|
310
1247
|
ok: false,
|
|
311
|
-
issues:
|
|
1248
|
+
issues: diagnostics.map((diagnostic) => diagnostic.issue),
|
|
1249
|
+
diagnostics,
|
|
312
1250
|
};
|
|
313
1251
|
}
|
|
1252
|
+
function manifestRuleOf(issue) {
|
|
1253
|
+
if (issue.code !== "custom" || typeof issue.params !== "object") {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
const rule = issue.params?.rule;
|
|
1257
|
+
return PLUGIN_MANIFEST_RULES.find((known) => known === rule) ?? null;
|
|
1258
|
+
}
|
|
314
1259
|
/** Convenience for a plugin's own CI: throws with every problem at once. */
|
|
315
1260
|
export function assertPluginPackage(input) {
|
|
316
1261
|
const result = parsePluginPackage(input);
|