@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,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
  })
@@ -8,15 +8,26 @@ import type { Clock } from "@modules/platform/clock"
8
8
  import { InjectPrimaryEntityManager } from "@modules/platform/database"
9
9
  import { InjectIds } from "@modules/platform/ids"
10
10
  import type { Ids } from "@modules/platform/ids"
11
- import { isDisabled } from "./account-directory.service"
11
+ import { InjectLogger } from "@modules/platform/logging"
12
+ import type { Logger } from "@modules/platform/logging"
12
13
  import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
13
14
  import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
14
- import { AuthIdentityEntity, AuthUserEntity } from "./persistence/entities"
15
+ import { IdentitySignInLogEvent } from "./identity-sign-in.log-events"
16
+ import { accountDoors } from "./persistence/account-doors.port"
15
17
 
16
- const GOOGLE = "google"
18
+ /** A Google profile whose email Google verified, so it has one. */
19
+ type VerifiedProfile = GoogleProfile & { readonly email: string }
20
+
21
+ /** The profile keys a Google sign-in may fill on the account; they never overwrite what the person already set. */
22
+ const profileKeysOf = (profile: VerifiedProfile): Record<string, string | boolean> => ({
23
+ ...(profile.name === null ? {} : { full_name: profile.name, name: profile.name }),
24
+ ...(profile.picture === null ? {} : { avatar_url: profile.picture, picture: profile.picture }),
25
+ email: profile.email,
26
+ email_verified: profile.emailVerified,
27
+ })
17
28
 
18
29
  @Injectable()
19
- /** The two sign-in decisions: is this email and password right, and which account does this Google profile stand for. */
30
+ /** The two sign-in decisions over the account doors: is this email and password right, and which account does this Google profile stand for. */
20
31
  export class IdentityAuthenticatorService implements AuthAuthenticator {
21
32
  constructor(
22
33
  @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
@@ -24,72 +35,50 @@ export class IdentityAuthenticatorService implements AuthAuthenticator {
24
35
  @InjectSignInBudget() private readonly budget: SignInBudget,
25
36
  @InjectClock() private readonly clock: Clock,
26
37
  @InjectIds() private readonly ids: Ids,
38
+ @InjectLogger() private readonly logger: Logger,
27
39
  ) {}
28
40
 
29
41
  /** Verifies the pair. An unknown address and a wrong password answer the same refusal; only wrong passwords count against the budget. */
30
42
  async verifyPassword(email: string, password: string): Promise<AuthSubject> {
31
43
  const address = normalizeEmail(email)
32
- const user = await this.budget.counted("login", address, async () => {
33
- const found = await this.entityManager.findOne(AuthUserEntity, { where: { email: address, isAnonymous: false } })
34
- const matches = await this.passwords.verify(isPresentablePassword(password) ? password : "", found?.encryptedPassword ?? "")
35
- return { passed: found !== null && matches, value: found !== null && matches ? found : null }
44
+ const account = await this.budget.counted("login", address, async () => {
45
+ const found = await accountDoors.userByEmail(this.entityManager, address)
46
+ const matches = await this.passwords.verify(isPresentablePassword(password) ? password : "", found?.encrypted_password ?? "")
47
+ return { passed: found !== undefined && matches, value: found !== undefined && matches ? found : undefined }
36
48
  })
37
- if (user === null) throw new IdentityError({ code: IdentityErrorCode.PairRefused })
38
- const now = this.clock.now()
39
- if (isDisabled(user, now)) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
40
- if (user.emailConfirmedAt === null || user.email === null) throw new IdentityError({ code: IdentityErrorCode.EmailNotConfirmed })
41
- await this.entityManager.update(AuthUserEntity, { id: user.id }, { lastSignInAt: now })
42
- return { userId: user.id, email: user.email }
49
+ if (account === undefined) throw new IdentityError({ code: IdentityErrorCode.PairRefused })
50
+ if (account.disabled) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
51
+ if (account.email_confirmed_at === null) throw new IdentityError({ code: IdentityErrorCode.EmailNotConfirmed })
52
+ await accountDoors.touchUser(this.entityManager, account.id, this.clock.now(), false, "{}")
53
+ return { userId: account.id, email: account.email }
43
54
  }
44
55
 
45
56
  /** Finds the account a Google subject is linked to, else links by a verified email, else creates the account. */
46
57
  async signInWithGoogle(profile: GoogleProfile): Promise<AuthSubject> {
47
58
  if (profile.email === null || !profile.emailVerified) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
48
59
  const address = normalizeEmail(profile.email)
60
+ const verified: VerifiedProfile = { ...profile, email: profile.email }
49
61
  const now = this.clock.now()
50
- const user = await this.entityManager.transaction(async (scoped) => {
51
- const linked = await scoped.findOne(AuthIdentityEntity, { where: { provider: GOOGLE, providerId: profile.subject } })
52
- const known =
53
- (linked === null ? null : await scoped.findOne(AuthUserEntity, { where: { id: linked.userId } })) ??
54
- (await scoped.findOne(AuthUserEntity, { where: { email: address, isAnonymous: false } }))
55
- const person = known ?? (await this.create(scoped, address, now))
56
- if (linked === null) {
57
- await scoped.insert(AuthIdentityEntity, {
58
- id: this.ids.next(),
59
- providerId: profile.subject,
60
- userId: person.id,
61
- provider: GOOGLE,
62
- identityData: { sub: profile.subject, email: address, name: profile.name, picture: profile.picture, email_verified: true },
63
- lastSignInAt: now,
64
- createdAt: now,
65
- updatedAt: now,
66
- })
67
- }
68
- await scoped.update(AuthUserEntity, { id: person.id }, { lastSignInAt: now, emailConfirmedAt: person.emailConfirmedAt ?? now })
69
- return person
62
+ const userId = await this.entityManager.transaction(async (scoped) => {
63
+ const linked = await accountDoors.userByGoogle(scoped, profile.subject)
64
+ const byEmail = linked === null ? await accountDoors.userByEmail(scoped, address) : undefined
65
+ const id = linked ?? byEmail?.id ?? (await this.createFor(scoped, address, verified, now))
66
+ const identityData = { sub: profile.subject, email: address, name: profile.name, picture: profile.picture, email_verified: true }
67
+ await accountDoors.linkGoogle(scoped, id, profile.subject, JSON.stringify(identityData), now)
68
+ await accountDoors.touchUser(scoped, id, now, true, JSON.stringify(profileKeysOf(verified)))
69
+ return id
70
70
  })
71
- if (isDisabled(user, now)) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
72
- return { userId: user.id, email: address }
71
+ const account = await accountDoors.userById(this.entityManager, userId)
72
+ if (account?.email === undefined || account.email === null) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
73
+ if (account.disabled) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
74
+ return { userId: account.id, email: account.email }
73
75
  }
74
76
 
75
- private async create(scoped: EntityManager, address: string, now: Date): Promise<AuthUserEntity> {
77
+ private async createFor(scoped: EntityManager, address: string, profile: VerifiedProfile, now: Date): Promise<string> {
76
78
  const id = this.ids.next()
77
- await scoped.insert(AuthUserEntity, {
78
- id,
79
- aud: "authenticated",
80
- role: "authenticated",
81
- email: address,
82
- encryptedPassword: null,
83
- emailConfirmedAt: now,
84
- lastSignInAt: now,
85
- bannedUntil: null,
86
- deletedAt: null,
87
- isAnonymous: false,
88
- createdAt: now,
89
- updatedAt: now,
90
- })
91
- const created = await scoped.findOne(AuthUserEntity, { where: { id } })
92
- if (created === null) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
79
+ const created = await accountDoors.createUser(scoped, { id, email: address, hash: null, confirmed: true, meta: JSON.stringify(profileKeysOf(profile)), now })
80
+ if (created === undefined) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
81
+ this.logger.info(IdentitySignInLogEvent.AccountCreated, { userId: created })
93
82
  return created
94
83
  }
95
84
  }
@@ -0,0 +1,6 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ /** Log events owned by the email doors of sign-in. */
3
+ export enum IdentityEmailLogEvent {
4
+ /** A confirmation or reset mail could not be sent; the door still answers as if it was, so an address is never confirmed or denied. */
5
+ MailFailed = "identity.email.mail_failed",
6
+ }