@hoardodile/sdk-types 0.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.
Files changed (57) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +59 -0
  3. package/dist/image-variant.d.ts +90 -0
  4. package/dist/image-variant.js +115 -0
  5. package/dist/image-variant.js.map +1 -0
  6. package/dist/index.d.ts +772 -0
  7. package/dist/index.js +353 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/manifest-Dk6_xyNy.d.ts +204 -0
  10. package/dist/media-exts.d.ts +92 -0
  11. package/dist/media-exts.js +160 -0
  12. package/dist/media-exts.js.map +1 -0
  13. package/dist/plugin-asset-limits.d.ts +16 -0
  14. package/dist/plugin-asset-limits.js +13 -0
  15. package/dist/plugin-asset-limits.js.map +1 -0
  16. package/dist/plugin-capabilities.d.ts +64 -0
  17. package/dist/plugin-capabilities.js +37 -0
  18. package/dist/plugin-capabilities.js.map +1 -0
  19. package/dist/plugin.d.ts +49 -0
  20. package/dist/plugin.js +12 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/dist/resource.d.ts +26 -0
  23. package/dist/resource.js +9 -0
  24. package/dist/resource.js.map +1 -0
  25. package/dist/result.d.ts +47 -0
  26. package/dist/result.js +20 -0
  27. package/dist/result.js.map +1 -0
  28. package/dist/schema.d.ts +29 -0
  29. package/dist/schema.js +124 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/template.d.ts +67 -0
  32. package/dist/template.js +137 -0
  33. package/dist/template.js.map +1 -0
  34. package/dist/text-limits.d.ts +11 -0
  35. package/dist/text-limits.js +7 -0
  36. package/dist/text-limits.js.map +1 -0
  37. package/package.json +102 -0
  38. package/src/file-list.ts +14 -0
  39. package/src/image-variant.test.ts +140 -0
  40. package/src/image-variant.ts +234 -0
  41. package/src/index.ts +115 -0
  42. package/src/manifest.ts +186 -0
  43. package/src/media-exts.ts +245 -0
  44. package/src/plugin-asset-limits.ts +23 -0
  45. package/src/plugin-asset.ts +127 -0
  46. package/src/plugin-capabilities.ts +91 -0
  47. package/src/plugin-definition.test.ts +117 -0
  48. package/src/plugin-definition.ts +902 -0
  49. package/src/plugin.ts +54 -0
  50. package/src/read-range.ts +12 -0
  51. package/src/resource.ts +28 -0
  52. package/src/result.test.ts +64 -0
  53. package/src/result.ts +73 -0
  54. package/src/schema.ts +29 -0
  55. package/src/template.test.ts +116 -0
  56. package/src/template.ts +199 -0
  57. package/src/text-limits.ts +11 -0
@@ -0,0 +1,186 @@
1
+ import { z } from "zod"
2
+
3
+ /**
4
+ * Plugin manifest UUID (v4). Generated once when scaffolding a plugin
5
+ * (e.g. `crypto.randomUUID()`) and never reused across plugins — the
6
+ * server keys installed plugins by this id.
7
+ */
8
+ export const pluginManifestId = z.string().uuid()
9
+ export type PluginManifestId = z.infer<typeof pluginManifestId>
10
+
11
+ /**
12
+ * Declared plugin capabilities. Each flag gates the corresponding API
13
+ * surface: a plugin without `danmaku` gets no danmaku methods and the
14
+ * host enforces the permission at the capability guard, so a manifest
15
+ * that does not declare a capability cannot call it.
16
+ */
17
+ export const pluginPermissions = z.object({
18
+ /** Read/write the resource's source metadata. */
19
+ sourceMeta: z.boolean().default(false),
20
+ /** Produce and store search metadata facets. */
21
+ searchMeta: z.boolean().default(false),
22
+ /** Create/list danmaku for resources this plugin renders. */
23
+ danmaku: z.boolean().default(false),
24
+ /** Create/list messages for resources this plugin renders. */
25
+ message: z.boolean().default(false),
26
+ /** Produce content hashes for duplicate detection / image similarity. */
27
+ imageHashes: z.boolean().default(false),
28
+ /**
29
+ * List and extract archive (zip/tar/7z/…) entries. The only API
30
+ * surface with a write side effect (the host's extraction cache), so
31
+ * it is denied by default.
32
+ */
33
+ container: z.boolean().default(false),
34
+ /**
35
+ * The plugin asset vault: user-consented downloads into the plugin's
36
+ * own `vault/` directory plus the vault read/delete methods. Denied
37
+ * by default — every download needs this capability AND the user's
38
+ * per-request approval.
39
+ */
40
+ download: z.boolean().default(false),
41
+ })
42
+ export type PluginPermissions = z.infer<typeof pluginPermissions>
43
+
44
+ /**
45
+ * Label key → locale table: `{ "cover.open": { "en": "Open", "zh-CN": "打开" } }`.
46
+ * The host's template engine resolves `t('cover.open')` against the
47
+ * resource's locale from this map.
48
+ */
49
+ export const localeString = z.record(z.string(), z.string())
50
+
51
+ /** Icon reference: an asset path inside the plugin zip (`assets/icon.svg`). */
52
+ export const iconRef = z.string().min(1)
53
+
54
+ /**
55
+ * Corner template slot: a string rendered by the host's template engine
56
+ * over the resource scope. The engine supports `{{data.field}}` paths,
57
+ * pipes (`bytes`, `duration`, `number`, `inc`), comparisons
58
+ * (`eq`/`ne`/`gt`/`lt`/`gte`/`lte`), `if(cond, a, b)`, `join`,
59
+ * `t('key')` for i18n, `icon('Icon')`, `asset('path')`,
60
+ * `kind(...)`, and `searchKindIcons()` (the plugin's search kinds).
61
+ * Unknown expressions render as the empty string.
62
+ */
63
+ const templateValue = z.string()
64
+
65
+ /**
66
+ * Corner template slots for one content kind. Templates are rendered by
67
+ * the host's template engine over the resource's file list; supported
68
+ * directives include `{{data.field}}`, `{{duration(ms)}}`, `{{inc(n)}}`
69
+ * and `{{t('key')}}`.
70
+ */
71
+ const coverKindUi = z.object({
72
+ tl: z.array(templateValue).optional(),
73
+ tr: z.array(templateValue).optional(),
74
+ bl: z.array(templateValue).optional(),
75
+ br: z.array(templateValue).optional(),
76
+ })
77
+
78
+ /**
79
+ * Cover templates per content kind. A plugin declares the kinds it can
80
+ * produce; the host picks the block matching the resource's cover type
81
+ * (`image`/`video`/`audio`/`default`) and renders each corner as
82
+ * specified, or falls back to the default cover when no block matches.
83
+ */
84
+ const coverKindUiMap = z.object({
85
+ image: coverKindUi.optional(),
86
+ video: coverKindUi.optional(),
87
+ audio: coverKindUi.optional(),
88
+ default: coverKindUi.optional(),
89
+ })
90
+
91
+ /**
92
+ * A search facet kind: a named dimension with an icon, rendered as a
93
+ * facet group in the host's search UI. `key` becomes the facet key in
94
+ * the search metadata the plugin produces.
95
+ */
96
+ export const searchKind = z.object({
97
+ key: z.string().min(1),
98
+ /** i18n label key shown as the facet group's title. */
99
+ label: z.string().min(1),
100
+ /** Optional icon asset path in the plugin zip. */
101
+ icon: templateValue.optional(),
102
+ })
103
+ export type SearchKind = z.infer<typeof searchKind>
104
+
105
+ const searchUi = z.object({
106
+ kinds: z.array(searchKind),
107
+ })
108
+
109
+ const messageUi = z.object({
110
+ /**
111
+ * Template string for message anchor chip labels. Rendered by the
112
+ * host's template engine. Supports `{{data.field}}`, `{{duration(ms)}}`,
113
+ * `{{inc(n)}}`, `{{t('key')}}`, etc.
114
+ */
115
+ anchor: z.string().min(1).optional(),
116
+ })
117
+
118
+ /**
119
+ * Manifest-declared UI preferences. These shape how the host app
120
+ * presents the plugin's iframe without the plugin shipping any host
121
+ * integration code.
122
+ */
123
+ export const pluginManifestUi = z.object({
124
+ /**
125
+ * Preferred preview surface height (any CSS length, e.g. "85vh").
126
+ * Applied by both the resource detail page and the preview dialog.
127
+ */
128
+ height: z.string().min(1).optional(),
129
+ /**
130
+ * Preferred preview surface aspect ratio (e.g. "16/9"), capped by the
131
+ * host at 70vh. Intended for video-centric plugins; takes precedence
132
+ * over `height`. When neither is set the host falls back to 60vh.
133
+ */
134
+ aspect: z.string().min(1).optional(),
135
+ /**
136
+ * Cover template blocks per content kind. When present, the host
137
+ * renders the resource cover from the plugin's file templates
138
+ * instead of the built-in thumbnail pipeline.
139
+ */
140
+ card: coverKindUiMap.optional(),
141
+ /** Search facet kinds; enables the plugin's search integration. */
142
+ search: searchUi.optional(),
143
+ /**
144
+ * Anchor chip label template for messages; declares message-anchor
145
+ * support in the host UI.
146
+ */
147
+ message: messageUi.optional(),
148
+ /**
149
+ * Whether the plugin iframe inherits the host's app font (default true).
150
+ * Set to false for plugins that must render with their own fonts.
151
+ */
152
+ inheritFont: z.boolean().optional(),
153
+ })
154
+ export type PluginManifestUi = z.infer<typeof pluginManifestUi>
155
+ export type CoverKindUi = z.infer<typeof coverKindUi>
156
+ export type CoverKindUiMap = z.infer<typeof coverKindUiMap>
157
+
158
+ /**
159
+ * The plugin manifest contract — the single schema validated everywhere
160
+ * via its parse: the server at install time, the build CLI, and the
161
+ * workbench. A manifest lives at the zip root of a built plugin next to
162
+ * `main.js` and `index.html`.
163
+ */
164
+ export const pluginManifest = z.object({
165
+ id: pluginManifestId,
166
+ /** Display name shown in the plugins list and resource badges. */
167
+ name: z.string().min(1),
168
+ /** One-line description shown in the plugins list. */
169
+ description: z.string().min(1),
170
+ /** Icon asset path inside the plugin zip. */
171
+ icon: iconRef.optional(),
172
+ /** Semantic plugin version; shown to users on the plugin card. */
173
+ version: z.string().min(1),
174
+ /** Declared capabilities (see {@link pluginPermissions}). */
175
+ permissions: pluginPermissions,
176
+ /**
177
+ * Localized label tables: `{ labelKey: { locale: label } }`, e.g.
178
+ * `{ "cover.open": { "en": "Open", "zh-CN": "打开" } }` — labels are
179
+ * referenced from templates with `{{t('labelKey')}}` (see
180
+ * {@link localeString}).
181
+ */
182
+ i18n: z.record(z.string(), localeString).optional(),
183
+ /** UI preferences (see {@link pluginManifestUi}). */
184
+ ui: pluginManifestUi.optional(),
185
+ })
186
+ export type PluginManifest = z.infer<typeof pluginManifest>
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Canonical media-type knowledge: the extension sets, the extension →
3
+ * MIME table and the MIME → media-kind mapping shared by content
4
+ * plugins, the runtime host's sniffer and the server's classification
5
+ * pipeline.
6
+ *
7
+ * Extensions are a **hint**, never the verdict: `ResourceAPI.sniff`
8
+ * reads the file's magic bytes and only falls back to the tables here
9
+ * when the content carries no recognizable signature (text formats).
10
+ * The sets below therefore answer "which extensions do we expect to
11
+ * decode", not "what is this file".
12
+ *
13
+ * Lower-case, with leading dot — match the output of
14
+ * `path.extname(name).toLowerCase()`.
15
+ *
16
+ * Adding a new extension here widens classification everywhere at
17
+ * once. Before adding, verify:
18
+ * - sharp can extract width/height (image)
19
+ * - ffprobe can read width/height/duration (video)
20
+ * - ffprobe can read the stream/format metadata (audio), and the
21
+ * extension has an entry in `AUDIO_FFMPEG_INPUT_FORMAT`
22
+ * - the extension has an entry in {@link EXT_MIME}
23
+ * - the gallery plugin's transcode-required set reflects the format
24
+ * (browser-renderable originals stay native; HEIC/TIFF need the
25
+ * sharp preview pipeline)
26
+ */
27
+ export const IMAGE_EXTS: ReadonlySet<string> = new Set([
28
+ ".jpg",
29
+ ".jpeg",
30
+ ".png",
31
+ ".webp",
32
+ ".gif",
33
+ ".bmp",
34
+ ".avif",
35
+ ".heic",
36
+ ".heif",
37
+ ".tif",
38
+ ".tiff",
39
+ ".svg",
40
+ ".jp2",
41
+ ".j2k",
42
+ ".jpx",
43
+ ])
44
+
45
+ export const VIDEO_EXTS: ReadonlySet<string> = new Set([
46
+ ".mp4",
47
+ ".webm",
48
+ ".mov",
49
+ ".mkv",
50
+ ".m4v",
51
+ ".avi",
52
+ ".3gp",
53
+ ])
54
+
55
+ export const AUDIO_EXTS: ReadonlySet<string> = new Set([
56
+ ".mp3",
57
+ ".flac",
58
+ ".ogg",
59
+ ".m4a",
60
+ ".wav",
61
+ ".opus",
62
+ ".aac",
63
+ ])
64
+
65
+ /**
66
+ * Video containers ffmpeg can demux from a forward-only pipe (matroska,
67
+ * avi). ISO-BMFF files (.mp4/.mov/.m4v) keep their moov index at the end
68
+ * of the file, so a stream attempt on a zip-entry source is guaranteed to
69
+ * fail after burning a full probesize read — consumers (thumb pipeline,
70
+ * cover probing) send those straight to the materialized entry instead.
71
+ */
72
+ export const STREAMABLE_VIDEO_EXTS: ReadonlySet<string> = new Set([
73
+ ".webm",
74
+ ".mkv",
75
+ ".avi",
76
+ ])
77
+
78
+ /**
79
+ * ffmpeg `-f` container name for piped audio bytes (no filename hint).
80
+ * `.opus` files are Ogg containers; `.m4a` is ISO-BMFF, demuxed by the
81
+ * mp4 demuxer.
82
+ */
83
+ export const AUDIO_FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {
84
+ ".mp3": "mp3",
85
+ ".flac": "flac",
86
+ ".ogg": "ogg",
87
+ ".opus": "ogg",
88
+ ".wav": "wav",
89
+ ".m4a": "mp4",
90
+ ".aac": "aac",
91
+ }
92
+
93
+ /**
94
+ * ffmpeg `-f` container name keyed by **sniffed MIME type**, for piped
95
+ * sources that have no filename ffprobe could key off. Content-derived
96
+ * routing is what lets a mislabelled file still demux correctly, so
97
+ * this table — not the extension one — drives `ResourceAPI.probe`.
98
+ *
99
+ * Several spellings map to the same container because magic-byte
100
+ * matchers and IANA disagree on the canonical name (`audio/wav` vs
101
+ * `audio/x-wav`, `video/vnd.avi` vs `video/x-msvideo`).
102
+ */
103
+ export const MIME_FFMPEG_INPUT_FORMAT: Readonly<Record<string, string>> = {
104
+ "video/mp4": "mp4",
105
+ "video/quicktime": "mov",
106
+ "video/webm": "webm",
107
+ "video/x-matroska": "matroska",
108
+ "video/matroska": "matroska",
109
+ "video/vnd.avi": "avi",
110
+ "video/x-msvideo": "avi",
111
+ "video/avi": "avi",
112
+ "video/ogg": "ogg",
113
+ "audio/mpeg": "mp3",
114
+ "audio/mp3": "mp3",
115
+ "audio/flac": "flac",
116
+ "audio/x-flac": "flac",
117
+ "audio/ogg": "ogg",
118
+ "audio/opus": "ogg",
119
+ "audio/vorbis": "ogg",
120
+ "audio/wav": "wav",
121
+ "audio/x-wav": "wav",
122
+ "audio/vnd.wave": "wav",
123
+ "audio/wave": "wav",
124
+ "audio/mp4": "mp4",
125
+ "audio/x-m4a": "mp4",
126
+ "audio/aac": "aac",
127
+ "video/3gpp": "mp4",
128
+ "application/ogg": "ogg",
129
+ "application/x-matroska": "matroska",
130
+ "application/mp4": "mp4",
131
+ }
132
+
133
+ /**
134
+ * The audio mirror of {@link STREAMABLE_VIDEO_EXTS}: containers whose
135
+ * headers lead the file, so ffmpeg/ffprobe can demux them from a
136
+ * forward-only pipe. `.m4a` is ISO-BMFF with a trailing moov index, so
137
+ * it must be probed from a materialized (seekable) entry.
138
+ */
139
+ export const STREAMABLE_AUDIO_EXTS: ReadonlySet<string> = new Set([
140
+ ".mp3",
141
+ ".flac",
142
+ ".ogg",
143
+ ".opus",
144
+ ".wav",
145
+ ".aac",
146
+ ])
147
+
148
+ /**
149
+ * Media families a file can belong to. `other` covers everything the
150
+ * media pipeline does not decode (text, documents, archives, ...) — it
151
+ * is a real answer, not a failure.
152
+ */
153
+ export const MEDIA_KINDS = ["image", "video", "audio", "other"] as const
154
+
155
+ export type MediaKind = (typeof MEDIA_KINDS)[number]
156
+
157
+ /**
158
+ * Extension → canonical MIME type. Used as the *fallback* branch of
159
+ * content sniffing: magic-byte detection covers binary media, while
160
+ * text-based formats (`.txt`, `.md`, `.csv`, subtitles, ...) carry no
161
+ * signature and can only be named by their extension.
162
+ */
163
+ export const EXT_MIME: Readonly<Record<string, string>> = {
164
+ ".jpg": "image/jpeg",
165
+ ".jpeg": "image/jpeg",
166
+ ".png": "image/png",
167
+ ".webp": "image/webp",
168
+ ".gif": "image/gif",
169
+ ".bmp": "image/bmp",
170
+ ".avif": "image/avif",
171
+ ".heic": "image/heic",
172
+ ".heif": "image/heif",
173
+ ".tif": "image/tiff",
174
+ ".tiff": "image/tiff",
175
+ ".jp2": "image/jp2",
176
+ ".j2k": "image/jp2",
177
+ ".jpx": "image/jp2",
178
+ ".mp4": "video/mp4",
179
+ ".m4v": "video/mp4",
180
+ ".webm": "video/webm",
181
+ ".mov": "video/quicktime",
182
+ ".mkv": "video/x-matroska",
183
+ ".avi": "video/vnd.avi",
184
+ ".3gp": "video/3gpp",
185
+ ".mp3": "audio/mpeg",
186
+ ".flac": "audio/flac",
187
+ ".ogg": "audio/ogg",
188
+ ".opus": "audio/opus",
189
+ ".m4a": "audio/mp4",
190
+ ".wav": "audio/wav",
191
+ ".aac": "audio/aac",
192
+ ".txt": "text/plain",
193
+ ".md": "text/markdown",
194
+ ".csv": "text/csv",
195
+ ".json": "application/json",
196
+ ".xml": "text/xml",
197
+ ".html": "text/html",
198
+ ".htm": "text/html",
199
+ ".svg": "image/svg+xml",
200
+ ".srt": "application/x-subrip",
201
+ ".vtt": "text/vtt",
202
+ ".ass": "text/x-ssa",
203
+ ".epub": "application/epub+zip",
204
+ ".pdf": "application/pdf",
205
+ ".zip": "application/zip",
206
+ ".cbz": "application/vnd.comicbook+zip",
207
+ ".cbr": "application/vnd.comicbook-rar",
208
+ ".rar": "application/vnd.rar",
209
+ ".7z": "application/x-7z-compressed",
210
+ ".cb7": "application/x-7z-compressed",
211
+ ".tar": "application/x-tar",
212
+ ".cbt": "application/x-tar",
213
+ }
214
+
215
+ /**
216
+ * Container MIME types whose top-level type does not describe the
217
+ * payload. Ogg and Matroska carry audio *or* video, and `application/*`
218
+ * says nothing either way — the values here are the common case, and
219
+ * `ResourceAPI.probe` overrides them with the stream layout ffprobe
220
+ * actually reports.
221
+ */
222
+ export const MIME_KIND_OVERRIDES: Readonly<Record<string, MediaKind>> = {
223
+ "application/ogg": "audio",
224
+ "application/x-matroska": "video",
225
+ "application/mp4": "video",
226
+ }
227
+
228
+ /**
229
+ * Media family of a MIME type: the override table first, then the
230
+ * top-level type. Never throws — unknown types are `other`.
231
+ */
232
+ export function mimeToKind(mime: string): MediaKind {
233
+ const normalized = mime.toLowerCase()
234
+ const override = MIME_KIND_OVERRIDES[normalized]
235
+ if (override !== undefined) return override
236
+ if (normalized.startsWith("image/")) return "image"
237
+ if (normalized.startsWith("video/")) return "video"
238
+ if (normalized.startsWith("audio/")) return "audio"
239
+ return "other"
240
+ }
241
+
242
+ /** Canonical MIME type for an extension (leading dot), or `undefined`. */
243
+ export function extToMime(ext: string): string | undefined {
244
+ return EXT_MIME[ext.toLowerCase()]
245
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Plugin asset runtime limits — the constants shared by the host, the
3
+ * SDK validators and the tooling. Backed by the `./plugin-asset-limits`
4
+ * subpath (plugin-facing constants never export from the root entry).
5
+ */
6
+
7
+ /** Max length of a vault-relative `dest` (a plugin path is bounded, not arbitrary). */
8
+ export const PLUGIN_ASSET_DEST_MAX_LENGTH = 256
9
+
10
+ /** Max length of the optional human `reason` shown in the consent dialog. */
11
+ export const PLUGIN_ASSET_REASON_MAX_LENGTH = 200
12
+
13
+ /** Expected shape of an SRI-style sha256 pin: 64 lowercase hex characters. */
14
+ export const PLUGIN_ASSET_SHA256_PATTERN = /^[0-9a-f]{64}$/
15
+
16
+ /** The machine-readable asset error names, in contract order. */
17
+ export const PLUGIN_ASSET_ERROR_NAMES = [
18
+ "DENIED",
19
+ "UNAVAILABLE",
20
+ "POLICY",
21
+ ] as const
22
+
23
+ export type PluginAssetErrorName = (typeof PLUGIN_ASSET_ERROR_NAMES)[number]
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The plugin asset contract — the download / read / delete surface of a
3
+ * plugin's own "vault". The vault is a host-reserved namespace inside the
4
+ * plugin's installed directory (`<plugin-dir>/vault/`) that the host
5
+ * manages on the plugin's behalf: data lands there only through the
6
+ * user-consented download API, and nothing a plugin ships in its zip can
7
+ * ever be overwritten by downloading (see the vault-confined `dest`
8
+ * rules).
9
+ *
10
+ * Both sides of the plugin speak the same shapes: the server-side
11
+ * `ResourceAPI` (main.js hooks) and the iframe `WebPluginAPI` (render)
12
+ * call the same four methods with the same request/result vocabulary.
13
+ * All methods are gated by the manifest `download` permission and
14
+ * denied inside the sandbox when the manifest does not declare it.
15
+ *
16
+ * Error convention (fixed rule): **classification uses `Result`,
17
+ * API calls throw.** `detect` (and other classifiers) return a
18
+ * {@link Result}; every other API method rejects with an `Error` whose
19
+ * `name` carries the machine-readable code. Plugins branch on
20
+ * {@link isPluginAssetError} — never parse messages.
21
+ *
22
+ * Runtime limits live behind `@hoardodile/sdk-types/plugin-asset-limits`;
23
+ * this module exports the types and the error helpers only.
24
+ */
25
+ import type { PluginAssetErrorName } from "./plugin-asset-limits.ts"
26
+
27
+ export type { PluginAssetErrorName }
28
+
29
+ /**
30
+ * A download request: the plugin declares the plaintext URL, the vault
31
+ * destination, and (optionally) an integrity pin plus a reason for the
32
+ * consent dialog. `dest` is vault-relative only — it must resolve under
33
+ * `<plugin-dir>/vault/` and can never reach the plugin's own bundled
34
+ * files (`main.js`, `index.html`, `assets/`, ...).
35
+ */
36
+ export type PluginDownloadRequest = {
37
+ /** Absolute `http(s)` URL to fetch. Shown verbatim in the consent dialog. */
38
+ readonly url: string
39
+ /**
40
+ * Vault-relative destination path (`"runtime/live2d.min.js"`). The host
41
+ * resolves it inside the plugin vault and rejects absolute paths,
42
+ * `..` traversal, path separators crossing segments, and reserved
43
+ * names — before any network request is made.
44
+ */
45
+ readonly dest: string
46
+ /**
47
+ * Optional SRI-style integrity pin (64 lowercase hex chars). When
48
+ * present the host verifies the downloaded bytes against it and
49
+ * discards a mismatch, so a tampered or corrupted response can
50
+ * never be stored.
51
+ */
52
+ readonly sha256?: string
53
+ /** Optional short rationale shown in the consent dialog (plugin-authored copy). */
54
+ readonly reason?: string
55
+ }
56
+
57
+ /**
58
+ * Result of {@link PluginDownloadRequest}: the stored file's identity.
59
+ * `cached` is true when the destination already existed — the host
60
+ * answered from the vault without any dialog and without touching the
61
+ * network (downloads are "ensure present", never unconditional).
62
+ */
63
+ export type PluginDownloadResult = {
64
+ /** The vault-relative destination that was resolved. */
65
+ readonly path: string
66
+ readonly sizeBytes: number
67
+ /** sha256 of the stored bytes (host-computed, always present). */
68
+ readonly sha256: string
69
+ /** True when the file already existed and no consent/network was needed. */
70
+ readonly cached: boolean
71
+ }
72
+
73
+ /**
74
+ * Result of {@link ResourceAPI.deleteAsset} / `WebPluginAPI.deleteAsset`.
75
+ * Deletion is idempotent: removing nothing is not an error.
76
+ */
77
+ export type PluginAssetDeleteResult = {
78
+ /** True when a file was actually removed. */
79
+ readonly existed: boolean
80
+ }
81
+
82
+ /**
83
+ * Error thrown by the asset methods. The name survives both wire
84
+ * boundaries (worker IPC and the iframe postMessage bridge), so plugin
85
+ * code can branch on `err.name` without parsing messages:
86
+ *
87
+ * - `DENIED` — the user declined the consent dialog, or consent timed out.
88
+ * - `UNAVAILABLE` — this runtime has no consent channel (CLI, workbench,
89
+ * offline mock) or the server is in read-only archive mode.
90
+ * - `POLICY` — the host rejected the request before downloading:
91
+ * manifest lacks the `download` permission, the URL or `dest` is not
92
+ * allowed, the destination is a directory, or a quota would be
93
+ * exceeded. Also used for reserved-name conflicts.
94
+ *
95
+ * Transport/network failures keep their own error names (e.g. socket
96
+ * errors) and are not part of this vocabulary.
97
+ */
98
+ export class PluginAssetError extends Error {
99
+ constructor(
100
+ readonly code: PluginAssetErrorName,
101
+ message: string,
102
+ ) {
103
+ super(message)
104
+ this.name = code
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Narrow an asset error to a machine-readable name. Works across the
110
+ * RPC boundaries because both preserve `Error.name` (the worker IPC and
111
+ * the iframe bridge carry the name explicitly) — the instance is often
112
+ * lost in transit, so the check keys on the name alone.
113
+ */
114
+ export function isPluginAssetError(
115
+ err: unknown,
116
+ name: PluginAssetErrorName,
117
+ ): err is PluginAssetError {
118
+ return err instanceof Error && err.name === name
119
+ }
120
+
121
+ /** Build a `PluginAssetError` carrying the given machine-readable name. */
122
+ export function pluginAssetError(
123
+ name: PluginAssetErrorName,
124
+ message: string,
125
+ ): PluginAssetError {
126
+ return new PluginAssetError(name, message)
127
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The single permission→capability declaration: every manifest
3
+ * permission key, what it gates, and which runtime layers enforce it.
4
+ * Consumers read this table instead of hand-mirrored sets — a new
5
+ * permission is declared once here, and the compile-time coverage
6
+ * checks below make a missing declaration impossible.
7
+ *
8
+ * This module is pure TypeScript (no zod): it is type-driven off
9
+ * {@link PluginPermissions} and consumed by the host sandbox, the
10
+ * server domain and the tooling. Plugin bundles never import it.
11
+ */
12
+ import type { PluginPermissions } from "./manifest.ts"
13
+
14
+ export type PluginCapabilityGate = {
15
+ /** One-line contract description (mirrors the manifest schema docs). */
16
+ readonly description: string
17
+ /**
18
+ * ResourceAPI method names gated at the sandbox RPC boundary. Absent
19
+ * means the permission is enforced at the host/service layer only
20
+ * (meta hooks, web routes).
21
+ */
22
+ readonly sandboxMethods?: readonly string[]
23
+ }
24
+
25
+ /**
26
+ * The capability gates, keyed by the manifest permission key. The
27
+ * `satisfies` below fails to compile when a permission is declared on
28
+ * the manifest but missing here; the AssertTrue checks at the bottom
29
+ * cover the reverse direction.
30
+ */
31
+ export const PLUGIN_CAPABILITY_GATES = {
32
+ sourceMeta: {
33
+ description: "Read/write the resource's source metadata (sourceMeta hook).",
34
+ },
35
+ searchMeta: {
36
+ description: "Produce and store search metadata facets (searchMeta hook).",
37
+ },
38
+ danmaku: {
39
+ description: "Create/list danmaku for resources this plugin renders.",
40
+ },
41
+ message: {
42
+ description: "Create/list messages for resources this plugin renders.",
43
+ },
44
+ imageHashes: {
45
+ description:
46
+ "Produce content hashes for duplicate detection / image similarity.",
47
+ },
48
+ container: {
49
+ description:
50
+ "List and extract archive (zip/tar/7z/…) entries; the only API surface with a write side effect.",
51
+ sandboxMethods: ["listContainer", "extractArchive"],
52
+ },
53
+ download: {
54
+ description:
55
+ "The plugin asset vault: user-consented downloads into the plugin's own vault/ plus the vault read/delete methods; denied by default and per-download by the user.",
56
+ sandboxMethods: ["download", "statAsset", "readAsset", "deleteAsset"],
57
+ },
58
+ } as const satisfies Record<keyof PluginPermissions, PluginCapabilityGate>
59
+
60
+ export type PluginCapabilityKey = keyof typeof PLUGIN_CAPABILITY_GATES
61
+
62
+ // -- compile-time coverage --------------------------------------------------
63
+ // Every manifest permission key is declared here and nothing more — the
64
+ // two assertions fail the build on either drift. Exported only so
65
+ // noUnusedLocals keeps them alive — never import.
66
+ type AssertTrue<T extends true> = T
67
+ export type _ManifestKeysCovered = AssertTrue<
68
+ keyof PluginPermissions extends keyof typeof PLUGIN_CAPABILITY_GATES
69
+ ? true
70
+ : false
71
+ >
72
+ export type _TableKeysCovered = AssertTrue<
73
+ keyof typeof PLUGIN_CAPABILITY_GATES extends keyof PluginPermissions
74
+ ? true
75
+ : false
76
+ >
77
+
78
+ /** Sandbox API method → capability key, derived once from the table. */
79
+ export const CAPABILITY_BY_METHOD: ReadonlyMap<string, PluginCapabilityKey> =
80
+ new Map(
81
+ (
82
+ Object.entries(PLUGIN_CAPABILITY_GATES) as [
83
+ PluginCapabilityKey,
84
+ PluginCapabilityGate,
85
+ ][]
86
+ ).flatMap(([capability, gate]) =>
87
+ (gate.sandboxMethods ?? []).map(
88
+ (method) => [method, capability] as [string, PluginCapabilityKey],
89
+ ),
90
+ ),
91
+ )