@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/persistence/account-doors.port.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
2
|
import type { EntityManager } from "typeorm"
|
|
3
3
|
import { callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
|
|
4
|
+
import { isRecord } from "@modules/platform/primitives"
|
|
4
5
|
|
|
5
6
|
/** The account an address stands for, with the stored hash the password is checked against. */
|
|
6
7
|
export interface AccountByEmail {
|
|
@@ -22,10 +23,16 @@ export interface AccountById {
|
|
|
22
23
|
readonly id: string
|
|
23
24
|
/** The sign-in address, or null. */
|
|
24
25
|
readonly email: string | null
|
|
26
|
+
/** The profile keys of the account (raw_user_meta_data), as the door returns them. */
|
|
27
|
+
readonly meta: unknown
|
|
25
28
|
/** True when the account is deleted or banned at the database's now(). */
|
|
26
29
|
readonly disabled: boolean
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** The full name the profile keys of an account hold (`full_name`, written by the sign-up and the Google sign-in), or undefined. */
|
|
33
|
+
export const fullNameOf = (meta: unknown): string | undefined =>
|
|
34
|
+
isRecord(meta) && typeof meta.full_name === "string" && meta.full_name !== "" ? meta.full_name : undefined
|
|
35
|
+
|
|
29
36
|
/** An account to create: the id, the address, the profile keys as JSON text and the instant. */
|
|
30
37
|
export interface NewAccount {
|
|
31
38
|
/** The account id. */
|
|
@@ -49,7 +56,12 @@ const isAccountByEmail: (value: unknown) => value is AccountByEmail = rowShape({
|
|
|
49
56
|
email_confirmed_at: "date?",
|
|
50
57
|
disabled: "boolean",
|
|
51
58
|
})
|
|
52
|
-
const isAccountById: (value: unknown) => value is AccountById = rowShape({
|
|
59
|
+
const isAccountById: (value: unknown) => value is AccountById = rowShape({
|
|
60
|
+
id: "string",
|
|
61
|
+
email: "string?",
|
|
62
|
+
meta: "json",
|
|
63
|
+
disabled: "boolean",
|
|
64
|
+
})
|
|
53
65
|
const isLinkedAccount = rowShape({ be_auth_user_by_google: "string?" })
|
|
54
66
|
const isCreatedAccount = rowShape({ be_auth_create_user: "string" })
|
|
55
67
|
|
|
@@ -60,7 +72,10 @@ const isCreatedAccount = rowShape({ be_auth_create_user: "string" })
|
|
|
60
72
|
export const accountDoors = {
|
|
61
73
|
/** The account of an exact address, or undefined. */
|
|
62
74
|
async userByEmail(manager: EntityManager, email: string): Promise<AccountByEmail | undefined> {
|
|
63
|
-
const [row] = returnedRows(
|
|
75
|
+
const [row] = returnedRows(
|
|
76
|
+
await callDbFunction(manager, "public.be_auth_user_by_email", [email]),
|
|
77
|
+
isAccountByEmail,
|
|
78
|
+
)
|
|
64
79
|
return row
|
|
65
80
|
},
|
|
66
81
|
/** The standing of an account by id, or undefined. */
|
|
@@ -70,7 +85,10 @@ export const accountDoors = {
|
|
|
70
85
|
},
|
|
71
86
|
/** The account a Google subject is linked to, or null. */
|
|
72
87
|
async userByGoogle(manager: EntityManager, subject: string): Promise<string | null> {
|
|
73
|
-
const [row] = returnedRows(
|
|
88
|
+
const [row] = returnedRows(
|
|
89
|
+
await callDbFunction(manager, "public.be_auth_user_by_google", [subject]),
|
|
90
|
+
isLinkedAccount,
|
|
91
|
+
)
|
|
74
92
|
return row?.be_auth_user_by_google ?? null
|
|
75
93
|
},
|
|
76
94
|
/** Creates an account and answers its id. */
|
|
@@ -88,7 +106,13 @@ export const accountDoors = {
|
|
|
88
106
|
await callDbFunction(manager, "public.be_auth_confirm_user", [userId, now])
|
|
89
107
|
},
|
|
90
108
|
/** Sets the password hash of an account; `confirm` also confirms the mailbox, as a spent reset link proves it. */
|
|
91
|
-
async setPassword(
|
|
109
|
+
async setPassword(
|
|
110
|
+
manager: EntityManager,
|
|
111
|
+
userId: string,
|
|
112
|
+
hash: string,
|
|
113
|
+
confirm: boolean,
|
|
114
|
+
now: Date,
|
|
115
|
+
): Promise<void> {
|
|
92
116
|
await callDbFunction(manager, "public.be_auth_set_password", [userId, hash, confirm, now])
|
|
93
117
|
},
|
|
94
118
|
/** Records a sign-in, confirms the mailbox when asked, and fills the profile keys the person has not set. */
|
|
@@ -31,4 +31,8 @@ export class IdentityRefreshTokenEntity {
|
|
|
31
31
|
/** When the token was rotated away, or null while it is the live one. */
|
|
32
32
|
@Column({ name: "used_at", type: "timestamptz", nullable: true })
|
|
33
33
|
usedAt!: Date | null
|
|
34
|
+
|
|
35
|
+
/** How many successors replays of this token obtained within the grace window after its rotation (bounded). */
|
|
36
|
+
@Column({ name: "raced_count", type: "integer", default: 0 })
|
|
37
|
+
racedCount!: number
|
|
34
38
|
}
|
|
@@ -7,4 +7,9 @@ import { IdentitySessionEntity } from "./identity-session.entity"
|
|
|
7
7
|
export { IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity }
|
|
8
8
|
|
|
9
9
|
/** The entities of the identity capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
|
|
10
|
-
export const identityEntities: ReadonlyArray<new () => object> = [
|
|
10
|
+
export const identityEntities: ReadonlyArray<new () => object> = [
|
|
11
|
+
IdentityAttemptEntity,
|
|
12
|
+
IdentityEmailTokenEntity,
|
|
13
|
+
IdentityRefreshTokenEntity,
|
|
14
|
+
IdentitySessionEntity,
|
|
15
|
+
]
|
|
@@ -31,7 +31,10 @@ const build = async (entityManager: MockEntityManager) => {
|
|
|
31
31
|
describe("IdentityRateCounterService.consume", () => {
|
|
32
32
|
it("counts a hit in the live window and compares it with the limit", async () => {
|
|
33
33
|
const entityManager = mockEntityManager()
|
|
34
|
-
const [counting] = queueQueryBuilders(entityManager, [
|
|
34
|
+
const [counting] = queueQueryBuilders(entityManager, [
|
|
35
|
+
{ execute: { raw: [{ hits: 3 }] } },
|
|
36
|
+
{ execute: { raw: [{ hits: 4 }] } },
|
|
37
|
+
])
|
|
35
38
|
const subject = await build(entityManager)
|
|
36
39
|
|
|
37
40
|
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(true)
|
|
@@ -50,7 +53,11 @@ describe("IdentityRateCounterService.consume", () => {
|
|
|
50
53
|
|
|
51
54
|
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(true)
|
|
52
55
|
|
|
53
|
-
expect(entityManager.update).toHaveBeenCalledWith(
|
|
56
|
+
expect(entityManager.update).toHaveBeenCalledWith(
|
|
57
|
+
IdentityAttemptEntity,
|
|
58
|
+
{ key: "login:abc", resetAt: LessThanOrEqual(NOW) },
|
|
59
|
+
{ hits: 1, resetAt: RESET_AT },
|
|
60
|
+
)
|
|
54
61
|
})
|
|
55
62
|
|
|
56
63
|
it("opens a window when the restart reports no affected count", async () => {
|
|
@@ -31,9 +31,18 @@ export class IdentityRateCounterService implements RateCounter {
|
|
|
31
31
|
const [live] = returnedRows(counted.raw, isCountedWindow)
|
|
32
32
|
if (live !== undefined) return live.hits <= limit
|
|
33
33
|
const resetAt = new Date(now.getTime() + windowMs)
|
|
34
|
-
const restarted = await this.entityManager.update(
|
|
34
|
+
const restarted = await this.entityManager.update(
|
|
35
|
+
IdentityAttemptEntity,
|
|
36
|
+
{ key, resetAt: LessThanOrEqual(now) },
|
|
37
|
+
{ hits: 1, resetAt },
|
|
38
|
+
)
|
|
35
39
|
if ((restarted.affected ?? 0) === 0) {
|
|
36
|
-
await this.entityManager
|
|
40
|
+
await this.entityManager
|
|
41
|
+
.createQueryBuilder(IdentityAttemptEntity, "attempt")
|
|
42
|
+
.insert()
|
|
43
|
+
.values({ key, hits: 1, resetAt })
|
|
44
|
+
.orIgnore()
|
|
45
|
+
.execute()
|
|
37
46
|
}
|
|
38
47
|
return limit >= 1
|
|
39
48
|
}
|
|
@@ -19,6 +19,7 @@ const row = (usedAt: Date | null): IdentityRefreshTokenEntity => ({
|
|
|
19
19
|
createdAt: NOW,
|
|
20
20
|
expiresAt: LATER,
|
|
21
21
|
usedAt,
|
|
22
|
+
racedCount: 0,
|
|
22
23
|
})
|
|
23
24
|
|
|
24
25
|
const build = async (entityManager: MockEntityManager = mockEntityManager()) => {
|
|
@@ -31,22 +32,49 @@ const build = async (entityManager: MockEntityManager = mockEntityManager()) =>
|
|
|
31
32
|
|
|
32
33
|
describe("IdentitySessionStoreService.transaction", () => {
|
|
33
34
|
it("opens a session and stores a live token in one transaction", async () => {
|
|
34
|
-
const { subject, entityManager, transaction } = await build(
|
|
35
|
+
const { subject, entityManager, transaction } = await build(
|
|
36
|
+
mockEntityManager({
|
|
37
|
+
insert: [
|
|
38
|
+
[IdentitySessionEntity, {}],
|
|
39
|
+
[IdentityRefreshTokenEntity, {}],
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
35
43
|
|
|
36
44
|
await subject.transaction(async (tx) => {
|
|
37
45
|
await tx.openSession({ id: "s-1", userId: "u-1", now: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
38
46
|
await tx.insertToken({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER })
|
|
39
47
|
})
|
|
40
48
|
|
|
41
|
-
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, {
|
|
42
|
-
|
|
49
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, {
|
|
50
|
+
id: "s-1",
|
|
51
|
+
userId: "u-1",
|
|
52
|
+
createdAt: NOW,
|
|
53
|
+
refreshedAt: NOW,
|
|
54
|
+
userAgent: "agent",
|
|
55
|
+
ip: "203.0.113.9",
|
|
56
|
+
})
|
|
57
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
58
|
+
id: "t-1",
|
|
59
|
+
sessionId: "s-1",
|
|
60
|
+
userId: "u-1",
|
|
61
|
+
tokenHash: "h-1",
|
|
62
|
+
expiresAt: LATER,
|
|
63
|
+
usedAt: null,
|
|
64
|
+
racedCount: 0,
|
|
65
|
+
})
|
|
43
66
|
expect(transaction.commits).toBe(1)
|
|
44
67
|
})
|
|
68
|
+
})
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
describe("IdentitySessionStoreService.transaction (a rotation)", () => {
|
|
71
|
+
it("reads the token for update, marks it used or raced and drops the expired ones", async () => {
|
|
47
72
|
const { subject, entityManager } = await build(
|
|
48
73
|
mockEntityManager({
|
|
49
|
-
findOne: [
|
|
74
|
+
findOne: [
|
|
75
|
+
[IdentityRefreshTokenEntity, row(NOW)],
|
|
76
|
+
[IdentityRefreshTokenEntity, null],
|
|
77
|
+
],
|
|
50
78
|
update: [IdentityRefreshTokenEntity, {}],
|
|
51
79
|
delete: [IdentityRefreshTokenEntity, {}],
|
|
52
80
|
}),
|
|
@@ -56,14 +84,30 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
56
84
|
const missing = await subject.transaction((tx) => tx.findTokenForUpdate("h-2"))
|
|
57
85
|
await subject.transaction(async (tx) => {
|
|
58
86
|
await tx.markTokenUsed("t-1", NOW)
|
|
87
|
+
await tx.markTokenRaced("t-1", 2)
|
|
59
88
|
await tx.deleteExpiredTokens("u-1", NOW)
|
|
60
89
|
})
|
|
61
90
|
|
|
62
|
-
expect(found).toEqual({
|
|
91
|
+
expect(found).toEqual({
|
|
92
|
+
id: "t-1",
|
|
93
|
+
sessionId: "s-1",
|
|
94
|
+
userId: "u-1",
|
|
95
|
+
tokenHash: "h-1",
|
|
96
|
+
expiresAt: LATER,
|
|
97
|
+
usedAt: NOW,
|
|
98
|
+
racedCount: 0,
|
|
99
|
+
})
|
|
63
100
|
expect(missing).toBeNull()
|
|
64
|
-
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
101
|
+
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
102
|
+
where: { tokenHash: "h-1" },
|
|
103
|
+
lock: { mode: "pessimistic_write" },
|
|
104
|
+
})
|
|
65
105
|
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
|
|
66
|
-
expect(entityManager.
|
|
106
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { racedCount: 2 })
|
|
107
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
108
|
+
userId: "u-1",
|
|
109
|
+
expiresAt: LessThan(NOW),
|
|
110
|
+
})
|
|
67
111
|
})
|
|
68
112
|
|
|
69
113
|
it("rolls the transaction back when the work throws", async () => {
|
|
@@ -77,14 +121,23 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
77
121
|
|
|
78
122
|
describe("IdentitySessionStoreService", () => {
|
|
79
123
|
it("finds a token outside a transaction, or none", async () => {
|
|
80
|
-
const { subject } = await build(
|
|
124
|
+
const { subject } = await build(
|
|
125
|
+
mockEntityManager({
|
|
126
|
+
findOne: [
|
|
127
|
+
[IdentityRefreshTokenEntity, row(null)],
|
|
128
|
+
[IdentityRefreshTokenEntity, null],
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
81
132
|
|
|
82
133
|
expect(await subject.findToken("h-1")).toMatchObject({ id: "t-1", usedAt: null })
|
|
83
134
|
expect(await subject.findToken("h-2")).toBeNull()
|
|
84
135
|
})
|
|
85
136
|
|
|
86
137
|
it("touches, closes and closes the others of a session", async () => {
|
|
87
|
-
const { subject, entityManager } = await build(
|
|
138
|
+
const { subject, entityManager } = await build(
|
|
139
|
+
mockEntityManager({ update: [IdentitySessionEntity, {}], delete: [IdentitySessionEntity, {}] }),
|
|
140
|
+
)
|
|
88
141
|
|
|
89
142
|
await subject.touchSession("s-1", NOW)
|
|
90
143
|
await subject.closeSession("s-1")
|
|
@@ -13,6 +13,7 @@ const recordOf = (row: IdentityRefreshTokenEntity): RefreshTokenRecord => ({
|
|
|
13
13
|
tokenHash: row.tokenHash,
|
|
14
14
|
expiresAt: row.expiresAt,
|
|
15
15
|
usedAt: row.usedAt,
|
|
16
|
+
racedCount: row.racedCount,
|
|
16
17
|
})
|
|
17
18
|
|
|
18
19
|
const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
@@ -34,15 +35,22 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
34
35
|
tokenHash: token.tokenHash,
|
|
35
36
|
expiresAt: token.expiresAt,
|
|
36
37
|
usedAt: null,
|
|
38
|
+
racedCount: 0,
|
|
37
39
|
})
|
|
38
40
|
},
|
|
39
41
|
findTokenForUpdate: async (tokenHash) => {
|
|
40
|
-
const row = await scoped.findOne(IdentityRefreshTokenEntity, {
|
|
42
|
+
const row = await scoped.findOne(IdentityRefreshTokenEntity, {
|
|
43
|
+
where: { tokenHash },
|
|
44
|
+
lock: { mode: "pessimistic_write" },
|
|
45
|
+
})
|
|
41
46
|
return row === null ? null : recordOf(row)
|
|
42
47
|
},
|
|
43
48
|
markTokenUsed: async (tokenId, at) => {
|
|
44
49
|
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { usedAt: at })
|
|
45
50
|
},
|
|
51
|
+
markTokenRaced: async (tokenId, racedCount) => {
|
|
52
|
+
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { racedCount })
|
|
53
|
+
},
|
|
46
54
|
deleteExpiredTokens: async (userId, before) => {
|
|
47
55
|
await scoped.delete(IdentityRefreshTokenEntity, { userId, expiresAt: LessThan(before) })
|
|
48
56
|
},
|
|
@@ -69,7 +77,7 @@ export class IdentitySessionStoreService implements SessionStore {
|
|
|
69
77
|
await this.entityManager.update(IdentitySessionEntity, { id: sessionId }, { refreshedAt: now })
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
/** Ends one session
|
|
80
|
+
/** Ends one session and revokes every token of it (the foreign key cascades). */
|
|
73
81
|
async closeSession(sessionId: string): Promise<void> {
|
|
74
82
|
await this.entityManager.delete(IdentitySessionEntity, { id: sessionId })
|
|
75
83
|
}
|
|
@@ -8,7 +8,12 @@ import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
|
8
8
|
import { IDENTITY_AUTH_DOORS, IDENTITY_AUTH_SESSIONS } from "./identity.decorators"
|
|
9
9
|
import { IdentitySignInService } from "./sign-in.service"
|
|
10
10
|
|
|
11
|
-
const GRANT: AccessGrant = {
|
|
11
|
+
const GRANT: AccessGrant = {
|
|
12
|
+
accessToken: "token",
|
|
13
|
+
expiresIn: 900,
|
|
14
|
+
expiresAt: "2026-02-03T04:20:06.000Z",
|
|
15
|
+
user: { id: "u-1", email: "a@example.test" },
|
|
16
|
+
}
|
|
12
17
|
const REDIRECT: GoogleRedirect = { url: "https://app.example.test/", statusCode: 303 }
|
|
13
18
|
const EXCHANGE = mock<HttpExchange>()
|
|
14
19
|
|
|
@@ -16,7 +21,11 @@ const build = async () => {
|
|
|
16
21
|
const doors = mock<AuthDoors>()
|
|
17
22
|
const sessions = mock<AuthSessions>()
|
|
18
23
|
const moduleRef = await Test.createTestingModule({
|
|
19
|
-
providers: [
|
|
24
|
+
providers: [
|
|
25
|
+
IdentitySignInService,
|
|
26
|
+
{ provide: IDENTITY_AUTH_DOORS, useValue: doors },
|
|
27
|
+
{ provide: IDENTITY_AUTH_SESSIONS, useValue: sessions },
|
|
28
|
+
],
|
|
20
29
|
}).compile()
|
|
21
30
|
return { subject: moduleRef.get(IdentitySignInService), doors, sessions }
|
|
22
31
|
}
|
|
@@ -38,7 +47,15 @@ describe("IdentitySignInService", () => {
|
|
|
38
47
|
await subject.beginGoogle(EXCHANGE)
|
|
39
48
|
expect(await subject.completeGoogle(EXCHANGE)).toBe(REDIRECT)
|
|
40
49
|
|
|
41
|
-
for (const door of [
|
|
50
|
+
for (const door of [
|
|
51
|
+
doors.login,
|
|
52
|
+
doors.refresh,
|
|
53
|
+
doors.session,
|
|
54
|
+
doors.logout,
|
|
55
|
+
doors.beginGoogle,
|
|
56
|
+
doors.completeGoogle,
|
|
57
|
+
])
|
|
58
|
+
expect(door).toHaveBeenCalledWith(EXCHANGE)
|
|
42
59
|
})
|
|
43
60
|
|
|
44
61
|
it("serves the public keys", async () => {
|
|
@@ -61,10 +78,14 @@ describe("IdentitySignInService", () => {
|
|
|
61
78
|
const { subject, sessions } = await build()
|
|
62
79
|
sessions.endOthers.mockRejectedValue(new AuthError("IDENTITY_UNAUTHENTICATED"))
|
|
63
80
|
|
|
64
|
-
await expect(subject.endOthers({ id: "u-1", role: "authenticated" })).rejects.toMatchObject({
|
|
81
|
+
await expect(subject.endOthers({ id: "u-1", role: "authenticated" })).rejects.toMatchObject({
|
|
82
|
+
code: IdentityErrorCode.Unauthenticated,
|
|
83
|
+
})
|
|
65
84
|
expect(sessions.endOthers).toHaveBeenCalledWith("u-1", undefined)
|
|
66
85
|
})
|
|
86
|
+
})
|
|
67
87
|
|
|
88
|
+
describe("IdentitySignInService refusals", () => {
|
|
68
89
|
it("turns a refusal of the package into the error family that owns its code", async () => {
|
|
69
90
|
const { subject, doors } = await build()
|
|
70
91
|
doors.refresh.mockRejectedValue(new AuthError("IDENTITY_REFRESH_REUSED"))
|
|
@@ -4,7 +4,14 @@ import { AuthError, PassportError, PassportErrorCode } from "@modules/integratio
|
|
|
4
4
|
import type { Principal } from "@modules/platform/cqrs"
|
|
5
5
|
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
6
6
|
import { InjectAuthDoors, InjectAuthSessions } from "./identity.decorators"
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AccessGrant,
|
|
9
|
+
AuthDoors,
|
|
10
|
+
AuthSessions,
|
|
11
|
+
GoogleRedirect,
|
|
12
|
+
HttpExchange,
|
|
13
|
+
JwksDocument,
|
|
14
|
+
} from "@modules/integrations/passport"
|
|
8
15
|
|
|
9
16
|
/** The wire codes of the package that belong to the passport family (the token, key and Google legs). */
|
|
10
17
|
const PASSPORT_CODES: ReadonlyArray<PassportErrorCode> = [
|
|
@@ -5,7 +5,7 @@ const BEFORE = new Date("2026-02-03T04:00:00.000Z")
|
|
|
5
5
|
export type AccountByEmailVariant = "live" | "disabled" | "unconfirmed" | "passwordless" | "invited"
|
|
6
6
|
|
|
7
7
|
/** The kinds of account the sign-in reads by id. */
|
|
8
|
-
export type AccountByIdVariant = "live" | "disabled" | "addressless"
|
|
8
|
+
export type AccountByIdVariant = "live" | "disabled" | "addressless" | "named"
|
|
9
9
|
|
|
10
10
|
/** A row of the by-address door: `disabled` is what the database decided at its own now(). */
|
|
11
11
|
export interface AccountByEmailRow {
|
|
@@ -20,6 +20,7 @@ export interface AccountByEmailRow {
|
|
|
20
20
|
export interface AccountByIdRow {
|
|
21
21
|
id: string
|
|
22
22
|
email: string | null
|
|
23
|
+
meta: Record<string, string>
|
|
23
24
|
disabled: boolean
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -36,5 +37,6 @@ export const accountByEmailRow = (variant: AccountByEmailVariant = "live"): Acco
|
|
|
36
37
|
export const accountByIdRow = (variant: AccountByIdVariant = "live", id = "u-1"): AccountByIdRow => ({
|
|
37
38
|
id,
|
|
38
39
|
email: variant === "addressless" ? null : "a@example.test",
|
|
40
|
+
meta: variant === "named" ? { full_name: "Ada Lovelace", name: "Ada Lovelace" } : {},
|
|
39
41
|
disabled: variant === "disabled",
|
|
40
42
|
})
|
|
@@ -3,15 +3,15 @@ import { DEFAULT_SEED_ENV, SeedRunnerService } from "@modules/platform/database"
|
|
|
3
3
|
|
|
4
4
|
/** The options of `cli seed run`. */
|
|
5
5
|
export interface SeedRunOptions {
|
|
6
|
-
/** The stack environment whose `.starcistacks/<env>/seeds`
|
|
6
|
+
/** The stack environment whose `.starcistacks/<env>/seeds/sql` runs after `.starcistacks/seeds/sql`. */
|
|
7
7
|
readonly env?: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
@SubCommand({
|
|
11
11
|
name: "run",
|
|
12
|
-
description: "Run
|
|
12
|
+
description: "Run the shared seed files, then those of a stack environment, each on the connection it names",
|
|
13
13
|
})
|
|
14
|
-
/** `cli seed run [--env <name>]`: seeds every connection of the back end from `.starcistacks/<env>/seeds
|
|
14
|
+
/** `cli seed run [--env <name>]`: seeds every connection of the back end from `.starcistacks/seeds/sql`, then `.starcistacks/<env>/seeds/sql`, and logs what it ran. */
|
|
15
15
|
export class RunSeedsCli extends CommandRunner {
|
|
16
16
|
constructor(private readonly seeds: SeedRunnerService) {
|
|
17
17
|
super()
|
|
@@ -38,7 +38,7 @@ function assertSqlIdent(_value: string): asserts _value is SqlIdent {
|
|
|
38
38
|
{{> be/common/sql-ident.ts.partial}}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Brands the statements of one tracked seed file (`.starcistacks/<env>/seeds/<connection>-<name>.sql`) as SQL text. A seed file
|
|
41
|
+
* Brands the statements of one tracked seed file (`.starcistacks/seeds/sql/<connection>-<name>.sql` or `.starcistacks/<env>/seeds/sql/<connection>-<name>.sql`) as SQL text. A seed file
|
|
42
42
|
* is source reviewed like a migration and run as written by the cli seed command; no value is ever spliced into it.
|
|
43
43
|
*/
|
|
44
44
|
export const seedText = (text: string): SqlText => {
|
|
@@ -36,9 +36,10 @@ const file = (name: string, text: string): SeedFile => ({
|
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
describe("seedDirectoryOf", () => {
|
|
39
|
-
it("is the
|
|
39
|
+
it("is the SQL folder of the shared seed level or of an environment, relative to the app root", async () => {
|
|
40
40
|
const client = await setup()
|
|
41
|
-
expect(client.seedDirectoryOf(
|
|
41
|
+
expect(client.seedDirectoryOf()).toBe(join(".starcistacks", "seeds", "sql"))
|
|
42
|
+
expect(client.seedDirectoryOf("dev")).toBe(join(".starcistacks", "dev", "seeds", "sql"))
|
|
42
43
|
})
|
|
43
44
|
})
|
|
44
45
|
|
|
@@ -27,8 +27,12 @@ export interface SeedReport {
|
|
|
27
27
|
readonly unmatched: ReadonlyArray<string>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The SQL seed folder of a seed level, relative to the working directory of the run (the app root): the shared level every environment runs
|
|
32
|
+
* first (`.starcistacks/seeds/sql`) when no environment is named, else the environment's own (`.starcistacks/<env>/seeds/sql`).
|
|
33
|
+
*/
|
|
34
|
+
export const seedDirectoryOf = (env?: string): string =>
|
|
35
|
+
env === undefined ? join(".starcistacks", "seeds", "sql") : join(".starcistacks", env, "seeds", "sql")
|
|
32
36
|
|
|
33
37
|
/** Reads every `*.sql` file of the seed directory, sorted by name; a missing directory fails the run. */
|
|
34
38
|
export const readSeedFiles = async (directory: string): Promise<ReadonlyArray<SeedFile>> => {
|
|
@@ -48,24 +48,28 @@ const build = async (
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
describe("SeedRunnerService", () => {
|
|
51
|
-
it("reads the seeds of the named environment, runs them on their connections and logs the report", async () => {
|
|
51
|
+
it("reads the shared seeds and then those of the named environment, runs them on their connections and logs the report", async () => {
|
|
52
52
|
const primary = mock<ConnectionSource>()
|
|
53
53
|
const { runner, logger, reader } = await build(
|
|
54
54
|
() => primary,
|
|
55
|
-
() =>
|
|
56
|
-
Promise.resolve(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
(directory) =>
|
|
56
|
+
Promise.resolve(
|
|
57
|
+
directory === seedDirectoryOf()
|
|
58
|
+
? [file("primary-plans.sql", "INSERT INTO plans (code) VALUES ('free')")]
|
|
59
|
+
: [
|
|
60
|
+
file("primary-notes.sql", "INSERT INTO notes (body) VALUES ('a')"),
|
|
61
|
+
file("legacy-users.sql", "INSERT INTO users DEFAULT VALUES"),
|
|
62
|
+
],
|
|
63
|
+
),
|
|
60
64
|
)
|
|
61
65
|
|
|
62
66
|
await expect(runner.run("staging")).resolves.toBeUndefined()
|
|
63
67
|
|
|
64
|
-
expect(reader.read).
|
|
65
|
-
expect(primary.query).
|
|
68
|
+
expect(reader.read.mock.calls).toEqual([[seedDirectoryOf()], [seedDirectoryOf("staging")]])
|
|
69
|
+
expect(primary.query.mock.calls).toEqual([["INSERT INTO plans (code) VALUES ('free')"], ["INSERT INTO notes (body) VALUES ('a')"]])
|
|
66
70
|
expect(logger.info).toHaveBeenCalledWith(LoggingLogEvent.SeedsApplied, {
|
|
67
71
|
env: "staging",
|
|
68
|
-
applied: { primary: ["primary-notes.sql"] },
|
|
72
|
+
applied: { primary: ["primary-plans.sql", "primary-notes.sql"] },
|
|
69
73
|
unmatched: ["legacy-users.sql"],
|
|
70
74
|
})
|
|
71
75
|
})
|
|
@@ -78,6 +82,7 @@ describe("SeedRunnerService", () => {
|
|
|
78
82
|
|
|
79
83
|
await expect(runner.run()).resolves.toBeUndefined()
|
|
80
84
|
|
|
85
|
+
expect(reader.read).toHaveBeenCalledWith(seedDirectoryOf())
|
|
81
86
|
expect(reader.read).toHaveBeenCalledWith(seedDirectoryOf("dev"))
|
|
82
87
|
expect(logger.info).toHaveBeenCalledWith(LoggingLogEvent.SeedsApplied, {
|
|
83
88
|
env: "dev",
|
|
@@ -7,7 +7,7 @@ import type { ConnectionOpener, SeedFileReader } from "./database.port"
|
|
|
7
7
|
import { DEFAULT_SEED_ENV, seedConnections, seedDirectoryOf } from "./seed-connections.client"
|
|
8
8
|
|
|
9
9
|
@Injectable()
|
|
10
|
-
/** The one seed runner: reads the seed files of a stack environment, runs them on the connections they name and logs the report. */
|
|
10
|
+
/** The one seed runner: reads the shared seed files and then those of a stack environment, runs them on the connections they name and logs the report. */
|
|
11
11
|
export class SeedRunnerService {
|
|
12
12
|
constructor(
|
|
13
13
|
@InjectDatabaseOptions() private readonly options: DatabaseOptions,
|
|
@@ -16,9 +16,9 @@ export class SeedRunnerService {
|
|
|
16
16
|
@InjectLogger() private readonly logger: Logger,
|
|
17
17
|
) {}
|
|
18
18
|
|
|
19
|
-
/** Reads the
|
|
19
|
+
/** Reads the shared SQL seeds, then those of the environment (dev when none is named), runs them on their connections and logs the report. */
|
|
20
20
|
async run(env: string = DEFAULT_SEED_ENV): Promise<void> {
|
|
21
|
-
const files = await this.reader.read(seedDirectoryOf(env))
|
|
21
|
+
const files = [...(await this.reader.read(seedDirectoryOf())), ...(await this.reader.read(seedDirectoryOf(env)))]
|
|
22
22
|
const report = await seedConnections(this.options.connections, files, this.opener)
|
|
23
23
|
this.logger.info(LoggingLogEvent.SeedsApplied, {
|
|
24
24
|
env,
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises"
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
2
3
|
import { Injectable } from "@nestjs/common"
|
|
3
4
|
import type { EntityManager } from "typeorm"
|
|
4
5
|
import type { SqlText } from "./database.sql"
|
|
5
6
|
import { InjectPrimaryEntityManager } from "./primary.decorators"
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/** The stack environment whose seed runs when `--env` is not given. */
|
|
9
|
+
export const DEFAULT_SEED_ENV = "dev"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The SQL seed folder of a seed level, relative to the app root the run starts from: the shared level every environment runs first
|
|
13
|
+
* (`.starcistacks/seeds/sql`) when no environment is named, else the environment's own (`.starcistacks/<env>/seeds/sql`).
|
|
14
|
+
*/
|
|
15
|
+
export const seedDirectoryOf = (env?: string): string =>
|
|
16
|
+
env === undefined ? join(".starcistacks", "seeds", "sql") : join(".starcistacks", env, "seeds", "sql")
|
|
8
17
|
|
|
9
18
|
function assertSeedText(_text: string): asserts _text is SqlText {
|
|
10
|
-
// The brand is compile-time only; this function's caller reads
|
|
19
|
+
// The brand is compile-time only; this function's caller reads a tracked seed file verbatim.
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
const seedText = (text: string): SqlText => {
|
|
@@ -15,14 +24,30 @@ const seedText = (text: string): SqlText => {
|
|
|
15
24
|
return text
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
/** The `*.sql` files of a seed folder in name order; a folder that is absent holds none. */
|
|
28
|
+
const seedFilesOf = async (directory: string): Promise<ReadonlyArray<string>> => {
|
|
29
|
+
try {
|
|
30
|
+
const names = await readdir(directory)
|
|
31
|
+
return names.filter((name) => name.endsWith(".sql")).sort((a, b) => a.localeCompare(b)).map((name) => join(directory, name))
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return []
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
@Injectable()
|
|
19
|
-
/** Executes the
|
|
39
|
+
/** Executes the tracked SQL seeds, the shared level and then the environment's own, through the shared least-privilege EntityManager. */
|
|
20
40
|
export class SeedRunnerService {
|
|
21
41
|
constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
|
|
22
42
|
|
|
23
|
-
/**
|
|
24
|
-
async run(): Promise<void> {
|
|
25
|
-
const
|
|
43
|
+
/** Runs every SQL seed of the shared level, then of the environment (dev when none is named), in name order; an empty file runs nothing. */
|
|
44
|
+
async run(env: string = DEFAULT_SEED_ENV): Promise<void> {
|
|
45
|
+
const files = [...(await seedFilesOf(seedDirectoryOf())), ...(await seedFilesOf(seedDirectoryOf(env)))]
|
|
46
|
+
await files.reduce<Promise<void>>((done, file) => done.then(() => this.apply(file)), Promise.resolve())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async apply(file: string): Promise<void> {
|
|
50
|
+
const text = seedText(await readFile(file, "utf8"))
|
|
26
51
|
if (text.trim() !== "") await this.entityManager.query(text)
|
|
27
52
|
}
|
|
28
53
|
}
|
|
@@ -10,7 +10,10 @@ export type EmailDoorName = "signup" | "resend" | "confirm" | "forgot" | "reset"
|
|
|
10
10
|
const doors = createAuthClient({ apiOrigin: () => apiUrl() })
|
|
11
11
|
|
|
12
12
|
/** Calls one email door with its posted fields; answers its failure (the wire code and its kind), or undefined when the back end accepted it. */
|
|
13
|
-
export const sendEmailDoor = async (
|
|
13
|
+
export const sendEmailDoor = async (
|
|
14
|
+
door: EmailDoorName,
|
|
15
|
+
body: Readonly<Record<string, string | undefined>>,
|
|
16
|
+
): Promise<AuthFailure | undefined> => {
|
|
14
17
|
const outcome = await doors.send(door, body)
|
|
15
18
|
return outcome.success ? undefined : { code: outcome.error, kind: outcome.message, message: "" }
|
|
16
19
|
}
|
package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx
CHANGED
|
@@ -11,6 +11,8 @@ export interface EmailDoorFormBaseProps {
|
|
|
11
11
|
readonly props: {
|
|
12
12
|
readonly title: string
|
|
13
13
|
readonly description: string
|
|
14
|
+
/** The label of the optional full-name field, or undefined when the door takes none. */
|
|
15
|
+
readonly nameLabel: string | undefined
|
|
14
16
|
/** The label of the email field, or undefined when the door takes none. */
|
|
15
17
|
readonly emailLabel: string | undefined
|
|
16
18
|
/** The label of the password field, or undefined when the door takes none. */
|
|
@@ -32,6 +34,7 @@ export const EmailDoorFormBase = (props: EmailDoorFormBaseProps) => (
|
|
|
32
34
|
{props.props.doneMessage === undefined ? (
|
|
33
35
|
<Form label={props.props.title} isPending={props.props.pending} onSubmit={props.on.submit}>
|
|
34
36
|
<Text>{props.props.description}</Text>
|
|
37
|
+
{props.props.nameLabel === undefined ? null : <Input id="email-door-name" name="name" label={props.props.nameLabel} kind="text" />}
|
|
35
38
|
{props.props.emailLabel === undefined ? null : <Input id="email-door-email" name="email" label={props.props.emailLabel} kind="email" isRequired />}
|
|
36
39
|
{props.props.passwordLabel === undefined ? null : <Input id="email-door-password" name="password" label={props.props.passwordLabel} kind="password" isRequired />}
|
|
37
40
|
{props.props.errorMessage === undefined ? null : <Text live="assertive">{props.props.errorMessage}</Text>}
|