@starci/hfs 3.0.0 → 4.0.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 (198) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  33. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  34. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  35. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  36. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  37. package/runtime/scripts/lib/language.mjs +11 -1
  38. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  39. package/scaffold/app.mjs +179 -0
  40. package/scaffold/service.mjs +26 -16
  41. package/sync/cli.mjs +1 -1
  42. package/sync/hygiene.mjs +11 -8
  43. package/sync/index.mjs +109 -111
  44. package/sync/managed.mjs +9 -8
  45. package/sync/sonar-key.mjs +20 -22
  46. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  47. package/templates/app/gitignore +6 -0
  48. package/templates/app/hooks/husky/pre-commit +25 -0
  49. package/templates/app/hooks/husky/pre-push +7 -0
  50. package/templates/app/package-scripts/package.json +22 -0
  51. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  52. package/templates/app/skeleton/.editorconfig +15 -0
  53. package/templates/app/skeleton/.gitattributes +2 -0
  54. package/templates/app/skeleton/.nvmrc +1 -0
  55. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  56. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  57. package/templates/app/skeleton/README.md +36 -0
  58. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  59. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  60. package/templates/be/skeleton/.sops.yaml +2 -0
  61. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  62. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  63. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  64. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  65. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  66. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  76. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  77. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  78. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  79. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  80. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  81. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  82. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  83. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  84. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  95. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  96. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  107. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  118. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  119. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  121. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  124. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  125. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  127. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  128. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  133. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  134. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  135. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  136. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  147. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  150. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  151. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  152. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  153. package/sync/skeleton.mjs +0 -76
  154. package/templates/be/gitignore +0 -2
  155. package/templates/be/hooks/husky/pre-commit +0 -13
  156. package/templates/be/hooks/husky/pre-push +0 -6
  157. package/templates/be/package-scripts/package.json +0 -19
  158. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  159. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  160. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  161. package/templates/be/tool-config/prettierignore +0 -8
  162. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  163. package/templates/fe/gitignore +0 -3
  164. package/templates/fe/hooks/husky/pre-commit +0 -16
  165. package/templates/fe/hooks/husky/pre-push +0 -5
  166. package/templates/fe/package-scripts/package.json +0 -13
  167. package/templates/fe/parts/api-client.ts +0 -44
  168. package/templates/fe/parts/api-outcome.ts +0 -7
  169. package/templates/fe/quality-config/sonar-project.properties +0 -8
  170. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  171. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  176. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  177. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  180. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  181. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  182. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  185. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  186. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  192. package/templates/fe/tool-config/prettierrc +0 -1
  193. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  194. /package/templates/{be → app}/starciwork.gitignore +0 -0
  195. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  196. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -0,0 +1,36 @@
1
+ # {{project}}
2
+
3
+ One StarCi app: its back end in be/, its front end in fe/, one install at the root.
4
+
5
+ ## Overview
6
+
7
+ The app starts with one api app (be/apps/api), one Next app (fe/apps/web) and the liveness probe both serve.
8
+
9
+ ## Stack
10
+
11
+ TypeScript everywhere; NestJS in be/, Next.js with next-intl in fe/; npm with one package.json and one lockfile at the root.
12
+
13
+ ## Repository layout
14
+
15
+ - `be/`: the back end, `apps/<app>/src` composes `src/features` and `src/modules`; its tests live under `src/tests`.
16
+ - `fe/`: the front end, one Next app per `apps/<app>`.
17
+ - `scripts/`: the app's operational scripts; `hfs.json` declares both sides.
18
+
19
+ ## Development
20
+
21
+ ```sh
22
+ npm install
23
+ npm run typecheck
24
+ npm run lint
25
+ npm test
26
+ npm run build:be
27
+ npm run build:fe
28
+ ```
29
+
30
+ The api app reads `PORT` and `HTTP_SECURITY_ALLOWED_ORIGINS` (comma-separated origins allowed to send state-changing browser
31
+ requests) at boot; every door is closed until it is marked public or sign-in is designed. The web app reads
32
+ `NEXT_PUBLIC_SITE_URL`. A missing key stops the process with an error that names it.
33
+
34
+ ## Work
35
+
36
+ The product's Work records live in `.starciwork`.
@@ -0,0 +1,4 @@
1
+ // codegen.mjs - the app's generated code, the one step behind `npm run codegen` (lint, typecheck, dev and build run it first).
2
+ // The front end reads the back end's committed contracts in place (be/contracts/, hfs.json sides.fe.reads) and generates its
3
+ // client from them into a gitignored __generated__/ folder. A new app has no contract yet, so there is nothing to generate.
4
+ process.stdout.write("codegen: no contract under be/contracts yet\n")
@@ -1,10 +1,13 @@
1
1
  {{header}}
2
2
  node_modules/
3
- .next/
4
3
  dist/
5
4
  coverage/
6
5
  reports/
6
+ .next/
7
7
  .starci/
8
8
  package-lock.json
9
+ contracts/
10
+ .starcistacks/
11
+ .starciwork/
9
12
  **/__generated__/
10
13
  **/messages/**
@@ -0,0 +1,2 @@
1
+ # Set a project-owned age recipient before encrypting the first secret.
2
+ creation_rules: []
@@ -0,0 +1,10 @@
1
+ schema: starci/application-stacks@1
2
+ services:
3
+ sonar:
4
+ provider: sonarqube
5
+ mode: local
6
+ qualityGate: {{sonarGate}}
7
+ stack:
8
+ owner: host
9
+ root: .claude/ext/sonar
10
+ environment: dev
@@ -1,7 +1,10 @@
1
+ import type { HttpSecurityOptions } from "@modules/platform/http-security"
1
2
  import type { ServerOptions } from "@modules/platform/config"
2
3
 
3
4
  /** Everything the {{app}} app needs from its environment, parsed once by `main.ts` and handed to `AppModule.register`. */
4
5
  export interface {{appPascal}}Options {
5
6
  /** The HTTP listener. */
6
7
  readonly server: ServerOptions
8
+ /** The origin allowlist and rate limits. */
9
+ readonly httpSecurity: HttpSecurityOptions
7
10
  }
@@ -1,17 +1,26 @@
1
1
  import { Module } from "@nestjs/common"
2
2
  import type { DynamicModule } from "@nestjs/common"
3
- import { APP_FILTER } from "@nestjs/core"
3
+ import { APP_FILTER, APP_GUARD } from "@nestjs/core"
4
4
  import { SystemHealthHttpModule } from "@features/system-health"
5
+ import { AuthGuard, IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES, IdentityModule } from "@modules/domain/identity"
5
6
  import { LivenessModule } from "@modules/domain/liveness"
6
7
  import { ClockModule } from "@modules/platform/clock"
7
- import { SERVER_OPTIONS } from "@modules/platform/config"
8
+ import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
8
9
  import { CqrsModule } from "@modules/platform/cqrs"
9
- import { ErrorFilter } from "@modules/platform/errors"
10
+ import { ERRORS_MESSAGES, ErrorsFilter, ErrorsModule } from "@modules/platform/errors"
11
+ import {
12
+ HTTP_SECURITY_ERROR_KINDS,
13
+ HTTP_SECURITY_MESSAGES,
14
+ HttpSecurityModule,
15
+ OriginGuard,
16
+ RateLimitGuard,
17
+ } from "@modules/platform/http-security"
18
+ import { I18nModule } from "@modules/platform/i18n"
10
19
  import { LoggingModule } from "@modules/platform/logging"
11
20
  import type { {{appPascal}}Options } from "./{{app}}.options"
12
21
 
13
22
  @Module({})
14
- /** Composition root of the {{app}} app: every capability is registered once, app-wide, the features it runs and its one error filter. */
23
+ /** Composition root of the {{app}} app: every capability is registered once, app-wide, and the three guards run in a fixed order. */
15
24
  export class AppModule {
16
25
  /** Builds the module from options parsed once at boot; nothing here reads the environment. */
17
26
  static register(options: {{appPascal}}Options): DynamicModule {
@@ -20,13 +29,25 @@ export class AppModule {
20
29
  imports: [
21
30
  ClockModule.register({ isGlobal: true }),
22
31
  LoggingModule.register({ isGlobal: true }),
32
+ I18nModule.register({
33
+ isGlobal: true,
34
+ bundles: [ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
35
+ }),
36
+ ErrorsModule.register({
37
+ isGlobal: true,
38
+ kinds: [CONFIG_ERROR_KINDS, HTTP_SECURITY_ERROR_KINDS, IDENTITY_ERROR_KINDS],
39
+ }),
23
40
  CqrsModule.register({ isGlobal: true }),
41
+ HttpSecurityModule.register({ isGlobal: true, ...options.httpSecurity }),
42
+ IdentityModule.register({ isGlobal: true }),
24
43
  LivenessModule.register({ isGlobal: true }),
25
44
  SystemHealthHttpModule,
26
45
  ],
27
46
  providers: [
28
- { provide: SERVER_OPTIONS, useValue: options.server },
29
- { provide: APP_FILTER, useClass: ErrorFilter },
47
+ { provide: APP_FILTER, useClass: ErrorsFilter },
48
+ { provide: APP_GUARD, useClass: RateLimitGuard },
49
+ { provide: APP_GUARD, useClass: OriginGuard },
50
+ { provide: APP_GUARD, useClass: AuthGuard },
30
51
  ],
31
52
  }
32
53
  }
@@ -1,14 +1,17 @@
1
1
  import { NestFactory } from "@nestjs/core"
2
2
  import { SystemClockService } from "@modules/platform/clock"
3
3
  import { EnvSource, parseServerConfig } from "@modules/platform/config"
4
+ import { parseHttpSecurityConfig } from "@modules/platform/http-security"
4
5
  import { createJsonLogger, LoggingLogEvent } from "@modules/platform/logging"
5
6
  import { AppModule } from "./app.module"
6
7
  import type { {{appPascal}}Options } from "./{{app}}.options"
7
8
 
8
9
  /** Reads the environment once, composes the app from the parsed options and serves until a shutdown signal. */
9
10
  const bootstrap = async (): Promise<void> => {
10
- const options: {{appPascal}}Options = { server: parseServerConfig(EnvSource.fromProcess()) }
11
+ const env = EnvSource.fromProcess()
12
+ const options: {{appPascal}}Options = { server: parseServerConfig(env), httpSecurity: parseHttpSecurityConfig(env) }
11
13
  const app = await NestFactory.create(AppModule.register(options))
14
+ app.enableCors({ origin: [...options.httpSecurity.allowedOrigins] })
12
15
  app.enableShutdownHooks()
13
16
  await app.listen(options.server.port)
14
17
  createJsonLogger(new SystemClockService()).info(LoggingLogEvent.ServerStarted, {
@@ -1,5 +1,6 @@
1
1
  import { Controller, Get } from "@nestjs/common"
2
2
  import type { QueryBus } from "@nestjs/cqrs"
3
+ import { Public, PublicReason } from "@modules/domain/identity"
3
4
  import type { LivenessReport } from "@modules/domain/liveness"
4
5
  import { InjectQueryBus } from "@modules/platform/cqrs"
5
6
  import { CheckLivenessQuery } from "../../application/check-liveness.query"
@@ -11,6 +12,7 @@ export class LiveController {
11
12
 
12
13
  /** Reports the process alive while its event loop still answers requests. */
13
14
  @Get("live")
15
+ @Public({ reason: PublicReason.Health })
14
16
  live(): Promise<LivenessReport> {
15
17
  return this.queryBus.execute(new CheckLivenessQuery({ request: {} }))
16
18
  }
@@ -0,0 +1,11 @@
1
+ import { ok, refused } from "@modules/platform/primitives"
2
+ import type { Outcome } from "@modules/platform/primitives"
3
+ import { IdentityErrorCode } from "./errors/identity.error"
4
+ import type { PublicMetadata, PublicReason } from "./identity.contracts"
5
+
6
+ /**
7
+ * The default-deny decision of one door: a door that states why it is public is admitted; every other door is refused
8
+ * until the design that adds sign-in establishes a caller here.
9
+ */
10
+ export const admit = (metadata: PublicMetadata | undefined): Outcome<PublicReason, IdentityErrorCode> =>
11
+ metadata === undefined ? refused(IdentityErrorCode.Unauthenticated) : ok(metadata.reason)
@@ -0,0 +1,25 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
+ import type { Reflector } from "@nestjs/core"
4
+ import { InjectReflector } from "@modules/platform/composition"
5
+ import { unwrapOutcome } from "@modules/platform/primitives"
6
+ import { admit } from "./admission.policy"
7
+ import { IdentityError } from "./errors/identity.error"
8
+ import type { PublicMetadata } from "./identity.contracts"
9
+ import { PUBLIC_KEY } from "./identity.decorators"
10
+
11
+ @Injectable()
12
+ /** The third app guard and the default-deny gate: a door is open only when it says `@Public({ reason })`. */
13
+ export class AuthGuard implements CanActivate {
14
+ constructor(@InjectReflector() private readonly reflector: Reflector) {}
15
+
16
+ /** Lets public doors through and refuses every other door. */
17
+ canActivate(context: ExecutionContext): boolean {
18
+ const metadata = this.reflector.getAllAndOverride<PublicMetadata | undefined>(PUBLIC_KEY, [
19
+ context.getHandler(),
20
+ context.getClass(),
21
+ ])
22
+ unwrapOutcome(admit(metadata), IdentityError)
23
+ return true
24
+ }
25
+ }
@@ -0,0 +1,16 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
3
+
4
+ /** Codes of the identity capability. */
5
+ export enum IdentityErrorCode {
6
+ /** The door needs a signed-in caller and the request establishes none. */
7
+ Unauthenticated = "IDENTITY_UNAUTHENTICATED",
8
+ }
9
+
10
+ /** How each identity code travels. */
11
+ export const IDENTITY_ERROR_KINDS: Record<IdentityErrorCode, ErrorKind> = {
12
+ [IdentityErrorCode.Unauthenticated]: "unauthenticated",
13
+ }
14
+
15
+ /** The one error class of the identity capability. */
16
+ export class IdentityError extends DomainError<IdentityErrorCode> {}
@@ -0,0 +1,11 @@
1
+ /** Why a door is open to anonymous callers; the vocabulary is closed. */
2
+ export enum PublicReason {
3
+ /** Liveness and readiness probes. */
4
+ Health = "health",
5
+ }
6
+
7
+ /** The metadata `@Public` attaches to a door. */
8
+ export interface PublicMetadata {
9
+ /** Why the door is anonymous. */
10
+ readonly reason: PublicReason
11
+ }
@@ -0,0 +1,8 @@
1
+ import { SetMetadata } from "@nestjs/common"
2
+ import type { PublicMetadata } from "./identity.contracts"
3
+
4
+ /** Metadata key of `@Public`. */
5
+ export const PUBLIC_KEY = "domain.identity.public"
6
+
7
+ /** Opens a door to anonymous callers, stating why; every other door needs a signed-in caller. */
8
+ export const Public = (metadata: PublicMetadata): ReturnType<typeof SetMetadata> => SetMetadata(PUBLIC_KEY, metadata)
@@ -0,0 +1,7 @@
1
+ import { ConfigurableModuleBuilder } from "@nestjs/common"
2
+ import type { IdentityOptions } from "./identity.options"
3
+
4
+ /** The configurable-module base of the identity capability; `isGlobal` is decided by the app root. */
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = new ConfigurableModuleBuilder<IdentityOptions>()
6
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
+ .build()
@@ -0,0 +1,14 @@
1
+ import { Module } from "@nestjs/common"
2
+ import type { DynamicModule } from "@nestjs/common"
3
+ import { AuthGuard } from "./auth.guard"
4
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./identity.module-definition"
5
+
6
+ @Module({})
7
+ /** The identity capability: the guard that closes every door by default. The app registers AuthGuard itself after the throttler and the origin guard. */
8
+ export class IdentityModule extends ConfigurableModuleClass {
9
+ /** Registers the capability once per app. */
10
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
11
+ const base = super.register(options)
12
+ return { ...base, providers: [...(base.providers ?? []), AuthGuard], exports: [AuthGuard] }
13
+ }
14
+ }
@@ -0,0 +1,2 @@
1
+ /** The identity capability takes no options yet; the empty record keeps `isGlobal` assignable through the configurable module. */
2
+ export type IdentityOptions = Record<never, never>
@@ -0,0 +1,6 @@
1
+ export { AuthGuard } from "./auth.guard"
2
+ export { IDENTITY_ERROR_KINDS } from "./errors/identity.error"
3
+ export { IDENTITY_MESSAGES } from "./messages/identity.messages"
4
+ export { PublicReason } from "./identity.contracts"
5
+ export { Public } from "./identity.decorators"
6
+ export { IdentityModule } from "./identity.module"
@@ -0,0 +1,11 @@
1
+ import type { MessageBundle } from "@modules/platform/i18n"
2
+
3
+ /** Display text of the identity codes, Vietnamese and English. */
4
+ export const IDENTITY_MESSAGES: MessageBundle = {
5
+ vi: {
6
+ "errors.IDENTITY_UNAUTHENTICATED": "Bạn cần đăng nhập để thực hiện thao tác này.",
7
+ },
8
+ en: {
9
+ "errors.IDENTITY_UNAUTHENTICATED": "You need to sign in to do this.",
10
+ },
11
+ }
@@ -1,8 +1,15 @@
1
1
  import { Inject } from "@nestjs/common"
2
2
  import type { InjectionToken } from "@nestjs/common"
3
+ import { Reflector } from "@nestjs/core"
3
4
 
4
5
  /** A parameter decorator that remembers the type `T` it injects, so a lint rule can compare it with the parameter annotation. */
5
- export type TypedParameterDecorator<T> = ParameterDecorator & { readonly __injects?: T }
6
+ export type TypedParameterDecorator<T> = ParameterDecorator & {
7
+ /** Never set at run time: it carries `T` so the lint rule can compare it with the parameter annotation. */
8
+ readonly __injects?: T
9
+ }
6
10
 
7
11
  /** Builds the parameter decorator that injects `token`, typed with the value it injects. Every `Inject<Thing>()` is built with it. */
8
12
  export const injector = <T>(token: InjectionToken): TypedParameterDecorator<T> => Inject(token)
13
+
14
+ /** Injects the framework Reflector that guards read handler metadata with. Parameter type: Reflector. */
15
+ export const InjectReflector = (): TypedParameterDecorator<Reflector> => injector<Reflector>(Reflector)
@@ -1,2 +1,2 @@
1
- export { injector } from "./composition.decorators"
1
+ export { InjectReflector, injector } from "./composition.decorators"
2
2
  export type { TypedParameterDecorator } from "./composition.decorators"
@@ -1,40 +1,88 @@
1
1
  import { readFileSync } from "node:fs"
2
- import { ConfigError } from "./errors/config.error"
2
+ import { ConfigError, ConfigErrorCode } from "./errors/config.error"
3
3
 
4
- type Values = Readonly<Record<string, string | undefined>>
4
+ const FILE_SUFFIX = "_FILE"
5
+ const DURATION_UNITS: Readonly<Record<string, number>> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }
6
+ const DURATION_PATTERN = /^(\d+)(ms|s|m|h)?$/
5
7
 
6
- /** The only reader of the process environment. Every key may instead be supplied as `<KEY>_FILE`, a path to its value. */
8
+ /**
9
+ * The only reader of the process environment. Typed readers name the missing or malformed key in the error; a key
10
+ * `<KEY>_FILE` supplies `<KEY>` from a file, so secrets can be mounted instead of exported.
11
+ */
7
12
  export class EnvSource {
8
- private constructor(private readonly values: Values) {}
13
+ constructor(private readonly values: Readonly<Record<string, string | undefined>>) {}
9
14
 
10
- /** The live process environment; called once, from `main.ts`. */
15
+ /** Reads the process environment once, resolving every `<KEY>_FILE` into `<KEY>`. */
11
16
  static fromProcess(): EnvSource {
12
- return new EnvSource(process.env)
17
+ const values: Record<string, string | undefined> = { ...process.env }
18
+ for (const [key, path] of Object.entries(process.env)) {
19
+ if (key.endsWith(FILE_SUFFIX) && path)
20
+ values[key.slice(0, -FILE_SUFFIX.length)] = readFileSync(path, "utf8").trim()
21
+ }
22
+ return new EnvSource(values)
13
23
  }
14
24
 
15
- /** A fixed snapshot, for specs and tools. */
16
- static of(values: Values): EnvSource {
17
- return new EnvSource(values)
25
+ /** True when the key is declared and not empty. */
26
+ has(key: string): boolean {
27
+ return Boolean(this.values[key])
28
+ }
29
+
30
+ /** True when at least one of the keys is declared: the switch of an all-or-nothing optional integration. */
31
+ anyDeclared(keys: ReadonlyArray<string>): boolean {
32
+ return keys.some((key) => this.has(key))
18
33
  }
19
34
 
20
- /** The value of `key`, or `undefined` when it is unset or empty. */
35
+ /** The declared value of an optional key, or undefined. */
21
36
  optional(key: string): string | undefined {
22
- const path = this.values[`${key}_FILE`]
23
- if (path !== undefined && path !== "") {
24
- try {
25
- return readFileSync(path, "utf8").trim()
26
- } catch (cause) {
27
- throw new ConfigError("CONFIG_FILE_UNREADABLE", `${key}_FILE`, { cause })
28
- }
29
- }
37
+ return this.values[key] || undefined
38
+ }
39
+
40
+ /** A required string. */
41
+ string(key: string): string {
30
42
  const value = this.values[key]
31
- return value === undefined || value === "" ? undefined : value
43
+ if (!value) throw new ConfigError({ code: ConfigErrorCode.KeyMissing, params: { key } })
44
+ return value
32
45
  }
33
46
 
34
- /** The value of `key`; a missing key stops the boot with an error that names it. */
35
- required(key: string): string {
36
- const value = this.optional(key)
37
- if (value === undefined) throw new ConfigError("CONFIG_KEY_MISSING", key)
47
+ /** A required integer, or the literal `fallback` when the key is not declared (tunables only). */
48
+ int(key: string, fallback?: number): number {
49
+ if (!this.has(key) && fallback !== undefined) return fallback
50
+ const value = Number(this.string(key))
51
+ if (!Number.isInteger(value)) throw new ConfigError({ code: ConfigErrorCode.KeyInvalid, params: { key } })
38
52
  return value
39
53
  }
54
+
55
+ /** A required boolean (`true` or `false`), or the literal `fallback` when not declared (tunables only). */
56
+ bool(key: string, fallback?: boolean): boolean {
57
+ if (!this.has(key) && fallback !== undefined) return fallback
58
+ const value = this.string(key)
59
+ if (value !== "true" && value !== "false")
60
+ throw new ConfigError({ code: ConfigErrorCode.KeyInvalid, params: { key } })
61
+ return value === "true"
62
+ }
63
+
64
+ /** A required absolute URL, returned as written. */
65
+ url(key: string): string {
66
+ const value = this.string(key)
67
+ if (!URL.canParse(value)) throw new ConfigError({ code: ConfigErrorCode.KeyInvalid, params: { key } })
68
+ return value
69
+ }
70
+
71
+ /** A required value that must be one of `allowed`. */
72
+ enum<T extends string>(key: string, allowed: ReadonlyArray<T>): T {
73
+ const value = this.string(key)
74
+ const found = allowed.find((candidate) => candidate === value)
75
+ if (found === undefined) throw new ConfigError({ code: ConfigErrorCode.KeyInvalid, params: { key } })
76
+ return found
77
+ }
78
+
79
+ /** A duration in milliseconds: `250`, `250ms`, `30s`, `5m` or `2h`; the literal `fallback` applies when not declared (tunables only). */
80
+ duration(key: string, fallback?: number): number {
81
+ if (!this.has(key) && fallback !== undefined) return fallback
82
+ const match = DURATION_PATTERN.exec(this.string(key))
83
+ const unit = DURATION_UNITS[match?.[2] ?? "ms"]
84
+ if (!match?.[1] || unit === undefined)
85
+ throw new ConfigError({ code: ConfigErrorCode.KeyInvalid, params: { key } })
86
+ return Number(match[1]) * unit
87
+ }
40
88
  }
@@ -1,21 +1,19 @@
1
1
  import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
2
3
 
3
- /** Why a configuration key was refused. */
4
- export type ConfigErrorCode = "CONFIG_KEY_MISSING" | "CONFIG_KEY_INVALID" | "CONFIG_FILE_UNREADABLE"
5
-
6
- const REASONS: Readonly<Record<ConfigErrorCode, string>> = {
7
- CONFIG_KEY_MISSING: "is required and has no default",
8
- CONFIG_KEY_INVALID: "does not hold a valid value",
9
- CONFIG_FILE_UNREADABLE: "points at a file that cannot be read",
4
+ /** Codes of the config capability; both are boot failures that name the offending key in the params. */
5
+ export enum ConfigErrorCode {
6
+ /** A required key is not declared in the environment. */
7
+ KeyMissing = "CONFIG_KEY_MISSING",
8
+ /** A declared key does not parse as the type its reader expects. */
9
+ KeyInvalid = "CONFIG_KEY_INVALID",
10
10
  }
11
11
 
12
- /** Startup configuration refusal: names the key and the rule it broke, never the value it was given. */
13
- export class ConfigError extends DomainError {
14
- /** The environment key that was refused. */
15
- readonly key: string
16
-
17
- constructor(code: ConfigErrorCode, key: string, options?: { readonly cause?: unknown }) {
18
- super(code, `Configuration key ${key} ${REASONS[code]}.`, options)
19
- this.key = key
20
- }
12
+ /** How each config code travels. */
13
+ export const CONFIG_ERROR_KINDS: Record<ConfigErrorCode, ErrorKind> = {
14
+ [ConfigErrorCode.KeyMissing]: "internal",
15
+ [ConfigErrorCode.KeyInvalid]: "internal",
21
16
  }
17
+
18
+ /** The one error class of the config capability. */
19
+ export class ConfigError extends DomainError<ConfigErrorCode> {}
@@ -1,4 +1,4 @@
1
- export { SERVER_OPTIONS } from "./config.decorators"
2
1
  export { EnvSource } from "./env-source.config"
2
+ export { CONFIG_ERROR_KINDS } from "./errors/config.error"
3
3
  export { parseServerConfig } from "./server.config"
4
4
  export type { ServerOptions } from "./server.options"
@@ -1,15 +1,5 @@
1
1
  import type { EnvSource } from "./env-source.config"
2
- import { ConfigError } from "./errors/config.error"
3
2
  import type { ServerOptions } from "./server.options"
4
3
 
5
- const DEFAULT_PORT = 3000
6
- const MAX_PORT = 65535
7
-
8
- /** Parses the server keys of an environment; a malformed PORT stops the boot. */
9
- export const parseServerConfig = (env: EnvSource): ServerOptions => {
10
- const raw = env.optional("PORT")
11
- if (raw === undefined) return { port: DEFAULT_PORT }
12
- const port = Number(raw)
13
- if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) throw new ConfigError("CONFIG_KEY_INVALID", "PORT")
14
- return { port }
15
- }
4
+ /** Parses the server keys of an environment; a missing or malformed PORT stops the boot. */
5
+ export const parseServerConfig = (env: EnvSource): ServerOptions => ({ port: env.int("PORT") })
@@ -1,11 +1,20 @@
1
- /** The base of every error a capability declares: a stable machine code, a sentence for people, and the cause. */
2
- export abstract class DomainError extends Error {
3
- /** Stable machine code that transports map to a status and logs group by. */
4
- readonly code: string
1
+ import type { DomainErrorInit, ErrorParams } from "./errors.contracts"
5
2
 
6
- constructor(code: string, message: string, options?: { readonly cause?: unknown }) {
7
- super(message, options)
3
+ /**
4
+ * The base of every capability error family: a stable code, text parameters and the cause. The message is the code
5
+ * itself; display text is resolved from the message catalog by code, so an error never carries prose.
6
+ */
7
+ export abstract class DomainError<C extends string> extends Error {
8
+ /** The stable machine code, `<CAPABILITY>_<WHAT>`. */
9
+ readonly code: C
10
+
11
+ /** Values for the placeholders of the display text. */
12
+ readonly params: ErrorParams
13
+
14
+ constructor(init: DomainErrorInit<C>) {
15
+ super(init.code, init.cause === undefined ? undefined : { cause: init.cause })
8
16
  this.name = new.target.name
9
- this.code = code
17
+ this.code = init.code
18
+ this.params = init.params ?? {}
10
19
  }
11
20
  }
@@ -0,0 +1,16 @@
1
+ import { DomainError } from "../domain.error"
2
+ import type { ErrorKind } from "../errors.contracts"
3
+
4
+ /** Codes of the errors capability: the answer it gives for a failure no capability declared. */
5
+ export enum ErrorsErrorCode {
6
+ /** A failure no capability declared; the transport masks its details. */
7
+ Internal = "ERRORS_INTERNAL",
8
+ }
9
+
10
+ /** How each errors code travels. */
11
+ export const ERRORS_ERROR_KINDS: Record<ErrorsErrorCode, ErrorKind> = {
12
+ [ErrorsErrorCode.Internal]: "internal",
13
+ }
14
+
15
+ /** The one error class of the errors capability. */
16
+ export class ErrorsError extends DomainError<ErrorsErrorCode> {}
@@ -0,0 +1,33 @@
1
+ /** How a failure travels: the transport maps each kind to one HTTP status. */
2
+ export type ErrorKind =
3
+ "invalid" | "unauthenticated" | "forbidden" | "not-found" | "conflict" | "rate-limited" | "unavailable" | "internal"
4
+
5
+ /** Values that fill the named placeholders of an error text and travel next to the code; never prose. */
6
+ export interface ErrorParams {
7
+ readonly [name: string]: string | number
8
+ }
9
+
10
+ /** What a capability error is built from: its code, optional text parameters and the failure that caused it. */
11
+ export interface DomainErrorInit<C extends string> {
12
+ /** The capability code, `<CAPABILITY>_<WHAT>`. */
13
+ readonly code: C
14
+ /** Values for the placeholders of the display text. */
15
+ readonly params?: ErrorParams
16
+ /** The failure that caused this one, kept for the log. */
17
+ readonly cause?: unknown
18
+ }
19
+
20
+ /** One capability's exhaustive code to kind table, as its `<C>_ERROR_KINDS` constant declares it. */
21
+ export type ErrorKindTable = Readonly<Record<string, ErrorKind>>
22
+
23
+ /** Everything a transport needs to answer one failure: the stable code, the kind, the HTTP status and the parameters. */
24
+ export interface ErrorDescription {
25
+ /** The stable machine code. */
26
+ readonly code: string
27
+ /** How the failure travels. */
28
+ readonly kind: ErrorKind
29
+ /** The HTTP status of the kind. */
30
+ readonly status: number
31
+ /** Values for the placeholders of the display text. */
32
+ readonly params: ErrorParams
33
+ }
@@ -0,0 +1,16 @@
1
+ import { injector } from "@modules/platform/composition"
2
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { ErrorsOptions } from "./errors.options"
4
+ import type { ErrorsService } from "./errors.service"
5
+
6
+ /** Token of the errors options, exported so a spec can provide it. */
7
+ export const ERRORS_OPTIONS: unique symbol = Symbol("platform.errors.options")
8
+
9
+ /** Token of the service that describes failures for the transports. */
10
+ export const ERRORS_SERVICE: unique symbol = Symbol("platform.errors.service")
11
+
12
+ /** Injects the options of the errors capability. Parameter type: ErrorsOptions. */
13
+ export const InjectErrorsOptions = (): TypedParameterDecorator<ErrorsOptions> => injector<ErrorsOptions>(ERRORS_OPTIONS)
14
+
15
+ /** Injects the service that describes failures for the transports. Parameter type: ErrorsService. */
16
+ export const InjectErrorsService = (): TypedParameterDecorator<ErrorsService> => injector<ErrorsService>(ERRORS_SERVICE)