@stjoseph/prumo 0.2.0 → 0.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 (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -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
+ })
@@ -11,8 +11,12 @@ describe('SignInForm', () => {
11
11
  renderApp(
12
12
  '/sign-in',
13
13
  fakeTransport({
14
- 'POST /api/auth/sign-in/email': () =>
15
- json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
14
+ 'POST /api/auth/sign-in': () =>
15
+ json(
16
+ 401,
17
+ { title: 'Unauthorized', status: 401, detail: 'Invalid email or password' },
18
+ 'application/problem+json',
19
+ ),
16
20
  }),
17
21
  )
18
22
 
@@ -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
+ })
@@ -1,45 +1,109 @@
1
- import { fakeTransport, json } from '@test/support/fake-transport'
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
3
  import { renderApp } from '@test/support/render-app'
3
- import { screen } from '@testing-library/react'
4
+ import { render, screen } from '@testing-library/react'
4
5
  import userEvent from '@testing-library/user-event'
5
- import { afterEach, describe, expect, it } from 'vitest'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import { createAuthClient } from '@/api-contract'
8
+ import { ConsentForm } from '@/features/oauth/consent-form'
6
9
 
7
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
10
+ const REQUEST = '0199a7e2-0000-7000-8000-000000000000'
8
11
 
9
- // The shape Better Auth signs: every signed name listed in ba_param, then the signature.
10
- const SIGNED =
11
- 'client_id=client-1&scope=openid+profile&ba_param=ba_param&ba_param=client_id&ba_param=scope&sig=signature'
12
+ const view = {
13
+ clientId: 'https://claude.example.com/client.json',
14
+ clientName: 'Claude',
15
+ clientHost: 'claude.example.com',
16
+ redirectHost: 'claude.example.com',
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
+ ],
22
+ }
12
23
 
13
- afterEach(() => {
14
- window.history.replaceState(null, '', '/')
15
- })
24
+ function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
25
+ const leave = vi.fn()
26
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: fakeTransport(routes) })
27
+
28
+ render(
29
+ <QueryClientProvider client={new QueryClient()}>
30
+ <ConsentForm auth={auth} requestId={REQUEST} leave={leave} />
31
+ </QueryClientProvider>,
32
+ )
33
+
34
+ return leave
35
+ }
16
36
 
17
37
  describe('ConsentForm', () => {
18
- it('names the client, lists the scopes and sends the signed query with the answer', async () => {
38
+ it('names the client and both hosts, and sends the browser where the API says', async () => {
19
39
  const visitor = userEvent.setup()
20
- let consent: Record<string, unknown> | undefined
40
+ let decision: unknown
41
+ const leave = renderForm({
42
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
43
+ [`POST /api/oauth/authorizations/${REQUEST}/decision`]: async (request) => {
44
+ decision = await request.json()
45
+ return json(200, { redirectTo: 'https://claude.example.com/callback?code=c' })
46
+ },
47
+ })
48
+
49
+ expect(await screen.findByText('Claude')).toBeInTheDocument()
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
+ )
54
+
55
+ await visitor.click(screen.getByRole('button', { name: 'Allow' }))
56
+
57
+ expect(decision).toEqual({ accept: true })
58
+ expect(leave).toHaveBeenCalledWith('https://claude.example.com/callback?code=c')
59
+ })
60
+
61
+ it('warns when the code returns to a program on this device', async () => {
62
+ renderForm({
63
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () =>
64
+ json(200, { ...view, redirectHost: 'localhost:33418', redirectsToThisDevice: true }),
65
+ })
21
66
 
22
- // The OAuth client plugin reads the browser's own URL, not the router's memory history.
23
- window.history.replaceState(null, '', `/consent?${SIGNED}&unrelated=1`)
67
+ expect(await screen.findByRole('note')).toHaveTextContent('a program on this device')
68
+ })
69
+
70
+ it('says so when the request has expired', async () => {
71
+ renderForm({
72
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () =>
73
+ json(404, { title: 'Not Found', status: 404 }, 'application/problem+json'),
74
+ })
75
+
76
+ expect(await screen.findByRole('alert')).toHaveTextContent('expired')
77
+ })
78
+ })
79
+
80
+ describe('the consent route', () => {
81
+ // The MCP client opened the browser at the API, which sent it here; without a session, sign-in comes first.
82
+ it('sends a visitor to sign in first, and back to the same request after', async () => {
83
+ const visitor = userEvent.setup()
84
+ let signedIn = false
24
85
 
25
86
  renderApp(
26
- `/consent?${SIGNED}`,
87
+ `/consent?request=${REQUEST}`,
27
88
  fakeTransport({
28
- 'GET /api/auth/get-session': () =>
29
- json(200, { session: { id: 's-1', userId: user.id }, user }),
30
- 'GET /api/auth/oauth2/public-client': () => json(200, { client_name: 'Claude' }),
31
- 'POST /api/auth/oauth2/consent': async (request) => {
32
- consent = await request.json()
33
- return json(200, { redirect: false })
89
+ 'GET /api/auth/session': () =>
90
+ signedIn
91
+ ? json(200, { user: { id: 'user-1', email: 'ana@example.com' } })
92
+ : json(401, {}),
93
+ 'POST /api/auth/refresh': () => json(401, {}),
94
+ 'POST /api/auth/sign-in': () => {
95
+ signedIn = true
96
+ return new Response(null, { status: 204 })
34
97
  },
98
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
35
99
  }),
36
100
  )
37
101
 
38
- expect(await screen.findByText('Claude')).toBeInTheDocument()
39
- expect(screen.getByText('profile')).toBeInTheDocument()
102
+ await visitor.type(await screen.findByLabelText('Email'), 'ana@example.com')
103
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
104
+ await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
40
105
 
41
- await visitor.click(screen.getByRole('button', { name: 'Allow' }))
42
-
43
- expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
106
+ expect(await screen.findByRole('heading', { name: 'Allow access' })).toBeInTheDocument()
107
+ expect(await screen.findByText('Claude')).toBeInTheDocument()
44
108
  })
45
109
  })
@@ -2,15 +2,15 @@ import { QueryClient } from '@tanstack/react-query'
2
2
  import { createMemoryHistory } from '@tanstack/react-router'
3
3
  import { API_URL } from '@test/support/fake-transport'
4
4
  import { render } from '@testing-library/react'
5
- import { createClient, type Transport } from '@/api-contract'
5
+ import { createAuthClient, createClient, type Transport } from '@/api-contract'
6
6
  import { App } from '@/app'
7
- import { createAuth } from '@/features/auth/auth-client'
8
7
 
9
8
  export function renderApp(path: string, transport: Transport) {
9
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: transport })
10
10
  const context = {
11
11
  queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }),
12
- api: createClient({ baseUrl: API_URL, fetch: transport }),
13
- auth: createAuth({ baseUrl: API_URL, fetch: transport }),
12
+ api: createClient({ baseUrl: API_URL, fetch: auth.fetch }),
13
+ auth,
14
14
  }
15
15
 
16
16
  return render(<App context={context} history={createMemoryHistory({ initialEntries: [path] })} />)
@@ -25,7 +25,7 @@ const APPS = {
25
25
  // A port that moves takes these with it: the key to write, in which app, from which app's port.
26
26
  const FOLLOWERS = {
27
27
  api: [
28
- { app: 'api', key: 'BETTER_AUTH_URL' },
28
+ { app: 'api', key: 'API_URL' },
29
29
  { app: 'web', key: 'VITE_API_URL' },
30
30
  { app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
31
31
  ],
package/dist/mcp.js DELETED
@@ -1,100 +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
- const FILES = {
8
- api: [
9
- 'src/infra/mcp',
10
- 'src/infra/http/controllers/mcp.controller.ts',
11
- 'test/infra/http/controllers/mcp.controller.spec.ts',
12
- ],
13
- web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
14
- };
15
- const DEPENDENCIES = {
16
- api: [
17
- '@better-auth/cimd',
18
- '@better-auth/mcp',
19
- '@modelcontextprotocol/client',
20
- '@modelcontextprotocol/server',
21
- ],
22
- web: ['@better-auth/oauth-provider'],
23
- };
24
- const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
25
- const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
26
- // A marked import keeps its marker as a trailing comment, which the import sorter carries along.
27
- const MARKED_LINE = /\s*\/\/ prumo:mcp$/;
28
- export function cutMcp(text, keep, file = 'a file') {
29
- const kept = [];
30
- let inBlock = false;
31
- for (const line of text.split('\n')) {
32
- if (BLOCK_START.test(line)) {
33
- if (inBlock) {
34
- throw new Error(`${file} opens a prumo:mcp block inside another`);
35
- }
36
- inBlock = true;
37
- }
38
- else if (BLOCK_END.test(line)) {
39
- if (!inBlock) {
40
- throw new Error(`${file} closes a prumo:mcp block it never opened`);
41
- }
42
- inBlock = false;
43
- }
44
- else if (MARKED_LINE.test(line)) {
45
- if (keep) {
46
- kept.push(line.replace(MARKED_LINE, ''));
47
- }
48
- }
49
- else if (keep || !inBlock) {
50
- kept.push(line);
51
- }
52
- }
53
- if (inBlock) {
54
- throw new Error(`${file} leaves a prumo:mcp block open`);
55
- }
56
- return kept.join('\n');
57
- }
58
- async function sourceFiles(directory) {
59
- if (!existsSync(directory)) {
60
- return [];
61
- }
62
- const entries = await readdir(directory, { withFileTypes: true, recursive: true });
63
- return entries
64
- .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
65
- .map((entry) => join(entry.parentPath, entry.name));
66
- }
67
- export async function applyMcp(app, type, enabled) {
68
- const files = FILES[type];
69
- const dependencies = DEPENDENCIES[type];
70
- if (files === undefined || dependencies === undefined) {
71
- return;
72
- }
73
- if (!enabled) {
74
- for (const file of files) {
75
- await rm(join(app, file), { recursive: true, force: true });
76
- }
77
- const manifest = join(app, 'package.json');
78
- let text = await readFile(manifest, 'utf8');
79
- for (const section of ['dependencies', 'devDependencies']) {
80
- for (const dependency of dependencies) {
81
- text = setJsonc(text, [section, dependency], undefined);
82
- }
83
- }
84
- await writeFile(manifest, text);
85
- }
86
- const sources = [
87
- ...(await sourceFiles(join(app, 'src'))),
88
- ...(await sourceFiles(join(app, 'test'))),
89
- ];
90
- for (const source of sources) {
91
- const text = await readFile(source, 'utf8');
92
- const cut = cutMcp(text, enabled, source);
93
- if (!enabled && dependencies.some((dependency) => cut.includes(`'${dependency}`))) {
94
- throw new Error(`${source} still imports an MCP package outside a prumo:mcp marker`);
95
- }
96
- if (cut !== text) {
97
- await writeFile(source, cut);
98
- }
99
- }
100
- }
@@ -1 +0,0 @@
1
- CREATE SCHEMA IF NOT EXISTS auth;
@@ -1,13 +0,0 @@
1
- import { Migration } from '@mikro-orm/migrations';
2
-
3
- export class Migration20260914123239_create_profile extends Migration {
4
-
5
- override name = 'Migration20260914123239_create_profile';
6
-
7
- override up(): void | Promise<void> {
8
- this.addSql(`create table "profile" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "display_name" text not null, "locale" text not null default 'en', "timezone" text not null default 'UTC', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
9
- this.addSql(`alter table "profile" add constraint "profile_user_id_unique" unique ("user_id");`);
10
- this.addSql(`alter table "profile" add constraint "profile_user_id_foreign" foreign key ("user_id") references "auth"."user" ("id") on delete restrict;`);
11
- }
12
-
13
- }
@@ -1,5 +0,0 @@
1
- import { loadEnv } from '@/infra/config/env'
2
- import { createAuth } from './auth.factory'
3
-
4
- // Read by Better Auth's CLI (`auth migrate --config`), which only needs the schema, not the hooks.
5
- export const auth = createAuth(loadEnv())
@@ -1,77 +0,0 @@
1
- import { cimd } from '@better-auth/cimd' // prumo:mcp
2
- import { fetchClientMetadataResource } from '@better-auth/cimd/node' // prumo:mcp
3
- import { expo } from '@better-auth/expo'
4
- import { mcp } from '@better-auth/mcp' // prumo:mcp
5
- import { type BetterAuthPlugin, betterAuth } from 'better-auth'
6
- import { jwt } from 'better-auth/plugins' // prumo:mcp
7
- import { Pool } from 'pg'
8
- import type { Env } from '@/infra/config/env'
9
-
10
- type NewUser = { id: string; name: string }
11
-
12
- export type AuthHooks = {
13
- onUserCreated?: (user: NewUser) => Promise<void>
14
- }
15
-
16
- export const AUTH = Symbol('Auth')
17
-
18
- // prumo:mcp
19
- // The protected resource MCP clients ask a token for; access tokens carry it as their audience.
20
- export function mcpResource(env: Pick<Env, 'BETTER_AUTH_URL'>): string {
21
- return `${env.BETTER_AUTH_URL}/api/mcp`
22
- }
23
-
24
- // prumo:end-mcp
25
- export function createAuth(
26
- env: Pick<
27
- Env,
28
- | 'AUTH_DATABASE_URL'
29
- | 'BETTER_AUTH_SECRET'
30
- | 'BETTER_AUTH_URL'
31
- | 'WEB_ORIGIN'
32
- | 'MOBILE_APP_SCHEME'
33
- >,
34
- hooks: AuthHooks = {},
35
- ) {
36
- const mobile = env.MOBILE_APP_SCHEME
37
- const plugins: BetterAuthPlugin[] = mobile === undefined ? [] : [expo()]
38
-
39
- // prumo:mcp
40
- plugins.push(
41
- jwt(),
42
- mcp({
43
- loginPage: `${env.WEB_ORIGIN}/sign-in`,
44
- consentPage: `${env.WEB_ORIGIN}/consent`,
45
- resource: mcpResource(env),
46
- }),
47
- cimd({ fetchClientMetadataResource, metadataProfile: 'mcp-2026-07-28' }),
48
- )
49
-
50
- // prumo:end-mcp
51
- return betterAuth({
52
- baseURL: env.BETTER_AUTH_URL,
53
- basePath: '/api/auth',
54
- secret: env.BETTER_AUTH_SECRET,
55
- trustedOrigins: mobile === undefined ? [env.WEB_ORIGIN] : [env.WEB_ORIGIN, `${mobile}://`],
56
- plugins,
57
- database: new Pool({
58
- connectionString: env.AUTH_DATABASE_URL,
59
- options: '-c search_path=auth',
60
- }),
61
- emailAndPassword: { enabled: true },
62
- advanced: { database: { generateId: 'uuid' } },
63
- databaseHooks: {
64
- user: {
65
- create: {
66
- after: async (user) => {
67
- await hooks.onUserCreated?.(user)
68
- },
69
- },
70
- },
71
- },
72
- })
73
- }
74
-
75
- export type Auth = ReturnType<typeof createAuth>
76
- export type AuthSession = Awaited<ReturnType<Auth['api']['getSession']>>
77
- export type AuthUser = NonNullable<AuthSession>['user']
@@ -1,16 +0,0 @@
1
- import { EntitySchema } from '@mikro-orm/core'
2
-
3
- // Better Auth owns auth.user. This maps only its id, so a foreign key into it is declared rather than
4
- // hand-written; schema diffing skips the table, and nothing reads or writes it through the ORM.
5
- export class AuthUser {
6
- id: string
7
- }
8
-
9
- export const AuthUserSchema = new EntitySchema({
10
- class: AuthUser,
11
- schema: 'auth',
12
- tableName: 'user',
13
- properties: {
14
- id: { type: 'uuid', primary: true },
15
- },
16
- })
@@ -1,22 +0,0 @@
1
- import { testEnv } from '@test/support/setup'
2
- import { describe, expect, it } from 'vitest'
3
- import { createAuth } from '@/infra/auth/auth.factory'
4
-
5
- const proxy =
6
- 'http://localhost:3000/api/auth/expo-authorization-proxy?authorizationURL=https://example.com/authorize&oauthState=s'
7
-
8
- describe('createAuth', () => {
9
- it('exposes no Expo redirect endpoint without a mobile scheme', async () => {
10
- const response = await createAuth(testEnv()).handler(new Request(proxy))
11
-
12
- expect(response.status).toBe(404)
13
- })
14
-
15
- it('exposes the Expo endpoint and trusts the scheme once a mobile scheme is set', async () => {
16
- const auth = createAuth({ ...testEnv(), MOBILE_APP_SCHEME: 'app' })
17
- const response = await auth.handler(new Request(proxy))
18
-
19
- expect(response.status).toBe(302)
20
- expect(auth.options.trustedOrigins).toContain('app://')
21
- })
22
- })
@@ -1,27 +0,0 @@
1
- import { expoClient } from '@better-auth/expo/client'
2
- import { createAuthClient } from 'better-auth/react'
3
- import * as SecureStore from 'expo-secure-store'
4
- import type { Transport } from '@/api-contract'
5
-
6
- export type AuthClient = ReturnType<typeof createAuth>
7
-
8
- export type SessionAuth = Pick<AuthClient, 'signIn' | 'signUp' | 'getSession'>
9
-
10
- export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
11
- return createAuthClient({
12
- baseURL: baseUrl,
13
- basePath: '/api/auth',
14
- fetchOptions: { customFetchImpl: fetch },
15
- plugins: [expoClient({ storage: SecureStore })],
16
- })
17
- }
18
-
19
- export function withSessionCookie(auth: AuthClient, fetch: Transport): Transport {
20
- return async (input, init) => {
21
- const headers = new Headers(init?.headers)
22
-
23
- headers.set('cookie', await auth.getCookie())
24
-
25
- return fetch(input, { ...init, headers, credentials: 'omit' })
26
- }
27
- }
@@ -1,17 +0,0 @@
1
- import { queryOptions } from '@tanstack/react-query'
2
- import { ApiError } from '@/api-contract'
3
- import type { SessionAuth } from './auth-client'
4
-
5
- export const sessionQuery = (auth: SessionAuth) =>
6
- queryOptions({
7
- queryKey: ['session'],
8
- queryFn: async () => {
9
- const { data, error } = await auth.getSession()
10
-
11
- if (error !== null) {
12
- throw ApiError.fromAuthError(error)
13
- }
14
-
15
- return data
16
- },
17
- })
@@ -1,17 +0,0 @@
1
- import { oauthProviderClient } from '@better-auth/oauth-provider/client' // prumo:mcp
2
- import { createAuthClient } from 'better-auth/react'
3
- import type { Transport } from '@/api-contract'
4
-
5
- export type AuthClient = ReturnType<typeof createAuth>
6
-
7
- export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
8
- return createAuthClient({
9
- baseURL: baseUrl,
10
- basePath: '/api/auth',
11
- fetchOptions: { customFetchImpl: fetch },
12
- // prumo:mcp
13
- // Carries the signed query of an MCP client's authorization through sign-in and consent.
14
- plugins: [oauthProviderClient()],
15
- // prumo:end-mcp
16
- })
17
- }