@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/main.mjs +6 -1
  6. package/codemod/seeds-layout.d.mts +36 -0
  7. package/codemod/seeds-layout.mjs +113 -0
  8. package/codemod/sops-rules.d.mts +1 -1
  9. package/codemod/sops-rules.mjs +8 -3
  10. package/lint/run.d.mts +5 -4
  11. package/lint/run.mjs +53 -27
  12. package/package.json +1 -1
  13. package/release/lint-report.mjs +4 -3
  14. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  15. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  16. package/runtime/knowledge/hfs/rules.yaml +15 -2
  17. package/runtime/knowledge/hfs/slots.yaml +15 -18
  18. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  19. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  20. package/runtime/scripts/hfs/check.mjs +4 -1
  21. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  22. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  23. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  25. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  26. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  27. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  28. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  29. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  30. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  31. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  32. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  33. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  34. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  35. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  36. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  37. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  38. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  39. package/runtime/scripts/hfs/slots.d.mts +2 -0
  40. package/runtime/scripts/hfs/slots.mjs +1 -0
  41. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  42. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  43. package/runtime/scripts/hfs/view.d.mts +25 -0
  44. package/runtime/scripts/hfs/view.mjs +41 -0
  45. package/scaffold/add-app.mjs +2 -2
  46. package/scaffold/add-cli-lite.mjs +41 -14
  47. package/scaffold/add-module.mjs +6 -3
  48. package/scaffold/lite-exports.mjs +1 -1
  49. package/scaffold/module-registry.mjs +10 -8
  50. package/src/main.mjs +8 -8
  51. package/stack/deploy.d.mts +3 -0
  52. package/stack/deploy.mjs +4 -1
  53. package/stack/local-up.mjs +25 -3
  54. package/stack/seeds.d.mts +29 -0
  55. package/stack/seeds.mjs +197 -0
  56. package/sync/canon-layout.d.mts +38 -0
  57. package/sync/canon-layout.mjs +62 -0
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +10 -7
  60. package/sync/index.d.mts +13 -2
  61. package/sync/index.mjs +52 -24
  62. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  63. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  64. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  65. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  66. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  67. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  68. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  69. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  70. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  71. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  72. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  73. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  74. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  76. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  77. package/templates/app/skeleton/README.md +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  79. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  80. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  81. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  82. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  83. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  84. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  85. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  86. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  87. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/README.md +2 -2
  92. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  94. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  95. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  96. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  97. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  98. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  99. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  100. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  101. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  102. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  103. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  104. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  105. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  106. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  107. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  108. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +59 -9
  109. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
  110. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  111. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  112. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  113. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  114. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  115. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  116. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  117. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  118. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  119. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  120. package/templates/be/tool-config/jest.config.js +1 -1
  121. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  122. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  123. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  124. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  125. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  126. package/templates/app/module/jest.config.js.tpl +0 -8
  127. /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, [{ execute: { raw: [{ hits: 3 }] } }, { execute: { raw: [{ hits: 4 }] } }])
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(IdentityAttemptEntity, { key: "login:abc", resetAt: LessThanOrEqual(NOW) }, { hits: 1, resetAt: RESET_AT })
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(IdentityAttemptEntity, { key, resetAt: LessThanOrEqual(now) }, { hits: 1, resetAt })
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.createQueryBuilder(IdentityAttemptEntity, "attempt").insert().values({ key, hits: 1, resetAt }).orIgnore().execute()
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(mockEntityManager({ insert: [[IdentitySessionEntity, {}], [IdentityRefreshTokenEntity, {}]] }))
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, { id: "s-1", userId: "u-1", createdAt: NOW, refreshedAt: NOW, userAgent: "agent", ip: "203.0.113.9" })
43
- expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null, racedCount: 0 })
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: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
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({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW, racedCount: 0 })
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, { where: { tokenHash: "h-1" }, lock: { mode: "pessimistic_write" } })
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, { userId: "u-1", expiresAt: LessThan(NOW) })
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(mockEntityManager({ findOne: [[IdentityRefreshTokenEntity, row(null)], [IdentityRefreshTokenEntity, null]] }))
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(mockEntityManager({ update: [IdentitySessionEntity, {}], delete: [IdentitySessionEntity, {}] }))
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, { where: { tokenHash }, lock: { mode: "pessimistic_write" } })
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 = { accessToken: "token", expiresIn: 900, expiresAt: "2026-02-03T04:20:06.000Z", user: { id: "u-1", email: "a@example.test" } }
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: [IdentitySignInService, { provide: IDENTITY_AUTH_DOORS, useValue: doors }, { provide: IDENTITY_AUTH_SESSIONS, useValue: sessions }],
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 [doors.login, doors.refresh, doors.session, doors.logout, doors.beginGoogle, doors.completeGoogle]) expect(door).toHaveBeenCalledWith(EXCHANGE)
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({ code: IdentityErrorCode.Unauthenticated })
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 { AccessGrant, AuthDoors, AuthSessions, GoogleRedirect, HttpExchange, JwksDocument } from "@modules/integrations/passport"
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` run. */
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 every seed file of a stack environment on the connection it names",
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` and logs what it ran. */
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 seeds folder of the environment under .starcistacks, relative to the app root", async () => {
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("dev")).toBe(join(".starcistacks", "dev", "seeds"))
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
- /** The seed directory of a stack environment, relative to the working directory of the run: the app root. */
31
- export const seedDirectoryOf = (env: string): string => join(".starcistacks", env, "seeds")
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
- file("primary-notes.sql", "INSERT INTO notes (body) VALUES ('a')"),
58
- file("legacy-users.sql", "INSERT INTO users DEFAULT VALUES"),
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).toHaveBeenCalledWith(seedDirectoryOf("staging"))
65
- expect(primary.query).toHaveBeenCalledWith("INSERT INTO notes (body) VALUES ('a')")
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 seed files of the environment (the dev seeds when none is named), runs them on their connections and logs the report. */
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
- const SEED_FILE = "db/seed.sql"
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 the tracked seed file verbatim.
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 one tracked seed through the shared least-privilege EntityManager. */
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
- /** Reads and executes the tracked seed when it contains at least one statement. */
24
- async run(): Promise<void> {
25
- const text = seedText(await readFile(SEED_FILE, "utf8"))
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
  }
@@ -1,3 +1,3 @@
1
1
  module.exports = require("@starci/jest-preset").starciJestConfig({
2
- coverage: {{jestCoverage}},
2
+ coverage: {{jestCoverage}},{{jestPackages}}
3
3
  })
@@ -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 (door: EmailDoorName, body: Readonly<Record<string, string | undefined>>): Promise<AuthFailure | undefined> => {
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
  }
@@ -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,
@@ -56,6 +56,7 @@
56
56
  "close": "Close"
57
57
  },
58
58
  "emailAuth": {
59
+ "name": "Full name (optional)",
59
60
  "email": "Email",
60
61
  "password": "Password",
61
62
  "newPassword": "New password",
@@ -56,6 +56,7 @@
56
56
  "close": "Đóng"
57
57
  },
58
58
  "emailAuth": {
59
+ "name": "Họ và tên (không bắt buộc)",
59
60
  "email": "Email",
60
61
  "password": "Mật khẩu",
61
62
  "newPassword": "Mật khẩu mới",
@@ -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
- }