@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/dist/catalog.d.ts +670 -11
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +174 -81
- package/dist/catalog.js.map +1 -1
- package/dist/findings.d.ts +3 -1
- package/dist/findings.d.ts.map +1 -1
- package/dist/findings.js +2 -0
- package/dist/findings.js.map +1 -1
- package/dist/host.d.ts +108 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +15 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +129 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +154 -3
- 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/package.json +1 -1
- package/src/catalog.ts +231 -116
- package/src/findings.ts +2 -0
- package/src/host.ts +112 -2
- package/src/index.ts +16 -0
- package/src/manifest.ts +199 -7
- package/src/package-check.ts +15 -0
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
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
|
*
|
package/src/package-check.ts
CHANGED
|
@@ -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 [
|