@openbrf/plugin-sdk 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) 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 +399 -7
  21. package/dist/catalog.d.ts.map +1 -1
  22. package/dist/catalog.js +373 -9
  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 +90 -1
  37. package/dist/findings.d.ts.map +1 -1
  38. package/dist/findings.js +89 -0
  39. package/dist/findings.js.map +1 -1
  40. package/dist/host.d.ts +621 -2
  41. package/dist/host.d.ts.map +1 -1
  42. package/dist/host.js +39 -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 +730 -0
  57. package/dist/manifest.d.ts.map +1 -1
  58. package/dist/manifest.js +805 -11
  59. package/dist/manifest.js.map +1 -1
  60. package/dist/permissions.d.ts +170 -11
  61. package/dist/permissions.d.ts.map +1 -1
  62. package/dist/permissions.js +210 -2
  63. package/dist/permissions.js.map +1 -1
  64. package/dist/recipients.d.ts +139 -0
  65. package/dist/recipients.d.ts.map +1 -0
  66. package/dist/recipients.js +223 -0
  67. package/dist/recipients.js.map +1 -0
  68. package/dist/records.d.ts +130 -0
  69. package/dist/records.d.ts.map +1 -0
  70. package/dist/records.js +85 -0
  71. package/dist/records.js.map +1 -0
  72. package/dist/require-capability.d.ts +37 -0
  73. package/dist/require-capability.d.ts.map +1 -0
  74. package/dist/require-capability.js +88 -0
  75. package/dist/require-capability.js.map +1 -0
  76. package/dist/settings-schema.d.ts +35 -0
  77. package/dist/settings-schema.d.ts.map +1 -1
  78. package/dist/settings-schema.js +55 -2
  79. package/dist/settings-schema.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/abort-signal.ts +22 -0
  82. package/src/action-refusal.ts +178 -0
  83. package/src/action-schema.ts +4 -1
  84. package/src/actions.ts +103 -6
  85. package/src/blocks.ts +289 -0
  86. package/src/catalog.ts +500 -57
  87. package/src/entitlements.ts +150 -0
  88. package/src/extensions.ts +212 -0
  89. package/src/finances.ts +317 -0
  90. package/src/findings.ts +92 -0
  91. package/src/host.ts +656 -2
  92. package/src/imports.ts +135 -0
  93. package/src/index.ts +193 -0
  94. package/src/localized-text.ts +19 -0
  95. package/src/manifest.ts +1039 -12
  96. package/src/permissions.ts +223 -3
  97. package/src/recipients.ts +294 -0
  98. package/src/records.ts +172 -0
  99. package/src/require-capability.ts +106 -0
  100. package/src/settings-schema.ts +64 -2
package/src/catalog.ts CHANGED
@@ -1,16 +1,37 @@
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
+ declaredExtensionPointsSchema,
9
+ declaredExtensionsSchema,
10
+ extensionGroupRefusals,
11
+ extensionHostPersonalDataRefused,
12
+ extensionPermissionsRefused,
13
+ extensionPointRefusal,
14
+ type ExtensionPointRefusal,
15
+ extensionRuleIssues,
16
+ pluginExtensionPointSchema,
17
+ pluginExtensionSchema,
18
+ declaredDeviceRoutesSchema,
19
+ deviceRouteRuleIssues,
6
20
  pluginActionSchema,
21
+ pluginDeviceRouteSchema,
7
22
  pluginIdSchema,
8
23
  pluginManifestSchema,
24
+ proposeImportPersonalDataIssues,
9
25
  } from "./manifest.ts";
10
26
  import {
11
27
  PLUGIN_PERMISSIONS,
12
28
  PLUGIN_PERSONAL_DATA_CATEGORIES,
13
29
  } from "./permissions.ts";
30
+ import {
31
+ declaredRecipientsSchema,
32
+ pluginRecipientSchema,
33
+ recipientCategoryIssues,
34
+ } from "./recipients.ts";
14
35
 
15
36
  /**
16
37
  * The catalog index.
@@ -81,13 +102,24 @@ export const catalogArtifactSchema = z.strictObject({
81
102
 
82
103
  export type CatalogArtifact = z.infer<typeof catalogArtifactSchema>;
83
104
 
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
- });
105
+ export type { LocalizedText } from "./localized-text.ts";
106
+
107
+ /**
108
+ * An extension point as the index lists it: the manifest's fields, and the
109
+ * curator's title and description in both languages.
110
+ *
111
+ * The text is the curator's rather than the plugin's own, as an entry's `name`
112
+ * is, because an addon's consent screen has to name the point before the host
113
+ * plugin's locale files exist on the instance (ADR 0030).
114
+ */
115
+ export const catalogExtensionPointSchema = pluginExtensionPointSchema
116
+ .extend({
117
+ title: localizedTextSchema,
118
+ description: localizedTextSchema,
119
+ })
120
+ .strict();
89
121
 
90
- export type LocalizedText = z.infer<typeof localizedTextSchema>;
122
+ export type CatalogExtensionPoint = z.infer<typeof catalogExtensionPointSchema>;
91
123
 
92
124
  /** Strict, and `.extend()` keeps it so for both kinds of entry. */
93
125
  const baseEntrySchema = z.strictObject({
@@ -106,52 +138,142 @@ const baseEntrySchema = z.strictObject({
106
138
  deprecated: z.boolean().default(false),
107
139
  });
108
140
 
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,
154
- });
141
+ export const catalogPluginEntrySchema = baseEntrySchema
142
+ .extend({
143
+ type: z.literal("plugin"),
144
+ /**
145
+ * The npm package name the tarball unpacks as. Needed because the installer
146
+ * writes a dependency set for npm, which keys on the package name and not on
147
+ * the catalog id.
148
+ */
149
+ packageName: z.string().min(1).max(214),
150
+ /** Gated against the host's own contract version before an install starts. */
151
+ apiVersion: z.int().min(1),
152
+ /**
153
+ * Repeated from the plugin's manifest so the consent screen can be shown
154
+ * before anything is downloaded. The installed manifest is authoritative:
155
+ * the loader compares the two and refuses a plugin that asks for more than
156
+ * the board consented to.
157
+ */
158
+ permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(16).default([]),
159
+ personalData: z
160
+ .array(z.enum(PLUGIN_PERSONAL_DATA_CATEGORIES))
161
+ .max(16)
162
+ .default([]),
163
+ /**
164
+ * The actions the plugin proposes, on the same terms as the two above.
165
+ *
166
+ * The widest part of the declaration, and so the part the consent screen
167
+ * most needs before anything is downloaded: an action names a capability and
168
+ * offers it to callers the board decides on.
169
+ *
170
+ * The manifest's own action fields and its own uniqueness rule, not a
171
+ * second array of the same thing: the rule on the ids has to hold at
172
+ * whichever boundary is read first, and two copies is two places for it to
173
+ * stop holding. Read strictly here, as everything in the index is.
174
+ */
175
+ actions: declaredActionsSchema(pluginActionSchema.strict()).default([]),
176
+ /**
177
+ * The route that serves MCP, repeated on the same terms as the three above.
178
+ *
179
+ * Both rules about it are decided before anything is downloaded - a second
180
+ * plugin declaring it is refused, and the reserved id `mcp-connector` may be
181
+ * taken only by a plugin that does declare it - so the index has to carry it.
182
+ * The manifest's own field schema rather than a second spelling of it: the
183
+ * value becomes a URL an unauthenticated caller is pointed at, and one
184
+ * definition of what it may contain is the point of that schema.
185
+ */
186
+ oauthProtectedResource: pluginManifestSchema.shape.oauthProtectedResource,
187
+ /**
188
+ * The extension points the plugin offers, repeated from the manifest with
189
+ * the curator's text, so the host plugin's consent screen can say what its
190
+ * points accept before anything is downloaded (ADR 0030).
191
+ *
192
+ * In version 2 of the index only; see `catalogSchema`. Left without a
193
+ * default, so that a version 1 index carrying the key, even empty, can be
194
+ * told from one that does not.
195
+ */
196
+ extensionPoints: declaredExtensionPointsSchema(
197
+ catalogExtensionPointSchema,
198
+ ).optional(),
199
+ /**
200
+ * The extension points of other plugins it extends, repeated on the same
201
+ * terms, so an addon's consent screen can say what it extends and what it
202
+ * hands over. Version 2 only.
203
+ */
204
+ extends: declaredExtensionsSchema(
205
+ pluginExtensionSchema.strict(),
206
+ ).optional(),
207
+ /**
208
+ * Where the plugin connects to, repeated on the same terms as the
209
+ * declaration above it (ADR 0037).
210
+ *
211
+ * A field version 1 of the index does not define, so only an index of
212
+ * version 2 may carry it, and an index of version 1 that does is refused.
213
+ * Absent rather than defaulted, so that rule can tell an entry that left it
214
+ * out from one that declared none; a reader takes absent as none.
215
+ */
216
+ recipients: declaredRecipientsSchema(
217
+ pluginRecipientSchema.strict(),
218
+ ).optional(),
219
+ /**
220
+ * The routes a device reaches with a device token, repeated on the same
221
+ * terms (ADR 0031).
222
+ *
223
+ * The consent screen names them - "can be reached by devices holding a token
224
+ * the board issues" - before anything is downloaded. In version 2 of the
225
+ * index only; see `catalogSchema`. Left without a default, so that a version 1
226
+ * index carrying the key, even empty, can be told from one that does not.
227
+ */
228
+ deviceRoutes: declaredDeviceRoutesSchema(
229
+ pluginDeviceRouteSchema.strict(),
230
+ ).optional(),
231
+ /**
232
+ * That the plugin is an AI package, repeated on the same terms.
233
+ *
234
+ * It decides what the plugin's dispatch reaches and how the audit log names
235
+ * its writes, so the board reads it on the consent screen, and the install
236
+ * echo compares it, before anything is downloaded.
237
+ *
238
+ * Added within version 1 of the index rather than as a new version, because
239
+ * absent means the same as false: an index that lists no AI package need not
240
+ * carry the field and stays one an older instance reads. One that lists an
241
+ * AI package is refused whole by an older instance, which is the rule above
242
+ * doing its work - that instance would otherwise offer the plugin for
243
+ * consent without saying what it is, and dispatch it as a plain plugin.
244
+ */
245
+ ai: pluginManifestSchema.shape.ai,
246
+ /**
247
+ * The licence a paid plugin reads, repeated on the same terms (ADR 0042).
248
+ *
249
+ * Above all its `audiences`: the services the plugin may present the
250
+ * association's licence to are a grant the board consents to, as it does
251
+ * `permissions`, so the consent screen states them and the install echo
252
+ * compares them before anything is downloaded. The manifest's own strict
253
+ * schema, so a misspelled field is refused here as it is there.
254
+ *
255
+ * Added within version 1 of the index on the `ai` field's terms: absent
256
+ * means the plugin reads no licence, and an index that lists a licensed
257
+ * plugin is refused whole by an instance from before the field.
258
+ */
259
+ licence: pluginManifestSchema.shape.licence,
260
+ /**
261
+ * The day the release was published, which the update screen compares with
262
+ * the `updatesUntil` of the licence the instance holds. Required with
263
+ * `licence`, as in the manifest.
264
+ */
265
+ released: pluginManifestSchema.shape.released,
266
+ })
267
+ .superRefine((entry, ctx) => {
268
+ if (entry.licence !== undefined && entry.released === undefined) {
269
+ ctx.addIssue({
270
+ code: "custom",
271
+ path: ["released"],
272
+ message:
273
+ "an entry that declares a licence must say when it was released",
274
+ });
275
+ }
276
+ });
155
277
 
156
278
  /**
157
279
  * A theme entry. Themes install through the same download-and-verify path and
@@ -178,13 +300,238 @@ export type CatalogPluginEntry = z.infer<typeof catalogPluginEntrySchema>;
178
300
  export type CatalogThemeEntry = z.infer<typeof catalogThemeEntrySchema>;
179
301
  export type CatalogEntry = z.infer<typeof catalogEntrySchema>;
180
302
 
303
+ /**
304
+ * The index versions this package reads.
305
+ *
306
+ * Version 2 adds the fields ADR 0030 lists: `extensionPoints` and `extends`
307
+ * (ADR 0030), `recipients` (ADR 0037) and `deviceRoutes` (ADR 0031). An
308
+ * instance reads both, and the catalog serves version 1, without the entries
309
+ * that need version 2, to instances that have not upgraded.
310
+ */
311
+ export const CATALOG_INDEX_VERSIONS = [1, 2] as const;
312
+
181
313
  export const catalogSchema = z
182
314
  .strictObject({
183
315
  /** Index format version, so a future shape can be recognised and refused. */
184
- version: z.literal(1),
316
+ version: z.literal(CATALOG_INDEX_VERSIONS),
185
317
  entries: z.array(catalogEntrySchema).max(500),
186
318
  })
187
319
  .superRefine((catalog, ctx) => {
320
+ const plugins = new Map<string, CatalogPluginEntry>();
321
+ const pluginIndex = new Map<string, number>();
322
+ for (const [index, entry] of catalog.entries.entries()) {
323
+ if (entry.type === "plugin" && !plugins.has(entry.id)) {
324
+ plugins.set(entry.id, entry);
325
+ pluginIndex.set(entry.id, index);
326
+ }
327
+ }
328
+ /** The addons already refused beside a plugin they extend. */
329
+ const pairRefused = new Set<string>();
330
+ for (const [index, entry] of catalog.entries.entries()) {
331
+ if (entry.type !== "plugin") {
332
+ continue;
333
+ }
334
+ // The manifest's rule, so an entry the loader would refuse is never
335
+ // offered for consent.
336
+ for (const issue of proposeImportPersonalDataIssues(entry)) {
337
+ ctx.addIssue({
338
+ code: "custom",
339
+ path: ["entries", index, "personalData"],
340
+ message: issue.message,
341
+ });
342
+ }
343
+ /*
344
+ * A field version 1 does not define is refused there as any unknown key
345
+ * is, so an older instance and this one read the same index the same
346
+ * way.
347
+ */
348
+ if (catalog.version === 1) {
349
+ for (const field of [
350
+ "extensionPoints",
351
+ "extends",
352
+ "deviceRoutes",
353
+ ] as const) {
354
+ if (entry[field] !== undefined) {
355
+ ctx.addIssue({
356
+ code: "custom",
357
+ path: ["entries", index, field],
358
+ message: `${field} needs version 2 of the index`,
359
+ });
360
+ }
361
+ }
362
+ continue;
363
+ }
364
+ // The manifest's own rules, so an entry whose own declaration breaks
365
+ // one is never offered for consent.
366
+ for (const issue of extensionRuleIssues({
367
+ id: entry.id,
368
+ permissions: entry.permissions,
369
+ personalData: entry.personalData,
370
+ extensionPoints: entry.extensionPoints ?? [],
371
+ extends: entry.extends ?? [],
372
+ })) {
373
+ ctx.addIssue({
374
+ code: "custom",
375
+ path: ["entries", index, ...issue.path],
376
+ message: issue.message,
377
+ });
378
+ }
379
+ for (const issue of deviceRouteRuleIssues({
380
+ deviceRoutes: entry.deviceRoutes ?? [],
381
+ oauthProtectedResource: entry.oauthProtectedResource,
382
+ permissions: entry.permissions,
383
+ })) {
384
+ ctx.addIssue({
385
+ code: "custom",
386
+ path: ["entries", index, ...issue.path],
387
+ message: issue.message,
388
+ });
389
+ }
390
+ /*
391
+ * The rule between an addon and a plugin it extends, where the index
392
+ * lists both, read from the two entries. One it does not list is held to
393
+ * it by the loader, which reads the installed manifests, as it holds
394
+ * one it lists.
395
+ */
396
+ for (const [position, extension] of (entry.extends ?? []).entries()) {
397
+ const host = plugins.get(extension.plugin);
398
+ if (host === undefined) {
399
+ continue;
400
+ }
401
+ const named = {
402
+ addon: entry.id,
403
+ plugin: extension.plugin,
404
+ point: extension.point,
405
+ };
406
+ const unserved = extensionPointRefusal(
407
+ extension,
408
+ host.extensionPoints ?? [],
409
+ );
410
+ if (unserved !== null) {
411
+ pairRefused.add(entry.id);
412
+ const { field, message, detail } = unservedIssue(extension, unserved);
413
+ ctx.addIssue({
414
+ code: "custom",
415
+ path: ["entries", index, "extends", position, field],
416
+ message,
417
+ params: {
418
+ refusal: {
419
+ code: unserved.reason,
420
+ detail: { ...named, ...detail },
421
+ } satisfies CatalogRefusal,
422
+ },
423
+ });
424
+ continue;
425
+ }
426
+ const refused = extensionPermissionsRefused(entry, host);
427
+ if (refused.length > 0) {
428
+ pairRefused.add(entry.id);
429
+ ctx.addIssue({
430
+ code: "custom",
431
+ path: ["entries", index, "extends", position, "plugin"],
432
+ message: host.permissions.includes("site:blocks")
433
+ ? `${extension.plugin} asks for "site:blocks", so a plugin that extends it may not read the register or propose imports: ${refused.join(", ")}`
434
+ : `${extension.plugin} reads the register or proposes imports, so a plugin that asks for "site:blocks" may not extend it: ${refused.join(", ")}`,
435
+ params: {
436
+ refusal: {
437
+ code: "extension-permission-refused",
438
+ detail: { ...named, permissions: refused },
439
+ } satisfies CatalogRefusal,
440
+ },
441
+ });
442
+ continue;
443
+ }
444
+ const categories = extensionHostPersonalDataRefused(entry, host);
445
+ if (categories.length > 0) {
446
+ pairRefused.add(entry.id);
447
+ ctx.addIssue({
448
+ code: "custom",
449
+ path: ["entries", index, "extends", position, "plugin"],
450
+ message: `${extension.plugin} handles personal data, so a plugin that asks for "site:blocks" may not extend it: ${categories.join(", ")}`,
451
+ params: {
452
+ refusal: {
453
+ code: "extension-host-personal-data-refused",
454
+ detail: { ...named, categories },
455
+ } satisfies CatalogRefusal,
456
+ },
457
+ });
458
+ }
459
+ }
460
+ }
461
+
462
+ /*
463
+ * And the rule on the plugins joined by extensions, among those the index
464
+ * lists, as the loader reads it: beside the addons the rule above has not
465
+ * already refused.
466
+ */
467
+ if (catalog.version === 2) {
468
+ const members = [...plugins.values()]
469
+ .filter((entry) => !pairRefused.has(entry.id))
470
+ .map((entry) => ({
471
+ id: entry.id,
472
+ permissions: entry.permissions,
473
+ extends: entry.extends ?? [],
474
+ }));
475
+ for (const [id, refusal] of extensionGroupRefusals(members)) {
476
+ ctx.addIssue({
477
+ code: "custom",
478
+ path: ["entries", pluginIndex.get(id) ?? 0, "extends"],
479
+ 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(", ")})`,
480
+ params: {
481
+ refusal: {
482
+ code: "extension-group-refused",
483
+ detail: {
484
+ addon: id,
485
+ website: refusal.website,
486
+ register: refusal.register,
487
+ },
488
+ } satisfies CatalogRefusal,
489
+ },
490
+ });
491
+ }
492
+ }
493
+
494
+ /*
495
+ * A field is in the version that defines it and in no earlier one: an
496
+ * instance that reads only version 1 would refuse the entry, and an index
497
+ * claiming version 1 while carrying it is not one such an instance could
498
+ * read.
499
+ */
500
+ if (catalog.version === 1) {
501
+ for (const [index, entry] of catalog.entries.entries()) {
502
+ if (entry.type === "plugin" && entry.recipients !== undefined) {
503
+ ctx.addIssue({
504
+ code: "custom",
505
+ path: ["entries", index, "recipients"],
506
+ message:
507
+ "recipients is not in version 1 of the index; an entry that declares them is listed in version 2",
508
+ });
509
+ }
510
+ }
511
+ }
512
+
513
+ /*
514
+ * The manifest's rule on what a recipient may receive, so an entry the
515
+ * loader would refuse is not offered for consent with a category the
516
+ * plugin does not declare it handles. The comparison with the installed
517
+ * manifest comes later; this holds the entry to itself.
518
+ */
519
+ for (const [index, entry] of catalog.entries.entries()) {
520
+ if (entry.type !== "plugin" || entry.recipients === undefined) {
521
+ continue;
522
+ }
523
+ for (const issue of recipientCategoryIssues(
524
+ entry.recipients,
525
+ entry.personalData,
526
+ )) {
527
+ ctx.addIssue({
528
+ code: "custom",
529
+ path: ["entries", index, ...issue.path],
530
+ message: issue.message,
531
+ });
532
+ }
533
+ }
534
+
188
535
  /*
189
536
  * An id names one entry in the whole index, not one per type. An install
190
537
  * finds its entry by id and then refuses one of the wrong type, so a theme
@@ -208,8 +555,53 @@ export const catalogSchema = z
208
555
 
209
556
  export type Catalog = z.infer<typeof catalogSchema>;
210
557
 
558
+ /**
559
+ * The refusals of an index that its reader is told in their own language.
560
+ *
561
+ * What the schema says about one entry's field is English for the catalog's
562
+ * curator, who fixes the index from it. These hold one entry against another,
563
+ * an addon against the plugin it extends or against the plugins joined to it,
564
+ * and are the loader's own refusals of the same pair, under the same codes. So
565
+ * the refusal carries its code and the values its sentence is completed with
566
+ * beside the English line, and whoever shows it translates the code.
567
+ */
568
+ export const CATALOG_REFUSALS = [
569
+ "extension-point-unknown",
570
+ "extension-version-unsupported",
571
+ "extension-personal-data-refused",
572
+ "extension-permission-refused",
573
+ "extension-host-personal-data-refused",
574
+ "extension-group-refused",
575
+ ] as const satisfies readonly PluginFindingReason[];
576
+
577
+ export type CatalogRefusalCode = (typeof CATALOG_REFUSALS)[number];
578
+
579
+ /**
580
+ * One refusal of `CATALOG_REFUSALS`: its code, and the refused entry's id as
581
+ * `addon` with the plugin, point and lists its sentence names.
582
+ */
583
+ export interface CatalogRefusal {
584
+ readonly code: CatalogRefusalCode;
585
+ readonly detail: PluginFindingDetail;
586
+ }
587
+
588
+ /** One problem with an index: the English line, and its refusal if it has one. */
589
+ export interface CatalogDiagnostic {
590
+ /** The line, already naming the field. */
591
+ readonly issue: string;
592
+ /** The refusal the line states, or null when it is a field's own problem. */
593
+ readonly refusal: CatalogRefusal | null;
594
+ }
595
+
211
596
  export type CatalogParseResult =
212
- { ok: true; value: Catalog } | { ok: false; issues: readonly string[] };
597
+ | { ok: true; value: Catalog }
598
+ | {
599
+ ok: false;
600
+ /** One line per problem, already naming the field. */
601
+ issues: readonly string[];
602
+ /** The same problems, each with its refusal where it has one. */
603
+ diagnostics: readonly CatalogDiagnostic[];
604
+ };
213
605
 
214
606
  /**
215
607
  * Parses a catalog index.
@@ -227,10 +619,61 @@ export function parseCatalogIndex(input: unknown): CatalogParseResult {
227
619
  if (result.success) {
228
620
  return { ok: true, value: result.data };
229
621
  }
622
+ const diagnostics = result.error.issues.map((issue) => ({
623
+ issue: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
624
+ refusal: catalogRefusalOf(issue),
625
+ }));
230
626
  return {
231
627
  ok: false,
232
- issues: result.error.issues.map(
233
- (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
234
- ),
628
+ issues: diagnostics.map((diagnostic) => diagnostic.issue),
629
+ diagnostics,
235
630
  };
236
631
  }
632
+
633
+ function catalogRefusalOf(issue: {
634
+ code: string;
635
+ params?: unknown;
636
+ }): CatalogRefusal | null {
637
+ if (issue.code !== "custom" || typeof issue.params !== "object") {
638
+ return null;
639
+ }
640
+ const refusal = (issue.params as { refusal?: CatalogRefusal } | null)
641
+ ?.refusal;
642
+ return refusal !== undefined && CATALOG_REFUSALS.includes(refusal.code)
643
+ ? refusal
644
+ : null;
645
+ }
646
+
647
+ /** The field, the curator's line and the values of a point's refusal. */
648
+ function unservedIssue(
649
+ extension: {
650
+ readonly point: string;
651
+ readonly plugin: string;
652
+ readonly version: number;
653
+ },
654
+ unserved: ExtensionPointRefusal,
655
+ ): { field: string; message: string; detail: PluginFindingDetail } {
656
+ switch (unserved.reason) {
657
+ case "extension-point-unknown":
658
+ return {
659
+ field: "point",
660
+ message: `${extension.plugin} declares no extension point "${extension.point}"`,
661
+ detail: {},
662
+ };
663
+ case "extension-version-unsupported":
664
+ return {
665
+ field: "version",
666
+ message: `"${extension.point}" of ${extension.plugin} is at version ${String(unserved.supported)}, not ${String(extension.version)}`,
667
+ detail: {
668
+ version: extension.version,
669
+ supported: unserved.supported,
670
+ },
671
+ };
672
+ case "extension-personal-data-refused":
673
+ return {
674
+ field: "personalData",
675
+ message: `"${extension.point}" of ${extension.plugin} does not accept ${unserved.categories.join(", ")}`,
676
+ detail: { categories: unserved.categories },
677
+ };
678
+ }
679
+ }