@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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/account-doors.mjs +7 -1
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +14 -5
- package/codemod/module-definition.d.mts +10 -3
- package/codemod/module-definition.mjs +32 -17
- package/codemod/seeds-layout.d.mts +36 -0
- package/codemod/seeds-layout.mjs +113 -0
- package/codemod/sops-rules.d.mts +1 -1
- package/codemod/sops-rules.mjs +8 -3
- package/lint/run.d.mts +5 -4
- package/lint/run.mjs +53 -27
- package/package.json +1 -1
- package/release/lint-report.mjs +4 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +15 -2
- package/runtime/knowledge/hfs/slots.yaml +15 -18
- package/runtime/modules/kernel/failure-codes.yaml +64 -3
- package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
- package/runtime/scripts/hfs/db-squash.mjs +9 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
- package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
- package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
- package/runtime/scripts/hfs/rules/database.d.mts +2 -2
- package/runtime/scripts/hfs/rules/database.mjs +7 -6
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
- package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
- package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
- package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
- package/runtime/scripts/hfs/slots.d.mts +2 -0
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
- package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
- package/runtime/scripts/hfs/view.d.mts +25 -0
- package/runtime/scripts/hfs/view.mjs +41 -0
- package/scaffold/add-app.mjs +2 -2
- package/scaffold/add-cli-lite.mjs +41 -14
- package/scaffold/add-module.mjs +6 -3
- package/scaffold/lite-exports.mjs +1 -1
- package/scaffold/module-registry.mjs +10 -8
- package/src/main.mjs +8 -8
- package/stack/deploy.d.mts +3 -0
- package/stack/deploy.mjs +4 -1
- package/stack/local-up.mjs +25 -3
- package/stack/seeds.d.mts +29 -0
- package/stack/seeds.mjs +197 -0
- package/sync/canon-layout.d.mts +38 -0
- package/sync/canon-layout.mjs +62 -0
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +10 -7
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/index.d.mts +22 -3
- package/sync/index.mjs +80 -32
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
- package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
- package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
- package/templates/be/tool-config/jest.config.js +1 -1
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
- package/templates/app/module/jest.config.js.tpl +0 -8
- /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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({
|
|
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(
|
|
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(
|
|
104
|
-
|
|
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(
|
|
115
|
-
|
|
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, [
|
|
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, [
|
|
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, [
|
|
166
|
-
|
|
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 = {
|
|
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(
|
|
206
|
-
|
|
207
|
-
|
|
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 {
|
|
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(
|
|
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)
|
|
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 = {
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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 => ({
|
|
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({
|
|
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, [
|
|
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(
|
|
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({
|
|
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.
|
|
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(
|
|
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({
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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(
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
-
/**
|
|
55
|
-
|
|
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, {
|
|
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(
|
|
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), {
|
|
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(
|
|
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), {
|
|
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
|
|