@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.
@@ -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
+ }