@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/actions.ts CHANGED
@@ -67,12 +67,15 @@ export type ActionPersonalData = (typeof ACTION_PERSONAL_DATA)[number];
67
67
  */
68
68
  export const ACTION_NAME_PATTERN = /^[a-z][a-z0-9_]{2,63}$/;
69
69
 
70
+ /** Whether trying again could ever work, and after what. */
71
+ export type ActionRetry = "never" | "after-edit" | "after-backoff";
72
+
70
73
  /** What a refusal means for a caller that is a model rather than a person. */
71
74
  export interface ActionErrorSpec {
72
75
  /** The machine-readable reason the handler raises. */
73
76
  readonly reason: string;
74
77
  /** Whether trying again could ever work, and after what. */
75
- readonly retry: "never" | "after-edit" | "after-backoff";
78
+ readonly retry: ActionRetry;
76
79
  /** The sentence a person is shown, as an i18n key. */
77
80
  readonly messageKey: string;
78
81
  }
@@ -80,9 +83,14 @@ export interface ActionErrorSpec {
80
83
  /**
81
84
  * The request a plugin's own route received.
82
85
  *
83
- * Opaque on purpose: the host reads a symbol on it that only the host writes,
84
- * so a plugin passes the request it was handed straight through and cannot
85
- * construct one. That is what stops a plugin naming a person it was not given.
86
+ * Opaque on purpose: the host recognises the request it authenticated by its
87
+ * identity, which nothing on it can copy, so a plugin passes the request it was
88
+ * handed straight through and cannot construct one. That is what stops a plugin
89
+ * naming a person it was not given.
90
+ *
91
+ * It speaks for its person only while the route is serving it. Once the
92
+ * response is sent the host refuses it, so a request kept for later names
93
+ * nobody.
86
94
  */
87
95
  export type ActionRequest = object;
88
96
 
@@ -120,6 +128,19 @@ export interface ActionContext {
120
128
  readonly principal: ActionPrincipal;
121
129
  readonly channel: ActionChannel;
122
130
  readonly client?: ActionClient;
131
+ /**
132
+ * The plugin whose route dispatched the call, when one did, by its manifest
133
+ * id. Set by the host from the plugin it serves, whatever the channel.
134
+ */
135
+ readonly pluginId?: string;
136
+ /**
137
+ * The pending change this call confirms, when it confirms one.
138
+ *
139
+ * A confirmed call is the confirming person's own, on `web`; this is what
140
+ * ties its audit entry back to the proposal, and to the channel and the app
141
+ * the proposal came through.
142
+ */
143
+ readonly pendingChangeId?: string;
123
144
  readonly requestId: string;
124
145
  readonly now: Date;
125
146
  }
@@ -212,6 +233,35 @@ export interface ActionSummary {
212
233
  readonly errors: readonly ActionErrorSpec[];
213
234
  }
214
235
 
236
+ /**
237
+ * What proposing a change answers with.
238
+ *
239
+ * Nothing has run. The change waits, for a week at most, until the person whose
240
+ * request proposed it confirms or rejects it on the platform's own screen at
241
+ * `path`, which a plugin's view links to. A plugin cannot confirm one.
242
+ */
243
+ export interface PendingChangeReceipt {
244
+ readonly id: string;
245
+ readonly expiresAt: Date;
246
+ /** The preview, on the platform's own screen, relative to the origin. */
247
+ readonly path: string;
248
+ }
249
+
250
+ /**
251
+ * One action as a plugin is offered it: the summary, and the JSON Schema
252
+ * (draft 2020-12) of what it takes and what it returns.
253
+ *
254
+ * What an MCP connector puts in `tools/list` and what a model is told a tool
255
+ * accepts. The input schema is the one `invoke` validates against, converted
256
+ * as the HTTP catalogue converts it, so a tool is described by the rule it is
257
+ * held to. Each listing builds both afresh: changing one changes nothing the
258
+ * host holds or hands anybody else.
259
+ */
260
+ export interface ActionWithSchemas extends ActionSummary {
261
+ readonly inputSchema: Record<string, unknown>;
262
+ readonly outputSchema: Record<string, unknown>;
263
+ }
264
+
215
265
  export interface PluginActions {
216
266
  /**
217
267
  * Registers an action the manifest declared.
@@ -229,19 +279,66 @@ export interface PluginActions {
229
279
  */
230
280
  register: (registration: PluginActionRegistration) => void;
231
281
 
232
- /** What this plugin's route may offer the request it is serving. */
282
+ /**
283
+ * What this plugin's route may offer the request it is serving, each with
284
+ * its schemas.
285
+ *
286
+ * Filtered by channel, surface, capability, liveness and arming, as `invoke`
287
+ * is, and the schemas are of the actions listed and no others. Token scope
288
+ * (`mcp:read`, `mcp:write`) and read-only mode are decided at `invoke`, which
289
+ * may still refuse a listed action.
290
+ */
233
291
  list: (
234
292
  request: ActionRequest,
235
293
  filter?: PluginActionFilter,
236
- ) => Promise<ActionSummary[]>;
294
+ ) => Promise<ActionWithSchemas[]>;
237
295
 
238
296
  /**
239
297
  * Dispatch. The person, the channel and the connected app all come from the
240
298
  * request the host authenticated, never from an argument.
299
+ *
300
+ * A refused call rejects with an `ActionRefusal`, whichever of the three
301
+ * ways it was refused - by the registry, over its input or by the service
302
+ * behind it - and `isActionRefusal` recognises one. Anything else it
303
+ * rejects with is a fault rather than an answer.
241
304
  */
242
305
  invoke: <Output>(
243
306
  request: ActionRequest,
244
307
  name: string,
245
308
  input: unknown,
246
309
  ) => Promise<Output>;
310
+
311
+ /**
312
+ * Holds a write as a pending change instead of running it (ADR 0039).
313
+ *
314
+ * The call is checked as `invoke` would check it before running anything -
315
+ * the action exists and is offered, the person holds its capability, the
316
+ * input fits its schema - and refused the same way. A read cannot be
317
+ * proposed, and nor can an action the platform's own screens are not offered.
318
+ * The person whose request this is then sees the change as a preview and
319
+ * confirms it, and it runs as them at that moment, with every check made
320
+ * again; or rejects it, and nothing runs.
321
+ *
322
+ * Added after `invoke`, so a plugin installed on an older host has to check
323
+ * that it is there before calling it, for the reason `host.actions` gives.
324
+ */
325
+ propose: (
326
+ request: ActionRequest,
327
+ name: string,
328
+ input: unknown,
329
+ ) => Promise<PendingChangeReceipt>;
330
+
331
+ /**
332
+ * The connected app the request was authenticated with, or null when it
333
+ * arrived on a signed-in session.
334
+ *
335
+ * The app a dispatch on this request is recorded as, read from what the host
336
+ * established when it authenticated the request rather than from the
337
+ * request's own properties, which the plugin can write. A connector asks it
338
+ * before answering on a route that serves connected apps only.
339
+ *
340
+ * Added after `list` and `invoke`, so a plugin must feature-detect it
341
+ * (`host.actions?.client === undefined`) before calling it.
342
+ */
343
+ client: (request: ActionRequest) => ActionClient | null;
247
344
  }
package/src/blocks.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { z } from "zod";
2
+
3
+ import type { PluginAbortSignal } from "./abort-signal.ts";
4
+ import type { PluginSettingsValues } from "./settings-schema.ts";
5
+ import { pluginSettingsSchema } from "./settings-schema.ts";
6
+
7
+ /**
8
+ * Blocks a plugin adds to the association's website.
9
+ *
10
+ * A page on the website is a list of blocks, never markup (ADR 0006), and a
11
+ * plugin's block is one more type in that list. The manifest declares it - its
12
+ * name, how the page editor offers it and the settings the board fills in - and
13
+ * the server bundle registers the function that renders it. The host does the
14
+ * rest: it offers the block in the editor, validates the settings on every
15
+ * save, calls the render function while it renders the page, and decides what
16
+ * of the answer reaches the visitor's browser.
17
+ *
18
+ * What the render function returns is a small tree of elements rather than a
19
+ * string of HTML. The host reads it against a short list of elements and
20
+ * attributes and prints what passes through the same renderer as the rest of
21
+ * the page, so a script, a stylesheet, an event handler or a picture from
22
+ * another host has no way into the markup however the tree was built. The
23
+ * website's content security policy refuses all four as well; the list is what
24
+ * keeps the page from carrying them at all.
25
+ */
26
+
27
+ /**
28
+ * The name of one block, unique within its plugin.
29
+ *
30
+ * The block's type id is composed by the host as `<plugin id>/<name>`, which is
31
+ * what a page stores, so two plugins cannot claim one type and no plugin can
32
+ * claim one of the core's. The name has a core block's own shape: a
33
+ * lowerCamelCase word of at most 32 characters.
34
+ */
35
+ export const PLUGIN_BLOCK_NAME_PATTERN = /^[a-z][a-zA-Z0-9]{0,31}$/;
36
+
37
+ /** The type id a page stores a plugin's block under. */
38
+ export function pluginBlockType(pluginId: string, name: string): string {
39
+ return `${pluginId}/${name}`;
40
+ }
41
+
42
+ /**
43
+ * The pictures the page editor can show beside a plugin's block.
44
+ *
45
+ * A name rather than an image, as for the core's blocks: the editor draws each
46
+ * one in the theme's own colours, and a manifest carries no markup. `plugin` is
47
+ * the one to choose when none of the others says what the block is.
48
+ */
49
+ export const PLUGIN_BLOCK_ICONS = [
50
+ "plugin",
51
+ "paragraph",
52
+ "heading",
53
+ "image",
54
+ "form",
55
+ "news",
56
+ "calendar",
57
+ "documents",
58
+ "people",
59
+ "facts",
60
+ "contact",
61
+ "questions",
62
+ ] as const;
63
+ export type PluginBlockIcon = (typeof PLUGIN_BLOCK_ICONS)[number];
64
+
65
+ /**
66
+ * Where the page editor files a plugin's block.
67
+ *
68
+ * The core's categories, less `layout`: a layout block arranges other blocks,
69
+ * and a plugin's block holds none.
70
+ */
71
+ export const PLUGIN_BLOCK_CATEGORIES = [
72
+ "text",
73
+ "media",
74
+ "association",
75
+ ] as const;
76
+ export type PluginBlockCategory = (typeof PLUGIN_BLOCK_CATEGORIES)[number];
77
+
78
+ /**
79
+ * One block a plugin declares, as the manifest carries it.
80
+ *
81
+ * `labelKey` is an i18n key in the plugin's own namespace, like every other
82
+ * label a manifest names. The settings are the same declaration the plugin's
83
+ * own settings form is made of, and the page editor renders them as a form on
84
+ * the block: one set of values per block on a page, validated by the host on
85
+ * every save.
86
+ */
87
+ export const pluginBlockSchema = z.strictObject({
88
+ name: z
89
+ .string()
90
+ .regex(
91
+ PLUGIN_BLOCK_NAME_PATTERN,
92
+ "must be a lowerCamelCase word of at most 32 characters",
93
+ ),
94
+ labelKey: z.string().min(1).max(200),
95
+ icon: z.enum(PLUGIN_BLOCK_ICONS).default("plugin"),
96
+ category: z.enum(PLUGIN_BLOCK_CATEGORIES).default("association"),
97
+ /*
98
+ * No secret field. A block's values are stored in the page that holds it
99
+ * and are read by whoever renders the page, so a credential there would be
100
+ * published with it.
101
+ */
102
+ settingsSchema: pluginSettingsSchema
103
+ .refine(
104
+ (schema) => schema.fields.every((field) => field.type !== "secret"),
105
+ {
106
+ message:
107
+ "a block's settings may not declare a secret field: they are published with the page",
108
+ },
109
+ )
110
+ .default({ fields: [] }),
111
+ });
112
+
113
+ export type PluginBlockDeclaration = z.infer<typeof pluginBlockSchema>;
114
+
115
+ /**
116
+ * The blocks a manifest declares. At most sixteen, each name once.
117
+ *
118
+ * A name appearing twice would be one type id with two renderers, and a page
119
+ * that holds it would render with whichever registered last.
120
+ */
121
+ export const pluginBlocksSchema = z
122
+ .array(pluginBlockSchema)
123
+ .max(16)
124
+ .superRefine((blocks, ctx) => {
125
+ const seen = new Set<string>();
126
+ for (const [index, block] of blocks.entries()) {
127
+ if (seen.has(block.name)) {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: [index, "name"],
131
+ message: `two blocks are declared with the name "${block.name}"`,
132
+ });
133
+ }
134
+ seen.add(block.name);
135
+ }
136
+ });
137
+
138
+ /**
139
+ * The elements a rendered block may contain.
140
+ *
141
+ * Text and its structure, and nothing that loads, runs or styles anything. An
142
+ * element outside this list is left out together with everything inside it,
143
+ * because what an unknown element holds is not text the plugin meant a reader
144
+ * to see: the body of a script, a stylesheet, a template.
145
+ *
146
+ * Headings start at the second level, as a page's own blocks do: the page's
147
+ * title is its first-level heading.
148
+ */
149
+ export const PLUGIN_BLOCK_ELEMENTS = [
150
+ "p",
151
+ "h2",
152
+ "h3",
153
+ "h4",
154
+ "ul",
155
+ "ol",
156
+ "li",
157
+ "dl",
158
+ "dt",
159
+ "dd",
160
+ "strong",
161
+ "em",
162
+ "small",
163
+ "code",
164
+ "blockquote",
165
+ "a",
166
+ "br",
167
+ "span",
168
+ "div",
169
+ "section",
170
+ "table",
171
+ "caption",
172
+ "thead",
173
+ "tbody",
174
+ "tfoot",
175
+ "tr",
176
+ "th",
177
+ "td",
178
+ "time",
179
+ ] as const;
180
+ export type PluginBlockElementName = (typeof PLUGIN_BLOCK_ELEMENTS)[number];
181
+
182
+ /**
183
+ * The attributes an element may carry, by element. Every other one is left out.
184
+ *
185
+ * href on `a`: http, https, mailto or a path on this instance. Any other
186
+ * address - `javascript:` above all - leaves the link out and keeps
187
+ * its text. A link to another host is given noopener and
188
+ * noreferrer by the host.
189
+ * dateTime on `time`: the machine-readable date beside the one shown.
190
+ * scope on `th`: `row`, `col`, `rowgroup` or `colgroup`.
191
+ *
192
+ * No `class`, no `style` and no `id`: the theme decides how the website looks,
193
+ * and the host wraps every plugin's block in one element the theme can style.
194
+ */
195
+ export const PLUGIN_BLOCK_ATTRIBUTES: Readonly<
196
+ Partial<Record<PluginBlockElementName, readonly string[]>>
197
+ > = {
198
+ a: ["href"],
199
+ time: ["dateTime"],
200
+ th: ["scope"],
201
+ };
202
+
203
+ /** One element of a rendered block. */
204
+ export interface PluginBlockElement {
205
+ readonly tag: PluginBlockElementName;
206
+ readonly attributes?: Readonly<Record<string, string>>;
207
+ readonly children?: readonly PluginBlockNode[];
208
+ }
209
+
210
+ /**
211
+ * A piece of a rendered block: text, or an element.
212
+ *
213
+ * Text is text. It is escaped as it is printed, so `"<b>"` reads as the three
214
+ * characters and is never an element.
215
+ */
216
+ export type PluginBlockNode = string | PluginBlockElement;
217
+
218
+ /** What a render function answers with. Null, or nothing, renders nothing. */
219
+ export type PluginBlockContent =
220
+ PluginBlockNode | readonly PluginBlockNode[] | null | undefined;
221
+
222
+ /**
223
+ * What a block's render function is told.
224
+ *
225
+ * The block's own settings, already validated against its declaration with
226
+ * the declared defaults applied, and the reader's language with a translation
227
+ * function in the plugin's own namespace. Nothing about the reader: the same
228
+ * page is rendered for a visitor with no account and for a member, and what a
229
+ * block may say must not depend on who is reading it.
230
+ */
231
+ export interface PluginBlockRenderContext {
232
+ /** The block's type id, `<plugin id>/<name>`. */
233
+ readonly type: string;
234
+ readonly settings: PluginSettingsValues;
235
+ /** The language the page is being rendered in: `sv` or `en`. */
236
+ readonly locale: string;
237
+ /** Translates a key from the plugin's own locale files. */
238
+ readonly t: (
239
+ key: string,
240
+ values?: Readonly<Record<string, string | number>>,
241
+ ) => string;
242
+ /**
243
+ * Aborted when the host stops waiting for this block.
244
+ *
245
+ * A render that has not answered within PLUGIN_BLOCK_RENDER_TIMEOUT_MS is
246
+ * left off the page, and the page is sent without it. The host cannot stop
247
+ * the plugin's code, so a render doing slow work of its own should hand this
248
+ * on to whatever it is waiting for.
249
+ */
250
+ readonly signal: PluginBlockAbortSignal;
251
+ }
252
+
253
+ /**
254
+ * The `AbortSignal` a render function is given. See {@link PluginAbortSignal}.
255
+ */
256
+ export type PluginBlockAbortSignal = PluginAbortSignal;
257
+
258
+ /**
259
+ * How long the host waits for one block to render, in milliseconds.
260
+ *
261
+ * Every block on a page renders at the same time, so this is also about the
262
+ * longest a plugin can add to a page's answer. A website's front page has to
263
+ * answer quickly for a visitor with no account, and a block that cannot is
264
+ * left off rather than waited for.
265
+ */
266
+ export const PLUGIN_BLOCK_RENDER_TIMEOUT_MS = 2000;
267
+
268
+ /** What a plugin hands the host for one block its manifest declared. */
269
+ export interface PluginBlockRegistration {
270
+ /** The block's name as the manifest declares it, not its type id. */
271
+ readonly name: string;
272
+ readonly render: (
273
+ context: PluginBlockRenderContext,
274
+ ) => PluginBlockContent | Promise<PluginBlockContent>;
275
+ }
276
+
277
+ export interface PluginBlocks {
278
+ /**
279
+ * Registers the render function of a block the manifest declared.
280
+ *
281
+ * Callable from the module factory onwards, as `host.actions.register` is: a
282
+ * call made before the application exists is kept until it does. A name the
283
+ * manifest does not declare stops the plugin serving and is reported as
284
+ * `block-refused`. A declared block with no render function registered is
285
+ * not offered in the page editor, and a page that already holds one shows
286
+ * nothing in its place.
287
+ */
288
+ register: (registration: PluginBlockRegistration) => void;
289
+ }