@vonzio/plugin-api 0.1.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/README.md +5 -0
- package/capabilities.d.ts +69 -0
- package/capabilities.js +286 -0
- package/errors.d.ts +72 -0
- package/errors.js +106 -0
- package/frontend.d.ts +13 -0
- package/frontend.js +16 -0
- package/index.d.ts +945 -0
- package/index.js +24 -0
- package/manifest-validate.d.ts +44 -0
- package/manifest-validate.js +338 -0
- package/manifest.d.ts +121 -0
- package/manifest.js +37 -0
- package/package.json +49 -0
- package/policy.d.ts +98 -0
- package/policy.js +223 -0
- package/version.d.ts +16 -0
- package/version.js +54 -0
package/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Plugin contract for vonzio. A plugin is a self-contained npm package
|
|
2
|
+
// that the loader in core-server imports at boot, parses config for,
|
|
3
|
+
// applies migrations from, and calls init() on. From there the plugin
|
|
4
|
+
// registers routes / notification handlers / MCP servers / background
|
|
5
|
+
// jobs to integrate with the host runtime.
|
|
6
|
+
//
|
|
7
|
+
// This file is the SURFACE plugin authors program against. Keep it
|
|
8
|
+
// minimal -- once shipped, breaking changes require a major bump and
|
|
9
|
+
// a migration guide.
|
|
10
|
+
/**
|
|
11
|
+
* Current plugin-api version. Plugins encode the version they were built
|
|
12
|
+
* against in their package.json `vonzio.apiVersion`; the loader rejects
|
|
13
|
+
* plugins whose major differs or whose minor is ahead of core's (see
|
|
14
|
+
* `assertApiCompatible`). Bumped to 1.0.0 with the external-loader contract
|
|
15
|
+
* (docs/PLUGIN_LOADER_SPEC.md) — the loader surface is now a stability
|
|
16
|
+
* commitment.
|
|
17
|
+
*/
|
|
18
|
+
export const PLUGIN_API_VERSION = "1.0.0";
|
|
19
|
+
export { PLUGIN_CAPABILITIES, CAPABILITY_SURFACE_MAP, ROOT_EQUIVALENT_COMBINATIONS, BUILTIN_ONLY_CAPABILITIES, isPluginCapability, } from "./capabilities.js";
|
|
20
|
+
export { MANIFEST_ALLOWED_KEYS, POLICY_ENTRY_ALLOWED_KEYS, SCHEMA_PREFIX_PATTERN, MTLS_SECRET_NAME_PATTERN, } from "./manifest.js";
|
|
21
|
+
export { validateManifest, validatePolicy, matchOutboundHost, normalizeHostPattern, } from "./manifest-validate.js";
|
|
22
|
+
export { CapabilityViolationError, OutboundHostViolationError, DbScopeViolationError, PluginRefusedError, PolicyViolationError, REFUSAL_REASONS, } from "./errors.js";
|
|
23
|
+
export { assertApiCompatible } from "./version.js";
|
|
24
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type OperatorPolicy, type PluginManifest } from "./manifest.js";
|
|
2
|
+
import { type RefusalReason } from "./errors.js";
|
|
3
|
+
export type ManifestValidationResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
manifest: PluginManifest;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: RefusalReason;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Normalize + validate one `outboundHosts` manifest entry into its canonical
|
|
13
|
+
* comparison form: lowercased, trailing-dot-stripped, IDNA/punycode-encoded,
|
|
14
|
+
* with a single leading `*.` glob preserved. Throws on malformed entries.
|
|
15
|
+
*
|
|
16
|
+
* Rules (§3 "Outbound host matching"):
|
|
17
|
+
* - hostnames only — `/ : ? # @` and whitespace rejected (schemes, ports,
|
|
18
|
+
* paths, userinfo are not part of the match)
|
|
19
|
+
* - one leading `*.` glob allowed (matches exactly one DNS label); `**`,
|
|
20
|
+
* mid-pattern `*`, or a bare `*` are rejected
|
|
21
|
+
* - IDNA: the non-glob remainder is ASCII-encoded via the URL parser
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalizeHostPattern(entry: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Does `hostname` (from `url.hostname` — already lowercased + IDNA) match any
|
|
26
|
+
* normalized pattern? Glob `*.x.com` matches exactly one extra leading label.
|
|
27
|
+
*/
|
|
28
|
+
export declare function matchOutboundHost(hostname: string, patterns: readonly string[]): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Validate the `vonzio` block from a plugin's package.json. Returns a
|
|
31
|
+
* discriminated result so the loader can map the failure to the right §11
|
|
32
|
+
* refusal reason without this module importing the loader. Does NOT run the
|
|
33
|
+
* apiVersion compatibility check (that's assertApiCompatible) nor any I/O
|
|
34
|
+
* (backendEntry existence, hashing) — those are the loader's job.
|
|
35
|
+
*/
|
|
36
|
+
export declare function validateManifest(raw: unknown): ManifestValidationResult;
|
|
37
|
+
/**
|
|
38
|
+
* Validate the operator policy file (or the shipped builtins policy). The
|
|
39
|
+
* policy file is operator-owned config: a malformed file is a hard error
|
|
40
|
+
* (throws PolicyViolationError) rather than a per-plugin skip. Strict:
|
|
41
|
+
* unknown per-entry keys are rejected (mirrors the manifest stance).
|
|
42
|
+
*/
|
|
43
|
+
export declare function validatePolicy(raw: unknown, sourceLabel?: string): OperatorPolicy;
|
|
44
|
+
//# sourceMappingURL=manifest-validate.d.ts.map
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Pure, dependency-free validators for the plugin manifest (the package.json
|
|
2
|
+
// `vonzio` block) and the operator policy file, plus the runtime outbound-host
|
|
3
|
+
// matcher. No I/O — the loader reads files and calls these. Strict: unknown
|
|
4
|
+
// fields are rejected so a typo (`capabilites`) can't leave a plugin ungated.
|
|
5
|
+
// See docs/PLUGIN_LOADER_SPEC.md §3 ("Outbound host matching", "Policy file
|
|
6
|
+
// JSON schema"), §4.
|
|
7
|
+
import { isPluginCapability, } from "./capabilities.js";
|
|
8
|
+
import { MANIFEST_ALLOWED_KEYS, MTLS_SECRET_NAME_PATTERN, POLICY_ENTRY_ALLOWED_KEYS, SCHEMA_PREFIX_PATTERN, } from "./manifest.js";
|
|
9
|
+
import { PolicyViolationError } from "./errors.js";
|
|
10
|
+
function isPlainObject(v) {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
/** Characters that disqualify a string from being a bare hostname pattern. */
|
|
14
|
+
const HOST_FORBIDDEN_CHARS = /[/:?#@\s]/;
|
|
15
|
+
/**
|
|
16
|
+
* Normalize + validate one `outboundHosts` manifest entry into its canonical
|
|
17
|
+
* comparison form: lowercased, trailing-dot-stripped, IDNA/punycode-encoded,
|
|
18
|
+
* with a single leading `*.` glob preserved. Throws on malformed entries.
|
|
19
|
+
*
|
|
20
|
+
* Rules (§3 "Outbound host matching"):
|
|
21
|
+
* - hostnames only — `/ : ? # @` and whitespace rejected (schemes, ports,
|
|
22
|
+
* paths, userinfo are not part of the match)
|
|
23
|
+
* - one leading `*.` glob allowed (matches exactly one DNS label); `**`,
|
|
24
|
+
* mid-pattern `*`, or a bare `*` are rejected
|
|
25
|
+
* - IDNA: the non-glob remainder is ASCII-encoded via the URL parser
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeHostPattern(entry) {
|
|
28
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
29
|
+
throw new Error("outboundHosts entry must be a non-empty string");
|
|
30
|
+
}
|
|
31
|
+
let host = entry.trim().toLowerCase().replace(/\.$/, "");
|
|
32
|
+
let glob = false;
|
|
33
|
+
if (host.startsWith("*.")) {
|
|
34
|
+
glob = true;
|
|
35
|
+
host = host.slice(2);
|
|
36
|
+
}
|
|
37
|
+
if (host.includes("*")) {
|
|
38
|
+
throw new Error(`Invalid outboundHosts entry "${entry}": "*" is only allowed as a single leading label (e.g. *.slack.com)`);
|
|
39
|
+
}
|
|
40
|
+
if (host.length === 0) {
|
|
41
|
+
throw new Error(`Invalid outboundHosts entry "${entry}": empty host after glob`);
|
|
42
|
+
}
|
|
43
|
+
if (HOST_FORBIDDEN_CHARS.test(host)) {
|
|
44
|
+
throw new Error(`Invalid outboundHosts entry "${entry}": hostnames only — no scheme, port, path, or userinfo`);
|
|
45
|
+
}
|
|
46
|
+
// IDNA-encode via the URL parser (applies punycode + validation). A host
|
|
47
|
+
// with an illegal character set will throw here.
|
|
48
|
+
let encoded;
|
|
49
|
+
try {
|
|
50
|
+
encoded = new URL(`http://${host}`).hostname;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`Invalid outboundHosts entry "${entry}": not a valid hostname`);
|
|
54
|
+
}
|
|
55
|
+
return glob ? `*.${encoded}` : encoded;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Does `hostname` (from `url.hostname` — already lowercased + IDNA) match any
|
|
59
|
+
* normalized pattern? Glob `*.x.com` matches exactly one extra leading label.
|
|
60
|
+
*/
|
|
61
|
+
export function matchOutboundHost(hostname, patterns) {
|
|
62
|
+
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
63
|
+
for (const raw of patterns) {
|
|
64
|
+
// Patterns are expected pre-normalized (validateManifest stores them so),
|
|
65
|
+
// but normalize defensively in case a caller passes raw manifest strings.
|
|
66
|
+
let pattern;
|
|
67
|
+
try {
|
|
68
|
+
pattern = raw.startsWith("*.") || !HOST_FORBIDDEN_CHARS.test(raw)
|
|
69
|
+
? normalizeHostPattern(raw)
|
|
70
|
+
: raw.toLowerCase().replace(/\.$/, "");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue; // a malformed pattern never matches
|
|
74
|
+
}
|
|
75
|
+
if (pattern.startsWith("*.")) {
|
|
76
|
+
const suffix = pattern.slice(2);
|
|
77
|
+
const labels = host.split(".");
|
|
78
|
+
const suffixLabels = suffix.split(".");
|
|
79
|
+
if (labels.length === suffixLabels.length + 1 &&
|
|
80
|
+
labels.slice(1).join(".") === suffix) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (host === pattern) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
function validateRoutePrefix(raw) {
|
|
91
|
+
if (!isPlainObject(raw))
|
|
92
|
+
return { error: "routePrefix must be an object" };
|
|
93
|
+
if (raw.kind === "auto") {
|
|
94
|
+
if (Object.keys(raw).length !== 1)
|
|
95
|
+
return { error: 'routePrefix {kind:"auto"} takes no other fields' };
|
|
96
|
+
return { kind: "auto" };
|
|
97
|
+
}
|
|
98
|
+
if (raw.kind === "absolute") {
|
|
99
|
+
const extra = Object.keys(raw).filter((k) => k !== "kind" && k !== "prefix");
|
|
100
|
+
if (extra.length)
|
|
101
|
+
return { error: `routePrefix has unknown fields: ${extra.join(", ")}` };
|
|
102
|
+
const p = raw.prefix;
|
|
103
|
+
const arr = Array.isArray(p) ? p : [p];
|
|
104
|
+
if (arr.length === 0)
|
|
105
|
+
return { error: "routePrefix.absolute.prefix is empty" };
|
|
106
|
+
for (const one of arr) {
|
|
107
|
+
if (typeof one !== "string" || !one.startsWith("/")) {
|
|
108
|
+
return { error: `routePrefix.absolute.prefix entries must be absolute paths: ${String(one)}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { kind: "absolute", prefix: p };
|
|
112
|
+
}
|
|
113
|
+
return { error: `routePrefix.kind must be "auto" or "absolute", got ${JSON.stringify(raw.kind)}` };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Validate the `vonzio` block from a plugin's package.json. Returns a
|
|
117
|
+
* discriminated result so the loader can map the failure to the right §11
|
|
118
|
+
* refusal reason without this module importing the loader. Does NOT run the
|
|
119
|
+
* apiVersion compatibility check (that's assertApiCompatible) nor any I/O
|
|
120
|
+
* (backendEntry existence, hashing) — those are the loader's job.
|
|
121
|
+
*/
|
|
122
|
+
export function validateManifest(raw) {
|
|
123
|
+
const fail = (message, reason = "manifest_invalid") => ({ ok: false, reason, message });
|
|
124
|
+
if (!isPlainObject(raw))
|
|
125
|
+
return fail("vonzio manifest block is missing or not an object");
|
|
126
|
+
// Strict: reject unknown keys (typo guard).
|
|
127
|
+
const unknown = Object.keys(raw).filter((k) => !MANIFEST_ALLOWED_KEYS.has(k));
|
|
128
|
+
if (unknown.length)
|
|
129
|
+
return fail(`manifest has unknown fields: ${unknown.join(", ")}`);
|
|
130
|
+
if (typeof raw.apiVersion !== "string" || raw.apiVersion.length === 0) {
|
|
131
|
+
return fail("manifest.apiVersion must be a non-empty string");
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw.backendEntry !== "string" || raw.backendEntry.length === 0) {
|
|
134
|
+
return fail("manifest.backendEntry must be a non-empty string");
|
|
135
|
+
}
|
|
136
|
+
if (raw.frontendEntry !== undefined && typeof raw.frontendEntry !== "string") {
|
|
137
|
+
return fail("manifest.frontendEntry must be a string when present");
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(raw.capabilities)) {
|
|
140
|
+
return fail("manifest.capabilities must be an array");
|
|
141
|
+
}
|
|
142
|
+
const capabilities = [];
|
|
143
|
+
for (const c of raw.capabilities) {
|
|
144
|
+
if (typeof c !== "string" || !isPluginCapability(c)) {
|
|
145
|
+
return fail(`manifest.capabilities contains an unknown capability: ${JSON.stringify(c)}`);
|
|
146
|
+
}
|
|
147
|
+
capabilities.push(c);
|
|
148
|
+
}
|
|
149
|
+
const declaresHttp = capabilities.includes("http.outbound");
|
|
150
|
+
const declaresDb = capabilities.includes("db.scoped") || capabilities.includes("db.access");
|
|
151
|
+
// outboundHosts: required + non-empty iff http.outbound; normalized.
|
|
152
|
+
let outboundHosts;
|
|
153
|
+
if (raw.outboundHosts !== undefined) {
|
|
154
|
+
if (!Array.isArray(raw.outboundHosts))
|
|
155
|
+
return fail("manifest.outboundHosts must be an array");
|
|
156
|
+
try {
|
|
157
|
+
outboundHosts = raw.outboundHosts.map((h) => normalizeHostPattern(h));
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (declaresHttp && (!outboundHosts || outboundHosts.length === 0)) {
|
|
164
|
+
return fail("manifest declares http.outbound but outboundHosts is missing or empty");
|
|
165
|
+
}
|
|
166
|
+
if (!declaresHttp && outboundHosts && outboundHosts.length > 0) {
|
|
167
|
+
return fail("manifest.outboundHosts is set but http.outbound capability is not declared");
|
|
168
|
+
}
|
|
169
|
+
// schemaPrefix: required iff db.* and must be a DB-safe identifier.
|
|
170
|
+
let schemaPrefix;
|
|
171
|
+
if (raw.schemaPrefix !== undefined) {
|
|
172
|
+
if (typeof raw.schemaPrefix !== "string")
|
|
173
|
+
return fail("manifest.schemaPrefix must be a string");
|
|
174
|
+
if (!SCHEMA_PREFIX_PATTERN.test(raw.schemaPrefix)) {
|
|
175
|
+
return fail(`manifest.schemaPrefix "${raw.schemaPrefix}" must match ${SCHEMA_PREFIX_PATTERN}`, "schema_prefix_invalid");
|
|
176
|
+
}
|
|
177
|
+
schemaPrefix = raw.schemaPrefix;
|
|
178
|
+
}
|
|
179
|
+
if (declaresDb && !schemaPrefix) {
|
|
180
|
+
return fail("manifest declares db.scoped/db.access but schemaPrefix is missing", "schema_prefix_invalid");
|
|
181
|
+
}
|
|
182
|
+
// mtlsSecrets: required + non-empty iff secrets.mtls; names pattern-checked.
|
|
183
|
+
const declaresMtls = capabilities.includes("secrets.mtls");
|
|
184
|
+
let mtlsSecrets;
|
|
185
|
+
if (raw.mtlsSecrets !== undefined) {
|
|
186
|
+
if (!Array.isArray(raw.mtlsSecrets))
|
|
187
|
+
return fail("manifest.mtlsSecrets must be an array");
|
|
188
|
+
const names = [];
|
|
189
|
+
for (const n of raw.mtlsSecrets) {
|
|
190
|
+
if (typeof n !== "string" || !MTLS_SECRET_NAME_PATTERN.test(n)) {
|
|
191
|
+
return fail(`manifest.mtlsSecrets entry ${JSON.stringify(n)} must match ${MTLS_SECRET_NAME_PATTERN}`);
|
|
192
|
+
}
|
|
193
|
+
names.push(n);
|
|
194
|
+
}
|
|
195
|
+
if (new Set(names).size !== names.length)
|
|
196
|
+
return fail("manifest.mtlsSecrets has duplicate names");
|
|
197
|
+
mtlsSecrets = names;
|
|
198
|
+
}
|
|
199
|
+
if (declaresMtls && (!mtlsSecrets || mtlsSecrets.length === 0)) {
|
|
200
|
+
return fail("manifest declares secrets.mtls but mtlsSecrets is missing or empty");
|
|
201
|
+
}
|
|
202
|
+
if (!declaresMtls && mtlsSecrets && mtlsSecrets.length > 0) {
|
|
203
|
+
return fail("manifest.mtlsSecrets is set but secrets.mtls capability is not declared");
|
|
204
|
+
}
|
|
205
|
+
// routePrefix (optional; default auto).
|
|
206
|
+
let routePrefix;
|
|
207
|
+
if (raw.routePrefix !== undefined) {
|
|
208
|
+
const rp = validateRoutePrefix(raw.routePrefix);
|
|
209
|
+
if ("error" in rp)
|
|
210
|
+
return fail(`manifest.${rp.error}`);
|
|
211
|
+
routePrefix = rp;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
manifest: {
|
|
216
|
+
apiVersion: raw.apiVersion,
|
|
217
|
+
backendEntry: raw.backendEntry,
|
|
218
|
+
...(raw.frontendEntry !== undefined ? { frontendEntry: raw.frontendEntry } : {}),
|
|
219
|
+
capabilities,
|
|
220
|
+
...(outboundHosts !== undefined ? { outboundHosts } : {}),
|
|
221
|
+
...(schemaPrefix !== undefined ? { schemaPrefix } : {}),
|
|
222
|
+
...(mtlsSecrets !== undefined ? { mtlsSecrets } : {}),
|
|
223
|
+
...(routePrefix !== undefined ? { routePrefix } : {}),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Validate the operator policy file (or the shipped builtins policy). The
|
|
229
|
+
* policy file is operator-owned config: a malformed file is a hard error
|
|
230
|
+
* (throws PolicyViolationError) rather than a per-plugin skip. Strict:
|
|
231
|
+
* unknown per-entry keys are rejected (mirrors the manifest stance).
|
|
232
|
+
*/
|
|
233
|
+
export function validatePolicy(raw, sourceLabel = "policy") {
|
|
234
|
+
if (!isPlainObject(raw)) {
|
|
235
|
+
throw new PolicyViolationError(`${sourceLabel}: file is not a JSON object`);
|
|
236
|
+
}
|
|
237
|
+
if (raw.policy_version !== "1") {
|
|
238
|
+
throw new PolicyViolationError(`${sourceLabel}: policy_version must be "1", got ${JSON.stringify(raw.policy_version)}`);
|
|
239
|
+
}
|
|
240
|
+
const topUnknown = Object.keys(raw).filter((k) => k !== "policy_version" && k !== "plugins");
|
|
241
|
+
if (topUnknown.length) {
|
|
242
|
+
throw new PolicyViolationError(`${sourceLabel}: unknown top-level fields: ${topUnknown.join(", ")}`);
|
|
243
|
+
}
|
|
244
|
+
if (!isPlainObject(raw.plugins)) {
|
|
245
|
+
throw new PolicyViolationError(`${sourceLabel}: "plugins" must be an object keyed by package name`);
|
|
246
|
+
}
|
|
247
|
+
const plugins = {};
|
|
248
|
+
for (const [name, entryRaw] of Object.entries(raw.plugins)) {
|
|
249
|
+
plugins[name] = validatePolicyEntry(name, entryRaw, sourceLabel);
|
|
250
|
+
}
|
|
251
|
+
return { policy_version: "1", plugins };
|
|
252
|
+
}
|
|
253
|
+
function validatePolicyEntry(name, raw, sourceLabel) {
|
|
254
|
+
const bad = (msg) => {
|
|
255
|
+
throw new PolicyViolationError(`${sourceLabel}: plugin "${name}": ${msg}`);
|
|
256
|
+
};
|
|
257
|
+
if (!isPlainObject(raw))
|
|
258
|
+
return bad("entry is not an object");
|
|
259
|
+
const unknown = Object.keys(raw).filter((k) => !POLICY_ENTRY_ALLOWED_KEYS.has(k));
|
|
260
|
+
if (unknown.length)
|
|
261
|
+
bad(`unknown fields: ${unknown.join(", ")}`);
|
|
262
|
+
if (typeof raw.version !== "string" || !raw.version)
|
|
263
|
+
bad("version must be a non-empty string");
|
|
264
|
+
if (typeof raw.approved_hash_sha256 !== "string" || !raw.approved_hash_sha256) {
|
|
265
|
+
bad("approved_hash_sha256 must be a non-empty string");
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(raw.approved_capabilities))
|
|
268
|
+
bad("approved_capabilities must be an array");
|
|
269
|
+
const caps = [];
|
|
270
|
+
for (const c of raw.approved_capabilities) {
|
|
271
|
+
if (typeof c !== "string" || !isPluginCapability(c))
|
|
272
|
+
bad(`approved_capabilities has unknown capability ${JSON.stringify(c)}`);
|
|
273
|
+
caps.push(c);
|
|
274
|
+
}
|
|
275
|
+
if (!Array.isArray(raw.approved_outbound_hosts))
|
|
276
|
+
bad("approved_outbound_hosts must be an array");
|
|
277
|
+
const hosts = [];
|
|
278
|
+
for (const h of raw.approved_outbound_hosts) {
|
|
279
|
+
if (typeof h !== "string")
|
|
280
|
+
bad("approved_outbound_hosts entries must be strings");
|
|
281
|
+
hosts.push(normalizeHostPattern(h));
|
|
282
|
+
}
|
|
283
|
+
if (raw.approved_frontend !== undefined && typeof raw.approved_frontend !== "boolean") {
|
|
284
|
+
bad("approved_frontend must be a boolean when present");
|
|
285
|
+
}
|
|
286
|
+
for (const optStr of ["approved_at", "approved_by", "approval_reason"]) {
|
|
287
|
+
if (raw[optStr] !== undefined && typeof raw[optStr] !== "string")
|
|
288
|
+
bad(`${optStr} must be a string when present`);
|
|
289
|
+
}
|
|
290
|
+
// mtls_secrets: optional map of logical name -> { cert, key, passphraseEnv? }
|
|
291
|
+
// host file paths. Validated strictly so a malformed mapping fails the whole
|
|
292
|
+
// policy file rather than silently dropping a cert at runtime.
|
|
293
|
+
let mtlsSecrets;
|
|
294
|
+
if (raw.mtls_secrets !== undefined) {
|
|
295
|
+
if (!isPlainObject(raw.mtls_secrets))
|
|
296
|
+
bad("mtls_secrets must be an object keyed by logical name");
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const [secretName, filesRaw] of Object.entries(raw.mtls_secrets)) {
|
|
299
|
+
if (!MTLS_SECRET_NAME_PATTERN.test(secretName))
|
|
300
|
+
bad(`mtls_secrets name "${secretName}" must match ${MTLS_SECRET_NAME_PATTERN}`);
|
|
301
|
+
if (!isPlainObject(filesRaw))
|
|
302
|
+
bad(`mtls_secrets["${secretName}"] must be an object`);
|
|
303
|
+
const files = filesRaw;
|
|
304
|
+
const fileUnknown = Object.keys(files).filter((k) => k !== "cert" && k !== "key" && k !== "ca" && k !== "passphraseEnv");
|
|
305
|
+
if (fileUnknown.length)
|
|
306
|
+
bad(`mtls_secrets["${secretName}"] has unknown fields: ${fileUnknown.join(", ")}`);
|
|
307
|
+
if (typeof files.cert !== "string" || !files.cert)
|
|
308
|
+
bad(`mtls_secrets["${secretName}"].cert must be a non-empty path string`);
|
|
309
|
+
if (typeof files.key !== "string" || !files.key)
|
|
310
|
+
bad(`mtls_secrets["${secretName}"].key must be a non-empty path string`);
|
|
311
|
+
if (files.ca !== undefined && (typeof files.ca !== "string" || !files.ca)) {
|
|
312
|
+
bad(`mtls_secrets["${secretName}"].ca must be a non-empty path string when present`);
|
|
313
|
+
}
|
|
314
|
+
if (files.passphraseEnv !== undefined && (typeof files.passphraseEnv !== "string" || !files.passphraseEnv)) {
|
|
315
|
+
bad(`mtls_secrets["${secretName}"].passphraseEnv must be a non-empty string when present`);
|
|
316
|
+
}
|
|
317
|
+
out[secretName] = {
|
|
318
|
+
cert: files.cert,
|
|
319
|
+
key: files.key,
|
|
320
|
+
...(files.ca !== undefined ? { ca: files.ca } : {}),
|
|
321
|
+
...(files.passphraseEnv !== undefined ? { passphraseEnv: files.passphraseEnv } : {}),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
mtlsSecrets = out;
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
version: raw.version,
|
|
328
|
+
approved_hash_sha256: raw.approved_hash_sha256,
|
|
329
|
+
approved_capabilities: caps,
|
|
330
|
+
approved_outbound_hosts: hosts,
|
|
331
|
+
...(raw.approved_frontend !== undefined ? { approved_frontend: raw.approved_frontend } : {}),
|
|
332
|
+
...(mtlsSecrets !== undefined ? { mtls_secrets: mtlsSecrets } : {}),
|
|
333
|
+
...(raw.approved_at !== undefined ? { approved_at: raw.approved_at } : {}),
|
|
334
|
+
...(raw.approved_by !== undefined ? { approved_by: raw.approved_by } : {}),
|
|
335
|
+
...(raw.approval_reason !== undefined ? { approval_reason: raw.approval_reason } : {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=manifest-validate.js.map
|
package/manifest.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { PluginCapability } from "./capabilities.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where the plugin's routes live in the URL space.
|
|
4
|
+
* - `auto` (default): mount under a real Fastify child scope at
|
|
5
|
+
* `/plugins/<name>`.
|
|
6
|
+
* - `absolute`: legacy escape hatch for externally-registered URLs (Slack
|
|
7
|
+
* OAuth callback, Telegram webhook). v1 registers the child with NO scope
|
|
8
|
+
* prefix — the plugin uses full paths as before — and the prefix string is
|
|
9
|
+
* deny-list-checked + audit-logged. `prefix` may be a single string or an
|
|
10
|
+
* array for plugins spanning multiple legacy roots (Slack/Telegram each
|
|
11
|
+
* own `/v1/integrations/<name>` AND `/api/<name>`).
|
|
12
|
+
*/
|
|
13
|
+
export type ManifestRoutePrefix = {
|
|
14
|
+
kind: "auto";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "absolute";
|
|
17
|
+
prefix: string | string[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The `vonzio` block in a plugin package's package.json. Strictly validated
|
|
21
|
+
* (unknown fields rejected) by validateManifest in manifest-validate.ts.
|
|
22
|
+
*/
|
|
23
|
+
export interface PluginManifest {
|
|
24
|
+
/**
|
|
25
|
+
* plugin-api semver this plugin targets. Loader refuses unless
|
|
26
|
+
* `plugin.major === core.major && plugin.minor <= core.minor`.
|
|
27
|
+
*/
|
|
28
|
+
apiVersion: string;
|
|
29
|
+
/** Path to the backend bundle, relative to package root. Must be a real
|
|
30
|
+
* file inside (a child of) the package root after realpath. */
|
|
31
|
+
backendEntry: string;
|
|
32
|
+
/** Path to the frontend bundle. Built-ins: unconditional. Externals: only
|
|
33
|
+
* when the operator policy sets `approved_frontend: true`. (Frontend
|
|
34
|
+
* bundling itself is PR 3J.2; the manifest field + gate ship here.) */
|
|
35
|
+
frontendEntry?: string;
|
|
36
|
+
/** Declared capabilities. Each must be a known PluginCapability. */
|
|
37
|
+
capabilities: PluginCapability[];
|
|
38
|
+
/** Required + non-empty iff `http.outbound` is declared. Hostname patterns
|
|
39
|
+
* only — schemes/ports/paths/userinfo rejected; single-label `*` glob. */
|
|
40
|
+
outboundHosts?: string[];
|
|
41
|
+
/** Required iff `db.scoped` or `db.access` is declared. DB-safe identifier
|
|
42
|
+
* matching ^[a-z][a-z0-9_]{1,30}$. */
|
|
43
|
+
schemaPrefix?: string;
|
|
44
|
+
/** Logical mTLS client-cert names the plugin needs (e.g. ["teller-client"]).
|
|
45
|
+
* Required + non-empty iff `secrets.mtls` is declared. Each must match
|
|
46
|
+
* {@link MTLS_SECRET_NAME_PATTERN}; the operator maps each to host file paths
|
|
47
|
+
* via `PolicyEntry.mtls_secrets`. The plugin resolves them with
|
|
48
|
+
* `ctx.secrets.mtls(name)`. See §5, §10. */
|
|
49
|
+
mtlsSecrets?: string[];
|
|
50
|
+
/** Route mounting strategy. Defaults to `{ kind: "auto" }` when absent. */
|
|
51
|
+
routePrefix?: ManifestRoutePrefix;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Operator-provisioned file paths backing one logical mTLS secret name. The
|
|
55
|
+
* paths point at PEM files on the host (mounted via Docker/k8s secret volumes);
|
|
56
|
+
* core reads them server-side and never exposes the bytes to the plugin.
|
|
57
|
+
*/
|
|
58
|
+
export interface MtlsSecretFiles {
|
|
59
|
+
/** Path to the PEM client certificate file on the host. */
|
|
60
|
+
cert: string;
|
|
61
|
+
/** Path to the PEM private-key file on the host. */
|
|
62
|
+
key: string;
|
|
63
|
+
/** Optional path to a PEM CA bundle to trust the SERVER's certificate. Omit
|
|
64
|
+
* for endpoints with a publicly-trusted cert (e.g. api.teller.io); set it
|
|
65
|
+
* for private mTLS endpoints that present a private server CA. */
|
|
66
|
+
ca?: string;
|
|
67
|
+
/** Optional env var NAME holding the key passphrase (never the passphrase
|
|
68
|
+
* itself — the policy file may be committed). */
|
|
69
|
+
passphraseEnv?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Source of a loaded plugin. Drives the external-only validation rules. */
|
|
72
|
+
export type PluginSource = "builtin" | "external";
|
|
73
|
+
/**
|
|
74
|
+
* One operator-policy entry — the operator's GRANT against a plugin's
|
|
75
|
+
* manifest REQUEST. Strictly validated (unknown fields rejected) by
|
|
76
|
+
* validatePolicy. See §4.
|
|
77
|
+
*/
|
|
78
|
+
export interface PolicyEntry {
|
|
79
|
+
/** Plugin version the operator approved. Compared to the installed
|
|
80
|
+
* package version (unless TRACK_VERSIONS=loose). */
|
|
81
|
+
version: string;
|
|
82
|
+
/** SHA-256 of the package directory (excl. node_modules) at approval. */
|
|
83
|
+
approved_hash_sha256: string;
|
|
84
|
+
/** Manifest.capabilities must be a subset of this. */
|
|
85
|
+
approved_capabilities: PluginCapability[];
|
|
86
|
+
/** Manifest.outboundHosts must be a subset of this. May be empty. */
|
|
87
|
+
approved_outbound_hosts: string[];
|
|
88
|
+
/** Operator grant for the plugin's frontend to be bundled into the
|
|
89
|
+
* dashboard. Default false when absent. */
|
|
90
|
+
approved_frontend?: boolean;
|
|
91
|
+
/** Maps each `manifest.mtlsSecrets` name the operator provisioned to its host
|
|
92
|
+
* PEM file paths. Every name the manifest declares must appear here, else the
|
|
93
|
+
* loader refuses (policy_mtls_secret_drift). */
|
|
94
|
+
mtls_secrets?: Record<string, MtlsSecretFiles>;
|
|
95
|
+
/** ISO-8601 approval timestamp. */
|
|
96
|
+
approved_at?: string;
|
|
97
|
+
/** Operator identity (e.g. email). */
|
|
98
|
+
approved_by?: string;
|
|
99
|
+
/** Free-form rationale (from `--reason`); required for dangerous combos. */
|
|
100
|
+
approval_reason?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The operator policy file (`vonzio-plugins.json`) and the shipped builtins
|
|
104
|
+
* policy (`vonzio-plugins.builtins.json`) share this shape.
|
|
105
|
+
*/
|
|
106
|
+
export interface OperatorPolicy {
|
|
107
|
+
/** Pinned to "1" for v1. */
|
|
108
|
+
policy_version: string;
|
|
109
|
+
/** Keyed by package name. */
|
|
110
|
+
plugins: Record<string, PolicyEntry>;
|
|
111
|
+
}
|
|
112
|
+
/** Keys allowed in the manifest `vonzio` block — used by the strict
|
|
113
|
+
* validator to reject typos / smuggled fields. */
|
|
114
|
+
export declare const MANIFEST_ALLOWED_KEYS: ReadonlySet<string>;
|
|
115
|
+
/** Keys allowed in one operator-policy entry. */
|
|
116
|
+
export declare const POLICY_ENTRY_ALLOWED_KEYS: ReadonlySet<string>;
|
|
117
|
+
/** DB-safe schema-prefix identifier pattern (§3 step 14). */
|
|
118
|
+
export declare const SCHEMA_PREFIX_PATTERN: RegExp;
|
|
119
|
+
/** Logical mTLS secret name pattern: lowercase, digits, hyphens; ≤63 chars. */
|
|
120
|
+
export declare const MTLS_SECRET_NAME_PATTERN: RegExp;
|
|
121
|
+
//# sourceMappingURL=manifest.d.ts.map
|
package/manifest.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Static plugin manifest (the `vonzio` block in a plugin's package.json) and
|
|
2
|
+
// the operator policy file shapes. The loader reads + validates the manifest
|
|
3
|
+
// from disk BEFORE importing the plugin's entry point, and cross-checks it
|
|
4
|
+
// against the operator policy. See docs/PLUGIN_LOADER_SPEC.md §3, §4.
|
|
5
|
+
//
|
|
6
|
+
// These are the DECLARED shapes (plugin-author request + operator grant).
|
|
7
|
+
// The runtime `VonzioPlugin` default export (name, init, configSchema,
|
|
8
|
+
// migrations) is a separate object validated after import — see index.ts.
|
|
9
|
+
/** Keys allowed in the manifest `vonzio` block — used by the strict
|
|
10
|
+
* validator to reject typos / smuggled fields. */
|
|
11
|
+
export const MANIFEST_ALLOWED_KEYS = new Set([
|
|
12
|
+
"apiVersion",
|
|
13
|
+
"backendEntry",
|
|
14
|
+
"frontendEntry",
|
|
15
|
+
"capabilities",
|
|
16
|
+
"outboundHosts",
|
|
17
|
+
"schemaPrefix",
|
|
18
|
+
"mtlsSecrets",
|
|
19
|
+
"routePrefix",
|
|
20
|
+
]);
|
|
21
|
+
/** Keys allowed in one operator-policy entry. */
|
|
22
|
+
export const POLICY_ENTRY_ALLOWED_KEYS = new Set([
|
|
23
|
+
"version",
|
|
24
|
+
"approved_hash_sha256",
|
|
25
|
+
"approved_capabilities",
|
|
26
|
+
"approved_outbound_hosts",
|
|
27
|
+
"approved_frontend",
|
|
28
|
+
"mtls_secrets",
|
|
29
|
+
"approved_at",
|
|
30
|
+
"approved_by",
|
|
31
|
+
"approval_reason",
|
|
32
|
+
]);
|
|
33
|
+
/** DB-safe schema-prefix identifier pattern (§3 step 14). */
|
|
34
|
+
export const SCHEMA_PREFIX_PATTERN = /^[a-z][a-z0-9_]{1,30}$/;
|
|
35
|
+
/** Logical mTLS secret name pattern: lowercase, digits, hyphens; ≤63 chars. */
|
|
36
|
+
export const MTLS_SECRET_NAME_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
|
37
|
+
//# sourceMappingURL=manifest.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vonzio/plugin-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Plugin contract for vonzio. Defines the types + helpers a plugin author implements; defines what core-server's loader and bus implement. Stable surface: breaking changes require a major bump.",
|
|
6
|
+
"license": "AGPL-3.0-or-later",
|
|
7
|
+
"homepage": "https://vonzio.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/vonzio/vonzio.git",
|
|
11
|
+
"directory": "packages/plugin-api"
|
|
12
|
+
},
|
|
13
|
+
"main": "./index.js",
|
|
14
|
+
"types": "./index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./index.d.ts",
|
|
18
|
+
"import": "./index.js"
|
|
19
|
+
},
|
|
20
|
+
"./frontend": {
|
|
21
|
+
"types": "./frontend.d.ts",
|
|
22
|
+
"import": "./frontend.js"
|
|
23
|
+
},
|
|
24
|
+
"./policy": {
|
|
25
|
+
"types": "./policy.d.ts",
|
|
26
|
+
"import": "./policy.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@vonzio/shared": "^0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"fastify": "^5.0.0",
|
|
34
|
+
"react": "^19.0.0",
|
|
35
|
+
"zod": "^3.0.0 || ^4.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"react": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"**/*.js",
|
|
44
|
+
"**/*.d.ts"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|