@starci/hfs 5.2.0 → 5.3.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 (215) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -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 +128 -0
  10. package/codemod/main.mjs +31 -1
  11. package/codemod/module-definition.d.mts +52 -0
  12. package/codemod/module-definition.mjs +242 -0
  13. package/codemod/quality-binding.d.mts +33 -0
  14. package/codemod/quality-binding.mjs +38 -0
  15. package/codemod/secrets-cache.d.mts +35 -0
  16. package/codemod/secrets-cache.mjs +70 -0
  17. package/codemod/sops-rules.d.mts +34 -0
  18. package/codemod/sops-rules.mjs +69 -0
  19. package/codemod/theme-tokens.mjs +48 -2
  20. package/lint/run.d.mts +6 -0
  21. package/lint/run.mjs +63 -10
  22. package/package.json +1 -1
  23. package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
  24. package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
  25. package/runtime/knowledge/hfs/rules.yaml +13 -9
  26. package/runtime/knowledge/hfs/slots.yaml +105 -12
  27. package/runtime/modules/kernel/failure-codes.yaml +5 -4
  28. package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
  29. package/runtime/scripts/hfs/architecture/config.mjs +25 -1
  30. package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
  31. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
  32. package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
  33. package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
  34. package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
  35. package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
  36. package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
  37. package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
  38. package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
  42. package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
  43. package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
  44. package/runtime/scripts/hfs/secret-errors.mjs +8 -0
  45. package/runtime/scripts/hfs/secret-init.d.mts +6 -0
  46. package/runtime/scripts/hfs/secret-init.mjs +23 -0
  47. package/runtime/scripts/hfs/secret-sops.mjs +9 -1
  48. package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
  49. package/runtime/scripts/hfs/secret-standard.mjs +106 -0
  50. package/runtime/scripts/hfs/secret.mjs +5 -1
  51. package/runtime/scripts/hfs/view.d.mts +23 -0
  52. package/runtime/scripts/hfs/view.mjs +68 -0
  53. package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
  54. package/runtime/scripts/lib/declaration-surface.mjs +55 -1
  55. package/runtime/scripts/lib/secret-records.d.mts +13 -0
  56. package/runtime/scripts/lib/secret-records.mjs +83 -0
  57. package/scaffold/add-inbox-lite.mjs +2 -2
  58. package/scaffold/add-queue-lite.mjs +1 -3
  59. package/scaffold/app.d.mts +2 -2
  60. package/scaffold/app.mjs +10 -12
  61. package/scaffold/lite-exports.mjs +1 -15
  62. package/scripts/sync-bundles.d.mts +8 -0
  63. package/stack/images/openclaw-runtime/Dockerfile +18 -0
  64. package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
  65. package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
  66. package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
  67. package/stack/local-up.mjs +3 -0
  68. package/stack/runtime-layers.d.mts +21 -0
  69. package/stack/runtime-layers.mjs +67 -0
  70. package/sync/hygiene.mjs +3 -1
  71. package/sync/index.d.mts +2 -0
  72. package/sync/index.mjs +33 -2
  73. package/templates/app/auth-doors.sql.partial +115 -0
  74. package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
  75. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
  76. package/templates/app/package-scripts/core.json +1 -1
  77. package/templates/app/package-scripts/package.json +1 -1
  78. package/templates/app/package-scripts-lite/package.json +1 -1
  79. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +9 -0
  80. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
  81. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
  82. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
  83. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
  84. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
  85. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +24 -4
  86. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
  87. package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
  88. package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
  89. package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
  90. package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
  91. package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
  92. package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
  97. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
  98. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
  99. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
  100. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
  101. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
  102. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
  103. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
  106. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
  108. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
  110. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
  113. package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
  116. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
  117. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
  119. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
  120. package/templates/be/inbox/module-definition.ts.tpl +2 -9
  121. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
  122. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  123. package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
  124. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
  125. package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
  126. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
  127. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  128. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
  129. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
  130. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
  131. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
  132. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
  133. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
  134. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
  135. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
  136. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
  137. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
  138. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
  139. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
  140. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
  141. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
  142. package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
  143. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
  144. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
  145. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
  146. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
  147. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
  148. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
  149. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
  150. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
  151. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
  152. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
  153. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
  154. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
  155. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
  156. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
  157. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
  158. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
  159. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
  160. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
  161. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
  162. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  163. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  164. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
  165. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
  166. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
  167. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
  168. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
  169. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
  170. package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
  171. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
  172. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
  173. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
  174. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
  175. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
  176. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
  177. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
  178. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  179. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
  180. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
  181. package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
  182. package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
  183. package/templates/be/table/module-definition.ts.tpl +2 -9
  184. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
  185. package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
  186. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
  187. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
  188. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
  189. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
  190. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
  191. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
  192. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
  193. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
  194. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
  195. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
  196. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
  197. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
  198. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
  199. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
  200. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
  201. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
  202. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
  203. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
  204. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
  205. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
  207. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
  208. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
  209. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
  210. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
  211. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
  212. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
  213. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
  214. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
  215. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
@@ -7,10 +7,15 @@ import { LogoutHandler } from "./application/logout.handler"
7
7
  import { GoogleBeginHandler } from "./application/google-begin.handler"
8
8
  import { GoogleCompleteHandler } from "./application/google-complete.handler"
9
9
  import { GetJwksHandler } from "./application/get-jwks.handler"
10
+ import { SignupHandler } from "./application/signup.handler"
11
+ import { ResendHandler } from "./application/resend.handler"
12
+ import { ConfirmHandler } from "./application/confirm.handler"
13
+ import { ForgotHandler } from "./application/forgot.handler"
14
+ import { ResetHandler } from "./application/reset.handler"
10
15
  import { ConfigurableModuleClass } from "./auth.module-definition"
11
16
 
12
17
  @Module({
13
- providers: [LoginHandler, RefreshHandler, SessionHandler, LogoutHandler, GoogleBeginHandler, GoogleCompleteHandler, GetJwksHandler],
18
+ providers: [LoginHandler, RefreshHandler, SessionHandler, LogoutHandler, GoogleBeginHandler, GoogleCompleteHandler, GetJwksHandler, SignupHandler, ResendHandler, ConfirmHandler, ForgotHandler, ResetHandler],
14
19
  })
15
20
  /** The auth feature: the handlers behind the sign-in doors. */
16
21
  export class AuthApiModule extends ConfigurableModuleClass {}
@@ -1,13 +1,6 @@
1
- // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
- import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
1
+ import { defineConfigurableModule } from "@starci/nest-kit"
3
2
  import type { AuthHttpOptions } from "./auth-http.options"
4
3
 
5
- const built = new ConfigurableModuleBuilder<AuthHttpOptions>().setClassMethodName("register").build()
6
- type Host = ConfigurableModuleHost<AuthHttpOptions, "register", "create", {}>
7
-
8
4
  /** The configurable-module base of the auth-http module. */
9
- export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
10
- /** The injection token of the registered options. */
11
- export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
12
- /** The options of a synchronous registration, for the register signature. */
13
- export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
5
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
6
+ defineConfigurableModule<AuthHttpOptions>()
@@ -6,6 +6,11 @@ import { Public, PublicReason } from "@modules/domain/identity"
6
6
  import type { AccessGrant, GoogleRedirect, JwksDocument } from "@modules/integrations/passport"
7
7
  import { InjectCommandBus, InjectQueryBus } from "@modules/platform/cqrs"
8
8
  import { RateLimit, RateTier } from "@modules/platform/http-security"
9
+ import { SignupCommand } from "../../application/signup.command"
10
+ import { ResendCommand } from "../../application/resend.command"
11
+ import { ConfirmCommand } from "../../application/confirm.command"
12
+ import { ForgotCommand } from "../../application/forgot.command"
13
+ import { ResetCommand } from "../../application/reset.command"
9
14
  import { GetJwksQuery } from "../../application/get-jwks.query"
10
15
  import { GoogleBeginCommand } from "../../application/google-begin.command"
11
16
  import { GoogleCompleteCommand } from "../../application/google-complete.command"
@@ -13,11 +18,16 @@ import { LoginCommand } from "../../application/login.command"
13
18
  import { LogoutCommand } from "../../application/logout.command"
14
19
  import { RefreshCommand } from "../../application/refresh.command"
15
20
  import { SessionCommand } from "../../application/session.command"
21
+ import { ForgotRequest } from "./dto/forgot.request"
16
22
  import { GoogleStartRequest } from "./dto/google-start.request"
17
23
  import { LoginRequest } from "./dto/login.request"
24
+ import { ResendRequest } from "./dto/resend.request"
25
+ import { ResetRequest } from "./dto/reset.request"
26
+ import { SignupRequest } from "./dto/signup.request"
27
+ import { TokenRequest } from "./dto/token.request"
18
28
 
19
29
  @Controller("auth")
20
- /** The cookie doors of sign-in: email and password, Google, refresh, session, sign-out and the public keys. */
30
+ /** The cookie doors of sign-in: email and password, the email doors (sign up, confirm, resend, forgot, reset), Google, refresh, session, sign-out and the public keys. */
21
31
  export class AuthController {
22
32
  constructor(
23
33
  @InjectCommandBus() private readonly commandBus: CommandBus,
@@ -80,6 +90,51 @@ export class AuthController {
80
90
  return this.commandBus.execute(new GoogleCompleteCommand({ request: { exchange: { req, res } } }))
81
91
  }
82
92
 
93
+ /** Creates an unconfirmed account and mails the confirmation link; always answers 204. */
94
+ @Post("signup")
95
+ @HttpCode(204)
96
+ @Public({ reason: PublicReason.AuthHandshake })
97
+ @RateLimit(RateTier.Strict)
98
+ signup(@Body() body: SignupRequest): Promise<void> {
99
+ return this.commandBus.execute(new SignupCommand({ request: { email: body.email, password: body.password, language: body.language } }))
100
+ }
101
+
102
+ /** Mails a new confirmation link; always answers 204. */
103
+ @Post("resend")
104
+ @HttpCode(204)
105
+ @Public({ reason: PublicReason.AuthHandshake })
106
+ @RateLimit(RateTier.Strict)
107
+ resend(@Body() body: ResendRequest): Promise<void> {
108
+ return this.commandBus.execute(new ResendCommand({ request: { email: body.email, language: body.language } }))
109
+ }
110
+
111
+ /** Spends a confirmation link; a spent, expired or unknown link is refused with IDENTITY_RESET_TOKEN_INVALID. */
112
+ @Post("confirm")
113
+ @HttpCode(204)
114
+ @Public({ reason: PublicReason.AuthHandshake })
115
+ @RateLimit(RateTier.Strict)
116
+ confirm(@Body() body: TokenRequest): Promise<void> {
117
+ return this.commandBus.execute(new ConfirmCommand({ request: { token: body.token } }))
118
+ }
119
+
120
+ /** Mails a password reset link when the address has an account; always answers 204. */
121
+ @Post("forgot")
122
+ @HttpCode(204)
123
+ @Public({ reason: PublicReason.AuthHandshake })
124
+ @RateLimit(RateTier.Strict)
125
+ forgot(@Body() body: ForgotRequest): Promise<void> {
126
+ return this.commandBus.execute(new ForgotCommand({ request: { email: body.email, language: body.language } }))
127
+ }
128
+
129
+ /** Spends a reset link and sets the new password; every session of the person ends. */
130
+ @Post("reset")
131
+ @HttpCode(204)
132
+ @Public({ reason: PublicReason.AuthHandshake })
133
+ @RateLimit(RateTier.Strict)
134
+ reset(@Body() body: ResetRequest): Promise<void> {
135
+ return this.commandBus.execute(new ResetCommand({ request: { token: body.token, password: body.password } }))
136
+ }
137
+
83
138
  /** The public keys access tokens verify against. */
84
139
  @Get("jwks")
85
140
  @Public({ reason: PublicReason.AuthHandshake })
@@ -0,0 +1,16 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsEmail, IsIn, IsOptional, MaxLength } from "class-validator"
3
+ import type { MailLanguage } from "@modules/integrations/mail"
4
+
5
+ /** The posted address of a password reset request. */
6
+ export class ForgotRequest {
7
+ /** The sign-in address. */
8
+ @IsEmail()
9
+ @MaxLength(254)
10
+ email!: string
11
+
12
+ /** The language of the mail; the app's default when left out. */
13
+ @IsOptional()
14
+ @IsIn(["vi", "en"])
15
+ language?: MailLanguage
16
+ }
@@ -0,0 +1,16 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsEmail, IsIn, IsOptional, MaxLength } from "class-validator"
3
+ import type { MailLanguage } from "@modules/integrations/mail"
4
+
5
+ /** The posted address of a resend. */
6
+ export class ResendRequest {
7
+ /** The sign-in address. */
8
+ @IsEmail()
9
+ @MaxLength(254)
10
+ email!: string
11
+
12
+ /** The language of the mail; the app's default when left out. */
13
+ @IsOptional()
14
+ @IsIn(["vi", "en"])
15
+ language?: MailLanguage
16
+ }
@@ -0,0 +1,18 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsString, MaxLength, MinLength } from "class-validator"
3
+
4
+ /** The posted link token and new password. */
5
+ export class ResetRequest {
6
+ /** The token the link carries. */
7
+ @IsString()
8
+ @MinLength(16)
9
+ @MaxLength(256)
10
+ token!: string
11
+
12
+ /** The new password. */
13
+ @IsString()
14
+ @MinLength(1)
15
+ @MaxLength(128)
16
+ password!: string
17
+
18
+ }
@@ -0,0 +1,22 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from "class-validator"
3
+ import type { MailLanguage } from "@modules/integrations/mail"
4
+
5
+ /** The posted sign-up data. */
6
+ export class SignupRequest {
7
+ /** The sign-in address. */
8
+ @IsEmail()
9
+ @MaxLength(254)
10
+ email!: string
11
+
12
+ /** The password. */
13
+ @IsString()
14
+ @MinLength(1)
15
+ @MaxLength(128)
16
+ password!: string
17
+
18
+ /** The language of the mail; the app's default when left out. */
19
+ @IsOptional()
20
+ @IsIn(["vi", "en"])
21
+ language?: MailLanguage
22
+ }
@@ -0,0 +1,11 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsString, MaxLength, MinLength } from "class-validator"
3
+
4
+ /** The posted link token. */
5
+ export class TokenRequest {
6
+ /** The token the link carries. */
7
+ @IsString()
8
+ @MinLength(16)
9
+ @MaxLength(256)
10
+ token!: string
11
+ }
@@ -1,46 +1,46 @@
1
1
  // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
2
  import "reflect-metadata"
3
3
  import { Test } from "@nestjs/testing"
4
- import { FakeClock, mockEntityManager } from "@starci/jest-preset"
5
- import { CLOCK } from "@modules/platform/clock"
4
+ import { mockEntityManager } from "@starci/jest-preset"
6
5
  import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
7
- import { authUserRow as user } from "@tests/fixtures/builders/identity.builder"
8
- import { AuthUserEntity } from "./persistence/entities"
9
- import { IdentityAccountDirectoryService, isDisabled } from "./account-directory.service"
6
+ import { accountByIdRow } from "@tests/fixtures/builders/identity.builder"
7
+ import { IdentityAccountDirectoryService } from "./account-directory.service"
10
8
 
11
- const AT = "2026-02-03T04:05:06.000Z"
9
+ const USER_BY_ID = 'SELECT * FROM "public"."be_auth_user_by_id"($1)'
12
10
 
13
- const build = async (rows: ReadonlyArray<AuthUserEntity | null>) => {
14
- const entityManager = mockEntityManager({ findOne: rows.map((result) => [AuthUserEntity, result] as const) })
11
+ const build = async (rows: unknown) => {
12
+ const entityManager = mockEntityManager({ query: [USER_BY_ID, rows] })
15
13
  const moduleRef = await Test.createTestingModule({
16
- providers: [
17
- IdentityAccountDirectoryService,
18
- { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
19
- { provide: CLOCK, useValue: new FakeClock(AT) },
20
- ],
14
+ providers: [IdentityAccountDirectoryService, { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager }],
21
15
  }).compile()
22
- return moduleRef.get(IdentityAccountDirectoryService)
16
+ return { directory: moduleRef.get(IdentityAccountDirectoryService), entityManager }
23
17
  }
24
18
 
25
19
  describe("IdentityAccountDirectoryService.find", () => {
26
- it("answers the standing of a live account, a banned one, a deleted one and a missing one", async () => {
27
- const subject = await build([user("live"), user("banned"), user("ban-ended"), user("deleted"), null])
28
-
29
- expect(await subject.find("u-1")).toEqual({ id: "u-1", email: "a@example.test", disabled: false })
30
- expect((await subject.find("u-1"))?.disabled).toBe(true)
31
- expect((await subject.find("u-1"))?.disabled).toBe(false)
32
- expect((await subject.find("u-1"))?.disabled).toBe(true)
33
- expect(await subject.find("u-2")).toBeUndefined()
20
+ it("answers the standing of a live account through the door", async () => {
21
+ const { directory, entityManager } = await build([accountByIdRow("live")])
22
+
23
+ expect(await directory.find("u-1")).toEqual({ id: "u-1", email: "a@example.test", disabled: false })
24
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_ID, ["u-1"])
25
+ })
26
+
27
+ it("answers a disabled account as disabled, and an account without an address with none", async () => {
28
+ const banned = await build([accountByIdRow("disabled")])
29
+ const addressless = await build([accountByIdRow("addressless")])
30
+
31
+ expect((await banned.directory.find("u-1"))?.disabled).toBe(true)
32
+ expect(await addressless.directory.find("u-1")).toEqual({ id: "u-1", email: null, disabled: false })
33
+ })
34
+
35
+ it("answers undefined for an account that does not exist", async () => {
36
+ const { directory } = await build([])
37
+
38
+ expect(await directory.find("u-2")).toBeUndefined()
34
39
  })
35
- })
36
40
 
37
- describe("isDisabled", () => {
38
- it("is true for a deleted account and for a ban that has not ended", () => {
39
- const now = new Date(AT)
41
+ it("refuses a door answer that is not the account shape", async () => {
42
+ const { directory } = await build([{ id: "u-1", email: "a@example.test", disabled: "no" }])
40
43
 
41
- expect(isDisabled(user("deleted"), now)).toBe(true)
42
- expect(isDisabled(user("banned"), now)).toBe(true)
43
- expect(isDisabled(user("ban-ended"), now)).toBe(false)
44
- expect(isDisabled(user("live"), now)).toBe(false)
44
+ await expect(directory.find("u-1")).rejects.toMatchObject({ code: "DATABASE_RETURNING_ROW_REJECTED" })
45
45
  })
46
46
  })
@@ -2,27 +2,18 @@
2
2
  import { Injectable } from "@nestjs/common"
3
3
  import type { EntityManager } from "typeorm"
4
4
  import type { AccountDirectory, AccountStanding } from "@modules/integrations/passport"
5
- import { InjectClock } from "@modules/platform/clock"
6
- import type { Clock } from "@modules/platform/clock"
7
5
  import { InjectPrimaryEntityManager } from "@modules/platform/database"
8
- import { AuthUserEntity } from "./persistence/entities"
9
-
10
- /** True when the account is deleted or banned at this instant. */
11
- export const isDisabled = (user: AuthUserEntity, now: Date): boolean =>
12
- user.deletedAt !== null || (user.bannedUntil !== null && user.bannedUntil > now)
6
+ import { accountDoors } from "./persistence/account-doors.port"
13
7
 
14
8
  @Injectable()
15
- /** The package's account directory over auth.users: whether an account still stands, for a refresh to go on. */
9
+ /** The package's account directory over the account doors: whether an account still stands, for a refresh to go on. */
16
10
  export class IdentityAccountDirectoryService implements AccountDirectory {
17
- constructor(
18
- @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
19
- @InjectClock() private readonly clock: Clock,
20
- ) {}
11
+ constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
21
12
 
22
- /** The standing of one account, or undefined when it does not exist. */
13
+ /** The standing of one account (disabled is decided by the database clock), or undefined when it does not exist. */
23
14
  async find(userId: string): Promise<AccountStanding | undefined> {
24
- const user = await this.entityManager.findOne(AuthUserEntity, { where: { id: userId } })
25
- if (user === null) return undefined
26
- return { id: user.id, email: user.email, disabled: isDisabled(user, this.clock.now()) }
15
+ const account = await accountDoors.userById(this.entityManager, userId)
16
+ if (account === undefined) return undefined
17
+ return { id: account.id, email: account.email, disabled: account.disabled }
27
18
  }
28
19
  }
@@ -8,16 +8,25 @@ import type { AuthPasswords, GoogleProfile, SignInBudget } from "@modules/integr
8
8
  import { CLOCK } from "@modules/platform/clock"
9
9
  import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
10
10
  import { IDS } from "@modules/platform/ids"
11
+ import { LOGGER } from "@modules/platform/logging"
12
+ import type { Logger } from "@modules/platform/logging"
11
13
  import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
12
14
  import { IDENTITY_AUTH_PASSWORDS, IDENTITY_SIGN_IN_BUDGET } from "./identity.decorators"
13
- import { authUserRow as user } from "@tests/fixtures/builders/identity.builder"
15
+ import { IdentitySignInLogEvent } from "./identity-sign-in.log-events"
16
+ import { accountByEmailRow, accountByIdRow } from "@tests/fixtures/builders/identity.builder"
14
17
  import { IdentityAuthenticatorService } from "./authenticator.service"
15
- import { AuthIdentityEntity, AuthUserEntity } from "./persistence/entities"
18
+
19
+ /** The statements callDbFunction sends for the account doors. */
20
+ const USER_BY_EMAIL = 'SELECT * FROM "public"."be_auth_user_by_email"($1)'
21
+ const USER_BY_ID = 'SELECT * FROM "public"."be_auth_user_by_id"($1)'
22
+ const USER_BY_GOOGLE = 'SELECT * FROM "public"."be_auth_user_by_google"($1)'
23
+ const CREATE_USER = 'SELECT * FROM "public"."be_auth_create_user"($1, $2, $3, $4, $5, $6)'
24
+ const LINK_GOOGLE = 'SELECT * FROM "public"."be_auth_link_google"($1, $2, $3, $4)'
25
+ const TOUCH_USER = 'SELECT * FROM "public"."be_auth_touch_user"($1, $2, $3, $4)'
16
26
 
17
27
  const NEW_ID = "00000000-0000-4000-8000-000000000001"
18
28
  const AT = "2026-02-03T04:05:06.000Z"
19
29
  const NOW = new Date(AT)
20
- const BEFORE = new Date("2026-02-03T04:00:00.000Z")
21
30
 
22
31
  const profile = (patch: Partial<GoogleProfile> = {}): GoogleProfile => ({
23
32
  subject: "g-1",
@@ -28,11 +37,16 @@ const profile = (patch: Partial<GoogleProfile> = {}): GoogleProfile => ({
28
37
  ...patch,
29
38
  })
30
39
 
31
- const build = async (entityManager: MockEntityManager, matches = true) => {
40
+ type Stubs = ReadonlyArray<readonly [string, unknown]>
41
+
42
+ const build = async (answers: Stubs, matches = true) => {
43
+ const entityManager: MockEntityManager = answers.length === 0 ? mockEntityManager() : mockEntityManager({ query: answers })
44
+ fakeTransaction(entityManager)
32
45
  const passwords = mock<AuthPasswords>()
33
46
  passwords.verify.mockResolvedValue(matches)
34
47
  const budget = mock<SignInBudget>()
35
48
  budget.counted.mockImplementation(async (_kind, _address, check) => (await check()).value)
49
+ const logger = mock<Logger>()
36
50
  const moduleRef = await Test.createTestingModule({
37
51
  providers: [
38
52
  IdentityAuthenticatorService,
@@ -41,105 +55,155 @@ const build = async (entityManager: MockEntityManager, matches = true) => {
41
55
  { provide: IDENTITY_SIGN_IN_BUDGET, useValue: budget },
42
56
  { provide: CLOCK, useValue: new FakeClock(AT) },
43
57
  { provide: IDS, useValue: fakeIds() },
58
+ { provide: LOGGER, useValue: logger },
44
59
  ],
45
60
  }).compile()
46
- return { subject: moduleRef.get(IdentityAuthenticatorService), passwords, budget }
61
+ return { subject: moduleRef.get(IdentityAuthenticatorService), entityManager, passwords, budget, logger }
47
62
  }
48
63
 
49
64
  const refusal = (code: string): unknown => expect.objectContaining({ code })
50
65
 
51
66
  describe("IdentityAuthenticatorService.verifyPassword", () => {
52
- it("answers the person of a right pair, normalising the address, and records the sign-in", async () => {
53
- const entityManager = mockEntityManager({ findOne: [AuthUserEntity, user()], update: [AuthUserEntity, {}] })
54
- const { subject, passwords, budget } = await build(entityManager)
67
+ it("answers the person of a right pair, normalising the address, and records the sign-in through the door", async () => {
68
+ const { subject, entityManager, passwords, budget } = await build([
69
+ [USER_BY_EMAIL, [accountByEmailRow()]],
70
+ [TOUCH_USER, []],
71
+ ])
55
72
 
56
73
  expect(await subject.verifyPassword(" A@Example.test ", "correct horse")).toEqual({ userId: "u-1", email: "a@example.test" })
57
74
 
58
75
  expect(budget.counted).toHaveBeenCalledWith("login", "a@example.test", expect.any(Function))
59
76
  expect(passwords.verify).toHaveBeenCalledWith("correct horse", "hash")
60
- expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW })
77
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_EMAIL, ["a@example.test"])
78
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, false, "{}"])
61
79
  })
62
80
 
63
81
  it("refuses an unknown address and a wrong password with the same code, and still spends the check on a decoy", async () => {
64
- const unknown = await build(mockEntityManager({ findOne: [AuthUserEntity, null] }))
65
- const wrong = await build(mockEntityManager({ findOne: [AuthUserEntity, user()] }), false)
82
+ const unknown = await build([[USER_BY_EMAIL, []]])
83
+ const wrong = await build([[USER_BY_EMAIL, [accountByEmailRow()]]], false)
66
84
 
67
85
  await expect(unknown.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.PairRefused))
68
86
  await expect(wrong.subject.verifyPassword("a@example.test", "pw")).rejects.toBeInstanceOf(IdentityError)
69
87
  expect(unknown.passwords.verify).toHaveBeenCalledWith("pw", "")
88
+ expect(wrong.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
70
89
  })
71
90
 
72
91
  it("compares an empty password for one bcrypt cannot read, an account without a hash against the decoy", async () => {
73
- const { subject, passwords } = await build(mockEntityManager({ findOne: [AuthUserEntity, user("passwordless")] }), false)
92
+ const { subject, passwords } = await build([[USER_BY_EMAIL, [accountByEmailRow("passwordless")]]], false)
74
93
 
75
94
  await expect(subject.verifyPassword("a@example.test", "x".repeat(100))).rejects.toBeInstanceOf(IdentityError)
76
95
 
77
96
  expect(passwords.verify).toHaveBeenCalledWith("", "")
78
97
  })
79
98
 
80
- it("refuses a banned or deleted account and an unconfirmed one after the password was right", async () => {
81
- const banned = await build(mockEntityManager({ findOne: [AuthUserEntity, user("banned")] }))
82
- const unconfirmed = await build(mockEntityManager({ findOne: [AuthUserEntity, user("unconfirmed")] }))
83
- const addressless = await build(mockEntityManager({ findOne: [AuthUserEntity, user("addressless")] }))
99
+ it("refuses a disabled account and an unconfirmed one after the password was right, recording no sign-in", async () => {
100
+ const disabled = await build([[USER_BY_EMAIL, [accountByEmailRow("disabled")]]])
101
+ const unconfirmed = await build([[USER_BY_EMAIL, [accountByEmailRow("unconfirmed")]]])
84
102
 
85
- await expect(banned.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
103
+ await expect(disabled.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
86
104
  await expect(unconfirmed.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
87
- await expect(addressless.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
105
+ expect(disabled.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
106
+ expect(unconfirmed.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
88
107
  })
89
108
  })
90
109
 
91
110
  describe("IdentityAuthenticatorService.signInWithGoogle", () => {
92
- it("refuses a profile without a verified address", async () => {
93
- const { subject } = await build(mockEntityManager())
111
+ it("refuses a profile without a verified address, and calls no door", async () => {
112
+ const { subject, entityManager } = await build([])
94
113
 
95
114
  await expect(subject.signInWithGoogle(profile({ emailVerified: false }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
96
115
  await expect(subject.signInWithGoogle(profile({ email: null }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
116
+ expect(entityManager.query).not.toHaveBeenCalled()
97
117
  })
98
118
 
99
- it("signs in the account a Google subject is already linked to", async () => {
100
- const linked: AuthIdentityEntity = { id: "i-1", providerId: "g-1", userId: "u-1", identityData: {}, provider: "google", lastSignInAt: null, createdAt: BEFORE, updatedAt: BEFORE }
101
- const entityManager = mockEntityManager({ findOne: [[AuthIdentityEntity, linked], [AuthUserEntity, user("unconfirmed")]], update: [AuthUserEntity, {}] })
102
- fakeTransaction(entityManager)
103
- const { subject } = await build(entityManager)
119
+ it("signs in the account a Google subject is already linked to, confirming its mailbox", async () => {
120
+ const { subject, entityManager } = await build([
121
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
122
+ [LINK_GOOGLE, []],
123
+ [TOUCH_USER, []],
124
+ [USER_BY_ID, [accountByIdRow()]],
125
+ ])
104
126
 
105
127
  expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
106
128
 
107
- expect(entityManager.insert).not.toHaveBeenCalled()
108
- expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW, emailConfirmedAt: NOW })
129
+ expect(entityManager.query).not.toHaveBeenCalledWith(USER_BY_EMAIL, expect.anything())
130
+ expect(entityManager.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
131
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, true, expect.stringContaining('"full_name":"A"')])
109
132
  })
110
133
 
111
- it("links an existing account by its verified address and keeps its confirmation date", async () => {
112
- const entityManager = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, user()]], insert: [AuthIdentityEntity, {}], update: [AuthUserEntity, {}] })
113
- fakeTransaction(entityManager)
114
- const { subject } = await build(entityManager)
134
+ it("links an existing account by its verified address", async () => {
135
+ const { subject, entityManager } = await build([
136
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
137
+ [USER_BY_EMAIL, [accountByEmailRow()]],
138
+ [LINK_GOOGLE, []],
139
+ [TOUCH_USER, []],
140
+ [USER_BY_ID, [accountByIdRow()]],
141
+ ])
115
142
 
116
143
  expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
117
144
 
118
- expect(entityManager.insert).toHaveBeenCalledWith(AuthIdentityEntity, expect.objectContaining({ id: NEW_ID, providerId: "g-1", userId: "u-1", provider: "google" }))
119
- expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW, emailConfirmedAt: BEFORE })
145
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_EMAIL, ["a@example.test"])
146
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, ["u-1", "g-1", expect.stringContaining('"sub":"g-1"'), NOW])
147
+ expect(entityManager.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
120
148
  })
121
149
 
122
- it("creates the account of a new person and links their Google identity", async () => {
123
- const entityManager = mockEntityManager({
124
- findOne: [[AuthIdentityEntity, null], [AuthUserEntity, null], [AuthUserEntity, user("created")]],
125
- insert: [[AuthUserEntity, {}], [AuthIdentityEntity, {}]],
126
- update: [AuthUserEntity, {}],
127
- })
128
- fakeTransaction(entityManager)
129
- const { subject } = await build(entityManager)
150
+ })
151
+
152
+ describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusals)", () => {
153
+ it("creates the account of a new person, links their Google identity and logs the id alone", async () => {
154
+ const { subject, entityManager, logger } = await build([
155
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
156
+ [USER_BY_EMAIL, []],
157
+ [CREATE_USER, [{ be_auth_create_user: NEW_ID }]],
158
+ [LINK_GOOGLE, []],
159
+ [TOUCH_USER, []],
160
+ [USER_BY_ID, [accountByIdRow("live", NEW_ID)]],
161
+ ])
130
162
 
131
163
  expect(await subject.signInWithGoogle(profile())).toEqual({ userId: NEW_ID, email: "a@example.test" })
132
164
 
133
- expect(entityManager.insert).toHaveBeenCalledWith(AuthUserEntity, expect.objectContaining({ id: NEW_ID, email: "a@example.test", encryptedPassword: null, emailConfirmedAt: NOW }))
165
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [NEW_ID, "a@example.test", null, true, expect.stringContaining('"full_name":"A"'), NOW])
166
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, [NEW_ID, "g-1", expect.stringContaining("g-1"), NOW])
167
+ expect(logger.info).toHaveBeenCalledWith(IdentitySignInLogEvent.AccountCreated, { userId: NEW_ID })
134
168
  })
135
169
 
136
- it("refuses a disabled account and an account that cannot be read back", async () => {
137
- const disabled = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, user("deleted")]], insert: [AuthIdentityEntity, {}], update: [AuthUserEntity, {}] })
138
- fakeTransaction(disabled)
139
- const missing = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, null]], insert: [[AuthUserEntity, {}], [AuthIdentityEntity, {}]], update: [AuthUserEntity, {}] })
140
- fakeTransaction(missing)
170
+ it("fills only the profile keys the Google profile has", async () => {
171
+ const { subject, entityManager } = await build([
172
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
173
+ [LINK_GOOGLE, []],
174
+ [TOUCH_USER, []],
175
+ [USER_BY_ID, [accountByIdRow()]],
176
+ ])
177
+
178
+ await subject.signInWithGoogle(profile({ name: null, picture: "https://img.example.test/a.png" }))
141
179
 
142
- await expect((await build(disabled)).subject.signInWithGoogle(profile())).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
143
- await expect((await build(missing)).subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
180
+ const keys = { avatar_url: "https://img.example.test/a.png", picture: "https://img.example.test/a.png", email: "A@Example.test", email_verified: true }
181
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, true, JSON.stringify(keys)])
182
+ })
183
+
184
+ it("refuses when the create door answers no account", async () => {
185
+ const { subject, entityManager } = await build([
186
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
187
+ [USER_BY_EMAIL, []],
188
+ [CREATE_USER, []],
189
+ ])
190
+
191
+ await expect(subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
192
+ expect(entityManager.query).not.toHaveBeenCalledWith(LINK_GOOGLE, expect.anything())
193
+ })
194
+
195
+ it("refuses a disabled account and an account that cannot be read back", async () => {
196
+ const linked: Stubs = [
197
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
198
+ [LINK_GOOGLE, []],
199
+ [TOUCH_USER, []],
200
+ ]
201
+ const disabled = await build([...linked, [USER_BY_ID, [accountByIdRow("disabled")]]])
202
+ const gone = await build([...linked, [USER_BY_ID, []]])
203
+ const addressless = await build([...linked, [USER_BY_ID, [accountByIdRow("addressless")]]])
204
+
205
+ await expect(disabled.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
206
+ await expect(gone.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
207
+ await expect(addressless.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
144
208
  })
145
209
  })