@starci/hfs 5.4.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 +19 -0
- package/README.md +4 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/main.mjs +6 -1
- 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/index.d.mts +13 -2
- package/sync/index.mjs +52 -24
- 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/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/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 +59 -9
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
- 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
|
@@ -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
|
}
|
|
@@ -32,22 +32,49 @@ const build = async (entityManager: MockEntityManager = mockEntityManager()) =>
|
|
|
32
32
|
|
|
33
33
|
describe("IdentitySessionStoreService.transaction", () => {
|
|
34
34
|
it("opens a session and stores a live token in one transaction", async () => {
|
|
35
|
-
const { subject, entityManager, transaction } = await build(
|
|
35
|
+
const { subject, entityManager, transaction } = await build(
|
|
36
|
+
mockEntityManager({
|
|
37
|
+
insert: [
|
|
38
|
+
[IdentitySessionEntity, {}],
|
|
39
|
+
[IdentityRefreshTokenEntity, {}],
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
36
43
|
|
|
37
44
|
await subject.transaction(async (tx) => {
|
|
38
45
|
await tx.openSession({ id: "s-1", userId: "u-1", now: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
39
46
|
await tx.insertToken({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER })
|
|
40
47
|
})
|
|
41
48
|
|
|
42
|
-
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, {
|
|
43
|
-
|
|
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
|
+
})
|
|
44
66
|
expect(transaction.commits).toBe(1)
|
|
45
67
|
})
|
|
68
|
+
})
|
|
46
69
|
|
|
70
|
+
describe("IdentitySessionStoreService.transaction (a rotation)", () => {
|
|
47
71
|
it("reads the token for update, marks it used or raced and drops the expired ones", async () => {
|
|
48
72
|
const { subject, entityManager } = await build(
|
|
49
73
|
mockEntityManager({
|
|
50
|
-
findOne: [
|
|
74
|
+
findOne: [
|
|
75
|
+
[IdentityRefreshTokenEntity, row(NOW)],
|
|
76
|
+
[IdentityRefreshTokenEntity, null],
|
|
77
|
+
],
|
|
51
78
|
update: [IdentityRefreshTokenEntity, {}],
|
|
52
79
|
delete: [IdentityRefreshTokenEntity, {}],
|
|
53
80
|
}),
|
|
@@ -61,12 +88,26 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
61
88
|
await tx.deleteExpiredTokens("u-1", NOW)
|
|
62
89
|
})
|
|
63
90
|
|
|
64
|
-
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
|
+
})
|
|
65
100
|
expect(missing).toBeNull()
|
|
66
|
-
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
101
|
+
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
102
|
+
where: { tokenHash: "h-1" },
|
|
103
|
+
lock: { mode: "pessimistic_write" },
|
|
104
|
+
})
|
|
67
105
|
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
|
|
68
106
|
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { racedCount: 2 })
|
|
69
|
-
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
107
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, {
|
|
108
|
+
userId: "u-1",
|
|
109
|
+
expiresAt: LessThan(NOW),
|
|
110
|
+
})
|
|
70
111
|
})
|
|
71
112
|
|
|
72
113
|
it("rolls the transaction back when the work throws", async () => {
|
|
@@ -80,14 +121,23 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
80
121
|
|
|
81
122
|
describe("IdentitySessionStoreService", () => {
|
|
82
123
|
it("finds a token outside a transaction, or none", async () => {
|
|
83
|
-
const { subject } = await build(
|
|
124
|
+
const { subject } = await build(
|
|
125
|
+
mockEntityManager({
|
|
126
|
+
findOne: [
|
|
127
|
+
[IdentityRefreshTokenEntity, row(null)],
|
|
128
|
+
[IdentityRefreshTokenEntity, null],
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
84
132
|
|
|
85
133
|
expect(await subject.findToken("h-1")).toMatchObject({ id: "t-1", usedAt: null })
|
|
86
134
|
expect(await subject.findToken("h-2")).toBeNull()
|
|
87
135
|
})
|
|
88
136
|
|
|
89
137
|
it("touches, closes and closes the others of a session", async () => {
|
|
90
|
-
const { subject, entityManager } = await build(
|
|
138
|
+
const { subject, entityManager } = await build(
|
|
139
|
+
mockEntityManager({ update: [IdentitySessionEntity, {}], delete: [IdentitySessionEntity, {}] }),
|
|
140
|
+
)
|
|
91
141
|
|
|
92
142
|
await subject.touchSession("s-1", NOW)
|
|
93
143
|
await subject.closeSession("s-1")
|
|
@@ -39,7 +39,10 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
39
39
|
})
|
|
40
40
|
},
|
|
41
41
|
findTokenForUpdate: async (tokenHash) => {
|
|
42
|
-
const row = await scoped.findOne(IdentityRefreshTokenEntity, {
|
|
42
|
+
const row = await scoped.findOne(IdentityRefreshTokenEntity, {
|
|
43
|
+
where: { tokenHash },
|
|
44
|
+
lock: { mode: "pessimistic_write" },
|
|
45
|
+
})
|
|
43
46
|
return row === null ? null : recordOf(row)
|
|
44
47
|
},
|
|
45
48
|
markTokenUsed: async (tokenId, at) => {
|
|
@@ -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>}
|
|
@@ -19,6 +19,7 @@ interface DoorCopy {
|
|
|
19
19
|
readonly description: string
|
|
20
20
|
readonly submit: string
|
|
21
21
|
readonly done: string
|
|
22
|
+
readonly name: string | undefined
|
|
22
23
|
readonly email: string | undefined
|
|
23
24
|
readonly password: string | undefined
|
|
24
25
|
}
|
|
@@ -29,6 +30,12 @@ const fieldOf = (input: FormData, name: string): string | undefined => {
|
|
|
29
30
|
return typeof value === "string" ? value : undefined
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** The full name of the submitted form: left blank, the door is sent none. */
|
|
34
|
+
const nameOf = (input: FormData): string | undefined => {
|
|
35
|
+
const value = fieldOf(input, "name")?.trim()
|
|
36
|
+
return value === undefined || value === "" ? undefined : value
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
/** The language a mail is written in: the page's, when the back end writes mail in it. */
|
|
33
40
|
const languageOf = (locale: string): "vi" | "en" | undefined => (locale === "vi" || locale === "en" ? locale : undefined)
|
|
34
41
|
|
|
@@ -38,11 +45,11 @@ export const EmailDoorForm = (props: EmailDoorFormProps) => {
|
|
|
38
45
|
const locale = useLocale()
|
|
39
46
|
const { failure, done, pending, submit, reject } = useEmailDoor(props.door)
|
|
40
47
|
const copy = {
|
|
41
|
-
signup: { title: t("signup.title"), description: t("signup.description"), submit: t("signup.submit"), done: t("signup.done"), email: t("email"), password: t("password") },
|
|
42
|
-
resend: { title: t("resend.title"), description: t("resend.description"), submit: t("resend.submit"), done: t("resend.done"), email: t("email"), password: undefined },
|
|
43
|
-
forgot: { title: t("forgot.title"), description: t("forgot.description"), submit: t("forgot.submit"), done: t("forgot.done"), email: t("email"), password: undefined },
|
|
44
|
-
confirm: { title: t("confirm.title"), description: t("confirm.description"), submit: t("confirm.submit"), done: t("confirm.done"), email: undefined, password: undefined },
|
|
45
|
-
reset: { title: t("reset.title"), description: t("reset.description"), submit: t("reset.submit"), done: t("reset.done"), email: undefined, password: t("newPassword") },
|
|
48
|
+
signup: { title: t("signup.title"), description: t("signup.description"), submit: t("signup.submit"), done: t("signup.done"), name: t("name"), email: t("email"), password: t("password") },
|
|
49
|
+
resend: { title: t("resend.title"), description: t("resend.description"), submit: t("resend.submit"), done: t("resend.done"), name: undefined, email: t("email"), password: undefined },
|
|
50
|
+
forgot: { title: t("forgot.title"), description: t("forgot.description"), submit: t("forgot.submit"), done: t("forgot.done"), name: undefined, email: t("email"), password: undefined },
|
|
51
|
+
confirm: { title: t("confirm.title"), description: t("confirm.description"), submit: t("confirm.submit"), done: t("confirm.done"), name: undefined, email: undefined, password: undefined },
|
|
52
|
+
reset: { title: t("reset.title"), description: t("reset.description"), submit: t("reset.submit"), done: t("reset.done"), name: undefined, email: undefined, password: t("newPassword") },
|
|
46
53
|
} satisfies Record<FormDoor, DoorCopy>
|
|
47
54
|
const links = {
|
|
48
55
|
signup: [{ href: APP_ROUTES.home, label: t("toSignIn") }, { href: APP_ROUTES.resend, label: t("toResend") }],
|
|
@@ -63,13 +70,14 @@ export const EmailDoorForm = (props: EmailDoorFormProps) => {
|
|
|
63
70
|
const email = fieldOf(input, "email")
|
|
64
71
|
const password = fieldOf(input, "password")
|
|
65
72
|
if ((chosen.email !== undefined && email === undefined) || (chosen.password !== undefined && password === undefined)) reject()
|
|
66
|
-
else submit({ email, password, token: props.token, language: languageOf(locale) })
|
|
73
|
+
else submit({ email, password, name: chosen.name === undefined ? undefined : nameOf(input), token: props.token, language: languageOf(locale) })
|
|
67
74
|
}
|
|
68
75
|
return (
|
|
69
76
|
<EmailDoorFormBase
|
|
70
77
|
props={{
|
|
71
78
|
title: chosen.title,
|
|
72
79
|
description: chosen.description,
|
|
80
|
+
nameLabel: chosen.name,
|
|
73
81
|
emailLabel: chosen.email,
|
|
74
82
|
passwordLabel: chosen.password,
|
|
75
83
|
submitLabel: chosen.submit,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/** Unit run of the {{pascal}} module package: the logic of its template and manifest is measured at 100 per file. */
|
|
2
|
-
module.exports = {
|
|
3
|
-
testEnvironment: "node",
|
|
4
|
-
transform: { "^.+\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json", isolatedModules: true }] },
|
|
5
|
-
testMatch: ["<rootDir>/src/**/*.spec.ts"],
|
|
6
|
-
collectCoverageFrom: ["src/manifest/**/*.ts", "src/template/**/*.ts", "!src/**/*.spec.ts", "!src/**/index.ts"],
|
|
7
|
-
coverageThreshold: { "./src/manifest/**/*.ts": { lines: 100, branches: 100, functions: 100, statements: 100 }, "./src/template/**/*.ts": { lines: 100, branches: 100, functions: 100, statements: 100 } },
|
|
8
|
-
}
|
/package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql}
RENAMED
|
File without changes
|