@decocms/runtime 1.0.0-alpha.5 → 1.0.1

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.
@@ -434,52 +434,59 @@
434
434
  "MCPBinding": {
435
435
  "anyOf": [
436
436
  {
437
- "$ref": "#/definitions/MCPIntegrationIdBinding"
437
+ "$ref": "#/definitions/MCPConnectionBinding"
438
438
  },
439
439
  {
440
- "$ref": "#/definitions/MCPIntegrationNameBinding"
440
+ "$ref": "#/definitions/MCPAppBinding"
441
441
  }
442
442
  ]
443
443
  },
444
- "MCPIntegrationIdBinding": {
444
+ "MCPConnectionBinding": {
445
445
  "type": "object",
446
446
  "properties": {
447
447
  "name": {
448
448
  "type": "string"
449
449
  },
450
+ "array": {
451
+ "type": "boolean",
452
+ "const": true
453
+ },
450
454
  "type": {
451
455
  "type": "string",
452
456
  "const": "mcp"
453
457
  },
454
- "integration_id": {
458
+ "connection_id": {
455
459
  "type": "string",
456
460
  "description": "If not provided, will return a function that takes the integration id and return the binding implementation.."
457
461
  }
458
462
  },
459
463
  "required": [
460
- "integration_id",
464
+ "connection_id",
461
465
  "name",
462
466
  "type"
463
467
  ],
464
468
  "additionalProperties": false
465
469
  },
466
- "MCPIntegrationNameBinding": {
470
+ "MCPAppBinding": {
467
471
  "type": "object",
468
472
  "properties": {
469
473
  "name": {
470
474
  "type": "string"
471
475
  },
476
+ "array": {
477
+ "type": "boolean",
478
+ "const": true
479
+ },
472
480
  "type": {
473
481
  "type": "string",
474
482
  "const": "mcp"
475
483
  },
476
- "integration_name": {
484
+ "app_name": {
477
485
  "type": "string",
478
486
  "description": "The name of the integration to bind."
479
487
  }
480
488
  },
481
489
  "required": [
482
- "integration_name",
483
490
  "name",
484
491
  "type"
485
492
  ],
@@ -491,6 +498,10 @@
491
498
  "name": {
492
499
  "type": "string"
493
500
  },
501
+ "array": {
502
+ "type": "boolean",
503
+ "const": true
504
+ },
494
505
  "type": {
495
506
  "type": "string",
496
507
  "const": "contract"
package/package.json CHANGED
@@ -1,38 +1,32 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
+ "scripts": {
6
+ "check": "tsc --noEmit",
7
+ "test": "bun test"
8
+ },
5
9
  "dependencies": {
6
10
  "@cloudflare/workers-types": "^4.20250617.0",
7
- "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5",
8
- "@decocms/bindings": "*",
9
- "@mastra/core": "^0.20.2",
10
- "@modelcontextprotocol/sdk": "^1.19.1",
11
+ "@deco/mcp": "npm:@jsr/deco__mcp@0.7.8",
12
+ "@decocms/bindings": "1.0.1-alpha.27",
13
+ "@modelcontextprotocol/sdk": "1.20.2",
11
14
  "@ai-sdk/provider": "^2.0.0",
12
- "bidc": "0.0.3",
13
- "drizzle-orm": "^0.44.5",
14
15
  "hono": "^4.10.7",
15
16
  "jose": "^6.0.11",
16
- "mime-db": "1.52.0",
17
17
  "zod": "^3.25.76",
18
- "zod-from-json-schema": "^0.0.5",
19
18
  "zod-to-json-schema": "3.25.0"
20
19
  },
21
20
  "exports": {
22
21
  ".": "./src/index.ts",
23
22
  "./proxy": "./src/proxy.ts",
24
- "./admin": "./src/admin.ts",
25
23
  "./client": "./src/client.ts",
26
- "./mastra": "./src/mastra.ts",
27
- "./drizzle": "./src/drizzle.ts",
28
- "./resources": "./src/resources.ts",
29
- "./views": "./src/views.ts",
30
- "./mcp-client": "./src/mcp-client.ts",
31
24
  "./bindings": "./src/bindings/index.ts",
32
- "./deconfig": "./src/bindings/deconfig/index.ts",
33
- "./asset-server": "./src/asset-server/index.ts"
25
+ "./asset-server": "./src/asset-server/index.ts",
26
+ "./tools": "./src/tools.ts"
34
27
  },
35
28
  "devDependencies": {
29
+ "@types/bun": "^1.3.5",
36
30
  "@types/mime-db": "^1.43.6",
37
31
  "ts-json-schema-generator": "^2.4.0"
38
32
  },
@@ -0,0 +1,306 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ isPathWithinDirectory,
4
+ resolveAssetPathWithTraversalCheck,
5
+ createAssetHandler,
6
+ } from "./index";
7
+ import { resolve } from "path";
8
+ import { mkdirSync, writeFileSync, rmSync } from "fs";
9
+
10
+ describe("isPathWithinDirectory", () => {
11
+ const baseDir = "/app/client";
12
+
13
+ describe("safe paths", () => {
14
+ test("allows file directly in base directory", () => {
15
+ expect(isPathWithinDirectory("/app/client/index.html", baseDir)).toBe(
16
+ true,
17
+ );
18
+ });
19
+
20
+ test("allows file in subdirectory", () => {
21
+ expect(
22
+ isPathWithinDirectory("/app/client/assets/style.css", baseDir),
23
+ ).toBe(true);
24
+ });
25
+
26
+ test("allows deeply nested file", () => {
27
+ expect(
28
+ isPathWithinDirectory("/app/client/assets/images/logo.png", baseDir),
29
+ ).toBe(true);
30
+ });
31
+
32
+ test("allows base directory itself", () => {
33
+ expect(isPathWithinDirectory("/app/client", baseDir)).toBe(true);
34
+ });
35
+
36
+ test("allows file with spaces in name", () => {
37
+ expect(
38
+ isPathWithinDirectory("/app/client/logos/deco logo.svg", baseDir),
39
+ ).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe("path traversal attacks - BLOCKED", () => {
44
+ test("blocks simple traversal to parent", () => {
45
+ expect(isPathWithinDirectory("/app/style.css", baseDir)).toBe(false);
46
+ });
47
+
48
+ test("blocks traversal to root", () => {
49
+ expect(isPathWithinDirectory("/etc/passwd", baseDir)).toBe(false);
50
+ });
51
+
52
+ test("blocks traversal with ../ sequence", () => {
53
+ const traversalPath = resolve(baseDir, "../../../etc/passwd");
54
+ expect(isPathWithinDirectory(traversalPath, baseDir)).toBe(false);
55
+ });
56
+
57
+ test("blocks traversal to sibling directory", () => {
58
+ expect(isPathWithinDirectory("/app/server/secrets.json", baseDir)).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ test("blocks path that starts with baseDir but is actually sibling", () => {
64
+ // /app/client-secrets is NOT within /app/client
65
+ expect(isPathWithinDirectory("/app/client-secrets/key", baseDir)).toBe(
66
+ false,
67
+ );
68
+ });
69
+
70
+ test("blocks absolute path outside base", () => {
71
+ expect(isPathWithinDirectory("/var/log/system.log", baseDir)).toBe(false);
72
+ });
73
+ });
74
+ });
75
+
76
+ describe("resolveAssetPathWithTraversalCheck", () => {
77
+ const clientDir = "/app/dist/client";
78
+
79
+ // Helper to reduce boilerplate
80
+ const resolvePath = (requestPath: string) =>
81
+ resolveAssetPathWithTraversalCheck({ requestPath, clientDir });
82
+
83
+ describe("valid paths", () => {
84
+ test("resolves root to clientDir", () => {
85
+ expect(resolvePath("/")).toBe(clientDir);
86
+ });
87
+
88
+ test("resolves CSS file", () => {
89
+ expect(resolvePath("/style.css")).toBe("/app/dist/client/style.css");
90
+ });
91
+
92
+ test("resolves nested path", () => {
93
+ expect(resolvePath("/assets/app.js")).toBe(
94
+ "/app/dist/client/assets/app.js",
95
+ );
96
+ });
97
+
98
+ test("resolves path without extension", () => {
99
+ expect(resolvePath("/dashboard")).toBe("/app/dist/client/dashboard");
100
+ });
101
+
102
+ test("resolves path with dots (SPA route)", () => {
103
+ expect(resolvePath("/user/john.doe")).toBe(
104
+ "/app/dist/client/user/john.doe",
105
+ );
106
+ });
107
+
108
+ test("resolves file with spaces", () => {
109
+ expect(resolvePath("/logos/deco logo.svg")).toBe(
110
+ "/app/dist/client/logos/deco logo.svg",
111
+ );
112
+ });
113
+ });
114
+
115
+ describe("path traversal attacks - BLOCKED", () => {
116
+ test("blocks /../../../etc/passwd", () => {
117
+ expect(resolvePath("/../../../etc/passwd")).toBeNull();
118
+ });
119
+
120
+ test("blocks /assets/../../../etc/passwd", () => {
121
+ expect(resolvePath("/assets/../../../etc/passwd")).toBeNull();
122
+ });
123
+
124
+ test("blocks /./../../etc/passwd", () => {
125
+ expect(resolvePath("/./../../etc/passwd")).toBeNull();
126
+ });
127
+
128
+ test("blocks /../etc/passwd", () => {
129
+ expect(resolvePath("/../etc/passwd")).toBeNull();
130
+ });
131
+
132
+ test("allows backslash paths (treated as literal on Unix)", () => {
133
+ // On Unix, backslashes are literal characters - path stays in clientDir
134
+ expect(resolvePath("/..\\..\\etc\\passwd")).not.toBeNull();
135
+ });
136
+
137
+ test("blocks /assets/../../package.json", () => {
138
+ expect(resolvePath("/assets/../../package.json")).toBeNull();
139
+ });
140
+
141
+ test("blocks //etc/passwd (resolves to absolute path)", () => {
142
+ // Double slash after stripping leading / becomes /etc/passwd (absolute)
143
+ expect(resolvePath("//etc/passwd")).toBeNull();
144
+ });
145
+
146
+ test("blocks /valid/../../../etc/passwd", () => {
147
+ expect(resolvePath("/valid/../../../etc/passwd")).toBeNull();
148
+ });
149
+ });
150
+ });
151
+
152
+ describe("createAssetHandler", () => {
153
+ // Temp directory for tests that need real files
154
+ const tempDir = resolve(import.meta.dir, ".test-temp-client");
155
+ const indexContent = "<!DOCTYPE html><html><body>SPA</body></html>";
156
+ const cssContent = "body { color: red; }";
157
+
158
+ beforeAll(() => {
159
+ // Create temp directory with test files
160
+ mkdirSync(resolve(tempDir, "assets"), { recursive: true });
161
+ writeFileSync(resolve(tempDir, "index.html"), indexContent);
162
+ writeFileSync(resolve(tempDir, "assets/style.css"), cssContent);
163
+ });
164
+
165
+ afterAll(() => {
166
+ // Clean up temp directory
167
+ rmSync(tempDir, { recursive: true, force: true });
168
+ });
169
+
170
+ describe("malformed URL encoding", () => {
171
+ test("handles malformed percent-encoded sequences gracefully", async () => {
172
+ // Create handler in production mode to test the decodeURIComponent path
173
+ const handler = createAssetHandler({
174
+ env: "production",
175
+ clientDir: "/app/dist/client",
176
+ });
177
+
178
+ // %E0%A4%A is an incomplete UTF-8 sequence that causes decodeURIComponent to throw
179
+ const malformedUrl = "http://localhost:3000/%E0%A4%A";
180
+ const request = new Request(malformedUrl);
181
+
182
+ // Should return null (graceful fallback) instead of throwing
183
+ const result = await handler(request);
184
+ expect(result).toBeNull();
185
+ });
186
+
187
+ test("handles %FF (invalid UTF-8 byte) gracefully", async () => {
188
+ const handler = createAssetHandler({
189
+ env: "production",
190
+ clientDir: "/app/dist/client",
191
+ });
192
+
193
+ // %FF is not valid in UTF-8
194
+ const malformedUrl = "http://localhost:3000/%FF";
195
+ const request = new Request(malformedUrl);
196
+
197
+ const result = await handler(request);
198
+ expect(result).toBeNull();
199
+ });
200
+
201
+ test("handles truncated multi-byte sequence gracefully", async () => {
202
+ const handler = createAssetHandler({
203
+ env: "production",
204
+ clientDir: "/app/dist/client",
205
+ });
206
+
207
+ // %C2 expects a continuation byte but is truncated
208
+ const malformedUrl = "http://localhost:3000/file%C2.txt";
209
+ const request = new Request(malformedUrl);
210
+
211
+ const result = await handler(request);
212
+ expect(result).toBeNull();
213
+ });
214
+ });
215
+
216
+ describe("SPA fallback for routes with dots", () => {
217
+ test("serves index.html for /user/john.doe (non-existent path with dot)", async () => {
218
+ const handler = createAssetHandler({
219
+ env: "production",
220
+ clientDir: tempDir,
221
+ });
222
+
223
+ const request = new Request("http://localhost:3000/user/john.doe");
224
+ const result = await handler(request);
225
+
226
+ expect(result).not.toBeNull();
227
+ expect(result?.status).toBe(200);
228
+ const text = await result?.text();
229
+ expect(text).toBe(indexContent);
230
+ });
231
+
232
+ test("serves index.html for /page/v2.0 (version-like route)", async () => {
233
+ const handler = createAssetHandler({
234
+ env: "production",
235
+ clientDir: tempDir,
236
+ });
237
+
238
+ const request = new Request("http://localhost:3000/page/v2.0");
239
+ const result = await handler(request);
240
+
241
+ expect(result).not.toBeNull();
242
+ const text = await result?.text();
243
+ expect(text).toBe(indexContent);
244
+ });
245
+
246
+ test("serves index.html for /files/report.2024 (date-like route)", async () => {
247
+ const handler = createAssetHandler({
248
+ env: "production",
249
+ clientDir: tempDir,
250
+ });
251
+
252
+ const request = new Request("http://localhost:3000/files/report.2024");
253
+ const result = await handler(request);
254
+
255
+ expect(result).not.toBeNull();
256
+ const text = await result?.text();
257
+ expect(text).toBe(indexContent);
258
+ });
259
+
260
+ test("serves actual file when it exists", async () => {
261
+ const handler = createAssetHandler({
262
+ env: "production",
263
+ clientDir: tempDir,
264
+ });
265
+
266
+ const request = new Request("http://localhost:3000/assets/style.css");
267
+ const result = await handler(request);
268
+
269
+ expect(result).not.toBeNull();
270
+ expect(result?.status).toBe(200);
271
+ const text = await result?.text();
272
+ expect(text).toBe(cssContent);
273
+ });
274
+
275
+ test("serves index.html for non-existent .css file", async () => {
276
+ const handler = createAssetHandler({
277
+ env: "production",
278
+ clientDir: tempDir,
279
+ });
280
+
281
+ // This CSS file doesn't exist, so it should fall back to index.html
282
+ const request = new Request(
283
+ "http://localhost:3000/assets/nonexistent.css",
284
+ );
285
+ const result = await handler(request);
286
+
287
+ expect(result).not.toBeNull();
288
+ const text = await result?.text();
289
+ expect(text).toBe(indexContent);
290
+ });
291
+
292
+ test("serves index.html for route without dots", async () => {
293
+ const handler = createAssetHandler({
294
+ env: "production",
295
+ clientDir: tempDir,
296
+ });
297
+
298
+ const request = new Request("http://localhost:3000/dashboard");
299
+ const result = await handler(request);
300
+
301
+ expect(result).not.toBeNull();
302
+ const text = await result?.text();
303
+ expect(text).toBe(indexContent);
304
+ });
305
+ });
306
+ });