@muninmd/munin-sdk 0.0.0-stage → 1.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 +21 -0
- package/README.md +315 -2
- package/pack-plugin.d.mts +4 -0
- package/pack-plugin.mjs +67 -0
- package/package.json +62 -4
- package/plugin-api.d.ts +779 -0
- package/plugin-package.d.ts +121 -0
- package/plugin-package.js +214 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a plugin package is, and the one rule for accepting it.
|
|
3
|
+
*
|
|
4
|
+
* A package is a single JSON file (`.mnp`): a manifest and the plugin's text
|
|
5
|
+
* files. One file because it has to mean the same thing on the desktop and in
|
|
6
|
+
* the Android WebView, where there is no `fs` and no unzip — and because a
|
|
7
|
+
* single file is installed whole or not at all, never half-unpacked.
|
|
8
|
+
*
|
|
9
|
+
* This module is the only place that decides whether a package is well formed.
|
|
10
|
+
* The app runs it when it installs, and the registry service runs a copy of it
|
|
11
|
+
* when an author publishes, so a package one side would refuse never reaches
|
|
12
|
+
* the other. The copy is checked against the same fixtures
|
|
13
|
+
* (`__tests__/fixtures/plugin-package-cases.json`) on both sides.
|
|
14
|
+
*
|
|
15
|
+
* Pure but for {@link sha256Hex}, which uses Web Crypto — present in the
|
|
16
|
+
* Electron renderer, the Android WebView and Node alike.
|
|
17
|
+
*/
|
|
18
|
+
/** What a plugin may ask to do. Disclosure to the person, not a sandbox. */
|
|
19
|
+
export type Permission = 'vault:read' | 'vault:write' | 'network'
|
|
20
|
+
export declare const PERMISSIONS: readonly Permission[]
|
|
21
|
+
export type PluginPlatform = 'desktop' | 'android'
|
|
22
|
+
export declare const PLATFORMS: readonly PluginPlatform[]
|
|
23
|
+
/** The plugin API versions this app can hand a context for. */
|
|
24
|
+
export declare const SUPPORTED_API_VERSIONS: readonly number[]
|
|
25
|
+
/** A package over this size is refused before it is looked at. */
|
|
26
|
+
export declare const MAX_PACKAGE_BYTES: number
|
|
27
|
+
export interface PluginManifest {
|
|
28
|
+
/** `<author>.<name>`, the same for every version of the plugin. */
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
/** `major.minor.patch`. */
|
|
32
|
+
version: string
|
|
33
|
+
description: string
|
|
34
|
+
/** The author's handle: the part of `id` before the dot. */
|
|
35
|
+
author: string
|
|
36
|
+
/** The file in the package that holds the plugin's code. */
|
|
37
|
+
entry: string
|
|
38
|
+
apiVersion: number
|
|
39
|
+
/** The oldest app version the plugin runs on. */
|
|
40
|
+
minAppVersion: string
|
|
41
|
+
platforms: PluginPlatform[]
|
|
42
|
+
permissions: Permission[]
|
|
43
|
+
tags: string[]
|
|
44
|
+
/** False when the plugin cannot take everything it did back out of the app on
|
|
45
|
+
* disable (listeners on `window`, patched prototypes). The person is told a
|
|
46
|
+
* restart finishes the job. Defaults to true. */
|
|
47
|
+
unloadSafe: boolean
|
|
48
|
+
/** Plugins this one needs, by id, with the oldest version of each it works
|
|
49
|
+
* with. They are switched on before it and off after it. Empty when none. */
|
|
50
|
+
dependencies: Record<string, string>
|
|
51
|
+
}
|
|
52
|
+
/** A plugin may need this many others at most. */
|
|
53
|
+
export declare const MAX_DEPENDENCIES = 16
|
|
54
|
+
export interface PluginPackage {
|
|
55
|
+
manifest: PluginManifest
|
|
56
|
+
/** Path inside the package → UTF-8 text. */
|
|
57
|
+
files: Record<string, string>
|
|
58
|
+
}
|
|
59
|
+
export type PackageErrorCode =
|
|
60
|
+
| 'package-too-large'
|
|
61
|
+
| 'package-not-json'
|
|
62
|
+
| 'manifest-missing'
|
|
63
|
+
| 'manifest-invalid'
|
|
64
|
+
| 'entry-missing'
|
|
65
|
+
| 'files-invalid'
|
|
66
|
+
export interface PackageError {
|
|
67
|
+
code: PackageErrorCode
|
|
68
|
+
/** Which rule, in words an author can act on. */
|
|
69
|
+
message: string
|
|
70
|
+
}
|
|
71
|
+
export type ParsedPackage =
|
|
72
|
+
| {
|
|
73
|
+
ok: true
|
|
74
|
+
pkg: PluginPackage
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
ok: false
|
|
78
|
+
error: PackageError
|
|
79
|
+
}
|
|
80
|
+
/** An author handle: what registers with the registry and prefixes every id. */
|
|
81
|
+
export declare function isAuthorHandle(text: string): boolean
|
|
82
|
+
/** `a` is strictly above `b`, part by part as numbers (0.10.0 is above 0.9.0).
|
|
83
|
+
* Both are `major.minor.patch`; anything else is not above anything. */
|
|
84
|
+
export declare function versionAbove(a: string, b: string): boolean
|
|
85
|
+
/** The id's two halves, or null when it is not `<author>.<name>`. */
|
|
86
|
+
export declare function splitPluginId(id: string): {
|
|
87
|
+
author: string
|
|
88
|
+
name: string
|
|
89
|
+
} | null
|
|
90
|
+
/**
|
|
91
|
+
* Read the text of a package and decide whether to accept it. Total: never
|
|
92
|
+
* throws, and a package that breaks any rule is refused whole with the rule
|
|
93
|
+
* named, so an author sees what to fix and nothing is kept half-way.
|
|
94
|
+
*
|
|
95
|
+
* Unknown manifest fields are ignored rather than refused, so a newer author
|
|
96
|
+
* toolchain does not break packages for an older app.
|
|
97
|
+
*/
|
|
98
|
+
export declare function parsePackage(text: string): ParsedPackage
|
|
99
|
+
export type Incompatibility = 'platform' | 'app-version' | 'api-version'
|
|
100
|
+
export interface Host {
|
|
101
|
+
platform: PluginPlatform
|
|
102
|
+
appVersion: string
|
|
103
|
+
apiVersions?: readonly number[]
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Why a plugin cannot run here, or null when it can. Checked on the way in (so
|
|
107
|
+
* an incompatible plugin is not installed from the catalog) and again at enable
|
|
108
|
+
* time (the app may have been downgraded, or the package sideloaded).
|
|
109
|
+
*/
|
|
110
|
+
export declare function incompatibility(
|
|
111
|
+
manifest: PluginManifest,
|
|
112
|
+
host: Host
|
|
113
|
+
): Incompatibility | null
|
|
114
|
+
/** Lower-case hex SHA-256 of a string's UTF-8 bytes. */
|
|
115
|
+
export declare function sha256Hex(text: string): Promise<string>
|
|
116
|
+
/**
|
|
117
|
+
* The checksum remembered at install time and compared before every load. It
|
|
118
|
+
* covers the whole package text as the registry served it, so any edit to any
|
|
119
|
+
* file — or to the manifest — changes it.
|
|
120
|
+
*/
|
|
121
|
+
export declare function packageDigest(packageText: string): Promise<string>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a plugin package is, and the one rule for accepting it.
|
|
3
|
+
*
|
|
4
|
+
* A package is a single JSON file (`.mnp`): a manifest and the plugin's text
|
|
5
|
+
* files. One file because it has to mean the same thing on the desktop and in
|
|
6
|
+
* the Android WebView, where there is no `fs` and no unzip — and because a
|
|
7
|
+
* single file is installed whole or not at all, never half-unpacked.
|
|
8
|
+
*
|
|
9
|
+
* This module is the only place that decides whether a package is well formed.
|
|
10
|
+
* The app runs it when it installs, and the registry service runs a copy of it
|
|
11
|
+
* when an author publishes, so a package one side would refuse never reaches
|
|
12
|
+
* the other. The copy is checked against the same fixtures
|
|
13
|
+
* (`__tests__/fixtures/plugin-package-cases.json`) on both sides.
|
|
14
|
+
*
|
|
15
|
+
* Pure but for {@link sha256Hex}, which uses Web Crypto — present in the
|
|
16
|
+
* Electron renderer, the Android WebView and Node alike.
|
|
17
|
+
*/
|
|
18
|
+
export const PERMISSIONS = ['vault:read', 'vault:write', 'network']
|
|
19
|
+
export const PLATFORMS = ['desktop', 'android']
|
|
20
|
+
/** The plugin API versions this app can hand a context for. */
|
|
21
|
+
export const SUPPORTED_API_VERSIONS = [1]
|
|
22
|
+
/** A package over this size is refused before it is looked at. */
|
|
23
|
+
export const MAX_PACKAGE_BYTES = 2 * 1024 * 1024
|
|
24
|
+
/** A plugin may need this many others at most. */
|
|
25
|
+
export const MAX_DEPENDENCIES = 16
|
|
26
|
+
// ---- Rules ------------------------------------------------------------------
|
|
27
|
+
const AUTHOR = /^[a-z0-9][a-z0-9-]{1,29}$/
|
|
28
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
29
|
+
const VERSION = /^\d+\.\d+\.\d+$/
|
|
30
|
+
const FILE_PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/
|
|
31
|
+
const TAG = /^[a-z0-9][a-z0-9-]{0,23}$/
|
|
32
|
+
/** An author handle: what registers with the registry and prefixes every id. */
|
|
33
|
+
export function isAuthorHandle(text) {
|
|
34
|
+
return AUTHOR.test(text)
|
|
35
|
+
}
|
|
36
|
+
/** `a` is strictly above `b`, part by part as numbers (0.10.0 is above 0.9.0).
|
|
37
|
+
* Both are `major.minor.patch`; anything else is not above anything. */
|
|
38
|
+
export function versionAbove(a, b) {
|
|
39
|
+
if (!VERSION.test(a) || !VERSION.test(b)) return false
|
|
40
|
+
const pa = a.split('.').map(Number)
|
|
41
|
+
const pb = b.split('.').map(Number)
|
|
42
|
+
for (let i = 0; i < 3; i++) {
|
|
43
|
+
if (pa[i] !== pb[i]) return pa[i] > pb[i]
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
/** The id's two halves, or null when it is not `<author>.<name>`. */
|
|
48
|
+
export function splitPluginId(id) {
|
|
49
|
+
const dot = id.indexOf('.')
|
|
50
|
+
if (dot === -1) return null
|
|
51
|
+
const author = id.slice(0, dot)
|
|
52
|
+
const name = id.slice(dot + 1)
|
|
53
|
+
return AUTHOR.test(author) && NAME.test(name) ? { author, name } : null
|
|
54
|
+
}
|
|
55
|
+
function fail(code, message) {
|
|
56
|
+
return { ok: false, error: { code, message } }
|
|
57
|
+
}
|
|
58
|
+
function asRecord(value) {
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null
|
|
60
|
+
}
|
|
61
|
+
function textField(m, key, max) {
|
|
62
|
+
const value = m[key]
|
|
63
|
+
return typeof value === 'string' && value.trim() !== '' && value.length <= max ? value : null
|
|
64
|
+
}
|
|
65
|
+
function subsetOf(value, allowed) {
|
|
66
|
+
if (!Array.isArray(value)) return null
|
|
67
|
+
const out = []
|
|
68
|
+
for (const item of value) {
|
|
69
|
+
if (typeof item !== 'string' || !allowed.includes(item)) return null
|
|
70
|
+
if (!out.includes(item)) out.push(item)
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
function parseManifest(raw) {
|
|
75
|
+
const m = asRecord(raw)
|
|
76
|
+
if (!m) return { problem: 'the manifest is not an object' }
|
|
77
|
+
const id = textField(m, 'id', 80)
|
|
78
|
+
const parts = id ? splitPluginId(id) : null
|
|
79
|
+
if (!id || !parts)
|
|
80
|
+
return { problem: 'id must look like "author.name" (lower case, digits, dashes)' }
|
|
81
|
+
const author = textField(m, 'author', 30)
|
|
82
|
+
if (author !== parts.author) return { problem: 'author must be the part of id before the dot' }
|
|
83
|
+
const name = textField(m, 'name', 60)
|
|
84
|
+
if (!name) return { problem: 'name is required (up to 60 characters)' }
|
|
85
|
+
const description = textField(m, 'description', 280)
|
|
86
|
+
if (!description) return { problem: 'description is required (up to 280 characters)' }
|
|
87
|
+
const version = textField(m, 'version', 20)
|
|
88
|
+
if (!version || !VERSION.test(version)) return { problem: 'version must be major.minor.patch' }
|
|
89
|
+
const minAppVersion = textField(m, 'minAppVersion', 20)
|
|
90
|
+
if (!minAppVersion || !VERSION.test(minAppVersion)) {
|
|
91
|
+
return { problem: 'minAppVersion must be major.minor.patch' }
|
|
92
|
+
}
|
|
93
|
+
const entry = textField(m, 'entry', 101)
|
|
94
|
+
if (!entry || !FILE_PATH.test(entry) || entry.includes('..')) {
|
|
95
|
+
return { problem: 'entry must be a plain file path inside the package' }
|
|
96
|
+
}
|
|
97
|
+
const apiVersion = m.apiVersion
|
|
98
|
+
if (typeof apiVersion !== 'number' || !Number.isInteger(apiVersion) || apiVersion < 1) {
|
|
99
|
+
return { problem: 'apiVersion must be a whole number from 1' }
|
|
100
|
+
}
|
|
101
|
+
const platforms = subsetOf(m.platforms, PLATFORMS)
|
|
102
|
+
if (!platforms || platforms.length === 0) {
|
|
103
|
+
return { problem: 'platforms must list "desktop" and/or "android"' }
|
|
104
|
+
}
|
|
105
|
+
const permissions = subsetOf(m.permissions ?? [], PERMISSIONS)
|
|
106
|
+
if (!permissions) return { problem: `permissions may only be: ${PERMISSIONS.join(', ')}` }
|
|
107
|
+
const rawTags = m.tags ?? []
|
|
108
|
+
if (!Array.isArray(rawTags) || rawTags.length > 8) return { problem: 'tags: up to 8' }
|
|
109
|
+
const tags = []
|
|
110
|
+
for (const tag of rawTags) {
|
|
111
|
+
if (typeof tag !== 'string' || !TAG.test(tag)) return { problem: `bad tag: ${String(tag)}` }
|
|
112
|
+
if (!tags.includes(tag)) tags.push(tag)
|
|
113
|
+
}
|
|
114
|
+
const unloadSafe = m.unloadSafe === undefined ? true : m.unloadSafe
|
|
115
|
+
if (typeof unloadSafe !== 'boolean') return { problem: 'unloadSafe must be true or false' }
|
|
116
|
+
const rawDeps = m.dependencies ?? {}
|
|
117
|
+
const depsRecord = asRecord(rawDeps)
|
|
118
|
+
if (!depsRecord) return { problem: 'dependencies must map plugin ids to versions' }
|
|
119
|
+
const depIds = Object.keys(depsRecord)
|
|
120
|
+
if (depIds.length > MAX_DEPENDENCIES) {
|
|
121
|
+
return { problem: `dependencies: up to ${MAX_DEPENDENCIES}` }
|
|
122
|
+
}
|
|
123
|
+
const dependencies = {}
|
|
124
|
+
for (const dep of depIds) {
|
|
125
|
+
if (!splitPluginId(dep)) return { problem: `dependency "${dep}" is not a plugin id` }
|
|
126
|
+
if (dep === id) return { problem: 'a plugin cannot depend on itself' }
|
|
127
|
+
const min = depsRecord[dep]
|
|
128
|
+
if (typeof min !== 'string' || !VERSION.test(min)) {
|
|
129
|
+
return { problem: `dependency "${dep}" must name a version, major.minor.patch` }
|
|
130
|
+
}
|
|
131
|
+
dependencies[dep] = min
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
manifest: {
|
|
135
|
+
id,
|
|
136
|
+
name,
|
|
137
|
+
version,
|
|
138
|
+
description,
|
|
139
|
+
author,
|
|
140
|
+
entry,
|
|
141
|
+
apiVersion,
|
|
142
|
+
minAppVersion,
|
|
143
|
+
platforms,
|
|
144
|
+
permissions,
|
|
145
|
+
tags,
|
|
146
|
+
unloadSafe,
|
|
147
|
+
dependencies
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read the text of a package and decide whether to accept it. Total: never
|
|
153
|
+
* throws, and a package that breaks any rule is refused whole with the rule
|
|
154
|
+
* named, so an author sees what to fix and nothing is kept half-way.
|
|
155
|
+
*
|
|
156
|
+
* Unknown manifest fields are ignored rather than refused, so a newer author
|
|
157
|
+
* toolchain does not break packages for an older app.
|
|
158
|
+
*/
|
|
159
|
+
export function parsePackage(text) {
|
|
160
|
+
if (new TextEncoder().encode(text).length > MAX_PACKAGE_BYTES) {
|
|
161
|
+
return fail('package-too-large', `a package is at most ${MAX_PACKAGE_BYTES} bytes`)
|
|
162
|
+
}
|
|
163
|
+
let raw
|
|
164
|
+
try {
|
|
165
|
+
raw = JSON.parse(text)
|
|
166
|
+
} catch {
|
|
167
|
+
return fail('package-not-json', 'the package is not JSON')
|
|
168
|
+
}
|
|
169
|
+
const pkg = asRecord(raw)
|
|
170
|
+
if (!pkg || !('manifest' in pkg)) return fail('manifest-missing', 'the package has no manifest')
|
|
171
|
+
const parsed = parseManifest(pkg.manifest)
|
|
172
|
+
if ('problem' in parsed) return fail('manifest-invalid', parsed.problem)
|
|
173
|
+
const rawFiles = asRecord(pkg.files)
|
|
174
|
+
if (!rawFiles) return fail('files-invalid', 'files must be an object of path → text')
|
|
175
|
+
const files = {}
|
|
176
|
+
for (const [path, content] of Object.entries(rawFiles)) {
|
|
177
|
+
if (!FILE_PATH.test(path) || path.includes('..') || path.includes('//')) {
|
|
178
|
+
return fail('files-invalid', `bad file path: ${path}`)
|
|
179
|
+
}
|
|
180
|
+
if (typeof content !== 'string') return fail('files-invalid', `${path} is not text`)
|
|
181
|
+
files[path] = content
|
|
182
|
+
}
|
|
183
|
+
if (!(parsed.manifest.entry in files)) {
|
|
184
|
+
return fail('entry-missing', `entry "${parsed.manifest.entry}" is not among the files`)
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, pkg: { manifest: parsed.manifest, files } }
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Why a plugin cannot run here, or null when it can. Checked on the way in (so
|
|
190
|
+
* an incompatible plugin is not installed from the catalog) and again at enable
|
|
191
|
+
* time (the app may have been downgraded, or the package sideloaded).
|
|
192
|
+
*/
|
|
193
|
+
export function incompatibility(manifest, host) {
|
|
194
|
+
if (!manifest.platforms.includes(host.platform)) return 'platform'
|
|
195
|
+
if (versionAbove(manifest.minAppVersion, host.appVersion)) return 'app-version'
|
|
196
|
+
if (!(host.apiVersions ?? SUPPORTED_API_VERSIONS).includes(manifest.apiVersion)) {
|
|
197
|
+
return 'api-version'
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
// ---- Integrity --------------------------------------------------------------
|
|
202
|
+
/** Lower-case hex SHA-256 of a string's UTF-8 bytes. */
|
|
203
|
+
export async function sha256Hex(text) {
|
|
204
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
|
205
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The checksum remembered at install time and compared before every load. It
|
|
209
|
+
* covers the whole package text as the registry served it, so any edit to any
|
|
210
|
+
* file — or to the manifest — changes it.
|
|
211
|
+
*/
|
|
212
|
+
export function packageDigest(packageText) {
|
|
213
|
+
return sha256Hex(packageText)
|
|
214
|
+
}
|