@openbrf/plugin-sdk 1.2.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/src/manifest.ts CHANGED
@@ -219,6 +219,99 @@ export const PLUGIN_VIEW_SECTIONS = [
219
219
 
220
220
  export type PluginViewSection = (typeof PLUGIN_VIEW_SECTIONS)[number];
221
221
 
222
+ /**
223
+ * Text a plugin author writes in both languages the interface is offered in.
224
+ *
225
+ * Strict, as everything in the processing declaration is: a field the
226
+ * declaration does not define is refused rather than dropped, so a misspelled
227
+ * key cannot leave a row of the record of processing activities without the
228
+ * text its author meant to put there.
229
+ */
230
+ const pluginLocalizedTextSchema = z.strictObject({
231
+ sv: z.string().trim().min(1).max(500),
232
+ en: z.string().trim().min(1).max(500),
233
+ });
234
+
235
+ /**
236
+ * The art. 6(1) bases a plugin may declare its processing on.
237
+ *
238
+ * Four of the six. Vital interests and public task are not bases a housing
239
+ * cooperative's plugin has any business with, and a declaration naming one
240
+ * would be a claim the board then has to correct rather than a statement it can
241
+ * publish.
242
+ */
243
+ export const PLUGIN_LEGAL_BASES = [
244
+ "consent",
245
+ "contract",
246
+ "legalObligation",
247
+ "legitimateInterest",
248
+ ] as const;
249
+
250
+ export type PluginLegalBasis = (typeof PLUGIN_LEGAL_BASES)[number];
251
+
252
+ /** Whose personal data a plugin's processing concerns. */
253
+ export const PLUGIN_DATA_SUBJECTS = ["members", "residents"] as const;
254
+
255
+ export type PluginDataSubject = (typeof PLUGIN_DATA_SUBJECTS)[number];
256
+
257
+ /**
258
+ * What a plugin's processing of personal data is for, on what basis, for how
259
+ * long, and about whom (ADR 0035).
260
+ *
261
+ * The install seeds the plugin's row in the record of processing activities
262
+ * from it, in the association's language, and the consent screen shows it
263
+ * before anything is downloaded. It grants nothing: what a plugin may read is
264
+ * its permissions.
265
+ */
266
+ export const pluginProcessingSchema = z
267
+ .strictObject({
268
+ purpose: pluginLocalizedTextSchema,
269
+ legalBasis: z.enum(PLUGIN_LEGAL_BASES),
270
+ /** The interest relied on, which art. 13(1)(d) requires on the notice. */
271
+ legitimateInterest: pluginLocalizedTextSchema.optional(),
272
+ /** How long the data is kept and when it goes. */
273
+ retention: pluginLocalizedTextSchema,
274
+ dataSubjects: z
275
+ .array(z.enum(PLUGIN_DATA_SUBJECTS))
276
+ .min(1)
277
+ .max(PLUGIN_DATA_SUBJECTS.length)
278
+ .refine(
279
+ (subjects) => new Set(subjects).size === subjects.length,
280
+ "names a category of data subjects twice",
281
+ ),
282
+ })
283
+ .superRefine((processing, ctx) => {
284
+ /*
285
+ * Both ways. A processing on legitimate interest names the interest,
286
+ * because the privacy notice has to; one on another basis names none,
287
+ * because the row would then carry an interest for a processing that does
288
+ * not rest on one.
289
+ */
290
+ if (
291
+ processing.legalBasis === "legitimateInterest" &&
292
+ processing.legitimateInterest === undefined
293
+ ) {
294
+ ctx.addIssue({
295
+ code: "custom",
296
+ path: ["legitimateInterest"],
297
+ message: "a processing on legitimate interest names the interest",
298
+ });
299
+ }
300
+ if (
301
+ processing.legalBasis !== "legitimateInterest" &&
302
+ processing.legitimateInterest !== undefined
303
+ ) {
304
+ ctx.addIssue({
305
+ code: "custom",
306
+ path: ["legitimateInterest"],
307
+ message:
308
+ 'only a processing on "legitimateInterest" names a legitimate interest',
309
+ });
310
+ }
311
+ });
312
+
313
+ export type PluginProcessing = z.infer<typeof pluginProcessingSchema>;
314
+
222
315
  export const pluginViewSchema = z.object({
223
316
  /** Named export of the remote module, per Module Federation. */
224
317
  module: z.string().min(1).max(100).default("./View"),
@@ -348,6 +441,7 @@ export interface ExtensionDeclarations {
348
441
  readonly id: string;
349
442
  readonly permissions: readonly string[];
350
443
  readonly personalData: readonly string[];
444
+ readonly deviceRoutes?: readonly unknown[];
351
445
  readonly extensionPoints: readonly {
352
446
  readonly personalData: readonly string[];
353
447
  }[];
@@ -364,10 +458,16 @@ export interface ExtensionDeclarations {
364
458
  * One function for the manifest and the catalog entry, so an entry the loader
365
459
  * would refuse is never offered for consent.
366
460
  */
367
- export function extensionRuleIssues(
368
- declaration: ExtensionDeclarations,
369
- ): { path: (string | number)[]; message: string }[] {
370
- const issues: { path: (string | number)[]; message: string }[] = [];
461
+ export function extensionRuleIssues(declaration: ExtensionDeclarations): {
462
+ path: (string | number)[];
463
+ message: string;
464
+ rule?: PluginManifestRule;
465
+ }[] {
466
+ const issues: {
467
+ path: (string | number)[];
468
+ message: string;
469
+ rule?: PluginManifestRule;
470
+ }[] = [];
371
471
  const own = new Set(declaration.personalData);
372
472
 
373
473
  /*
@@ -410,6 +510,22 @@ export function extensionRuleIssues(
410
510
  'a plugin that asks for "site:blocks" declares extension points that accept no personal data',
411
511
  });
412
512
  }
513
+ /*
514
+ * A device is a screen on a wall, and a device token reaches a plugin's
515
+ * routes. A point that accepted contact details would let it reach them
516
+ * through an addon, so a plugin with device routes declares none that does
517
+ * (ADR 0030, ADR 0031).
518
+ */
519
+ const contact = point.personalData.filter(
520
+ (category) => category === "email" || category === "phone",
521
+ );
522
+ if ((declaration.deviceRoutes ?? []).length > 0 && contact.length > 0) {
523
+ issues.push({
524
+ path: ["extensionPoints", index, "personalData"],
525
+ message: `a plugin that declares deviceRoutes declares no extension point that accepts ${contact.join(" or ")}: contact details have no business on a device`,
526
+ rule: "device-route-extension-point-contact",
527
+ });
528
+ }
413
529
  }
414
530
 
415
531
  for (const [index, extension] of declaration.extends.entries()) {
@@ -1011,6 +1127,16 @@ export const pluginManifestSchema = z
1011
1127
  .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
1012
1128
  .max(16)
1013
1129
  .default([]),
1130
+ /**
1131
+ * What the processing of that personal data is for (ADR 0035).
1132
+ *
1133
+ * Declared by a plugin that declares personal data, and by no other: a
1134
+ * plugin with none has no row in the record of processing activities.
1135
+ * Optional here, so a plugin published before the field existed keeps
1136
+ * loading; the package check, and so the catalog, refuses a package that
1137
+ * declares personal data without it.
1138
+ */
1139
+ processing: pluginProcessingSchema.optional(),
1014
1140
  settingsSchema: pluginSettingsSchema.optional(),
1015
1141
  /**
1016
1142
  * The plugin's own screen in the application, when it has one, at
@@ -1103,14 +1229,30 @@ export const pluginManifestSchema = z
1103
1229
  released: calendarDaySchema.optional(),
1104
1230
  })
1105
1231
  .superRefine((manifest, ctx) => {
1106
- for (const issue of extensionRuleIssues(manifest)) {
1232
+ for (const { rule, ...issue } of extensionRuleIssues(manifest)) {
1107
1233
  ctx.addIssue({
1108
1234
  code: "custom",
1109
- path: issue.path,
1110
- message: issue.message,
1235
+ ...issue,
1236
+ ...(rule === undefined ? {} : { params: { rule } }),
1111
1237
  });
1112
1238
  }
1113
1239
 
1240
+ /*
1241
+ * A processing with no personal data in it would seed a row of the record
1242
+ * of processing activities claiming the plugin processes somebody's data,
1243
+ * which is the placeholder this declaration exists to remove.
1244
+ */
1245
+ if (
1246
+ manifest.processing !== undefined &&
1247
+ manifest.personalData.length === 0
1248
+ ) {
1249
+ ctx.addIssue({
1250
+ code: "custom",
1251
+ path: ["processing"],
1252
+ message:
1253
+ "a plugin that declares no personal data declares no processing",
1254
+ });
1255
+ }
1114
1256
  /*
1115
1257
  * A licence sold with a year of updates covers the releases published in
1116
1258
  * that year, and a release that does not say when it was published cannot
@@ -1267,6 +1409,56 @@ export type PluginExtensionPointDeclaration = z.infer<
1267
1409
  export type PluginExtensionDeclaration = z.infer<typeof pluginExtensionSchema>;
1268
1410
  export type PluginDeviceRoute = z.infer<typeof pluginDeviceRouteSchema>;
1269
1411
 
1412
+ /**
1413
+ * The problem with a declaration that names personal data and no processing,
1414
+ * or null when there is none.
1415
+ *
1416
+ * Not part of the manifest schema, which the loader also reads: a plugin
1417
+ * published before the field existed has to keep loading, and its row keeps
1418
+ * the placeholder it was seeded with. The package check and version 2 of the
1419
+ * catalog index refuse it, so nothing listed from now on lacks one.
1420
+ */
1421
+ export function missingProcessingProblem(declaration: {
1422
+ personalData: readonly string[];
1423
+ processing?: PluginProcessing | undefined;
1424
+ }): string | null {
1425
+ return declaration.personalData.length > 0 &&
1426
+ declaration.processing === undefined
1427
+ ? "a plugin that declares personal data declares its processing"
1428
+ : null;
1429
+ }
1430
+
1431
+ /**
1432
+ * Whether these permissions read the address book: a plugin holding one
1433
+ * processes the residents' personal data, whatever it declares.
1434
+ */
1435
+ export function readsAddressBook(permissions: readonly string[]): boolean {
1436
+ return (
1437
+ permissions.includes("addressBook:read") ||
1438
+ permissions.includes("addressBook:readContact")
1439
+ );
1440
+ }
1441
+
1442
+ /**
1443
+ * The problem with a declaration that reads the address book and names no
1444
+ * personal data, or null when there is none.
1445
+ *
1446
+ * Such a plugin would have no processing declaration and no row in the record
1447
+ * of processing activities, so nobody could object to what it reads. Held
1448
+ * where `missingProcessingProblem` is, and for the same reason not in the
1449
+ * manifest schema: a plugin published before this rule keeps loading, and its
1450
+ * install keeps a row (`readsAddressBook`).
1451
+ */
1452
+ export function undeclaredPersonalDataProblem(declaration: {
1453
+ permissions: readonly string[];
1454
+ personalData: readonly string[];
1455
+ }): string | null {
1456
+ return readsAddressBook(declaration.permissions) &&
1457
+ declaration.personalData.length === 0
1458
+ ? "a plugin that reads the address book declares the personal data it reads"
1459
+ : null;
1460
+ }
1461
+
1270
1462
  /**
1271
1463
  * Dependency fields npm installs at install time.
1272
1464
  *
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  dependencyNames,
3
3
  isEmptyDependencyField,
4
+ missingProcessingProblem,
5
+ undeclaredPersonalDataProblem,
4
6
  parsePluginPackage,
5
7
  RUNTIME_DEPENDENCY_FIELDS,
6
8
  } from "./manifest.ts";
@@ -183,6 +185,19 @@ export function pluginPackageProblems(
183
185
  problems.push(...dependencyProblems(packageJson));
184
186
 
185
187
  if (parsed.ok) {
188
+ const processing = missingProcessingProblem(parsed.value.openbrf);
189
+ if (processing !== null) {
190
+ problems.push(
191
+ `The manifest in package.json is invalid at openbrf.processing: ${processing}.`,
192
+ );
193
+ }
194
+ const personalData = undeclaredPersonalDataProblem(parsed.value.openbrf);
195
+ if (personalData !== null) {
196
+ problems.push(
197
+ `The manifest in package.json is invalid at openbrf.personalData: ${personalData}.`,
198
+ );
199
+ }
200
+
186
201
  const entries = parsed.value.openbrf.entry;
187
202
  const files = new Set(contents.files.map(normalizedPath));
188
203
  for (const [kind, declared] of [