@stjoseph/prumo 0.3.0 → 0.4.1

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 (155) hide show
  1. package/.prumo-templates/api/auth.md +3 -2
  2. package/.prumo-templates/core/testing.md +4 -3
  3. package/.prumo-templates/email/codes.md +62 -0
  4. package/.prumo-templates/email/mailer.md +47 -0
  5. package/.prumo-templates/mcp/server.md +19 -5
  6. package/.prumo-templates/monorepo/tasks.md +1 -1
  7. package/.prumo-templates/social/sign-in.md +77 -0
  8. package/README.md +9 -7
  9. package/dist/asker.js +13 -0
  10. package/dist/cli.js +25 -2
  11. package/dist/commands.js +7 -0
  12. package/dist/compose.js +6 -3
  13. package/dist/context.js +4 -0
  14. package/dist/features.js +234 -0
  15. package/dist/generate.js +17 -3
  16. package/dist/next-steps.js +24 -0
  17. package/dist/questions.js +64 -1
  18. package/package.json +1 -1
  19. package/templates/api/.env.example +18 -0
  20. package/templates/api/README.md +19 -0
  21. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  22. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  23. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  24. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  25. package/templates/api/package.json +1 -0
  26. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  27. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  28. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  29. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  30. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  31. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  32. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  33. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  34. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  36. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  37. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  39. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  40. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  41. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  42. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  43. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  46. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  47. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  48. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  49. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  50. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  52. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  53. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  54. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  55. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  56. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  57. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  58. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  59. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  60. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  61. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  62. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  63. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  65. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  66. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  67. package/templates/api/src/infra/config/env.ts +18 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  73. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  74. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  77. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  78. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  79. package/templates/api/src/infra/di/email.di.ts +23 -0
  80. package/templates/api/src/infra/di/index.ts +4 -0
  81. package/templates/api/src/infra/di/social.di.ts +17 -0
  82. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  83. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  84. package/templates/api/src/infra/http/app.ts +4 -0
  85. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  89. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  90. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  91. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  92. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  93. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  94. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  95. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  96. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  97. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  98. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  99. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  100. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  101. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  102. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  103. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  104. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  105. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  106. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  107. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  108. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  109. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  110. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  111. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  112. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  114. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  115. package/templates/api/test/support/test-app.ts +65 -0
  116. package/templates/mobile/README.md +16 -3
  117. package/templates/mobile/package.json +3 -2
  118. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  119. package/templates/mobile/src/api-contract/auth.ts +63 -3
  120. package/templates/mobile/src/api-contract/index.ts +4 -0
  121. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  122. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  125. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  126. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  127. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  128. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  129. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  130. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  131. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  132. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  133. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  134. package/templates/web/README.md +5 -0
  135. package/templates/web/src/api-contract/api-error.ts +4 -0
  136. package/templates/web/src/api-contract/auth.ts +63 -3
  137. package/templates/web/src/api-contract/index.ts +4 -0
  138. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  139. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  141. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  142. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  143. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  144. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  145. package/templates/web/src/routeTree.gen.ts +73 -2
  146. package/templates/web/src/routes/forgot-password.tsx +17 -0
  147. package/templates/web/src/routes/reset-password.tsx +20 -0
  148. package/templates/web/src/routes/sign-in.tsx +10 -3
  149. package/templates/web/src/routes/sign-up.tsx +4 -0
  150. package/templates/web/src/routes/verify-email.tsx +20 -0
  151. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  152. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  153. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  154. package/templates/workspace/README.md +10 -0
  155. package/dist/mcp.js +0 -107
@@ -10,8 +10,11 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
13
+ import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
14
+ import { Route as ResetPasswordRouteImport } from './routes/reset-password'
13
15
  import { Route as SignInRouteImport } from './routes/sign-in'
14
16
  import { Route as SignUpRouteImport } from './routes/sign-up'
17
+ import { Route as VerifyEmailRouteImport } from './routes/verify-email'
15
18
  import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
16
19
  import { Route as AuthenticatedConsentRouteImport } from './routes/_authenticated/consent'
17
20
 
@@ -19,6 +22,16 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
19
22
  id: '/_authenticated',
20
23
  getParentRoute: () => rootRouteImport,
21
24
  } as any)
25
+ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
26
+ id: '/forgot-password',
27
+ path: '/forgot-password',
28
+ getParentRoute: () => rootRouteImport,
29
+ } as any)
30
+ const ResetPasswordRoute = ResetPasswordRouteImport.update({
31
+ id: '/reset-password',
32
+ path: '/reset-password',
33
+ getParentRoute: () => rootRouteImport,
34
+ } as any)
22
35
  const SignInRoute = SignInRouteImport.update({
23
36
  id: '/sign-in',
24
37
  path: '/sign-in',
@@ -29,6 +42,11 @@ const SignUpRoute = SignUpRouteImport.update({
29
42
  path: '/sign-up',
30
43
  getParentRoute: () => rootRouteImport,
31
44
  } as any)
45
+ const VerifyEmailRoute = VerifyEmailRouteImport.update({
46
+ id: '/verify-email',
47
+ path: '/verify-email',
48
+ getParentRoute: () => rootRouteImport,
49
+ } as any)
32
50
  const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
33
51
  id: '/',
34
52
  path: '/',
@@ -42,42 +60,71 @@ const AuthenticatedConsentRoute = AuthenticatedConsentRouteImport.update({
42
60
 
43
61
  export interface FileRoutesByFullPath {
44
62
  '/': typeof AuthenticatedIndexRoute
63
+ '/forgot-password': typeof ForgotPasswordRoute
64
+ '/reset-password': typeof ResetPasswordRoute
45
65
  '/sign-in': typeof SignInRoute
46
66
  '/sign-up': typeof SignUpRoute
67
+ '/verify-email': typeof VerifyEmailRoute
47
68
  '/consent': typeof AuthenticatedConsentRoute
48
69
  }
49
70
  export interface FileRoutesByTo {
71
+ '/forgot-password': typeof ForgotPasswordRoute
72
+ '/reset-password': typeof ResetPasswordRoute
50
73
  '/sign-in': typeof SignInRoute
51
74
  '/sign-up': typeof SignUpRoute
75
+ '/verify-email': typeof VerifyEmailRoute
52
76
  '/consent': typeof AuthenticatedConsentRoute
53
77
  '/': typeof AuthenticatedIndexRoute
54
78
  }
55
79
  export interface FileRoutesById {
56
80
  __root__: typeof rootRouteImport
57
81
  '/_authenticated': typeof AuthenticatedRouteWithChildren
82
+ '/forgot-password': typeof ForgotPasswordRoute
83
+ '/reset-password': typeof ResetPasswordRoute
58
84
  '/sign-in': typeof SignInRoute
59
85
  '/sign-up': typeof SignUpRoute
86
+ '/verify-email': typeof VerifyEmailRoute
60
87
  '/_authenticated/consent': typeof AuthenticatedConsentRoute
61
88
  '/_authenticated/': typeof AuthenticatedIndexRoute
62
89
  }
63
90
  export interface FileRouteTypes {
64
91
  fileRoutesByFullPath: FileRoutesByFullPath
65
- fullPaths: '/' | '/sign-in' | '/sign-up' | '/consent'
92
+ fullPaths:
93
+ | '/'
94
+ | '/forgot-password'
95
+ | '/reset-password'
96
+ | '/sign-in'
97
+ | '/sign-up'
98
+ | '/verify-email'
99
+ | '/consent'
66
100
  fileRoutesByTo: FileRoutesByTo
67
- to: '/sign-in' | '/sign-up' | '/consent' | '/'
101
+ to:
102
+ | '/forgot-password'
103
+ | '/reset-password'
104
+ | '/sign-in'
105
+ | '/sign-up'
106
+ | '/verify-email'
107
+ | '/consent'
108
+ | '/'
68
109
  id:
69
110
  | '__root__'
70
111
  | '/_authenticated'
112
+ | '/forgot-password'
113
+ | '/reset-password'
71
114
  | '/sign-in'
72
115
  | '/sign-up'
116
+ | '/verify-email'
73
117
  | '/_authenticated/consent'
74
118
  | '/_authenticated/'
75
119
  fileRoutesById: FileRoutesById
76
120
  }
77
121
  export interface RootRouteChildren {
78
122
  AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
123
+ ForgotPasswordRoute: typeof ForgotPasswordRoute
124
+ ResetPasswordRoute: typeof ResetPasswordRoute
79
125
  SignInRoute: typeof SignInRoute
80
126
  SignUpRoute: typeof SignUpRoute
127
+ VerifyEmailRoute: typeof VerifyEmailRoute
81
128
  }
82
129
 
83
130
  declare module '@tanstack/react-router' {
@@ -89,6 +136,20 @@ declare module '@tanstack/react-router' {
89
136
  preLoaderRoute: typeof AuthenticatedRouteImport
90
137
  parentRoute: typeof rootRouteImport
91
138
  }
139
+ '/forgot-password': {
140
+ id: '/forgot-password'
141
+ path: '/forgot-password'
142
+ fullPath: '/forgot-password'
143
+ preLoaderRoute: typeof ForgotPasswordRouteImport
144
+ parentRoute: typeof rootRouteImport
145
+ }
146
+ '/reset-password': {
147
+ id: '/reset-password'
148
+ path: '/reset-password'
149
+ fullPath: '/reset-password'
150
+ preLoaderRoute: typeof ResetPasswordRouteImport
151
+ parentRoute: typeof rootRouteImport
152
+ }
92
153
  '/sign-in': {
93
154
  id: '/sign-in'
94
155
  path: '/sign-in'
@@ -103,6 +164,13 @@ declare module '@tanstack/react-router' {
103
164
  preLoaderRoute: typeof SignUpRouteImport
104
165
  parentRoute: typeof rootRouteImport
105
166
  }
167
+ '/verify-email': {
168
+ id: '/verify-email'
169
+ path: '/verify-email'
170
+ fullPath: '/verify-email'
171
+ preLoaderRoute: typeof VerifyEmailRouteImport
172
+ parentRoute: typeof rootRouteImport
173
+ }
106
174
  '/_authenticated/': {
107
175
  id: '/_authenticated/'
108
176
  path: '/'
@@ -136,8 +204,11 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
136
204
 
137
205
  const rootRouteChildren: RootRouteChildren = {
138
206
  AuthenticatedRoute: AuthenticatedRouteWithChildren,
207
+ ForgotPasswordRoute: ForgotPasswordRoute,
208
+ ResetPasswordRoute: ResetPasswordRoute,
139
209
  SignInRoute: SignInRoute,
140
210
  SignUpRoute: SignUpRoute,
211
+ VerifyEmailRoute: VerifyEmailRoute,
141
212
  }
142
213
  export const routeTree = rootRouteImport
143
214
  ._addFileChildren(rootRouteChildren)
@@ -0,0 +1,17 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ForgotPasswordForm } from '@/features/auth/forgot-password-form'
3
+
4
+ export const Route = createFileRoute('/forgot-password')({
5
+ component: ForgotPasswordPage,
6
+ })
7
+
8
+ function ForgotPasswordPage() {
9
+ const { auth } = Route.useRouteContext()
10
+
11
+ return (
12
+ <>
13
+ <h1 className="text-2xl font-semibold">Reset your password</h1>
14
+ <ForgotPasswordForm auth={auth} />
15
+ </>
16
+ )
17
+ }
@@ -0,0 +1,20 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
3
+ import { ResetPasswordForm } from '@/features/auth/reset-password-form'
4
+
5
+ export const Route = createFileRoute('/reset-password')({
6
+ validateSearch: z.object({ email: z.email() }),
7
+ component: ResetPasswordPage,
8
+ })
9
+
10
+ function ResetPasswordPage() {
11
+ const { auth } = Route.useRouteContext()
12
+ const { email } = Route.useSearch()
13
+
14
+ return (
15
+ <>
16
+ <h1 className="text-2xl font-semibold">Choose a new password</h1>
17
+ <ResetPasswordForm auth={auth} email={email} />
18
+ </>
19
+ )
20
+ }
@@ -1,20 +1,27 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { SignInForm } from '@/features/auth/sign-in-form'
4
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
4
5
 
5
6
  export const Route = createFileRoute('/sign-in')({
6
- validateSearch: z.object({ redirect: z.string().optional() }),
7
+ validateSearch: z.object({
8
+ redirect: z.string().optional(),
9
+ error: z.string().optional(), // prumo:social
10
+ }),
7
11
  component: SignInPage,
8
12
  })
9
13
 
10
14
  function SignInPage() {
11
15
  const { auth } = Route.useRouteContext()
12
- const { redirect } = Route.useSearch()
16
+ const search = Route.useSearch()
13
17
 
14
18
  return (
15
19
  <>
16
20
  <h1 className="text-2xl font-semibold">Sign in</h1>
17
- <SignInForm auth={auth} redirect={redirect} />
21
+ {/* prumo:social */}
22
+ <SocialButtons auth={auth} returnTo={search.redirect} error={search.error} />
23
+ {/* prumo:end-social */}
24
+ <SignInForm auth={auth} redirect={search.redirect} />
18
25
  </>
19
26
  )
20
27
  }
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { SignUpForm } from '@/features/auth/sign-up-form'
3
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
3
4
 
4
5
  export const Route = createFileRoute('/sign-up')({
5
6
  component: SignUpPage,
@@ -11,6 +12,9 @@ function SignUpPage() {
11
12
  return (
12
13
  <>
13
14
  <h1 className="text-2xl font-semibold">Create an account</h1>
15
+ {/* prumo:social */}
16
+ <SocialButtons auth={auth} returnTo={undefined} error={undefined} />
17
+ {/* prumo:end-social */}
14
18
  <SignUpForm auth={auth} />
15
19
  </>
16
20
  )
@@ -0,0 +1,20 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
3
+ import { VerifyEmailForm } from '@/features/auth/verify-email-form'
4
+
5
+ export const Route = createFileRoute('/verify-email')({
6
+ validateSearch: z.object({ email: z.email() }),
7
+ component: VerifyEmailPage,
8
+ })
9
+
10
+ function VerifyEmailPage() {
11
+ const { auth } = Route.useRouteContext()
12
+ const { email } = Route.useSearch()
13
+
14
+ return (
15
+ <>
16
+ <h1 className="text-2xl font-semibold">Confirm your email</h1>
17
+ <VerifyEmailForm auth={auth} email={email} />
18
+ </>
19
+ )
20
+ }
@@ -0,0 +1,121 @@
1
+ import { fakeTransport, json } from '@test/support/fake-transport'
2
+ import { renderApp } from '@test/support/render-app'
3
+ import { screen } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { describe, expect, it } from 'vitest'
6
+ import type { Profile } from '@/api-contract'
7
+
8
+ const user = { id: 'user-1', email: 'ana@example.com' }
9
+
10
+ const profile: Profile = {
11
+ id: 'profile-1',
12
+ userId: user.id,
13
+ displayName: 'Ana',
14
+ locale: 'en',
15
+ timezone: 'UTC',
16
+ createdAt: '2026-09-14T12:57:24.057Z',
17
+ updatedAt: '2026-09-14T12:57:24.057Z',
18
+ }
19
+
20
+ const empty = (status: number) => new Response(null, { status })
21
+
22
+ const problem = (status: number, body: Record<string, unknown>) =>
23
+ json(status, { status, title: 'Error', ...body }, 'application/problem+json')
24
+
25
+ // A visitor whose session appears once `signedIn` flips, as cookies would.
26
+ function app(routes: Parameters<typeof fakeTransport>[0]) {
27
+ const state = { signedIn: false }
28
+
29
+ return {
30
+ state,
31
+ transport: fakeTransport({
32
+ 'GET /api/auth/session': () => (state.signedIn ? json(200, { user }) : json(401, {})),
33
+ 'POST /api/auth/refresh': () => json(401, {}),
34
+ 'GET /api/v1/users/me': () => json(200, profile),
35
+ ...routes,
36
+ }),
37
+ }
38
+ }
39
+
40
+ describe('email verification on the web', () => {
41
+ it('takes a new account to the code, and into the app once it is entered', async () => {
42
+ const visitor = userEvent.setup()
43
+ const { state, transport } = app({
44
+ 'POST /api/auth/sign-up': () => json(201, { verificationRequired: true }),
45
+ 'POST /api/auth/email/verify': () => {
46
+ state.signedIn = true
47
+ return empty(204)
48
+ },
49
+ })
50
+
51
+ renderApp('/sign-up', transport)
52
+
53
+ await visitor.type(await screen.findByLabelText('Name'), 'Ana')
54
+ await visitor.type(screen.getByLabelText('Email'), user.email)
55
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
56
+ await visitor.click(screen.getByRole('button', { name: 'Create account' }))
57
+
58
+ expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
59
+
60
+ await visitor.type(screen.getByLabelText('Code'), '123456')
61
+ await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
62
+
63
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
64
+ })
65
+
66
+ it('sends an unconfirmed account that signs in to the code page', async () => {
67
+ const visitor = userEvent.setup()
68
+ const { transport } = app({
69
+ 'POST /api/auth/sign-in': () => problem(403, { code: 'email_not_verified' }),
70
+ })
71
+
72
+ renderApp('/sign-in', transport)
73
+
74
+ await visitor.type(await screen.findByLabelText('Email'), user.email)
75
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
76
+ await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
77
+
78
+ expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
79
+ })
80
+
81
+ it('shows a wrong code on the code field', async () => {
82
+ const visitor = userEvent.setup()
83
+ const { transport } = app({
84
+ 'POST /api/auth/email/verify': () =>
85
+ problem(422, { code: 'invalid_code', detail: 'The code is wrong or has expired' }),
86
+ })
87
+
88
+ renderApp(`/verify-email?email=${user.email}`, transport)
89
+
90
+ await visitor.type(await screen.findByLabelText('Code'), '000000')
91
+ await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
92
+
93
+ expect(await screen.findByText(/The code is wrong or has expired/)).toBeInTheDocument()
94
+ })
95
+ })
96
+
97
+ describe('password reset on the web', () => {
98
+ it('goes from forgot to reset to the app', async () => {
99
+ const visitor = userEvent.setup()
100
+ const { state, transport } = app({
101
+ 'POST /api/auth/password/forgot': () => empty(204),
102
+ 'POST /api/auth/password/reset': () => {
103
+ state.signedIn = true
104
+ return empty(204)
105
+ },
106
+ })
107
+
108
+ renderApp('/sign-in', transport)
109
+
110
+ await visitor.click(await screen.findByRole('link', { name: 'Forgot your password?' }))
111
+ await screen.findByRole('heading', { name: 'Reset your password' })
112
+ await visitor.type(screen.getByLabelText('Email'), user.email)
113
+ await visitor.click(screen.getByRole('button', { name: 'Send a reset code' }))
114
+ await screen.findByRole('heading', { name: 'Choose a new password' })
115
+ await visitor.type(screen.getByLabelText('Code'), '123456')
116
+ await visitor.type(screen.getByLabelText('New password'), 'another-good-password')
117
+ await visitor.click(screen.getByRole('button', { name: 'Set the new password' }))
118
+
119
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
120
+ })
121
+ })
@@ -0,0 +1,35 @@
1
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
+ import { renderApp } from '@test/support/render-app'
3
+ import { screen } from '@testing-library/react'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const signedOut = fakeTransport({
7
+ 'GET /api/auth/session': () => json(401, {}),
8
+ 'POST /api/auth/refresh': () => json(401, {}),
9
+ })
10
+
11
+ describe('SocialButtons', () => {
12
+ it('send the browser to the API, which returns it to the page it was turned away from', async () => {
13
+ renderApp('/sign-in?redirect=%2Forders', signedOut)
14
+
15
+ for (const name of [
16
+ 'Continue with Google', // prumo:google
17
+ 'Continue with Apple', // prumo:apple
18
+ ]) {
19
+ const link = await screen.findByRole('link', { name })
20
+ const href = new URL(link.getAttribute('href') ?? '')
21
+
22
+ expect(`${href.origin}${href.pathname}`).toMatch(
23
+ new RegExp(`^${API_URL}/api/auth/social/(google|apple)$`),
24
+ )
25
+ expect(href.searchParams.get('client')).toBe('web')
26
+ expect(href.searchParams.get('returnTo')).toBe('/orders')
27
+ }
28
+ })
29
+
30
+ it('says why a provider sign-in came back without a session', async () => {
31
+ renderApp('/sign-in?error=provider_email_unverified', signedOut)
32
+
33
+ expect(await screen.findByRole('alert')).toHaveTextContent('not verified with the provider')
34
+ })
35
+ })
@@ -15,6 +15,10 @@ const view = {
15
15
  clientHost: 'claude.example.com',
16
16
  redirectHost: 'claude.example.com',
17
17
  redirectsToThisDevice: false,
18
+ scopes: [
19
+ { scope: 'profile:read', description: 'Read your profile' },
20
+ { scope: 'profile:write', description: 'Change your display name, language and timezone' },
21
+ ],
18
22
  }
19
23
 
20
24
  function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
@@ -44,6 +48,9 @@ describe('ConsentForm', () => {
44
48
 
45
49
  expect(await screen.findByText('Claude')).toBeInTheDocument()
46
50
  expect(screen.getByText(/claude\.example\.com\)/)).toBeInTheDocument()
51
+ expect(screen.getByRole('list', { name: 'What it will be able to do' })).toHaveTextContent(
52
+ 'Read your profile',
53
+ )
47
54
 
48
55
  await visitor.click(screen.getByRole('button', { name: 'Allow' }))
49
56
 
@@ -29,6 +29,16 @@ If the workspace holds `api` and its database is not set up yet, `pnpm dev` offe
29
29
 
30
30
  If the workspace holds `web`, open `http://localhost:5173`: you are sent to sign in, and **Sign up** creates an
31
31
  account and takes you to your profile. Each app's README says what it needs before it starts.
32
+ <!-- prumo:email -->
33
+
34
+ Signing up first asks for the 6-digit code that confirms the email. No mail is sent yet: the API writes it to its
35
+ log, in a line starting `[mail] to`, which `pnpm dev` prints with the rest of the API's output.
36
+ <!-- prumo:end-email -->
37
+ <!-- prumo:social -->
38
+
39
+ A provider's sign-in button answers *That sign-in is not set up yet* until its settings are filled in `apps/api/.env`;
40
+ `.env.example` lists them and says where each one comes from.
41
+ <!-- prumo:end-social -->
32
42
 
33
43
  ## Conventions
34
44
 
package/dist/mcp.js DELETED
@@ -1,107 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { setJsonc } from "./jsonc.js";
5
- // The api and web templates ship with MCP. A project that answered no receives them without it: these files are
6
- // deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
7
- // The API's MCP is its server and the OAuth authorization server that issues its tokens, with the table that
8
- // holds an authorization in a migration of its own, so a project without MCP never creates it.
9
- export const FILES = {
10
- api: [
11
- 'src/infra/mcp',
12
- 'src/domain/oauth',
13
- 'src/infra/oauth',
14
- 'src/infra/di/oauth.di.ts',
15
- 'src/infra/http/fetch-bridge.ts',
16
- 'src/infra/http/controllers/mcp.controller.ts',
17
- 'src/infra/http/controllers/oauth.controller.ts',
18
- 'src/infra/database/mikroorm/entities/authorization.schema.ts',
19
- 'src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts',
20
- 'migrations/Migration20261002210631_create_authorization.ts',
21
- 'test/domain/oauth',
22
- 'test/infra/oauth',
23
- 'test/infra/http/controllers/mcp.controller.spec.ts',
24
- ],
25
- web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
26
- };
27
- const DEPENDENCIES = {
28
- api: ['@modelcontextprotocol/client', '@modelcontextprotocol/server'],
29
- web: [],
30
- };
31
- const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
32
- const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
33
- // A marked import keeps its marker as a trailing comment, which the import sorter carries along.
34
- const MARKED_LINE = /\s*\/\/ prumo:mcp$/;
35
- export function cutMcp(text, keep, file = 'a file') {
36
- const kept = [];
37
- let inBlock = false;
38
- for (const line of text.split('\n')) {
39
- if (BLOCK_START.test(line)) {
40
- if (inBlock) {
41
- throw new Error(`${file} opens a prumo:mcp block inside another`);
42
- }
43
- inBlock = true;
44
- }
45
- else if (BLOCK_END.test(line)) {
46
- if (!inBlock) {
47
- throw new Error(`${file} closes a prumo:mcp block it never opened`);
48
- }
49
- inBlock = false;
50
- }
51
- else if (MARKED_LINE.test(line)) {
52
- if (keep) {
53
- kept.push(line.replace(MARKED_LINE, ''));
54
- }
55
- }
56
- else if (keep || !inBlock) {
57
- kept.push(line);
58
- }
59
- }
60
- if (inBlock) {
61
- throw new Error(`${file} leaves a prumo:mcp block open`);
62
- }
63
- return kept.join('\n');
64
- }
65
- async function sourceFiles(directory) {
66
- if (!existsSync(directory)) {
67
- return [];
68
- }
69
- const entries = await readdir(directory, { withFileTypes: true, recursive: true });
70
- return entries
71
- .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
72
- .map((entry) => join(entry.parentPath, entry.name));
73
- }
74
- export async function applyMcp(app, type, enabled) {
75
- const files = FILES[type];
76
- const dependencies = DEPENDENCIES[type];
77
- if (files === undefined || dependencies === undefined) {
78
- return;
79
- }
80
- if (!enabled) {
81
- for (const file of files) {
82
- await rm(join(app, file), { recursive: true, force: true });
83
- }
84
- const manifest = join(app, 'package.json');
85
- let text = await readFile(manifest, 'utf8');
86
- for (const section of ['dependencies', 'devDependencies']) {
87
- for (const dependency of dependencies) {
88
- text = setJsonc(text, [section, dependency], undefined);
89
- }
90
- }
91
- await writeFile(manifest, text);
92
- }
93
- const sources = [
94
- ...(await sourceFiles(join(app, 'src'))),
95
- ...(await sourceFiles(join(app, 'test'))),
96
- ];
97
- for (const source of sources) {
98
- const text = await readFile(source, 'utf8');
99
- const cut = cutMcp(text, enabled, source);
100
- if (!enabled && dependencies.some((dependency) => cut.includes(`'${dependency}`))) {
101
- throw new Error(`${source} still imports an MCP package outside a prumo:mcp marker`);
102
- }
103
- if (cut !== text) {
104
- await writeFile(source, cut);
105
- }
106
- }
107
- }