@stonyx/rest-server 0.2.1-beta.9 → 0.2.1-beta.91

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.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ [![CI](https://github.com/abofs/stonyx-rest-server/actions/workflows/ci.yml/badge.svg)](https://github.com/abofs/stonyx-rest-server/actions/workflows/ci.yml)
2
+ [![npm version](https://img.shields.io/npm/v/@stonyx/rest-server.svg)](https://www.npmjs.com/package/@stonyx/rest-server)
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+
1
5
  # @stonyx/rest-server
2
6
 
3
7
  REST server module for the [Stonyx framework](https://github.com/abofs/stonyx), providing dynamic route registration and built-in request handling with optional authentication hooks.
@@ -75,8 +79,169 @@ Configuration is read from `stonyx/config` under `restServer`:
75
79
  | `origin` | **String \| Array** | `'*'` | CORS origin(s) allowed |
76
80
  | `methods` | **String** | `'GET,POST,PATCH,PUT,DELETE'` | CORS allowed methods |
77
81
  | `enableHealthCheck` | **Boolean** | `true` | Register `GET /health` endpoint (disable via `REST_HEALTH_CHECK_DISABLE=true`) |
82
+ | `caseSensitiveRoutes` | **Boolean** | `true` | Match route paths case-sensitively. Disable via `REST_CASE_SENSITIVE_ROUTES=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**. |
83
+ | `strictRoutes` | **Boolean** | `true` | Match route paths strictly, so a trailing slash does not match a route registered without one. Disable via `REST_STRICT_ROUTES=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**, and note `GET /health/` now 404s. |
84
+ | `trustProxy` | **Boolean** | `false` | Trust reverse proxy headers (e.g. `X-Forwarded-Proto`). Enable via `REST_TRUST_PROXY=true` when running behind a load balancer such as AWS ALB/ELB to ensure correct protocol detection. |
78
85
  | `statusMap` | **Object** | `{}` | Optional mapping of HTTP status codes to custom messages |
79
86
 
87
+ ### Route Matching Strictness
88
+
89
+ Routes match **case-sensitively and strictly by default**. Two settings, both
90
+ on, both applied at both express construction sites:
91
+
92
+ | axis | setting | example that no longer matches |
93
+ |---|---|---|
94
+ | casing | `case sensitive routing` | `GET /users/Success` -> does not reach `/success` |
95
+ | trailing slash | `strict routing` | `GET /users/success/` -> does not reach `/success` |
96
+
97
+ Read [What this does not do](#what-this-does-not-do) and
98
+ [Upgrading](#upgrading-behaviour-changes) before you rely on that. Two things
99
+ the table does not say: "does not reach the handler" is not the same as "404",
100
+ and one edge of the trailing-slash axis is **not** closed and cannot be.
101
+
102
+ This is deliberate and security-relevant. Express matches both case-insensitively
103
+ and slash-insensitively by default, which means any authorization written
104
+ against the request URL can be walked past by changing the case of the request,
105
+ or by appending one character:
106
+
107
+ ```
108
+ GET /owners/angela -> 404 (correctly filtered)
109
+ GET /OwNeRs/angela -> 200 (full record) <- closed by case sensitive routing
110
+ GET /owners/angela/ -> 200 (full record) <- closed by strict routing
111
+ DELETE /ANIMALS/22 -> 204 (record destroyed)
112
+ DELETE /animals/22/ -> 204 (record destroyed)
113
+ ```
114
+
115
+ The consumer's predicate is stricter than the router that dispatched the
116
+ request, so the router hands the handler a request the predicate would have
117
+ rejected. Measured against this repo's own fixture, before and after:
118
+
119
+ ```
120
+ before after
121
+ GET /private/failure 505 505 (auth hook fires, request blocked)
122
+ GET /private/failure/ 200 404 (auth hook never fired; now a miss)
123
+ GET /private/FAILURE 200 200 (absorbed by /:id — see below)
124
+ ```
125
+
126
+ For a handler that authorizes on `req.path`, the path it sees can now only ever
127
+ be the exact registered spelling, in the exact registered casing, with no
128
+ trailing slash. That closes [#47](https://github.com/abofs/stonyx-rest-server/issues/47)
129
+ and [#50](https://github.com/abofs/stonyx-rest-server/issues/50).
130
+
131
+ #### What this does not do
132
+
133
+ **It does not close the trailing slash on a mount root, and no setting can.**
134
+ This is the one edge that remains open, so do not read the section above as
135
+ closing the class outright:
136
+
137
+ ```
138
+ GET /public -> req.path '/' req.originalUrl '/public'
139
+ GET /public/ -> req.path '/' req.originalUrl '/public/'
140
+ ```
141
+
142
+ Express's router applies mount-prefix matching with `strict: false`
143
+ unconditionally (`router@2.2.0`; the file-and-line citation is in
144
+ [`docs/project-structure.md`](docs/project-structure.md) § *Strict routing
145
+ (#50)*), so both forms reach the mounted route class and both arrive with
146
+ `req.path === '/'`. A hook authorizing on `req.path` cannot tell them apart, so
147
+ for that hook there is no asymmetry to exploit. **A hook comparing
148
+ `req.originalUrl` still sees two different strings, and `strictRoutes` does not
149
+ change that.** If your authorization compares `req.originalUrl` rather than
150
+ `req.path`, keep whatever URL normalization you have.
151
+
152
+ No *setting* closes this, but the module can:
153
+ [#54](https://github.com/abofs/stonyx-rest-server/issues/54) tracks closing it
154
+ with a canonical-path check ahead of the `auth` hook. Until that ships, an
155
+ `originalUrl` hook is bypassed by one character — `GET /admin` denied,
156
+ `GET /admin/` reaching the route class's index handler.
157
+
158
+ **It does not normalize path *parameter values*.** If your `auth()` hook rejects
159
+ `params.id === 'restricted'`, then `GET /private/RESTRICTED` still reaches the
160
+ handler — the router matched the route correctly, and `restricted` and
161
+ `RESTRICTED` are different values. Record ids are legitimately case-sensitive,
162
+ so this is a comparison your application owns. Compare param values with the
163
+ same case-handling you use when you look them up.
164
+
165
+ **A sub-path that misses is not necessarily a 404.** If the route class also
166
+ registers a param route such as `/:id`, a mis-cased sub-path is absorbed by it
167
+ rather than rejected. `GET /private/FAILURE` misses `/failure` and is dispatched
168
+ to `/:id` with `id="FAILURE"` — a different handler, at 200, not a miss; this
169
+ repo's AC5 asserts exactly that. A class exposing `/orders/summary` alongside
170
+ `/orders/:id` will send `GET /orders/SUMMARY` into the `/:id` handler and its
171
+ database lookup. The param route's own `auth()` hook still runs, so this is an
172
+ expectation defect rather than a bypass — but plan for a reroute, not a 404.
173
+
174
+ Note the two axes differ here. A *trailing slash* is not absorbed by `/:id`,
175
+ because `/:id` is equally strict: `GET /private/failure/` misses `/failure` and
176
+ misses `/:id`, and is a true 404.
177
+
178
+ **It does not redirect or rewrite** mixed-case or trailing-slash requests to
179
+ their canonical form. Whether `/Users` is a typo to forgive or an attack to
180
+ reject is an application policy decision, and encoding it here would mint
181
+ another variant of the bug above.
182
+
183
+ #### Upgrading: behaviour changes
184
+
185
+ Both settings change which requests match, so both are consumer-visible.
186
+
187
+ **`GET /health/` now returns 404.** `GET /health` is unaffected. This is the
188
+ change most likely to page someone, and it is an **availability** problem rather
189
+ than a 404 you will read about in a log: if a Kubernetes liveness probe, an ELB
190
+ target-group health check or an uptime monitor is pointed at the trailing-slash
191
+ form, it starts failing and the deployment gets marked unhealthy and cycled.
192
+ This module emits no request logging, so the only symptom is the probe going
193
+ red. **Check your probe URLs before upgrading.**
194
+
195
+ Also affected:
196
+
197
+ - **Param routes.** `/resource/:id/` no longer matches. Any client calling
198
+ `/private/restricted/` gets a 404 where it previously got the param route.
199
+ - **Trailing-slash-normalizing proxies.** nginx `try_files`/`rewrite`, Apache
200
+ `DirectorySlash On` and some CDN edge rules append a slash; behind one of
201
+ those, every route stops matching at once.
202
+ - **Mount paths from filenames.** With `camelCaseRoutes` truthy, `phone-number.ts`
203
+ mounts at `/phoneNumber`, so `GET /phonenumber` returns 404; with it falsy,
204
+ `Users.ts` mounts at `/Users`, so `GET /users` returns 404.
205
+
206
+ A request that stops matching returns express's default `404 Cannot GET /x` with
207
+ no log line and no stack, so it looks like a deploy that dropped a route.
208
+
209
+ #### Opting out
210
+
211
+ Two separate flags, one per axis:
212
+
213
+ ```bash
214
+ REST_CASE_SENSITIVE_ROUTES=false # restores case-insensitive matching (#47)
215
+ REST_STRICT_ROUTES=false # restores trailing-slash tolerance (#50)
216
+ ```
217
+
218
+ or equivalently `restServer: { caseSensitiveRoutes: false, strictRoutes: false }`.
219
+
220
+ **They are deliberately separate keys, and neither implies the other.** Slash
221
+ tolerance is a legitimate need — a health-check URL you cannot change today is
222
+ the common case. Casing tolerance almost never is. Folding them into one flag
223
+ would force anyone who needs the first to accept the second, which is why a
224
+ consumer who took the `#47` opt-out still has to set `REST_STRICT_ROUTES=false`
225
+ separately to keep trailing slashes working.
226
+
227
+ **Each flag restores the corresponding vulnerability described above** — the
228
+ URL-based authorization in your application becomes bypassable along that axis
229
+ again. They exist as one-line remediations for an existing deployment, not as a
230
+ configuration to run on. Set the flag to restore service, then fix the client
231
+ and remove the flag.
232
+
233
+ ### Running Behind a Load Balancer
234
+
235
+ When your application runs behind a reverse proxy or load balancer (e.g. AWS ALB/ELB), the load balancer terminates SSL and forwards requests to your server over HTTP internally. This means Express sees `http` as the protocol even though the original client request used `https`.
236
+
237
+ To fix this, enable the `trustProxy` option:
238
+
239
+ ```bash
240
+ REST_TRUST_PROXY=true
241
+ ```
242
+
243
+ This tells Express to trust the `X-Forwarded-Proto` header set by the load balancer, so `request.protocol` correctly returns `https`. This is important for any functionality that generates URLs based on the incoming request protocol, such as JSON:API relationship links.
244
+
80
245
  ## Request Class
81
246
 
82
247
  The `Request` class provides a structured way to define route handlers and authorization hooks. Route classes extend `Request` and define:
@@ -1,17 +1,76 @@
1
1
  const {
2
+ REST_CASE_SENSITIVE_ROUTES,
2
3
  REST_CORS_ORIGIN,
3
4
  REST_CORS_METHODS,
4
5
  REST_HEALTH_CHECK_DISABLE,
5
6
  REST_PORT,
6
- REST_REQUEST_PATH
7
- } = process;
7
+ REST_REQUEST_PATH,
8
+ REST_STRICT_ROUTES,
9
+ REST_TRUST_PROXY
10
+ } = process.env;
11
+
12
+ const config = {
13
+ // Secure by default: routes match case-sensitively so a consumer's
14
+ // URL-based authorization cannot be walked past by changing case
15
+ // (abofs/stonyx-rest-server#47). Opt out with REST_CASE_SENSITIVE_ROUTES=false
16
+ // only as a temporary remediation for a client that relies on loose casing.
17
+ //
18
+ // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
19
+ // as part of abofs/stonyx-rest-server#43. This line is the only thing the
20
+ // suite still checks about the SHIPPED default. Inverting it to
21
+ // `=== 'true'` turns AC3, AC4 and AC5 red; AC6 stays GREEN, because AC6
22
+ // stubs `caseSensitiveRoutes` to `undefined` and src/route-matching.ts reads
23
+ // `!== false`, so AC6 guards the source's read and not this default.
24
+ // Measured: pin `caseSensitiveRoutes: true` in test/config/environment.ts AND
25
+ // invert this line, and the suite reports 28 pass / 0 fail. A naive pin makes
26
+ // an insecure published default completely invisible to a green suite --
27
+ // quieter and weaker, which is the outcome pinning was supposed to prevent.
28
+ //
29
+ // The cost of leaving it unpinned is that the suite is ambient-sensitive here
30
+ // (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` => 25 pass / 3 fail), but it
31
+ // fails LOUDLY, so there is no false green. Closing #43 for this key needs
32
+ // the subprocess-based env isolation this repo does not yet have; any fix
33
+ // must keep a live assertion on this default.
34
+ caseSensitiveRoutes: REST_CASE_SENSITIVE_ROUTES !== 'false',
35
+
36
+ // Secure by default, same polarity and same reasoning as caseSensitiveRoutes
37
+ // above: routes match strictly, so a trailing slash cannot walk past a
38
+ // consumer's URL-based authorization (abofs/stonyx-rest-server#50).
39
+ //
40
+ // BEHAVIOUR CHANGE for consumers upgrading: `/health/` now returns 404, and
41
+ // param routes like `/resource/:id/` no longer match. Opt out with
42
+ // REST_STRICT_ROUTES=false only as a temporary remediation. It is a separate
43
+ // key from REST_CASE_SENSITIVE_ROUTES on purpose -- opting out of slash
44
+ // strictness must not silently re-open #47's case bypass.
45
+ //
46
+ // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
47
+ // as part of abofs/stonyx-rest-server#43. Same trap as the key above, and now
48
+ // measured for both: pin `strictRoutes: true` in test/config/environment.ts
49
+ // AND invert this line to `=== 'true'`, and the suite reports 31 pass /
50
+ // 0 fail. A naive pin makes an insecure published default completely
51
+ // invisible to a green suite.
52
+ //
53
+ // Unpinned, inverting this line alone turns #50's AC1 and AC2 red (29/2).
54
+ // AC3 stays GREEN under that mutation, because AC3 sets `strictRoutes` on the
55
+ // config object directly and so guards src/route-matching.ts's READ rather
56
+ // than this default -- the two assertions cover different halves and neither
57
+ // subsumes the other.
58
+ //
59
+ // The cost is that the suite is ambient-sensitive here
60
+ // (`REST_STRICT_ROUTES=false pnpm test` => 29 pass / 2 fail), but it fails
61
+ // LOUDLY, so there is no false green. Closing #43 for either key needs
62
+ // subprocess-based env isolation this repo does not have; any fix must keep a
63
+ // live assertion on this default.
64
+ strictRoutes: REST_STRICT_ROUTES !== 'false',
8
65
 
9
- export default {
10
66
  enableHealthCheck: REST_HEALTH_CHECK_DISABLE !== 'true',
11
67
  origin: REST_CORS_ORIGIN ?? '*',
12
68
  methods: REST_CORS_METHODS ?? 'GET,POST,PATCH,PUT,DELETE',
13
69
  dir: REST_REQUEST_PATH ?? './requests',
14
70
  port: REST_PORT ?? 2666,
71
+ trustProxy: REST_TRUST_PROXY === 'true',
15
72
  logColor: 'yellow',
16
73
  logMethod: 'api'
17
74
  };
75
+
76
+ export default config;
package/dist/main.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type Express } from 'express';
2
+ import type { Server } from 'http';
3
+ export { default as Request } from './request.js';
4
+ export default class RestServer {
5
+ static instance: RestServer;
6
+ api: Express;
7
+ server: Server;
8
+ constructor();
9
+ static close(): void;
10
+ init(): Promise<void>;
11
+ setupRouter(): Promise<void>;
12
+ setupGlobalMiddleware(): void;
13
+ mountRoute(routeClassUntyped: unknown, { name, options }: {
14
+ name: string;
15
+ options?: unknown;
16
+ }): void;
17
+ }
18
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAiBA,OAAgB,EAAE,KAAK,OAAO,EAAoE,MAAM,SAAS,CAAC;AAKlH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,CAAC,OAAO,OAAO,UAAU;IAC7B,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC;IAE5B,GAAG,EAAG,OAAO,CAAC;IACd,MAAM,EAAG,MAAM,CAAC;;IAwBhB,MAAM,CAAC,KAAK,IAAI,IAAI;IAQd,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAerB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAelC,qBAAqB,IAAI,IAAI;IAW7B,UAAU,CAAC,iBAAiB,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI;CAarG"}
package/dist/main.js ADDED
@@ -0,0 +1,101 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import cors from 'cors';
17
+ import express from 'express';
18
+ import config from 'stonyx/config';
19
+ import log from 'stonyx/log';
20
+ import { forEachFileImport } from '@stonyx/utils/file';
21
+ import applyRouteMatching from './route-matching.js';
22
+ export { default as Request } from './request.js';
23
+ export default class RestServer {
24
+ static instance;
25
+ api;
26
+ server;
27
+ constructor() {
28
+ if (RestServer.instance)
29
+ return RestServer.instance;
30
+ RestServer.instance = this;
31
+ this.api = express();
32
+ // Applies BOTH route-matching settings: case sensitive routing
33
+ // (abofs/stonyx-rest-server#47) and strict routing (#50). The two do not
34
+ // have the same role at this site:
35
+ // - #47: this call closes the mount segment (/PUBLIC/...). The matching
36
+ // call in Request's constructor closes sub-paths; both are required.
37
+ // - #50: this call closes exactly /health/, the only route registered
38
+ // directly on this app. It has NO security role for #50 -- do not
39
+ // describe it as having one. Router.prototype.use hardcodes
40
+ // `strict: false`, so mount segments are strict-immune, and the call in
41
+ // Request's constructor closes the trailing-slash bypass on its own.
42
+ // Must stay in the constructor: the router is materialized lazily on first
43
+ // route registration, so applying this after setupRouter() is silently
44
+ // ineffective. See src/route-matching.ts for the per-site split.
45
+ applyRouteMatching(this.api);
46
+ }
47
+ static close() {
48
+ if (!RestServer.instance)
49
+ throw new Error('RestServer has not been initialized yet');
50
+ const { server } = RestServer.instance;
51
+ server.closeAllConnections();
52
+ server.close();
53
+ }
54
+ async init() {
55
+ // Self-register so log.api works even when @stonyx/rest-server is in the
56
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
57
+ const { logColor = 'yellow', logMethod = 'api' } = config.restServer;
58
+ log.defineType(logMethod, logColor);
59
+ await this.setupRouter();
60
+ const { port } = config.restServer;
61
+ // start REST server
62
+ this.server = this.api.listen(port);
63
+ log.title(`API Server is listening on port ${port}`);
64
+ }
65
+ async setupRouter() {
66
+ const { camelCaseRoutes, dir, enableHealthCheck } = config.restServer;
67
+ this.setupGlobalMiddleware();
68
+ try {
69
+ await forEachFileImport(dir, this.mountRoute.bind(this), { rawName: !camelCaseRoutes, ignoreAccessFailure: true });
70
+ if (enableHealthCheck)
71
+ this.api.get('/health', (_req, res) => res.sendStatus(200));
72
+ }
73
+ catch (error) {
74
+ if (config.debug)
75
+ console.log(error);
76
+ log.error(`Unable to dynamically configure routes from files in ${dir}`);
77
+ throw new Error(`Unable to dynamically configure routes from files in ${dir}`);
78
+ }
79
+ }
80
+ setupGlobalMiddleware() {
81
+ const { origin, methods, trustProxy } = config.restServer;
82
+ if (trustProxy)
83
+ this.api.set('trust proxy', true);
84
+ this.api.use([
85
+ cors({ origin, methods }),
86
+ express.json()
87
+ ]);
88
+ }
89
+ mountRoute(routeClassUntyped, { name, options }) {
90
+ const routeClass = routeClassUntyped;
91
+ const { api } = this;
92
+ const classInstance = new routeClass(options);
93
+ const route = name === 'index' ? '/' : `/${name}`;
94
+ const { expressInstance } = classInstance;
95
+ classInstance.registerCalls();
96
+ expressInstance.mountpath = route;
97
+ // Mount handler to main api instance
98
+ api.use(route, expressInstance);
99
+ }
100
+ }
101
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import { type Request as ExpressRequest, type Response as ExpressResponse, type Express } from 'express';
2
+ export type RequestState = Record<string, unknown>;
3
+ export type RequestHandler = (req: ExpressRequest, state: RequestState) => unknown | Promise<unknown>;
4
+ export type AuthHandler = (req: ExpressRequest, state: RequestState) => number | undefined;
5
+ export type RouteHandlers = Record<string, Record<string, RequestHandler | RequestHandler[]>>;
6
+ export default class Request {
7
+ static stateProp: string;
8
+ static getState(req: ExpressRequest): RequestState;
9
+ static sendStatusResponse(res: ExpressResponse, status: number): void;
10
+ expressInstance: Express;
11
+ handlers: RouteHandlers;
12
+ auth?: AuthHandler;
13
+ constructor();
14
+ registerCalls(): void;
15
+ }
16
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,KAAK,OAAO,IAAI,cAAc,EAAE,KAAK,QAAQ,IAAI,eAAe,EAAE,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAOlH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AACnD,MAAM,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AACtG,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,MAAM,GAAG,SAAS,CAAC;AAC3F,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,EAAE,CAAC,CAAC,CAAC;AAE9F,MAAM,CAAC,OAAO,OAAO,OAAO;IAC1B,MAAM,CAAC,SAAS,SAAmB;IAEnC,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,YAAY;IASlD,MAAM,CAAC,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAWrE,eAAe,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAG,aAAa,CAAC;IACjB,IAAI,CAAC,EAAE,WAAW,CAAC;;IAmB3B,aAAa,IAAI,IAAI;CAqDtB"}
@@ -0,0 +1,97 @@
1
+ import express from 'express';
2
+ import config from 'stonyx/config';
3
+ import { makeArray } from '@stonyx/utils/object';
4
+ import applyRouteMatching from './route-matching.js';
5
+ const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
6
+ export default class Request {
7
+ static stateProp = '__stonyxState';
8
+ static getState(req) {
9
+ const { stateProp } = Request;
10
+ const record = req;
11
+ if (record[stateProp] !== undefined)
12
+ return record[stateProp];
13
+ record[stateProp] = {};
14
+ return record[stateProp];
15
+ }
16
+ static sendStatusResponse(res, status) {
17
+ const statusMap = config.restServer?.statusMap ?? {};
18
+ const message = statusMap[status] || '';
19
+ if (message) {
20
+ res.status(status).send(message);
21
+ }
22
+ else {
23
+ res.sendStatus(status);
24
+ }
25
+ }
26
+ expressInstance;
27
+ handlers;
28
+ constructor() {
29
+ const api = express();
30
+ api.disable('x-powered-by');
31
+ // Applies BOTH route-matching settings: case sensitive routing
32
+ // (abofs/stonyx-rest-server#47) and strict routing (#50). For #47 this
33
+ // call closes sub-paths (/public/SUCCESS) and the parent's call closes the
34
+ // mount segment; for #50 THIS call closes the entire trailing-slash
35
+ // authorization bypass on its own, and the parent's call has no security
36
+ // role. Must stay in the constructor: registerCalls() materializes this
37
+ // router, and a set applied afterwards has no effect. The parent app's
38
+ // setting does not reach here -- see src/route-matching.ts.
39
+ applyRouteMatching(api);
40
+ this.expressInstance = api;
41
+ }
42
+ registerCalls() {
43
+ const { expressInstance } = this;
44
+ const { getState, sendStatusResponse } = Request;
45
+ for (const [method, handlers] of Object.entries(this.handlers)) {
46
+ if (!METHODS.has(method)) {
47
+ console.warn(`Method "${method}" is not a valid HTTP method. Skipping...`);
48
+ continue;
49
+ }
50
+ for (const [route, handler] of Object.entries(handlers)) {
51
+ expressInstance[method](route, async (req, res) => {
52
+ // Run auth after route matching so request.params is populated
53
+ if (this.auth) {
54
+ const status = this.auth(req, getState(req));
55
+ if (status)
56
+ return sendStatusResponse(res, status);
57
+ }
58
+ const callStack = [...makeArray(handler)];
59
+ const mainCall = callStack.pop();
60
+ let response;
61
+ // Run middleware
62
+ while (callStack.length) {
63
+ response = await callStack.shift().bind(this)(req, getState(req));
64
+ if (response !== undefined)
65
+ break;
66
+ }
67
+ if (response === undefined)
68
+ response = await mainCall(req, getState(req));
69
+ if (Number.isInteger(response))
70
+ return sendStatusResponse(res, response);
71
+ // Handle redirect if set via call state object
72
+ const state = getState(req);
73
+ const { redirect } = state;
74
+ if (redirect)
75
+ return res.redirect(redirect);
76
+ // Handle pipe if set via call state object
77
+ const { pipe } = state;
78
+ if (pipe) {
79
+ const { headers, source } = pipe;
80
+ if (headers)
81
+ for (const [key, value] of Object.entries(headers))
82
+ res.set(key, value);
83
+ return source.pipe(res);
84
+ }
85
+ if (response === undefined) {
86
+ res.sendStatus(200);
87
+ return;
88
+ }
89
+ if (typeof response !== 'object')
90
+ return sendStatusResponse(res, 500);
91
+ res.send(response);
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ //# sourceMappingURL=request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,90 @@
1
+ import type { Express } from 'express';
2
+ /**
3
+ * Applies this module's route-matching settings to an express app.
4
+ *
5
+ * Called from BOTH express construction sites (abofs/stonyx-rest-server#47):
6
+ * `RestServer`'s constructor closes the mount segment (`/PUBLIC/...`), and
7
+ * `Request`'s constructor closes sub-paths (`/public/SUCCESS`). Neither alone
8
+ * is sufficient -- settings are inherited on mount, but `mountRoute()` calls
9
+ * `registerCalls()` before `api.use()`, so each child router is already built
10
+ * by the time the parent's setting could reach it.
11
+ *
12
+ * Both callers invoke this from a constructor, and must keep doing so: express
13
+ * materializes a router lazily on first route registration, and a setting
14
+ * applied afterwards is silently ineffective -- no throw, no warning.
15
+ *
16
+ * The guard is `!== false`, not a plain truthy check, and that polarity is
17
+ * load-bearing. `trustProxy` and `enableHealthCheck` default to the falsy
18
+ * direction, so a missing key fails safe for them. This flag defaults to the
19
+ * truthy direction, so `if (config.restServer?.caseSensitiveRoutes)` would
20
+ * silently fail OPEN for a consumer whose shipped config predates the key.
21
+ *
22
+ * It lives here, in one place, rather than being written out at each call
23
+ * site, so that a single test can anchor it. The invariant is duplicated the
24
+ * moment the expression is: `test/unit/request-test.ts` AC6 reaches this
25
+ * function through `Request`, which means the same assertion now also covers
26
+ * the `RestServer` half. Two copies of the predicate left the parent's copy
27
+ * free to drift -- inverting it, or dropping the condition entirely, kept the
28
+ * suite green.
29
+ *
30
+ * Note `express({ caseSensitive: true })` does NOT work: express 5's
31
+ * `createApplication()` takes zero arguments and forwards nothing. The app
32
+ * setting is the only mechanism. The same is true of `strict`.
33
+ *
34
+ * ---
35
+ *
36
+ * `strict routing` (abofs/stonyx-rest-server#50) closes the same class of
37
+ * authorization bypass for a TRAILING SLASH: `GET /private/failure` was denied
38
+ * by a consumer's auth hook while `GET /private/failure/` reached the guarded
39
+ * handler, because the hook compares `req.path` against `/failure`.
40
+ *
41
+ * It is a SEPARATE key from `caseSensitiveRoutes`, not a rename and not a
42
+ * reuse. Coupling them would force any consumer who legitimately needs
43
+ * trailing-slash tolerance -- a load balancer probing `/health/`, a
44
+ * slash-normalizing proxy -- to re-open #47's case bypass to get it. Case
45
+ * insensitivity is almost never intentional; slash tolerance frequently is.
46
+ *
47
+ * The two settings do NOT share the #47 split above, and this is the one thing
48
+ * not to carry across from that fix. For `strict routing`:
49
+ *
50
+ * - The CHILD site (Request's constructor) closes the entire security
51
+ * defect on its own. The parent site does nothing for it.
52
+ * - The PARENT site (RestServer's constructor) closes exactly one thing in
53
+ * this repo: `/health/`, the only route registered directly on the parent
54
+ * app. It has no security role here; do not describe it as having one.
55
+ *
56
+ * The cause is concrete: `Router.prototype.use` hardcodes `strict: false` (and
57
+ * `end: false`), so mount segments are structurally strict-immune. That is the
58
+ * OPPOSITE of `sensitive`, which `use()` DOES forward, and which is why #47's
59
+ * parent site closed `/PUBLIC/...`. The version-pinned file-and-line citation
60
+ * for that upstream behaviour is deliberately kept in ONE place --
61
+ * `docs/project-structure.md`, section "Strict routing (#50)" -- so a router
62
+ * upgrade invalidates one line rather than five. Both sites still get both
63
+ * settings (they share this function), but the justification differs and the
64
+ * tests are built on the measured split, not on the analogy.
65
+ *
66
+ * Consequence worth stating so nobody expects this setting to cover it: the
67
+ * mount-segment trailing slash (`/public/`) cannot be closed by an express
68
+ * SETTING. For both `/public` and `/public/` the mounted sub-app receives
69
+ * `req.path === '/'`, so a `req.path` auth hook sees no difference, and a test
70
+ * asserting `/public/` -> 404 could never pass.
71
+ *
72
+ * That is NOT the same as saying the edge is harmless or unclosable.
73
+ * `req.originalUrl` does differ, and a hook authorizing on it is bypassed by
74
+ * one character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
75
+ * guarded handler running unauthenticated. That is a live bypass of the same
76
+ * class as #47 and #50, and it IS closable by this module: a canonical-path
77
+ * check ahead of the `auth` call in `Request.registerCalls()`, or opt-in
78
+ * normalizing middleware. It is tracked as abofs/stonyx-rest-server#54. Do not
79
+ * close it here, and do not read this note as saying it cannot be closed.
80
+ *
81
+ * Both guards are `!== false` for the same reason: these flags default to the
82
+ * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
83
+ * config predates the key. Both are also asserted at the unit tier for BOTH
84
+ * failure shapes -- key present-and-`undefined` and key absent as an own
85
+ * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
86
+ * integration tier cannot see either: with the shipped default `true`, a
87
+ * fail-open guard leaves every integration assertion green.
88
+ */
89
+ export default function applyRouteMatching(api: Express): void;
90
+ //# sourceMappingURL=route-matching.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-matching.d.ts","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAG7D"}
@@ -0,0 +1,95 @@
1
+ import config from 'stonyx/config';
2
+ /**
3
+ * Applies this module's route-matching settings to an express app.
4
+ *
5
+ * Called from BOTH express construction sites (abofs/stonyx-rest-server#47):
6
+ * `RestServer`'s constructor closes the mount segment (`/PUBLIC/...`), and
7
+ * `Request`'s constructor closes sub-paths (`/public/SUCCESS`). Neither alone
8
+ * is sufficient -- settings are inherited on mount, but `mountRoute()` calls
9
+ * `registerCalls()` before `api.use()`, so each child router is already built
10
+ * by the time the parent's setting could reach it.
11
+ *
12
+ * Both callers invoke this from a constructor, and must keep doing so: express
13
+ * materializes a router lazily on first route registration, and a setting
14
+ * applied afterwards is silently ineffective -- no throw, no warning.
15
+ *
16
+ * The guard is `!== false`, not a plain truthy check, and that polarity is
17
+ * load-bearing. `trustProxy` and `enableHealthCheck` default to the falsy
18
+ * direction, so a missing key fails safe for them. This flag defaults to the
19
+ * truthy direction, so `if (config.restServer?.caseSensitiveRoutes)` would
20
+ * silently fail OPEN for a consumer whose shipped config predates the key.
21
+ *
22
+ * It lives here, in one place, rather than being written out at each call
23
+ * site, so that a single test can anchor it. The invariant is duplicated the
24
+ * moment the expression is: `test/unit/request-test.ts` AC6 reaches this
25
+ * function through `Request`, which means the same assertion now also covers
26
+ * the `RestServer` half. Two copies of the predicate left the parent's copy
27
+ * free to drift -- inverting it, or dropping the condition entirely, kept the
28
+ * suite green.
29
+ *
30
+ * Note `express({ caseSensitive: true })` does NOT work: express 5's
31
+ * `createApplication()` takes zero arguments and forwards nothing. The app
32
+ * setting is the only mechanism. The same is true of `strict`.
33
+ *
34
+ * ---
35
+ *
36
+ * `strict routing` (abofs/stonyx-rest-server#50) closes the same class of
37
+ * authorization bypass for a TRAILING SLASH: `GET /private/failure` was denied
38
+ * by a consumer's auth hook while `GET /private/failure/` reached the guarded
39
+ * handler, because the hook compares `req.path` against `/failure`.
40
+ *
41
+ * It is a SEPARATE key from `caseSensitiveRoutes`, not a rename and not a
42
+ * reuse. Coupling them would force any consumer who legitimately needs
43
+ * trailing-slash tolerance -- a load balancer probing `/health/`, a
44
+ * slash-normalizing proxy -- to re-open #47's case bypass to get it. Case
45
+ * insensitivity is almost never intentional; slash tolerance frequently is.
46
+ *
47
+ * The two settings do NOT share the #47 split above, and this is the one thing
48
+ * not to carry across from that fix. For `strict routing`:
49
+ *
50
+ * - The CHILD site (Request's constructor) closes the entire security
51
+ * defect on its own. The parent site does nothing for it.
52
+ * - The PARENT site (RestServer's constructor) closes exactly one thing in
53
+ * this repo: `/health/`, the only route registered directly on the parent
54
+ * app. It has no security role here; do not describe it as having one.
55
+ *
56
+ * The cause is concrete: `Router.prototype.use` hardcodes `strict: false` (and
57
+ * `end: false`), so mount segments are structurally strict-immune. That is the
58
+ * OPPOSITE of `sensitive`, which `use()` DOES forward, and which is why #47's
59
+ * parent site closed `/PUBLIC/...`. The version-pinned file-and-line citation
60
+ * for that upstream behaviour is deliberately kept in ONE place --
61
+ * `docs/project-structure.md`, section "Strict routing (#50)" -- so a router
62
+ * upgrade invalidates one line rather than five. Both sites still get both
63
+ * settings (they share this function), but the justification differs and the
64
+ * tests are built on the measured split, not on the analogy.
65
+ *
66
+ * Consequence worth stating so nobody expects this setting to cover it: the
67
+ * mount-segment trailing slash (`/public/`) cannot be closed by an express
68
+ * SETTING. For both `/public` and `/public/` the mounted sub-app receives
69
+ * `req.path === '/'`, so a `req.path` auth hook sees no difference, and a test
70
+ * asserting `/public/` -> 404 could never pass.
71
+ *
72
+ * That is NOT the same as saying the edge is harmless or unclosable.
73
+ * `req.originalUrl` does differ, and a hook authorizing on it is bypassed by
74
+ * one character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
75
+ * guarded handler running unauthenticated. That is a live bypass of the same
76
+ * class as #47 and #50, and it IS closable by this module: a canonical-path
77
+ * check ahead of the `auth` call in `Request.registerCalls()`, or opt-in
78
+ * normalizing middleware. It is tracked as abofs/stonyx-rest-server#54. Do not
79
+ * close it here, and do not read this note as saying it cannot be closed.
80
+ *
81
+ * Both guards are `!== false` for the same reason: these flags default to the
82
+ * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
83
+ * config predates the key. Both are also asserted at the unit tier for BOTH
84
+ * failure shapes -- key present-and-`undefined` and key absent as an own
85
+ * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
86
+ * integration tier cannot see either: with the shipped default `true`, a
87
+ * fail-open guard leaves every integration assertion green.
88
+ */
89
+ export default function applyRouteMatching(api) {
90
+ if (config.restServer?.caseSensitiveRoutes !== false)
91
+ api.set('case sensitive routing', true);
92
+ if (config.restServer?.strictRoutes !== false)
93
+ api.set('strict routing', true);
94
+ }
95
+ //# sourceMappingURL=route-matching.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-matching.js","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,eAAe,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAY;IACrD,IAAI,MAAM,CAAC,UAAU,EAAE,mBAAmB,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC;IAC9F,IAAI,MAAM,CAAC,UAAU,EAAE,YAAY,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,CAAC;AACjF,CAAC"}
package/package.json CHANGED
@@ -4,22 +4,31 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.9",
7
+ "version": "0.2.1-beta.91",
8
8
  "description": "Rest Server Module for Stonyx Framework",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "https://github.com/abofs/stonyx-rest-server"
12
12
  },
13
- "main": "src/main.js",
13
+ "main": "dist/main.js",
14
+ "types": "dist/main.d.ts",
14
15
  "type": "module",
15
16
  "exports": {
16
- ".": "./src/main.js"
17
+ ".": {
18
+ "types": "./dist/main.d.ts",
19
+ "default": "./dist/main.js"
20
+ }
17
21
  },
18
22
  "author": "Stone Costa",
19
23
  "license": "Apache-2.0",
20
24
  "contributors": [
21
25
  "Stone Costa <stone.costa@synamicd.com>"
22
26
  ],
27
+ "files": [
28
+ "dist",
29
+ "config",
30
+ "README.md"
31
+ ],
23
32
  "publishConfig": {
24
33
  "access": "public",
25
34
  "provenance": true
@@ -27,14 +36,23 @@
27
36
  "dependencies": {
28
37
  "cors": "^2.8.5",
29
38
  "express": "^5.1.0",
30
- "stonyx": "0.2.3-beta.4"
39
+ "stonyx": "0.2.3-beta.78"
31
40
  },
32
41
  "devDependencies": {
33
- "@stonyx/utils": "0.2.3-beta.4",
42
+ "@stonyx/utils": "0.2.3-beta.26",
43
+ "@types/cors": "^2.8.17",
44
+ "@types/express": "^5.0.6",
45
+ "@types/node": "^25.5.2",
46
+ "@types/qunit": "^2.19.13",
47
+ "@types/sinon": "^21.0.1",
34
48
  "qunit": "^2.24.1",
35
- "sinon": "^21.0.0"
49
+ "sinon": "^21.0.0",
50
+ "tsx": "^4.21.0",
51
+ "typescript": "^5.8.3"
36
52
  },
37
53
  "scripts": {
38
- "test": "stonyx test"
54
+ "build": "tsc",
55
+ "typecheck": "tsc -p tsconfig.test.json",
56
+ "test": "pnpm build && pnpm typecheck && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
39
57
  }
40
58
  }
@@ -1,44 +0,0 @@
1
- # Improvement Opportunities
2
-
3
- ## 1. `new express()` in RestServer constructor
4
-
5
- **File**: `src/main.js`, line 30
6
-
7
- ```js
8
- this.api = new express();
9
- ```
10
-
11
- Express 5 (which this project targets via `"express": "^5.1.0"`) documents calling `express()` as a plain function, not as a constructor with `new`. While `new express()` works in practice because the function returns a new object regardless, it is unconventional and may break if Express ever enforces non-constructor semantics. Consider changing to:
12
-
13
- ```js
14
- this.api = express();
15
- ```
16
-
17
- Note: The `Request` class in `src/request.js` line 26 already uses the correct pattern (`const api = express();` without `new`).
18
-
19
- ## 2. `setupGlobalMiddleware` is `async` but contains no awaits
20
-
21
- **File**: `src/main.js`, lines 63-70
22
-
23
- ```js
24
- async setupGlobalMiddleware() {
25
- const { origin, methods } = config.restServer;
26
-
27
- this.api.use([
28
- cors({ origin, methods }),
29
- express.json()
30
- ]);
31
- }
32
- ```
33
-
34
- The `async` keyword is unnecessary here since the method body contains no `await` expressions and `app.use()` is synchronous. The `async` keyword causes the method to return a `Promise` wrapping `undefined`, but the caller (`setupRouter`) does not `await` it either — it calls `this.setupGlobalMiddleware()` without `await` on line 52. Removing `async` would make the intent clearer.
35
-
36
- ## 3. `logMethod` config option is not used in rest-server source
37
-
38
- **File**: `config/environment.js`, line 16
39
-
40
- ```js
41
- logMethod: 'api'
42
- ```
43
-
44
- The `logMethod` property is defined in the default config but is never referenced anywhere in `src/main.js` or `src/request.js`. It is likely consumed by the Stonyx framework core (`stonyx/log`) for registering a named log method, but this should be verified. If it is indeed framework-level plumbing, consider documenting that it is a Stonyx convention rather than a rest-server feature. If it is unused, consider removing it to reduce config surface area.
@@ -1,149 +0,0 @@
1
- # @stonyx/rest-server — Project Structure
2
-
3
- ## Overview
4
-
5
- REST server module for the Stonyx framework. Provides dynamic route registration from a file directory, built-in CORS/JSON middleware, per-route authorization hooks, and a structured `Request` base class for defining handlers.
6
-
7
- - **Package**: `@stonyx/rest-server` (v0.2.1-beta.1)
8
- - **License**: Apache-2.0
9
- - **Entry point**: `src/main.js`
10
- - **Module type**: ESM (`"type": "module"`)
11
- - **Node version**: v24.13.0 (per `.nvmrc`)
12
- - **Package manager**: pnpm
13
-
14
- ## Architecture
15
-
16
- ### RestServer (src/main.js)
17
-
18
- Singleton class wrapping an Express 5 instance.
19
-
20
- - **Constructor** — enforces singleton via `RestServer.instance`; creates the Express app with `new express()`
21
- - **`init()`** — calls `setupRouter()`, then starts listening on the configured port
22
- - **`setupRouter()`** — calls `setupGlobalMiddleware()`, then uses `forEachFileImport` (from `@stonyx/utils/file`) to dynamically import all files in the configured `dir` and mount each as a route via `mountRoute()`. Optionally registers a `/health` endpoint.
23
- - **`setupGlobalMiddleware()`** — attaches `cors()` and `express.json()` middleware to the Express app
24
- - **`mountRoute(routeClass, { name, options })`** — instantiates the imported Request subclass, wires up the `authorization` middleware if present, calls `registerCalls()`, and mounts the sub-app at `/<filename>`
25
- - **`RestServer.close()`** — static method to close the server
26
-
27
- ### Request (src/request.js)
28
-
29
- Base class for route definitions. Each file in the requests directory exports a class extending `Request`.
30
-
31
- - **Constructor** — creates a child Express instance with `x-powered-by` disabled
32
- - **`handlers`** — instance property: object mapping HTTP methods (`get`, `post`, `put`, `delete`, `patch`) to route-path/handler pairs
33
- - **`auth(req, state)`** — optional hook. Return an integer status code to reject the request; return nothing to allow it through.
34
- - **`authorization(req, res, next)`** — wrapper that calls `auth()` and short-circuits with a status response if it returns a code
35
- - **`registerCalls()`** — iterates `handlers`, registers each route on the child Express instance. Supports:
36
- - Single handler function or array (last element is the main handler, preceding elements are middleware)
37
- - Middleware functions are bound to the class instance and executed in order
38
- - Integer return = status code response (via `sendStatusResponse`)
39
- - Object return = JSON response
40
- - `undefined` return = 200 OK
41
- - Pipe support via `state.pipe` (sets headers and pipes a stream)
42
- - **`Request.getState(req)`** — attaches/retrieves a `__stonyxState` object on the Express request
43
- - **`Request.sendStatusResponse(res, status)`** — sends status with optional custom message from `config.restServer.statusMap`
44
-
45
- Valid HTTP methods (enforced): `get`, `post`, `put`, `delete`, `patch`
46
-
47
- ## Configuration Reference
48
-
49
- From `config/environment.js`. All values are overridable via environment variables.
50
-
51
- | Option | Type | Default | Env Var | Description |
52
- |---------------------|-------------------|-------------------------------|----------------------------|-----------------------------------------------------------------|
53
- | `enableHealthCheck` | **Boolean** | `true` | `REST_HEALTH_CHECK_DISABLE=true` to disable | Registers `GET /health` returning 200 |
54
- | `origin` | **String** | `'*'` | `REST_CORS_ORIGIN` | CORS allowed origin(s) |
55
- | `methods` | **String** | `'GET,POST,PATCH,PUT,DELETE'` | `REST_CORS_METHODS` | CORS allowed methods |
56
- | `dir` | **String** | `'./requests'` | `REST_REQUEST_PATH` | Directory containing Request class files to mount as routes |
57
- | `port` | **Number/String** | `2666` | `REST_PORT` | Port the REST server listens on |
58
- | `logColor` | **String** | `'yellow'` | — | Console log color for this module (Stonyx logging integration) |
59
- | `logMethod` | **String** | `'api'` | — | Log method name (Stonyx logging integration) |
60
-
61
- Additional config used (not rest-server-specific):
62
- - `config.debug` (top-level Stonyx config) — if truthy, logs errors during route setup
63
- - `config.restServer.statusMap` (optional, no default in environment.js) — maps status codes to custom message strings
64
- - `config.restServer.camelCaseRoutes` (optional, no default in environment.js) — when falsy, passes `rawName: true` to `forEachFileImport` so filenames are used as-is for route paths
65
-
66
- ## Test Structure
67
-
68
- Tests use **QUnit** and run via `stonyx test` (the `npm test` script).
69
-
70
- ### test/config/environment.js
71
- Overrides `restServer.dir` to `'./test/sample/requests'` so tests load sample request classes.
72
-
73
- ### test/unit/request-test.js
74
- Unit tests for `Request` static methods:
75
- - `getState` — creates/returns state object on request
76
- - `sendStatusResponse` — sends status with optional `statusMap` message
77
-
78
- ### test/integration/rest-server-test.js
79
- Integration tests that boot the full server and make HTTP requests:
80
- - 404 for non-existent routes
81
- - `/public` — JSON response, 200 OK default, URL params, middleware (success/failure), `this` binding for handlers and middleware
82
- - `/private` — authenticated success, auth hook rejection (505)
83
- - `/health` — health check endpoint returns 200
84
-
85
- ### test/sample/requests/
86
- Sample Request subclasses used by integration tests:
87
- - `public.js` — `PublicRequest` with various GET handlers demonstrating middleware, params, binding
88
- - `private.js` — `PrivateRequest` with `auth()` hook that rejects `/failure` with 505
89
-
90
- ## CI/CD
91
-
92
- ### .github/workflows/ci.yml
93
- Runs on pull requests to `dev` and `main`. Delegates to shared workflow at `abofs/stonyx-workflows`.
94
-
95
- ### .github/workflows/publish.yml
96
- Publishes to NPM. Triggered by:
97
- - `workflow_dispatch` with version-type selection (patch/minor/major) or custom version
98
- - Pull requests to `main`/`dev`
99
- - Pushes to `main`
100
-
101
- Delegates to `abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main`.
102
-
103
- ## File Structure
104
-
105
- ```
106
- stonyx-rest-server/
107
- ├── .claude/
108
- │ ├── improvements.md
109
- │ └── project-structure.md # this file
110
- ├── .github/
111
- │ └── workflows/
112
- │ ├── ci.yml # PR CI — delegates to shared workflow
113
- │ └── publish.yml # NPM publish — delegates to shared workflow
114
- ├── config/
115
- │ └── environment.js # Default config with env var overrides
116
- ├── src/
117
- │ ├── main.js # RestServer class (singleton, Express wrapper)
118
- │ └── request.js # Request base class (handler registration, auth hook)
119
- ├── test/
120
- │ ├── config/
121
- │ │ └── environment.js # Test config override (dir → test/sample/requests)
122
- │ ├── integration/
123
- │ │ └── rest-server-test.js # Integration tests (QUnit)
124
- │ ├── sample/
125
- │ │ └── requests/
126
- │ │ ├── private.js # Sample private request with auth hook
127
- │ │ └── public.js # Sample public request with middleware demos
128
- │ └── unit/
129
- │ └── request-test.js # Unit tests for Request statics (QUnit)
130
- ├── .gitignore
131
- ├── .npmignore
132
- ├── .nvmrc # Node v24.13.0
133
- ├── LICENSE.md # Apache 2.0
134
- ├── package.json
135
- ├── pnpm-lock.yaml
136
- └── README.md
137
- ```
138
-
139
- ## Dependencies
140
-
141
- ### Runtime
142
- - `cors` ^2.8.5 — CORS middleware
143
- - `express` ^5.1.0 — HTTP framework
144
- - `stonyx` (local link) — Framework core (config, logging)
145
-
146
- ### Dev
147
- - `@stonyx/utils` (local link) — Utility functions (file import, object helpers)
148
- - `qunit` ^2.24.1 — Test framework
149
- - `sinon` ^21.0.0 — Test stubs/spies
@@ -1,16 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- branches: [dev, main]
6
-
7
- concurrency:
8
- group: ci-${{ github.head_ref || github.ref }}
9
- cancel-in-progress: true
10
-
11
- permissions:
12
- contents: read
13
-
14
- jobs:
15
- test:
16
- uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
@@ -1,51 +0,0 @@
1
- name: Publish to NPM
2
-
3
- on:
4
- repository_dispatch:
5
- types: [cascade-publish]
6
- workflow_dispatch:
7
- inputs:
8
- version-type:
9
- description: 'Version type'
10
- required: true
11
- type: choice
12
- options:
13
- - patch
14
- - minor
15
- - major
16
- custom-version:
17
- description: 'Custom version (optional, overrides version-type)'
18
- required: false
19
- type: string
20
- pull_request:
21
- types: [opened, synchronize, reopened]
22
- branches: [main]
23
- push:
24
- branches: [main]
25
-
26
- concurrency:
27
- group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
28
- cancel-in-progress: false
29
-
30
- permissions:
31
- contents: write
32
- id-token: write
33
- pull-requests: write
34
-
35
- jobs:
36
- publish:
37
- if: "!contains(github.event.head_commit.message, '[skip ci]')"
38
- uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
39
- with:
40
- version-type: ${{ github.event.inputs.version-type }}
41
- custom-version: ${{ github.event.inputs.custom-version }}
42
- cascade-source: ${{ github.event.client_payload.source_package || '' }}
43
- secrets: inherit
44
-
45
- cascade:
46
- needs: publish
47
- uses: abofs/stonyx-workflows/.github/workflows/cascade.yml@main
48
- with:
49
- package-name: ${{ needs.publish.outputs.package-name }}
50
- published-version: ${{ needs.publish.outputs.published-version }}
51
- secrets: inherit
package/src/main.js DELETED
@@ -1,89 +0,0 @@
1
- /*
2
- * Copyright 2025 Stone Costa
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the 'License');
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- import cors from 'cors';
18
- import express from 'express';
19
- import config from 'stonyx/config';
20
- import log from 'stonyx/log';
21
- import { forEachFileImport } from '@stonyx/utils/file';
22
-
23
- export { default as Request } from './request.js';
24
-
25
- export default class RestServer {
26
- constructor() {
27
- if (RestServer.instance) return RestServer.instance;
28
- RestServer.instance = this;
29
-
30
- this.api = new express();
31
- }
32
-
33
- static close() {
34
- if (!RestServer.instance) throw new Error('RestServer has not been initialized yet');
35
-
36
- RestServer.instance.server.close();
37
- }
38
-
39
- async init() {
40
- await this.setupRouter();
41
-
42
- const { port } = config.restServer;
43
-
44
- // start REST server
45
- this.server = this.api.listen(port);
46
- log.title(`API Server is listening on port ${port}`);
47
- }
48
-
49
- async setupRouter() {
50
- const { camelCaseRoutes, dir, enableHealthCheck } = config.restServer;
51
- this.setupGlobalMiddleware();
52
-
53
- try {
54
- await forEachFileImport(dir, this.mountRoute.bind(this), { rawName: !camelCaseRoutes, ignoreAccessFailure: true });
55
-
56
- if (enableHealthCheck) this.api.get('/health', (_req, res) => res.sendStatus(200));
57
- } catch (error) {
58
- if (config.debug) console.log(error);
59
- throw log.error(`Unable to dynamically configure routes from files in ${dir}`);
60
- }
61
- }
62
-
63
- async setupGlobalMiddleware() {
64
- const { origin, methods } = config.restServer;
65
-
66
- this.api.use([
67
- cors({ origin, methods }),
68
- express.json()
69
- ]);
70
- }
71
-
72
- async mountRoute(routeClass, { name, options }) {
73
- const { api } = this;
74
- const classInstance = new routeClass(options);
75
- const route = name === 'index' ? '/' : `/${name}`;
76
- const { expressInstance, authorization } = classInstance;
77
-
78
- const routeCalls = [ expressInstance ];
79
-
80
- // Assign auth callback if it exists
81
- if (authorization) routeCalls.unshift(authorization.bind(classInstance));
82
-
83
- classInstance.registerCalls();
84
- expressInstance.mountpath = route;
85
-
86
- // Mount handler to main api instance
87
- api.use(route, ...routeCalls);
88
- }
89
- }
package/src/request.js DELETED
@@ -1,85 +0,0 @@
1
- import express from 'express';
2
- import config from 'stonyx/config';
3
- import { makeArray } from '@stonyx/utils/object';
4
-
5
- const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
6
-
7
- export default class Request {
8
- static stateProp = '__stonyxState';
9
-
10
- static getState(req) {
11
- const { stateProp } = Request;
12
- if (req[stateProp] !== undefined) return req[stateProp];
13
-
14
- req[stateProp] = {};
15
- return req[stateProp];
16
- }
17
-
18
- static sendStatusResponse(res, status) {
19
- const statusMap = config.restServer?.statusMap || {};
20
- const message = statusMap[status] || '';
21
-
22
- return message ? res.status(status).send(message) : res.sendStatus(status);
23
- }
24
-
25
- constructor() {
26
- const api = express();
27
- api.disable('x-powered-by');
28
-
29
- this.expressInstance = api;
30
- }
31
-
32
- // auth hook wrapper
33
- authorization(req, res, next) {
34
- if (!this.auth) return next();
35
-
36
- const status = this.auth(req, Request.getState(req));
37
- if (status) return Request.sendStatusResponse(res, status);
38
-
39
- next();
40
- }
41
-
42
- registerCalls() {
43
- const { expressInstance } = this;
44
- const { getState } = Request;
45
-
46
- for (const [method, handlers] of Object.entries(this.handlers)) {
47
- if (!METHODS.has(method)) {
48
- console.warn(`Method "${method}" is not a valid HTTP method. Skipping...`);
49
- continue;
50
- }
51
-
52
- for (const [route, handler] of Object.entries(handlers)) {
53
- expressInstance[method](route, async (req, res) => {
54
- const callStack = [...makeArray(handler)];
55
- const mainCall = callStack.pop();
56
- const { sendStatusResponse } = Request;
57
- let response;
58
-
59
- // Run middleware
60
- while(callStack.length) {
61
- response = await callStack.shift().bind(this)(req, getState(req));
62
- if (response !== undefined) break;
63
- }
64
-
65
- if (response === undefined) response = await mainCall(req, getState(req));
66
- if (Number.isInteger(response)) return sendStatusResponse(res, response);
67
-
68
- // Handle pipe if set via call state object
69
- const { pipe } = getState(req);
70
- if (pipe) {
71
- const { headers, source } = pipe;
72
-
73
- if (headers) for (const [key, value] of Object.entries(headers)) res.set(key, value);
74
- return source.pipe(res);
75
- }
76
-
77
- if (response === undefined) return res.sendStatus(200);
78
- if (typeof response !== 'object') return sendStatusResponse(res, 500);
79
-
80
- res.send(response);
81
- });
82
- }
83
- }
84
- }
85
- }