mikoshi-construct 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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/cli.js +1492 -0
  4. package/package.json +73 -0
  5. package/templates/ai/claude/CLAUDE.md.eta +18 -0
  6. package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
  7. package/templates/ai/claude/_claude/agents/architect.md +38 -0
  8. package/templates/ai/claude/_claude/agents/harness.md +40 -0
  9. package/templates/ai/claude/_claude/agents/implementer.md +40 -0
  10. package/templates/ai/claude/_claude/commands/plan.md +24 -0
  11. package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
  12. package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
  13. package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
  14. package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
  15. package/templates/ai/shared/AGENTS.md.eta +123 -0
  16. package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
  17. package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
  18. package/templates/ai/shared/_claude/rules/conventions.md +63 -0
  19. package/templates/ai/shared/_claude/rules/secrets.md +8 -0
  20. package/templates/ai/shared/_claude/rules/tests.md +6 -0
  21. package/templates/base/_editorconfig +12 -0
  22. package/templates/base/_github/workflows/security.yml +41 -0
  23. package/templates/base/_gitignore +11 -0
  24. package/templates/base/_gitleaks.toml +2 -0
  25. package/templates/base/_nvmrc.eta +1 -0
  26. package/templates/base/_vscode/settings.json +22 -0
  27. package/templates/base/architecture/checklists.md +42 -0
  28. package/templates/base/architecture/principles.md +113 -0
  29. package/templates/base/architecture/security-invariants.md +17 -0
  30. package/templates/harness/_github/workflows/ci.yml.eta +31 -0
  31. package/templates/harness/eslint.config.mjs +13 -0
  32. package/templates/harness/package.json.eta +29 -0
  33. package/templates/harness/pnpm-workspace.yaml +11 -0
  34. package/templates/harness/scripts/composition/check.ts +19 -0
  35. package/templates/harness/scripts/composition/files.ts +34 -0
  36. package/templates/harness/scripts/composition/model.ts +137 -0
  37. package/templates/harness/scripts/composition/render.ts +72 -0
  38. package/templates/harness/scripts/composition/sync-docs.ts +11 -0
  39. package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
  40. package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
  41. package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
  42. package/templates/harness/tsconfig.base.json +14 -0
  43. package/templates/harness/tsconfig.json +5 -0
  44. package/templates/harness/vitest.config.ts +8 -0
  45. package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
  46. package/templates/presets/monorepo/baseline/package.json.eta +18 -0
  47. package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
  48. package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
  49. package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
  50. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
  51. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
  52. package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
  53. package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
  54. package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
  55. package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
  56. package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
  57. package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
  58. package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
  59. package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
  60. package/templates/presets/node-backend/baseline/package.json.eta +20 -0
  61. package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
  62. package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
  63. package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
  64. package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
  65. package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
  66. package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
  67. package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
  68. package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
  69. package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
  70. package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
  71. package/templates/presets/node-frontend/sample/index.html.eta +12 -0
  72. package/templates/presets/node-frontend/sample/src/app.ts +32 -0
  73. package/templates/presets/node-frontend/sample/src/main.ts +9 -0
  74. package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
  75. package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
  76. package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
  77. package/templates/stacks/express-api/app/src/app.ts +27 -0
  78. package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
  79. package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
  80. package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
  81. package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
  82. package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
  83. package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
  84. package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
  85. package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
  86. package/templates/stacks/express-api/app/src/server.ts +9 -0
  87. package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
  88. package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
  89. package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
  90. package/templates/stacks/express-api/repo/architecture/http.md +39 -0
  91. package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
  92. package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
  93. package/templates/stacks/http-contract/package.json.eta +13 -0
  94. package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
  95. package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
  96. package/templates/stacks/http-contract/scripts/tests/contracts/security.test.ts.eta +86 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "types": ["node", "vite/client"]
8
+ },
9
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", "vitest.config.ts"],
10
+ "exclude": ["**/node_modules/**", "**/dist/**"]
11
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ projects: [
6
+ { test: { name: 'app', include: ['tests/**/*.test.ts'], environment: 'happy-dom' } },
7
+ { test: { name: 'scripts', include: ['scripts/tests/**/*.test.ts'], environment: 'node' } },
8
+ ],
9
+ },
10
+ })
@@ -0,0 +1,37 @@
1
+ # Application
2
+
3
+ The bootstrap path and the wiring below are rendered from
4
+ [composition/app.yaml](composition/app.yaml). Edit the model, then run `pnpm composition:render`;
5
+ `pnpm composition:check` fails when the diagram and the model drift apart.
6
+
7
+ <!-- composition:app -->
8
+ `src/main.ts` is the composition root: it finds the mount point in `index.html`, loads the design tokens and the component styles, and hands the root element to `createApp`. State crosses into CSS as `data-*` attributes; nothing else writes styles from JavaScript.
9
+
10
+ ```mermaid
11
+ flowchart LR
12
+ subgraph b_bootstrap["Bootstrap"]
13
+ main["main.ts"]
14
+ end
15
+ subgraph b_ui["UI"]
16
+ app["createApp(root)"]
17
+ counter["createCounter(button)"]
18
+ end
19
+ subgraph b_styles["Styles"]
20
+ tokens["tokens.css"]
21
+ css["app.css"]
22
+ end
23
+ main -.->|"imports"| tokens
24
+ main -.->|"imports"| css
25
+ main --> app
26
+ app --> counter
27
+ counter -.->|"data-tone"| css
28
+ ```
29
+ <!-- /composition:app -->
30
+
31
+ ## Conventions
32
+
33
+ `src/main.ts` is the only file that touches `document` at module scope; everything else receives its
34
+ root element. State that CSS must see is written as a `data-*` or ARIA attribute (a lint rule rejects
35
+ `classList` mutation and inline style writes in `src/`). Tokens live in
36
+ [src/styles/tokens.css](../src/styles/tokens.css); a raw colour, length or duration in a component is a
37
+ missing token. Layout reacts to its container, never to the viewport.
@@ -0,0 +1,41 @@
1
+ id: app
2
+ title: Application composition
3
+ description: >-
4
+ `src/main.ts` is the composition root: it finds the mount point in `index.html`, loads the design
5
+ tokens and the component styles, and hands the root element to `createApp`. State crosses into CSS
6
+ as `data-*` attributes; nothing else writes styles from JavaScript.
7
+ doc: architecture/app.md
8
+ boundaries:
9
+ - id: bootstrap
10
+ label: Bootstrap
11
+ - id: ui
12
+ label: UI
13
+ - id: styles
14
+ label: Styles
15
+ nodes:
16
+ - id: main
17
+ label: main.ts
18
+ path: src/main.ts
19
+ boundary: bootstrap
20
+ - id: app
21
+ label: createApp(root)
22
+ path: src/app.ts
23
+ boundary: ui
24
+ - id: counter
25
+ label: createCounter(button)
26
+ path: src/app.ts
27
+ boundary: ui
28
+ - id: tokens
29
+ label: tokens.css
30
+ path: src/styles/tokens.css
31
+ boundary: styles
32
+ - id: css
33
+ label: app.css
34
+ path: src/styles/app.css
35
+ boundary: styles
36
+ edges:
37
+ - {from: main, to: tokens, kind: wires, label: imports}
38
+ - {from: main, to: css, kind: wires, label: imports}
39
+ - {from: main, to: app, kind: sequence}
40
+ - {from: app, to: counter, kind: sequence}
41
+ - {from: counter, to: css, kind: wires, label: data-tone}
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{projectName}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,32 @@
1
+ export interface Counter {
2
+ element: HTMLButtonElement
3
+ value: () => number
4
+ increment: () => void
5
+ }
6
+
7
+ function render(element: HTMLButtonElement, count: number): void {
8
+ element.dataset.count = String(count)
9
+ element.dataset.tone = count > 0 ? 'active' : 'idle'
10
+ element.textContent = `Clicked ${count} ${count === 1 ? 'time' : 'times'}`
11
+ }
12
+
13
+ export function createCounter(element: HTMLButtonElement): Counter {
14
+ let count = 0
15
+ render(element, count)
16
+ element.addEventListener('click', () => {
17
+ count += 1
18
+ render(element, count)
19
+ })
20
+ return { element, value: () => count, increment: () => element.click() }
21
+ }
22
+
23
+ export function createApp(root: HTMLElement): Counter {
24
+ const document = root.ownerDocument
25
+ const heading = document.createElement('h1')
26
+ heading.textContent = document.title
27
+ const button = document.createElement('button')
28
+ button.type = 'button'
29
+ button.className = 'counter'
30
+ root.replaceChildren(heading, button)
31
+ return createCounter(button)
32
+ }
@@ -0,0 +1,9 @@
1
+ import { createApp } from './app.js'
2
+ import './styles/tokens.css'
3
+ import './styles/app.css'
4
+
5
+ const root = document.querySelector<HTMLElement>('#app')
6
+ if (root == null)
7
+ throw new Error('index.html must contain <div id="app">')
8
+
9
+ createApp(root)
@@ -0,0 +1,43 @@
1
+ body {
2
+ margin: 0;
3
+ background: var(--color-surface);
4
+ color: var(--color-on-surface);
5
+ font-family: system-ui, sans-serif;
6
+ }
7
+
8
+ #app {
9
+ container: app / inline-size;
10
+ display: grid;
11
+ gap: var(--space-4);
12
+ max-inline-size: var(--body-max-inline-size);
13
+ margin-inline: auto;
14
+ padding-block: var(--space-8);
15
+ padding-inline: var(--space-4);
16
+ }
17
+
18
+ .counter {
19
+ --_bg: var(--color-surface);
20
+ --_fg: var(--color-on-surface);
21
+ --_bd: var(--color-on-surface);
22
+
23
+ justify-self: start;
24
+ padding: 0.25lh 1ch;
25
+ border: 1px solid var(--_bd);
26
+ border-radius: var(--radius-md);
27
+ background: var(--_bg);
28
+ color: var(--_fg);
29
+ font: inherit;
30
+ transition: background var(--duration-fast) ease;
31
+
32
+ &[data-tone='active'] {
33
+ --_bg: var(--color-primary);
34
+ --_fg: var(--color-on-primary);
35
+ --_bd: transparent;
36
+ }
37
+ }
38
+
39
+ @container app (inline-size <= 30rem) {
40
+ .counter {
41
+ justify-self: stretch;
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+
4
+ --color-surface: light-dark(oklch(98% 0.01 250), oklch(18% 0.02 250));
5
+ --color-on-surface: light-dark(oklch(22% 0.02 250), oklch(94% 0.01 250));
6
+ --color-primary: light-dark(oklch(52% 0.2 265), oklch(78% 0.14 265));
7
+ --color-on-primary: light-dark(oklch(99% 0 0), oklch(18% 0.05 265));
8
+
9
+ --space-2: 0.5rem;
10
+ --space-4: 1rem;
11
+ --space-8: 2rem;
12
+ --radius-md: 0.5rem;
13
+ --duration-fast: 120ms;
14
+ --body-max-inline-size: 65ch;
15
+ --bp-cq-sm: 30rem;
16
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp } from '../src/app.js'
3
+
4
+ describe('createApp', () => {
5
+ it('mounts the counter and keeps its state in data attributes', () => {
6
+ const root = document.createElement('div')
7
+ document.body.append(root)
8
+ const counter = createApp(root)
9
+ expect(root.querySelector('h1')).not.toBeNull()
10
+ expect(counter.element.dataset.count).toBe('0')
11
+ expect(counter.element.dataset.tone).toBe('idle')
12
+
13
+ counter.increment()
14
+ counter.increment()
15
+
16
+ expect(counter.value()).toBe(2)
17
+ expect(counter.element.dataset.count).toBe('2')
18
+ expect(counter.element.dataset.tone).toBe('active')
19
+ expect(counter.element.className).toBe('counter')
20
+ })
21
+ })
@@ -0,0 +1,27 @@
1
+ import type { AppConfig } from './config.js'
2
+ import type { HealthProbe } from './health/health.service.js'
3
+ import express from 'express'
4
+ import { HealthController } from './health/health.controller.js'
5
+ import { alwaysUp, HealthService } from './health/health.service.js'
6
+ import { createApiContractValidator } from './http/api-contract.middleware.js'
7
+ import { errorHandler } from './http/error-handler.middleware.js'
8
+
9
+ export interface AppDependencies {
10
+ healthProbe?: HealthProbe
11
+ }
12
+
13
+ export function createApp(config: AppConfig, dependencies: AppDependencies = {}): express.Express {
14
+ const app = express()
15
+ app.disable('x-powered-by')
16
+ app.use(express.json())
17
+ app.use(createApiContractValidator(config))
18
+
19
+ const health = new HealthService(config.serviceName, dependencies.healthProbe ?? alwaysUp)
20
+
21
+ const router = express.Router()
22
+ HealthController.register(router, health)
23
+ app.use(router)
24
+
25
+ app.use(errorHandler)
26
+ return app
27
+ }
@@ -0,0 +1,30 @@
1
+ import process from 'node:process'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ const DEFAULT_CONTRACT_PATH = fileURLToPath(new URL('{{contractPathFromConfig}}', import.meta.url))
5
+
6
+ export interface AppConfig {
7
+ port: number
8
+ serviceName: string
9
+ apiContractPath: string
10
+ validateApiResponses: boolean
11
+ }
12
+
13
+ function integer(name: string, fallback: number): number {
14
+ const raw = process.env[name]
15
+ if (raw == null || raw === '')
16
+ return fallback
17
+ const parsed = Number(raw)
18
+ if (!Number.isInteger(parsed))
19
+ throw new Error(`${name} must be an integer, got "${raw}"`)
20
+ return parsed
21
+ }
22
+
23
+ export function readConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
24
+ return {
25
+ port: integer('PORT', 3000),
26
+ serviceName: env.SERVICE_NAME ?? '{{projectName}}',
27
+ apiContractPath: env.API_CONTRACT_PATH ?? DEFAULT_CONTRACT_PATH,
28
+ validateApiResponses: env.NODE_ENV !== 'production',
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ export type { components } from '{{contractTypesImport}}'
@@ -0,0 +1,12 @@
1
+ import type { Router } from 'express'
2
+ import type { HealthService } from './health.service.js'
3
+ import { asyncHandler } from '../http/async-handler.middleware.js'
4
+
5
+ export class HealthController {
6
+ static register(router: Router, health: HealthService): void {
7
+ router.get('/health', asyncHandler(async (_req, res) => {
8
+ const snapshot = await health.snapshot()
9
+ res.status(snapshot.status === 'ok' ? 200 : 503).json(snapshot)
10
+ }))
11
+ }
12
+ }
@@ -0,0 +1,23 @@
1
+ import type { components } from '../contracts/types.js'
2
+
3
+ export type HealthResponse = components['schemas']['HealthResponse']
4
+
5
+ export interface HealthProbe {
6
+ ping: () => Promise<boolean>
7
+ }
8
+
9
+ export const alwaysUp: HealthProbe = {
10
+ ping: async () => true,
11
+ }
12
+
13
+ export class HealthService {
14
+ constructor(
15
+ private readonly serviceName: string,
16
+ private readonly probe: HealthProbe,
17
+ ) {}
18
+
19
+ async snapshot(): Promise<HealthResponse> {
20
+ const up = await this.probe.ping()
21
+ return { status: up ? 'ok' : 'error', service: this.serviceName, time: new Date().toISOString() }
22
+ }
23
+ }
@@ -0,0 +1,13 @@
1
+ import type { RequestHandler } from 'express'
2
+ import type { AppConfig } from '../config.js'
3
+ import { middleware as openApiValidator } from 'express-openapi-validator'
4
+
5
+ export function createApiContractValidator(config: AppConfig): RequestHandler[] {
6
+ return openApiValidator({
7
+ apiSpec: config.apiContractPath,
8
+ validateRequests: { allowUnknownQueryParameters: true },
9
+ validateResponses: config.validateApiResponses,
10
+ validateSecurity: false,
11
+ ignoreUndocumented: true,
12
+ })
13
+ }
@@ -0,0 +1,14 @@
1
+ export class AppError extends Error {
2
+ constructor(
3
+ readonly code: string,
4
+ readonly status: number,
5
+ message: string,
6
+ ) {
7
+ super(message)
8
+ this.name = 'AppError'
9
+ }
10
+ }
11
+
12
+ export function isAppError(error: unknown): error is AppError {
13
+ return error instanceof AppError
14
+ }
@@ -0,0 +1,7 @@
1
+ import type { NextFunction, Request, RequestHandler, Response } from 'express'
2
+
3
+ export function asyncHandler(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
4
+ return (req, res, next) => {
5
+ handler(req, res, next).catch(next)
6
+ }
7
+ }
@@ -0,0 +1,50 @@
1
+ import type express from 'express'
2
+ import type { components } from '../contracts/types.js'
3
+ import { isAppError } from './app-error.js'
4
+
5
+ type ErrorResponse = components['schemas']['ErrorResponse']
6
+
7
+ interface ContractError extends Error {
8
+ status: number
9
+ errors: Array<{ path: string, message: string }>
10
+ }
11
+
12
+ function isContractError(error: unknown): error is ContractError {
13
+ return error instanceof Error
14
+ && typeof (error as ContractError).status === 'number'
15
+ && Array.isArray((error as ContractError).errors)
16
+ }
17
+
18
+ function contractErrorPayload(error: ContractError): ErrorResponse {
19
+ const message = error.errors.map(item => `${item.path} ${item.message}`).join('; ') || error.message
20
+ if (error.status === 404)
21
+ return { error: 'NOT_FOUND', message }
22
+ if (error.status >= 500)
23
+ return { error: 'INTERNAL_ERROR', message }
24
+ return { error: 'VALIDATION_ERROR', message }
25
+ }
26
+
27
+ export function errorHandler(
28
+ error: unknown,
29
+ _req: express.Request,
30
+ res: express.Response,
31
+ _next: express.NextFunction,
32
+ ): void {
33
+ if (res.headersSent)
34
+ return
35
+
36
+ if (isAppError(error)) {
37
+ const payload: ErrorResponse = { error: error.code, message: error.message }
38
+ res.status(error.status).json(payload)
39
+ return
40
+ }
41
+
42
+ if (isContractError(error)) {
43
+ res.status(error.status).json(contractErrorPayload(error))
44
+ return
45
+ }
46
+
47
+ console.error('[Unhandled error]', error)
48
+ const payload: ErrorResponse = { error: 'INTERNAL_ERROR', message: 'Internal server error' }
49
+ res.status(500).json(payload)
50
+ }
@@ -0,0 +1,9 @@
1
+ import { createApp } from './app.js'
2
+ import { readConfig } from './config.js'
3
+
4
+ const config = readConfig()
5
+ const app = createApp(config)
6
+
7
+ app.listen(config.port, () => {
8
+ console.log(`${config.serviceName} listening on http://localhost:${config.port}`)
9
+ })
@@ -0,0 +1,28 @@
1
+ import type { HealthResponse } from '../src/health/health.service.js'
2
+ import request from 'supertest'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { createApp } from '../src/app.js'
5
+ import { readConfig } from '../src/config.js'
6
+
7
+ const config = readConfig({ NODE_ENV: 'test' })
8
+
9
+ describe('health endpoint', () => {
10
+ it('answers ok with the contract shape', async () => {
11
+ const response = await request(createApp(config)).get('/health')
12
+ expect(response.status).toBe(200)
13
+ expect(response.body).toEqual({ status: 'ok', service: config.serviceName, time: expect.any(String) })
14
+ expect(response.headers['x-powered-by']).toBeUndefined()
15
+ })
16
+
17
+ it('answers 503 when the probe fails', async () => {
18
+ const app = createApp(config, { healthProbe: { ping: async () => false } })
19
+ const response = await request(app).get('/health')
20
+ expect(response.status).toBe(503)
21
+ expect((response.body as HealthResponse).status).toBe('error')
22
+ })
23
+
24
+ it('answers 404 for a route outside the contract', async () => {
25
+ const response = await request(createApp(config)).get('/nowhere')
26
+ expect(response.status).toBe(404)
27
+ })
28
+ })
@@ -0,0 +1,48 @@
1
+ import type { NextFunction, Request, Response } from 'express'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { AppError } from '../../src/http/app-error.js'
4
+ import { errorHandler } from '../../src/http/error-handler.middleware.js'
5
+
6
+ interface CapturedResponse {
7
+ statusCode: number
8
+ body: unknown
9
+ }
10
+
11
+ function handle(error: unknown): CapturedResponse {
12
+ const captured: CapturedResponse = { statusCode: 0, body: undefined }
13
+ const res = {
14
+ headersSent: false,
15
+ status(code: number) {
16
+ captured.statusCode = code
17
+ return this
18
+ },
19
+ json(payload: unknown) {
20
+ captured.body = payload
21
+ },
22
+ }
23
+ errorHandler(error, {} as Request, res as unknown as Response, (() => {}) as NextFunction)
24
+ return captured
25
+ }
26
+
27
+ describe('errorHandler', () => {
28
+ it('answers an AppError with its status, code and message only', () => {
29
+ expect(handle(new AppError('THING_MISSING', 404, 'no such thing')))
30
+ .toEqual({ statusCode: 404, body: { error: 'THING_MISSING', message: 'no such thing' } })
31
+ })
32
+
33
+ it('maps a contract validation failure to VALIDATION_ERROR with the offending paths', () => {
34
+ const contractError = Object.assign(new Error('request failed'), {
35
+ status: 400,
36
+ errors: [{ path: '/body/name', message: 'must be string' }],
37
+ })
38
+ expect(handle(contractError))
39
+ .toEqual({ statusCode: 400, body: { error: 'VALIDATION_ERROR', message: '/body/name must be string' } })
40
+ })
41
+
42
+ it('hides the cause of an unexpected error behind a generic 500 body', () => {
43
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {})
44
+ const result = handle(new Error('connect ECONNREFUSED db:5432 password=hunter2'))
45
+ log.mockRestore()
46
+ expect(result).toEqual({ statusCode: 500, body: { error: 'INTERNAL_ERROR', message: 'Internal server error' } })
47
+ })
48
+ })
@@ -0,0 +1,45 @@
1
+ id: http
2
+ title: HTTP API composition
3
+ description: >-
4
+ `createApp(config, dependencies)` is the composition root: middleware runs in the order shown, every
5
+ service is constructed there and handed to a controller, and nothing else constructs a service.
6
+ Dotted edges are wiring, solid edges are the request path.
7
+ doc: architecture/http.md
8
+ boundaries:
9
+ - id: middleware
10
+ label: Middleware, in order
11
+ - id: routes
12
+ label: Controllers by route
13
+ - id: services
14
+ label: Services
15
+ nodes:
16
+ - id: app
17
+ label: createApp(config, dependencies)
18
+ path: {{appRoot}}src/app.ts
19
+ boundary: middleware
20
+ - id: body
21
+ label: express.json
22
+ boundary: middleware
23
+ - id: contract
24
+ label: API contract validator
25
+ path: {{appRoot}}src/http/api-contract.middleware.ts
26
+ boundary: middleware
27
+ - id: errors
28
+ label: errorHandler
29
+ path: {{appRoot}}src/http/error-handler.middleware.ts
30
+ boundary: middleware
31
+ - id: c_health
32
+ label: GET /health
33
+ path: {{appRoot}}src/health/health.controller.ts
34
+ boundary: routes
35
+ - id: s_health
36
+ label: HealthService
37
+ path: {{appRoot}}src/health/health.service.ts
38
+ boundary: services
39
+ edges:
40
+ - {from: app, to: body, kind: sequence}
41
+ - {from: body, to: contract, kind: sequence}
42
+ - {from: contract, to: c_health, kind: route}
43
+ - {from: c_health, to: s_health, kind: sequence}
44
+ - {from: c_health, to: errors, kind: sequence, label: on error}
45
+ - {from: app, to: s_health, kind: wires, label: constructs}
@@ -0,0 +1,39 @@
1
+ # HTTP API
2
+
3
+ The request path and the wiring below are rendered from
4
+ [composition/http.yaml](composition/http.yaml). Edit the model, then run `pnpm composition:render`;
5
+ `pnpm composition:check` fails when the diagram and the model drift apart.
6
+
7
+ <!-- composition:http -->
8
+ `createApp(config, dependencies)` is the composition root: middleware runs in the order shown, every service is constructed there and handed to a controller, and nothing else constructs a service. Dotted edges are wiring, solid edges are the request path.
9
+
10
+ ```mermaid
11
+ flowchart LR
12
+ subgraph b_middleware["Middleware, in order"]
13
+ app["createApp(config, dependencies)"]
14
+ body["express.json"]
15
+ contract["API contract validator"]
16
+ errors["errorHandler"]
17
+ end
18
+ subgraph b_routes["Controllers by route"]
19
+ c_health["GET /health"]
20
+ end
21
+ subgraph b_services["Services"]
22
+ s_health["HealthService"]
23
+ end
24
+ app --> body
25
+ body --> contract
26
+ contract --> c_health
27
+ c_health --> s_health
28
+ c_health -->|"on error"| errors
29
+ app -.->|"constructs"| s_health
30
+ ```
31
+ <!-- /composition:http -->
32
+
33
+ ## Conventions
34
+
35
+ File names carry the role: `*.controller.ts` (routing and HTTP), `*.service.ts` (business logic),
36
+ `*.middleware.ts` (Express middleware). One feature per directory. `src/config.ts` is the only reader
37
+ of `process.env`; `src/http/` is the only place that reads `req.body`, `req.query` or `req.params`
38
+ (both are lint rules). Expected failures throw `AppError(code, status, message)`; the global
39
+ `errorHandler` turns it into `{ error, message }`, and anything else becomes a 500 with a generic body.
@@ -0,0 +1,36 @@
1
+ name: API contract
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - contracts/**
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ breaking-changes:
13
+ name: Breaking changes against ${{ github.base_ref }}
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Check whether the base branch has the contract
21
+ id: base
22
+ run: |
23
+ if git cat-file -e "origin/${{ github.base_ref }}:contracts/api/openapi.yaml" 2>/dev/null; then
24
+ echo "exists=true" >> "$GITHUB_OUTPUT"
25
+ else
26
+ echo "exists=false" >> "$GITHUB_OUTPUT"
27
+ echo "::notice::contracts/api/openapi.yaml is not on origin/${{ github.base_ref }}; skipping the breaking-change comparison"
28
+ fi
29
+
30
+ - name: Compare with the base branch
31
+ if: steps.base.outputs.exists == 'true'
32
+ uses: oasdiff/oasdiff-action/breaking@v0
33
+ with:
34
+ base: origin/${{ github.base_ref }}:contracts/api/openapi.yaml
35
+ revision: HEAD:contracts/api/openapi.yaml
36
+ fail-on: ERR