@starci/hfs 5.3.0 → 5.5.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 (137) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/account-doors.mjs +7 -1
  6. package/codemod/app-prettier.d.mts +5 -0
  7. package/codemod/app-prettier.mjs +27 -0
  8. package/codemod/explicit-surface.mjs +1 -25
  9. package/codemod/main.mjs +14 -5
  10. package/codemod/module-definition.d.mts +10 -3
  11. package/codemod/module-definition.mjs +32 -17
  12. package/codemod/seeds-layout.d.mts +36 -0
  13. package/codemod/seeds-layout.mjs +113 -0
  14. package/codemod/sops-rules.d.mts +1 -1
  15. package/codemod/sops-rules.mjs +8 -3
  16. package/lint/run.d.mts +5 -4
  17. package/lint/run.mjs +53 -27
  18. package/package.json +1 -1
  19. package/release/lint-report.mjs +4 -3
  20. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  21. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  22. package/runtime/knowledge/hfs/rules.yaml +15 -2
  23. package/runtime/knowledge/hfs/slots.yaml +15 -18
  24. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  25. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  26. package/runtime/scripts/hfs/check.mjs +4 -1
  27. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  28. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  29. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  30. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  31. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  32. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  33. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  34. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  35. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  37. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  38. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  39. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  43. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  44. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  45. package/runtime/scripts/hfs/slots.d.mts +2 -0
  46. package/runtime/scripts/hfs/slots.mjs +1 -0
  47. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  48. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  49. package/runtime/scripts/hfs/view.d.mts +25 -0
  50. package/runtime/scripts/hfs/view.mjs +41 -0
  51. package/scaffold/add-app.mjs +2 -2
  52. package/scaffold/add-cli-lite.mjs +41 -14
  53. package/scaffold/add-module.mjs +6 -3
  54. package/scaffold/lite-exports.mjs +1 -1
  55. package/scaffold/module-registry.mjs +10 -8
  56. package/src/main.mjs +8 -8
  57. package/stack/deploy.d.mts +3 -0
  58. package/stack/deploy.mjs +4 -1
  59. package/stack/local-up.mjs +25 -3
  60. package/stack/seeds.d.mts +29 -0
  61. package/stack/seeds.mjs +197 -0
  62. package/sync/canon-layout.d.mts +38 -0
  63. package/sync/canon-layout.mjs +62 -0
  64. package/sync/ci-commands.d.mts +2 -1
  65. package/sync/ci-commands.mjs +10 -7
  66. package/sync/compile-guard.d.mts +36 -0
  67. package/sync/compile-guard.mjs +189 -0
  68. package/sync/index.d.mts +22 -3
  69. package/sync/index.mjs +80 -32
  70. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  71. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  72. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  73. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  74. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  76. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  77. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  78. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  79. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  80. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  81. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  82. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  83. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  84. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  85. package/templates/app/skeleton/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  87. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  92. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  93. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  94. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  95. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  96. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  97. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  98. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  99. package/templates/app/skeleton-lite/README.md +2 -2
  100. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
  101. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  108. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  110. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  113. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  116. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  117. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  118. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
  119. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
  120. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  121. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  122. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  123. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  124. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  125. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  126. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  127. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  128. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  129. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  130. package/templates/be/tool-config/jest.config.js +1 -1
  131. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  132. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  133. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  134. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  135. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  136. package/templates/app/module/jest.config.js.tpl +0 -8
  137. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -32,6 +32,21 @@ describe("IdentityAccountDirectoryService.find", () => {
32
32
  expect(await addressless.directory.find("u-1")).toEqual({ id: "u-1", email: null, disabled: false })
33
33
  })
34
34
 
35
+ it("answers the full name the profile keys hold, and none for an empty or a missing one", async () => {
36
+ const named = await build([accountByIdRow("named")])
37
+ const blank = await build([{ ...accountByIdRow(), meta: { full_name: "" } }])
38
+ const raw = await build([{ ...accountByIdRow(), meta: null }])
39
+
40
+ expect(await named.directory.find("u-1")).toEqual({
41
+ id: "u-1",
42
+ email: "a@example.test",
43
+ disabled: false,
44
+ name: "Ada Lovelace",
45
+ })
46
+ expect((await blank.directory.find("u-1"))?.name).toBeUndefined()
47
+ expect((await raw.directory.find("u-1"))?.name).toBeUndefined()
48
+ })
49
+
35
50
  it("answers undefined for an account that does not exist", async () => {
36
51
  const { directory } = await build([])
37
52
 
@@ -3,10 +3,10 @@ import { Injectable } from "@nestjs/common"
3
3
  import type { EntityManager } from "typeorm"
4
4
  import type { AccountDirectory, AccountStanding } from "@modules/integrations/passport"
5
5
  import { InjectPrimaryEntityManager } from "@modules/platform/database"
6
- import { accountDoors } from "./persistence/account-doors.port"
6
+ import { accountDoors, fullNameOf } from "./persistence/account-doors.port"
7
7
 
8
8
  @Injectable()
9
- /** The package's account directory over the account doors: 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, and the person's full name. */
10
10
  export class IdentityAccountDirectoryService implements AccountDirectory {
11
11
  constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
12
12
 
@@ -14,6 +14,12 @@ export class IdentityAccountDirectoryService implements AccountDirectory {
14
14
  async find(userId: string): Promise<AccountStanding | undefined> {
15
15
  const account = await accountDoors.userById(this.entityManager, userId)
16
16
  if (account === undefined) return undefined
17
- return { id: account.id, email: account.email, disabled: account.disabled }
17
+ const name = fullNameOf(account.meta)
18
+ return {
19
+ id: account.id,
20
+ email: account.email,
21
+ disabled: account.disabled,
22
+ ...(name === undefined ? {} : { name }),
23
+ }
18
24
  }
19
25
  }
@@ -40,7 +40,8 @@ const profile = (patch: Partial<GoogleProfile> = {}): GoogleProfile => ({
40
40
  type Stubs = ReadonlyArray<readonly [string, unknown]>
41
41
 
42
42
  const build = async (answers: Stubs, matches = true) => {
43
- const entityManager: MockEntityManager = answers.length === 0 ? mockEntityManager() : mockEntityManager({ query: answers })
43
+ const entityManager: MockEntityManager =
44
+ answers.length === 0 ? mockEntityManager() : mockEntityManager({ query: answers })
44
45
  fakeTransaction(entityManager)
45
46
  const passwords = mock<AuthPasswords>()
46
47
  passwords.verify.mockResolvedValue(matches)
@@ -70,7 +71,10 @@ describe("IdentityAuthenticatorService.verifyPassword", () => {
70
71
  [TOUCH_USER, []],
71
72
  ])
72
73
 
73
- expect(await subject.verifyPassword(" A@Example.test ", "correct horse")).toEqual({ userId: "u-1", email: "a@example.test" })
74
+ expect(await subject.verifyPassword(" A@Example.test ", "correct horse")).toEqual({
75
+ userId: "u-1",
76
+ email: "a@example.test",
77
+ })
74
78
 
75
79
  expect(budget.counted).toHaveBeenCalledWith("login", "a@example.test", expect.any(Function))
76
80
  expect(passwords.verify).toHaveBeenCalledWith("correct horse", "hash")
@@ -82,7 +86,9 @@ describe("IdentityAuthenticatorService.verifyPassword", () => {
82
86
  const unknown = await build([[USER_BY_EMAIL, []]])
83
87
  const wrong = await build([[USER_BY_EMAIL, [accountByEmailRow()]]], false)
84
88
 
85
- await expect(unknown.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.PairRefused))
89
+ await expect(unknown.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(
90
+ refusal(IdentityErrorCode.PairRefused),
91
+ )
86
92
  await expect(wrong.subject.verifyPassword("a@example.test", "pw")).rejects.toBeInstanceOf(IdentityError)
87
93
  expect(unknown.passwords.verify).toHaveBeenCalledWith("pw", "")
88
94
  expect(wrong.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
@@ -100,8 +106,12 @@ describe("IdentityAuthenticatorService.verifyPassword", () => {
100
106
  const disabled = await build([[USER_BY_EMAIL, [accountByEmailRow("disabled")]]])
101
107
  const unconfirmed = await build([[USER_BY_EMAIL, [accountByEmailRow("unconfirmed")]]])
102
108
 
103
- await expect(disabled.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
104
- await expect(unconfirmed.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
109
+ await expect(disabled.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(
110
+ refusal(IdentityErrorCode.AccountDisabled),
111
+ )
112
+ await expect(unconfirmed.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(
113
+ refusal(IdentityErrorCode.EmailNotConfirmed),
114
+ )
105
115
  expect(disabled.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
106
116
  expect(unconfirmed.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
107
117
  })
@@ -111,8 +121,12 @@ describe("IdentityAuthenticatorService.signInWithGoogle", () => {
111
121
  it("refuses a profile without a verified address, and calls no door", async () => {
112
122
  const { subject, entityManager } = await build([])
113
123
 
114
- await expect(subject.signInWithGoogle(profile({ emailVerified: false }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
115
- await expect(subject.signInWithGoogle(profile({ email: null }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
124
+ await expect(subject.signInWithGoogle(profile({ emailVerified: false }))).rejects.toEqual(
125
+ refusal(PassportErrorCode.GoogleRejected),
126
+ )
127
+ await expect(subject.signInWithGoogle(profile({ email: null }))).rejects.toEqual(
128
+ refusal(PassportErrorCode.GoogleRejected),
129
+ )
116
130
  expect(entityManager.query).not.toHaveBeenCalled()
117
131
  })
118
132
 
@@ -128,7 +142,27 @@ describe("IdentityAuthenticatorService.signInWithGoogle", () => {
128
142
 
129
143
  expect(entityManager.query).not.toHaveBeenCalledWith(USER_BY_EMAIL, expect.anything())
130
144
  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"')])
145
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, [
146
+ "u-1",
147
+ NOW,
148
+ true,
149
+ expect.stringContaining('"full_name":"A"'),
150
+ ])
151
+ })
152
+
153
+ it("answers the full name the account's profile keys hold with the person", async () => {
154
+ const { subject } = await build([
155
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
156
+ [LINK_GOOGLE, []],
157
+ [TOUCH_USER, []],
158
+ [USER_BY_ID, [accountByIdRow("named")]],
159
+ ])
160
+
161
+ expect(await subject.signInWithGoogle(profile())).toEqual({
162
+ userId: "u-1",
163
+ email: "a@example.test",
164
+ name: "Ada Lovelace",
165
+ })
132
166
  })
133
167
 
134
168
  it("links an existing account by its verified address", async () => {
@@ -143,10 +177,14 @@ describe("IdentityAuthenticatorService.signInWithGoogle", () => {
143
177
  expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
144
178
 
145
179
  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])
180
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, [
181
+ "u-1",
182
+ "g-1",
183
+ expect.stringContaining('"sub":"g-1"'),
184
+ NOW,
185
+ ])
147
186
  expect(entityManager.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
148
187
  })
149
-
150
188
  })
151
189
 
152
190
  describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusals)", () => {
@@ -162,8 +200,20 @@ describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusa
162
200
 
163
201
  expect(await subject.signInWithGoogle(profile())).toEqual({ userId: NEW_ID, email: "a@example.test" })
164
202
 
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])
203
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [
204
+ NEW_ID,
205
+ "a@example.test",
206
+ null,
207
+ true,
208
+ expect.stringContaining('"full_name":"A"'),
209
+ NOW,
210
+ ])
211
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, [
212
+ NEW_ID,
213
+ "g-1",
214
+ expect.stringContaining("g-1"),
215
+ NOW,
216
+ ])
167
217
  expect(logger.info).toHaveBeenCalledWith(IdentitySignInLogEvent.AccountCreated, { userId: NEW_ID })
168
218
  })
169
219
 
@@ -177,10 +227,17 @@ describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusa
177
227
 
178
228
  await subject.signInWithGoogle(profile({ name: null, picture: "https://img.example.test/a.png" }))
179
229
 
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 }
230
+ const keys = {
231
+ avatar_url: "https://img.example.test/a.png",
232
+ picture: "https://img.example.test/a.png",
233
+ email: "A@Example.test",
234
+ email_verified: true,
235
+ }
181
236
  expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, true, JSON.stringify(keys)])
182
237
  })
238
+ })
183
239
 
240
+ describe("IdentityAuthenticatorService.signInWithGoogle (refusals of a new account)", () => {
184
241
  it("refuses when the create door answers no account", async () => {
185
242
  const { subject, entityManager } = await build([
186
243
  [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
@@ -202,8 +259,14 @@ describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusa
202
259
  const gone = await build([...linked, [USER_BY_ID, []]])
203
260
  const addressless = await build([...linked, [USER_BY_ID, [accountByIdRow("addressless")]]])
204
261
 
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))
262
+ await expect(disabled.subject.signInWithGoogle(profile())).rejects.toEqual(
263
+ refusal(IdentityErrorCode.AccountDisabled),
264
+ )
265
+ await expect(gone.subject.signInWithGoogle(profile())).rejects.toEqual(
266
+ refusal(PassportErrorCode.GoogleRejected),
267
+ )
268
+ await expect(addressless.subject.signInWithGoogle(profile())).rejects.toEqual(
269
+ refusal(PassportErrorCode.GoogleRejected),
270
+ )
208
271
  })
209
272
  })
@@ -2,7 +2,13 @@
2
2
  import { Injectable } from "@nestjs/common"
3
3
  import type { EntityManager } from "typeorm"
4
4
  import { PassportError, PassportErrorCode, isPresentablePassword, normalizeEmail } from "@modules/integrations/passport"
5
- import type { AuthAuthenticator, AuthPasswords, AuthSubject, GoogleProfile, SignInBudget } from "@modules/integrations/passport"
5
+ import type {
6
+ AuthAuthenticator,
7
+ AuthPasswords,
8
+ AuthSubject,
9
+ GoogleProfile,
10
+ SignInBudget,
11
+ } from "@modules/integrations/passport"
6
12
  import { InjectClock } from "@modules/platform/clock"
7
13
  import type { Clock } from "@modules/platform/clock"
8
14
  import { InjectPrimaryEntityManager } from "@modules/platform/database"
@@ -13,7 +19,7 @@ import type { Logger } from "@modules/platform/logging"
13
19
  import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
14
20
  import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
15
21
  import { IdentitySignInLogEvent } from "./identity-sign-in.log-events"
16
- import { accountDoors } from "./persistence/account-doors.port"
22
+ import { accountDoors, fullNameOf } from "./persistence/account-doors.port"
17
23
 
18
24
  /** A Google profile whose email Google verified, so it has one. */
19
25
  type VerifiedProfile = GoogleProfile & { readonly email: string }
@@ -43,7 +49,10 @@ export class IdentityAuthenticatorService implements AuthAuthenticator {
43
49
  const address = normalizeEmail(email)
44
50
  const account = await this.budget.counted("login", address, async () => {
45
51
  const found = await accountDoors.userByEmail(this.entityManager, address)
46
- const matches = await this.passwords.verify(isPresentablePassword(password) ? password : "", found?.encrypted_password ?? "")
52
+ const matches = await this.passwords.verify(
53
+ isPresentablePassword(password) ? password : "",
54
+ found?.encrypted_password ?? "",
55
+ )
47
56
  return { passed: found !== undefined && matches, value: found !== undefined && matches ? found : undefined }
48
57
  })
49
58
  if (account === undefined) throw new IdentityError({ code: IdentityErrorCode.PairRefused })
@@ -55,7 +64,8 @@ export class IdentityAuthenticatorService implements AuthAuthenticator {
55
64
 
56
65
  /** Finds the account a Google subject is linked to, else links by a verified email, else creates the account. */
57
66
  async signInWithGoogle(profile: GoogleProfile): Promise<AuthSubject> {
58
- if (profile.email === null || !profile.emailVerified) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
67
+ if (profile.email === null || !profile.emailVerified)
68
+ throw new PassportError({ code: PassportErrorCode.GoogleRejected })
59
69
  const address = normalizeEmail(profile.email)
60
70
  const verified: VerifiedProfile = { ...profile, email: profile.email }
61
71
  const now = this.clock.now()
@@ -63,20 +73,40 @@ export class IdentityAuthenticatorService implements AuthAuthenticator {
63
73
  const linked = await accountDoors.userByGoogle(scoped, profile.subject)
64
74
  const byEmail = linked === null ? await accountDoors.userByEmail(scoped, address) : undefined
65
75
  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 }
76
+ const identityData = {
77
+ sub: profile.subject,
78
+ email: address,
79
+ name: profile.name,
80
+ picture: profile.picture,
81
+ email_verified: true,
82
+ }
67
83
  await accountDoors.linkGoogle(scoped, id, profile.subject, JSON.stringify(identityData), now)
68
84
  await accountDoors.touchUser(scoped, id, now, true, JSON.stringify(profileKeysOf(verified)))
69
85
  return id
70
86
  })
71
87
  const account = await accountDoors.userById(this.entityManager, userId)
72
- if (account?.email === undefined || account.email === null) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
88
+ if (account?.email === undefined || account.email === null)
89
+ throw new PassportError({ code: PassportErrorCode.GoogleRejected })
73
90
  if (account.disabled) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
74
- return { userId: account.id, email: account.email }
91
+ const name = fullNameOf(account.meta)
92
+ return { userId: account.id, email: account.email, ...(name === undefined ? {} : { name }) }
75
93
  }
76
94
 
77
- private async createFor(scoped: EntityManager, address: string, profile: VerifiedProfile, now: Date): Promise<string> {
95
+ private async createFor(
96
+ scoped: EntityManager,
97
+ address: string,
98
+ profile: VerifiedProfile,
99
+ now: Date,
100
+ ): Promise<string> {
78
101
  const id = this.ids.next()
79
- const created = await accountDoors.createUser(scoped, { id, email: address, hash: null, confirmed: true, meta: JSON.stringify(profileKeysOf(profile)), now })
102
+ const created = await accountDoors.createUser(scoped, {
103
+ id,
104
+ email: address,
105
+ hash: null,
106
+ confirmed: true,
107
+ meta: JSON.stringify(profileKeysOf(profile)),
108
+ now,
109
+ })
80
110
  if (created === undefined) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
81
111
  this.logger.info(IdentitySignInLogEvent.AccountCreated, { userId: created })
82
112
  return created
@@ -38,7 +38,15 @@ const SETTINGS = {
38
38
  } as unknown as MailOptions
39
39
 
40
40
  const refusal = (code: string): unknown => expect.objectContaining({ code })
41
- const tokenRow = (purpose: string): IdentityEmailTokenEntity => ({ id: "t-1", userId: "u-1", purpose, tokenHash: "h", expiresAt: new Date("2026-02-04T00:00:00.000Z"), usedAt: null, createdAt: NOW })
41
+ const tokenRow = (purpose: string): IdentityEmailTokenEntity => ({
42
+ id: "t-1",
43
+ userId: "u-1",
44
+ purpose,
45
+ tokenHash: "h",
46
+ expiresAt: new Date("2026-02-04T00:00:00.000Z"),
47
+ usedAt: null,
48
+ createdAt: NOW,
49
+ })
42
50
 
43
51
  const build = async (entityManager: MockEntityManager) => {
44
52
  const passwords = mock<AuthPasswords>()
@@ -68,42 +76,85 @@ const build = async (entityManager: MockEntityManager) => {
68
76
 
69
77
  describe("IdentityEmailService.signup", () => {
70
78
  it("creates an unconfirmed account through the door, stores only the digest of the link token and mails the link", async () => {
71
- const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
79
+ const entityManager = mockEntityManager({
80
+ query: [
81
+ [USER_BY_EMAIL, []],
82
+ [CREATE_USER, [{ be_auth_create_user: NEW_ID }]],
83
+ ],
84
+ insert: [IdentityEmailTokenEntity, {}],
85
+ update: [IdentityEmailTokenEntity, {}],
86
+ })
72
87
  fakeTransaction(entityManager)
73
88
  const { subject, budget, mailer } = await build(entityManager)
74
89
 
75
90
  await subject.signup(" New@Example.test ", "correct horse")
76
91
 
77
92
  expect(budget.admit).toHaveBeenCalledWith("signup", "new@example.test")
78
- expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [NEW_ID, "new@example.test", "new-hash", false, "{}", NOW])
93
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [
94
+ NEW_ID,
95
+ "new@example.test",
96
+ "new-hash",
97
+ false,
98
+ "{}",
99
+ NOW,
100
+ ])
79
101
  const sent = mailer.send.mock.calls[0]?.[0]
80
102
  const url = /https:\/\/[^\s"<]+/u.exec(sent?.message.text ?? "")?.[0] ?? ""
81
103
  const token = new URL(url).searchParams.get("token") ?? ""
82
104
  expect(new URL(url).pathname).toBe("/auth/confirm")
83
105
  expect(token).toHaveLength(64)
84
- expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID, purpose: "confirm", tokenHash: digestOf(token), usedAt: null }))
106
+ expect(entityManager.insert).toHaveBeenCalledWith(
107
+ IdentityEmailTokenEntity,
108
+ expect.objectContaining({ userId: NEW_ID, purpose: "confirm", tokenHash: digestOf(token), usedAt: null }),
109
+ )
85
110
  expect(sent?.to).toBe("new@example.test")
86
111
  })
87
112
 
88
- it("keeps the generated id when the create door answers no row", async () => {
89
- const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, []]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
113
+ it("keeps the generated id when the create door answers no row, and the full name in the profile keys", async () => {
114
+ const entityManager = mockEntityManager({
115
+ query: [
116
+ [USER_BY_EMAIL, []],
117
+ [CREATE_USER, []],
118
+ ],
119
+ insert: [IdentityEmailTokenEntity, {}],
120
+ update: [IdentityEmailTokenEntity, {}],
121
+ })
90
122
  fakeTransaction(entityManager)
91
123
  const { subject } = await build(entityManager)
92
124
 
93
- await subject.signup("new@example.test", "correct horse")
94
-
95
- expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID }))
125
+ await subject.signup("new@example.test", "correct horse", "en", "Ada Lovelace")
126
+
127
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [
128
+ NEW_ID,
129
+ "new@example.test",
130
+ "new-hash",
131
+ false,
132
+ JSON.stringify({ full_name: "Ada Lovelace", name: "Ada Lovelace" }),
133
+ NOW,
134
+ ])
135
+ expect(entityManager.insert).toHaveBeenCalledWith(
136
+ IdentityEmailTokenEntity,
137
+ expect.objectContaining({ userId: NEW_ID }),
138
+ )
96
139
  })
97
140
 
98
141
  it("refuses a password the policy rejects before it touches an account", async () => {
99
142
  const { subject, mailer } = await build(mockEntityManager())
100
143
 
101
- await expect(subject.signup("a@example.test", "short")).rejects.toEqual(refusal(IdentityErrorCode.PasswordInvalid))
144
+ await expect(subject.signup("a@example.test", "short")).rejects.toEqual(
145
+ refusal(IdentityErrorCode.PasswordInvalid),
146
+ )
102
147
  expect(mailer.send).not.toHaveBeenCalled()
103
148
  })
149
+ })
104
150
 
151
+ describe("IdentityEmailService.signup (an address already known, a failing mail server, a spent budget)", () => {
105
152
  it("mails a new link to an unconfirmed account and says nothing about a confirmed one", async () => {
106
- const unconfirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
153
+ const unconfirmed = mockEntityManager({
154
+ query: [USER_BY_EMAIL, [account("unconfirmed")]],
155
+ insert: [IdentityEmailTokenEntity, {}],
156
+ update: [IdentityEmailTokenEntity, {}],
157
+ })
107
158
  fakeTransaction(unconfirmed)
108
159
  const confirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account()]] })
109
160
  const first = await build(unconfirmed)
@@ -118,7 +169,14 @@ describe("IdentityEmailService.signup", () => {
118
169
  })
119
170
 
120
171
  it("answers as if the mail was sent when the mail server fails, and logs the failure", async () => {
121
- const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
172
+ const entityManager = mockEntityManager({
173
+ query: [
174
+ [USER_BY_EMAIL, []],
175
+ [CREATE_USER, [{ be_auth_create_user: NEW_ID }]],
176
+ ],
177
+ insert: [IdentityEmailTokenEntity, {}],
178
+ update: [IdentityEmailTokenEntity, {}],
179
+ })
122
180
  fakeTransaction(entityManager)
123
181
  const { subject, mailer, logger } = await build(entityManager)
124
182
  mailer.send.mockRejectedValue(new Error("down"))
@@ -131,7 +189,9 @@ describe("IdentityEmailService.signup", () => {
131
189
  const { subject, budget } = await build(mockEntityManager())
132
190
  budget.admit.mockRejectedValue(new AuthError(IdentityErrorCode.TooManyAttempts))
133
191
 
134
- await expect(subject.signup("a@example.test", "correct horse")).rejects.toEqual(refusal(IdentityErrorCode.TooManyAttempts))
192
+ await expect(subject.signup("a@example.test", "correct horse")).rejects.toEqual(
193
+ refusal(IdentityErrorCode.TooManyAttempts),
194
+ )
135
195
  budget.admit.mockRejectedValue(new Error("store down"))
136
196
  await expect(subject.resend("a@example.test")).rejects.toThrow("store down")
137
197
  })
@@ -139,7 +199,11 @@ describe("IdentityEmailService.signup", () => {
139
199
 
140
200
  describe("IdentityEmailService.resend", () => {
141
201
  it("mails only an unconfirmed password account", async () => {
142
- const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
202
+ const entityManager = mockEntityManager({
203
+ query: [USER_BY_EMAIL, [account("unconfirmed")]],
204
+ insert: [IdentityEmailTokenEntity, {}],
205
+ update: [IdentityEmailTokenEntity, {}],
206
+ })
143
207
  fakeTransaction(entityManager)
144
208
  const { subject, mailer } = await build(entityManager)
145
209
  await subject.resend("a@example.test")
@@ -155,13 +219,20 @@ describe("IdentityEmailService.resend", () => {
155
219
 
156
220
  describe("IdentityEmailService.confirm", () => {
157
221
  it("spends a live link and stamps the confirmation through the door", async () => {
158
- const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("confirm")], update: [IdentityEmailTokenEntity, {}], query: [CONFIRM_USER, []] })
222
+ const entityManager = mockEntityManager({
223
+ findOne: [IdentityEmailTokenEntity, tokenRow("confirm")],
224
+ update: [IdentityEmailTokenEntity, {}],
225
+ query: [CONFIRM_USER, []],
226
+ })
159
227
  fakeTransaction(entityManager)
160
228
  const { subject } = await build(entityManager)
161
229
 
162
230
  await subject.confirm("the-token-of-the-link")
163
231
 
164
- expect(entityManager.findOne).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ lock: { mode: "pessimistic_write" } }))
232
+ expect(entityManager.findOne).toHaveBeenCalledWith(
233
+ IdentityEmailTokenEntity,
234
+ expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
235
+ )
165
236
  expect(entityManager.query).toHaveBeenCalledWith(CONFIRM_USER, ["u-1", NOW])
166
237
  })
167
238
 
@@ -176,12 +247,18 @@ describe("IdentityEmailService.confirm", () => {
176
247
 
177
248
  describe("IdentityEmailService.forgot", () => {
178
249
  it("mails a reset link to a live account and to nobody else, with the same answer", async () => {
179
- const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account()]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
250
+ const entityManager = mockEntityManager({
251
+ query: [USER_BY_EMAIL, [account()]],
252
+ insert: [IdentityEmailTokenEntity, {}],
253
+ update: [IdentityEmailTokenEntity, {}],
254
+ })
180
255
  fakeTransaction(entityManager)
181
256
  const known = await build(entityManager)
182
257
  await known.subject.forgot("a@example.test", "vi")
183
258
  expect(known.budget.admit).toHaveBeenCalledWith("reset", "a@example.test")
184
- expect(known.mailer.send.mock.calls[0]?.[0].message.text).toContain("https://app.example.test/auth/reset?token=")
259
+ expect(known.mailer.send.mock.calls[0]?.[0].message.text).toContain(
260
+ "https://app.example.test/auth/reset?token=",
261
+ )
185
262
  await known.subject.forgot("a@example.test")
186
263
  expect(known.mailer.send).toHaveBeenCalledTimes(2)
187
264
 
@@ -195,7 +272,11 @@ describe("IdentityEmailService.forgot", () => {
195
272
 
196
273
  describe("IdentityEmailService.reset", () => {
197
274
  it("sets the new password and confirms the mailbox its link proved through the door, then ends every session of the person", async () => {
198
- const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("reset")], update: [IdentityEmailTokenEntity, {}], query: [SET_PASSWORD, []] })
275
+ const entityManager = mockEntityManager({
276
+ findOne: [IdentityEmailTokenEntity, tokenRow("reset")],
277
+ update: [IdentityEmailTokenEntity, {}],
278
+ query: [SET_PASSWORD, []],
279
+ })
199
280
  fakeTransaction(entityManager)
200
281
  const { subject, sessions } = await build(entityManager)
201
282
 
@@ -219,7 +300,14 @@ describe("IdentityEmailService.reset", () => {
219
300
  describe("IdentityEmailService language", () => {
220
301
  it("writes the mail in the language the request names, and in the default one when it names none the mails are written in", async () => {
221
302
  const subjectFor = async (language?: string): Promise<string | undefined> => {
222
- const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
303
+ const entityManager = mockEntityManager({
304
+ query: [
305
+ [USER_BY_EMAIL, []],
306
+ [CREATE_USER, [{ be_auth_create_user: NEW_ID }]],
307
+ ],
308
+ insert: [IdentityEmailTokenEntity, {}],
309
+ update: [IdentityEmailTokenEntity, {}],
310
+ })
223
311
  fakeTransaction(entityManager)
224
312
  const { subject, mailer } = await build(entityManager)
225
313
  await subject.signup("a@example.test", "correct horse", language)
@@ -51,8 +51,11 @@ export class IdentityEmailService {
51
51
  private readonly sessions: IdentitySessionStoreService,
52
52
  ) {}
53
53
 
54
- /** Creates an unconfirmed password account and mails the confirmation link; an address that already has an account is answered the same way. */
55
- async signup(email: string, password: string, language?: string): Promise<void> {
54
+ /**
55
+ * Creates an unconfirmed password account, its full name (when given) in the profile keys a Google sign-in fills too, and mails the
56
+ * confirmation link; an address that already has an account is answered the same way and its profile is left as it is.
57
+ */
58
+ async signup(email: string, password: string, language?: string, name?: string): Promise<void> {
56
59
  const address = normalizeEmail(email)
57
60
  await this.admit("signup", address)
58
61
  if (!isAcceptablePassword(password)) throw new IdentityError({ code: IdentityErrorCode.PasswordInvalid })
@@ -64,7 +67,14 @@ export class IdentityEmailService {
64
67
  const now = this.clock.now()
65
68
  const id = this.ids.next()
66
69
  const hash = await this.passwords.hash(password)
67
- const created = await accountDoors.createUser(this.entityManager, { id, email: address, hash, confirmed: false, meta: "{}", now })
70
+ const created = await accountDoors.createUser(this.entityManager, {
71
+ id,
72
+ email: address,
73
+ hash,
74
+ confirmed: false,
75
+ meta: JSON.stringify(name === undefined ? {} : { full_name: name, name }),
76
+ now,
77
+ })
68
78
  await this.sendConfirmation(created ?? id, address, language)
69
79
  }
70
80
 
@@ -82,7 +92,11 @@ export class IdentityEmailService {
82
92
  const now = this.clock.now()
83
93
  await this.entityManager.transaction(async (scoped) => {
84
94
  const row = await this.spendable(scoped, token, CONFIRM, now)
85
- await scoped.update(IdentityEmailTokenEntity, { userId: row.userId, purpose: CONFIRM, usedAt: IsNull() }, { usedAt: now })
95
+ await scoped.update(
96
+ IdentityEmailTokenEntity,
97
+ { userId: row.userId, purpose: CONFIRM, usedAt: IsNull() },
98
+ { usedAt: now },
99
+ )
86
100
  await accountDoors.confirmUser(scoped, row.userId, now)
87
101
  })
88
102
  }
@@ -95,7 +109,11 @@ export class IdentityEmailService {
95
109
  if (user === undefined || user.disabled) return
96
110
  const token = await this.mint(user.id, RESET, this.options.resetTtlSeconds)
97
111
  const url = `${this.options.webOrigin}/auth/reset?token=${encodeURIComponent(token)}`
98
- const mail = resetPasswordMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.resetTtlSeconds) })
112
+ const mail = resetPasswordMail(this.languageOf(language), {
113
+ brand: this.options.brandName,
114
+ url,
115
+ hours: this.hoursOf(this.options.resetTtlSeconds),
116
+ })
99
117
  await this.deliver(address, mail, RESET)
100
118
  }
101
119
 
@@ -122,7 +140,12 @@ export class IdentityEmailService {
122
140
  }
123
141
  }
124
142
 
125
- private async spendable(scoped: EntityManager, token: string, purpose: Purpose, now: Date): Promise<IdentityEmailTokenEntity> {
143
+ private async spendable(
144
+ scoped: EntityManager,
145
+ token: string,
146
+ purpose: Purpose,
147
+ now: Date,
148
+ ): Promise<IdentityEmailTokenEntity> {
126
149
  const row = await scoped.findOne(IdentityEmailTokenEntity, {
127
150
  where: { tokenHash: digestOf(token), purpose, usedAt: IsNull(), expiresAt: MoreThan(now) },
128
151
  lock: { mode: "pessimistic_write" },
@@ -139,7 +162,11 @@ export class IdentityEmailService {
139
162
  private async sendConfirmation(userId: string, address: string, language: string | undefined): Promise<void> {
140
163
  const token = await this.mint(userId, CONFIRM, this.options.confirmTtlSeconds)
141
164
  const url = `${this.options.webOrigin}/auth/confirm?token=${encodeURIComponent(token)}`
142
- const mail = confirmEmailMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.confirmTtlSeconds) })
165
+ const mail = confirmEmailMail(this.languageOf(language), {
166
+ brand: this.options.brandName,
167
+ url,
168
+ hours: this.hoursOf(this.options.confirmTtlSeconds),
169
+ })
143
170
  await this.deliver(address, mail, CONFIRM)
144
171
  }
145
172