@starci/hfs 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/account-doors.mjs +7 -1
  6. package/codemod/app-prettier.d.mts +5 -0
  7. package/codemod/app-prettier.mjs +27 -0
  8. package/codemod/explicit-surface.mjs +1 -25
  9. package/codemod/main.mjs +14 -5
  10. package/codemod/module-definition.d.mts +10 -3
  11. package/codemod/module-definition.mjs +32 -17
  12. package/codemod/seeds-layout.d.mts +36 -0
  13. package/codemod/seeds-layout.mjs +113 -0
  14. package/codemod/sops-rules.d.mts +1 -1
  15. package/codemod/sops-rules.mjs +8 -3
  16. package/lint/run.d.mts +5 -4
  17. package/lint/run.mjs +53 -27
  18. package/package.json +1 -1
  19. package/release/lint-report.mjs +4 -3
  20. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  21. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  22. package/runtime/knowledge/hfs/rules.yaml +15 -2
  23. package/runtime/knowledge/hfs/slots.yaml +15 -18
  24. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  25. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  26. package/runtime/scripts/hfs/check.mjs +4 -1
  27. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  28. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  29. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  30. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  31. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  32. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  33. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  34. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  35. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  37. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  38. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  39. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  43. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  44. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  45. package/runtime/scripts/hfs/slots.d.mts +2 -0
  46. package/runtime/scripts/hfs/slots.mjs +1 -0
  47. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  48. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  49. package/runtime/scripts/hfs/view.d.mts +25 -0
  50. package/runtime/scripts/hfs/view.mjs +41 -0
  51. package/scaffold/add-app.mjs +2 -2
  52. package/scaffold/add-cli-lite.mjs +41 -14
  53. package/scaffold/add-module.mjs +6 -3
  54. package/scaffold/lite-exports.mjs +1 -1
  55. package/scaffold/module-registry.mjs +10 -8
  56. package/src/main.mjs +8 -8
  57. package/stack/deploy.d.mts +3 -0
  58. package/stack/deploy.mjs +4 -1
  59. package/stack/local-up.mjs +25 -3
  60. package/stack/seeds.d.mts +29 -0
  61. package/stack/seeds.mjs +197 -0
  62. package/sync/canon-layout.d.mts +38 -0
  63. package/sync/canon-layout.mjs +62 -0
  64. package/sync/ci-commands.d.mts +2 -1
  65. package/sync/ci-commands.mjs +10 -7
  66. package/sync/compile-guard.d.mts +36 -0
  67. package/sync/compile-guard.mjs +189 -0
  68. package/sync/index.d.mts +22 -3
  69. package/sync/index.mjs +80 -32
  70. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  71. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  72. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  73. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  74. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  76. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  77. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  78. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  79. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  80. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  81. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  82. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  83. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  84. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  85. package/templates/app/skeleton/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  87. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  92. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  93. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  94. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  95. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  96. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  97. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  98. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  99. package/templates/app/skeleton-lite/README.md +2 -2
  100. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
  101. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  108. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  110. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  113. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  116. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  117. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  118. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
  119. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
  120. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  121. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  122. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  123. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  124. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  125. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  126. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  127. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  128. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  129. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  130. package/templates/be/tool-config/jest.config.js +1 -1
  131. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  132. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  133. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  134. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  135. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  136. package/templates/app/module/jest.config.js.tpl +0 -8
  137. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -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({ id: "string", email: "string?", disabled: "boolean" })
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(await callDbFunction(manager, "public.be_auth_user_by_email", [email]), isAccountByEmail)
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(await callDbFunction(manager, "public.be_auth_user_by_google", [subject]), isLinkedAccount)
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(manager: EntityManager, userId: string, hash: string, confirm: boolean, now: Date): Promise<void> {
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> = [IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity]
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, [{ 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
  }
@@ -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(mockEntityManager({ insert: [[IdentitySessionEntity, {}], [IdentityRefreshTokenEntity, {}]] }))
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, { id: "s-1", userId: "u-1", createdAt: NOW, refreshedAt: NOW, userAgent: "agent", ip: "203.0.113.9" })
42
- expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null })
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
- it("reads the token for update, marks it used and drops the expired ones", async () => {
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: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
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({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW })
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, { 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
+ })
65
105
  expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
66
- expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { userId: "u-1", expiresAt: LessThan(NOW) })
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(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
+ )
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(mockEntityManager({ update: [IdentitySessionEntity, {}], delete: [IdentitySessionEntity, {}] }))
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, { where: { tokenHash }, lock: { mode: "pessimistic_write" } })
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; its tokens go with it (the foreign key cascades). */
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 = { 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>}