@openbrf/plugin-sdk 0.0.0-stage → 1.0.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/LICENSE +661 -0
- package/LICENSE-EXCEPTION.md +28 -0
- package/dist/action-schema.d.ts +42 -0
- package/dist/action-schema.d.ts.map +1 -0
- package/dist/action-schema.js +64 -0
- package/dist/action-schema.js.map +1 -0
- package/dist/actions.d.ts +199 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +68 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-version.d.ts +23 -0
- package/dist/api-version.d.ts.map +1 -0
- package/dist/api-version.js +27 -0
- package/dist/api-version.js.map +1 -0
- package/dist/catalog.d.ts +315 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +203 -0
- package/dist/catalog.js.map +1 -0
- package/dist/define-plugin.d.ts +14 -0
- package/dist/define-plugin.d.ts.map +1 -0
- package/dist/define-plugin.js +15 -0
- package/dist/define-plugin.js.map +1 -0
- package/dist/findings.d.ts +67 -0
- package/dist/findings.d.ts.map +1 -0
- package/dist/findings.js +57 -0
- package/dist/findings.js.map +1 -0
- package/dist/host.d.ts +208 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +41 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +38 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +105 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +392 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +324 -0
- package/dist/manifest.js.map +1 -0
- package/dist/module.d.ts +35 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +2 -0
- package/dist/module.js.map +1 -0
- package/dist/package-check.d.ts +66 -0
- package/dist/package-check.d.ts.map +1 -0
- package/dist/package-check.js +755 -0
- package/dist/package-check.js.map +1 -0
- package/dist/permissions.d.ts +65 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +69 -0
- package/dist/permissions.js.map +1 -0
- package/dist/settings-schema.d.ts +95 -0
- package/dist/settings-schema.d.ts.map +1 -0
- package/dist/settings-schema.js +219 -0
- package/dist/settings-schema.js.map +1 -0
- package/dist/standard-schema.d.ts +43 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +2 -0
- package/dist/standard-schema.js.map +1 -0
- package/package.json +55 -3
- package/src/action-schema.ts +86 -0
- package/src/actions.ts +247 -0
- package/src/api-version.ts +28 -0
- package/src/catalog.ts +236 -0
- package/src/define-plugin.ts +18 -0
- package/src/findings.ts +71 -0
- package/src/host.ts +240 -0
- package/src/index.ts +123 -0
- package/src/integrity.ts +125 -0
- package/src/manifest.ts +385 -0
- package/src/module.ts +38 -0
- package/src/package-check.ts +894 -0
- package/src/permissions.ts +75 -0
- package/src/settings-schema.ts +272 -0
- package/src/standard-schema.ts +39 -0
- package/README.md +0 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { StandardSchemaV1 } from "./standard-schema.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An action's schemas, as the JSON Schema a caller is published.
|
|
7
|
+
*
|
|
8
|
+
* The only call to `z.toJSONSchema` in the product. Every option here is set
|
|
9
|
+
* against a failure that would otherwise be silent, so they are worth reading
|
|
10
|
+
* one at a time.
|
|
11
|
+
*
|
|
12
|
+
* `io: "input"` because output mode makes a key with a default REQUIRED, and a
|
|
13
|
+
* model reading that will dutifully supply a value for every field the platform
|
|
14
|
+
* was perfectly happy to choose itself.
|
|
15
|
+
*
|
|
16
|
+
* `cycles: "throw"` because a self-referential schema has no JSON Schema
|
|
17
|
+
* without `$ref`, and a silently emitted `$ref` to a positional name is worse
|
|
18
|
+
* than a refusal at registration.
|
|
19
|
+
*
|
|
20
|
+
* `reused: "inline"` because `$defs` invents names from the position a schema
|
|
21
|
+
* happens to hold, and those names would reach a committed artefact and a
|
|
22
|
+
* third-party client. Inlining costs bytes and keeps the document stable.
|
|
23
|
+
*
|
|
24
|
+
* The function form of `unrepresentable` because the default error names
|
|
25
|
+
* neither the action nor the path, and a build failing with "unrepresentable
|
|
26
|
+
* type" and nothing else is a half hour of searching.
|
|
27
|
+
*
|
|
28
|
+
* One property is worth stating because it is NOT what one would assume: a bare
|
|
29
|
+
* `.transform()` does not throw under input mode - it is erased and the
|
|
30
|
+
* pre-transform type is emitted. The guarantee is placed where it is real, on
|
|
31
|
+
* the output helper, which does throw. So an action whose pair is converted at
|
|
32
|
+
* registration cannot carry a transform anywhere.
|
|
33
|
+
*
|
|
34
|
+
* `.refine()`, `.check()`, `.superRefine()` and `.brand()` are erased with no
|
|
35
|
+
* marker under both modes. That is why the rule for a published schema is
|
|
36
|
+
* structural constraints first: what the document says has to be at least as
|
|
37
|
+
* strict as what the runtime enforces, or a caller is told a value is
|
|
38
|
+
* acceptable that the service will refuse.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Refuses a schema built with a second copy of zod, whose internals differ. */
|
|
42
|
+
export function isHostZodSchema(schema: unknown): schema is z.ZodType {
|
|
43
|
+
return (
|
|
44
|
+
typeof schema === "object" &&
|
|
45
|
+
schema !== null &&
|
|
46
|
+
"_zod" in schema &&
|
|
47
|
+
(schema as { _zod?: unknown })._zod !== undefined
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function convert(
|
|
52
|
+
schema: unknown,
|
|
53
|
+
actionName: string,
|
|
54
|
+
io: "input" | "output",
|
|
55
|
+
): Record<string, unknown> {
|
|
56
|
+
if (!isHostZodSchema(schema)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${actionName}: an action schema must be built with the host's zod.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return z.toJSONSchema(schema, {
|
|
62
|
+
target: "draft-2020-12",
|
|
63
|
+
io,
|
|
64
|
+
cycles: "throw",
|
|
65
|
+
reused: "inline",
|
|
66
|
+
unrepresentable: ({ path, message }) => {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${actionName}: ${message} at ${[io, ...path.map(String)].join(".")}`,
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
}) as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function actionInputJsonSchema(
|
|
75
|
+
schema: StandardSchemaV1<unknown, unknown>,
|
|
76
|
+
actionName: string,
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
return convert(schema, actionName, "input");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function actionOutputJsonSchema(
|
|
82
|
+
schema: StandardSchemaV1<unknown, unknown>,
|
|
83
|
+
actionName: string,
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
return convert(schema, actionName, "output");
|
|
86
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "./standard-schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the platform can be asked to do, declared once and dispatched through
|
|
5
|
+
* one place: an action (atgard).
|
|
6
|
+
*
|
|
7
|
+
* An action is a name, an input schema, the single capability it needs, what it
|
|
8
|
+
* does to the records, which categories of personal data it can touch, and which
|
|
9
|
+
* surfaces it may be offered on. The registry checks the calling person's
|
|
10
|
+
* capability on every call rather than trusting the caller, so registering an
|
|
11
|
+
* action is never a permission of its own - it is a way of reaching something
|
|
12
|
+
* the caller could already do.
|
|
13
|
+
*
|
|
14
|
+
* A core feature registers its own. A plugin registers the ones its manifest
|
|
15
|
+
* declared and the board consented to, and they disappear with the plugin.
|
|
16
|
+
*
|
|
17
|
+
* Swedish domain terms follow GLOSSARY.md.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const ACTION_EFFECTS = ["read", "write", "delete"] as const;
|
|
21
|
+
export type ActionEffect = (typeof ACTION_EFFECTS)[number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where an action may be offered.
|
|
25
|
+
*
|
|
26
|
+
* "ui" means in-process only: the platform's own screens and a plugin's own
|
|
27
|
+
* route. The other two are the ones a board switches on deliberately.
|
|
28
|
+
*/
|
|
29
|
+
export const ACTION_SURFACES = ["ui", "mcp", "ai"] as const;
|
|
30
|
+
export type ActionSurface = (typeof ACTION_SURFACES)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What an action can touch.
|
|
34
|
+
*
|
|
35
|
+
* The first thirteen are PERSONAL_DATA_CATEGORIES from @openbrf/shared, in
|
|
36
|
+
* order, which this package deliberately does not depend on; the API side
|
|
37
|
+
* asserts the prefix equality, because it is the only place that can import
|
|
38
|
+
* both. "protected" is this list's own and marks an action that can return a
|
|
39
|
+
* field of a person carrying protected personal data (skyddade
|
|
40
|
+
* personuppgifter): it is the value the registry refuses to expose to a
|
|
41
|
+
* connected app or to the AI package at all.
|
|
42
|
+
*/
|
|
43
|
+
export const ACTION_PERSONAL_DATA = [
|
|
44
|
+
"name",
|
|
45
|
+
"apartment",
|
|
46
|
+
"residency",
|
|
47
|
+
"email",
|
|
48
|
+
"phone",
|
|
49
|
+
"postalAddress",
|
|
50
|
+
"personalIdentityNumber",
|
|
51
|
+
"account",
|
|
52
|
+
"financial",
|
|
53
|
+
"health",
|
|
54
|
+
"photograph",
|
|
55
|
+
"freeText",
|
|
56
|
+
"auditTrail",
|
|
57
|
+
"protected",
|
|
58
|
+
] as const;
|
|
59
|
+
export type ActionPersonalData = (typeof ACTION_PERSONAL_DATA)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a public action name may look like.
|
|
63
|
+
*
|
|
64
|
+
* The intersection of three separate limits: what an MCP tools/list entry
|
|
65
|
+
* allows, what a model API allows (no dots), and what stays readable in a
|
|
66
|
+
* directory review (64 characters).
|
|
67
|
+
*/
|
|
68
|
+
export const ACTION_NAME_PATTERN = /^[a-z][a-z0-9_]{2,63}$/;
|
|
69
|
+
|
|
70
|
+
/** What a refusal means for a caller that is a model rather than a person. */
|
|
71
|
+
export interface ActionErrorSpec {
|
|
72
|
+
/** The machine-readable reason the handler raises. */
|
|
73
|
+
readonly reason: string;
|
|
74
|
+
/** Whether trying again could ever work, and after what. */
|
|
75
|
+
readonly retry: "never" | "after-edit" | "after-backoff";
|
|
76
|
+
/** The sentence a person is shown, as an i18n key. */
|
|
77
|
+
readonly messageKey: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The request a plugin's own route received.
|
|
82
|
+
*
|
|
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
|
+
*/
|
|
87
|
+
export type ActionRequest = object;
|
|
88
|
+
|
|
89
|
+
/** The lowercase channel vocabulary. The registry maps it to the audit channel. */
|
|
90
|
+
export const ACTION_CHANNELS = [
|
|
91
|
+
"web",
|
|
92
|
+
"mcp",
|
|
93
|
+
"ai",
|
|
94
|
+
"system",
|
|
95
|
+
"plugin",
|
|
96
|
+
] as const;
|
|
97
|
+
export type ActionChannel = (typeof ACTION_CHANNELS)[number];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Who is acting.
|
|
101
|
+
*
|
|
102
|
+
* The person, and the capabilities the registry re-derived for this call. No
|
|
103
|
+
* email, no name, no roles: a handler that needs to decide what an audience may
|
|
104
|
+
* see reads the capability list, and a handler that does not need it is not
|
|
105
|
+
* given a person's details to leak.
|
|
106
|
+
*/
|
|
107
|
+
export interface ActionPrincipal {
|
|
108
|
+
readonly personId: string;
|
|
109
|
+
readonly capabilities: readonly string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The connected app that acted, when one did. Both hosts, never the token. */
|
|
113
|
+
export interface ActionClient {
|
|
114
|
+
readonly clientId: string;
|
|
115
|
+
readonly clientHost: string | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a handler is told about the call. Built by the host, never by a caller. */
|
|
119
|
+
export interface ActionContext {
|
|
120
|
+
readonly principal: ActionPrincipal;
|
|
121
|
+
readonly channel: ActionChannel;
|
|
122
|
+
readonly client?: ActionClient;
|
|
123
|
+
readonly requestId: string;
|
|
124
|
+
readonly now: Date;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ActionDefinition<Input = unknown, Output = unknown> {
|
|
128
|
+
readonly name: string;
|
|
129
|
+
readonly titleKey: string;
|
|
130
|
+
readonly descriptionKey: string;
|
|
131
|
+
readonly group: string;
|
|
132
|
+
readonly groupTitleKey: string;
|
|
133
|
+
/** Exactly one core capability, by name. */
|
|
134
|
+
readonly capability: string;
|
|
135
|
+
readonly effect: ActionEffect;
|
|
136
|
+
readonly idempotent: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* True when the act adds and overwrites nothing a person wrote.
|
|
139
|
+
*
|
|
140
|
+
* It is what separates a create from an update, and the annotation mapping
|
|
141
|
+
* reads it: MCP's destructiveHint defaults to true, so a non-read action has
|
|
142
|
+
* to say for itself that it destroys nothing.
|
|
143
|
+
*/
|
|
144
|
+
readonly additive: boolean;
|
|
145
|
+
readonly needsConfirmation: boolean;
|
|
146
|
+
/** True when the handler reaches a system outside this instance. */
|
|
147
|
+
readonly openWorld: boolean;
|
|
148
|
+
/** Never empty by convention: an action that touches none says []. */
|
|
149
|
+
readonly personalData: readonly ActionPersonalData[];
|
|
150
|
+
/** Opt-in allowlist. An empty array means the action is offered nowhere. */
|
|
151
|
+
readonly surfaces: readonly ActionSurface[];
|
|
152
|
+
/** Every domain refusal the handler can raise, for the caller to act on. */
|
|
153
|
+
readonly errors: readonly ActionErrorSpec[];
|
|
154
|
+
readonly deprecatedAliases?: readonly string[];
|
|
155
|
+
/** Every object in it, at every depth, must refuse unknown keys. */
|
|
156
|
+
readonly input: StandardSchemaV1<unknown, Input>;
|
|
157
|
+
readonly output: StandardSchemaV1<unknown, Output>;
|
|
158
|
+
readonly handler: (input: Input, context: ActionContext) => Promise<Output>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What a plugin hands the host: the id its manifest declared, and the rest.
|
|
163
|
+
*
|
|
164
|
+
* The capability, the effect, the personal-data categories and the surfaces are
|
|
165
|
+
* manifest data and are not repeated here. The loader's rule is that nothing
|
|
166
|
+
* which can refuse a plugin may run after the code that executes it, and the
|
|
167
|
+
* install consent screen renders from the catalog before anything is
|
|
168
|
+
* downloaded - so what the board consents to cannot come from the bundle.
|
|
169
|
+
*/
|
|
170
|
+
export interface PluginActionRegistration {
|
|
171
|
+
readonly id: string;
|
|
172
|
+
readonly definition: Omit<
|
|
173
|
+
ActionDefinition,
|
|
174
|
+
"capability" | "effect" | "personalData" | "surfaces"
|
|
175
|
+
>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Narrows what a plugin's route offers the request it is serving. */
|
|
179
|
+
export interface PluginActionFilter {
|
|
180
|
+
readonly surface?: ActionSurface;
|
|
181
|
+
readonly group?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One action as a caller sees it, with its text already in the right language. */
|
|
185
|
+
export interface ActionSummary {
|
|
186
|
+
readonly name: string;
|
|
187
|
+
readonly title: string;
|
|
188
|
+
readonly description: string;
|
|
189
|
+
readonly titleKey: string;
|
|
190
|
+
readonly descriptionKey: string;
|
|
191
|
+
readonly group: string;
|
|
192
|
+
readonly groupTitle: string;
|
|
193
|
+
/**
|
|
194
|
+
* The key behind `groupTitle`, alongside the two above.
|
|
195
|
+
*
|
|
196
|
+
* Both forms travel for the reason `titleKey` does: the resolved string is
|
|
197
|
+
* for a caller with no translations of its own - a connected app, a model -
|
|
198
|
+
* while a screen in this product translates the key itself. The interface's
|
|
199
|
+
* language and the language a request happens to declare are not the same
|
|
200
|
+
* thing, so a screen rendering the resolved string can show one heading in
|
|
201
|
+
* the browser's language above a page in the association's.
|
|
202
|
+
*/
|
|
203
|
+
readonly groupTitleKey: string;
|
|
204
|
+
readonly capability: string;
|
|
205
|
+
readonly effect: ActionEffect;
|
|
206
|
+
readonly idempotent: boolean;
|
|
207
|
+
readonly additive: boolean;
|
|
208
|
+
readonly needsConfirmation: boolean;
|
|
209
|
+
readonly openWorld: boolean;
|
|
210
|
+
readonly personalData: readonly ActionPersonalData[];
|
|
211
|
+
readonly surfaces: readonly ActionSurface[];
|
|
212
|
+
readonly errors: readonly ActionErrorSpec[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface PluginActions {
|
|
216
|
+
/**
|
|
217
|
+
* Registers an action the manifest declared.
|
|
218
|
+
*
|
|
219
|
+
* Callable from the module factory onwards: a call made that early is
|
|
220
|
+
* buffered until the application is built, because the host object is not
|
|
221
|
+
* bound yet.
|
|
222
|
+
*
|
|
223
|
+
* It does not throw for a registration the host will refuse. An id the
|
|
224
|
+
* manifest never declared, or a schema that cannot be published, is found
|
|
225
|
+
* when the buffer is flushed - long after this call returned - and reaches
|
|
226
|
+
* the board as an `action-refused` finding rather than as an error the
|
|
227
|
+
* factory could have caught. A plugin that wants to know sooner validates
|
|
228
|
+
* its own manifest in its own build.
|
|
229
|
+
*/
|
|
230
|
+
register: (registration: PluginActionRegistration) => void;
|
|
231
|
+
|
|
232
|
+
/** What this plugin's route may offer the request it is serving. */
|
|
233
|
+
list: (
|
|
234
|
+
request: ActionRequest,
|
|
235
|
+
filter?: PluginActionFilter,
|
|
236
|
+
) => Promise<ActionSummary[]>;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Dispatch. The person, the channel and the connected app all come from the
|
|
240
|
+
* request the host authenticated, never from an argument.
|
|
241
|
+
*/
|
|
242
|
+
invoke: <Output>(
|
|
243
|
+
request: ActionRequest,
|
|
244
|
+
name: string,
|
|
245
|
+
input: unknown,
|
|
246
|
+
) => Promise<Output>;
|
|
247
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin API version.
|
|
3
|
+
*
|
|
4
|
+
* A single integer rather than a semver range, because the gate it guards is
|
|
5
|
+
* binary: a plugin either speaks the contract this host implements or it does
|
|
6
|
+
* not get loaded. A range would invite a plugin to claim ">=1" and then fail
|
|
7
|
+
* at the first call to a method version 2 removed, which is exactly the class
|
|
8
|
+
* of breakage the gate exists to prevent.
|
|
9
|
+
*
|
|
10
|
+
* Additive changes - a new optional manifest field, a new SDK method - keep
|
|
11
|
+
* the number. Anything a plugin built against the old contract could notice
|
|
12
|
+
* raises it, and the host may then support more than one version at a time by
|
|
13
|
+
* listing them in SUPPORTED_PLUGIN_API_VERSIONS.
|
|
14
|
+
*/
|
|
15
|
+
export const PLUGIN_API_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every version this contract can still load. Kept as a list so a host can
|
|
19
|
+
* carry an old version through a deprecation window without a plugin having
|
|
20
|
+
* to be republished on the day the new one lands.
|
|
21
|
+
*/
|
|
22
|
+
export const SUPPORTED_PLUGIN_API_VERSIONS: readonly number[] = [
|
|
23
|
+
PLUGIN_API_VERSION,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function isSupportedApiVersion(apiVersion: number): boolean {
|
|
27
|
+
return SUPPORTED_PLUGIN_API_VERSIONS.includes(apiVersion);
|
|
28
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { IntegrityError, parseSha512 } from "./integrity.ts";
|
|
4
|
+
import {
|
|
5
|
+
declaredActionsSchema,
|
|
6
|
+
pluginActionSchema,
|
|
7
|
+
pluginIdSchema,
|
|
8
|
+
pluginManifestSchema,
|
|
9
|
+
} from "./manifest.ts";
|
|
10
|
+
import {
|
|
11
|
+
PLUGIN_PERMISSIONS,
|
|
12
|
+
PLUGIN_PERSONAL_DATA_CATEGORIES,
|
|
13
|
+
} from "./permissions.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The catalog index.
|
|
17
|
+
*
|
|
18
|
+
* One file listing everything an instance may install: plugins and themes
|
|
19
|
+
* alike, since both are distributed the same way and the board browses one
|
|
20
|
+
* screen per kind rather than one source per kind. Delisting is a commit
|
|
21
|
+
* against the index, which is why an instance re-reads it rather than caching
|
|
22
|
+
* it across installs.
|
|
23
|
+
*
|
|
24
|
+
* Exported so the index is checked by the same schema wherever it is read: by
|
|
25
|
+
* the instance before it offers an entry to a board, and by the catalog's own
|
|
26
|
+
* check before a listing is merged.
|
|
27
|
+
*
|
|
28
|
+
* Entries are parsed strictly. The index arrives over the network, and an
|
|
29
|
+
* entry the instance does not fully understand is an entry it must not offer
|
|
30
|
+
* to a board for consent - the consent screen's whole job is to say precisely
|
|
31
|
+
* what is being agreed to.
|
|
32
|
+
*
|
|
33
|
+
* Every object in it is strict as well: a field this version of the index does
|
|
34
|
+
* not define is refused, never dropped. Dropped, a misspelled field of the
|
|
35
|
+
* declaration would be replaced by its default before the consent screen or
|
|
36
|
+
* any check of the manifest saw it. The index carries its version for exactly
|
|
37
|
+
* this, so a new field is a new index version rather than a key an older
|
|
38
|
+
* instance ignores.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Whether `declared` is a digest `parseSha512` can read. */
|
|
42
|
+
function isReadableSha512(declared: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
parseSha512(declared);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof IntegrityError) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A tarball and the digest its bytes must hash to. */
|
|
55
|
+
export const catalogArtifactSchema = z.strictObject({
|
|
56
|
+
/**
|
|
57
|
+
* Direct URL.
|
|
58
|
+
*
|
|
59
|
+
* https on a curated instance, and nothing else: the shape is checked here
|
|
60
|
+
* but the destination is decided at fetch time, where http: and file: are
|
|
61
|
+
* refused unless the instance has opted out of curation. That is what keeps
|
|
62
|
+
* an entry - which is data fetched from elsewhere - from naming a `file:`
|
|
63
|
+
* path and having the instance read its own disk, or a plain-http address
|
|
64
|
+
* inside the network the instance sits in.
|
|
65
|
+
*/
|
|
66
|
+
url: z.string().min(1).max(2000),
|
|
67
|
+
/**
|
|
68
|
+
* "sha512-<base64>" or 128 hex characters.
|
|
69
|
+
*
|
|
70
|
+
* Read through the same `parseSha512` the instance verifies a download with,
|
|
71
|
+
* so an entry the catalog's check lets through is one the instance can read,
|
|
72
|
+
* and an index with a badly spelled digest is refused by `parseCatalogIndex`
|
|
73
|
+
* alone.
|
|
74
|
+
*/
|
|
75
|
+
sha512: z.string().min(1).max(200).refine(isReadableSha512, {
|
|
76
|
+
error:
|
|
77
|
+
'Expected a digest written as "sha512-<base64>" or 128 hex characters.',
|
|
78
|
+
}),
|
|
79
|
+
bytes: z.int().min(1).optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export type CatalogArtifact = z.infer<typeof catalogArtifactSchema>;
|
|
83
|
+
|
|
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
|
+
});
|
|
89
|
+
|
|
90
|
+
export type LocalizedText = z.infer<typeof localizedTextSchema>;
|
|
91
|
+
|
|
92
|
+
/** Strict, and `.extend()` keeps it so for both kinds of entry. */
|
|
93
|
+
const baseEntrySchema = z.strictObject({
|
|
94
|
+
/**
|
|
95
|
+
* Unique across the whole index, whatever the entry's type: an install
|
|
96
|
+
* looks an entry up by id alone.
|
|
97
|
+
*/
|
|
98
|
+
id: pluginIdSchema,
|
|
99
|
+
version: z.string().min(1).max(64),
|
|
100
|
+
/** The curator's text, in both languages the interface is offered in. */
|
|
101
|
+
name: localizedTextSchema,
|
|
102
|
+
description: localizedTextSchema,
|
|
103
|
+
artifact: catalogArtifactSchema,
|
|
104
|
+
homepage: z.string().max(2000).optional(),
|
|
105
|
+
/** Set on an entry that is still listed but should not be installed anew. */
|
|
106
|
+
deprecated: z.boolean().default(false),
|
|
107
|
+
});
|
|
108
|
+
|
|
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
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A theme entry. Themes install through the same download-and-verify path and
|
|
158
|
+
* are listed in the same index; what happens after the bytes are verified is
|
|
159
|
+
* the theme installer's business, not this schema's.
|
|
160
|
+
*
|
|
161
|
+
* No package name: a theme is data read out of its archive, never an npm
|
|
162
|
+
* package, and nothing that installs one keys on a package name.
|
|
163
|
+
*/
|
|
164
|
+
export const catalogThemeEntrySchema = baseEntrySchema.extend({
|
|
165
|
+
type: z.literal("theme"),
|
|
166
|
+
/** The token contract range the theme was authored against. */
|
|
167
|
+
contract: z.string().min(1).max(64).optional(),
|
|
168
|
+
/** The theme it inherits from, as its manifest names it. */
|
|
169
|
+
extends: z.string().min(1).max(64).optional(),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export const catalogEntrySchema = z.discriminatedUnion("type", [
|
|
173
|
+
catalogPluginEntrySchema,
|
|
174
|
+
catalogThemeEntrySchema,
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
export type CatalogPluginEntry = z.infer<typeof catalogPluginEntrySchema>;
|
|
178
|
+
export type CatalogThemeEntry = z.infer<typeof catalogThemeEntrySchema>;
|
|
179
|
+
export type CatalogEntry = z.infer<typeof catalogEntrySchema>;
|
|
180
|
+
|
|
181
|
+
export const catalogSchema = z
|
|
182
|
+
.strictObject({
|
|
183
|
+
/** Index format version, so a future shape can be recognised and refused. */
|
|
184
|
+
version: z.literal(1),
|
|
185
|
+
entries: z.array(catalogEntrySchema).max(500),
|
|
186
|
+
})
|
|
187
|
+
.superRefine((catalog, ctx) => {
|
|
188
|
+
/*
|
|
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.
|
|
193
|
+
*/
|
|
194
|
+
const firstIndexById = new Map<string, number>();
|
|
195
|
+
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);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
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
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
export type Catalog = z.infer<typeof catalogSchema>;
|
|
210
|
+
|
|
211
|
+
export type CatalogParseResult =
|
|
212
|
+
{ ok: true; value: Catalog } | { ok: false; issues: readonly string[] };
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Parses a catalog index.
|
|
216
|
+
*
|
|
217
|
+
* A single malformed entry rejects the whole index rather than being dropped
|
|
218
|
+
* quietly. A board that installs from a catalog which silently lost an entry
|
|
219
|
+
* has no way to tell that from an entry that was delisted on purpose, and the
|
|
220
|
+
* two mean opposite things.
|
|
221
|
+
*
|
|
222
|
+
* Returns a result rather than throwing, as `parsePluginPackage` does, so a
|
|
223
|
+
* catalog's own check can list every problem at once.
|
|
224
|
+
*/
|
|
225
|
+
export function parseCatalogIndex(input: unknown): CatalogParseResult {
|
|
226
|
+
const result = catalogSchema.safeParse(input);
|
|
227
|
+
if (result.success) {
|
|
228
|
+
return { ok: true, value: result.data };
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
issues: result.error.issues.map(
|
|
233
|
+
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PluginModuleFactory } from "./module.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declares a plugin's server entry point.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately an identity function. ADR 0003 requires a plugin to ship a
|
|
7
|
+
* prebuilt CJS bundle whose only externals are host packages, so anything
|
|
8
|
+
* this helper did at runtime would either have to be bundled into every
|
|
9
|
+
* plugin or would make the SDK a runtime dependency the host has to resolve
|
|
10
|
+
* for it. Being identity means a bundler inlines it to nothing and the value
|
|
11
|
+
* the helper adds - the type of the factory, checked at the author's build
|
|
12
|
+
* time - costs the running instance nothing.
|
|
13
|
+
*/
|
|
14
|
+
export function definePlugin(
|
|
15
|
+
factory: PluginModuleFactory,
|
|
16
|
+
): PluginModuleFactory {
|
|
17
|
+
return factory;
|
|
18
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a plugin on the data volume is not running.
|
|
3
|
+
*
|
|
4
|
+
* A finding travels as one of these codes plus the values its sentence needs,
|
|
5
|
+
* never as a sentence composed by the host. The interface is Swedish by
|
|
6
|
+
* default and this package is English, so what a board reads has to be a
|
|
7
|
+
* translation of the code: a sentence written where the finding is produced
|
|
8
|
+
* reaches the board panel in the language the server happens to be written in.
|
|
9
|
+
*
|
|
10
|
+
* Listed as values rather than as a bare union so a caller can walk them and
|
|
11
|
+
* prove it has a sentence for each. Part of the contract rather than an
|
|
12
|
+
* implementation detail - docs/plugin-contract.md lists the same set, and a
|
|
13
|
+
* plugin author reading why their package was skipped is reading one of these.
|
|
14
|
+
*/
|
|
15
|
+
export const PLUGIN_FINDING_REASONS = [
|
|
16
|
+
/** The `openbrf` field failed validation. */
|
|
17
|
+
"manifest-invalid",
|
|
18
|
+
/** Built against a contract version this instance does not implement. */
|
|
19
|
+
"api-version-unsupported",
|
|
20
|
+
/** A declared entry file is not in the package. */
|
|
21
|
+
"entry-missing",
|
|
22
|
+
/** The server bundle does not export `createPlugin`. */
|
|
23
|
+
"entry-invalid",
|
|
24
|
+
/** `createPlugin` returned no NestJS dynamic module. */
|
|
25
|
+
"module-invalid",
|
|
26
|
+
/** Its module declares behaviour a plugin may not register. */
|
|
27
|
+
"module-refused",
|
|
28
|
+
/** Its module could not be built into the application. */
|
|
29
|
+
"module-failed",
|
|
30
|
+
/** The package carries its own copy of a host package it must share. */
|
|
31
|
+
"module-identity",
|
|
32
|
+
/** It asks for more than was consented to. */
|
|
33
|
+
"permissions-widened",
|
|
34
|
+
/** It handles a personal-data category that was not consented to. */
|
|
35
|
+
"personal-data-widened",
|
|
36
|
+
/** On the volume with no record of consent. */
|
|
37
|
+
"not-consented",
|
|
38
|
+
/** Switched off in the admin interface. */
|
|
39
|
+
"disabled",
|
|
40
|
+
/** It threw while being loaded. */
|
|
41
|
+
"load-failed",
|
|
42
|
+
/** Recorded as installed but not present. */
|
|
43
|
+
"not-on-volume",
|
|
44
|
+
/** The installed version declares an action the board did not consent to. */
|
|
45
|
+
"actions-widened",
|
|
46
|
+
/** An action the manifest declares could not be registered. */
|
|
47
|
+
"action-refused",
|
|
48
|
+
/** One of its providers reaches for a core service a plugin may not hold. */
|
|
49
|
+
"forbidden-injection",
|
|
50
|
+
/**
|
|
51
|
+
* It declares the OAuth protected resource, and another installed plugin
|
|
52
|
+
* already does. The one installed first keeps it: the resource URL is the
|
|
53
|
+
* audience every issued token carries, so moving it would strand them all.
|
|
54
|
+
*/
|
|
55
|
+
"oauth-resource-conflict",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
export type PluginFindingReason = (typeof PLUGIN_FINDING_REASONS)[number];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The values a finding's sentence is completed with.
|
|
62
|
+
*
|
|
63
|
+
* Identifiers, paths, numbers and what a thrown error said - never a sentence,
|
|
64
|
+
* and never personal data. A list stays a list rather than being joined here,
|
|
65
|
+
* because its members can have sentences of their own: a board told that a
|
|
66
|
+
* package handles a category it never agreed to has to read that category in
|
|
67
|
+
* its own language, not as `residency`.
|
|
68
|
+
*/
|
|
69
|
+
export type PluginFindingDetail = Readonly<
|
|
70
|
+
Record<string, string | number | readonly string[]>
|
|
71
|
+
>;
|