@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.
Files changed (104) hide show
  1. package/README.md +95 -0
  2. package/dist/abort-signal.d.ts +23 -0
  3. package/dist/abort-signal.d.ts.map +1 -0
  4. package/dist/abort-signal.js +2 -0
  5. package/dist/abort-signal.js.map +1 -0
  6. package/dist/action-refusal.d.ts +101 -0
  7. package/dist/action-refusal.d.ts.map +1 -0
  8. package/dist/action-refusal.js +96 -0
  9. package/dist/action-refusal.js.map +1 -0
  10. package/dist/action-schema.d.ts.map +1 -1
  11. package/dist/action-schema.js +4 -1
  12. package/dist/action-schema.js.map +1 -1
  13. package/dist/actions.d.ts +94 -6
  14. package/dist/actions.d.ts.map +1 -1
  15. package/dist/actions.js.map +1 -1
  16. package/dist/blocks.d.ts +310 -0
  17. package/dist/blocks.d.ts.map +1 -0
  18. package/dist/blocks.js +185 -0
  19. package/dist/blocks.js.map +1 -0
  20. package/dist/catalog.d.ts +1092 -41
  21. package/dist/catalog.d.ts.map +1 -1
  22. package/dist/catalog.js +472 -15
  23. package/dist/catalog.js.map +1 -1
  24. package/dist/entitlements.d.ts +117 -0
  25. package/dist/entitlements.d.ts.map +1 -0
  26. package/dist/entitlements.js +62 -0
  27. package/dist/entitlements.js.map +1 -0
  28. package/dist/extensions.d.ts +183 -0
  29. package/dist/extensions.d.ts.map +1 -0
  30. package/dist/extensions.js +67 -0
  31. package/dist/extensions.js.map +1 -0
  32. package/dist/finances.d.ts +294 -0
  33. package/dist/finances.d.ts.map +1 -0
  34. package/dist/finances.js +56 -0
  35. package/dist/finances.js.map +1 -0
  36. package/dist/findings.d.ts +92 -1
  37. package/dist/findings.d.ts.map +1 -1
  38. package/dist/findings.js +91 -0
  39. package/dist/findings.js.map +1 -1
  40. package/dist/host.d.ts +729 -4
  41. package/dist/host.d.ts.map +1 -1
  42. package/dist/host.js +54 -0
  43. package/dist/host.js.map +1 -1
  44. package/dist/imports.d.ts +104 -0
  45. package/dist/imports.d.ts.map +1 -0
  46. package/dist/imports.js +70 -0
  47. package/dist/imports.js.map +1 -0
  48. package/dist/index.d.ts +17 -7
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -6
  51. package/dist/index.js.map +1 -1
  52. package/dist/localized-text.d.ts +18 -0
  53. package/dist/localized-text.d.ts.map +1 -0
  54. package/dist/localized-text.js +17 -0
  55. package/dist/localized-text.js.map +1 -0
  56. package/dist/manifest.d.ts +859 -0
  57. package/dist/manifest.d.ts.map +1 -1
  58. package/dist/manifest.js +957 -12
  59. package/dist/manifest.js.map +1 -1
  60. package/dist/package-check.d.ts.map +1 -1
  61. package/dist/package-check.js +9 -1
  62. package/dist/package-check.js.map +1 -1
  63. package/dist/permissions.d.ts +170 -11
  64. package/dist/permissions.d.ts.map +1 -1
  65. package/dist/permissions.js +210 -2
  66. package/dist/permissions.js.map +1 -1
  67. package/dist/recipients.d.ts +139 -0
  68. package/dist/recipients.d.ts.map +1 -0
  69. package/dist/recipients.js +223 -0
  70. package/dist/recipients.js.map +1 -0
  71. package/dist/records.d.ts +130 -0
  72. package/dist/records.d.ts.map +1 -0
  73. package/dist/records.js +85 -0
  74. package/dist/records.js.map +1 -0
  75. package/dist/require-capability.d.ts +37 -0
  76. package/dist/require-capability.d.ts.map +1 -0
  77. package/dist/require-capability.js +88 -0
  78. package/dist/require-capability.js.map +1 -0
  79. package/dist/settings-schema.d.ts +35 -0
  80. package/dist/settings-schema.d.ts.map +1 -1
  81. package/dist/settings-schema.js +55 -2
  82. package/dist/settings-schema.js.map +1 -1
  83. package/package.json +4 -4
  84. package/src/abort-signal.ts +22 -0
  85. package/src/action-refusal.ts +178 -0
  86. package/src/action-schema.ts +4 -1
  87. package/src/actions.ts +103 -6
  88. package/src/blocks.ts +289 -0
  89. package/src/catalog.ts +630 -72
  90. package/src/entitlements.ts +150 -0
  91. package/src/extensions.ts +212 -0
  92. package/src/finances.ts +317 -0
  93. package/src/findings.ts +94 -0
  94. package/src/host.ts +768 -4
  95. package/src/imports.ts +135 -0
  96. package/src/index.ts +209 -0
  97. package/src/localized-text.ts +19 -0
  98. package/src/manifest.ts +1231 -12
  99. package/src/package-check.ts +15 -0
  100. package/src/permissions.ts +223 -3
  101. package/src/recipients.ts +294 -0
  102. package/src/records.ts +172 -0
  103. package/src/require-capability.ts +106 -0
  104. package/src/settings-schema.ts +64 -2
package/src/catalog.ts CHANGED
@@ -1,16 +1,40 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import type { PluginFindingDetail, PluginFindingReason } from "./findings.ts";
3
4
  import { IntegrityError, parseSha512 } from "./integrity.ts";
5
+ import { localizedTextSchema } from "./localized-text.ts";
4
6
  import {
5
7
  declaredActionsSchema,
8
+ missingProcessingProblem,
9
+ undeclaredPersonalDataProblem,
10
+ declaredExtensionPointsSchema,
11
+ declaredExtensionsSchema,
12
+ extensionGroupRefusals,
13
+ extensionHostPersonalDataRefused,
14
+ extensionPermissionsRefused,
15
+ extensionPointRefusal,
16
+ type ExtensionPointRefusal,
17
+ extensionRuleIssues,
18
+ pluginExtensionPointSchema,
19
+ pluginExtensionSchema,
20
+ declaredDeviceRoutesSchema,
21
+ deviceRouteRuleIssues,
6
22
  pluginActionSchema,
23
+ pluginDeviceRouteSchema,
7
24
  pluginIdSchema,
8
25
  pluginManifestSchema,
26
+ pluginProcessingSchema,
27
+ proposeImportPersonalDataIssues,
9
28
  } from "./manifest.ts";
10
29
  import {
11
30
  PLUGIN_PERMISSIONS,
12
31
  PLUGIN_PERSONAL_DATA_CATEGORIES,
13
32
  } from "./permissions.ts";
33
+ import {
34
+ declaredRecipientsSchema,
35
+ pluginRecipientSchema,
36
+ recipientCategoryIssues,
37
+ } from "./recipients.ts";
14
38
 
15
39
  /**
16
40
  * The catalog index.
@@ -36,6 +60,12 @@ import {
36
60
  * any check of the manifest saw it. The index carries its version for exactly
37
61
  * this, so a new field is a new index version rather than a key an older
38
62
  * instance ignores.
63
+ *
64
+ * An instance reads both versions there are. Version 2 adds the device routes
65
+ * of ADR 0031 and the processing declaration of ADR 0035 to a plugin entry, and
66
+ * the other fields of ADR 0030 join them there as they are built; version 1 is
67
+ * the index as it stood before, which a catalog keeps serving to instances that
68
+ * read nothing newer.
39
69
  */
40
70
 
41
71
  /** Whether `declared` is a digest `parseSha512` can read. */
@@ -81,13 +111,24 @@ export const catalogArtifactSchema = z.strictObject({
81
111
 
82
112
  export type CatalogArtifact = z.infer<typeof catalogArtifactSchema>;
83
113
 
84
- /** Text a curator writes in both languages the interface is offered in. */
85
- const localizedTextSchema = z.strictObject({
86
- sv: z.string().min(1).max(500),
87
- en: z.string().min(1).max(500),
88
- });
114
+ export type { LocalizedText } from "./localized-text.ts";
89
115
 
90
- export type LocalizedText = z.infer<typeof localizedTextSchema>;
116
+ /**
117
+ * An extension point as the index lists it: the manifest's fields, and the
118
+ * curator's title and description in both languages.
119
+ *
120
+ * The text is the curator's rather than the plugin's own, as an entry's `name`
121
+ * is, because an addon's consent screen has to name the point before the host
122
+ * plugin's locale files exist on the instance (ADR 0030).
123
+ */
124
+ export const catalogExtensionPointSchema = pluginExtensionPointSchema
125
+ .extend({
126
+ title: localizedTextSchema,
127
+ description: localizedTextSchema,
128
+ })
129
+ .strict();
130
+
131
+ export type CatalogExtensionPoint = z.infer<typeof catalogExtensionPointSchema>;
91
132
 
92
133
  /** Strict, and `.extend()` keeps it so for both kinds of entry. */
93
134
  const baseEntrySchema = z.strictObject({
@@ -106,51 +147,155 @@ const baseEntrySchema = z.strictObject({
106
147
  deprecated: z.boolean().default(false),
107
148
  });
108
149
 
109
- export const catalogPluginEntrySchema = baseEntrySchema.extend({
110
- type: z.literal("plugin"),
111
- /**
112
- * The npm package name the tarball unpacks as. Needed because the installer
113
- * writes a dependency set for npm, which keys on the package name and not on
114
- * the catalog id.
115
- */
116
- packageName: z.string().min(1).max(214),
117
- /** Gated against the host's own contract version before an install starts. */
118
- apiVersion: z.int().min(1),
119
- /**
120
- * Repeated from the plugin's manifest so the consent screen can be shown
121
- * before anything is downloaded. The installed manifest is authoritative:
122
- * the loader compares the two and refuses a plugin that asks for more than
123
- * the board consented to.
124
- */
125
- permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(16).default([]),
126
- personalData: z
127
- .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
128
- .max(16)
129
- .default([]),
130
- /**
131
- * The actions the plugin proposes, on the same terms as the two above.
132
- *
133
- * The widest part of the declaration, and so the part the consent screen
134
- * most needs before anything is downloaded: an action names a capability and
135
- * offers it to callers the board decides on.
136
- *
137
- * The manifest's own action fields and its own uniqueness rule, not a
138
- * second array of the same thing: the rule on the ids has to hold at
139
- * whichever boundary is read first, and two copies is two places for it to
140
- * stop holding. Read strictly here, as everything in the index is.
141
- */
142
- actions: declaredActionsSchema(pluginActionSchema.strict()).default([]),
143
- /**
144
- * The route that serves MCP, repeated on the same terms as the three above.
145
- *
146
- * Both rules about it are decided before anything is downloaded - a second
147
- * plugin declaring it is refused, and the reserved id `mcp-connector` may be
148
- * taken only by a plugin that does declare it - so the index has to carry it.
149
- * The manifest's own field schema rather than a second spelling of it: the
150
- * value becomes a URL an unauthenticated caller is pointed at, and one
151
- * definition of what it may contain is the point of that schema.
152
- */
153
- oauthProtectedResource: pluginManifestSchema.shape.oauthProtectedResource,
150
+ export const catalogPluginEntrySchema = baseEntrySchema
151
+ .extend({
152
+ type: z.literal("plugin"),
153
+ /**
154
+ * The npm package name the tarball unpacks as. Needed because the installer
155
+ * writes a dependency set for npm, which keys on the package name and not on
156
+ * the catalog id.
157
+ */
158
+ packageName: z.string().min(1).max(214),
159
+ /** Gated against the host's own contract version before an install starts. */
160
+ apiVersion: z.int().min(1),
161
+ /**
162
+ * Repeated from the plugin's manifest so the consent screen can be shown
163
+ * before anything is downloaded. The installed manifest is authoritative:
164
+ * the loader compares the two and refuses a plugin that asks for more than
165
+ * the board consented to.
166
+ */
167
+ permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(16).default([]),
168
+ personalData: z
169
+ .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
170
+ .max(16)
171
+ .default([]),
172
+ /**
173
+ * The actions the plugin proposes, on the same terms as the two above.
174
+ *
175
+ * The widest part of the declaration, and so the part the consent screen
176
+ * most needs before anything is downloaded: an action names a capability and
177
+ * offers it to callers the board decides on.
178
+ *
179
+ * The manifest's own action fields and its own uniqueness rule, not a
180
+ * second array of the same thing: the rule on the ids has to hold at
181
+ * whichever boundary is read first, and two copies is two places for it to
182
+ * stop holding. Read strictly here, as everything in the index is.
183
+ */
184
+ actions: declaredActionsSchema(pluginActionSchema.strict()).default([]),
185
+ /**
186
+ * The route that serves MCP, repeated on the same terms as the three above.
187
+ *
188
+ * Both rules about it are decided before anything is downloaded - a second
189
+ * plugin declaring it is refused, and the reserved id `mcp-connector` may be
190
+ * taken only by a plugin that does declare it - so the index has to carry it.
191
+ * The manifest's own field schema rather than a second spelling of it: the
192
+ * value becomes a URL an unauthenticated caller is pointed at, and one
193
+ * definition of what it may contain is the point of that schema.
194
+ */
195
+ oauthProtectedResource: pluginManifestSchema.shape.oauthProtectedResource,
196
+ /**
197
+ * The extension points the plugin offers, repeated from the manifest with
198
+ * the curator's text, so the host plugin's consent screen can say what its
199
+ * points accept before anything is downloaded (ADR 0030).
200
+ *
201
+ * In version 2 of the index only; see `catalogSchema`. Left without a
202
+ * default, so that a version 1 index carrying the key, even empty, can be
203
+ * told from one that does not.
204
+ */
205
+ extensionPoints: declaredExtensionPointsSchema(
206
+ catalogExtensionPointSchema,
207
+ ).optional(),
208
+ /**
209
+ * The extension points of other plugins it extends, repeated on the same
210
+ * terms, so an addon's consent screen can say what it extends and what it
211
+ * hands over. Version 2 only.
212
+ */
213
+ extends: declaredExtensionsSchema(
214
+ pluginExtensionSchema.strict(),
215
+ ).optional(),
216
+ /**
217
+ * Where the plugin connects to, repeated on the same terms as the
218
+ * declaration above it (ADR 0037).
219
+ *
220
+ * A field version 1 of the index does not define, so only an index of
221
+ * version 2 may carry it, and an index of version 1 that does is refused.
222
+ * Absent rather than defaulted, so that rule can tell an entry that left it
223
+ * out from one that declared none; a reader takes absent as none.
224
+ */
225
+ recipients: declaredRecipientsSchema(
226
+ pluginRecipientSchema.strict(),
227
+ ).optional(),
228
+ /**
229
+ * The routes a device reaches with a device token, repeated on the same
230
+ * terms (ADR 0031).
231
+ *
232
+ * The consent screen names them - "can be reached by devices holding a token
233
+ * the board issues" - before anything is downloaded. In version 2 of the
234
+ * index only; see `catalogSchema`. Left without a default, so that a version 1
235
+ * index carrying the key, even empty, can be told from one that does not.
236
+ */
237
+ deviceRoutes: declaredDeviceRoutesSchema(
238
+ pluginDeviceRouteSchema.strict(),
239
+ ).optional(),
240
+ /**
241
+ * That the plugin is an AI package, repeated on the same terms.
242
+ *
243
+ * It decides what the plugin's dispatch reaches and how the audit log names
244
+ * its writes, so the board reads it on the consent screen, and the install
245
+ * echo compares it, before anything is downloaded.
246
+ *
247
+ * Added within version 1 of the index rather than as a new version, because
248
+ * absent means the same as false: an index that lists no AI package need not
249
+ * carry the field and stays one an older instance reads. One that lists an
250
+ * AI package is refused whole by an older instance, which is the rule above
251
+ * doing its work - that instance would otherwise offer the plugin for
252
+ * consent without saying what it is, and dispatch it as a plain plugin.
253
+ */
254
+ ai: pluginManifestSchema.shape.ai,
255
+ /**
256
+ * The licence a paid plugin reads, repeated on the same terms (ADR 0042).
257
+ *
258
+ * Above all its `audiences`: the services the plugin may present the
259
+ * association's licence to are a grant the board consents to, as it does
260
+ * `permissions`, so the consent screen states them and the install echo
261
+ * compares them before anything is downloaded. The manifest's own strict
262
+ * schema, so a misspelled field is refused here as it is there.
263
+ *
264
+ * Added within version 1 of the index on the `ai` field's terms: absent
265
+ * means the plugin reads no licence, and an index that lists a licensed
266
+ * plugin is refused whole by an instance from before the field.
267
+ */
268
+ licence: pluginManifestSchema.shape.licence,
269
+ /**
270
+ * The day the release was published, which the update screen compares with
271
+ * the `updatesUntil` of the licence the instance holds. Required with
272
+ * `licence`, as in the manifest.
273
+ */
274
+ released: pluginManifestSchema.shape.released,
275
+ })
276
+ .superRefine((entry, ctx) => {
277
+ if (entry.licence !== undefined && entry.released === undefined) {
278
+ ctx.addIssue({
279
+ code: "custom",
280
+ path: ["released"],
281
+ message:
282
+ "an entry that declares a licence must say when it was released",
283
+ });
284
+ }
285
+ });
286
+
287
+ /**
288
+ * A plugin entry in version 2 of the index: version 1's, and the processing
289
+ * declaration.
290
+ *
291
+ * Repeated from the manifest on the terms the permissions are, because the
292
+ * consent screen shows it before anything is downloaded and the install seeds
293
+ * the record of processing activities from what that screen showed. Whether it
294
+ * is there is decided with the personal data, by the index as a whole below,
295
+ * since a refined object cannot be extended into the next version's entry.
296
+ */
297
+ export const catalogPluginEntryV2Schema = catalogPluginEntrySchema.extend({
298
+ processing: pluginProcessingSchema.optional(),
154
299
  });
155
300
 
156
301
  /**
@@ -174,42 +319,404 @@ export const catalogEntrySchema = z.discriminatedUnion("type", [
174
319
  catalogThemeEntrySchema,
175
320
  ]);
176
321
 
177
- export type CatalogPluginEntry = z.infer<typeof catalogPluginEntrySchema>;
322
+ export const catalogEntryV2Schema = z.discriminatedUnion("type", [
323
+ catalogPluginEntryV2Schema,
324
+ catalogThemeEntrySchema,
325
+ ]);
326
+
327
+ /**
328
+ * A plugin entry, as whichever version of the index it came from. A version 1
329
+ * entry is one with no processing and no device routes declared.
330
+ */
331
+ export type CatalogPluginEntry = z.infer<typeof catalogPluginEntryV2Schema>;
178
332
  export type CatalogThemeEntry = z.infer<typeof catalogThemeEntrySchema>;
179
- export type CatalogEntry = z.infer<typeof catalogEntrySchema>;
333
+ export type CatalogEntry = CatalogPluginEntry | CatalogThemeEntry;
334
+
335
+ /** The index's rules about its entries taken together, in either version. */
336
+ function checkEntries(
337
+ catalog: { version: 1 | 2; entries: readonly CatalogEntry[] },
338
+ ctx: z.RefinementCtx,
339
+ ): void {
340
+ for (const [index, entry] of catalog.entries.entries()) {
341
+ if (entry.type !== "plugin") {
342
+ continue;
343
+ }
344
+ // The manifest's rule, so an entry the loader would refuse is never
345
+ // offered for consent.
346
+ for (const issue of proposeImportPersonalDataIssues(entry)) {
347
+ ctx.addIssue({
348
+ code: "custom",
349
+ path: ["entries", index, "personalData"],
350
+ message: issue.message,
351
+ });
352
+ }
353
+ }
354
+
355
+ for (const [index, entry] of catalog.entries.entries()) {
356
+ if (entry.type !== "plugin") {
357
+ continue;
358
+ }
359
+ /*
360
+ * A field version 1 does not define is refused there as any unknown key
361
+ * is, so an older instance and this one read the same index the same
362
+ * way.
363
+ */
364
+ if (catalog.version === 1) {
365
+ for (const field of [
366
+ "extensionPoints",
367
+ "extends",
368
+ "deviceRoutes",
369
+ ] as const) {
370
+ if (entry[field] !== undefined) {
371
+ ctx.addIssue({
372
+ code: "custom",
373
+ path: ["entries", index, field],
374
+ message: `${field} needs version 2 of the index`,
375
+ });
376
+ }
377
+ }
378
+ continue;
379
+ }
380
+ // The manifest's own rules, so an entry whose own declaration breaks
381
+ // one is never offered for consent.
382
+ for (const issue of extensionRuleIssues({
383
+ id: entry.id,
384
+ permissions: entry.permissions,
385
+ personalData: entry.personalData,
386
+ deviceRoutes: entry.deviceRoutes ?? [],
387
+ extensionPoints: entry.extensionPoints ?? [],
388
+ extends: entry.extends ?? [],
389
+ })) {
390
+ ctx.addIssue({
391
+ code: "custom",
392
+ path: ["entries", index, ...issue.path],
393
+ message: issue.message,
394
+ });
395
+ }
396
+ // The manifest's own rules, so an entry that the loader would refuse is
397
+ // never offered for consent.
398
+ for (const issue of deviceRouteRuleIssues({
399
+ deviceRoutes: entry.deviceRoutes ?? [],
400
+ oauthProtectedResource: entry.oauthProtectedResource,
401
+ permissions: entry.permissions,
402
+ })) {
403
+ ctx.addIssue({
404
+ code: "custom",
405
+ path: ["entries", index, ...issue.path],
406
+ message: issue.message,
407
+ });
408
+ }
409
+ }
180
410
 
181
- export const catalogSchema = z
411
+ /*
412
+ * A field is in the version that defines it and in no earlier one: an
413
+ * instance that reads only version 1 would refuse the entry, and an index
414
+ * claiming version 1 while carrying it is not one such an instance could
415
+ * read.
416
+ */
417
+ if (catalog.version === 1) {
418
+ for (const [index, entry] of catalog.entries.entries()) {
419
+ if (entry.type === "plugin" && entry.recipients !== undefined) {
420
+ ctx.addIssue({
421
+ code: "custom",
422
+ path: ["entries", index, "recipients"],
423
+ message:
424
+ "recipients is not in version 1 of the index; an entry that declares them is listed in version 2",
425
+ });
426
+ }
427
+ }
428
+ }
429
+
430
+ /*
431
+ * The manifest's rule on what a recipient may receive, so an entry the
432
+ * loader would refuse is not offered for consent with a category the
433
+ * plugin does not declare it handles. The comparison with the installed
434
+ * manifest comes later; this holds the entry to itself.
435
+ */
436
+ for (const [index, entry] of catalog.entries.entries()) {
437
+ if (entry.type !== "plugin" || entry.recipients === undefined) {
438
+ continue;
439
+ }
440
+ for (const issue of recipientCategoryIssues(
441
+ entry.recipients,
442
+ entry.personalData,
443
+ )) {
444
+ ctx.addIssue({
445
+ code: "custom",
446
+ path: ["entries", index, ...issue.path],
447
+ message: issue.message,
448
+ });
449
+ }
450
+ }
451
+
452
+ /*
453
+ * An id names one entry in the whole index, not one per type. An install
454
+ * finds its entry by id and then refuses one of the wrong type, so a theme
455
+ * sharing a plugin's id would leave that plugin impossible to install, and
456
+ * which of the two a board got would depend on the order of the file.
457
+ */
458
+ const firstIndexById = new Map<string, number>();
459
+ for (const [index, entry] of catalog.entries.entries()) {
460
+ const first = firstIndexById.get(entry.id);
461
+ if (first === undefined) {
462
+ firstIndexById.set(entry.id, index);
463
+ continue;
464
+ }
465
+ ctx.addIssue({
466
+ code: "custom",
467
+ path: ["entries", index, "id"],
468
+ message: `"${entry.id}" is already the id of entry ${String(first)}; an id appears once in the index`,
469
+ });
470
+ }
471
+ }
472
+
473
+ const catalogV1Schema = z
182
474
  .strictObject({
183
475
  /** Index format version, so a future shape can be recognised and refused. */
184
476
  version: z.literal(1),
185
477
  entries: z.array(catalogEntrySchema).max(500),
186
478
  })
187
479
  .superRefine((catalog, ctx) => {
480
+ checkEntries(catalog, ctx);
481
+ });
482
+
483
+ const catalogV2Schema = z
484
+ .strictObject({
485
+ /**
486
+ * Version 2 adds the fields `CATALOG_INDEX_VERSIONS` lists to a plugin
487
+ * entry, `processing` (ADR 0035) among them, and changes nothing else.
488
+ * This package reads both versions; the catalog keeps serving version 1,
489
+ * without the entries that need version 2, to instances that have not
490
+ * upgraded.
491
+ */
492
+ version: z.literal(2),
493
+ entries: z.array(catalogEntryV2Schema).max(500),
494
+ })
495
+ .superRefine((catalog, ctx) => {
496
+ checkEntries(catalog, ctx);
497
+ const plugins = new Map<string, CatalogPluginEntry>();
498
+ const pluginIndex = new Map<string, number>();
499
+ for (const [index, entry] of catalog.entries.entries()) {
500
+ if (entry.type === "plugin" && !plugins.has(entry.id)) {
501
+ plugins.set(entry.id, entry);
502
+ pluginIndex.set(entry.id, index);
503
+ }
504
+ }
505
+ /** The addons already refused beside a plugin they extend. */
506
+ const pairRefused = new Set<string>();
188
507
  /*
189
- * An id names one entry in the whole index, not one per type. An install
190
- * finds its entry by id and then refuses one of the wrong type, so a theme
191
- * sharing a plugin's id would leave that plugin impossible to install, and
192
- * which of the two a board got would depend on the order of the file.
508
+ * The manifest's own rule, both ways: an entry that declares personal data
509
+ * declares its processing, and one that declares none declares no
510
+ * processing. One that reads the address book declares personal data, so
511
+ * it has a processing a person can object to. Held here rather than only
512
+ * by the catalog's check of the package, so an index written by hand is
513
+ * held to it as well.
193
514
  */
194
- const firstIndexById = new Map<string, number>();
195
515
  for (const [index, entry] of catalog.entries.entries()) {
196
- const first = firstIndexById.get(entry.id);
197
- if (first === undefined) {
198
- firstIndexById.set(entry.id, index);
516
+ if (entry.type !== "plugin") {
199
517
  continue;
200
518
  }
201
- ctx.addIssue({
202
- code: "custom",
203
- path: ["entries", index, "id"],
204
- message: `"${entry.id}" is already the id of entry ${String(first)}; an id appears once in the index`,
205
- });
519
+ const missing = missingProcessingProblem(entry);
520
+ if (missing !== null) {
521
+ ctx.addIssue({
522
+ code: "custom",
523
+ path: ["entries", index, "processing"],
524
+ message: missing,
525
+ });
526
+ }
527
+ const undeclared = undeclaredPersonalDataProblem(entry);
528
+ if (undeclared !== null) {
529
+ ctx.addIssue({
530
+ code: "custom",
531
+ path: ["entries", index, "personalData"],
532
+ message: undeclared,
533
+ });
534
+ }
535
+ if (entry.processing !== undefined && entry.personalData.length === 0) {
536
+ ctx.addIssue({
537
+ code: "custom",
538
+ path: ["entries", index, "processing"],
539
+ message:
540
+ "a plugin that declares no personal data declares no processing",
541
+ });
542
+ }
543
+ /*
544
+ * The rule between an addon and a plugin it extends, where the index
545
+ * lists both, read from the two entries. One it does not list is held to
546
+ * it by the loader, which reads the installed manifests, as it holds
547
+ * one it lists.
548
+ */
549
+ for (const [position, extension] of (entry.extends ?? []).entries()) {
550
+ const host = plugins.get(extension.plugin);
551
+ if (host === undefined) {
552
+ continue;
553
+ }
554
+ const named = {
555
+ addon: entry.id,
556
+ plugin: extension.plugin,
557
+ point: extension.point,
558
+ };
559
+ const unserved = extensionPointRefusal(
560
+ extension,
561
+ host.extensionPoints ?? [],
562
+ );
563
+ if (unserved !== null) {
564
+ pairRefused.add(entry.id);
565
+ const { field, message, detail } = unservedIssue(extension, unserved);
566
+ ctx.addIssue({
567
+ code: "custom",
568
+ path: ["entries", index, "extends", position, field],
569
+ message,
570
+ params: {
571
+ refusal: {
572
+ code: unserved.reason,
573
+ detail: { ...named, ...detail },
574
+ } satisfies CatalogRefusal,
575
+ },
576
+ });
577
+ continue;
578
+ }
579
+ const refused = extensionPermissionsRefused(entry, host);
580
+ if (refused.length > 0) {
581
+ pairRefused.add(entry.id);
582
+ ctx.addIssue({
583
+ code: "custom",
584
+ path: ["entries", index, "extends", position, "plugin"],
585
+ message: host.permissions.includes("site:blocks")
586
+ ? `${extension.plugin} asks for "site:blocks", so a plugin that extends it may not read the register or propose imports: ${refused.join(", ")}`
587
+ : `${extension.plugin} reads the register or proposes imports, so a plugin that asks for "site:blocks" may not extend it: ${refused.join(", ")}`,
588
+ params: {
589
+ refusal: {
590
+ code: "extension-permission-refused",
591
+ detail: { ...named, permissions: refused },
592
+ } satisfies CatalogRefusal,
593
+ },
594
+ });
595
+ continue;
596
+ }
597
+ const categories = extensionHostPersonalDataRefused(entry, host);
598
+ if (categories.length > 0) {
599
+ pairRefused.add(entry.id);
600
+ ctx.addIssue({
601
+ code: "custom",
602
+ path: ["entries", index, "extends", position, "plugin"],
603
+ message: `${extension.plugin} handles personal data, so a plugin that asks for "site:blocks" may not extend it: ${categories.join(", ")}`,
604
+ params: {
605
+ refusal: {
606
+ code: "extension-host-personal-data-refused",
607
+ detail: { ...named, categories },
608
+ } satisfies CatalogRefusal,
609
+ },
610
+ });
611
+ }
612
+ }
613
+ }
614
+
615
+ /*
616
+ * And the rule on the plugins joined by extensions, among those the index
617
+ * lists, as the loader reads it: beside the addons the rule above has not
618
+ * already refused.
619
+ */
620
+ if (catalog.version === 2) {
621
+ const members = [...plugins.values()]
622
+ .filter((entry) => !pairRefused.has(entry.id))
623
+ .map((entry) => ({
624
+ id: entry.id,
625
+ permissions: entry.permissions,
626
+ extends: entry.extends ?? [],
627
+ }));
628
+ for (const [id, refusal] of extensionGroupRefusals(members)) {
629
+ ctx.addIssue({
630
+ code: "custom",
631
+ path: ["entries", pluginIndex.get(id) ?? 0, "extends"],
632
+ message: `the plugins it is joined to by extensions would hold one that asks for "site:blocks" (${refusal.website.join(", ")}) and one that reads the register or proposes imports (${refusal.register.join(", ")})`,
633
+ params: {
634
+ refusal: {
635
+ code: "extension-group-refused",
636
+ detail: {
637
+ addon: id,
638
+ website: refusal.website,
639
+ register: refusal.register,
640
+ },
641
+ } satisfies CatalogRefusal,
642
+ },
643
+ });
644
+ }
206
645
  }
207
646
  });
208
647
 
209
- export type Catalog = z.infer<typeof catalogSchema>;
648
+ /**
649
+ * The index versions this package reads.
650
+ *
651
+ * Version 2 adds the fields ADR 0030 lists: `extensionPoints` and `extends`
652
+ * (ADR 0030), `recipients` (ADR 0037) and `deviceRoutes` (ADR 0031), and
653
+ * `processing` (ADR 0035). An instance reads both, and the catalog serves
654
+ * version 1, without the entries that need version 2, to instances that have
655
+ * not upgraded.
656
+ */
657
+ export const CATALOG_INDEX_VERSIONS = [1, 2] as const;
658
+
659
+ /** The version of the index this SDK writes and reads first. */
660
+ export const CATALOG_INDEX_VERSION = 2;
661
+
662
+ export const catalogSchema = z.discriminatedUnion("version", [
663
+ catalogV1Schema,
664
+ catalogV2Schema,
665
+ ]);
666
+
667
+ /** An index of either version, read as one shape. */
668
+ export interface Catalog {
669
+ version: z.infer<typeof catalogSchema>["version"];
670
+ entries: CatalogEntry[];
671
+ }
672
+
673
+ /**
674
+ * The refusals of an index that its reader is told in their own language.
675
+ *
676
+ * What the schema says about one entry's field is English for the catalog's
677
+ * curator, who fixes the index from it. These hold one entry against another,
678
+ * an addon against the plugin it extends or against the plugins joined to it,
679
+ * and are the loader's own refusals of the same pair, under the same codes. So
680
+ * the refusal carries its code and the values its sentence is completed with
681
+ * beside the English line, and whoever shows it translates the code.
682
+ */
683
+ export const CATALOG_REFUSALS = [
684
+ "extension-point-unknown",
685
+ "extension-version-unsupported",
686
+ "extension-personal-data-refused",
687
+ "extension-permission-refused",
688
+ "extension-host-personal-data-refused",
689
+ "extension-group-refused",
690
+ ] as const satisfies readonly PluginFindingReason[];
691
+
692
+ export type CatalogRefusalCode = (typeof CATALOG_REFUSALS)[number];
693
+
694
+ /**
695
+ * One refusal of `CATALOG_REFUSALS`: its code, and the refused entry's id as
696
+ * `addon` with the plugin, point and lists its sentence names.
697
+ */
698
+ export interface CatalogRefusal {
699
+ readonly code: CatalogRefusalCode;
700
+ readonly detail: PluginFindingDetail;
701
+ }
702
+
703
+ /** One problem with an index: the English line, and its refusal if it has one. */
704
+ export interface CatalogDiagnostic {
705
+ /** The line, already naming the field. */
706
+ readonly issue: string;
707
+ /** The refusal the line states, or null when it is a field's own problem. */
708
+ readonly refusal: CatalogRefusal | null;
709
+ }
210
710
 
211
711
  export type CatalogParseResult =
212
- { ok: true; value: Catalog } | { ok: false; issues: readonly string[] };
712
+ | { ok: true; value: Catalog }
713
+ | {
714
+ ok: false;
715
+ /** One line per problem, already naming the field. */
716
+ issues: readonly string[];
717
+ /** The same problems, each with its refusal where it has one. */
718
+ diagnostics: readonly CatalogDiagnostic[];
719
+ };
213
720
 
214
721
  /**
215
722
  * Parses a catalog index.
@@ -227,10 +734,61 @@ export function parseCatalogIndex(input: unknown): CatalogParseResult {
227
734
  if (result.success) {
228
735
  return { ok: true, value: result.data };
229
736
  }
737
+ const diagnostics = result.error.issues.map((issue) => ({
738
+ issue: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
739
+ refusal: catalogRefusalOf(issue),
740
+ }));
230
741
  return {
231
742
  ok: false,
232
- issues: result.error.issues.map(
233
- (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
234
- ),
743
+ issues: diagnostics.map((diagnostic) => diagnostic.issue),
744
+ diagnostics,
235
745
  };
236
746
  }
747
+
748
+ function catalogRefusalOf(issue: {
749
+ code: string;
750
+ params?: unknown;
751
+ }): CatalogRefusal | null {
752
+ if (issue.code !== "custom" || typeof issue.params !== "object") {
753
+ return null;
754
+ }
755
+ const refusal = (issue.params as { refusal?: CatalogRefusal } | null)
756
+ ?.refusal;
757
+ return refusal !== undefined && CATALOG_REFUSALS.includes(refusal.code)
758
+ ? refusal
759
+ : null;
760
+ }
761
+
762
+ /** The field, the curator's line and the values of a point's refusal. */
763
+ function unservedIssue(
764
+ extension: {
765
+ readonly point: string;
766
+ readonly plugin: string;
767
+ readonly version: number;
768
+ },
769
+ unserved: ExtensionPointRefusal,
770
+ ): { field: string; message: string; detail: PluginFindingDetail } {
771
+ switch (unserved.reason) {
772
+ case "extension-point-unknown":
773
+ return {
774
+ field: "point",
775
+ message: `${extension.plugin} declares no extension point "${extension.point}"`,
776
+ detail: {},
777
+ };
778
+ case "extension-version-unsupported":
779
+ return {
780
+ field: "version",
781
+ message: `"${extension.point}" of ${extension.plugin} is at version ${String(unserved.supported)}, not ${String(extension.version)}`,
782
+ detail: {
783
+ version: extension.version,
784
+ supported: unserved.supported,
785
+ },
786
+ };
787
+ case "extension-personal-data-refused":
788
+ return {
789
+ field: "personalData",
790
+ message: `"${extension.point}" of ${extension.plugin} does not accept ${unserved.categories.join(", ")}`,
791
+ detail: { categories: unserved.categories },
792
+ };
793
+ }
794
+ }