geonix 1.23.6 → 1.23.8

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,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Skill(code-review:code-review)",
5
+ "Bash(node --test --test-reporter=spec tests/integration/service.test.js tests/integration/stream.test.js)",
6
+ "Bash(node --test --test-reporter=spec tests/integration/gateway.test.js)",
7
+ "Bash(timeout 90 node --test --test-reporter=tap tests/integration/gateway.test.js)"
8
+ ]
9
+ }
10
+ }
package/PROJECT.md ADDED
@@ -0,0 +1,164 @@
1
+ # Geonix
2
+
3
+ ## What It Is
4
+
5
+ Geonix is a Node.js microservices framework built on top of [NATS](https://nats.io/). It allows you to write self-announcing services as plain JavaScript classes, with HTTP endpoints defined as specially-named class methods. A gateway component dynamically discovers services and routes HTTP/WebSocket traffic to them.
6
+
7
+ Published on npm as `geonix`, version 1.23.6. MIT licensed. Author: Davor Tarandek, Tria d.o.o.
8
+
9
+ ---
10
+
11
+ ## Core Concept
12
+
13
+ You write a service like this:
14
+
15
+ ```js
16
+ import { Service } from "geonix";
17
+
18
+ class MyService extends Service {
19
+ "GET /hello"(req, res) {
20
+ res.send("Hello World");
21
+ }
22
+ someRpcMethod(input) {
23
+ return input.toUpperCase();
24
+ }
25
+ }
26
+
27
+ MyService.start();
28
+ ```
29
+
30
+ That's it. The service:
31
+ - Starts an embedded Express HTTP server on a random port
32
+ - Announces itself on NATS every second (a "beacon")
33
+ - Accepts remote procedure calls over NATS
34
+ - Can be discovered and called by the Gateway or other services
35
+
36
+ ---
37
+
38
+ ## Architecture
39
+
40
+ ```
41
+ Client (HTTP/WS)
42
+
43
+ [Gateway] ── discovers services via NATS beacons
44
+
45
+ ┌────┴────┐
46
+ │ Service │ ── embedded Express HTTP server
47
+ │ Service │ ── announces on NATS gx2.beacon every 1s
48
+ │ Service │
49
+ └─────────┘
50
+
51
+ [NATS] ── message bus for beacons, RPC, streaming
52
+ ```
53
+
54
+ ### Components
55
+
56
+ | File | Role |
57
+ |------|------|
58
+ | `src/Service.js` | Base class for all services. Manages HTTP endpoints, NATS RPC listeners, beacons. |
59
+ | `src/Gateway.js` | HTTP reverse proxy. Subscribes to beacons, builds a dynamic Express router. |
60
+ | `src/Connection.js` | Singleton NATS connection wrapper. Supports multiple connections with round-robin. |
61
+ | `src/Registry.js` | Maintains a live map of available services from beacon messages. Entries expire after 5s. |
62
+ | `src/Request.js` | Sends RPC calls to services over NATS. Waits up to 5 minutes for service to appear. |
63
+ | `src/Remote.js` | Proxy-based sugar: `Remote("ServiceName").methodName(args)` → RPC call. |
64
+ | `src/Stream.js` | Wraps large payloads as streamable objects. Can stream over HTTP or NATS. |
65
+ | `src/WebServer.js` | Shared internal Express instance for all services in the same process. |
66
+ | `src/Util.js` | Utilities: multipart parser, HTTP proxy, TCP server creation, ID generation, deep merge. |
67
+ | `src/Codec.js` | NATS JSON codec wrapper. |
68
+ | `src/Logger.js` | Simple timestamped console logger. |
69
+
70
+ ---
71
+
72
+ ## Key Mechanisms
73
+
74
+ ### Service Discovery
75
+ - Services publish a beacon on `gx2.beacon` every 1 second
76
+ - The `Registry` class subscribes to beacons and tracks all live services
77
+ - Entries expire after 5 seconds without a beacon
78
+ - Each service has: instance ID, name, version, method list, network addresses
79
+
80
+ ### Routing (Gateway)
81
+ - On startup, the Gateway subscribes to all beacons
82
+ - Every second it checks for newly added or removed services
83
+ - When services change, it rebuilds the Express router with updated routes
84
+ - If the backend service is directly reachable via HTTP (same network), it proxies directly
85
+ - If not, it tunnels HTTP over NATS using a TCP-over-NATS bridge
86
+
87
+ ### RPC Calls
88
+ - `Request("ServiceName", "methodName", [args])` resolves the service identifier from the registry
89
+ - It publishes to `gx2.service.<sha256(identifier)>` with a reply-to subject
90
+ - The service picks it up from its queue subscription and responds
91
+ - Load balancing is built-in: multiple instances of `ServiceName@version` use NATS queue groups
92
+
93
+ ### Streaming
94
+ - When a payload exceeds NATS max payload size (~512KB), it's automatically converted to a `Stream` object
95
+ - The `Stream` object contains `{ $: "stream", id, a: [addresses] }`
96
+ - Consumers use `getReadable()` to retrieve the stream via HTTP or NATS
97
+
98
+ ### Versioning
99
+ - Services can specify a version via `process.env.VERSION`, options, or `this.version`
100
+ - The gateway does semver-aware routing and always routes to the highest version
101
+ - RPC calls can target a specific version: `Request("MyService@1.2.x", "method", [])`
102
+
103
+ ---
104
+
105
+ ## Endpoint Declaration Syntax
106
+
107
+ Methods on a Service class are matched against this pattern:
108
+ ```
109
+ [options|]VERB /path
110
+ ```
111
+
112
+ Examples:
113
+ - `"GET /api/users"` — HTTP GET endpoint
114
+ - `"POST /upload"` — HTTP POST endpoint
115
+ - `"WS /chat"` — WebSocket endpoint
116
+ - `"SUB my.nats.subject"` — NATS subscription handler
117
+ - `"order=10|GET /api"` — HTTP GET with routing priority 10
118
+
119
+ ---
120
+
121
+ ## Transport
122
+
123
+ - **NATS** (required): service discovery, RPC, streaming fallback
124
+ - **HTTP**: direct service-to-service and gateway-to-service communication
125
+ - **TCP-over-NATS**: tunnel for HTTP traffic when direct addressing is not possible
126
+
127
+ ---
128
+
129
+ ## Built-in Service Methods
130
+
131
+ All services expose these internal methods (callable over NATS, not over HTTP):
132
+
133
+ | Method | Returns |
134
+ |--------|---------|
135
+ | `$createConnection(streamId)` | Opens a TCP tunnel for proxying HTTP |
136
+ | `$getEnv()` | System info, Node.js version, `process.env`, memory/CPU usage |
137
+ | `$getServiceInfo()` | Service metadata (name, version, methods, addresses) |
138
+
139
+ ---
140
+
141
+ ## Configuration
142
+
143
+ | Env Variable | Default | Description |
144
+ |---|---|---|
145
+ | `TRANSPORT` | `nats://localhost` | NATS server URL |
146
+ | `PORT` | `8080` | Gateway listen port |
147
+ | `LOCAL_PORT` | random | Force service HTTP server to a specific port |
148
+ | `VERSION` | auto-generated | Service version |
149
+ | `NODE_ENV` | — | Set to `production` to disable debug endpoint |
150
+ | `TRANSPORT_DEBUG` | `false` | Enable NATS debug logging |
151
+
152
+ ---
153
+
154
+ ## Dependencies
155
+
156
+ | Package | Purpose |
157
+ |---------|---------|
158
+ | `express` | HTTP server |
159
+ | `express-ws` | WebSocket support for Express |
160
+ | `express-async-errors` | Async error handling for Express |
161
+ | `nats` | NATS client |
162
+ | `cookie-parser` | Cookie parsing middleware |
163
+ | `semver` | Semantic versioning comparisons |
164
+ | `ws` | WebSocket client (for gateway WS proxy) |
package/REVIEW.md ADDED
@@ -0,0 +1,372 @@
1
+ # Code Review: Geonix
2
+
3
+ ---
4
+
5
+ ## Honest Opinion on the Idea
6
+
7
+ The core idea is clever and genuinely useful: service discovery + RPC + HTTP routing in one framework, with zero external service registry. Classes become services, methods become endpoints, and everything discovers each other automatically via NATS beacons. For small teams deploying Node.js microservices on the same NATS network, this could eliminate a lot of boilerplate.
8
+
9
+ The design does lean heavily on some opinionated tradeoffs:
10
+
11
+ - **The method-name-as-endpoint convention** (`"GET /path"`) is novel but weird. It works in JS, but it's surprising to most developers, not statically typed, and can silently misbehave when method names overlap or are substrings of each other (see bugs below). It also means your service's public interface is entangled with its internal class structure.
12
+ - **Using NATS as both the transport and the discovery layer** is clever but means you're fully coupled to NATS. If NATS goes down, everything stops. There's no degraded-mode operation.
13
+ - **The gateway rebuilding its routing table from live beacons** is interesting but creates a window during which newly deployed services are unreachable, and destroyed services may still receive traffic.
14
+ - **Trust model is zero**: any process on the NATS bus can call any method on any service, including methods that expose `process.env`. This is the biggest design-level concern.
15
+
16
+ Overall: a reasonable idea for a trusted internal microservices environment, but it needs significant hardening before it's safe for anything facing external networks or for multi-tenant deployments.
17
+
18
+ ---
19
+
20
+ ## Security Issues
21
+
22
+ ### S1 — `$getEnv()` exposes all environment variables to any NATS caller (CRITICAL)
23
+
24
+ **File:** `src/Service.js:312-324`
25
+
26
+ ```js
27
+ $getEnv() {
28
+ return {
29
+ ...
30
+ env: process.env, // all secrets: API keys, DB passwords, tokens
31
+ ...
32
+ };
33
+ }
34
+ ```
35
+
36
+ This method is accessible to any service on the NATS bus. It is **not** gated by authentication. Methods prefixed with `$` are excluded from HTTP routes (`m: fields.filter(methodName => !methodName.startsWith("$"))`), but they can be called directly over NATS via:
37
+
38
+ ```js
39
+ await Request("MyService", "$getEnv");
40
+ ```
41
+
42
+ Any compromised service, or any process that can connect to NATS, can harvest credentials from all other services. There is no way to opt out of this method — it is always present on every service.
43
+
44
+ The same information is available at the debug endpoint's `/info` route (`Gateway.js:260-272`).
45
+
46
+ ---
47
+
48
+ ### S2 — CORS configuration allows credential theft from any origin (HIGH)
49
+
50
+ **File:** `src/Gateway.js:71-85`
51
+
52
+ ```js
53
+ res.set("Access-Control-Allow-Credentials", "true");
54
+ res.set("Access-Control-Allow-Origin", origin || "*");
55
+ res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
56
+ res.set("Access-Control-Allow-Headers", requestHeaders || allHeaders);
57
+ ```
58
+
59
+ This reflects the request's `Origin` back as the allowed origin, which is effectively "allow all origins." Combined with `Allow-Credentials: true`, this means **any website on the internet can make authenticated cross-origin requests** (including requests with cookies or auth headers) to the gateway.
60
+
61
+ This is actually more dangerous than `Access-Control-Allow-Origin: *` because `*` does not allow credentials, but the current implementation does.
62
+
63
+ ---
64
+
65
+ ### S3 — Debug endpoint exposes `process.env` and internal state in non-production (HIGH)
66
+
67
+ **File:** `src/Gateway.js:13`, `src/Gateway.js:260-272`
68
+
69
+ ```js
70
+ const DEBUG_ENDPOINT = "/lZ6jD2eC3iP0zB3jJ1yJ9pM8gG3yI3vS";
71
+ ```
72
+
73
+ The debug endpoint is "protected" by a random-looking path, but that path is published in source code (visible on npm and any git host). The `/info` route sends the full `process.env`. The endpoint is disabled in `NODE_ENV=production`, but any non-production deployment with an exposed gateway leaks all secrets.
74
+
75
+ Additionally, the `/router-registry` route dumps the entire internal registry including backend addresses.
76
+
77
+ ---
78
+
79
+ ### S4 — No authentication or authorization on NATS RPC layer (HIGH)
80
+
81
+ **File:** `src/Service.js` (entire file), `src/Request.js`
82
+
83
+ The NATS bus is fully trusted. Any process that can publish to `gx2.service.<hash>` can invoke any method on any service. This includes:
84
+ - Internal business logic methods
85
+ - `$createConnection` — can open TCP tunnels into the service
86
+ - `$getEnv` — leaks secrets
87
+ - `$getServiceInfo` — leaks internal service metadata
88
+
89
+ If NATS is ever exposed to an untrusted network (misconfigured firewall, a compromised service, etc.), the entire cluster is fully compromised. There is no way to add per-method authorization at the framework level.
90
+
91
+ ---
92
+
93
+ ### S5 — `randomSafeId` uses `Math.random()` (MEDIUM)
94
+
95
+ **File:** `src/Util.js:389-398`
96
+
97
+ ```js
98
+ export function randomSafeId(size = 12) {
99
+ const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
100
+ let result = "";
101
+ for (let i = 0; i < size; i++) {
102
+ result += charset.charAt(Math.floor(Math.random() * charset.length));
103
+ }
104
+ return result;
105
+ }
106
+ ```
107
+
108
+ This is used in `tempFilename()` to generate temporary file paths. `Math.random()` is not cryptographically secure and is predictable. An attacker who can observe some outputs can predict future filenames and pre-create symlinks to redirect where uploaded files are written. `randomBytes` is already imported and used by `picoid()` — `randomSafeId` should use it too.
109
+
110
+ ---
111
+
112
+ ### S6 — Multipart parser ignores declared `maxFileSize` and `maxFiles` limits (MEDIUM)
113
+
114
+ **File:** `src/Util.js:255-383`, `src/Types.js:3-6`
115
+
116
+ `parseMultipartOptions` is typed to accept `maxFileSize` and `maxFiles`, but neither is checked anywhere in `parseMultipart()`. A caller relying on these options to enforce upload limits gets no protection. With the default body limit of 1GB (`express.raw({ limit: "1024mb" })`), a single request could consume all disk space.
117
+
118
+ ---
119
+
120
+ ## Performance Issues
121
+
122
+ ### P1 — RPC resolution uses a CPU-burning polling loop (HIGH)
123
+
124
+ **File:** `src/Request.js:77-84`
125
+
126
+ ```js
127
+ const delay = 5;
128
+ let retries = Math.floor(registryTimeout / delay); // 60,000 iterations for 5-minute timeout
129
+ while (identifier == null && retries-- > 0) {
130
+ identifier = registry.getIdentifier(name, version, id);
131
+ if (!identifier) {
132
+ await sleep(delay);
133
+ }
134
+ }
135
+ ```
136
+
137
+ For every call to a service that hasn't been discovered yet, this executes up to 60,000 iterations, each calling `registry.getIdentifier()` (which iterates all registry entries) and sleeping 5ms. With a 5-minute default timeout, a failed RPC call keeps a tight polling loop running for 5 minutes.
138
+
139
+ If many concurrent requests target a missing service, this creates severe CPU pressure. The registry already emits events via `EventEmitter` — waiting on the `"added"` event would be far more efficient.
140
+
141
+ ---
142
+
143
+ ### P2 — Beacon frequency vs. expiry creates unnecessary NATS traffic (MEDIUM)
144
+
145
+ **File:** `src/Service.js:121-130`, `src/Registry.js:8`
146
+
147
+ ```js
148
+ const REGISTRY_ENTRY_TIMEOUT = 5000; // entries expire after 5s
149
+ // but beacons are sent every 1s:
150
+ await sleep(1000);
151
+ ```
152
+
153
+ Each service sends a beacon every 1 second, but entries only expire after 5 seconds. With N services all sending beacons every second, this is N messages/second on `gx2.beacon`. With `fullBeacon: false` (short beacon), there's an additional `$getServiceInfo` RPC call per short beacon per receiving node. At scale this creates significant traffic with no value beyond faster failure detection. The interval should be configurable, or should use NATS's built-in heartbeat facilities.
154
+
155
+ ---
156
+
157
+ ### P3 — Large payload streams are double-encoded for Stream fallback (MEDIUM)
158
+
159
+ **File:** `src/Connection.js:116-120`
160
+
161
+ ```js
162
+ if (payload.length > this.getMaxPayloadSize()) {
163
+ payload = encode(Stream(JSON.stringify(json)));
164
+ }
165
+ ```
166
+
167
+ When a payload exceeds the max size, it is:
168
+ 1. JSON-encoded by `JSON.stringify`
169
+ 2. Wrapped in a `Stream` object
170
+ 3. JSON-encoded again by `encode()`
171
+
172
+ The inner `JSON.stringify` + outer `encode()` (which is also JSON) means the data is encoded twice. The receiver must decode twice, which is handled correctly, but it's wasteful for large payloads where performance matters most.
173
+
174
+ ---
175
+
176
+ ### P4 — The endpoint ordering sort in `Service.#start` is O(n²) per endpoint (LOW)
177
+
178
+ **File:** `src/Service.js:68-77`
179
+
180
+ ```js
181
+ fields.sort((a, b, ia = -1, ib = -1) => {
182
+ for (let line = 0; line < serviceSource.length; line++) {
183
+ ia = serviceSource[line].includes(a) ? line : ia;
184
+ }
185
+ for (let line = 0; line < serviceSource.length; line++) {
186
+ ib = serviceSource[line].includes(b) ? line : ib;
187
+ }
188
+ return ia - ib;
189
+ });
190
+ ```
191
+
192
+ The sort comparator does two full O(lines) scans of the source code for every comparison. For a service with N endpoints and M source lines, this is O(N log N × M) total. This only runs once at startup, but for large services it could cause noticeable startup delay. It also has a correctness issue (see B3).
193
+
194
+ ---
195
+
196
+ ## Bugs
197
+
198
+ ### B1 — WebSocket load balancing is broken: backend is fixed at router build time (HIGH)
199
+
200
+ **File:** `src/Gateway.js:394-406`
201
+
202
+ ```js
203
+ // Outside the handler — backend selected ONCE at build time:
204
+ let backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
205
+
206
+ if (verb === "ws") {
207
+ router.ws(uri, (ws, req) => {
208
+ let target = cleanupWebsocketUrl(`ws://${backend}${req.originalUrl}`);
209
+ this.#proxyWebsocketOverNats(target, ws, req); // always uses the same backend
210
+ });
211
+ ```
212
+
213
+ For HTTP routes, there is a runtime re-evaluation at line 410 (`backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length]`). But for WebSocket routes, the `backend` variable is captured by closure at router build time and never updated. All WebSocket connections after the first router build will target the same backend instance, even when multiple instances exist.
214
+
215
+ ---
216
+
217
+ ### B2 — `Gateway.#start()` accepts connections before middleware is registered (MEDIUM)
218
+
219
+ **File:** `src/Gateway.js:59-127`
220
+
221
+ ```js
222
+ async #start(port = 8080) {
223
+ await connection.waitUntilReady();
224
+
225
+ this.#port = process.env.PORT || port;
226
+ this.#api.listen(this.#port); // server starts accepting connections here
227
+
228
+ logger.debug(...);
229
+
230
+ // middleware added AFTER listen:
231
+ this.#api.use(requestLogger);
232
+ this.#api.use(/* CORS */);
233
+ this.#api.use(DEBUG_ENDPOINT, this.#debugRouter());
234
+ ...
235
+ ```
236
+
237
+ Express registers middleware in order, and while this works for middleware definitions (Express adds them to a list for subsequent requests), there is a race condition: requests arriving between `listen()` and the `use()` calls will be processed with no CORS headers, no request logging, and potentially no debug route protection. The correct pattern is to register all middleware before calling `listen()`.
238
+
239
+ ---
240
+
241
+ ### B3 — Endpoint ordering sort is broken for methods whose names are substrings of each other (MEDIUM)
242
+
243
+ **File:** `src/Service.js:68-77`
244
+
245
+ ```js
246
+ ia = serviceSource[line].includes(a) ? line : ia;
247
+ ```
248
+
249
+ This uses `String.includes()` instead of an exact match. If you have methods `"GET /user"` and `"GET /users"`, any line containing `"GET /users"` will also match `"GET /user"` because `"GET /user"` is a substring of `"GET /users"`. This can assign the wrong line number for endpoint ordering, causing routes to be registered in the wrong order — which matters for Express routing (more specific routes should come first).
250
+
251
+ ---
252
+
253
+ ### B4 — WebSocket proxy does not close the inbound socket on backend connection failure (MEDIUM)
254
+
255
+ **File:** `src/Gateway.js:310-328`
256
+
257
+ ```js
258
+ #proxyWebsocketOverNats(target, inbound, req) {
259
+ try {
260
+ const backend = new WebSocket(target, { headers: { ...req.headers } });
261
+
262
+ backend.on("open", () => {
263
+ // setup relay...
264
+ });
265
+ } catch (e) {
266
+ logger.error(e); // error is logged, but inbound socket is never closed
267
+ }
268
+ }
269
+ ```
270
+
271
+ If the `new WebSocket(target)` constructor throws synchronously, or if the `"open"` event never fires because the backend is unreachable, the `inbound` WebSocket is never closed. The client will hang indefinitely with an open connection that receives no data and never closes.
272
+
273
+ ---
274
+
275
+ ### B5 — `deepMerge` mutates the shared `defaultServiceOptions` object (MEDIUM)
276
+
277
+ **File:** `src/Service.js:53-57`, `src/Util.js:400-416`
278
+
279
+ ```js
280
+ #options = defaultServiceOptions; // shares reference, does not copy
281
+
282
+ async #start(options = {}) {
283
+ this.#options = deepMerge(this.#options, options); // mutates defaultServiceOptions in place
284
+ ```
285
+
286
+ `deepMerge` modifies its first argument in place and returns it. Because `this.#options` is initialized as a reference to the module-level `defaultServiceOptions` constant, the first `Service.start()` call with a non-default `options` argument will mutate the shared default. Subsequent services started in the same process will start with the already-mutated defaults rather than the intended defaults.
287
+
288
+ Example: if `ServiceA.start({ middleware: { json: false } })` runs first, then `ServiceB.start()` will also have `middleware.json === false`.
289
+
290
+ ---
291
+
292
+ ### B6 — `Connection.monitorStatus()` advances the round-robin and only monitors one connection (LOW)
293
+
294
+ **File:** `src/Connection.js:68-72`
295
+
296
+ ```js
297
+ async monitorStatus() {
298
+ for await (const event of this.#getConnection().status()) {
299
+ ```
300
+
301
+ `#getConnection()` increments `#connectionRoundRobin` as a side effect. When `monitorStatus()` is called right after `start()` (line 64), it permanently moves the round-robin index forward by one and monitors only one of the available connections. For `connections > 1`, the other connections' disconnections/reconnections are silently unmonitored.
302
+
303
+ ---
304
+
305
+ ### B7 — `createHTTPServer` uses `net` (TCP) instead of `http` (LOW)
306
+
307
+ **File:** `src/Util.js:116`
308
+
309
+ ```js
310
+ export const createHTTPServer = (handler, start = 30000, poolSize = 20000) =>
311
+ createServerAtFreePort(net, handler, start, poolSize);
312
+ // ^^^ should be `http`, not `net`
313
+ ```
314
+
315
+ `createHTTPServer` is named to suggest it creates an HTTP server but calls `createServerAtFreePort` with the `net` module (raw TCP), making it identical to `createTCPServer`. The function is not used anywhere in the codebase currently, but any future caller expecting an HTTP server will get a raw TCP server.
316
+
317
+ ---
318
+
319
+ ### B8 — No timeout or cleanup for orphaned streams (LOW)
320
+
321
+ **File:** `src/Stream.js:41-56`, `src/Service.js:269-310`
322
+
323
+ In `Stream()`, if the NATS control message on `gx2.stream.<id>.a` never arrives (e.g., the receiver crashed before consuming the stream), the entry persists in `activeStreams` indefinitely, leaking memory and potentially holding open file handles.
324
+
325
+ Similarly, in `$createConnection`, if the control message on `gx2.stream.<id>.c` never arrives (e.g., the gateway crashed), the TCP connection and both NATS subscriptions stay open indefinitely. There is no timeout to force cleanup of orphaned connections.
326
+
327
+ ---
328
+
329
+ ## Design Observations
330
+
331
+ ### D1 — No way to mark methods as non-RPC-callable
332
+
333
+ All public instance methods (not starting with `$`, not `constructor` or `onStart`) are advertised via beacon and callable over NATS. There is no annotation or convention to make a method private/internal. This means any helper method you add to a service is automatically exposed as an RPC endpoint.
334
+
335
+ ### D2 — Service beacon advertises its HTTP port to all services
336
+
337
+ Every beacon includes `a: getNetworkAddresses().map(address => \`\${address}:\${webserver.getPort()}\`)`. This means every service on the network learns the internal ports of every other service. In environments where services should not be directly reachable from each other (e.g., different network segments), the gateway acts as the intended chokepoint, but services inadvertently publish their direct addresses.
338
+
339
+ ### D3 — Endpoint options parsed from the method name with `querystring.parse`
340
+
341
+ **File:** `src/Gateway.js:351-353`
342
+
343
+ ```js
344
+ ...(endpoint.options ? querystring.parse(endpoint.options) : {})
345
+ ```
346
+
347
+ The `querystring` module is deprecated (Node.js recommends `URLSearchParams`). More importantly, the `order` option is parsed as a string and later cast with `parseInt()` — type errors are silent. Any typo in endpoint options silently gets an `order` of `NaN` which sorts unpredictably.
348
+
349
+ ---
350
+
351
+ ## Summary Table
352
+
353
+ | ID | Severity | Category | Issue |
354
+ |----|----------|----------|-------|
355
+ | S1 | Critical | Security | `$getEnv()` exposes all secrets to any NATS caller |
356
+ | S2 | High | Security | CORS reflects origin + allows credentials — any site can make authenticated requests |
357
+ | S3 | High | Security | Debug endpoint leaks `process.env` (path is in public source) |
358
+ | S4 | High | Security | Zero authentication on NATS RPC layer |
359
+ | S5 | Medium | Security | `randomSafeId` uses `Math.random()` for temp file names |
360
+ | S6 | Medium | Security | `maxFileSize`/`maxFiles` multipart options are silently ignored |
361
+ | P1 | High | Performance | 60,000-iteration polling loop for unresolved RPC service lookup |
362
+ | P2 | Medium | Performance | Beacon rate (1s) vs. expiry (5s) generates excessive NATS traffic at scale |
363
+ | P3 | Medium | Performance | Large payloads double-encoded on stream fallback path |
364
+ | P4 | Low | Performance | O(n² × lines) endpoint ordering sort at startup |
365
+ | B1 | High | Bug | WebSocket round-robin broken — backend fixed at router build time |
366
+ | B2 | Medium | Bug | Gateway listens before middleware is registered (race at startup) |
367
+ | B3 | Medium | Bug | Endpoint sort uses substring match — breaks ordering for similar-named routes |
368
+ | B4 | Medium | Bug | Inbound WebSocket never closed on backend connection failure |
369
+ | B5 | Medium | Bug | `deepMerge` mutates shared `defaultServiceOptions` across service instances |
370
+ | B6 | Low | Bug | `monitorStatus()` advances round-robin and only monitors one connection |
371
+ | B7 | Low | Bug | `createHTTPServer` creates a TCP server, not an HTTP server |
372
+ | B8 | Low | Bug | No timeout for orphaned stream/connection cleanup |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geonix",
3
- "version": "1.23.6",
3
+ "version": "1.23.8",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "bin": {
@@ -8,7 +8,9 @@
8
8
  },
9
9
  "main": "exports.js",
10
10
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1",
11
+ "test": "node --test --test-reporter=spec 'tests/unit/*.test.js' && node --test-concurrency=1 --test-reporter=spec 'tests/integration/*.test.js'",
12
+ "test:unit": "node --test --test-reporter=spec 'tests/unit/*.test.js'",
13
+ "test:integration": "node --test-concurrency=1 --test-reporter=spec 'tests/integration/*.test.js'",
12
14
  "build": "npx tsc && cat build/* > index.d.ts && rm -rf build",
13
15
  "lint": "npx eslint src",
14
16
  "deploy": "npm run build && npm publish"
package/src/Gateway.js CHANGED
@@ -37,6 +37,8 @@ export class Gateway {
37
37
  return new Gateway(opts);
38
38
  }
39
39
 
40
+ #isActive = false;
41
+
40
42
  #opts = defaultOpts;
41
43
  #api = express();
42
44
  #router = (req, res, next) => next();
@@ -57,6 +59,8 @@ export class Gateway {
57
59
  }
58
60
 
59
61
  async #start(port = 8080) {
62
+ this.#isActive = true;
63
+
60
64
  await connection.waitUntilReady();
61
65
 
62
66
  this.#port = process.env.PORT || port;
@@ -132,7 +136,7 @@ export class Gateway {
132
136
  }
133
137
  }, 1000);
134
138
 
135
- while (true) {
139
+ while (this.#isActive) {
136
140
  try {
137
141
  // send keeplive to check if connection is still alive
138
142
  await connection.publish("gx.gateway.keepalive", Date.now());
@@ -149,9 +153,10 @@ export class Gateway {
149
153
  }
150
154
  }
151
155
 
156
+ await this.#api.close();
157
+
152
158
  // terminate process
153
159
  logger.debug("geonix.gateway: stopped");
154
- process.exit(0);
155
160
  }
156
161
 
157
162
  async #handleAddedServics() {
@@ -425,4 +430,8 @@ export class Gateway {
425
430
  }
426
431
  }
427
432
 
433
+ async stop() {
434
+ this.#isActive = false;
435
+ }
436
+
428
437
  }
package/src/Registry.js CHANGED
@@ -12,6 +12,7 @@ const REGISTRY_ENTRY_TIMEOUT = 5000;
12
12
  */
13
13
  class Registry extends EventEmitter {
14
14
 
15
+ #isActive = false;
15
16
  #registry = {};
16
17
 
17
18
  constructor() {
@@ -21,12 +22,17 @@ class Registry extends EventEmitter {
21
22
  }
22
23
 
23
24
  async #start() {
25
+ this.#isActive = true;
24
26
  await connection.waitUntilReady();
25
27
 
26
28
  this.#beaconListener();
27
29
  this.#garbageCollector();
28
30
  }
29
31
 
32
+ async stop() {
33
+ this.#isActive = false;
34
+ }
35
+
30
36
  async #beaconListener() {
31
37
  const subscription = await connection.subscribe("gx2.beacon");
32
38
 
@@ -55,7 +61,7 @@ class Registry extends EventEmitter {
55
61
  * Remove stale entries from the list
56
62
  */
57
63
  async #garbageCollector() {
58
- while (true) {
64
+ while (this.#isActive) {
59
65
  const now = Date.now();
60
66
  for (let identifier in this.#registry) {
61
67
  const entry = this.#registry[identifier];
package/src/Service.js CHANGED
@@ -50,10 +50,12 @@ export class Service {
50
50
 
51
51
  // ---------------------------------------------------------------------------------------------
52
52
 
53
+ #isActive = false;
53
54
  #me = {};
54
55
  #options = defaultServiceOptions;
55
56
 
56
57
  async #start(options = {}) {
58
+ this.#isActive = true;
57
59
  this.#options = deepMerge(this.#options, options); // { ...this.#options, ...options };
58
60
 
59
61
  await webserver.waitUntilReady();
@@ -328,4 +330,8 @@ export class Service {
328
330
  return this.#me;
329
331
  }
330
332
 
333
+ async $stop() {
334
+ this.#isActive = false;
335
+ }
336
+
331
337
  }
package/src/Util.js CHANGED
@@ -235,6 +235,10 @@ export function getNetworkAddresses() {
235
235
  if (addressObject.family === "IPv4") {
236
236
  list.push(addressObject.address);
237
237
  }
238
+
239
+ if (addressObject.family === "IPv6") {
240
+ list.push(`[${addressObject.address}]`);
241
+ }
238
242
  }
239
243
  }
240
244
 
package/test/context.js DELETED
@@ -1,35 +0,0 @@
1
- import { Remote, Service } from "../exports.js";
2
- import { sleep } from "../src/Util.js";
3
-
4
- class TimeService extends Service {
5
-
6
- #timestamp() {
7
- return new Date().toISOString();
8
- }
9
-
10
- getCurrentTime() {
11
- const [prefix] = this.context;
12
-
13
- return `${prefix} ${this.#timestamp()}`;
14
- }
15
-
16
- }
17
-
18
- class ApplicationService extends Service {
19
-
20
- #timeService = Remote("TimeService", "prefix");
21
-
22
- async onStart() {
23
- while (true) {
24
- const time = await this.#timeService.getCurrentTime();
25
-
26
- console.log("TIME =", time);
27
-
28
- await sleep(1000);
29
- }
30
- }
31
-
32
- }
33
-
34
- TimeService.start();
35
- ApplicationService.start();
@@ -1,24 +0,0 @@
1
- import { Gateway, Service, streamToBuffer } from "../exports.js";
2
- import { parseMultipart, sleep } from "../src/Util.js";
3
-
4
- class TestService extends Service {
5
-
6
- "GET /test/"(req, res) {
7
- res.send("Hello World");
8
- }
9
-
10
- }
11
-
12
- class Application extends Service {
13
-
14
- "GET /"(req, res) {
15
- res.send("app");
16
- }
17
-
18
- }
19
-
20
- Application.start();
21
-
22
- await sleep(3000);
23
-
24
- TestService.start();
package/test/gateway.js DELETED
@@ -1,34 +0,0 @@
1
- import { Gateway, Service, streamToBuffer } from "../exports.js";
2
- import { parseMultipart } from "../src/Util.js";
3
-
4
- // class TestService extends Service {
5
-
6
- // "GET /"(req, res) {
7
- // res.send("Hello World");
8
- // }
9
-
10
- // async "POST /upload"(req, res) {
11
- // const parts = await parseMultipart(req, { useMemory: false });
12
-
13
- // for (const part of parts) {
14
- // console.log(part.body);
15
- // }
16
-
17
- // res.send("OK");
18
- // }
19
-
20
- // }
21
-
22
- // TestService.start({
23
- // middleware: {
24
- // raw: true,
25
- // json: false,
26
- // cookies: false,
27
- // }
28
- // });
29
- // Gateway.start({
30
- // beforeRequest: (req, res) => {
31
- // res.set("X-Test", "Test");
32
- // }
33
- // });
34
- Gateway.start();
@@ -1,24 +0,0 @@
1
- import { Router } from "express";
2
- import { ServeStatic, Service } from "../exports.js";
3
-
4
- function special(req, res) {
5
- console.log("THIS:", this);
6
-
7
- res.send(`Hello ${this.value}`);
8
- };
9
-
10
- class ApplicationService extends Service {
11
-
12
- value = "World!";
13
-
14
- async onStart() {
15
- }
16
-
17
- "GET *" = [special, ServeStatic("test/static", { indexOn404: true })];
18
- // "GET *" = [(req, res) => {
19
- // res.send(`Hello ${this.value}`);
20
- // }];
21
-
22
- }
23
-
24
- ApplicationService.start();
package/test/package.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "name": "test",
3
- "version": "1.0.0",
4
- "description": "",
5
- "type": "module",
6
- "main": "stream.js",
7
- "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
9
- },
10
- "keywords": [],
11
- "author": "",
12
- "license": "ISC",
13
- "dependencies": {
14
- "geonix": "file:.."
15
- }
16
- }
package/test/pubsub.js DELETED
@@ -1,29 +0,0 @@
1
- import { Publish, Remote, Service } from "../exports.js";
2
- import { sleep } from "../src/Util.js";
3
-
4
- class TimeService extends Service {
5
-
6
- counter = 0;
7
-
8
- "SUB test,q1"(data) {
9
- console.log(`SUB test (${this.counter++})`, data);
10
- }
11
-
12
- }
13
-
14
- class ApplicationService extends Service {
15
-
16
- async onStart() {
17
- await sleep(1000);
18
-
19
- while (true) {
20
- await Publish("test", "Hello World");
21
- await sleep(1000);
22
- }
23
- }
24
-
25
- }
26
-
27
- TimeService.start();
28
- TimeService.start();
29
- ApplicationService.start();
package/test/simple.js DELETED
@@ -1,29 +0,0 @@
1
- import { Remote, Service } from "../exports.js";
2
- import { sleep } from "../src/Util.js";
3
-
4
- class TimeService extends Service {
5
-
6
- getCurrentTime() {
7
- return new Date().toISOString();
8
- }
9
-
10
- }
11
-
12
- class ApplicationService extends Service {
13
-
14
- #timeService = Remote("TimeService");
15
-
16
- async onStart() {
17
- while (true) {
18
- const time = await this.#timeService.getCurrentTime();
19
-
20
- console.log("TIME =", time);
21
-
22
- await sleep(1000);
23
- }
24
- }
25
-
26
- }
27
-
28
- TimeService.start();
29
- ApplicationService.start();
@@ -1 +0,0 @@
1
- Works!
package/test/stream.js DELETED
@@ -1,43 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { Stream, getReadable, connection } from "geonix";
3
- import { createWriteStream, readFileSync } from "node:fs";
4
- import { createHash } from "node:crypto";
5
- import { pipeline } from "node:stream/promises";
6
- import { webserver } from "../src/WebServer.js";
7
-
8
- await connection.waitUntilReady();
9
-
10
- const hash = data => createHash("sha512").update(data).digest("base64");
11
-
12
- const PAYLOAD_SIZE = 1024 * 1024 * 1024;
13
- const TEMP_FILE = "/tmp/geonix.stream_test";
14
-
15
- await webserver.start();
16
-
17
- console.time("test");
18
- try {
19
- const payload = randomBytes(PAYLOAD_SIZE);
20
- const sourceHash = hash(payload);
21
-
22
- const stream = Stream(payload);
23
-
24
- const source = await getReadable(stream);
25
- const dest = createWriteStream(TEMP_FILE);
26
-
27
- await pipeline(source, dest);
28
-
29
- const check = readFileSync(TEMP_FILE);
30
- const destHash = hash(check);
31
-
32
- if (sourceHash == destHash) {
33
- console.log("MATCH");
34
- } else {
35
- console.error("Destination does not match the source!");
36
- console.log("P =", payload);
37
- console.log("C =", check);
38
- }
39
- } catch (e) {
40
- console.error(e);
41
- } finally {
42
- console.timeEnd("test");
43
- }
package/test/upload.js DELETED
@@ -1,34 +0,0 @@
1
- import { write } from "fs";
2
- import { Service, streamToBuffer } from "../exports.js";
3
- import { parseMultipart } from "../src/Util.js";
4
- import { writeFile } from "fs/promises";
5
-
6
- class UploadService extends Service {
7
-
8
- async "POST /upload"(req, res) {
9
- console.log(req.body);
10
- let size = 0;
11
-
12
- req.on("data", (chunk) => {
13
- console.log(`size=${size}`);
14
- size += chunk.length;
15
- });
16
- req.on("end", () => {
17
- console.log(`size=${size}`);
18
- });
19
-
20
- // const files = await parseMultipart(req, { useMemory: false });
21
-
22
- // for (const file of files) {
23
- // file.body = await streamToBuffer(file.body);
24
- // console.log(`${file.filename} ${file.headers["content-type"]} size=${file.body.length}`);
25
-
26
- // await writeFile("/tmp/temp.pdf", file.body);
27
- // }
28
-
29
- res.send("ok");
30
- }
31
-
32
- }
33
-
34
- UploadService.start({ middleware: { raw: false } });
package/test/ws_auth.js DELETED
@@ -1,21 +0,0 @@
1
- import { Gateway, ServeStatic, Service } from "../exports.js";
2
-
3
- class ServiceFirst extends Service {
4
- "WS /ws/first" = (ws) => {
5
- ws.on("message", (message) => {
6
- ws.send("first:" + message);
7
- });
8
- };
9
- }
10
-
11
- class ServiceSecond extends Service {
12
- "WS /ws/second" = (ws) => {
13
- ws.on("message", (message) => {
14
- ws.send("second:" + message);
15
- });
16
- };
17
- }
18
-
19
- Gateway.start();
20
- ServiceFirst.start();
21
- ServiceSecond.start();