• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nogoo9 / no-crd / 26592721271

28 May 2026 06:00PM UTC coverage: 66.301% (-2.9%) from 69.196%
26592721271

push

github

web-flow
release: v0.4.0 (#6)

* feat: add local templates, session cookies, built-in themes, and ADRs

- Local filesystem templates (YAML/JSON) with 3-source merge
  (ConfigMap → custom dir → built-in) for both templates and themes
- Stateless HMAC-signed session cookies (nocr_sess) with peer
  discovery for shared key distribution
- Built-in theme bundling with scanThemeDir/readThemeCssFile helpers
- Refactor: extract setCorsHeaders (24 sites), themeDisplayName,
  scanThemeDir, readThemeCssFile helpers in server.ts
- Architecture Decision Records (ADR-001 through ADR-005) integrated
  into VitePress documentation site
- 186 tests passing (39 new across session + local-templates)

* refactor(server): modularize server and routes, improve test suite

- Break up monolithic src/server.ts into helpers, auth, ws-proxy, and routes sub-modules
- Modularize routes under src/server/routes/ directory (mcp, themes, static, proxy)
- Shift src/server.test.ts to src/server/index.test.ts and add robust environment resets
- Fix mock module pollution and unused bun:test imports across MCP test files
- Solve security check warnings in header parsing using safe Object.fromEntries
- Create src/server/helpers.test.ts for helper unit tests
- Add scripts/check-imports.ts to enforce path alias usage and trailing file extensions

* docs: add npm social link, document docker registry, and fix packaged asset resolution

- Add npm icon social link to VitePress docs header
- Add Docker registry image run instructions and link to GHCR Package in README and docs
- Add npm package link to docs
- Document local templates directory config and theme merge rules
- Fix packaged UI and config asset resolution in flat build dirs
- Add prepublishOnly build guard to package.json
- Replace mermaid diagrams in docs with generated premium images
- Add ADR-006 for packaged UI asset resolution

* feat: add deployment manifests, configure image metadata, and update author

- Generate standard production K... (continued)

1280 of 1788 new or added lines in 15 files covered. (71.59%)

115 existing lines in 1 file now uncovered.

4295 of 6478 relevant lines covered (66.3%)

18.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

50.49
/src/server/ws-proxy.ts
1
import net from "node:net";
27✔
2
import { getLogger } from "@logtape/logtape";
45✔
3
import { config } from "~/config.js";
37✔
4
import {
196✔
5
        DEFAULT_NAMESPACE,
6
        extractTokenFromCookie,
7
        extractUserIdentity,
8
        hasRequiredRole,
9
        hasRequiredScope,
10
        MODE,
11
        parseWorkspaceApis,
12
        resolveNamespace,
13
        verifyToken,
14
} from "~/k8s/index.js";
15
import { getBasePrefix } from "./helpers.js";
45✔
16

17
const logger = getLogger(["nogoo9", "ws-proxy"]);
49✔
18

19
export function registerUpgradeHandler(
6✔
20
        app: any,
5✔
21
        deps: {
6✔
22
                getK8sContext: () => any;
23
        },
24
): void {
3✔
25
        const basePrefix = getBasePrefix();
36✔
26

27
        async function handleUpgradeRequest(req: any, socket: any, head: any) {
24✔
28
                const url = req.url || "";
30✔
29
                const qIndex = url.indexOf("?");
36✔
30
                const pathname = qIndex !== -1 ? url.substring(0, qIndex) : url;
60✔
31
                const query = qIndex !== -1 ? url.substring(qIndex) : "";
54✔
32

33
                // Only handle websocket upgrades on workspace routes
34
                let requestPath = pathname;
31✔
35
                if (basePrefix && requestPath.startsWith(basePrefix)) {
53✔
NEW
36
                        requestPath = requestPath.substring(basePrefix.length);
×
37
                }
4✔
38

39
                // Workspace routes are of the form: /route/:workspaceId/...
40
                const match = requestPath.match(/^\/route\/([a-zA-Z0-9_-]+)(.*)$/);
71✔
41
                if (!match) {
11✔
NEW
42
                        return;
×
43
                }
4✔
44

45
                const workspaceId = match[1];
33✔
46
                const subpath = match[2] || "/";
36✔
47

48
                logger.info(
12✔
49
                        "Handling WebSocket upgrade request for workspace {workspaceId} path {subpath}",
81✔
50
                        { workspaceId, subpath },
24✔
51
                );
6✔
52

53
                // 1. Authentication Check
54
                let userSub = "anonymous";
30✔
55
                if (config.auth.enabled) {
24✔
NEW
56
                        let token: string | null = null;
×
NEW
57
                        const authHeader = req.headers.authorization;
×
NEW
58
                        if (authHeader?.toLowerCase().startsWith("bearer ")) {
×
NEW
59
                                token = authHeader.substring(7);
×
NEW
60
                        } else {
×
NEW
61
                                try {
×
NEW
62
                                        const urlObj = new URL(url, "http://localhost");
×
NEW
63
                                        token = urlObj.searchParams.get("token");
×
NEW
64
                                } catch (_) {}
×
NEW
65
                                if (!token) {
×
NEW
66
                                        token =
×
NEW
67
                                                extractTokenFromCookie(req.headers.cookie, "nocr_token") || null;
×
NEW
68
                                }
×
69
                        }
70

NEW
71
                        if (!token) {
×
NEW
72
                                logger.warn("WebSocket upgrade failed: Missing token");
×
NEW
73
                                socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
×
NEW
74
                                socket.destroy();
×
NEW
75
                                return;
×
NEW
76
                        }
×
77

NEW
78
                        try {
×
NEW
79
                                let expectedAudience: string | undefined;
×
NEW
80
                                try {
×
NEW
81
                                        const host =
×
NEW
82
                                                req.headers["x-forwarded-host"] || req.headers.host || "localhost";
×
NEW
83
                                        let proto = req.headers["x-forwarded-proto"] || "http";
×
NEW
84
                                        if (proto === "ws") proto = "http";
×
NEW
85
                                        if (proto === "wss") proto = "https";
×
NEW
86
                                        expectedAudience = `${proto}://${host}${basePrefix}`;
×
NEW
87
                                } catch (_) {}
×
NEW
88
                                const jwtPayload = await verifyToken(token, expectedAudience);
×
89

NEW
90
                                const requiredScope = config.auth.requiredReadScope;
×
NEW
91
                                if (
×
NEW
92
                                        requiredScope &&
×
NEW
93
                                        !hasRequiredScope(
×
NEW
94
                                                jwtPayload,
×
NEW
95
                                                requiredScope,
×
NEW
96
                                                config.auth.scopeJsonPath,
×
NEW
97
                                        )
×
NEW
98
                                ) {
×
NEW
99
                                        logger.warn(
×
NEW
100
                                                "WebSocket upgrade failed: Missing scope {requiredScope}",
×
NEW
101
                                                { requiredScope },
×
NEW
102
                                        );
×
NEW
103
                                        socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
×
NEW
104
                                        socket.destroy();
×
NEW
105
                                        return;
×
NEW
106
                                }
×
107

NEW
108
                                const requiredRole = config.auth.requiredReadRole;
×
NEW
109
                                if (
×
NEW
110
                                        requiredRole &&
×
NEW
111
                                        !hasRequiredRole(jwtPayload, requiredRole, config.auth.rolesJsonPath)
×
NEW
112
                                ) {
×
NEW
113
                                        logger.warn("WebSocket upgrade failed: Missing role {requiredRole}", {
×
NEW
114
                                                requiredRole,
×
NEW
115
                                        });
×
NEW
116
                                        socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
×
NEW
117
                                        socket.destroy();
×
NEW
118
                                        return;
×
NEW
119
                                }
×
120

NEW
121
                                try {
×
NEW
122
                                        userSub = extractUserIdentity(jwtPayload, config.auth.subJsonPath);
×
NEW
123
                                } catch (_err) {
×
NEW
124
                                        logger.warn(
×
NEW
125
                                                "WebSocket upgrade failed: Could not extract user identity",
×
NEW
126
                                        );
×
NEW
127
                                        socket.write(
×
NEW
128
                                                "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n",
×
NEW
129
                                        );
×
NEW
130
                                        socket.destroy();
×
NEW
131
                                        return;
×
132
                                }
NEW
133
                        } catch (err) {
×
NEW
134
                                logger.warn(
×
NEW
135
                                        "WebSocket upgrade failed: Token verification failed: {error}",
×
NEW
136
                                        {
×
NEW
137
                                                error: err instanceof Error ? err.message : String(err),
×
NEW
138
                                        },
×
NEW
139
                                );
×
NEW
140
                                socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
×
NEW
141
                                socket.destroy();
×
NEW
142
                                return;
×
143
                        }
144
                }
4✔
145

146
                // 2. Resolve target pod & port
147
                const ns = resolveNamespace(undefined, MODE, DEFAULT_NAMESPACE);
68✔
148
                const k8sCtx = deps.getK8sContext();
40✔
149
                let podIP: string;
14✔
150
                let port: string;
13✔
151
                let upstreamPath = subpath;
31✔
152

153
                try {
11✔
154
                        const res = await k8sCtx.coreApi.listNamespacedPod({
60✔
155
                                namespace: ns,
22✔
156
                                labelSelector: `nogoo9/type=workspace,nogoo9/workspace-id=${workspaceId}`,
79✔
157
                        });
9✔
158

159
                        if (res.items.length === 0) {
27✔
NEW
160
                                logger.warn(
×
NEW
161
                                        "WebSocket upgrade failed: Workspace {workspaceId} not found",
×
NEW
162
                                        { workspaceId },
×
NEW
163
                                );
×
NEW
164
                                socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
×
NEW
165
                                socket.destroy();
×
NEW
166
                                return;
×
167
                        }
6✔
168

169
                        const pod = res.items[0];
31✔
170
                        const podSub = pod.metadata?.labels?.["nogoo9/user-sub"];
63✔
171

172
                        if (config.auth.enabled && podSub !== userSub) {
46✔
NEW
173
                                logger.warn("WebSocket upgrade failed: Forbidden owner mismatch");
×
NEW
174
                                socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
×
NEW
175
                                socket.destroy();
×
NEW
176
                                return;
×
177
                        }
6✔
178

179
                        if (pod.status?.phase !== "Running") {
36✔
NEW
180
                                logger.warn("WebSocket upgrade failed: Pod is in phase {phase}", {
×
NEW
181
                                        phase: pod.status?.phase,
×
NEW
182
                                });
×
NEW
183
                                socket.write(
×
NEW
184
                                        "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n",
×
NEW
185
                                );
×
NEW
186
                                socket.destroy();
×
NEW
187
                                return;
×
188
                        }
6✔
189

190
                        const ip = pod.status?.podIP;
35✔
191
                        if (!ip) {
8✔
NEW
192
                                logger.warn("WebSocket upgrade failed: Pod IP not assigned");
×
NEW
193
                                socket.write(
×
NEW
194
                                        "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n",
×
NEW
195
                                );
×
NEW
196
                                socket.destroy();
×
NEW
197
                                return;
×
198
                        }
6✔
199
                        podIP = ip;
17✔
200

201
                        const targetPortAnnotation =
29✔
202
                                pod.metadata?.annotations?.["nogoo9/workspace-port"];
59✔
203
                        port = String(
14✔
204
                                targetPortAnnotation || config.k8s.defaultWorkspacePort || "3000",
65✔
205
                        );
8✔
206

207
                        // Dynamic API routing path match for WebSockets
208
                        const apis = parseWorkspaceApis(pod.metadata?.annotations);
65✔
209
                        const sortedApis = [...apis].sort(
33✔
210
                                (a, b) => b.path.length - a.path.length,
211
                        );
8✔
212
                        for (const api of sortedApis) {
29✔
NEW
213
                                const apiPathNoTrailingSlash = api.path.replace(/\/$/, "");
×
NEW
214
                                if (apiPathNoTrailingSlash !== "") {
×
NEW
215
                                        const pathMatches =
×
NEW
216
                                                subpath === apiPathNoTrailingSlash ||
×
NEW
217
                                                subpath.startsWith(`${apiPathNoTrailingSlash}/`);
×
NEW
218
                                        if (pathMatches) {
×
NEW
219
                                                port = String(api.port);
×
NEW
220
                                                upstreamPath =
×
NEW
221
                                                        subpath.substring(apiPathNoTrailingSlash.length) || "/";
×
NEW
222
                                                break;
×
NEW
223
                                        }
×
NEW
224
                                }
×
225
                        }
6✔
226

227
                        // Check if pod uses SUBFOLDER prefix (e.g. for KasmVNC / Obsidian GUI workspaces).
228
                        const envs = pod.spec?.containers?.[0]?.env || [];
56✔
229
                        const hasSubfolder = envs.some(
30✔
230
                                (e: any) => e.name === "SUBFOLDER" && e.value,
231
                        );
8✔
232
                        if (hasSubfolder && upstreamPath === subpath) {
45✔
NEW
233
                                upstreamPath = `/route/${workspaceId}${subpath}`;
×
234
                        }
4✔
NEW
235
                } catch (err) {
×
NEW
236
                        logger.error("Failed to list pods during WebSocket upgrade: {error}", {
×
NEW
237
                                error: err,
×
NEW
238
                        });
×
NEW
239
                        socket.write(
×
NEW
240
                                "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n",
×
NEW
241
                        );
×
NEW
242
                        socket.destroy();
×
243
                        return;
4✔
244
                }
245

246
                logger.info(
12✔
247
                        "Proxying WebSocket to upstream {podIP}:{port} path {upstreamPath} (socket constructor: {ctor})",
98✔
248
                        {
7✔
249
                                podIP,
12✔
250
                                port,
11✔
251
                                upstreamPath,
19✔
252
                                ctor: socket?.constructor?.name || "unknown",
48✔
253
                        },
1✔
254
                );
6✔
255

256
                if (typeof socket.setNoDelay === "function") {
44✔
NEW
257
                        socket.setNoDelay(true);
×
258
                }
4✔
259

260
                const upstreamSocket = net.connect(Number(port), podIP, () => {
68✔
261
                        if (typeof upstreamSocket.setNoDelay === "function") {
61✔
262
                                upstreamSocket.setNoDelay(true);
38✔
263
                        }
6✔
264

265
                        const fullUpstreamPath = upstreamPath + query;
52✔
266
                        let rawRequest = `${req.method} ${fullUpstreamPath} HTTP/${req.httpVersion}\r\n`;
85✔
267
                        for (const [key, value] of Object.entries(req.headers)) {
64✔
268
                                if (Array.isArray(value)) {
25✔
NEW
269
                                        for (const val of value) {
×
NEW
270
                                                rawRequest += `${key}: ${val}\r\n`;
×
NEW
271
                                        }
×
272
                                } else if (value !== undefined) {
41✔
273
                                        rawRequest += `${key}: ${value}\r\n`;
43✔
274
                                }
7✔
275
                        }
6✔
276
                        rawRequest += "\r\n";
25✔
277

278
                        logger.info(
12✔
279
                                "WebSocket proxy: writing request to upstream:\n{rawRequest}",
61✔
280
                                { rawRequest },
14✔
281
                        );
8✔
282
                        upstreamSocket.write(rawRequest);
39✔
283

284
                        if (head && head.length > 0) {
28✔
NEW
285
                                logger.info(
×
NEW
286
                                        "WebSocket proxy: writing head of length {length} to upstream",
×
NEW
287
                                        { length: head.length },
×
NEW
288
                                );
×
NEW
289
                                upstreamSocket.write(head);
×
290
                        }
6✔
291

292
                        let handshakeComplete = false;
36✔
NEW
293
                        socket.on("data", (chunk: any) => {
×
NEW
294
                                logger.info("WebSocket proxy: client sent {length} bytes", {
×
NEW
295
                                        length: chunk.length,
×
NEW
296
                                });
×
297
                                upstreamSocket.write(chunk);
298
                        });
8✔
299
                        upstreamSocket.on("data", (chunk: any) => {
45✔
300
                                logger.info(
12✔
301
                                        "WebSocket proxy: upstream sent {length} bytes: {preview}",
60✔
302
                                        {
11✔
303
                                                length: chunk.length,
31✔
304
                                                preview: chunk
15✔
305
                                                        .slice(0, 100)
14✔
306
                                                        .toString()
11✔
307
                                                        .trim()
7✔
308
                                                        .replace(/\r\n/g, "\\r\\n"),
34✔
309
                                        },
1✔
310
                                );
10✔
311
                                if (!handshakeComplete) {
34✔
312
                                        handshakeComplete = true;
35✔
313
                                        socket.write(chunk.toString("utf8"));
47✔
314
                                        if (typeof socket.resume === "function") {
40✔
NEW
315
                                                logger.info(
×
NEW
316
                                                        "WebSocket proxy: handshake complete, resuming client socket",
×
NEW
317
                                                );
×
NEW
318
                                                socket.resume();
×
319
                                        } else {
19✔
320
                                                logger.info(
12✔
321
                                                        "WebSocket proxy: client socket does not support resume",
56✔
322
                                                );
20✔
323
                                        }
NEW
324
                                } else {
×
325
                                        socket.write(chunk);
6✔
326
                                }
327
                        });
6✔
328
                });
6✔
329

NEW
330
                upstreamSocket.on("error", (err: any) => {
×
NEW
331
                        logger.error("WebSocket upstream socket error: {error}", { error: err });
×
332
                        socket.destroy();
333
                });
6✔
334

NEW
335
                socket.on("error", (err: any) => {
×
NEW
336
                        logger.debug("WebSocket client socket error: {error}", { error: err });
×
337
                        upstreamSocket.destroy();
338
                });
6✔
339

340
                upstreamSocket.on("close", () => {
39✔
341
                        logger.info("WebSocket proxy: upstream socket closed");
61✔
342
                        socket.destroy();
21✔
343
                });
6✔
344

345
                socket.on("close", () => {
31✔
346
                        logger.info("WebSocket proxy: client socket closed");
59✔
347
                        upstreamSocket.destroy();
29✔
348
                });
7✔
349
        }
350

351
        const originalEmit = app.server.emit;
39✔
352
        app.server.emit = function (this: any, event: string, ...args: any[]) {
39✔
353
                if (event === "upgrade") {
31✔
354
                        const req = args[0];
26✔
355
                        const socket = args[1];
29✔
356
                        const head = args[2];
27✔
357
                        const url = req.url || "";
32✔
358
                        let requestPath = url;
28✔
359
                        const qIndex = requestPath.indexOf("?");
46✔
360
                        if (qIndex !== -1) {
27✔
361
                                requestPath = requestPath.substring(0, qIndex);
53✔
362
                        }
6✔
363
                        if (basePrefix && requestPath.startsWith(basePrefix)) {
53✔
NEW
364
                                requestPath = requestPath.substring(basePrefix.length);
×
365
                        }
6✔
366
                        if (requestPath.match(/^\/route\/([a-zA-Z0-9_-]+)/)) {
61✔
367
                                handleUpgradeRequest(req, socket, head);
48✔
368
                                return true;
11✔
NEW
369
                        }
×
370
                }
4✔
371
                return originalEmit.apply(this, [event, ...args] as any);
52✔
372
        };
373

374
        app.server.on("upgrade", () => {});
27✔
375
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc