@starci/hfs 5.2.0 → 5.4.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 (223) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +2 -1
  3. package/cache/hfs-family.d.mts +2 -3
  4. package/cache/hfs-family.mjs +4 -9
  5. package/cache/secret-records.d.mts +7 -0
  6. package/cache/secret-records.mjs +12 -0
  7. package/cache/session.mjs +4 -2
  8. package/codemod/account-doors.d.mts +36 -0
  9. package/codemod/account-doors.mjs +134 -0
  10. package/codemod/app-prettier.d.mts +5 -0
  11. package/codemod/app-prettier.mjs +27 -0
  12. package/codemod/explicit-surface.mjs +1 -25
  13. package/codemod/main.mjs +35 -1
  14. package/codemod/module-definition.d.mts +59 -0
  15. package/codemod/module-definition.mjs +257 -0
  16. package/codemod/quality-binding.d.mts +33 -0
  17. package/codemod/quality-binding.mjs +38 -0
  18. package/codemod/secrets-cache.d.mts +35 -0
  19. package/codemod/secrets-cache.mjs +70 -0
  20. package/codemod/sops-rules.d.mts +34 -0
  21. package/codemod/sops-rules.mjs +69 -0
  22. package/codemod/theme-tokens.mjs +48 -2
  23. package/lint/run.d.mts +6 -0
  24. package/lint/run.mjs +63 -10
  25. package/package.json +1 -1
  26. package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
  27. package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
  28. package/runtime/knowledge/hfs/rules.yaml +13 -9
  29. package/runtime/knowledge/hfs/slots.yaml +105 -12
  30. package/runtime/modules/kernel/failure-codes.yaml +5 -4
  31. package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
  32. package/runtime/scripts/hfs/architecture/config.mjs +25 -1
  33. package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
  35. package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
  36. package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
  37. package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
  38. package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
  39. package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
  40. package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
  41. package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
  43. package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
  44. package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
  45. package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
  46. package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
  47. package/runtime/scripts/hfs/secret-errors.mjs +8 -0
  48. package/runtime/scripts/hfs/secret-init.d.mts +6 -0
  49. package/runtime/scripts/hfs/secret-init.mjs +23 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +9 -1
  51. package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
  52. package/runtime/scripts/hfs/secret-standard.mjs +106 -0
  53. package/runtime/scripts/hfs/secret.mjs +5 -1
  54. package/runtime/scripts/hfs/view.d.mts +23 -0
  55. package/runtime/scripts/hfs/view.mjs +68 -0
  56. package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
  57. package/runtime/scripts/lib/declaration-surface.mjs +55 -1
  58. package/runtime/scripts/lib/secret-records.d.mts +13 -0
  59. package/runtime/scripts/lib/secret-records.mjs +83 -0
  60. package/scaffold/add-inbox-lite.mjs +2 -2
  61. package/scaffold/add-queue-lite.mjs +1 -3
  62. package/scaffold/app.d.mts +2 -2
  63. package/scaffold/app.mjs +10 -12
  64. package/scaffold/lite-exports.mjs +1 -15
  65. package/scripts/sync-bundles.d.mts +8 -0
  66. package/stack/images/openclaw-runtime/Dockerfile +18 -0
  67. package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
  68. package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
  69. package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
  70. package/stack/local-up.mjs +3 -0
  71. package/stack/runtime-layers.d.mts +21 -0
  72. package/stack/runtime-layers.mjs +67 -0
  73. package/sync/compile-guard.d.mts +36 -0
  74. package/sync/compile-guard.mjs +189 -0
  75. package/sync/hygiene.mjs +3 -1
  76. package/sync/index.d.mts +11 -1
  77. package/sync/index.mjs +61 -10
  78. package/templates/app/auth-doors.sql.partial +115 -0
  79. package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
  80. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
  81. package/templates/app/package-scripts/core.json +1 -1
  82. package/templates/app/package-scripts/package.json +1 -1
  83. package/templates/app/package-scripts-lite/package.json +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +9 -0
  85. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
  87. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
  88. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
  89. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
  90. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +27 -5
  91. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
  92. package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
  102. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
  107. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
  108. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
  109. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
  110. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
  111. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
  112. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
  113. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
  114. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
  115. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
  116. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
  117. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
  123. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
  124. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
  125. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
  127. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
  128. package/templates/be/inbox/module-definition.ts.tpl +2 -9
  129. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
  130. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  131. package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
  132. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
  133. package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
  134. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
  135. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  136. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
  137. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
  138. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
  139. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
  140. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
  141. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
  142. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
  143. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
  144. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
  145. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
  146. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
  147. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
  148. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
  149. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
  150. package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
  151. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
  152. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
  153. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
  154. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
  155. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
  156. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
  157. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
  158. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
  159. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
  162. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
  163. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
  164. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
  165. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
  166. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
  167. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
  168. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
  169. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
  170. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  171. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  172. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
  173. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
  174. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
  175. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
  176. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
  177. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
  185. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
  186. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  187. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
  188. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
  189. package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
  190. package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
  191. package/templates/be/table/module-definition.ts.tpl +2 -9
  192. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
  193. package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
  194. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
  195. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
  196. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
  197. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
  198. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
  199. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
  200. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
  201. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
  202. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
  203. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
  204. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
  205. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
  207. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
  208. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
  209. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
  210. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
  211. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
  212. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
  213. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
  214. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
  215. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
  216. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
  217. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
  218. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
  219. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
  220. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
  221. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
  222. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
  223. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { IdentityOptions } from "./identity.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<IdentityOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<IdentityOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the identity capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<IdentityOptions>()
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { LivenessOptions } from "./liveness.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<LivenessOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<LivenessOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the liveness capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<LivenessOptions>()
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { NoteOptions } from "./note.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<NoteOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<NoteOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the note capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<NoteOptions>()
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { ClockOptions } from "./clock.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<ClockOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<ClockOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the clock capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<ClockOptions>()
@@ -6,6 +6,8 @@ export interface Principal {
6
6
  readonly email?: string
7
7
  /** The authenticated provider role. */
8
8
  readonly role: "authenticated"
9
+ /** The device session the access token belongs to, when the token names one. */
10
+ readonly sessionId?: string
9
11
  }
10
12
 
11
13
  /** The params of an authenticated operation: its mapped request and verified caller. */
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { CqrsOptions } from "./cqrs.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<CqrsOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<CqrsOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the cqrs capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<CqrsOptions>()
@@ -1,18 +1,8 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import { DATABASE_OPTIONS } from "./database.port"
3
3
  import type { DatabaseOptions } from "./database.options"
4
4
 
5
- const built = new ConfigurableModuleBuilder<DatabaseOptions>({
5
+ /** The configurable-module base of the database capability; `isGlobal` is decided by the app root. */
6
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<DatabaseOptions>({
6
7
  optionsInjectionToken: DATABASE_OPTIONS,
7
8
  })
8
- .setExtras({ isGlobal: false }, (definition, extras) => ({
9
- ...definition,
10
- global: extras.isGlobal,
11
- }))
12
- .build()
13
- type Host = ConfigurableModuleHost<DatabaseOptions, "register", "create", { isGlobal: boolean }>
14
-
15
- /** The configurable-module base of the database capability; `isGlobal` is decided by the app root. */
16
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
17
- /** The options of a synchronous registration, for the register signature. */
18
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
@@ -1,15 +1,8 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import { ERRORS_OPTIONS } from "./errors.decorators"
3
3
  import type { ErrorsOptions } from "./errors.options"
4
4
 
5
- const built = new ConfigurableModuleBuilder<ErrorsOptions>({ optionsInjectionToken: ERRORS_OPTIONS })
6
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
- .build()
8
- type Host = ConfigurableModuleHost<ErrorsOptions, "register", "create", { isGlobal: boolean }>
9
-
10
5
  /** The configurable-module base of the errors capability; `isGlobal` is decided by the app root. */
11
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
12
- /** The injection token of the registered options. */
13
- export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
14
- /** The options of a synchronous registration, for the register signature. */
15
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = defineCapabilityModule<ErrorsOptions>({
7
+ optionsInjectionToken: ERRORS_OPTIONS,
8
+ })
@@ -1,13 +1,8 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import { HTTP_OPTIONS } from "./http.decorators"
3
3
  import type { HttpOptions } from "./http.options"
4
4
 
5
- const built = new ConfigurableModuleBuilder<HttpOptions>({ optionsInjectionToken: HTTP_OPTIONS })
6
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
- .build()
8
- type Host = ConfigurableModuleHost<HttpOptions, "register", "create", { isGlobal: boolean }>
9
-
10
5
  /** The configurable-module base of the http capability; `isGlobal` is decided by the app root. */
11
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
12
- /** The options of a synchronous registration, for the register signature. */
13
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
6
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<HttpOptions>({
7
+ optionsInjectionToken: HTTP_OPTIONS,
8
+ })
@@ -1,15 +1,9 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
3
3
  import type { HttpSecurityOptions } from "./http-security.options"
4
4
 
5
- const built = new ConfigurableModuleBuilder<HttpSecurityOptions>({ optionsInjectionToken: HTTP_SECURITY_OPTIONS })
6
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
- .build()
8
- type Host = ConfigurableModuleHost<HttpSecurityOptions, "register", "create", { isGlobal: boolean }>
9
-
10
5
  /** The configurable-module base of the http-security capability; `isGlobal` is decided by the app root. */
11
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
12
- /** The injection token of the registered options. */
13
- export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
14
- /** The options of a synchronous registration, for the register signature. */
15
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
7
+ defineCapabilityModule<HttpSecurityOptions>({
8
+ optionsInjectionToken: HTTP_SECURITY_OPTIONS,
9
+ })
@@ -1,15 +1,8 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import { I18N_OPTIONS } from "./i18n.decorators"
3
3
  import type { I18nOptions } from "./i18n.options"
4
4
 
5
- const built = new ConfigurableModuleBuilder<I18nOptions>({ optionsInjectionToken: I18N_OPTIONS })
6
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
- .build()
8
- type Host = ConfigurableModuleHost<I18nOptions, "register", "create", { isGlobal: boolean }>
9
-
10
5
  /** The configurable-module base of the i18n capability; `isGlobal` is decided by the app root. */
11
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
12
- /** The injection token of the registered options. */
13
- export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
14
- /** The options of a synchronous registration, for the register signature. */
15
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
6
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = defineCapabilityModule<I18nOptions>({
7
+ optionsInjectionToken: I18N_OPTIONS,
8
+ })
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { IdsOptions } from "./ids.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<IdsOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<IdsOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the ids capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<IdsOptions>()
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { LoggingOptions } from "./logging.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<LoggingOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<LoggingOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the logging capability; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<LoggingOptions>()
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { RetryOptions } from "./retry.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<RetryOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<RetryOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the retry capability; the app root decides its global scope. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<RetryOptions>()
@@ -4,6 +4,7 @@ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
4
  import { parseHttpConfig } from "@modules/platform/http"
5
5
  import { parseHttpSecurityConfig } from "@modules/platform/http-security"
6
6
  import { parsePassportConfig } from "@modules/integrations/passport"
7
+ import { parseMailConfig } from "@modules/integrations/mail"
7
8
  import { parseObjectStorageConfig } from "@modules/integrations/object-storage"
8
9
 
9
10
  /** The API environment values required before composition. */
@@ -20,6 +21,8 @@ export interface ApiEnv {
20
21
  readonly passport: ReturnType<typeof parsePassportConfig>
21
22
  /** S3-compatible object storage. */
22
23
  readonly objectStorage: ReturnType<typeof parseObjectStorageConfig>
24
+ /** Outgoing mail: the SMTP server and the app's own mail settings. */
25
+ readonly mail: ReturnType<typeof parseMailConfig>
23
26
  }
24
27
 
25
28
  /** Reads the API environment; missing or invalid values fail boot, and secrets use `_FILE` first. */
@@ -30,4 +33,5 @@ export const readApiEnv = (env: EnvSource): ApiEnv => ({
30
33
  database: parsePrimaryDatabaseConfig(env),
31
34
  passport: parsePassportConfig(env),
32
35
  objectStorage: parseObjectStorageConfig(env),
36
+ mail: parseMailConfig(env),
33
37
  })
@@ -1,4 +1,5 @@
1
1
  import type { PassportOptions } from "@modules/integrations/passport"
2
+ import type { MailOptions } from "@modules/integrations/mail"
2
3
  import type { ObjectStorageOptions } from "@modules/integrations/object-storage"
3
4
  import type { ServerOptions } from "@modules/platform/config"
4
5
  import type { DatabaseConnectionOptions } from "@modules/platform/database"
@@ -19,4 +20,6 @@ export interface ApiOptions {
19
20
  readonly passport: PassportOptions
20
21
  /** S3-compatible object storage. */
21
22
  readonly objectStorage: ObjectStorageOptions
23
+ /** Outgoing mail: the SMTP server and the app's own mail settings. */
24
+ readonly mail: MailOptions
22
25
  }
@@ -1,13 +1,15 @@
1
1
  import type { DynamicModule, Provider } from "@nestjs/common"
2
2
  import { OWN_TOKEN_VERIFIER, PASSPORT_OPTIONS, passportModule } from "@modules/integrations/passport"
3
3
  import type { VerifyAccessToken } from "@modules/integrations/passport"
4
+ import { MailModule } from "@modules/integrations/mail"
4
5
  import { ObjectStorageModule } from "@modules/integrations/object-storage"
5
6
  import type { ApiOptions } from "../api.options"
6
7
 
7
- /** The sign-in and object storage packages of the app, each registered once with the app's typed settings. */
8
+ /** The sign-in, object storage and mail packages of the app, each registered once with the app's typed settings. */
8
9
  export const integrationModules = (options: ApiOptions): Array<DynamicModule> => [
9
10
  passportModule(options.passport, { isGlobal: true }),
10
11
  ObjectStorageModule.register({ ...options.objectStorage, isGlobal: true }),
12
+ MailModule.register({ ...options.mail, isGlobal: true }),
11
13
  ]
12
14
 
13
15
  /** The passport integration ports bound once for this app. */
@@ -17,6 +17,10 @@ export enum IdentityErrorCode {
17
17
  PairRefused = "IDENTITY_INVALID_CREDENTIALS",
18
18
  /** The mailbox was never confirmed. */
19
19
  EmailNotConfirmed = "IDENTITY_EMAIL_NOT_CONFIRMED",
20
+ /** The new password does not fit the policy (at least 8 characters, at most 72 bytes). */
21
+ PasswordInvalid = "IDENTITY_PASSWORD_INVALID",
22
+ /** The confirmation or reset link is unknown, expired or already spent. */
23
+ ResetTokenInvalid = "IDENTITY_RESET_TOKEN_INVALID",
20
24
  }
21
25
 
22
26
  /** How each identity code travels. */
@@ -28,6 +32,8 @@ export const IDENTITY_ERROR_KINDS: Record<IdentityErrorCode, ErrorKind> = {
28
32
  [IdentityErrorCode.TooManyAttempts]: "rate-limited",
29
33
  [IdentityErrorCode.PairRefused]: "unauthenticated",
30
34
  [IdentityErrorCode.EmailNotConfirmed]: "forbidden",
35
+ [IdentityErrorCode.PasswordInvalid]: "invalid",
36
+ [IdentityErrorCode.ResetTokenInvalid]: "invalid",
31
37
  }
32
38
 
33
39
  /** The one error class of the identity capability. */
@@ -26,6 +26,7 @@ import {
26
26
  import { ConfigurableModuleClass, OPTIONS_TYPE } from "./identity.module-definition"
27
27
  import { IdentityRateCounterService } from "./rate-counter.service"
28
28
  import { IdentitySessionStoreService } from "./session-store.service"
29
+ import { IdentityEmailService } from "./identity-email.service"
29
30
  import { IdentitySignInService } from "./sign-in.service"
30
31
 
31
32
  const refusingAuthenticator: Provider = {
@@ -61,6 +62,7 @@ export class IdentityModule extends ConfigurableModuleClass {
61
62
  IdentityRateCounterService,
62
63
  IdentityAuthenticatorService,
63
64
  IdentitySignInService,
65
+ IdentityEmailService,
64
66
  { provide: IDENTITY_AUTH_DOORS, useExisting: PASSPORT_DOORS },
65
67
  { provide: IDENTITY_AUTH_SESSIONS, useExisting: PASSPORT_SESSIONS },
66
68
  { provide: IDENTITY_AUTH_PASSWORDS, useExisting: PASSPORT_PASSWORDS },
@@ -76,6 +78,7 @@ export class IdentityModule extends ConfigurableModuleClass {
76
78
  AuthGuard,
77
79
  ACCESS_TOKEN_VERIFIER,
78
80
  IdentitySignInService,
81
+ IdentityEmailService,
79
82
  PASSPORT_SESSION_STORE,
80
83
  PASSPORT_ACCOUNTS,
81
84
  PASSPORT_RATE_COUNTER,
@@ -6,4 +6,5 @@ export { PublicReason } from "./identity.contracts"
6
6
  export { ACCESS_TOKEN_VERIFIER, Public } from "./identity.decorators"
7
7
  export { IdentityModule } from "./identity.module"
8
8
  export { identityEntities } from "./persistence/entities"
9
+ export { IdentityEmailService } from "./identity-email.service"
9
10
  export { IdentitySignInService } from "./sign-in.service"
@@ -0,0 +1,65 @@
1
+ import { renderTransactional } from "@modules/integrations/mail"
2
+ import type { MailLanguage, RenderedMail } from "@modules/integrations/mail"
3
+
4
+ /** What a confirmation or reset mail is drawn from. */
5
+ export interface LinkMailInput {
6
+ /** The product name in the header. */
7
+ readonly brand: string
8
+ /** The absolute link the button opens. */
9
+ readonly url: string
10
+ /** How many hours the link lives. */
11
+ readonly hours: number
12
+ }
13
+
14
+ interface LinkCopy {
15
+ readonly subject: string
16
+ readonly paragraphs: (input: LinkMailInput) => readonly string[]
17
+ readonly button: string
18
+ readonly footer: string
19
+ }
20
+
21
+ const CONFIRM: Record<MailLanguage, LinkCopy> = {
22
+ vi: {
23
+ subject: "Xác nhận email của bạn",
24
+ paragraphs: ({ brand, hours }) => [`Cảm ơn bạn đã đăng ký ${brand}.`, `Nhấn nút bên dưới để xác nhận email. Liên kết có hiệu lực trong ${String(hours)} giờ và chỉ dùng được một lần.`],
25
+ button: "Xác nhận email",
26
+ footer: "Nếu bạn không đăng ký, hãy bỏ qua thư này.",
27
+ },
28
+ en: {
29
+ subject: "Confirm your email",
30
+ paragraphs: ({ brand, hours }) => [`Thanks for signing up to ${brand}.`, `Press the button below to confirm your email. The link works once and lasts ${String(hours)} hours.`],
31
+ button: "Confirm email",
32
+ footer: "If you did not sign up, ignore this message.",
33
+ },
34
+ }
35
+
36
+ const RESET: Record<MailLanguage, LinkCopy> = {
37
+ vi: {
38
+ subject: "Đặt lại mật khẩu",
39
+ paragraphs: ({ hours }) => ["Chúng tôi nhận được yêu cầu đặt lại mật khẩu cho tài khoản của bạn.", `Nhấn nút bên dưới để chọn mật khẩu mới. Liên kết có hiệu lực trong ${String(hours)} giờ và chỉ dùng được một lần.`],
40
+ button: "Đặt lại mật khẩu",
41
+ footer: "Nếu bạn không yêu cầu, hãy bỏ qua thư này; mật khẩu của bạn không thay đổi.",
42
+ },
43
+ en: {
44
+ subject: "Reset your password",
45
+ paragraphs: ({ hours }) => ["We received a request to reset the password of your account.", `Press the button below to choose a new password. The link works once and lasts ${String(hours)} hours.`],
46
+ button: "Reset password",
47
+ footer: "If you did not ask for this, ignore this message; your password stays as it is.",
48
+ },
49
+ }
50
+
51
+ const draw = (copy: LinkCopy, lang: MailLanguage, input: LinkMailInput): RenderedMail =>
52
+ renderTransactional({
53
+ lang,
54
+ subject: copy.subject,
55
+ brand: { name: input.brand },
56
+ paragraphs: copy.paragraphs(input),
57
+ action: { label: copy.button, url: input.url },
58
+ footer: copy.footer,
59
+ })
60
+
61
+ /** The mail that carries the link confirming a mailbox. */
62
+ export const confirmEmailMail = (lang: MailLanguage, input: LinkMailInput): RenderedMail => draw(CONFIRM[lang], lang, input)
63
+
64
+ /** The mail that carries the link choosing a new password. */
65
+ export const resetPasswordMail = (lang: MailLanguage, input: LinkMailInput): RenderedMail => draw(RESET[lang], lang, input)
@@ -11,6 +11,8 @@ export const IDENTITY_MESSAGES: MessageBundle = {
11
11
  "errors.IDENTITY_TOO_MANY_ATTEMPTS": "Quá nhiều lần thử. Hãy thử lại sau.",
12
12
  "errors.IDENTITY_INVALID_CREDENTIALS": "Email hoặc mật khẩu không đúng.",
13
13
  "errors.IDENTITY_EMAIL_NOT_CONFIRMED": "Hãy xác nhận email trước khi đăng nhập.",
14
+ "errors.IDENTITY_PASSWORD_INVALID": "Mật khẩu cần ít nhất 8 ký tự và tối đa 72 byte.",
15
+ "errors.IDENTITY_RESET_TOKEN_INVALID": "Liên kết không hợp lệ hoặc đã hết hạn.",
14
16
  },
15
17
  en: {
16
18
  "errors.IDENTITY_UNAUTHENTICATED": "You need to sign in to do this.",
@@ -20,5 +22,7 @@ export const IDENTITY_MESSAGES: MessageBundle = {
20
22
  "errors.IDENTITY_TOO_MANY_ATTEMPTS": "Too many attempts. Try again later.",
21
23
  "errors.IDENTITY_INVALID_CREDENTIALS": "The email or password is wrong.",
22
24
  "errors.IDENTITY_EMAIL_NOT_CONFIRMED": "Confirm your email before signing in.",
25
+ "errors.IDENTITY_PASSWORD_INVALID": "The password needs at least 8 characters and at most 72 bytes.",
26
+ "errors.IDENTITY_RESET_TOKEN_INVALID": "The link is invalid or has expired.",
23
27
  },
24
28
  }
@@ -0,0 +1,6 @@
1
+ export { MAILER, MailError, escapeHtml, html, renderTransactional } from "@starci/mail"
2
+ export type { Mailer, MailMessage, RenderedMail, SentMail } from "@starci/mail"
3
+ export { parseMailConfig } from "./mail.config"
4
+ export { InjectMailSettings, InjectMailer, MAIL_SETTINGS } from "./mail.decorators"
5
+ export { MailModule } from "./mail.module"
6
+ export type { MailLanguage, MailOptions } from "./mail.options"
@@ -0,0 +1,33 @@
1
+ import type { MailSecurity } from "@starci/mail"
2
+ import type { EnvSource } from "@modules/platform/config"
3
+ import type { MailLanguage, MailOptions } from "./mail.options"
4
+
5
+ const DEFAULT_PORT = 587
6
+ const CONFIRM_TTL_SECONDS = 86_400
7
+ const RESET_TTL_SECONDS = 3_600
8
+
9
+ const readSecurity = (env: EnvSource): MailSecurity => {
10
+ const value = env.optional("MAIL_SECURITY") ?? "starttls"
11
+ if (value !== "tls" && value !== "starttls" && value !== "none") throw new Error("MAIL_SECURITY must be tls, starttls or none")
12
+ return value
13
+ }
14
+
15
+ const readLanguage = (env: EnvSource): MailLanguage => {
16
+ const value = env.optional("MAIL_DEFAULT_LANGUAGE") ?? "en"
17
+ if (value !== "vi" && value !== "en") throw new Error("MAIL_DEFAULT_LANGUAGE must be vi or en")
18
+ return value
19
+ }
20
+
21
+ /** Reads the mail configuration with no credential or server defaults; the login is optional and its password reads `<KEY>_FILE` first. */
22
+ export const parseMailConfig = (env: EnvSource): MailOptions => ({
23
+ host: env.string("MAIL_HOST"),
24
+ port: env.int("MAIL_PORT", DEFAULT_PORT),
25
+ security: readSecurity(env),
26
+ ...(env.has("MAIL_USER") ? { auth: { user: env.string("MAIL_USER"), pass: env.secret("MAIL_PASSWORD") } } : {}),
27
+ from: { name: env.string("MAIL_FROM_NAME"), address: env.string("MAIL_FROM") },
28
+ webOrigin: new URL(env.url("AUTH_WEB_ORIGIN")).origin,
29
+ brandName: env.string("MAIL_FROM_NAME"),
30
+ defaultLanguage: readLanguage(env),
31
+ confirmTtlSeconds: env.int("MAIL_CONFIRM_TTL_SECONDS", CONFIRM_TTL_SECONDS),
32
+ resetTtlSeconds: env.int("MAIL_RESET_TTL_SECONDS", RESET_TTL_SECONDS),
33
+ })
@@ -0,0 +1,14 @@
1
+ import { MAILER } from "@starci/mail"
2
+ import type { Mailer } from "@starci/mail"
3
+ import { injector } from "@modules/platform/composition"
4
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
5
+ import type { MailOptions } from "./mail.options"
6
+
7
+ /** Token of the app's mail settings (the brand, the default language, the link lifetimes). */
8
+ export const MAIL_SETTINGS: unique symbol = Symbol("integrations.mail.settings")
9
+
10
+ /** Injects the mail port of `@starci/mail`. Parameter type: Mailer. */
11
+ export const InjectMailer = (): TypedParameterDecorator<Mailer> => injector<Mailer>(MAILER)
12
+
13
+ /** Injects the app's mail settings. Parameter type: MailOptions. */
14
+ export const InjectMailSettings = (): TypedParameterDecorator<MailOptions> => injector<MailOptions>(MAIL_SETTINGS)
@@ -0,0 +1,12 @@
1
+ import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
2
+ import type { MailOptions } from "./mail.options"
3
+
4
+ const built = new ConfigurableModuleBuilder<MailOptions>()
5
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
+ .build()
7
+ type Host = ConfigurableModuleHost<MailOptions, "register", "create", { isGlobal: boolean }>
8
+
9
+ /** The configurable-module base of the mail glue; `isGlobal` is decided by the app root. */
10
+ export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
+ /** The options of a synchronous registration, for the register signature. */
12
+ export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
@@ -0,0 +1,27 @@
1
+ import { MailModule as PackageMailModule } from "@starci/mail"
2
+ import type { MailOptions as PackageMailOptions } from "@starci/mail"
3
+ import { Module } from "@nestjs/common"
4
+ import type { DynamicModule } from "@nestjs/common"
5
+ import { MAIL_SETTINGS } from "./mail.decorators"
6
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./mail.module-definition"
7
+
8
+ @Module({})
9
+ /** Composes `@starci/mail` with the app's typed settings and provides the settings of the app's own mail. */
10
+ export class MailModule extends ConfigurableModuleClass {
11
+ /** Registers the capability once per app. */
12
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
13
+ const { webOrigin, brandName, defaultLanguage, confirmTtlSeconds, resetTtlSeconds, isGlobal, ...transport } = options
14
+ const base = super.register(options)
15
+ const mail = PackageMailModule.register({ ...(transport satisfies PackageMailOptions), isGlobal: false })
16
+ return {
17
+ ...base,
18
+ imports: [...(base.imports ?? []), mail],
19
+ providers: [
20
+ ...(base.providers ?? []),
21
+ { provide: MAIL_SETTINGS, useValue: { ...transport, webOrigin, brandName, defaultLanguage, confirmTtlSeconds, resetTtlSeconds } },
22
+ ],
23
+ exports: [...(base.exports ?? []), mail, MAIL_SETTINGS],
24
+ global: isGlobal === true,
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,22 @@
1
+ import type { MailOptions as MailSettings } from "@starci/mail"
2
+
3
+ /** The languages the app writes its mail in. */
4
+ export type MailLanguage = "vi" | "en"
5
+
6
+ /**
7
+ * Outgoing mail parsed once at API boot: the settings of `@starci/mail` and what the app's own mail needs (the product name in
8
+ * the header, the language of a mail whose request names none, how long a confirmation or reset link lives). The words and
9
+ * layout of each mail belong to the domain that sends it.
10
+ */
11
+ export interface MailOptions extends MailSettings {
12
+ /** The origin of the web app: the links of a confirmation or reset mail open its pages. */
13
+ readonly webOrigin: string
14
+ /** The product name in the header of every mail. */
15
+ readonly brandName: string
16
+ /** The language of a mail whose request names none. */
17
+ readonly defaultLanguage: MailLanguage
18
+ /** How long a confirmation link lives, in seconds. */
19
+ readonly confirmTtlSeconds: number
20
+ /** How long a password reset link lives, in seconds. */
21
+ readonly resetTtlSeconds: number
22
+ }
@@ -1,12 +1,5 @@
1
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineCapabilityModule } from "@starci/nest-kit"
2
2
  import type { ObjectStorageOptions } from "./object-storage.options"
3
3
 
4
- const built = new ConfigurableModuleBuilder<ObjectStorageOptions>()
5
- .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
- .build()
7
- type Host = ConfigurableModuleHost<ObjectStorageOptions, "register", "create", { isGlobal: boolean }>
8
-
9
4
  /** The configurable-module base of the object storage glue; `isGlobal` is decided by the app root. */
10
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
11
- /** The options of a synchronous registration, for the register signature. */
12
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<ObjectStorageOptions>()
@@ -1,4 +1,4 @@
1
- export { AuthError, isPresentablePassword, normalizeEmail } from "@starci/auth/server"
1
+ export { AuthError, digestOf, isAcceptablePassword, isPresentablePassword, normalizeEmail } from "@starci/auth/server"
2
2
  export type {
3
3
  AccountDirectory,
4
4
  AccountStanding,
@@ -1,4 +1,5 @@
1
1
  export { DatabaseModule } from "./database.module"
2
+ export { callDbFunction } from "./database.client"
2
3
  export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
3
4
  export { DatabaseProbeService } from "./database-probe.service"
4
5
  export type { DatabaseConnectionOptions } from "./database.options"
@@ -129,6 +129,17 @@ describe("RateLimitGuard", () => {
129
129
  expect(entityManager.update).not.toHaveBeenCalled()
130
130
  })
131
131
 
132
+ it("counts with an increment the database evaluates, so concurrent hits never overwrite each other", async () => {
133
+ const { entityManager, counting } = liveWindow(2)
134
+ const { guard } = await build(entityManager)
135
+
136
+ await guard.canActivate(contextOf("127.0.0.1"))
137
+
138
+ const assignment: unknown = counting?.callsOf("set")[0]?.[0]
139
+ const increment: unknown = typeof assignment === "object" && assignment !== null && "hits" in assignment && typeof assignment.hits === "function" ? Reflect.apply(assignment.hits, undefined, []) : undefined
140
+ expect(increment).toBe("hits + 1")
141
+ })
142
+
132
143
  it("refuses a caller whose shared window is over the default limit", async () => {
133
144
  const { guard } = await build(liveWindow(3).entityManager)
134
145