webmcp 0.0.1 → 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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * @webmcp/runtime v0.1.0 — manifest v1
3
+ * Registers server-defined tools and invokes their same-origin HTTP endpoints.
4
+ * The application keeps responsibility for authentication, CSRF protection, and
5
+ * authorization; this runtime uses the existing browser session and endpoints.
6
+ */
7
+
8
+ const forbiddenKeys = new Set(['__proto__', 'constructor', 'prototype']);
9
+ const messages = {
10
+ invalid_input: 'Provide only declared parameters with the required types.',
11
+ csrf_token_missing: 'Refresh the page to obtain a CSRF token, then try again.',
12
+ network_error: 'The request failed. Check the connection and try again.',
13
+ aborted: 'The request was cancelled.',
14
+ invalid_response: 'The endpoint returned invalid JSON. Check the endpoint response.',
15
+ response_too_large: 'Narrow the request and try again.',
16
+ unknown_outcome: 'The result is unknown. Do not retry automatically; ask the user to check.',
17
+ };
18
+
19
+ function failure(code, status) {
20
+ return {
21
+ ok: false,
22
+ ...(status === undefined ? {} : { status }),
23
+ error: {
24
+ code,
25
+ message: code === 'http_error'
26
+ ? `The endpoint returned HTTP ${status}. Check the request and your access.`
27
+ : messages[code],
28
+ },
29
+ };
30
+ }
31
+
32
+ function matchesScalar(type, value) {
33
+ switch (type) {
34
+ case 'string': return typeof value === 'string';
35
+ case 'boolean': return typeof value === 'boolean';
36
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
37
+ case 'integer': return Number.isInteger(value);
38
+ default: return false;
39
+ }
40
+ }
41
+
42
+ /** Returns { ok: true } or an invalid_input outcome. No schema constraint checks. */
43
+ export function validateInput(tool, input) {
44
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
45
+ return failure('invalid_input');
46
+ }
47
+ const schema = tool.inputSchema ?? {};
48
+ const properties = schema.properties ?? {};
49
+ for (const key of schema.required ?? []) {
50
+ if (!Object.hasOwn(input, key)) return failure('invalid_input');
51
+ }
52
+ for (const key of Object.keys(input)) {
53
+ if (forbiddenKeys.has(key) || !Object.hasOwn(properties, key)) {
54
+ return failure('invalid_input');
55
+ }
56
+ const property = properties[key];
57
+ const value = input[key];
58
+ if (property.type === 'array') {
59
+ if (!Array.isArray(value)) return failure('invalid_input');
60
+ // Iteration also rejects sparse slots, which would serialize as null.
61
+ for (const item of value) {
62
+ if (!matchesScalar(property.items?.type, item)) return failure('invalid_input');
63
+ }
64
+ } else if (!matchesScalar(property.type, value)) {
65
+ return failure('invalid_input');
66
+ }
67
+ }
68
+ return { ok: true };
69
+ }
70
+
71
+ function* parameters(tool, input) {
72
+ const properties = tool.inputSchema?.properties ?? {};
73
+ const mapping = tool.endpoint.paramMap ?? {};
74
+ for (const key of Object.keys(input)) {
75
+ if (!forbiddenKeys.has(key) && Object.hasOwn(properties, key)) {
76
+ yield [Object.hasOwn(mapping, key) ? mapping[key] : key, input[key]];
77
+ }
78
+ }
79
+ }
80
+
81
+ /** Encodes declared own keys; null/undefined scalar values are omitted. */
82
+ export function encodeQuery(tool, input) {
83
+ const query = new URLSearchParams();
84
+ for (const [key, value] of parameters(tool, input)) {
85
+ if (value === null || value === undefined) continue;
86
+ if (Array.isArray(value)) {
87
+ const name = tool.endpoint.arrayFormat === 'brackets' ? `${key}[]` : key;
88
+ for (const item of value) query.append(name, String(item));
89
+ } else {
90
+ query.append(key, String(value));
91
+ }
92
+ }
93
+ return query.toString();
94
+ }
95
+
96
+ function csrfToken(csrf, doc) {
97
+ if (csrf.source === 'meta') {
98
+ const name = csrf.name.replace(/["\\]/g, '\\$&');
99
+ return doc?.querySelector(`meta[name="${name}"]`)?.content;
100
+ }
101
+ if (csrf.source === 'cookie') {
102
+ for (const part of (doc?.cookie ?? '').split(';')) {
103
+ const cookie = part.trim();
104
+ const separator = cookie.indexOf('=');
105
+ if (separator < 0 || cookie.slice(0, separator) !== csrf.name) continue;
106
+ const value = cookie.slice(separator + 1);
107
+ try {
108
+ return decodeURIComponent(value);
109
+ } catch {
110
+ return value;
111
+ }
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /** Returns { url, options } or a pre-dispatch failure. The caller supplies signal. */
118
+ export function buildRequest(tool, input, transport, doc) {
119
+ const validation = validateInput(tool, input);
120
+ if (!validation.ok) return validation;
121
+ const endpoint = tool.endpoint;
122
+ const path = endpoint?.path;
123
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') ||
124
+ /[\\\u0000-\u001f\u007f]/.test(path)) {
125
+ return failure('invalid_input');
126
+ }
127
+ let url;
128
+ try {
129
+ const origin = doc?.location?.origin ?? globalThis.location?.origin;
130
+ url = new URL(path, origin);
131
+ if (url.origin !== origin) return failure('invalid_input');
132
+ } catch {
133
+ return failure('invalid_input');
134
+ }
135
+ const method = endpoint.method.toUpperCase();
136
+ const headers = { Accept: 'application/json' };
137
+ const options = { method, mode: 'same-origin', credentials: 'same-origin', redirect: 'error', headers };
138
+ const csrf = transport?.csrf;
139
+ if (csrf) {
140
+ const token = csrfToken(csrf, doc);
141
+ if (!token && method !== 'GET') return failure('csrf_token_missing');
142
+ if (token) headers[csrf.header] = token;
143
+ }
144
+ if (method === 'GET') {
145
+ for (const [key, value] of new URLSearchParams(encodeQuery(tool, input))) {
146
+ url.searchParams.append(key, value);
147
+ }
148
+ } else {
149
+ headers['Content-Type'] = 'application/json';
150
+ options.body = JSON.stringify(Object.fromEntries(parameters(tool, input)));
151
+ }
152
+ return { url: url.href, options };
153
+ }
154
+
155
+ /**
156
+ * Pure outcome conversion. phase is 'before-dispatch', 'after-dispatch' (errors),
157
+ * or 'response' (default). A parsed response is { status, data, invalidResponse? }.
158
+ */
159
+ export function toOutcome(tool, value, phase = 'response') {
160
+ const readOnly = tool.annotations?.readOnlyHint === true;
161
+ if (phase === 'before-dispatch') {
162
+ const code = value?.name === 'AbortError' ? 'aborted'
163
+ : value?.code === 'csrf_token_missing' ? 'csrf_token_missing' : 'invalid_input';
164
+ return failure(code);
165
+ }
166
+ if (phase === 'after-dispatch') {
167
+ return failure(readOnly
168
+ ? value?.name === 'AbortError' ? 'aborted' : 'network_error'
169
+ : 'unknown_outcome');
170
+ }
171
+ const { status, data, invalidResponse } = value;
172
+ if (status < 200 || status >= 300) return failure('http_error', status);
173
+ if (status === 204) return { ok: true, status, data: null };
174
+ let omitted = invalidResponse ? 'invalid_response' : undefined;
175
+ if (!omitted && tool.maxResponseChars !== undefined) {
176
+ try {
177
+ if (JSON.stringify(data).length > tool.maxResponseChars) omitted = 'response_too_large';
178
+ } catch {
179
+ omitted = 'invalid_response';
180
+ }
181
+ }
182
+ if (!omitted) return { ok: true, status, data };
183
+ if (readOnly) return failure(omitted, status);
184
+ return {
185
+ ok: true, status, data: null, dataOmitted: omitted,
186
+ message: 'The operation succeeded. Do not repeat it.',
187
+ };
188
+ }
189
+
190
+ function executor(tool, transport, doc, fetcher) {
191
+ return async (inputObject, options = {}) => {
192
+ let phase = 'before-dispatch';
193
+ try {
194
+ const request = buildRequest(tool, inputObject, transport, doc);
195
+ if (request.ok === false) return request;
196
+ const signal = options?.signal;
197
+ if (signal?.aborted) return toOutcome(tool, { name: 'AbortError' }, phase);
198
+ phase = 'after-dispatch';
199
+ const response = await fetcher(request.url, { ...request.options, signal });
200
+ const status = response.status;
201
+ if (status === 204 || status < 200 || status >= 300) {
202
+ return toOutcome(tool, { status });
203
+ }
204
+ let data;
205
+ try {
206
+ data = await response.json();
207
+ } catch (error) {
208
+ // A body read can itself fail after dispatch; only a JSON parse failure
209
+ // proves that the received response was non-JSON.
210
+ if (error?.name !== 'SyntaxError') throw error;
211
+ return toOutcome(tool, { status, invalidResponse: true });
212
+ }
213
+ return toOutcome(tool, { status, data });
214
+ } catch (error) {
215
+ return toOutcome(tool, error, phase);
216
+ }
217
+ };
218
+ }
219
+
220
+ export function mount({
221
+ selector = '#webmcp-manifest', modelContext, fetch: fetcher = globalThis.fetch,
222
+ document: doc = globalThis.document,
223
+ } = {}) {
224
+ const context = modelContext === undefined
225
+ ? doc?.modelContext ?? globalThis.navigator?.modelContext : modelContext;
226
+ if (!context) return { refresh: async () => {}, dispose() {} };
227
+
228
+ const registrations = new Map();
229
+ let queue = Promise.resolve();
230
+ let generation = 0;
231
+ let disposed = false;
232
+ let warnedVersion = false;
233
+ let finishDisposal;
234
+ const disposal = new Promise(resolve => { finishDisposal = resolve; });
235
+
236
+ function remove(name, entry) {
237
+ entry.controller.abort();
238
+ if (registrations.get(name) === entry) registrations.delete(name);
239
+ }
240
+
241
+ function readManifest() {
242
+ try {
243
+ const element = doc?.querySelector(selector);
244
+ if (!element) return { tools: [] };
245
+ const manifest = JSON.parse(element.textContent);
246
+ if (manifest?.webmcpManifestVersion !== 1) {
247
+ if (!warnedVersion) {
248
+ warnedVersion = true;
249
+ console.warn('WebMCP: unsupported manifest version; no tools registered.');
250
+ }
251
+ return { tools: [] };
252
+ }
253
+ if (!Array.isArray(manifest.tools)) throw new TypeError('Manifest tools must be an array.');
254
+ return manifest;
255
+ } catch (error) {
256
+ console.warn('WebMCP: could not read the manifest.', error);
257
+ return { tools: [] };
258
+ }
259
+ }
260
+
261
+ async function reconcile(currentGeneration) {
262
+ if (disposed || currentGeneration !== generation) return;
263
+ const manifest = readManifest();
264
+ const desired = new Map(manifest.tools.map(tool => [tool.name, tool]));
265
+ for (const [name, entry] of registrations) {
266
+ if (!desired.has(name) || desired.get(name).fingerprint !== entry.fingerprint) {
267
+ remove(name, entry);
268
+ }
269
+ }
270
+ for (const [name, tool] of desired) {
271
+ if (disposed || currentGeneration !== generation) return;
272
+ if (registrations.has(name)) continue;
273
+ const entry = { fingerprint: tool.fingerprint, controller: new AbortController() };
274
+ registrations.set(name, entry);
275
+ const registration = (async () => {
276
+ try {
277
+ const definition = {
278
+ name: tool.name, description: tool.description, inputSchema: tool.inputSchema,
279
+ execute: executor(tool, manifest.transport, doc, fetcher),
280
+ };
281
+ if (tool.title !== undefined) definition.title = tool.title;
282
+ if (tool.annotations !== undefined) definition.annotations = tool.annotations;
283
+ await context.registerTool(definition, { signal: entry.controller.signal });
284
+ if (disposed || currentGeneration !== generation) remove(name, entry);
285
+ } catch (error) {
286
+ remove(name, entry);
287
+ console.warn(`WebMCP: could not register tool "${name}".`, error);
288
+ }
289
+ })();
290
+ // Disposal releases callers even if an implementation never settles its
291
+ // registerTool promise. The handler above still cleans up a late result.
292
+ await Promise.race([registration, disposal]);
293
+ }
294
+ }
295
+
296
+ function refresh() {
297
+ if (disposed) return Promise.resolve();
298
+ const currentGeneration = ++generation;
299
+ queue = queue.then(() => reconcile(currentGeneration)).catch(error => {
300
+ console.warn('WebMCP: could not refresh tools.', error);
301
+ });
302
+ return queue;
303
+ }
304
+
305
+ function dispose() {
306
+ if (disposed) return;
307
+ disposed = true;
308
+ ++generation;
309
+ doc?.removeEventListener('turbo:load', onTurboLoad);
310
+ for (const [name, entry] of registrations) remove(name, entry);
311
+ queue = Promise.resolve();
312
+ finishDisposal();
313
+ }
314
+
315
+ function onTurboLoad() { void refresh(); }
316
+ doc?.addEventListener('turbo:load', onTurboLoad);
317
+ void refresh();
318
+ return { refresh, dispose };
319
+ }
320
+
321
+ // Opt in through markup so strict CSP applications need no inline bootstrap.
322
+ if (globalThis.document) {
323
+ const doc = globalThis.document;
324
+ const started = Symbol.for('webmcp.runtime.autostart');
325
+ const autostart = () => {
326
+ if (globalThis[started]) return;
327
+ // Turbo keeps this module loaded across visits, so a page without an
328
+ // opted-in manifest (a sign-in page, say) waits for a later visit that has one.
329
+ if (!doc.querySelector('#webmcp-manifest')?.hasAttribute('data-webmcp-autostart')) {
330
+ doc.addEventListener('turbo:load', autostart);
331
+ return;
332
+ }
333
+ doc.removeEventListener('turbo:load', autostart);
334
+ globalThis[started] = true;
335
+ const handle = mount();
336
+ globalThis.WebMCPRuntime = { handle, mount };
337
+ doc.dispatchEvent(new CustomEvent('webmcp:mounted', { detail: handle }));
338
+ };
339
+ if (doc.readyState === 'loading') {
340
+ doc.addEventListener('DOMContentLoaded', autostart, { once: true });
341
+ } else {
342
+ autostart();
343
+ }
344
+ }
@@ -0,0 +1,29 @@
1
+ # Manifest v1 conformance
2
+
3
+ Each JSON fixture has `definition` (snake_case public API inputs), `transport`
4
+ (camelCase manifest transport), and `expected` (one manifest tool entry).
5
+ Compare parsed JSON values, not serialized key order. Fixtures are fixed inputs
6
+ and expected outputs; tests must not regenerate expected output using the code
7
+ under test. The initial expected fingerprints were calculated independently with
8
+ Python's sorted, compact, UTF-8 JSON serialization and SHA-256.
9
+
10
+ Fingerprint preimages:
11
+
12
+ - Effective tool: the tool entry without `fingerprint`, with an added `transport`
13
+ member containing the manifest transport. Hash recursively sorted keys,
14
+ compact JSON, UTF-8, with no HTML escaping, then prefix the hex digest `sha256:`.
15
+ - MCP source: select the keys actually present in `to_h` from `name`, `title`,
16
+ `description`, `inputSchema`, then canonicalize as above. Absent optional keys
17
+ are omitted; explicit null values are retained. MCP annotations are excluded.
18
+ - Only integer metadata numbers within +/-2^53 are allowed. Array order is
19
+ significant. Empty `paramMap` and absent optional fields are omitted. Empty
20
+ `annotations` is retained. Ruby GET arrays emit `arrayFormat: "brackets"`.
21
+
22
+ `projection-result.json` describes the effective definition after renaming
23
+ `content` to `title`, reducing `limit.maximum` from 500 to 20, and setting an
24
+ explicit endpoint map back to `content`. It can be consumed by languages without
25
+ an MCP SDK bridge.
26
+
27
+ `rake webmcp:sync_runtime` copies the separately maintained canonical runtime into
28
+ Rails assets and writes `RUNTIME.sha256` in sha256sum format. It never edits the
29
+ canonical source. Re-run after runtime changes, before packaging.
@@ -0,0 +1 @@
1
+ 287200d4ef89e16ccd0f0bbb035d9b484c3a761503f3df40f8f1cf5f3baae452 runtime/webmcp-runtime.js
@@ -0,0 +1,50 @@
1
+ {
2
+ "definition": {
3
+ "name": "escape_probe",
4
+ "description": "Find </ScRiPt> & <!-- notes
한글 \u001f tab\b end",
5
+ "input_schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "q": {
9
+ "type": "string",
10
+ "description": "검색어 <b>&</b>"
11
+ }
12
+ }
13
+ },
14
+ "annotations": {
15
+ "read_only": true
16
+ },
17
+ "endpoint": {
18
+ "path": "/api/search",
19
+ "method": "get"
20
+ }
21
+ },
22
+ "transport": {
23
+ "csrf": {
24
+ "source": "meta",
25
+ "name": "csrf-token",
26
+ "header": "X-CSRF-Token"
27
+ }
28
+ },
29
+ "expected": {
30
+ "name": "escape_probe",
31
+ "description": "Find </ScRiPt> & <!-- notes
한글 \u001f tab\b end",
32
+ "inputSchema": {
33
+ "type": "object",
34
+ "properties": {
35
+ "q": {
36
+ "type": "string",
37
+ "description": "검색어 <b>&</b>"
38
+ }
39
+ }
40
+ },
41
+ "annotations": {
42
+ "readOnlyHint": true
43
+ },
44
+ "endpoint": {
45
+ "path": "/api/search",
46
+ "method": "GET"
47
+ },
48
+ "fingerprint": "sha256:e2e6e6650c07d308c89a242c69439ff03c3cf83e258a2f951df22e25ce8d1885"
49
+ }
50
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "definition": {
3
+ "name": "search_tasks",
4
+ "title": "Search tasks",
5
+ "description": "Search at most 20 tasks.",
6
+ "input_schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "title": {
10
+ "type": "string"
11
+ },
12
+ "limit": {
13
+ "type": "integer",
14
+ "maximum": 20
15
+ }
16
+ },
17
+ "required": [
18
+ "title"
19
+ ]
20
+ },
21
+ "annotations": {
22
+ "read_only": true
23
+ },
24
+ "endpoint": {
25
+ "path": "/api/search",
26
+ "method": "post",
27
+ "param_map": {
28
+ "title": "content"
29
+ }
30
+ },
31
+ "max_response_chars": 1500
32
+ },
33
+ "transport": {
34
+ "csrf": {
35
+ "source": "meta",
36
+ "name": "csrf-token",
37
+ "header": "X-CSRF-Token"
38
+ }
39
+ },
40
+ "expected": {
41
+ "name": "search_tasks",
42
+ "title": "Search tasks",
43
+ "description": "Search at most 20 tasks.",
44
+ "inputSchema": {
45
+ "type": "object",
46
+ "properties": {
47
+ "title": {
48
+ "type": "string"
49
+ },
50
+ "limit": {
51
+ "type": "integer",
52
+ "maximum": 20
53
+ }
54
+ },
55
+ "required": [
56
+ "title"
57
+ ]
58
+ },
59
+ "annotations": {
60
+ "readOnlyHint": true
61
+ },
62
+ "endpoint": {
63
+ "path": "/api/search",
64
+ "method": "POST",
65
+ "paramMap": {
66
+ "title": "content"
67
+ }
68
+ },
69
+ "maxResponseChars": 1500,
70
+ "fingerprint": "sha256:e5a40c0528a542f096bd4bd58ffeac911f009d5d48d67013595906b7e86a1f08"
71
+ }
72
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "definition": {
3
+ "name": "list_tasks",
4
+ "description": "List tasks tagged by the user.",
5
+ "input_schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "tags": {
9
+ "type": "array",
10
+ "items": {
11
+ "type": "string"
12
+ }
13
+ }
14
+ }
15
+ },
16
+ "annotations": {
17
+ "read_only": true,
18
+ "untrusted_content": true
19
+ },
20
+ "endpoint": {
21
+ "path": "/api/tasks",
22
+ "method": "get",
23
+ "array_format": "brackets"
24
+ }
25
+ },
26
+ "transport": {},
27
+ "expected": {
28
+ "name": "list_tasks",
29
+ "description": "List tasks tagged by the user.",
30
+ "inputSchema": {
31
+ "type": "object",
32
+ "properties": {
33
+ "tags": {
34
+ "type": "array",
35
+ "items": {
36
+ "type": "string"
37
+ }
38
+ }
39
+ }
40
+ },
41
+ "annotations": {
42
+ "readOnlyHint": true,
43
+ "untrustedContentHint": true
44
+ },
45
+ "endpoint": {
46
+ "path": "/api/tasks",
47
+ "method": "GET",
48
+ "arrayFormat": "brackets"
49
+ },
50
+ "fingerprint": "sha256:5507520a0c34b5f1e028a4a76e2b68e1a6cacbc12b3f1c9eadc2d6ef42b143d6"
51
+ }
52
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "definition": {
3
+ "name": "create_task",
4
+ "title": "Create task",
5
+ "description": "Create one task.",
6
+ "input_schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "title": {
10
+ "type": "string",
11
+ "maxLength": 120
12
+ }
13
+ },
14
+ "required": [
15
+ "title"
16
+ ]
17
+ },
18
+ "annotations": {
19
+ "consequential": true,
20
+ "debugging": false
21
+ },
22
+ "endpoint": {
23
+ "path": "/api/tasks",
24
+ "method": "post",
25
+ "param_map": {
26
+ "title": "content"
27
+ }
28
+ },
29
+ "max_response_chars": 1500
30
+ },
31
+ "transport": {
32
+ "csrf": {
33
+ "source": "meta",
34
+ "name": "csrf-token",
35
+ "header": "X-CSRF-Token"
36
+ }
37
+ },
38
+ "expected": {
39
+ "name": "create_task",
40
+ "title": "Create task",
41
+ "description": "Create one task.",
42
+ "inputSchema": {
43
+ "type": "object",
44
+ "properties": {
45
+ "title": {
46
+ "type": "string",
47
+ "maxLength": 120
48
+ }
49
+ },
50
+ "required": [
51
+ "title"
52
+ ]
53
+ },
54
+ "annotations": {
55
+ "consequentialHint": true
56
+ },
57
+ "endpoint": {
58
+ "path": "/api/tasks",
59
+ "method": "POST",
60
+ "paramMap": {
61
+ "title": "content"
62
+ }
63
+ },
64
+ "maxResponseChars": 1500,
65
+ "fingerprint": "sha256:bc6e6c1c3aae5362177623f8a0c64919989236bb24571bc6d76a24054d4ca66c"
66
+ }
67
+ }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ namespace :webmcp do
7
+ desc "Copy the canonical runtime into Rails assets and record its SHA-256"
8
+ task :sync_runtime do
9
+ root = File.expand_path("../..", __dir__)
10
+ source = File.join(root, "runtime/webmcp-runtime.js")
11
+ abort "Missing #{source}; the runtime lane must supply it before packaging" unless File.file?(source)
12
+ destination = File.join(root, "app/assets/javascripts/webmcp/runtime.js")
13
+ FileUtils.mkdir_p(File.dirname(destination))
14
+ FileUtils.cp(source, destination)
15
+ FileUtils.mkdir_p(File.join(root, "conformance"))
16
+ digest = Digest::SHA256.file(destination).hexdigest
17
+ File.write(File.join(root, "conformance/RUNTIME.sha256"), "#{digest} runtime/webmcp-runtime.js\n")
18
+ puts "Synced webmcp/runtime.js (sha256:#{digest})"
19
+ end
20
+ end