@databehave/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/dist/admin/admin-routes.d.ts +42 -0
  5. package/dist/admin/admin-routes.d.ts.map +1 -0
  6. package/dist/admin/admin-routes.js +472 -0
  7. package/dist/admin/admin-routes.js.map +1 -0
  8. package/dist/admin/admin-types.d.ts +170 -0
  9. package/dist/admin/admin-types.d.ts.map +1 -0
  10. package/dist/admin/admin-types.js +30 -0
  11. package/dist/admin/admin-types.js.map +1 -0
  12. package/dist/admin/banner.d.ts +11 -0
  13. package/dist/admin/banner.d.ts.map +1 -0
  14. package/dist/admin/banner.js +5 -0
  15. package/dist/admin/banner.js.map +1 -0
  16. package/dist/admin/header-parser.d.ts +14 -0
  17. package/dist/admin/header-parser.d.ts.map +1 -0
  18. package/dist/admin/header-parser.js +171 -0
  19. package/dist/admin/header-parser.js.map +1 -0
  20. package/dist/admin/inject.d.ts +48 -0
  21. package/dist/admin/inject.d.ts.map +1 -0
  22. package/dist/admin/inject.js +167 -0
  23. package/dist/admin/inject.js.map +1 -0
  24. package/dist/admin/overrides-store.d.ts +18 -0
  25. package/dist/admin/overrides-store.d.ts.map +1 -0
  26. package/dist/admin/overrides-store.js +100 -0
  27. package/dist/admin/overrides-store.js.map +1 -0
  28. package/dist/admin/resolve-cors.d.ts +22 -0
  29. package/dist/admin/resolve-cors.d.ts.map +1 -0
  30. package/dist/admin/resolve-cors.js +28 -0
  31. package/dist/admin/resolve-cors.js.map +1 -0
  32. package/dist/admin/scenarios-store.d.ts +15 -0
  33. package/dist/admin/scenarios-store.d.ts.map +1 -0
  34. package/dist/admin/scenarios-store.js +109 -0
  35. package/dist/admin/scenarios-store.js.map +1 -0
  36. package/dist/admin/ui.css +2 -0
  37. package/dist/admin/ui.html +13 -0
  38. package/dist/admin/ui.js +240 -0
  39. package/dist/bin-helpers.d.ts +46 -0
  40. package/dist/bin-helpers.d.ts.map +1 -0
  41. package/dist/bin-helpers.js +61 -0
  42. package/dist/bin-helpers.js.map +1 -0
  43. package/dist/bin.d.ts +3 -0
  44. package/dist/bin.d.ts.map +1 -0
  45. package/dist/bin.js +57 -0
  46. package/dist/bin.js.map +1 -0
  47. package/dist/cors.d.ts +57 -0
  48. package/dist/cors.d.ts.map +1 -0
  49. package/dist/cors.js +167 -0
  50. package/dist/cors.js.map +1 -0
  51. package/dist/define.d.ts +26 -0
  52. package/dist/define.d.ts.map +1 -0
  53. package/dist/define.js +55 -0
  54. package/dist/define.js.map +1 -0
  55. package/dist/index.d.ts +21 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +19 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/json-config.d.ts +170 -0
  60. package/dist/json-config.d.ts.map +1 -0
  61. package/dist/json-config.js +684 -0
  62. package/dist/json-config.js.map +1 -0
  63. package/dist/jsonc.d.ts +24 -0
  64. package/dist/jsonc.d.ts.map +1 -0
  65. package/dist/jsonc.js +125 -0
  66. package/dist/jsonc.js.map +1 -0
  67. package/dist/log.d.ts +57 -0
  68. package/dist/log.d.ts.map +1 -0
  69. package/dist/log.js +103 -0
  70. package/dist/log.js.map +1 -0
  71. package/dist/middleware/request-validation.d.ts +58 -0
  72. package/dist/middleware/request-validation.d.ts.map +1 -0
  73. package/dist/middleware/request-validation.js +328 -0
  74. package/dist/middleware/request-validation.js.map +1 -0
  75. package/dist/mock-mode.d.ts +95 -0
  76. package/dist/mock-mode.d.ts.map +1 -0
  77. package/dist/mock-mode.js +95 -0
  78. package/dist/mock-mode.js.map +1 -0
  79. package/dist/openapi/auto-schema.d.ts +83 -0
  80. package/dist/openapi/auto-schema.d.ts.map +1 -0
  81. package/dist/openapi/auto-schema.js +232 -0
  82. package/dist/openapi/auto-schema.js.map +1 -0
  83. package/dist/openapi/generate.d.ts +20 -0
  84. package/dist/openapi/generate.d.ts.map +1 -0
  85. package/dist/openapi/generate.js +121 -0
  86. package/dist/openapi/generate.js.map +1 -0
  87. package/dist/openapi/loader.d.ts +18 -0
  88. package/dist/openapi/loader.d.ts.map +1 -0
  89. package/dist/openapi/loader.js +25 -0
  90. package/dist/openapi/loader.js.map +1 -0
  91. package/dist/openapi/register.d.ts +58 -0
  92. package/dist/openapi/register.d.ts.map +1 -0
  93. package/dist/openapi/register.js +166 -0
  94. package/dist/openapi/register.js.map +1 -0
  95. package/dist/openapi/seed.d.ts +16 -0
  96. package/dist/openapi/seed.d.ts.map +1 -0
  97. package/dist/openapi/seed.js +58 -0
  98. package/dist/openapi/seed.js.map +1 -0
  99. package/dist/openapi/translate.d.ts +55 -0
  100. package/dist/openapi/translate.d.ts.map +1 -0
  101. package/dist/openapi/translate.js +319 -0
  102. package/dist/openapi/translate.js.map +1 -0
  103. package/dist/openapi/walker.d.ts +44 -0
  104. package/dist/openapi/walker.d.ts.map +1 -0
  105. package/dist/openapi/walker.js +15 -0
  106. package/dist/openapi/walker.js.map +1 -0
  107. package/dist/request.d.ts +19 -0
  108. package/dist/request.d.ts.map +1 -0
  109. package/dist/request.js +66 -0
  110. package/dist/request.js.map +1 -0
  111. package/dist/response.d.ts +13 -0
  112. package/dist/response.d.ts.map +1 -0
  113. package/dist/response.js +43 -0
  114. package/dist/response.js.map +1 -0
  115. package/dist/route-key.d.ts +60 -0
  116. package/dist/route-key.d.ts.map +1 -0
  117. package/dist/route-key.js +105 -0
  118. package/dist/route-key.js.map +1 -0
  119. package/dist/run.d.ts +27 -0
  120. package/dist/run.d.ts.map +1 -0
  121. package/dist/run.js +87 -0
  122. package/dist/run.js.map +1 -0
  123. package/dist/server.d.ts +60 -0
  124. package/dist/server.d.ts.map +1 -0
  125. package/dist/server.js +497 -0
  126. package/dist/server.js.map +1 -0
  127. package/dist/types.d.ts +274 -0
  128. package/dist/types.d.ts.map +1 -0
  129. package/dist/types.js +9 -0
  130. package/dist/types.js.map +1 -0
  131. package/dist/validation/validate.d.ts +38 -0
  132. package/dist/validation/validate.d.ts.map +1 -0
  133. package/dist/validation/validate.js +372 -0
  134. package/dist/validation/validate.js.map +1 -0
  135. package/docs/admin/error-mode.md +14 -0
  136. package/docs/admin/headers.md +63 -0
  137. package/docs/admin/index.md +111 -0
  138. package/docs/admin/overrides.md +23 -0
  139. package/docs/admin/rest-api.md +90 -0
  140. package/docs/admin/scenarios.md +28 -0
  141. package/docs/cli.md +76 -0
  142. package/docs/config/admin.md +62 -0
  143. package/docs/config/cors.md +72 -0
  144. package/docs/config/endpoints.md +162 -0
  145. package/docs/config/index.md +107 -0
  146. package/docs/config/log.md +43 -0
  147. package/docs/config/mock-mode.md +84 -0
  148. package/docs/config/openapi.md +8 -0
  149. package/docs/config/schema.md +34 -0
  150. package/docs/config/server.md +32 -0
  151. package/docs/config/validation.md +161 -0
  152. package/docs/design.md +517 -0
  153. package/docs/errors.md +192 -0
  154. package/docs/getting-started.md +146 -0
  155. package/docs/index.md +23 -0
  156. package/docs/openapi/auto-schema.md +146 -0
  157. package/docs/openapi/fallback.md +242 -0
  158. package/docs/openapi/keywords.md +78 -0
  159. package/docs/recipes.md +258 -0
  160. package/docs/stability.md +118 -0
  161. package/docs/troubleshooting.md +191 -0
  162. package/package.json +80 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@databehave/server` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-06-03
11
+
12
+ Initial public release of `@databehave/server`.
13
+
14
+ ### Added
15
+
16
+ - JSON / JSONC config-driven mock HTTP server with hand-written and
17
+ OpenAPI-derived routes.
18
+ - OpenAPI walker with auto-schema mode (optional `@databehave/schema` peer)
19
+ for realistic, deterministic response data.
20
+ - Per-route override panel via opt-in admin UI; deterministic seeding
21
+ knobs (fixed / `stable` / `random`).
22
+ - Per-request hooks (`onRequest`, `onResponse`, `onError`,
23
+ `onServerError`), CORS, opt-in inbound request validation, opt-in
24
+ access logs.
25
+ - Public API renamed to drop the `DataBehaveKit` prefix; request /
26
+ response facades exposed as `MockRequest` / `MockResponse` to avoid
27
+ shadowing `globalThis.Request` / `Response`.
28
+ - `seedFor` / `SeedInput` exported for callers deriving their own seeds.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ochairo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ <!-- markdownlint-disable MD033 MD041 -->
2
+
3
+ <div align="center">
4
+
5
+ # @databehave/server
6
+
7
+ <b>A JSON file config-driven mock server</b>
8
+
9
+ [![npm version](https://img.shields.io/npm/v/@databehave/server.svg)](https://www.npmjs.com/package/@databehave/server)
10
+ [![CI](https://github.com/ochairo/databehave/actions/workflows/ci.yml/badge.svg)](https://github.com/ochairo/databehave/actions/workflows/ci.yml)
11
+ [![Node.js](https://img.shields.io/badge/node-%E2%89%A520.19-brightgreen)](https://nodejs.org/)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
13
+
14
+ </div>
15
+
16
+ ## Install
17
+
18
+ You can install `@databehave/server` alone but to get full functionality, the recommended companion is `@databehave/schema`.
19
+
20
+ ```bash
21
+ npm install @databehave/schema @databehave/server
22
+ # or: pnpm add @databehave/schema @databehave/server
23
+ # or: yarn add @databehave/schema @databehave/server
24
+ ```
25
+
26
+ > `@databehave/schema` is declared as an optional peer dependency. Install it alongside `@databehave/server` whenever you enable auto-schema mode.
27
+
28
+ ## A 60-second tour
29
+
30
+ **1. OpenAPI specs driven data** — realistic mock data derived from your OpenAPI document, with zero config. See [docs/openapi.md](docs/openapi.md) for the `openapi` field reference.
31
+
32
+ **2. Auto-schema mode (`@databehave/schema`)** — realistic distributions, referential integrity, and deterministic seeding. See [docs/openapi/auto-schema.md](docs/openapi/auto-schema.md) for the `schema` field reference (knobs, defaults, install hint).
33
+
34
+ **3. Full custom data** — hand-write response generators as modules, and point routes to them. The module exports a function that receives the request and returns a response shape. You can use `@databehave/schema` or any other data source.
35
+
36
+ **4. Admin UI** You have a UI to override any route's response, with the override "sticking" until you remove it. No code change required for error injection.
37
+
38
+ ```jsonc
39
+ {
40
+ "server": {
41
+ "host": "127.0.0.1",
42
+ "port": "8000"
43
+ },
44
+ // 1. OpenAPI specs driven data
45
+ "openapi": "./openapi.json",
46
+ // 2. Auto-schema mode (Adds @databehave/schema data generation engine to OpenAPI specs)
47
+ "schema": { "enabled": true },
48
+ // 3. Full custom data
49
+ "endpoints": {
50
+ "GET /health": { "response": { "status": 200, "json": { "ok": true } } },
51
+ "GET /products/list": "./src/routes/products/list.js",
52
+ "POST /products/bulk-update": "./src/routes/products/bulk-update.js"
53
+ },
54
+ // 4. Admin UI: http://[server.host]:[server.port]/[admin.path]
55
+ "admin": { "enabled": true, "path": "/databehave" },
56
+ }
57
+ ```
58
+
59
+ ## Documentation
60
+
61
+ ### Start here
62
+
63
+ - [Index](docs/index.md) — package TOC and reading order.
64
+ - [Getting Started](docs/getting-started.md) — 5-step tutorial from install to a custom route.
65
+ - [Troubleshooting](docs/troubleshooting.md) — symptom → cause → fix.
66
+
67
+ ### Reference
68
+
69
+ - [Config](docs/config/index.md) — every `databehave.json` field.
70
+ - [Config Validation](docs/config/validation.md) — opt-in inbound request validation (RFC 7807).
71
+ - [Admin](docs/admin/index.md) — admin UI, REST endpoints, matchers, ErrorMode, `x-mock-*` headers, scenarios, security notes.
72
+ - [OpenAPI](docs/openapi/fallback.md) — OAS fallback walker, [Auto-schema](docs/openapi/auto-schema.md), keyword parity.
73
+ - [CLI](docs/cli.md) — `@databehave/server` CLI reference.
74
+ - [Errors](docs/errors.md) — every user-visible error message, source-cited.
75
+
76
+ ### Deeper
77
+
78
+ - [Design](docs/design.md) — architecture, dispatch pipeline, why the HTTP framework is hidden.
79
+ - [Stability](docs/stability.md) — public-surface lock, SemVer triggers, contract surfaces.
80
+ - [Recipes](docs/recipes.md) — error injection, static responses, programmatic hooks, in-process tests with `fetch()`.
81
+
82
+ <br /><br />
83
+ <div align="center">
84
+
85
+ © 2026-present ochairo. See [LICENSE](LICENSE)
86
+
87
+ </div>
@@ -0,0 +1,42 @@
1
+ import type { Handler, RouteKey } from '../types.js';
2
+ import type { OverridesStore } from './overrides-store.js';
3
+ import type { ScenariosStore } from './scenarios-store.js';
4
+ export interface AdminListedRoute {
5
+ method: string;
6
+ path: string;
7
+ summary?: string;
8
+ /**
9
+ * Where this entry came from:
10
+ * - 'openapi' — derived from the configured OAS document
11
+ * - 'handler' — a hand-written route registered with the dispatcher
12
+ * (config.routes / `endpoints`) that has no OAS entry.
13
+ * OAS wins on duplicates so schema-rich rows take precedence in the UI.
14
+ */
15
+ source: 'openapi' | 'handler';
16
+ }
17
+ export interface AdminRoutesOptions {
18
+ readonly store: OverridesStore;
19
+ /** Mount point. Default '/databehave'. Must start with '/'. */
20
+ readonly basePath?: string;
21
+ /** Serve the UI HTML page. Default true. */
22
+ readonly ui?: boolean;
23
+ /** CORS headers added to every admin response. Empty = same-origin. */
24
+ readonly corsHeaders?: Readonly<Record<string, string>>;
25
+ /** Raw OpenAPI document text — used to populate the route picker. */
26
+ readonly openapiBody?: string;
27
+ /**
28
+ * Route keys registered on the dispatcher (hand-written `endpoints` /
29
+ * `config.routes`). Merged with the OAS-derived list so the admin UI
30
+ * surfaces handler-only routes that aren't in the OpenAPI document.
31
+ * OAS entries win on `(method, path)` collisions. Entries under
32
+ * `basePath` are filtered to avoid self-listing the admin routes.
33
+ */
34
+ readonly handlerRoutes?: ReadonlyArray<{
35
+ method: string;
36
+ path: string;
37
+ }>;
38
+ /** Optional scenarios store. When omitted, scenarios endpoints are not mounted. */
39
+ readonly scenarios?: ScenariosStore;
40
+ }
41
+ export declare const createAdminRoutes: (opts: AdminRoutesOptions) => Partial<Record<RouteKey, Handler>>;
42
+ //# sourceMappingURL=admin-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.d.ts","sourceRoot":"","sources":["../../src/admin/admin-routes.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EACV,OAAO,EAGP,QAAQ,EACT,MAAM,aAAa,CAAA;AAEpB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAwK1D,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;;OAMG;IACH,MAAM,EAAE,SAAS,GAAG,SAAS,CAAA;CAC9B;AA2DD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAA;IACrB,uEAAuE;IACvE,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACvD,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxE,mFAAmF;IACnF,QAAQ,CAAC,SAAS,CAAC,EAAE,cAAc,CAAA;CACpC;AAED,eAAO,MAAM,iBAAiB,GAC5B,MAAM,kBAAkB,KACvB,OAAO,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,CA6QnC,CAAA"}
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Admin REST + UI routes. Mounted under a configurable `basePath`
3
+ * (default `/databehave`).
4
+ *
5
+ * GET {basePath} — admin HTML page (UI shell)
6
+ * GET {basePath}/ — same (trailing-slash equivalent)
7
+ * GET {basePath}/ui.js — admin UI script bundle
8
+ * GET {basePath}/ui.css — admin UI stylesheet
9
+ * GET {basePath}/openapi-routes — `{method, path, summary}[]`
10
+ * GET {basePath}/openapi.json — raw OpenAPI document (404 when not configured)
11
+ * GET {basePath}/overrides — list active sticky overrides
12
+ * POST {basePath}/overrides — add a sticky override (201)
13
+ * DELETE {basePath}/overrides — clear all
14
+ * DELETE {basePath}/overrides/:id — remove by id (404 if missing)
15
+ * GET {basePath}/scenarios — list saved scenarios
16
+ * POST {basePath}/scenarios — save current overrides as a scenario
17
+ * GET {basePath}/scenarios/:name — fetch a saved scenario
18
+ * DELETE {basePath}/scenarios/:name — delete a saved scenario
19
+ * POST {basePath}/scenarios/:name/load — replace active overrides with the scenario
20
+ *
21
+ * CORS for these routes is controlled by `admin.cors`; the kit's
22
+ * server integration injects the resolved header bag here via
23
+ * {@link AdminRoutesOptions.corsHeaders}.
24
+ */
25
+ import { readFile } from 'node:fs/promises';
26
+ import { dirname, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { isValidScenarioName } from './scenarios-store.js';
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const UI_HTML_PATH = resolve(HERE, 'ui.html');
31
+ const UI_JS_PATH = resolve(HERE, 'ui.js');
32
+ const UI_CSS_PATH = resolve(HERE, 'ui.css');
33
+ // Cached on first read — the admin bundle is static for the process lifetime.
34
+ // HTML is cached per basePath because the served body is templated with the
35
+ // base so the UI bundle and the asset <script>/<link> tags know where to
36
+ // fetch from when the UI is mounted at a non-default path.
37
+ const uiHtmlCacheByBase = new Map();
38
+ let uiJsCache;
39
+ let uiCssCache;
40
+ /**
41
+ * Inject the resolved `basePath` into the prebuilt UI shell HTML so
42
+ * the `<script>` / `<link>` tags resolve correctly when the UI is
43
+ * mounted at the bare base path (browsers resolve `./ui.js` against
44
+ * the parent of the current URL, which is wrong when the URL has no
45
+ * trailing slash) and so the UI bundle can read
46
+ * `window.__DATABEHAVE_BASE__` to build its fetch URLs.
47
+ */
48
+ const templateHtml = (raw, basePath) => {
49
+ const baseJson = JSON.stringify(basePath);
50
+ return raw
51
+ .replace(/src="\.\/ui\.js"/g, `src="${basePath}/ui.js"`)
52
+ .replace(/href="\.\/ui\.css"/g, `href="${basePath}/ui.css"`)
53
+ .replace(/<script type="module"/, `<script>window.__DATABEHAVE_BASE__=${baseJson};</script>\n <script type="module"`);
54
+ };
55
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ const validateMode = (raw) => {
57
+ if (!isPlainObject(raw) || typeof raw.kind !== 'string') {
58
+ return [null, 'mode.kind must be a string'];
59
+ }
60
+ switch (raw.kind) {
61
+ case 'http-status':
62
+ if (typeof raw.status !== 'number')
63
+ return [null, 'http-status.status must be number'];
64
+ return [{ kind: 'http-status', status: raw.status }, null];
65
+ case 'business-failure': {
66
+ if (typeof raw.message !== 'string')
67
+ return [null, 'business-failure.message must be string'];
68
+ const mode = { kind: 'business-failure', message: raw.message };
69
+ if (raw.extra !== undefined) {
70
+ if (!isPlainObject(raw.extra))
71
+ return [null, 'business-failure.extra must be a plain object'];
72
+ mode.extra = raw.extra;
73
+ }
74
+ return [mode, null];
75
+ }
76
+ case 'custom-body': {
77
+ if (!('body' in raw))
78
+ return [null, 'custom-body.body is required'];
79
+ const mode = { kind: 'custom-body', body: raw.body };
80
+ if (raw.status !== undefined) {
81
+ if (typeof raw.status !== 'number')
82
+ return [null, 'custom-body.status must be number'];
83
+ mode.status = raw.status;
84
+ }
85
+ if (raw.contentType !== undefined) {
86
+ if (typeof raw.contentType !== 'string')
87
+ return [null, 'custom-body.contentType must be string'];
88
+ mode.contentType = raw.contentType;
89
+ }
90
+ return [mode, null];
91
+ }
92
+ case 'empty-body': {
93
+ const mode = { kind: 'empty-body' };
94
+ if (raw.status !== undefined) {
95
+ if (typeof raw.status !== 'number')
96
+ return [null, 'empty-body.status must be number'];
97
+ mode.status = raw.status;
98
+ }
99
+ return [mode, null];
100
+ }
101
+ case 'malformed-json': {
102
+ const mode = { kind: 'malformed-json' };
103
+ if (raw.status !== undefined) {
104
+ if (typeof raw.status !== 'number')
105
+ return [null, 'malformed-json.status must be number'];
106
+ mode.status = raw.status;
107
+ }
108
+ return [mode, null];
109
+ }
110
+ case 'delay': {
111
+ if (typeof raw.ms !== 'number' || raw.ms < 0)
112
+ return [null, 'delay.ms must be a non-negative number'];
113
+ if (raw.then === undefined)
114
+ return [{ kind: 'delay', ms: raw.ms }, null];
115
+ const [inner, err] = validateMode(raw.then);
116
+ if (err)
117
+ return [null, `delay.then: ${err}`];
118
+ if (inner.kind === 'delay' ||
119
+ inner.kind === 'hang' ||
120
+ inner.kind === 'destroy') {
121
+ return [null, `delay.then cannot be ${inner.kind}`];
122
+ }
123
+ return [
124
+ {
125
+ kind: 'delay',
126
+ ms: raw.ms,
127
+ then: inner,
128
+ },
129
+ null,
130
+ ];
131
+ }
132
+ case 'hang':
133
+ return [{ kind: 'hang' }, null];
134
+ case 'destroy':
135
+ return [{ kind: 'destroy' }, null];
136
+ default:
137
+ return [null, `unknown mode.kind: ${String(raw.kind)}`];
138
+ }
139
+ };
140
+ const validateMatcher = (raw) => {
141
+ if (!isPlainObject(raw) || typeof raw.kind !== 'string') {
142
+ return [null, 'matcher.kind must be a string'];
143
+ }
144
+ switch (raw.kind) {
145
+ case 'exact':
146
+ if (typeof raw.method !== 'string')
147
+ return [null, 'matcher.method must be string'];
148
+ if (typeof raw.path !== 'string' || !raw.path.startsWith('/'))
149
+ return [null, 'matcher.path must start with "/"'];
150
+ return [{ kind: 'exact', method: raw.method, path: raw.path }, null];
151
+ case 'path':
152
+ if (typeof raw.path !== 'string' || !raw.path.startsWith('/'))
153
+ return [null, 'matcher.path must start with "/"'];
154
+ return [{ kind: 'path', path: raw.path }, null];
155
+ case 'global': {
156
+ if (raw.methods === undefined)
157
+ return [{ kind: 'global' }, null];
158
+ if (!Array.isArray(raw.methods))
159
+ return [null, 'matcher.methods must be an array of strings'];
160
+ const methods = [];
161
+ for (const m of raw.methods) {
162
+ if (typeof m !== 'string')
163
+ return [null, 'matcher.methods must be an array of strings'];
164
+ const trimmed = m.trim();
165
+ if (trimmed.length === 0)
166
+ return [null, 'matcher.methods entries must be non-empty'];
167
+ methods.push(trimmed.toUpperCase());
168
+ }
169
+ if (methods.length === 0)
170
+ return [{ kind: 'global' }, null];
171
+ return [{ kind: 'global', methods }, null];
172
+ }
173
+ default:
174
+ return [null, `unknown matcher.kind: ${String(raw.kind)}`];
175
+ }
176
+ };
177
+ const buildAdminRoutesList = (openapiBody, handlerRoutes, basePath) => {
178
+ const out = [];
179
+ const seen = new Set();
180
+ if (openapiBody !== undefined) {
181
+ try {
182
+ const doc = JSON.parse(openapiBody);
183
+ const paths = doc.paths ?? {};
184
+ for (const [p, ops] of Object.entries(paths)) {
185
+ for (const method of [
186
+ 'get',
187
+ 'post',
188
+ 'put',
189
+ 'delete',
190
+ 'patch',
191
+ ]) {
192
+ const op = ops[method];
193
+ if (op !== undefined) {
194
+ const upper = method.toUpperCase();
195
+ seen.add(`${upper} ${p}`);
196
+ out.push({
197
+ method: upper,
198
+ path: p,
199
+ ...(typeof op.summary === 'string'
200
+ ? { summary: op.summary }
201
+ : {}),
202
+ source: 'openapi',
203
+ });
204
+ }
205
+ }
206
+ }
207
+ }
208
+ catch {
209
+ // ignore parse errors — UI will simply show an empty list.
210
+ }
211
+ }
212
+ if (handlerRoutes) {
213
+ for (const r of handlerRoutes) {
214
+ const upper = r.method.toUpperCase();
215
+ // Hide admin routes from the UI list — they would self-list otherwise.
216
+ if (r.path === basePath || r.path.startsWith(`${basePath}/`))
217
+ continue;
218
+ const key = `${upper} ${r.path}`;
219
+ if (seen.has(key))
220
+ continue;
221
+ seen.add(key);
222
+ out.push({ method: upper, path: r.path, source: 'handler' });
223
+ }
224
+ }
225
+ out.sort((a, b) => a.path === b.path
226
+ ? a.method.localeCompare(b.method)
227
+ : a.path.localeCompare(b.path));
228
+ return { routes: out, discoveredAt: new Date().toISOString() };
229
+ };
230
+ export const createAdminRoutes = (opts) => {
231
+ const { store, openapiBody } = opts;
232
+ const basePath = opts.basePath ?? '/databehave';
233
+ const ui = opts.ui !== false;
234
+ const corsHeaders = opts.corsHeaders ?? {};
235
+ const routesCache = buildAdminRoutesList(openapiBody, opts.handlerRoutes, basePath);
236
+ if (!basePath.startsWith('/')) {
237
+ throw new Error(`@databehave/server: admin.path must start with "/" (got ${JSON.stringify(basePath)})`);
238
+ }
239
+ const json = (status, body) => ({
240
+ status,
241
+ json: body,
242
+ headers: { ...corsHeaders },
243
+ });
244
+ const uiHandler = async () => {
245
+ try {
246
+ let html = uiHtmlCacheByBase.get(basePath);
247
+ if (html === undefined) {
248
+ const raw = await readFile(UI_HTML_PATH, 'utf8');
249
+ html = templateHtml(raw, basePath);
250
+ uiHtmlCacheByBase.set(basePath, html);
251
+ }
252
+ return { status: 200, html, headers: { ...corsHeaders } };
253
+ /* v8 ignore start -- ui.html ships with the package; missing only on a broken install */
254
+ }
255
+ catch (err) {
256
+ return json(500, {
257
+ error: 'failed to read ui.html',
258
+ detail: err instanceof Error ? err.message : String(err),
259
+ });
260
+ }
261
+ /* v8 ignore stop */
262
+ };
263
+ const uiJsHandler = async () => {
264
+ try {
265
+ if (uiJsCache === undefined) {
266
+ uiJsCache = await readFile(UI_JS_PATH, 'utf8');
267
+ }
268
+ return {
269
+ status: 200,
270
+ text: uiJsCache,
271
+ headers: {
272
+ ...corsHeaders,
273
+ 'Content-Type': 'application/javascript; charset=utf-8',
274
+ },
275
+ };
276
+ /* v8 ignore start -- ui.js ships with the package; missing only on a broken install */
277
+ }
278
+ catch (err) {
279
+ return json(500, {
280
+ error: 'failed to read ui.js',
281
+ detail: err instanceof Error ? err.message : String(err),
282
+ });
283
+ }
284
+ /* v8 ignore stop */
285
+ };
286
+ const uiCssHandler = async () => {
287
+ try {
288
+ if (uiCssCache === undefined) {
289
+ uiCssCache = await readFile(UI_CSS_PATH, 'utf8');
290
+ }
291
+ return {
292
+ status: 200,
293
+ text: uiCssCache,
294
+ headers: {
295
+ ...corsHeaders,
296
+ 'Content-Type': 'text/css; charset=utf-8',
297
+ },
298
+ };
299
+ /* v8 ignore start -- ui.css ships with the package; missing only on a broken install */
300
+ }
301
+ catch (err) {
302
+ return json(500, {
303
+ error: 'failed to read ui.css',
304
+ detail: err instanceof Error ? err.message : String(err),
305
+ });
306
+ }
307
+ /* v8 ignore stop */
308
+ };
309
+ const listRoutesHandler = () => json(200, routesCache);
310
+ const openApiDocHandler = () => {
311
+ if (openapiBody === undefined) {
312
+ return json(404, { error: 'no openapi document configured' });
313
+ }
314
+ return {
315
+ status: 200,
316
+ text: openapiBody,
317
+ headers: {
318
+ ...corsHeaders,
319
+ 'Content-Type': 'application/json',
320
+ 'Cache-Control': 'no-store',
321
+ },
322
+ };
323
+ };
324
+ const listOverridesHandler = () => json(200, { overrides: store.list() });
325
+ const addOverrideHandler = async (req) => {
326
+ let body;
327
+ try {
328
+ body = await req.json();
329
+ }
330
+ catch (err) {
331
+ return json(400, {
332
+ error: 'invalid JSON body',
333
+ detail: err instanceof Error ? err.message : String(err),
334
+ });
335
+ }
336
+ if (!isPlainObject(body))
337
+ return json(400, { error: 'body must be a JSON object' });
338
+ const [matcher, mErr] = validateMatcher(body.matcher);
339
+ if (mErr)
340
+ return json(400, { error: `matcher invalid: ${mErr}` });
341
+ const [mode, modeErr] = validateMode(body.mode);
342
+ if (modeErr)
343
+ return json(400, { error: `mode invalid: ${modeErr}` });
344
+ const description = typeof body.description === 'string' ? body.description : undefined;
345
+ const override = store.add({
346
+ matcher: matcher,
347
+ mode: mode,
348
+ ...(description !== undefined ? { description } : {}),
349
+ });
350
+ return {
351
+ status: 201,
352
+ json: { id: override.id, override },
353
+ headers: { ...corsHeaders },
354
+ };
355
+ };
356
+ const removeByIdHandler = (req) => {
357
+ const id = req.params.id;
358
+ if (!id)
359
+ return json(400, { error: 'missing :id' });
360
+ const ok = store.remove(id);
361
+ if (!ok)
362
+ return json(404, { error: 'override not found', id });
363
+ return json(200, { deleted: true, id });
364
+ };
365
+ const clearAllHandler = () => {
366
+ const n = store.clear();
367
+ return json(200, { cleared: n });
368
+ };
369
+ const routes = {
370
+ [`GET ${basePath}/openapi-routes`]: listRoutesHandler,
371
+ [`GET ${basePath}/openapi.json`]: openApiDocHandler,
372
+ [`GET ${basePath}/overrides`]: listOverridesHandler,
373
+ [`POST ${basePath}/overrides`]: addOverrideHandler,
374
+ [`DELETE ${basePath}/overrides`]: clearAllHandler,
375
+ [`DELETE ${basePath}/overrides/:id`]: removeByIdHandler,
376
+ };
377
+ if (ui) {
378
+ // Serve the HTML shell at the bare base path AND its trailing-slash
379
+ // variant so both `/databehave` and `/databehave/` work in a browser.
380
+ routes[`GET ${basePath}`] = uiHandler;
381
+ if (!basePath.endsWith('/')) {
382
+ routes[`GET ${basePath}/`] = uiHandler;
383
+ }
384
+ routes[`GET ${basePath}/ui.js`] = uiJsHandler;
385
+ routes[`GET ${basePath}/ui.css`] = uiCssHandler;
386
+ }
387
+ // Scenarios (file-backed snapshots of override sets). Mounted only
388
+ // when a scenarios store is provided — pure REST CRUD plus a
389
+ // `:name/load` action that atomically replaces the active overrides.
390
+ const scenarios = opts.scenarios;
391
+ if (scenarios) {
392
+ const listScenariosHandler = async () => json(200, { scenarios: await scenarios.list() });
393
+ const getScenarioHandler = async (req) => {
394
+ const name = req.params.name ?? '';
395
+ if (!isValidScenarioName(name))
396
+ return json(400, { error: 'invalid scenario name' });
397
+ const s = await scenarios.get(name);
398
+ if (!s)
399
+ return json(404, { error: 'scenario not found', name });
400
+ return json(200, s);
401
+ };
402
+ const createScenarioHandler = async (req) => {
403
+ let body;
404
+ try {
405
+ body = await req.json();
406
+ }
407
+ catch (err) {
408
+ return json(400, {
409
+ error: 'invalid JSON body',
410
+ detail: err instanceof Error ? err.message : String(err),
411
+ });
412
+ }
413
+ if (!isPlainObject(body))
414
+ return json(400, { error: 'body must be a JSON object' });
415
+ const name = typeof body.name === 'string' ? body.name : '';
416
+ if (!isValidScenarioName(name))
417
+ return json(400, {
418
+ error: 'invalid scenario name (only [A-Za-z0-9_-], max 64 chars)',
419
+ });
420
+ const explicit = body.overrides;
421
+ const overrides = Array.isArray(explicit)
422
+ ? explicit
423
+ : store.list();
424
+ const saved = await scenarios.save(name, overrides);
425
+ return {
426
+ status: 201,
427
+ json: saved,
428
+ headers: { ...corsHeaders },
429
+ };
430
+ };
431
+ const deleteScenarioHandler = async (req) => {
432
+ const name = req.params.name ?? '';
433
+ if (!isValidScenarioName(name))
434
+ return json(400, { error: 'invalid scenario name' });
435
+ const ok = await scenarios.remove(name);
436
+ if (!ok)
437
+ return json(404, { error: 'scenario not found', name });
438
+ return json(200, { deleted: true, name });
439
+ };
440
+ const loadScenarioHandler = async (req) => {
441
+ const name = req.params.name ?? '';
442
+ if (!isValidScenarioName(name))
443
+ return json(400, { error: 'invalid scenario name' });
444
+ const s = await scenarios.get(name);
445
+ if (!s)
446
+ return json(404, { error: 'scenario not found', name });
447
+ store.clear();
448
+ const loaded = [];
449
+ for (const o of s.overrides) {
450
+ const added = store.add({
451
+ matcher: o.matcher,
452
+ mode: o.mode,
453
+ ...(o.description !== undefined
454
+ ? { description: o.description }
455
+ : {}),
456
+ });
457
+ loaded.push(added.id);
458
+ }
459
+ return json(200, { loaded: loaded.length, name });
460
+ };
461
+ routes[`GET ${basePath}/scenarios`] =
462
+ listScenariosHandler;
463
+ routes[`GET ${basePath}/scenarios/:name`] =
464
+ getScenarioHandler;
465
+ routes[`POST ${basePath}/scenarios`] =
466
+ createScenarioHandler;
467
+ routes[`DELETE ${basePath}/scenarios/:name`] = deleteScenarioHandler;
468
+ routes[`POST ${basePath}/scenarios/:name/load`] = loadScenarioHandler;
469
+ }
470
+ return routes;
471
+ };
472
+ //# sourceMappingURL=admin-routes.js.map