@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,32 @@
1
+ import { Catch } from "@nestjs/common"
2
+ import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"
3
+ import type { Request, Response } from "express"
4
+ import { InjectRequestLocale } from "@modules/platform/i18n"
5
+ import type { RequestLocale } from "@modules/platform/i18n"
6
+ import { InjectErrorsService } from "./errors.decorators"
7
+ import type { ErrorsService } from "./errors.service"
8
+
9
+ @Catch()
10
+ /** The one exception filter of an app: answers `{ code, kind, message, params }` with the status of the kind. */
11
+ export class ErrorsFilter implements ExceptionFilter {
12
+ constructor(
13
+ @InjectErrorsService() private readonly errors: ErrorsService,
14
+ @InjectRequestLocale() private readonly requestLocale: RequestLocale,
15
+ ) {}
16
+
17
+ /** Writes the description of `exception` on the HTTP response. */
18
+ catch(exception: unknown, host: ArgumentsHost): void {
19
+ const http = host.switchToHttp()
20
+ const request = http.getRequest<Request>()
21
+ const description = this.errors.describe(exception)
22
+ const locale = this.requestLocale.of(request.headers["accept-language"])
23
+ http.getResponse<Response>()
24
+ .status(description.status)
25
+ .json({
26
+ code: description.code,
27
+ kind: description.kind,
28
+ message: this.errors.text(description.code, description.params, locale),
29
+ params: description.params,
30
+ })
31
+ }
32
+ }
@@ -1,5 +1,5 @@
1
1
  /** Log events of the errors capability. */
2
2
  export enum ErrorsLogEvent {
3
- /** A request ended in a failure the app's filter answered. */
4
- RequestFailed = "http.request_failed",
3
+ /** A failure no capability declared reached a transport and was masked; the cause rides in the line. */
4
+ Unhandled = "errors.unhandled",
5
5
  }
@@ -0,0 +1,9 @@
1
+ import { ConfigurableModuleBuilder } from "@nestjs/common"
2
+ import { ERRORS_OPTIONS } from "./errors.decorators"
3
+ import type { ErrorsOptions } from "./errors.options"
4
+
5
+ /** The configurable-module base of the errors capability; `isGlobal` is decided by the app root. */
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
7
+ new ConfigurableModuleBuilder<ErrorsOptions>({ optionsInjectionToken: ERRORS_OPTIONS })
8
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
9
+ .build()
@@ -0,0 +1,19 @@
1
+ import { Module } from "@nestjs/common"
2
+ import type { DynamicModule } from "@nestjs/common"
3
+ import { ERRORS_SERVICE } from "./errors.decorators"
4
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./errors.module-definition"
5
+ import { ErrorsService } from "./errors.service"
6
+
7
+ @Module({})
8
+ /** The errors capability: the one description service the REST filter and the GraphQL formatter answer through. */
9
+ export class ErrorsModule extends ConfigurableModuleClass {
10
+ /** Registers the capability once per app; each api app binds the ErrorsFilter through APP_FILTER itself. */
11
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
12
+ const base = super.register(options)
13
+ return {
14
+ ...base,
15
+ providers: [...(base.providers ?? []), { provide: ERRORS_SERVICE, useClass: ErrorsService }],
16
+ exports: [ERRORS_SERVICE],
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,7 @@
1
+ import type { ErrorKindTable } from "./errors.contracts"
2
+
3
+ /** Options of the errors capability: the kind tables of every capability the app composes. */
4
+ export interface ErrorsOptions {
5
+ /** One `<C>_ERROR_KINDS` table per capability; a code found in none of them is internal. */
6
+ readonly kinds: ReadonlyArray<ErrorKindTable>
7
+ }
@@ -0,0 +1,94 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { mock } from "@starci/jest-preset"
3
+ import { MESSAGE_CATALOG } from "@modules/platform/i18n"
4
+ import type { MessageCatalog } from "@modules/platform/i18n"
5
+ import { LOGGER } from "@modules/platform/logging"
6
+ import type { Logger } from "@modules/platform/logging"
7
+ import { DomainError } from "./domain.error"
8
+ import { ErrorsErrorCode } from "./errors/errors.error"
9
+ import { ErrorsLogEvent } from "./errors.log-events"
10
+ import { ErrorsService } from "./errors.service"
11
+ import { ERRORS_OPTIONS } from "./errors.decorators"
12
+
13
+ enum DemoCode {
14
+ Missing = "DEMO_MISSING",
15
+ Undeclared = "DEMO_UNDECLARED",
16
+ }
17
+
18
+ class DemoError extends DomainError<DemoCode> {}
19
+
20
+ const build = async () => {
21
+ const logger = mock<Logger>()
22
+ const catalog = mock<MessageCatalog>()
23
+ const moduleRef = await Test.createTestingModule({
24
+ providers: [
25
+ ErrorsService,
26
+ { provide: ERRORS_OPTIONS, useValue: { kinds: [{ [DemoCode.Missing]: "not-found" }] } },
27
+ { provide: MESSAGE_CATALOG, useValue: catalog },
28
+ { provide: LOGGER, useValue: logger },
29
+ ],
30
+ }).compile()
31
+ return { service: moduleRef.get(ErrorsService), logger, catalog }
32
+ }
33
+
34
+ describe("ErrorsService", () => {
35
+ describe("describe", () => {
36
+ it("keeps the code, kind, status and params of a declared capability error", async () => {
37
+ const { service, logger } = await build()
38
+
39
+ const description = service.describe(new DemoError({ code: DemoCode.Missing, params: { id: "t-1" } }))
40
+
41
+ expect(description).toEqual({
42
+ code: DemoCode.Missing,
43
+ kind: "not-found",
44
+ status: 404,
45
+ params: { id: "t-1" },
46
+ })
47
+ expect(logger.error).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it("masks a capability error no composed capability declared, and logs it", async () => {
51
+ const { service, logger } = await build()
52
+ const error = new DemoError({ code: DemoCode.Undeclared })
53
+
54
+ expect(service.describe(error)).toEqual({
55
+ code: ErrorsErrorCode.Internal,
56
+ kind: "internal",
57
+ status: 500,
58
+ params: {},
59
+ })
60
+ expect(logger.error).toHaveBeenCalledWith(ErrorsLogEvent.Unhandled, error)
61
+ })
62
+
63
+ it("masks any other failure as internal, and logs it", async () => {
64
+ const { service, logger } = await build()
65
+ const error = new Error("boom")
66
+
67
+ expect(service.describe(error)).toEqual({
68
+ code: ErrorsErrorCode.Internal,
69
+ kind: "internal",
70
+ status: 500,
71
+ params: {},
72
+ })
73
+ expect(logger.error).toHaveBeenCalledWith(ErrorsLogEvent.Unhandled, error)
74
+ })
75
+ })
76
+
77
+ describe("text", () => {
78
+ it("answers the catalog text of the code in the locale", async () => {
79
+ const { service, catalog } = await build()
80
+ catalog.get.mockReturnValue("Not found")
81
+
82
+ expect(service.text(DemoCode.Missing, { id: "t-1" }, "en")).toBe("Not found")
83
+ expect(catalog.get).toHaveBeenCalledWith(`errors.${DemoCode.Missing}`, { id: "t-1" }, "en")
84
+ })
85
+
86
+ it("answers the text of the internal error for a code without a catalog entry", async () => {
87
+ const { service, catalog } = await build()
88
+ catalog.get.mockImplementation((key) => (key === `errors.${ErrorsErrorCode.Internal}` ? "Internal" : key))
89
+
90
+ expect(service.text(DemoCode.Undeclared, {}, "en")).toBe("Internal")
91
+ expect(catalog.get).toHaveBeenLastCalledWith(`errors.${ErrorsErrorCode.Internal}`, {}, "en")
92
+ })
93
+ })
94
+ })
@@ -0,0 +1,47 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import { InjectLogger } from "@modules/platform/logging"
3
+ import type { Logger } from "@modules/platform/logging"
4
+ import { InjectMessageCatalog } from "@modules/platform/i18n"
5
+ import type { Locale, MessageCatalog } from "@modules/platform/i18n"
6
+ import { DomainError } from "./domain.error"
7
+ import { InjectErrorsOptions } from "./errors.decorators"
8
+ import type { ErrorDescription, ErrorKind, ErrorParams } from "./errors.contracts"
9
+ import { ERRORS_ERROR_KINDS, ErrorsErrorCode } from "./errors/errors.error"
10
+ import { ErrorsLogEvent } from "./errors.log-events"
11
+ import type { ErrorsOptions } from "./errors.options"
12
+ import { HTTP_STATUS_BY_KIND } from "./http-status.policy"
13
+
14
+ @Injectable()
15
+ /** Turns any failure into the one description the transport answers with: declared codes keep their code and kind, everything else is masked. */
16
+ export class ErrorsService {
17
+ private readonly kinds: ReadonlyMap<string, ErrorKind>
18
+
19
+ constructor(
20
+ @InjectErrorsOptions() options: ErrorsOptions,
21
+ @InjectMessageCatalog() private readonly catalog: MessageCatalog,
22
+ @InjectLogger() private readonly logger: Logger,
23
+ ) {
24
+ this.kinds = new Map([...options.kinds, ERRORS_ERROR_KINDS].flatMap((table) => Object.entries(table)))
25
+ }
26
+
27
+ /** Describes `error`: a DomainError of a composed capability keeps its code; anything else is logged and masked as internal. */
28
+ describe(error: unknown): ErrorDescription {
29
+ if (error instanceof DomainError) {
30
+ const kind = this.kinds.get(error.code)
31
+ if (kind !== undefined) return this.build(error.code, kind, error.params)
32
+ }
33
+ this.logger.error(ErrorsLogEvent.Unhandled, error)
34
+ return this.build(ErrorsErrorCode.Internal, "internal", {})
35
+ }
36
+
37
+ /** The display text of a failure code in `locale`; a code without a catalog entry answers the text of the internal error. */
38
+ text(code: string, params: ErrorParams, locale: Locale): string {
39
+ const key = `errors.${code}`
40
+ const text = this.catalog.get(key, params, locale)
41
+ return text === key ? this.catalog.get(`errors.${ErrorsErrorCode.Internal}`, {}, locale) : text
42
+ }
43
+
44
+ private build(code: string, kind: ErrorKind, params: ErrorParams): ErrorDescription {
45
+ return { code, kind, status: HTTP_STATUS_BY_KIND[kind], params }
46
+ }
47
+ }
@@ -0,0 +1,13 @@
1
+ import type { ErrorKind } from "./errors.contracts"
2
+
3
+ /** The one table from error kind to HTTP status; every transport answers a failure through it. */
4
+ export const HTTP_STATUS_BY_KIND: Readonly<Record<ErrorKind, number>> = {
5
+ invalid: 400,
6
+ unauthenticated: 401,
7
+ forbidden: 403,
8
+ "not-found": 404,
9
+ conflict: 409,
10
+ "rate-limited": 429,
11
+ unavailable: 503,
12
+ internal: 500,
13
+ }
@@ -1,2 +1,5 @@
1
+ export type { DomainErrorInit, ErrorKind, ErrorParams } from "./errors.contracts"
1
2
  export { DomainError } from "./domain.error"
2
- export { ErrorFilter } from "./error.filter"
3
+ export { ErrorsFilter } from "./errors.filter"
4
+ export { ErrorsModule } from "./errors.module"
5
+ export { ERRORS_MESSAGES } from "./messages/errors.messages"
@@ -0,0 +1,11 @@
1
+ import type { MessageBundle } from "@modules/platform/i18n"
2
+
3
+ /** Display text of the errors capability codes, Vietnamese and English. */
4
+ export const ERRORS_MESSAGES: MessageBundle = {
5
+ vi: {
6
+ "errors.ERRORS_INTERNAL": "Đã xảy ra lỗi không mong muốn.",
7
+ },
8
+ en: {
9
+ "errors.ERRORS_INTERNAL": "An unexpected error occurred.",
10
+ },
11
+ }
@@ -0,0 +1,19 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
3
+
4
+ /** Codes of the http-security capability. */
5
+ export enum HttpSecurityErrorCode {
6
+ /** A state-changing request came from an origin that is not on the allowlist. */
7
+ OriginRejected = "HTTP_SECURITY_ORIGIN_REJECTED",
8
+ /** The caller sent more requests than its tier allows in the current window. */
9
+ RateLimited = "HTTP_SECURITY_RATE_LIMITED",
10
+ }
11
+
12
+ /** How each http-security code travels. */
13
+ export const HTTP_SECURITY_ERROR_KINDS: Record<HttpSecurityErrorCode, ErrorKind> = {
14
+ [HttpSecurityErrorCode.OriginRejected]: "forbidden",
15
+ [HttpSecurityErrorCode.RateLimited]: "rate-limited",
16
+ }
17
+
18
+ /** The one error class of the http-security capability. */
19
+ export class HttpSecurityError extends DomainError<HttpSecurityErrorCode> {}
@@ -0,0 +1,5 @@
1
+ import type { ExecutionContext } from "@nestjs/common"
2
+ import type { Request } from "express"
3
+
4
+ /** The HTTP request behind an execution context. */
5
+ export const requestOf = (context: ExecutionContext): Request => context.switchToHttp().getRequest<Request>()
@@ -0,0 +1,16 @@
1
+ import type { EnvSource } from "@modules/platform/config"
2
+ import type { HttpSecurityOptions } from "./http-security.options"
3
+
4
+ /** Reads the http-security options: the origin allowlist is required, the rate limits are tunables with literal defaults. */
5
+ export const parseHttpSecurityConfig = (env: EnvSource): HttpSecurityOptions => ({
6
+ allowedOrigins: env
7
+ .string("HTTP_SECURITY_ALLOWED_ORIGINS")
8
+ .split(",")
9
+ .map((origin) => origin.trim())
10
+ .filter(Boolean),
11
+ rateLimit: {
12
+ windowMs: env.duration("HTTP_SECURITY_RATE_WINDOW", 60_000),
13
+ defaultLimit: env.int("HTTP_SECURITY_RATE_DEFAULT_LIMIT", 600),
14
+ strictLimit: env.int("HTTP_SECURITY_RATE_STRICT_LIMIT", 30),
15
+ },
16
+ })
@@ -0,0 +1,10 @@
1
+ import { injector } from "@modules/platform/composition"
2
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { HttpSecurityOptions } from "./http-security.options"
4
+
5
+ /** Token of the http-security options, exported so a spec can provide it. */
6
+ export const HTTP_SECURITY_OPTIONS: unique symbol = Symbol("platform.http-security.options")
7
+
8
+ /** Injects the options of the http-security capability. Parameter type: HttpSecurityOptions. */
9
+ export const InjectHttpSecurityOptions = (): TypedParameterDecorator<HttpSecurityOptions> =>
10
+ injector<HttpSecurityOptions>(HTTP_SECURITY_OPTIONS)
@@ -0,0 +1,9 @@
1
+ import { ConfigurableModuleBuilder } from "@nestjs/common"
2
+ import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
3
+ import type { HttpSecurityOptions } from "./http-security.options"
4
+
5
+ /** The configurable-module base of the http-security capability; `isGlobal` is decided by the app root. */
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
7
+ new ConfigurableModuleBuilder<HttpSecurityOptions>({ optionsInjectionToken: HTTP_SECURITY_OPTIONS })
8
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
9
+ .build()
@@ -0,0 +1,13 @@
1
+ import { Module } from "@nestjs/common"
2
+ import type { DynamicModule } from "@nestjs/common"
3
+ import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } from "./http-security.module-definition"
4
+
5
+ @Module({})
6
+ /** The http-security capability: the options its guards (registered by each api app) read. */
7
+ export class HttpSecurityModule extends ConfigurableModuleClass {
8
+ /** Registers the capability once per app. */
9
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
10
+ const base = super.register(options)
11
+ return { ...base, exports: [MODULE_OPTIONS_TOKEN] }
12
+ }
13
+ }
@@ -0,0 +1,17 @@
1
+ /** How many requests a caller may send per window, per tier. */
2
+ export interface RateLimitOptions {
3
+ /** The length of one window in milliseconds. */
4
+ readonly windowMs: number
5
+ /** Requests per window for the default tier. */
6
+ readonly defaultLimit: number
7
+ /** Requests per window for the strict tier (authentication handshakes and signed webhooks). */
8
+ readonly strictLimit: number
9
+ }
10
+
11
+ /** Options of the http-security capability. */
12
+ export interface HttpSecurityOptions {
13
+ /** The origins allowed to send state-changing requests from a browser. */
14
+ readonly allowedOrigins: ReadonlyArray<string>
15
+ /** The rate limit tiers. */
16
+ readonly rateLimit: RateLimitOptions
17
+ }
@@ -0,0 +1,7 @@
1
+ export { HTTP_SECURITY_ERROR_KINDS } from "./errors/http-security.error"
2
+ export { parseHttpSecurityConfig } from "./http-security.config"
3
+ export { HttpSecurityModule } from "./http-security.module"
4
+ export type { HttpSecurityOptions } from "./http-security.options"
5
+ export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
6
+ export { OriginGuard } from "./origin.guard"
7
+ export { RateLimitGuard } from "./rate-limit.guard"
@@ -0,0 +1,13 @@
1
+ import type { MessageBundle } from "@modules/platform/i18n"
2
+
3
+ /** Display text of the http-security codes, Vietnamese and English. */
4
+ export const HTTP_SECURITY_MESSAGES: MessageBundle = {
5
+ vi: {
6
+ "errors.HTTP_SECURITY_ORIGIN_REJECTED": "Nguồn gửi yêu cầu không được phép.",
7
+ "errors.HTTP_SECURITY_RATE_LIMITED": "Bạn gửi quá nhiều yêu cầu, hãy thử lại sau.",
8
+ },
9
+ en: {
10
+ "errors.HTTP_SECURITY_ORIGIN_REJECTED": "The origin of the request is not allowed.",
11
+ "errors.HTTP_SECURITY_RATE_LIMITED": "Too many requests, try again later.",
12
+ },
13
+ }
@@ -0,0 +1,31 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
+ import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
4
+ import { requestOf } from "./execution-request.mapper"
5
+ import { InjectHttpSecurityOptions } from "./http-security.decorators"
6
+ import type { HttpSecurityOptions } from "./http-security.options"
7
+
8
+ const SAFE_METHODS: ReadonlyArray<string> = ["GET", "HEAD", "OPTIONS"]
9
+
10
+ const originOf = (origin: string | undefined, referer: string | undefined): string | undefined => {
11
+ if (origin) return origin
12
+ return referer && URL.canParse(referer) ? new URL(referer).origin : undefined
13
+ }
14
+
15
+ @Injectable()
16
+ /**
17
+ * The second app guard, the CSRF defence: a state-changing request that names an origin (browsers always do) must name
18
+ * an allowed one. A request without Origin and Referer is not a browser and carries no ambient credential, so it passes.
19
+ */
20
+ export class OriginGuard implements CanActivate {
21
+ constructor(@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions) {}
22
+
23
+ /** Refuses a state-changing request from an origin outside the allowlist. */
24
+ canActivate(context: ExecutionContext): boolean {
25
+ const request = requestOf(context)
26
+ if (SAFE_METHODS.includes(request.method)) return true
27
+ const origin = originOf(request.headers.origin, request.headers.referer)
28
+ if (origin === undefined || this.options.allowedOrigins.includes(origin)) return true
29
+ throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
30
+ }
31
+ }
@@ -0,0 +1,68 @@
1
+ import { Injectable, SetMetadata } from "@nestjs/common"
2
+ import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
+ import type { Reflector } from "@nestjs/core"
4
+ import { InjectClock } from "@modules/platform/clock"
5
+ import type { Clock } from "@modules/platform/clock"
6
+ import { InjectReflector } from "@modules/platform/composition"
7
+ import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
8
+ import { requestOf } from "./execution-request.mapper"
9
+ import { InjectHttpSecurityOptions } from "./http-security.decorators"
10
+ import type { HttpSecurityOptions } from "./http-security.options"
11
+
12
+ /** The rate limit tiers of a door. */
13
+ export enum RateTier {
14
+ /** The global default tier. */
15
+ Default = "default",
16
+ /** The tight tier every authentication handshake and signed webhook carries. */
17
+ Strict = "strict",
18
+ }
19
+
20
+ const RATE_TIER_KEY = "platform.http-security.rate-tier"
21
+ const BUCKETS_MAX = 10_000
22
+
23
+ interface Bucket {
24
+ count: number
25
+ resetAt: number
26
+ }
27
+
28
+ /** Marks a door with a rate limit tier other than the default. */
29
+ export const RateLimit = (tier: RateTier): ReturnType<typeof SetMetadata> => SetMetadata(RATE_TIER_KEY, tier)
30
+
31
+ @Injectable()
32
+ /** The first app guard: a fixed-window counter per caller address and tier, kept in process memory and stamped by the Clock. */
33
+ export class RateLimitGuard implements CanActivate {
34
+ private readonly buckets = new Map<string, Bucket>()
35
+
36
+ constructor(
37
+ @InjectReflector() private readonly reflector: Reflector,
38
+ @InjectClock() private readonly clock: Clock,
39
+ @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
40
+ ) {}
41
+
42
+ /** Counts the request and refuses it when the caller is over the limit of the door tier. */
43
+ canActivate(context: ExecutionContext): boolean {
44
+ const tier =
45
+ this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, [
46
+ context.getHandler(),
47
+ context.getClass(),
48
+ ]) ?? RateTier.Default
49
+ const limit =
50
+ tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
51
+ const now = this.clock.now().getTime()
52
+ const key = `${tier}:${requestOf(context).ip ?? "unknown"}`
53
+ const current = this.buckets.get(key)
54
+ const bucket =
55
+ current && current.resetAt > now ? current : { count: 0, resetAt: now + this.options.rateLimit.windowMs }
56
+ bucket.count += 1
57
+ this.buckets.set(key, bucket)
58
+ if (this.buckets.size > BUCKETS_MAX) this.prune(now)
59
+ if (bucket.count > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
60
+ return true
61
+ }
62
+
63
+ private prune(now: number): void {
64
+ for (const [key, bucket] of this.buckets) {
65
+ if (bucket.resetAt <= now) this.buckets.delete(key)
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,66 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { builder } from "@starci/jest-preset"
3
+ import { BundleMessageCatalog } from "./bundle-message-catalog.service"
4
+ import type { I18nOptions } from "./i18n.options"
5
+ import { I18N_OPTIONS } from "./i18n.decorators"
6
+
7
+ const defaults: I18nOptions = {
8
+ bundles: [
9
+ {
10
+ vi: { "task.title": "Cong viec {{title}}", "task.count": "{{n}} viec" },
11
+ en: { "task.title": "Task {{title}}", "task.count": "{{n}} tasks" },
12
+ },
13
+ { vi: { "share.ok": "Da chia se" }, en: { "share.ok": "Shared" } },
14
+ ],
15
+ }
16
+
17
+ const build = async (overrides?: Partial<I18nOptions>) => {
18
+ const moduleRef = await Test.createTestingModule({
19
+ providers: [
20
+ BundleMessageCatalog,
21
+ { provide: I18N_OPTIONS, useValue: builder<I18nOptions>(defaults)(overrides) },
22
+ ],
23
+ }).compile()
24
+ return moduleRef.get(BundleMessageCatalog)
25
+ }
26
+
27
+ describe("BundleMessageCatalog", () => {
28
+ describe("get", () => {
29
+ it("fills the placeholders of the text in the locale", async () => {
30
+ const catalog = await build()
31
+
32
+ expect(catalog.get("task.title", { title: "Write" }, "en")).toBe("Task Write")
33
+ expect(catalog.get("task.title", { title: "Write" }, "vi")).toBe("Cong viec Write")
34
+ })
35
+
36
+ it("fills a numeric parameter", async () => {
37
+ const catalog = await build()
38
+
39
+ expect(catalog.get("task.count", { n: 3 }, "en")).toBe("3 tasks")
40
+ })
41
+
42
+ it("reads a key from any composed bundle", async () => {
43
+ const catalog = await build()
44
+
45
+ expect(catalog.get("share.ok", {}, "en")).toBe("Shared")
46
+ })
47
+
48
+ it("leaves a placeholder without a value as written", async () => {
49
+ const catalog = await build()
50
+
51
+ expect(catalog.get("task.title", {}, "en")).toBe("Task {{title}}")
52
+ })
53
+
54
+ it("answers the key itself for an unknown key", async () => {
55
+ const catalog = await build()
56
+
57
+ expect(catalog.get("no.such.key", {}, "en")).toBe("no.such.key")
58
+ })
59
+
60
+ it("answers the key itself when no bundle is composed", async () => {
61
+ const catalog = await build({ bundles: [] })
62
+
63
+ expect(catalog.get("task.title", {}, "vi")).toBe("task.title")
64
+ })
65
+ })
66
+ })
@@ -0,0 +1,30 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { Locale, MessageParams } from "./i18n.contracts"
3
+ import { InjectI18nOptions } from "./i18n.decorators"
4
+ import type { I18nOptions } from "./i18n.options"
5
+ import type { MessageCatalog } from "./i18n.port"
6
+
7
+ const PLACEHOLDER = /\{\{(\w+)\}\}/g
8
+
9
+ @Injectable()
10
+ /** The catalog over the bundles of I18nOptions: later bundles never override earlier ones, keys are owned by one bundle. */
11
+ export class BundleMessageCatalog implements MessageCatalog {
12
+ private readonly texts: Readonly<Record<Locale, ReadonlyMap<string, string>>>
13
+
14
+ constructor(@InjectI18nOptions() options: I18nOptions) {
15
+ this.texts = {
16
+ vi: new Map(options.bundles.flatMap((bundle) => Object.entries(bundle.vi))),
17
+ en: new Map(options.bundles.flatMap((bundle) => Object.entries(bundle.en))),
18
+ }
19
+ }
20
+
21
+ /** The text of `key` in `locale` with placeholders filled; a placeholder without a value stays as written. */
22
+ get(key: string, params: MessageParams, locale: Locale): string {
23
+ const text = this.texts[locale].get(key)
24
+ if (text === undefined) return key
25
+ return text.replace(PLACEHOLDER, (placeholder, name: string) => {
26
+ const value = params[name]
27
+ return value === undefined ? placeholder : String(value)
28
+ })
29
+ }
30
+ }
@@ -0,0 +1,18 @@
1
+ /** The languages a catalog carries: Vietnamese and English, both complete. */
2
+ export type Locale = "vi" | "en"
3
+
4
+ /** The value of an `Accept-Language` request header: one line, several lines, or absent. */
5
+ export type AcceptLanguage = string | ReadonlyArray<string> | undefined
6
+
7
+ /** Values that fill the `{{name}}` placeholders of a message. */
8
+ export interface MessageParams {
9
+ readonly [name: string]: string | number
10
+ }
11
+
12
+ /** What an owner writes in its `messages/<owner>.messages.ts`: one flat key to text map per language, the same keys in each. */
13
+ export interface MessageBundle {
14
+ /** Vietnamese texts by key. */
15
+ readonly vi: Readonly<Record<string, string>>
16
+ /** English texts by key. */
17
+ readonly en: Readonly<Record<string, string>>
18
+ }
@@ -0,0 +1,23 @@
1
+ import { injector } from "@modules/platform/composition"
2
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { I18nOptions } from "./i18n.options"
4
+ import type { MessageCatalog, RequestLocale } from "./i18n.port"
5
+
6
+ /** Token of the i18n options, exported so a spec can provide it. */
7
+ export const I18N_OPTIONS: unique symbol = Symbol("platform.i18n.options")
8
+
9
+ /** Token of the MessageCatalog port. */
10
+ export const MESSAGE_CATALOG: unique symbol = Symbol("platform.i18n.message-catalog")
11
+
12
+ /** Token of the RequestLocale port. */
13
+ export const REQUEST_LOCALE: unique symbol = Symbol("platform.i18n.request-locale")
14
+
15
+ /** Injects the options of the i18n capability. Parameter type: I18nOptions. */
16
+ export const InjectI18nOptions = (): TypedParameterDecorator<I18nOptions> => injector<I18nOptions>(I18N_OPTIONS)
17
+
18
+ /** Injects the MessageCatalog port. Parameter type: MessageCatalog. */
19
+ export const InjectMessageCatalog = (): TypedParameterDecorator<MessageCatalog> =>
20
+ injector<MessageCatalog>(MESSAGE_CATALOG)
21
+
22
+ /** Injects the RequestLocale port. Parameter type: RequestLocale. */
23
+ export const InjectRequestLocale = (): TypedParameterDecorator<RequestLocale> => injector<RequestLocale>(REQUEST_LOCALE)
@@ -0,0 +1,9 @@
1
+ import { ConfigurableModuleBuilder } from "@nestjs/common"
2
+ import { I18N_OPTIONS } from "./i18n.decorators"
3
+ import type { I18nOptions } from "./i18n.options"
4
+
5
+ /** The configurable-module base of the i18n capability; `isGlobal` is decided by the app root. */
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
7
+ new ConfigurableModuleBuilder<I18nOptions>({ optionsInjectionToken: I18N_OPTIONS })
8
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
9
+ .build()