@starci/hfs 5.2.0 → 5.4.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 (223) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +2 -1
  3. package/cache/hfs-family.d.mts +2 -3
  4. package/cache/hfs-family.mjs +4 -9
  5. package/cache/secret-records.d.mts +7 -0
  6. package/cache/secret-records.mjs +12 -0
  7. package/cache/session.mjs +4 -2
  8. package/codemod/account-doors.d.mts +36 -0
  9. package/codemod/account-doors.mjs +134 -0
  10. package/codemod/app-prettier.d.mts +5 -0
  11. package/codemod/app-prettier.mjs +27 -0
  12. package/codemod/explicit-surface.mjs +1 -25
  13. package/codemod/main.mjs +35 -1
  14. package/codemod/module-definition.d.mts +59 -0
  15. package/codemod/module-definition.mjs +257 -0
  16. package/codemod/quality-binding.d.mts +33 -0
  17. package/codemod/quality-binding.mjs +38 -0
  18. package/codemod/secrets-cache.d.mts +35 -0
  19. package/codemod/secrets-cache.mjs +70 -0
  20. package/codemod/sops-rules.d.mts +34 -0
  21. package/codemod/sops-rules.mjs +69 -0
  22. package/codemod/theme-tokens.mjs +48 -2
  23. package/lint/run.d.mts +6 -0
  24. package/lint/run.mjs +63 -10
  25. package/package.json +1 -1
  26. package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
  27. package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
  28. package/runtime/knowledge/hfs/rules.yaml +13 -9
  29. package/runtime/knowledge/hfs/slots.yaml +105 -12
  30. package/runtime/modules/kernel/failure-codes.yaml +5 -4
  31. package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
  32. package/runtime/scripts/hfs/architecture/config.mjs +25 -1
  33. package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
  35. package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
  36. package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
  37. package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
  38. package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
  39. package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
  40. package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
  41. package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
  43. package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
  44. package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
  45. package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
  46. package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
  47. package/runtime/scripts/hfs/secret-errors.mjs +8 -0
  48. package/runtime/scripts/hfs/secret-init.d.mts +6 -0
  49. package/runtime/scripts/hfs/secret-init.mjs +23 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +9 -1
  51. package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
  52. package/runtime/scripts/hfs/secret-standard.mjs +106 -0
  53. package/runtime/scripts/hfs/secret.mjs +5 -1
  54. package/runtime/scripts/hfs/view.d.mts +23 -0
  55. package/runtime/scripts/hfs/view.mjs +68 -0
  56. package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
  57. package/runtime/scripts/lib/declaration-surface.mjs +55 -1
  58. package/runtime/scripts/lib/secret-records.d.mts +13 -0
  59. package/runtime/scripts/lib/secret-records.mjs +83 -0
  60. package/scaffold/add-inbox-lite.mjs +2 -2
  61. package/scaffold/add-queue-lite.mjs +1 -3
  62. package/scaffold/app.d.mts +2 -2
  63. package/scaffold/app.mjs +10 -12
  64. package/scaffold/lite-exports.mjs +1 -15
  65. package/scripts/sync-bundles.d.mts +8 -0
  66. package/stack/images/openclaw-runtime/Dockerfile +18 -0
  67. package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
  68. package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
  69. package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
  70. package/stack/local-up.mjs +3 -0
  71. package/stack/runtime-layers.d.mts +21 -0
  72. package/stack/runtime-layers.mjs +67 -0
  73. package/sync/compile-guard.d.mts +36 -0
  74. package/sync/compile-guard.mjs +189 -0
  75. package/sync/hygiene.mjs +3 -1
  76. package/sync/index.d.mts +11 -1
  77. package/sync/index.mjs +61 -10
  78. package/templates/app/auth-doors.sql.partial +115 -0
  79. package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
  80. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
  81. package/templates/app/package-scripts/core.json +1 -1
  82. package/templates/app/package-scripts/package.json +1 -1
  83. package/templates/app/package-scripts-lite/package.json +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +9 -0
  85. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
  87. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
  88. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
  89. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
  90. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +27 -5
  91. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
  92. package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
  102. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
  107. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
  108. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
  109. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
  110. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
  111. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
  112. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
  113. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
  114. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
  115. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
  116. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
  117. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
  123. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
  124. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
  125. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
  127. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
  128. package/templates/be/inbox/module-definition.ts.tpl +2 -9
  129. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
  130. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  131. package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
  132. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
  133. package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
  134. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
  135. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  136. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
  137. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
  138. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
  139. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
  140. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
  141. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
  142. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
  143. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
  144. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
  145. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
  146. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
  147. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
  148. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
  149. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
  150. package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
  151. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
  152. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
  153. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
  154. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
  155. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
  156. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
  157. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
  158. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
  159. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
  162. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
  163. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
  164. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
  165. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
  166. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
  167. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
  168. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
  169. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
  170. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  171. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  172. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
  173. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
  174. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
  175. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
  176. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
  177. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
  185. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
  186. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  187. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
  188. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
  189. package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
  190. package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
  191. package/templates/be/table/module-definition.ts.tpl +2 -9
  192. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
  193. package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
  194. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
  195. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
  196. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
  197. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
  198. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
  199. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
  200. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
  201. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
  202. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
  203. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
  204. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
  205. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
  207. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
  208. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
  209. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
  210. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
  211. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
  212. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
  213. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
  214. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
  215. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
  216. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
  217. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
  218. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
  219. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
  220. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
  221. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
  222. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
  223. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
@@ -0,0 +1,233 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ import "reflect-metadata"
3
+ import { Test } from "@nestjs/testing"
4
+ import { FakeClock, fakeIds, fakeTransaction, mock, mockEntityManager } from "@starci/jest-preset"
5
+ import type { MockEntityManager } from "@starci/jest-preset"
6
+ import { MAILER } from "@modules/integrations/mail"
7
+ import type { Mailer, MailOptions } from "@modules/integrations/mail"
8
+ import { AuthError, digestOf } from "@modules/integrations/passport"
9
+ import type { AuthPasswords, SignInBudget } from "@modules/integrations/passport"
10
+ import { CLOCK } from "@modules/platform/clock"
11
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
12
+ import { IDS } from "@modules/platform/ids"
13
+ import { LOGGER } from "@modules/platform/logging"
14
+ import type { Logger } from "@modules/platform/logging"
15
+ import { MAIL_SETTINGS } from "@modules/integrations/mail"
16
+ import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
17
+ import { IDENTITY_AUTH_PASSWORDS, IDENTITY_SIGN_IN_BUDGET } from "./identity.decorators"
18
+ import { IdentityEmailService } from "./identity-email.service"
19
+ import { IdentityEmailTokenEntity } from "./persistence/entities"
20
+ import { IdentitySessionStoreService } from "./session-store.service"
21
+ import { accountByEmailRow as account } from "@tests/fixtures/builders/identity.builder"
22
+
23
+ /** The statements callDbFunction sends for the account doors the email doors use. */
24
+ const USER_BY_EMAIL = 'SELECT * FROM "public"."be_auth_user_by_email"($1)'
25
+ const CREATE_USER = 'SELECT * FROM "public"."be_auth_create_user"($1, $2, $3, $4, $5, $6)'
26
+ const CONFIRM_USER = 'SELECT * FROM "public"."be_auth_confirm_user"($1, $2)'
27
+ const SET_PASSWORD = 'SELECT * FROM "public"."be_auth_set_password"($1, $2, $3, $4)'
28
+
29
+ const NEW_ID = "00000000-0000-4000-8000-000000000001"
30
+ const AT = "2026-02-03T04:05:06.000Z"
31
+ const NOW = new Date(AT)
32
+ const SETTINGS = {
33
+ webOrigin: "https://app.example.test",
34
+ brandName: "Acme",
35
+ defaultLanguage: "en",
36
+ confirmTtlSeconds: 86_400,
37
+ resetTtlSeconds: 3_600,
38
+ } as unknown as MailOptions
39
+
40
+ const refusal = (code: string): unknown => expect.objectContaining({ code })
41
+ const tokenRow = (purpose: string): IdentityEmailTokenEntity => ({ id: "t-1", userId: "u-1", purpose, tokenHash: "h", expiresAt: new Date("2026-02-04T00:00:00.000Z"), usedAt: null, createdAt: NOW })
42
+
43
+ const build = async (entityManager: MockEntityManager) => {
44
+ const passwords = mock<AuthPasswords>()
45
+ passwords.hash.mockResolvedValue("new-hash")
46
+ const budget = mock<SignInBudget>()
47
+ budget.admit.mockResolvedValue(undefined)
48
+ const mailer = mock<Mailer>()
49
+ mailer.send.mockResolvedValue({ messageId: "<m>", accepted: ["a@example.test"] })
50
+ const sessions = mock<IdentitySessionStoreService>()
51
+ const logger = mock<Logger>()
52
+ const moduleRef = await Test.createTestingModule({
53
+ providers: [
54
+ IdentityEmailService,
55
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
56
+ { provide: IDENTITY_AUTH_PASSWORDS, useValue: passwords },
57
+ { provide: IDENTITY_SIGN_IN_BUDGET, useValue: budget },
58
+ { provide: MAILER, useValue: mailer },
59
+ { provide: MAIL_SETTINGS, useValue: mock<MailOptions>(SETTINGS) },
60
+ { provide: CLOCK, useValue: new FakeClock(AT) },
61
+ { provide: IDS, useValue: fakeIds() },
62
+ { provide: LOGGER, useValue: logger },
63
+ { provide: IdentitySessionStoreService, useValue: sessions },
64
+ ],
65
+ }).compile()
66
+ return { subject: moduleRef.get(IdentityEmailService), passwords, budget, mailer, sessions, logger }
67
+ }
68
+
69
+ describe("IdentityEmailService.signup", () => {
70
+ it("creates an unconfirmed account through the door, stores only the digest of the link token and mails the link", async () => {
71
+ const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
72
+ fakeTransaction(entityManager)
73
+ const { subject, budget, mailer } = await build(entityManager)
74
+
75
+ await subject.signup(" New@Example.test ", "correct horse")
76
+
77
+ expect(budget.admit).toHaveBeenCalledWith("signup", "new@example.test")
78
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [NEW_ID, "new@example.test", "new-hash", false, "{}", NOW])
79
+ const sent = mailer.send.mock.calls[0]?.[0]
80
+ const url = /https:\/\/[^\s"<]+/u.exec(sent?.message.text ?? "")?.[0] ?? ""
81
+ const token = new URL(url).searchParams.get("token") ?? ""
82
+ expect(new URL(url).pathname).toBe("/auth/confirm")
83
+ expect(token).toHaveLength(64)
84
+ expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID, purpose: "confirm", tokenHash: digestOf(token), usedAt: null }))
85
+ expect(sent?.to).toBe("new@example.test")
86
+ })
87
+
88
+ it("keeps the generated id when the create door answers no row", async () => {
89
+ const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, []]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
90
+ fakeTransaction(entityManager)
91
+ const { subject } = await build(entityManager)
92
+
93
+ await subject.signup("new@example.test", "correct horse")
94
+
95
+ expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID }))
96
+ })
97
+
98
+ it("refuses a password the policy rejects before it touches an account", async () => {
99
+ const { subject, mailer } = await build(mockEntityManager())
100
+
101
+ await expect(subject.signup("a@example.test", "short")).rejects.toEqual(refusal(IdentityErrorCode.PasswordInvalid))
102
+ expect(mailer.send).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it("mails a new link to an unconfirmed account and says nothing about a confirmed one", async () => {
106
+ const unconfirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
107
+ fakeTransaction(unconfirmed)
108
+ const confirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account()]] })
109
+ const first = await build(unconfirmed)
110
+ const second = await build(confirmed)
111
+
112
+ await first.subject.signup("a@example.test", "correct horse")
113
+ await second.subject.signup("a@example.test", "correct horse")
114
+
115
+ expect(first.mailer.send).toHaveBeenCalledTimes(1)
116
+ expect(second.mailer.send).not.toHaveBeenCalled()
117
+ expect(confirmed.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
118
+ })
119
+
120
+ it("answers as if the mail was sent when the mail server fails, and logs the failure", async () => {
121
+ const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
122
+ fakeTransaction(entityManager)
123
+ const { subject, mailer, logger } = await build(entityManager)
124
+ mailer.send.mockRejectedValue(new Error("down"))
125
+
126
+ await expect(subject.signup("a@example.test", "correct horse", "vi")).resolves.toBeUndefined()
127
+ expect(logger.error).toHaveBeenCalledWith("identity.email.mail_failed", expect.any(Error), { kind: "confirm" })
128
+ })
129
+
130
+ it("turns a spent budget into the too-many-attempts refusal", async () => {
131
+ const { subject, budget } = await build(mockEntityManager())
132
+ budget.admit.mockRejectedValue(new AuthError(IdentityErrorCode.TooManyAttempts))
133
+
134
+ await expect(subject.signup("a@example.test", "correct horse")).rejects.toEqual(refusal(IdentityErrorCode.TooManyAttempts))
135
+ budget.admit.mockRejectedValue(new Error("store down"))
136
+ await expect(subject.resend("a@example.test")).rejects.toThrow("store down")
137
+ })
138
+ })
139
+
140
+ describe("IdentityEmailService.resend", () => {
141
+ it("mails only an unconfirmed password account", async () => {
142
+ const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
143
+ fakeTransaction(entityManager)
144
+ const { subject, mailer } = await build(entityManager)
145
+ await subject.resend("a@example.test")
146
+ expect(mailer.send).toHaveBeenCalledTimes(1)
147
+
148
+ for (const rows of [[], [account()], [account("invited")]]) {
149
+ const other = await build(mockEntityManager({ query: [USER_BY_EMAIL, rows] }))
150
+ await other.subject.resend("a@example.test")
151
+ expect(other.mailer.send).not.toHaveBeenCalled()
152
+ }
153
+ })
154
+ })
155
+
156
+ describe("IdentityEmailService.confirm", () => {
157
+ it("spends a live link and stamps the confirmation through the door", async () => {
158
+ const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("confirm")], update: [IdentityEmailTokenEntity, {}], query: [CONFIRM_USER, []] })
159
+ fakeTransaction(entityManager)
160
+ const { subject } = await build(entityManager)
161
+
162
+ await subject.confirm("the-token-of-the-link")
163
+
164
+ expect(entityManager.findOne).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ lock: { mode: "pessimistic_write" } }))
165
+ expect(entityManager.query).toHaveBeenCalledWith(CONFIRM_USER, ["u-1", NOW])
166
+ })
167
+
168
+ it("refuses an unknown, spent or expired link with the reset-token code", async () => {
169
+ const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, null] })
170
+ fakeTransaction(entityManager)
171
+ const { subject } = await build(entityManager)
172
+
173
+ await expect(subject.confirm("nothing")).rejects.toEqual(refusal(IdentityErrorCode.ResetTokenInvalid))
174
+ })
175
+ })
176
+
177
+ describe("IdentityEmailService.forgot", () => {
178
+ it("mails a reset link to a live account and to nobody else, with the same answer", async () => {
179
+ const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account()]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
180
+ fakeTransaction(entityManager)
181
+ const known = await build(entityManager)
182
+ await known.subject.forgot("a@example.test", "vi")
183
+ expect(known.budget.admit).toHaveBeenCalledWith("reset", "a@example.test")
184
+ expect(known.mailer.send.mock.calls[0]?.[0].message.text).toContain("https://app.example.test/auth/reset?token=")
185
+ await known.subject.forgot("a@example.test")
186
+ expect(known.mailer.send).toHaveBeenCalledTimes(2)
187
+
188
+ for (const rows of [[], [account("disabled")]]) {
189
+ const other = await build(mockEntityManager({ query: [USER_BY_EMAIL, rows] }))
190
+ await expect(other.subject.forgot("a@example.test")).resolves.toBeUndefined()
191
+ expect(other.mailer.send).not.toHaveBeenCalled()
192
+ }
193
+ })
194
+ })
195
+
196
+ describe("IdentityEmailService.reset", () => {
197
+ it("sets the new password and confirms the mailbox its link proved through the door, then ends every session of the person", async () => {
198
+ const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("reset")], update: [IdentityEmailTokenEntity, {}], query: [SET_PASSWORD, []] })
199
+ fakeTransaction(entityManager)
200
+ const { subject, sessions } = await build(entityManager)
201
+
202
+ await subject.reset("the-token-of-the-link", "a better password")
203
+
204
+ expect(entityManager.query).toHaveBeenCalledWith(SET_PASSWORD, ["u-1", "new-hash", true, NOW])
205
+ expect(sessions.closeOtherSessions).toHaveBeenCalledWith("u-1", "00000000-0000-0000-0000-000000000000")
206
+ })
207
+
208
+ it("refuses a weak password and a link that is not live", async () => {
209
+ const weak = await build(mockEntityManager())
210
+ await expect(weak.subject.reset("t", "short")).rejects.toEqual(refusal(IdentityErrorCode.PasswordInvalid))
211
+
212
+ const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, null] })
213
+ fakeTransaction(entityManager)
214
+ const dead = await build(entityManager)
215
+ await expect(dead.subject.reset("t", "a better password")).rejects.toBeInstanceOf(IdentityError)
216
+ })
217
+ })
218
+
219
+ describe("IdentityEmailService language", () => {
220
+ it("writes the mail in the language the request names, and in the default one when it names none the mails are written in", async () => {
221
+ const subjectFor = async (language?: string): Promise<string | undefined> => {
222
+ const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
223
+ fakeTransaction(entityManager)
224
+ const { subject, mailer } = await build(entityManager)
225
+ await subject.signup("a@example.test", "correct horse", language)
226
+ return mailer.send.mock.calls[0]?.[0].message.subject
227
+ }
228
+ const english = await subjectFor("en")
229
+ expect(await subjectFor("vi")).not.toBe(english)
230
+ expect(await subjectFor("xx")).toBe(english)
231
+ expect(await subjectFor()).toBe(english)
232
+ })
233
+ })
@@ -0,0 +1,176 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ import { Injectable } from "@nestjs/common"
3
+ import { IsNull, MoreThan } from "typeorm"
4
+ import type { EntityManager } from "typeorm"
5
+ import { AuthError, digestOf, isAcceptablePassword, normalizeEmail } from "@modules/integrations/passport"
6
+ import type { AuthPasswords, SignInBudget } from "@modules/integrations/passport"
7
+ import { InjectMailSettings, InjectMailer } from "@modules/integrations/mail"
8
+ import type { MailLanguage, MailOptions, Mailer } from "@modules/integrations/mail"
9
+ import { InjectClock } from "@modules/platform/clock"
10
+ import type { Clock } from "@modules/platform/clock"
11
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
12
+ import { InjectIds } from "@modules/platform/ids"
13
+ import type { Ids } from "@modules/platform/ids"
14
+ import { InjectLogger } from "@modules/platform/logging"
15
+ import type { Logger } from "@modules/platform/logging"
16
+ import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
17
+ import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
18
+ import { IdentityEmailLogEvent } from "./identity-email.log-events"
19
+ import { confirmEmailMail, resetPasswordMail } from "./messages/identity-mail.messages"
20
+ import { accountDoors } from "./persistence/account-doors.port"
21
+ import { IdentityEmailTokenEntity } from "./persistence/entities"
22
+ import { IdentitySessionStoreService } from "./session-store.service"
23
+
24
+ const CONFIRM = "confirm"
25
+ const RESET = "reset"
26
+ const SECONDS_PER_HOUR = 3600
27
+ /** The session id no session has: closing every session "but" it closes them all. */
28
+ const NO_SESSION = "00000000-0000-0000-0000-000000000000"
29
+
30
+ type Purpose = typeof CONFIRM | typeof RESET
31
+
32
+ /** The languages the mails are written in. */
33
+ const LANGUAGES: readonly MailLanguage[] = ["vi", "en"]
34
+
35
+ @Injectable()
36
+ /**
37
+ * The email doors of sign-in: sign up, confirm the mailbox, resend the confirmation, forget and reset the password. A link is a
38
+ * random single-use token that expires and is kept only as its digest; every request counts against the address's budget and a
39
+ * request never says whether an address has an account.
40
+ */
41
+ export class IdentityEmailService {
42
+ constructor(
43
+ @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
44
+ @InjectAuthPasswords() private readonly passwords: AuthPasswords,
45
+ @InjectSignInBudget() private readonly budget: SignInBudget,
46
+ @InjectMailer() private readonly mailer: Mailer,
47
+ @InjectMailSettings() private readonly options: MailOptions,
48
+ @InjectClock() private readonly clock: Clock,
49
+ @InjectIds() private readonly ids: Ids,
50
+ @InjectLogger() private readonly logger: Logger,
51
+ private readonly sessions: IdentitySessionStoreService,
52
+ ) {}
53
+
54
+ /** Creates an unconfirmed password account and mails the confirmation link; an address that already has an account is answered the same way. */
55
+ async signup(email: string, password: string, language?: string): Promise<void> {
56
+ const address = normalizeEmail(email)
57
+ await this.admit("signup", address)
58
+ if (!isAcceptablePassword(password)) throw new IdentityError({ code: IdentityErrorCode.PasswordInvalid })
59
+ const known = await accountDoors.userByEmail(this.entityManager, address)
60
+ if (known !== undefined) {
61
+ if (known.email_confirmed_at === null) await this.sendConfirmation(known.id, address, language)
62
+ return
63
+ }
64
+ const now = this.clock.now()
65
+ const id = this.ids.next()
66
+ const hash = await this.passwords.hash(password)
67
+ const created = await accountDoors.createUser(this.entityManager, { id, email: address, hash, confirmed: false, meta: "{}", now })
68
+ await this.sendConfirmation(created ?? id, address, language)
69
+ }
70
+
71
+ /** Mails a new confirmation link to an unconfirmed account; any other address is answered the same way. */
72
+ async resend(email: string, language?: string): Promise<void> {
73
+ const address = normalizeEmail(email)
74
+ await this.admit("signup", address)
75
+ const user = await accountDoors.userByEmail(this.entityManager, address)
76
+ if (user === undefined || user.email_confirmed_at !== null || user.encrypted_password === null) return
77
+ await this.sendConfirmation(user.id, address, language)
78
+ }
79
+
80
+ /** Spends a confirmation link: the mailbox becomes confirmed. */
81
+ async confirm(token: string): Promise<void> {
82
+ const now = this.clock.now()
83
+ await this.entityManager.transaction(async (scoped) => {
84
+ const row = await this.spendable(scoped, token, CONFIRM, now)
85
+ await scoped.update(IdentityEmailTokenEntity, { userId: row.userId, purpose: CONFIRM, usedAt: IsNull() }, { usedAt: now })
86
+ await accountDoors.confirmUser(scoped, row.userId, now)
87
+ })
88
+ }
89
+
90
+ /** Mails a reset link when the address has a live password account; the answer never says whether it does. */
91
+ async forgot(email: string, language?: string): Promise<void> {
92
+ const address = normalizeEmail(email)
93
+ await this.admit("reset", address)
94
+ const user = await accountDoors.userByEmail(this.entityManager, address)
95
+ if (user === undefined || user.disabled) return
96
+ const token = await this.mint(user.id, RESET, this.options.resetTtlSeconds)
97
+ const url = `${this.options.webOrigin}/auth/reset?token=${encodeURIComponent(token)}`
98
+ const mail = resetPasswordMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.resetTtlSeconds) })
99
+ await this.deliver(address, mail, RESET)
100
+ }
101
+
102
+ /** Spends a reset link: sets the new password, confirms the mailbox (the link proved it) and ends every session of the person. */
103
+ async reset(token: string, password: string): Promise<void> {
104
+ if (!isAcceptablePassword(password)) throw new IdentityError({ code: IdentityErrorCode.PasswordInvalid })
105
+ const now = this.clock.now()
106
+ const hash = await this.passwords.hash(password)
107
+ const userId = await this.entityManager.transaction(async (scoped) => {
108
+ const row = await this.spendable(scoped, token, RESET, now)
109
+ await scoped.update(IdentityEmailTokenEntity, { userId: row.userId, usedAt: IsNull() }, { usedAt: now })
110
+ await accountDoors.setPassword(scoped, row.userId, hash, true, now)
111
+ return row.userId
112
+ })
113
+ await this.sessions.closeOtherSessions(userId, NO_SESSION)
114
+ }
115
+
116
+ private async admit(kind: "signup" | "reset", address: string): Promise<void> {
117
+ try {
118
+ await this.budget.admit(kind, address)
119
+ } catch (cause) {
120
+ if (cause instanceof AuthError) throw new IdentityError({ code: IdentityErrorCode.TooManyAttempts, cause })
121
+ throw cause
122
+ }
123
+ }
124
+
125
+ private async spendable(scoped: EntityManager, token: string, purpose: Purpose, now: Date): Promise<IdentityEmailTokenEntity> {
126
+ const row = await scoped.findOne(IdentityEmailTokenEntity, {
127
+ where: { tokenHash: digestOf(token), purpose, usedAt: IsNull(), expiresAt: MoreThan(now) },
128
+ lock: { mode: "pessimistic_write" },
129
+ })
130
+ if (row === null) throw new IdentityError({ code: IdentityErrorCode.ResetTokenInvalid })
131
+ return row
132
+ }
133
+
134
+ /** The language a request named when it is one the mails are written in, else the default of the settings. */
135
+ private languageOf(language: string | undefined): MailLanguage {
136
+ return LANGUAGES.find((candidate) => candidate === language) ?? this.options.defaultLanguage
137
+ }
138
+
139
+ private async sendConfirmation(userId: string, address: string, language: string | undefined): Promise<void> {
140
+ const token = await this.mint(userId, CONFIRM, this.options.confirmTtlSeconds)
141
+ const url = `${this.options.webOrigin}/auth/confirm?token=${encodeURIComponent(token)}`
142
+ const mail = confirmEmailMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.confirmTtlSeconds) })
143
+ await this.deliver(address, mail, CONFIRM)
144
+ }
145
+
146
+ /** Stores a fresh token (older live ones of the same purpose stop working) and answers the plain token for the one link that carries it. */
147
+ private async mint(userId: string, purpose: Purpose, ttlSeconds: number): Promise<string> {
148
+ const now = this.clock.now()
149
+ const token = `${this.ids.next()}${this.ids.next()}`.replaceAll("-", "")
150
+ await this.entityManager.transaction(async (scoped) => {
151
+ await scoped.update(IdentityEmailTokenEntity, { userId, purpose, usedAt: IsNull() }, { usedAt: now })
152
+ await scoped.insert(IdentityEmailTokenEntity, {
153
+ id: this.ids.next(),
154
+ userId,
155
+ purpose,
156
+ tokenHash: digestOf(token),
157
+ expiresAt: new Date(now.getTime() + ttlSeconds * 1000),
158
+ usedAt: null,
159
+ createdAt: now,
160
+ })
161
+ })
162
+ return token
163
+ }
164
+
165
+ private async deliver(to: string, message: Parameters<Mailer["send"]>[0]["message"], kind: Purpose): Promise<void> {
166
+ try {
167
+ await this.mailer.send({ to, message })
168
+ } catch (cause) {
169
+ this.logger.error(IdentityEmailLogEvent.MailFailed, cause, { kind })
170
+ }
171
+ }
172
+
173
+ private hoursOf(seconds: number): number {
174
+ return Math.max(1, Math.ceil(seconds / SECONDS_PER_HOUR))
175
+ }
176
+ }
@@ -0,0 +1,6 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ /** Log events of the sign-in decisions of identity; a line names an account by id, never by address, password or token. */
3
+ export enum IdentitySignInLogEvent {
4
+ /** A first Google sign-in created an account. */
5
+ AccountCreated = "identity.sign_in.account_created",
6
+ }
@@ -0,0 +1,98 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ import type { EntityManager } from "typeorm"
3
+ import { callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
4
+
5
+ /** The account an address stands for, with the stored hash the password is checked against. */
6
+ export interface AccountByEmail {
7
+ /** The account id. */
8
+ readonly id: string
9
+ /** The sign-in address. */
10
+ readonly email: string
11
+ /** The stored bcrypt hash, or null for an account that signs in with Google only. */
12
+ readonly encrypted_password: string | null
13
+ /** When the mailbox was confirmed, or null. */
14
+ readonly email_confirmed_at: Date | null
15
+ /** True when the account is deleted or banned at the database's now(). */
16
+ readonly disabled: boolean
17
+ }
18
+
19
+ /** The standing of an account found by id. */
20
+ export interface AccountById {
21
+ /** The account id. */
22
+ readonly id: string
23
+ /** The sign-in address, or null. */
24
+ readonly email: string | null
25
+ /** True when the account is deleted or banned at the database's now(). */
26
+ readonly disabled: boolean
27
+ }
28
+
29
+ /** An account to create: the id, the address, the profile keys as JSON text and the instant. */
30
+ export interface NewAccount {
31
+ /** The account id. */
32
+ readonly id: string
33
+ /** The sign-in address, lower-cased. */
34
+ readonly email: string
35
+ /** The bcrypt hash, or null for an account that signs in with Google only. */
36
+ readonly hash: string | null
37
+ /** Whether the mailbox counts as confirmed. */
38
+ readonly confirmed: boolean
39
+ /** The profile keys as JSON text. */
40
+ readonly meta: string
41
+ /** The instant of creation. */
42
+ readonly now: Date
43
+ }
44
+
45
+ const isAccountByEmail: (value: unknown) => value is AccountByEmail = rowShape({
46
+ id: "string",
47
+ email: "string",
48
+ encrypted_password: "string?",
49
+ email_confirmed_at: "date?",
50
+ disabled: "boolean",
51
+ })
52
+ const isAccountById: (value: unknown) => value is AccountById = rowShape({ id: "string", email: "string?", disabled: "boolean" })
53
+ const isLinkedAccount = rowShape({ be_auth_user_by_google: "string?" })
54
+ const isCreatedAccount = rowShape({ be_auth_create_user: "string" })
55
+
56
+ /**
57
+ * The account doors: the SECURITY DEFINER functions of the baseline migration through which the application role reads and writes
58
+ * auth.users and auth.identities. It holds no grant on schema auth, so no other path reaches an account.
59
+ */
60
+ export const accountDoors = {
61
+ /** The account of an exact address, or undefined. */
62
+ async userByEmail(manager: EntityManager, email: string): Promise<AccountByEmail | undefined> {
63
+ const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_email", [email]), isAccountByEmail)
64
+ return row
65
+ },
66
+ /** The standing of an account by id, or undefined. */
67
+ async userById(manager: EntityManager, userId: string): Promise<AccountById | undefined> {
68
+ const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_id", [userId]), isAccountById)
69
+ return row
70
+ },
71
+ /** The account a Google subject is linked to, or null. */
72
+ async userByGoogle(manager: EntityManager, subject: string): Promise<string | null> {
73
+ const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_google", [subject]), isLinkedAccount)
74
+ return row?.be_auth_user_by_google ?? null
75
+ },
76
+ /** Creates an account and answers its id. */
77
+ async createUser(manager: EntityManager, account: NewAccount): Promise<string | undefined> {
78
+ const args = [account.id, account.email, account.hash, account.confirmed, account.meta, account.now]
79
+ const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_create_user", args), isCreatedAccount)
80
+ return row?.be_auth_create_user
81
+ },
82
+ /** Links a Google subject to an account, or refreshes the link. */
83
+ async linkGoogle(manager: EntityManager, userId: string, subject: string, data: string, now: Date): Promise<void> {
84
+ await callDbFunction(manager, "public.be_auth_link_google", [userId, subject, data, now])
85
+ },
86
+ /** Stamps the confirmation of a mailbox (a spent confirmation link) without recording a sign-in. */
87
+ async confirmUser(manager: EntityManager, userId: string, now: Date): Promise<void> {
88
+ await callDbFunction(manager, "public.be_auth_confirm_user", [userId, now])
89
+ },
90
+ /** 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> {
92
+ await callDbFunction(manager, "public.be_auth_set_password", [userId, hash, confirm, now])
93
+ },
94
+ /** Records a sign-in, confirms the mailbox when asked, and fills the profile keys the person has not set. */
95
+ async touchUser(manager: EntityManager, userId: string, now: Date, confirm: boolean, meta: string): Promise<void> {
96
+ await callDbFunction(manager, "public.be_auth_touch_user", [userId, now, confirm, meta])
97
+ },
98
+ }
@@ -0,0 +1,34 @@
1
+ // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
+ import { Column, Entity, PrimaryColumn } from "typeorm"
3
+
4
+ @Entity({ schema: "public", name: "identity_email_tokens" })
5
+ /** One single-use link token that confirms a mailbox or resets a password, mapped from public.identity_email_tokens; only its digest is kept. */
6
+ export class IdentityEmailTokenEntity {
7
+ /** The token row id. */
8
+ @PrimaryColumn({ name: "id", type: "uuid" })
9
+ id!: string
10
+
11
+ /** The account the link is for. */
12
+ @Column({ name: "user_id", type: "uuid" })
13
+ userId!: string
14
+
15
+ /** What the link does: `confirm` or `reset`. */
16
+ @Column({ name: "purpose", type: "varchar", length: 16 })
17
+ purpose!: string
18
+
19
+ /** The SHA-256 digest of the token in the link; the token itself is never stored. */
20
+ @Column({ name: "token_hash", type: "varchar", length: 64 })
21
+ tokenHash!: string
22
+
23
+ /** When the link stops working. */
24
+ @Column({ name: "expires_at", type: "timestamptz" })
25
+ expiresAt!: Date
26
+
27
+ /** When the link was spent, or null while it is live. */
28
+ @Column({ name: "used_at", type: "timestamptz", nullable: true })
29
+ usedAt!: Date | null
30
+
31
+ /** When the link was issued. */
32
+ @Column({ name: "created_at", type: "timestamptz" })
33
+ createdAt!: Date
34
+ }
@@ -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
  }
@@ -1,17 +1,10 @@
1
1
  // Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
2
- import { AuthIdentityEntity } from "./auth-identity.entity"
3
- import { AuthUserEntity } from "./auth-user.entity"
4
2
  import { IdentityAttemptEntity } from "./identity-attempt.entity"
3
+ import { IdentityEmailTokenEntity } from "./identity-email-token.entity"
5
4
  import { IdentityRefreshTokenEntity } from "./identity-refresh-token.entity"
6
5
  import { IdentitySessionEntity } from "./identity-session.entity"
7
6
 
8
- export { AuthIdentityEntity, AuthUserEntity, IdentityAttemptEntity, IdentityRefreshTokenEntity, IdentitySessionEntity }
7
+ export { IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity }
9
8
 
10
9
  /** The entities of the identity capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
11
- export const identityEntities: ReadonlyArray<new () => object> = [
12
- AuthIdentityEntity,
13
- AuthUserEntity,
14
- IdentityAttemptEntity,
15
- IdentityRefreshTokenEntity,
16
- IdentitySessionEntity,
17
- ]
10
+ export const identityEntities: ReadonlyArray<new () => object> = [IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity]
@@ -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()) => {
@@ -39,11 +40,11 @@ describe("IdentitySessionStoreService.transaction", () => {
39
40
  })
40
41
 
41
42
  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 })
43
+ expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null, racedCount: 0 })
43
44
  expect(transaction.commits).toBe(1)
44
45
  })
45
46
 
46
- it("reads the token for update, marks it used and drops the expired ones", async () => {
47
+ it("reads the token for update, marks it used or raced and drops the expired ones", async () => {
47
48
  const { subject, entityManager } = await build(
48
49
  mockEntityManager({
49
50
  findOne: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
@@ -56,13 +57,15 @@ describe("IdentitySessionStoreService.transaction", () => {
56
57
  const missing = await subject.transaction((tx) => tx.findTokenForUpdate("h-2"))
57
58
  await subject.transaction(async (tx) => {
58
59
  await tx.markTokenUsed("t-1", NOW)
60
+ await tx.markTokenRaced("t-1", 2)
59
61
  await tx.deleteExpiredTokens("u-1", NOW)
60
62
  })
61
63
 
62
- expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW })
64
+ expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW, racedCount: 0 })
63
65
  expect(missing).toBeNull()
64
66
  expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { where: { tokenHash: "h-1" }, lock: { mode: "pessimistic_write" } })
65
67
  expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
68
+ expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { racedCount: 2 })
66
69
  expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { userId: "u-1", expiresAt: LessThan(NOW) })
67
70
  })
68
71
 
@@ -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,6 +35,7 @@ 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) => {
@@ -43,6 +45,9 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
43
45
  markTokenUsed: async (tokenId, at) => {
44
46
  await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { usedAt: at })
45
47
  },
48
+ markTokenRaced: async (tokenId, racedCount) => {
49
+ await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { racedCount })
50
+ },
46
51
  deleteExpiredTokens: async (userId, before) => {
47
52
  await scoped.delete(IdentityRefreshTokenEntity, { userId, expiresAt: LessThan(before) })
48
53
  },
@@ -69,7 +74,7 @@ export class IdentitySessionStoreService implements SessionStore {
69
74
  await this.entityManager.update(IdentitySessionEntity, { id: sessionId }, { refreshedAt: now })
70
75
  }
71
76
 
72
- /** Ends one session; its tokens go with it (the foreign key cascades). */
77
+ /** Ends one session and revokes every token of it (the foreign key cascades). */
73
78
  async closeSession(sessionId: string): Promise<void> {
74
79
  await this.entityManager.delete(IdentitySessionEntity, { id: sessionId })
75
80
  }