@starci/hfs 5.1.0 → 5.3.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 (365) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +11 -2
  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 +128 -0
  10. package/codemod/main.mjs +31 -1
  11. package/codemod/module-definition.d.mts +52 -0
  12. package/codemod/module-definition.mjs +242 -0
  13. package/codemod/quality-binding.d.mts +33 -0
  14. package/codemod/quality-binding.mjs +38 -0
  15. package/codemod/secrets-cache.d.mts +35 -0
  16. package/codemod/secrets-cache.mjs +70 -0
  17. package/codemod/sops-rules.d.mts +34 -0
  18. package/codemod/sops-rules.mjs +69 -0
  19. package/codemod/theme-tokens.mjs +48 -2
  20. package/db/postgres.mjs +9 -1
  21. package/lint/run.d.mts +6 -0
  22. package/lint/run.mjs +63 -10
  23. package/package.json +1 -1
  24. package/runtime/knowledge/hfs/canon-pins.yaml +65 -11
  25. package/runtime/knowledge/hfs/fe-stack.yaml +9 -9
  26. package/runtime/knowledge/hfs/fork-images.yaml +7 -0
  27. package/runtime/knowledge/hfs/rules.yaml +67 -15
  28. package/runtime/knowledge/hfs/slots.yaml +174 -30
  29. package/runtime/modules/kernel/failure-codes.yaml +67 -4
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
  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/identity.mjs +18 -83
  34. package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
  35. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
  36. package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
  37. package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
  38. package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
  39. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
  40. package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
  41. package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
  42. package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
  43. package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
  44. package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
  46. package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
  47. package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
  48. package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
  49. package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
  50. package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
  51. package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
  52. package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
  53. package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
  54. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
  55. package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
  56. package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
  57. package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
  58. package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
  59. package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
  60. package/runtime/scripts/hfs/secret-errors.mjs +8 -0
  61. package/runtime/scripts/hfs/secret-init.d.mts +6 -0
  62. package/runtime/scripts/hfs/secret-init.mjs +23 -0
  63. package/runtime/scripts/hfs/secret-sops.mjs +9 -1
  64. package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
  65. package/runtime/scripts/hfs/secret-standard.mjs +106 -0
  66. package/runtime/scripts/hfs/secret.mjs +15 -8
  67. package/runtime/scripts/hfs/slots.mjs +1 -1
  68. package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
  69. package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
  70. package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
  71. package/runtime/scripts/hfs/view.d.mts +23 -0
  72. package/runtime/scripts/hfs/view.mjs +68 -0
  73. package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
  74. package/runtime/scripts/lib/declaration-surface.mjs +55 -1
  75. package/runtime/scripts/lib/secret-records.d.mts +13 -0
  76. package/runtime/scripts/lib/secret-records.mjs +83 -0
  77. package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
  78. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  79. package/scaffold/add-inbox-lite.mjs +2 -2
  80. package/scaffold/add-module.d.mts +16 -0
  81. package/scaffold/add-module.mjs +232 -0
  82. package/scaffold/add-queue-lite.mjs +50 -3
  83. package/scaffold/add.mjs +5 -0
  84. package/scaffold/app.d.mts +2 -2
  85. package/scaffold/app.mjs +16 -18
  86. package/scaffold/lite-exports.mjs +1 -15
  87. package/scaffold/module-registry.d.mts +19 -0
  88. package/scaffold/module-registry.mjs +84 -0
  89. package/scripts/sync-bundles.d.mts +8 -0
  90. package/src/main.mjs +23 -2
  91. package/stack/cli.mjs +6 -1
  92. package/stack/context.d.mts +15 -0
  93. package/stack/context.mjs +1 -0
  94. package/stack/images/openclaw-runtime/Dockerfile +18 -0
  95. package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
  96. package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
  97. package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
  98. package/stack/local-cli.d.mts +25 -0
  99. package/stack/local-cli.mjs +89 -0
  100. package/stack/local-config.d.mts +17 -0
  101. package/stack/local-config.mjs +37 -0
  102. package/stack/local-custody.d.mts +24 -0
  103. package/stack/local-custody.mjs +109 -0
  104. package/stack/local-doctor.d.mts +6 -0
  105. package/stack/local-doctor.mjs +49 -0
  106. package/stack/local-down.d.mts +8 -0
  107. package/stack/local-down.mjs +20 -0
  108. package/stack/local-host.d.mts +42 -0
  109. package/stack/local-host.mjs +163 -0
  110. package/stack/local-logs.d.mts +8 -0
  111. package/stack/local-logs.mjs +32 -0
  112. package/stack/local-ports.d.mts +7 -0
  113. package/stack/local-ports.mjs +39 -0
  114. package/stack/local-prepare.d.mts +22 -0
  115. package/stack/local-prepare.mjs +62 -0
  116. package/stack/local-run.d.mts +71 -0
  117. package/stack/local-run.mjs +200 -0
  118. package/stack/local-state.d.mts +35 -0
  119. package/stack/local-state.mjs +29 -0
  120. package/stack/local-status.d.mts +6 -0
  121. package/stack/local-status.mjs +36 -0
  122. package/stack/local-up.d.mts +11 -0
  123. package/stack/local-up.mjs +169 -0
  124. package/stack/local-verify.d.mts +6 -0
  125. package/stack/local-verify.mjs +21 -0
  126. package/stack/runtime-layers.d.mts +21 -0
  127. package/stack/runtime-layers.mjs +67 -0
  128. package/sync/hygiene.mjs +3 -1
  129. package/sync/index.d.mts +15 -12
  130. package/sync/index.mjs +114 -28
  131. package/templates/app/auth-doors.sql.partial +115 -0
  132. package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
  133. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
  134. package/templates/app/module/jest.config.js.tpl +8 -0
  135. package/templates/app/module/src/index.ts.tpl +2 -0
  136. package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
  137. package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
  138. package/templates/app/module/src/manifest/index.ts.tpl +1 -0
  139. package/templates/app/module/src/public/index.ts.tpl +2 -0
  140. package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
  141. package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
  142. package/templates/app/module/src/template/index.ts.tpl +1 -0
  143. package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
  144. package/templates/app/module/src/workbench/index.ts.tpl +2 -0
  145. package/templates/app/package-scripts/core.json +1 -1
  146. package/templates/app/package-scripts/package.json +1 -1
  147. package/templates/app/package-scripts-lite/package.json +1 -1
  148. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +85 -70
  149. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +24 -12
  150. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +43 -0
  151. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +21 -26
  152. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +6 -0
  153. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +6 -1
  154. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +76 -13
  155. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +44 -0
  156. package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
  157. package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
  158. package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
  159. package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
  160. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
  161. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
  162. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
  163. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
  164. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
  165. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
  166. package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
  167. package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
  168. package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
  169. package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
  170. package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
  171. package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
  172. package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
  173. package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
  174. package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
  175. package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
  176. package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
  177. package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
  178. package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
  179. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
  180. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +6 -0
  181. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +21 -0
  182. package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
  183. package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
  184. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +6 -0
  185. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
  186. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
  187. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +145 -0
  188. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
  189. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
  190. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
  191. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
  192. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
  193. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
  194. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
  195. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
  196. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +19 -0
  197. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +209 -0
  198. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +84 -0
  199. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
  200. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
  201. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
  202. package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
  203. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
  204. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
  205. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
  206. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
  207. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
  208. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +10 -0
  209. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
  210. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
  211. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
  212. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
  213. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +95 -0
  214. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +93 -0
  215. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +40 -0
  216. package/templates/be/inbox/module-definition.ts.tpl +2 -9
  217. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
  218. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  219. package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
  220. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
  221. package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
  222. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
  223. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  224. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
  225. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
  226. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
  227. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
  228. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
  229. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
  230. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
  231. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
  232. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
  233. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
  234. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
  235. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
  236. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
  237. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
  238. package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
  239. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
  240. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
  241. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
  242. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
  243. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
  244. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
  245. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
  246. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
  247. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
  248. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
  249. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
  250. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
  251. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
  252. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
  253. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
  254. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
  255. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
  256. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
  257. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
  258. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  259. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  260. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
  261. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
  262. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +12 -5
  263. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +40 -0
  264. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
  265. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +50 -2
  266. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +3 -0
  267. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
  268. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +28 -0
  269. package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
  270. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
  271. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
  272. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
  273. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
  274. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
  275. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
  276. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
  277. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
  278. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +5 -0
  279. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
  280. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
  281. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
  282. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
  283. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
  284. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
  285. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
  286. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
  287. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
  288. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
  289. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
  290. package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
  291. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
  292. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  293. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  294. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
  295. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +22 -4
  296. package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
  297. package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
  298. package/templates/be/table/module-definition.ts.tpl +2 -9
  299. package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
  300. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
  301. package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
  302. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
  303. package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
  304. package/templates/fe/common/site-shell.tsx.partial +3 -3
  305. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
  306. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
  307. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
  308. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
  309. package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
  310. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
  311. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
  312. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
  313. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
  314. package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
  315. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
  316. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
  317. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
  318. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
  319. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
  320. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
  321. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
  322. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
  323. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
  324. package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
  325. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
  326. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
  327. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
  328. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
  329. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
  330. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
  331. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
  332. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
  333. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
  334. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
  335. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
  336. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
  337. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
  338. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
  339. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
  340. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
  341. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
  342. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
  343. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
  344. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
  345. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
  346. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
  347. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
  348. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
  349. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
  350. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
  351. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
  352. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
  353. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
  354. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
  355. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
  356. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
  357. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
  358. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
  359. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
  360. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
  361. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
  362. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
  363. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
  364. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
  365. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
@@ -0,0 +1,18 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsString, MaxLength, MinLength } from "class-validator"
3
+
4
+ /** The posted link token and new password. */
5
+ export class ResetRequest {
6
+ /** The token the link carries. */
7
+ @IsString()
8
+ @MinLength(16)
9
+ @MaxLength(256)
10
+ token!: string
11
+
12
+ /** The new password. */
13
+ @IsString()
14
+ @MinLength(1)
15
+ @MaxLength(128)
16
+ password!: string
17
+
18
+ }
@@ -0,0 +1,22 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from "class-validator"
3
+ import type { MailLanguage } from "@modules/integrations/mail"
4
+
5
+ /** The posted sign-up data. */
6
+ export class SignupRequest {
7
+ /** The sign-in address. */
8
+ @IsEmail()
9
+ @MaxLength(254)
10
+ email!: string
11
+
12
+ /** The password. */
13
+ @IsString()
14
+ @MinLength(1)
15
+ @MaxLength(128)
16
+ password!: string
17
+
18
+ /** The language of the mail; the app's default when left out. */
19
+ @IsOptional()
20
+ @IsIn(["vi", "en"])
21
+ language?: MailLanguage
22
+ }
@@ -0,0 +1,11 @@
1
+ // Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
2
+ import { IsString, MaxLength, MinLength } from "class-validator"
3
+
4
+ /** The posted link token. */
5
+ export class TokenRequest {
6
+ /** The token the link carries. */
7
+ @IsString()
8
+ @MinLength(16)
9
+ @MaxLength(256)
10
+ token!: string
11
+ }
@@ -0,0 +1,46 @@
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 { mockEntityManager } from "@starci/jest-preset"
5
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
6
+ import { accountByIdRow } from "@tests/fixtures/builders/identity.builder"
7
+ import { IdentityAccountDirectoryService } from "./account-directory.service"
8
+
9
+ const USER_BY_ID = 'SELECT * FROM "public"."be_auth_user_by_id"($1)'
10
+
11
+ const build = async (rows: unknown) => {
12
+ const entityManager = mockEntityManager({ query: [USER_BY_ID, rows] })
13
+ const moduleRef = await Test.createTestingModule({
14
+ providers: [IdentityAccountDirectoryService, { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager }],
15
+ }).compile()
16
+ return { directory: moduleRef.get(IdentityAccountDirectoryService), entityManager }
17
+ }
18
+
19
+ describe("IdentityAccountDirectoryService.find", () => {
20
+ it("answers the standing of a live account through the door", async () => {
21
+ const { directory, entityManager } = await build([accountByIdRow("live")])
22
+
23
+ expect(await directory.find("u-1")).toEqual({ id: "u-1", email: "a@example.test", disabled: false })
24
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_ID, ["u-1"])
25
+ })
26
+
27
+ it("answers a disabled account as disabled, and an account without an address with none", async () => {
28
+ const banned = await build([accountByIdRow("disabled")])
29
+ const addressless = await build([accountByIdRow("addressless")])
30
+
31
+ expect((await banned.directory.find("u-1"))?.disabled).toBe(true)
32
+ expect(await addressless.directory.find("u-1")).toEqual({ id: "u-1", email: null, disabled: false })
33
+ })
34
+
35
+ it("answers undefined for an account that does not exist", async () => {
36
+ const { directory } = await build([])
37
+
38
+ expect(await directory.find("u-2")).toBeUndefined()
39
+ })
40
+
41
+ it("refuses a door answer that is not the account shape", async () => {
42
+ const { directory } = await build([{ id: "u-1", email: "a@example.test", disabled: "no" }])
43
+
44
+ await expect(directory.find("u-1")).rejects.toMatchObject({ code: "DATABASE_RETURNING_ROW_REJECTED" })
45
+ })
46
+ })
@@ -0,0 +1,19 @@
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 type { EntityManager } from "typeorm"
4
+ import type { AccountDirectory, AccountStanding } from "@modules/integrations/passport"
5
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
6
+ import { accountDoors } from "./persistence/account-doors.port"
7
+
8
+ @Injectable()
9
+ /** The package's account directory over the account doors: whether an account still stands, for a refresh to go on. */
10
+ export class IdentityAccountDirectoryService implements AccountDirectory {
11
+ constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
12
+
13
+ /** The standing of one account (disabled is decided by the database clock), or undefined when it does not exist. */
14
+ async find(userId: string): Promise<AccountStanding | undefined> {
15
+ const account = await accountDoors.userById(this.entityManager, userId)
16
+ if (account === undefined) return undefined
17
+ return { id: account.id, email: account.email, disabled: account.disabled }
18
+ }
19
+ }
@@ -0,0 +1,209 @@
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 { PassportErrorCode } from "@modules/integrations/passport"
7
+ import type { AuthPasswords, GoogleProfile, SignInBudget } from "@modules/integrations/passport"
8
+ import { CLOCK } from "@modules/platform/clock"
9
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
10
+ import { IDS } from "@modules/platform/ids"
11
+ import { LOGGER } from "@modules/platform/logging"
12
+ import type { Logger } from "@modules/platform/logging"
13
+ import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
14
+ import { IDENTITY_AUTH_PASSWORDS, IDENTITY_SIGN_IN_BUDGET } from "./identity.decorators"
15
+ import { IdentitySignInLogEvent } from "./identity-sign-in.log-events"
16
+ import { accountByEmailRow, accountByIdRow } from "@tests/fixtures/builders/identity.builder"
17
+ import { IdentityAuthenticatorService } from "./authenticator.service"
18
+
19
+ /** The statements callDbFunction sends for the account doors. */
20
+ const USER_BY_EMAIL = 'SELECT * FROM "public"."be_auth_user_by_email"($1)'
21
+ const USER_BY_ID = 'SELECT * FROM "public"."be_auth_user_by_id"($1)'
22
+ const USER_BY_GOOGLE = 'SELECT * FROM "public"."be_auth_user_by_google"($1)'
23
+ const CREATE_USER = 'SELECT * FROM "public"."be_auth_create_user"($1, $2, $3, $4, $5, $6)'
24
+ const LINK_GOOGLE = 'SELECT * FROM "public"."be_auth_link_google"($1, $2, $3, $4)'
25
+ const TOUCH_USER = 'SELECT * FROM "public"."be_auth_touch_user"($1, $2, $3, $4)'
26
+
27
+ const NEW_ID = "00000000-0000-4000-8000-000000000001"
28
+ const AT = "2026-02-03T04:05:06.000Z"
29
+ const NOW = new Date(AT)
30
+
31
+ const profile = (patch: Partial<GoogleProfile> = {}): GoogleProfile => ({
32
+ subject: "g-1",
33
+ email: "A@Example.test",
34
+ emailVerified: true,
35
+ name: "A",
36
+ picture: null,
37
+ ...patch,
38
+ })
39
+
40
+ type Stubs = ReadonlyArray<readonly [string, unknown]>
41
+
42
+ const build = async (answers: Stubs, matches = true) => {
43
+ const entityManager: MockEntityManager = answers.length === 0 ? mockEntityManager() : mockEntityManager({ query: answers })
44
+ fakeTransaction(entityManager)
45
+ const passwords = mock<AuthPasswords>()
46
+ passwords.verify.mockResolvedValue(matches)
47
+ const budget = mock<SignInBudget>()
48
+ budget.counted.mockImplementation(async (_kind, _address, check) => (await check()).value)
49
+ const logger = mock<Logger>()
50
+ const moduleRef = await Test.createTestingModule({
51
+ providers: [
52
+ IdentityAuthenticatorService,
53
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
54
+ { provide: IDENTITY_AUTH_PASSWORDS, useValue: passwords },
55
+ { provide: IDENTITY_SIGN_IN_BUDGET, useValue: budget },
56
+ { provide: CLOCK, useValue: new FakeClock(AT) },
57
+ { provide: IDS, useValue: fakeIds() },
58
+ { provide: LOGGER, useValue: logger },
59
+ ],
60
+ }).compile()
61
+ return { subject: moduleRef.get(IdentityAuthenticatorService), entityManager, passwords, budget, logger }
62
+ }
63
+
64
+ const refusal = (code: string): unknown => expect.objectContaining({ code })
65
+
66
+ describe("IdentityAuthenticatorService.verifyPassword", () => {
67
+ it("answers the person of a right pair, normalising the address, and records the sign-in through the door", async () => {
68
+ const { subject, entityManager, passwords, budget } = await build([
69
+ [USER_BY_EMAIL, [accountByEmailRow()]],
70
+ [TOUCH_USER, []],
71
+ ])
72
+
73
+ expect(await subject.verifyPassword(" A@Example.test ", "correct horse")).toEqual({ userId: "u-1", email: "a@example.test" })
74
+
75
+ expect(budget.counted).toHaveBeenCalledWith("login", "a@example.test", expect.any(Function))
76
+ expect(passwords.verify).toHaveBeenCalledWith("correct horse", "hash")
77
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_EMAIL, ["a@example.test"])
78
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, false, "{}"])
79
+ })
80
+
81
+ it("refuses an unknown address and a wrong password with the same code, and still spends the check on a decoy", async () => {
82
+ const unknown = await build([[USER_BY_EMAIL, []]])
83
+ const wrong = await build([[USER_BY_EMAIL, [accountByEmailRow()]]], false)
84
+
85
+ await expect(unknown.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.PairRefused))
86
+ await expect(wrong.subject.verifyPassword("a@example.test", "pw")).rejects.toBeInstanceOf(IdentityError)
87
+ expect(unknown.passwords.verify).toHaveBeenCalledWith("pw", "")
88
+ expect(wrong.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
89
+ })
90
+
91
+ it("compares an empty password for one bcrypt cannot read, an account without a hash against the decoy", async () => {
92
+ const { subject, passwords } = await build([[USER_BY_EMAIL, [accountByEmailRow("passwordless")]]], false)
93
+
94
+ await expect(subject.verifyPassword("a@example.test", "x".repeat(100))).rejects.toBeInstanceOf(IdentityError)
95
+
96
+ expect(passwords.verify).toHaveBeenCalledWith("", "")
97
+ })
98
+
99
+ it("refuses a disabled account and an unconfirmed one after the password was right, recording no sign-in", async () => {
100
+ const disabled = await build([[USER_BY_EMAIL, [accountByEmailRow("disabled")]]])
101
+ const unconfirmed = await build([[USER_BY_EMAIL, [accountByEmailRow("unconfirmed")]]])
102
+
103
+ await expect(disabled.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
104
+ await expect(unconfirmed.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
105
+ expect(disabled.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
106
+ expect(unconfirmed.entityManager.query).not.toHaveBeenCalledWith(TOUCH_USER, expect.anything())
107
+ })
108
+ })
109
+
110
+ describe("IdentityAuthenticatorService.signInWithGoogle", () => {
111
+ it("refuses a profile without a verified address, and calls no door", async () => {
112
+ const { subject, entityManager } = await build([])
113
+
114
+ await expect(subject.signInWithGoogle(profile({ emailVerified: false }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
115
+ await expect(subject.signInWithGoogle(profile({ email: null }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
116
+ expect(entityManager.query).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it("signs in the account a Google subject is already linked to, confirming its mailbox", async () => {
120
+ const { subject, entityManager } = await build([
121
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
122
+ [LINK_GOOGLE, []],
123
+ [TOUCH_USER, []],
124
+ [USER_BY_ID, [accountByIdRow()]],
125
+ ])
126
+
127
+ expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
128
+
129
+ expect(entityManager.query).not.toHaveBeenCalledWith(USER_BY_EMAIL, expect.anything())
130
+ expect(entityManager.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
131
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, true, expect.stringContaining('"full_name":"A"')])
132
+ })
133
+
134
+ it("links an existing account by its verified address", async () => {
135
+ const { subject, entityManager } = await build([
136
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
137
+ [USER_BY_EMAIL, [accountByEmailRow()]],
138
+ [LINK_GOOGLE, []],
139
+ [TOUCH_USER, []],
140
+ [USER_BY_ID, [accountByIdRow()]],
141
+ ])
142
+
143
+ expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
144
+
145
+ expect(entityManager.query).toHaveBeenCalledWith(USER_BY_EMAIL, ["a@example.test"])
146
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, ["u-1", "g-1", expect.stringContaining('"sub":"g-1"'), NOW])
147
+ expect(entityManager.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
148
+ })
149
+
150
+ })
151
+
152
+ describe("IdentityAuthenticatorService.signInWithGoogle (new accounts and refusals)", () => {
153
+ it("creates the account of a new person, links their Google identity and logs the id alone", async () => {
154
+ const { subject, entityManager, logger } = await build([
155
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
156
+ [USER_BY_EMAIL, []],
157
+ [CREATE_USER, [{ be_auth_create_user: NEW_ID }]],
158
+ [LINK_GOOGLE, []],
159
+ [TOUCH_USER, []],
160
+ [USER_BY_ID, [accountByIdRow("live", NEW_ID)]],
161
+ ])
162
+
163
+ expect(await subject.signInWithGoogle(profile())).toEqual({ userId: NEW_ID, email: "a@example.test" })
164
+
165
+ expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [NEW_ID, "a@example.test", null, true, expect.stringContaining('"full_name":"A"'), NOW])
166
+ expect(entityManager.query).toHaveBeenCalledWith(LINK_GOOGLE, [NEW_ID, "g-1", expect.stringContaining("g-1"), NOW])
167
+ expect(logger.info).toHaveBeenCalledWith(IdentitySignInLogEvent.AccountCreated, { userId: NEW_ID })
168
+ })
169
+
170
+ it("fills only the profile keys the Google profile has", async () => {
171
+ const { subject, entityManager } = await build([
172
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
173
+ [LINK_GOOGLE, []],
174
+ [TOUCH_USER, []],
175
+ [USER_BY_ID, [accountByIdRow()]],
176
+ ])
177
+
178
+ await subject.signInWithGoogle(profile({ name: null, picture: "https://img.example.test/a.png" }))
179
+
180
+ const keys = { avatar_url: "https://img.example.test/a.png", picture: "https://img.example.test/a.png", email: "A@Example.test", email_verified: true }
181
+ expect(entityManager.query).toHaveBeenCalledWith(TOUCH_USER, ["u-1", NOW, true, JSON.stringify(keys)])
182
+ })
183
+
184
+ it("refuses when the create door answers no account", async () => {
185
+ const { subject, entityManager } = await build([
186
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: null }]],
187
+ [USER_BY_EMAIL, []],
188
+ [CREATE_USER, []],
189
+ ])
190
+
191
+ await expect(subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
192
+ expect(entityManager.query).not.toHaveBeenCalledWith(LINK_GOOGLE, expect.anything())
193
+ })
194
+
195
+ it("refuses a disabled account and an account that cannot be read back", async () => {
196
+ const linked: Stubs = [
197
+ [USER_BY_GOOGLE, [{ be_auth_user_by_google: "u-1" }]],
198
+ [LINK_GOOGLE, []],
199
+ [TOUCH_USER, []],
200
+ ]
201
+ const disabled = await build([...linked, [USER_BY_ID, [accountByIdRow("disabled")]]])
202
+ const gone = await build([...linked, [USER_BY_ID, []]])
203
+ const addressless = await build([...linked, [USER_BY_ID, [accountByIdRow("addressless")]]])
204
+
205
+ await expect(disabled.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
206
+ await expect(gone.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
207
+ await expect(addressless.subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
208
+ })
209
+ })
@@ -0,0 +1,84 @@
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 type { EntityManager } from "typeorm"
4
+ import { PassportError, PassportErrorCode, isPresentablePassword, normalizeEmail } from "@modules/integrations/passport"
5
+ import type { AuthAuthenticator, AuthPasswords, AuthSubject, GoogleProfile, SignInBudget } from "@modules/integrations/passport"
6
+ import { InjectClock } from "@modules/platform/clock"
7
+ import type { Clock } from "@modules/platform/clock"
8
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
9
+ import { InjectIds } from "@modules/platform/ids"
10
+ import type { Ids } from "@modules/platform/ids"
11
+ import { InjectLogger } from "@modules/platform/logging"
12
+ import type { Logger } from "@modules/platform/logging"
13
+ import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
14
+ import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
15
+ import { IdentitySignInLogEvent } from "./identity-sign-in.log-events"
16
+ import { accountDoors } from "./persistence/account-doors.port"
17
+
18
+ /** A Google profile whose email Google verified, so it has one. */
19
+ type VerifiedProfile = GoogleProfile & { readonly email: string }
20
+
21
+ /** The profile keys a Google sign-in may fill on the account; they never overwrite what the person already set. */
22
+ const profileKeysOf = (profile: VerifiedProfile): Record<string, string | boolean> => ({
23
+ ...(profile.name === null ? {} : { full_name: profile.name, name: profile.name }),
24
+ ...(profile.picture === null ? {} : { avatar_url: profile.picture, picture: profile.picture }),
25
+ email: profile.email,
26
+ email_verified: profile.emailVerified,
27
+ })
28
+
29
+ @Injectable()
30
+ /** The two sign-in decisions over the account doors: is this email and password right, and which account does this Google profile stand for. */
31
+ export class IdentityAuthenticatorService implements AuthAuthenticator {
32
+ constructor(
33
+ @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
34
+ @InjectAuthPasswords() private readonly passwords: AuthPasswords,
35
+ @InjectSignInBudget() private readonly budget: SignInBudget,
36
+ @InjectClock() private readonly clock: Clock,
37
+ @InjectIds() private readonly ids: Ids,
38
+ @InjectLogger() private readonly logger: Logger,
39
+ ) {}
40
+
41
+ /** Verifies the pair. An unknown address and a wrong password answer the same refusal; only wrong passwords count against the budget. */
42
+ async verifyPassword(email: string, password: string): Promise<AuthSubject> {
43
+ const address = normalizeEmail(email)
44
+ const account = await this.budget.counted("login", address, async () => {
45
+ const found = await accountDoors.userByEmail(this.entityManager, address)
46
+ const matches = await this.passwords.verify(isPresentablePassword(password) ? password : "", found?.encrypted_password ?? "")
47
+ return { passed: found !== undefined && matches, value: found !== undefined && matches ? found : undefined }
48
+ })
49
+ if (account === undefined) throw new IdentityError({ code: IdentityErrorCode.PairRefused })
50
+ if (account.disabled) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
51
+ if (account.email_confirmed_at === null) throw new IdentityError({ code: IdentityErrorCode.EmailNotConfirmed })
52
+ await accountDoors.touchUser(this.entityManager, account.id, this.clock.now(), false, "{}")
53
+ return { userId: account.id, email: account.email }
54
+ }
55
+
56
+ /** Finds the account a Google subject is linked to, else links by a verified email, else creates the account. */
57
+ async signInWithGoogle(profile: GoogleProfile): Promise<AuthSubject> {
58
+ if (profile.email === null || !profile.emailVerified) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
59
+ const address = normalizeEmail(profile.email)
60
+ const verified: VerifiedProfile = { ...profile, email: profile.email }
61
+ const now = this.clock.now()
62
+ const userId = await this.entityManager.transaction(async (scoped) => {
63
+ const linked = await accountDoors.userByGoogle(scoped, profile.subject)
64
+ const byEmail = linked === null ? await accountDoors.userByEmail(scoped, address) : undefined
65
+ const id = linked ?? byEmail?.id ?? (await this.createFor(scoped, address, verified, now))
66
+ const identityData = { sub: profile.subject, email: address, name: profile.name, picture: profile.picture, email_verified: true }
67
+ await accountDoors.linkGoogle(scoped, id, profile.subject, JSON.stringify(identityData), now)
68
+ await accountDoors.touchUser(scoped, id, now, true, JSON.stringify(profileKeysOf(verified)))
69
+ return id
70
+ })
71
+ const account = await accountDoors.userById(this.entityManager, userId)
72
+ if (account?.email === undefined || account.email === null) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
73
+ if (account.disabled) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
74
+ return { userId: account.id, email: account.email }
75
+ }
76
+
77
+ private async createFor(scoped: EntityManager, address: string, profile: VerifiedProfile, now: Date): Promise<string> {
78
+ const id = this.ids.next()
79
+ const created = await accountDoors.createUser(scoped, { id, email: address, hash: null, confirmed: true, meta: JSON.stringify(profileKeysOf(profile)), now })
80
+ if (created === undefined) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
81
+ this.logger.info(IdentitySignInLogEvent.AccountCreated, { userId: created })
82
+ return created
83
+ }
84
+ }
@@ -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 owned by the email doors of sign-in. */
3
+ export enum IdentityEmailLogEvent {
4
+ /** A confirmation or reset mail could not be sent; the door still answers as if it was, so an address is never confirmed or denied. */
5
+ MailFailed = "identity.email.mail_failed",
6
+ }