@stjoseph/prumo 0.3.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 (148) 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/social/sign-in.md +77 -0
  7. package/README.md +6 -4
  8. package/dist/asker.js +13 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/commands.js +7 -0
  11. package/dist/compose.js +3 -3
  12. package/dist/context.js +4 -0
  13. package/dist/features.js +230 -0
  14. package/dist/generate.js +17 -3
  15. package/dist/questions.js +64 -1
  16. package/package.json +1 -1
  17. package/templates/api/.env.example +18 -0
  18. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  19. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  20. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  21. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  22. package/templates/api/package.json +1 -0
  23. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  24. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  25. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  26. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  27. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  28. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  29. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  30. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  31. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  32. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  33. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  34. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  36. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  37. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  38. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  39. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  40. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  41. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  42. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  43. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  45. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  46. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  47. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  48. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  49. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  50. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  51. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  53. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  54. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  55. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  56. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  60. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  61. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  62. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  63. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  64. package/templates/api/src/infra/config/env.ts +18 -0
  65. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  66. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  67. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  71. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  72. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  73. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  76. package/templates/api/src/infra/di/email.di.ts +23 -0
  77. package/templates/api/src/infra/di/index.ts +4 -0
  78. package/templates/api/src/infra/di/social.di.ts +17 -0
  79. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  80. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  81. package/templates/api/src/infra/http/app.ts +4 -0
  82. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  83. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  84. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  85. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  86. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  87. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  88. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  89. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  90. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  91. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  92. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  93. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  94. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  95. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  96. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  98. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  99. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  100. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  101. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  102. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  103. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  104. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  105. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  106. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  107. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  108. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  109. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  110. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  111. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  112. package/templates/api/test/support/test-app.ts +65 -0
  113. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  114. package/templates/mobile/src/api-contract/auth.ts +63 -3
  115. package/templates/mobile/src/api-contract/index.ts +4 -0
  116. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  117. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  118. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  119. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  120. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  121. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  122. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  123. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  124. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  125. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  126. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  127. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  128. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  129. package/templates/web/src/api-contract/api-error.ts +4 -0
  130. package/templates/web/src/api-contract/auth.ts +63 -3
  131. package/templates/web/src/api-contract/index.ts +4 -0
  132. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  133. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  134. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  135. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  136. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  137. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  138. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  139. package/templates/web/src/routeTree.gen.ts +73 -2
  140. package/templates/web/src/routes/forgot-password.tsx +17 -0
  141. package/templates/web/src/routes/reset-password.tsx +20 -0
  142. package/templates/web/src/routes/sign-in.tsx +10 -3
  143. package/templates/web/src/routes/sign-up.tsx +4 -0
  144. package/templates/web/src/routes/verify-email.tsx +20 -0
  145. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  146. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  147. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  148. package/dist/mcp.js +0 -107
@@ -52,8 +52,9 @@ one in a body. A cookie is also sent by the browser whoever wrote the page, whic
52
52
  answers: a request leaning on a cookie must come from the web app. A native app has no cookie jar and no
53
53
  Origin, so it carries a bearer and passes.
54
54
 
55
- **What this costs:** the security of sign-in is this code's. There is no upstream fix to wait for, and no
56
- reset of a password, verification of an email or social sign-in until one is written.
55
+ **What this costs:** the security of sign-in is this code's. There is no upstream fix to wait for. Email
56
+ verification, password reset and social sign-in are questions `prumo new` asks; a project that answered no to
57
+ them has none of them until they are written.
57
58
 
58
59
  ## Applies to
59
60
 
@@ -55,10 +55,11 @@ been published since January 2024. Every other rule here (placement, no database
55
55
  behaviour) holds unchanged. **What it costs:** two runners in one workspace with near-identical APIs, so `vi.fn`
56
56
  written in a mobile test is a mistake an assistant will make.
57
57
 
58
- **Two things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
- is **asynchronous** and its matchers need no setup. And `expo-secure-store` needs the native Keychain or Keystore,
58
+ **Three things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
+ is **asynchronous** and its matchers need no setup. `expo-secure-store` needs the native Keychain or Keystore,
60
60
  which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
- asks only for the auth methods it calls.
61
+ asks only for the auth methods it calls. And `jest-expo` leaves the app manifest empty, so `expo-linking` cannot
62
+ know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects.
62
63
 
63
64
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
64
65
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
@@ -0,0 +1,62 @@
1
+ # Email codes
2
+
3
+ ## Rule
4
+
5
+ Confirm an email address and reset a password with a **6-digit code** sent by mail, never a link. One flow
6
+ serves the web and the mobile app alike.
7
+
8
+ Keep one live code per user and purpose (`verify-email`, `reset-password`) in `email_code`. A code lives
9
+ **15 minutes** and allows **5 attempts**; the fifth wrong one kills it. Sending another replaces it, and the old
10
+ one stops working. Store only its HMAC-SHA256, keyed with `JWT_SECRET`.
11
+
12
+ Open no session at sign-up: answer `201 { verificationRequired: true }` and send the code. Refuse sign-in to an
13
+ unconfirmed account with **403** and `code: 'email_not_verified'`, and only after the password matched. Sign
14
+ the user in when the code is confirmed.
15
+
16
+ On a password reset, set the new password, mark the email confirmed, and revoke every session of the user before
17
+ starting a new one.
18
+
19
+ Answer `/email/verification` and `/password/forgot` with 204 whether or not the email has an account, and a
20
+ wrong code, a dead one and an unknown email with the same `422 invalid_code`.
21
+
22
+ Read the outcome of a sign-up and of a refused sign-in in the client: `verificationRequired` and
23
+ `email_not_verified` both lead to the code screen, which offers to send a new code.
24
+
25
+ ## Rationale
26
+
27
+ A link needs a page to open: easy on the web, and a deep link with universal links on mobile. A code is typed
28
+ the same way everywhere, so there is one flow, one set of screens and one set of tests.
29
+
30
+ Six digits are a million possibilities, which is why the attempts are counted and the lifetime short: five
31
+ guesses against a million cannot succeed by luck. The hash is keyed because a million codes hash in a moment,
32
+ so an unkeyed digest in a database copy would give every live code away.
33
+
34
+ Every answer that could say whether an email has an account says the same thing instead, so these routes cannot
35
+ be used to find out who signed up. Asking for confirmation only after the password matched keeps that true at
36
+ sign-in too.
37
+
38
+ A reset revokes every session because whoever knew the old password may have opened one, and the reset is often
39
+ the reaction to exactly that.
40
+
41
+ ## Applies to
42
+
43
+ `src/domain/auth/` for the codes, `auth-email.controller.ts`, and the code, forgot-password and reset-password
44
+ screens on the web and mobile.
45
+
46
+ ## Examples
47
+
48
+ ```
49
+ ✅ POST /api/auth/password/forgot → 204 for any email
50
+ ❌ POST /api/auth/password/forgot → 404 "no account with this email"
51
+
52
+ ✅ sign-in with the right password, unconfirmed → 403 email_not_verified
53
+ ❌ sign-in, unconfirmed, before checking the password → 403 // tells anyone the account exists
54
+ ```
55
+
56
+ ## Enforcement
57
+
58
+ **Tests.** `email.use-case.spec.ts` covers the dead code after five guesses, the replaced code, the unknown email
59
+ and the reset revoking sessions. `auth-email.controller.spec.ts` covers the HTTP answers, and the clients'
60
+ `email-flows.spec.tsx` the screens.
61
+
62
+ **Review only.** That a new mail with a code goes through `IssueEmailCodeUseCase`, so it keeps the same limits.
@@ -0,0 +1,47 @@
1
+ # Mailer
2
+
3
+ ## Rule
4
+
5
+ Send every email through the `Mailer` port in `src/domain/auth/ports/mailer.port.ts`. A use case composes the
6
+ `Mail` (`to`, `subject`, `text`) and hands it over; nothing else in the domain knows how mail travels.
7
+
8
+ Keep `LogMailer`, which writes each mail to the API's log, as the adapter until the project sends real mail.
9
+ Read a code during development from that log.
10
+
11
+ To send real mail, write a second adapter under `src/infra/email/` and register it in `email.di.ts` in place of
12
+ `LogMailer`, for production only. Either speak SMTP, with `nodemailer` and an `SMTP_URL` any provider accepts, or
13
+ call a provider's HTTP API. Validate the new variable in `env.ts`.
14
+
15
+ In a test, register a `FakeMailer` and read the code from it, as `test/support/test-app.ts` does.
16
+
17
+ ## Rationale
18
+
19
+ Sending mail needs an account with somebody, and choosing one is a commercial decision the project's owner
20
+ makes, not the template. So the template ships the port and an adapter that needs nothing, and the flows that
21
+ depend on mail work end to end from the first `pnpm dev`: the code is in the terminal.
22
+
23
+ The port is the whole contract on purpose. SES, Postmark, Resend, Mailgun and a company relay all accept the
24
+ same three fields, so changing provider is one new adapter and one line in the container.
25
+
26
+ **What this costs:** until an adapter is written, a deployed API sends nothing, and a user waiting for a code
27
+ never receives it. Nothing warns about that but this document.
28
+
29
+ ## Applies to
30
+
31
+ Every email the API sends, now and later: codes, invitations, receipts.
32
+
33
+ ## Examples
34
+
35
+ ```
36
+ ✅ await this.mailer.send({ to, subject, text })
37
+ ❌ await fetch('https://api.provider.example/send', …) // inside a use case
38
+
39
+ ✅ container.register(MAILER, { useValue: new SmtpMailer(env) }) // production adapter
40
+ ❌ if (env.NODE_ENV === 'production') { … } // inside LogMailer
41
+ ```
42
+
43
+ ## Enforcement
44
+
45
+ **Tests.** The suite reads every code through `FakeMailer`, so a flow that stops sending mail fails.
46
+
47
+ **Review only.** That production registers a real adapter. `LogMailer` in production loses mail silently.
@@ -11,8 +11,10 @@ Build the server per request in `src/infra/mcp/mcp.server.ts` with `createMcpHan
11
11
  `@modelcontextprotocol/server`, `legacy: 'reject'`. The user is the token's `sub`, passed through
12
12
  `authInfo.extra.userId`.
13
13
 
14
- Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`. A tool resolves a use case and calls
15
- `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
14
+ Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`, with the scope each one needs beside it:
15
+ `<module>:read` to read the module's data, `<module>:write` to change it. Add the module's two scopes to
16
+ `SCOPES` in `mcp.scopes.ts`, worded for a person, and its tools to `TOOL_SCOPES`. A tool resolves a use case and
17
+ calls `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
16
18
  result through the module's output schema with `.parse()` before returning it.
17
19
 
18
20
  Keep the authorization server in the `oauth` module and `oauth.controller.ts`, following MCP's 2026-07-28
@@ -28,6 +30,11 @@ authorization profile:
28
30
  - Return the code with `iss` (RFC 9207), and spend it once: a code presented twice revokes the session its
29
31
  first exchange started.
30
32
  - Rotate refresh tokens, and accept one only from the client it was issued to.
33
+ - Grant the scopes the client asked for, all of `SCOPES` when it asked for none, and refuse an unknown one with
34
+ `invalid_scope`. Carry them in the session, its access token and every refresh. Advertise them in both metadata
35
+ documents and in the 401.
36
+ - Refuse a `tools/call` the token's scopes do not cover before the server runs it: 403 with
37
+ `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`, every scope the call needs in one challenge.
31
38
  - Fetch a metadata document over https only, at most 5 KB and 5 seconds, following no redirect, and never from
32
39
  a private, loopback or link-local address, checked at the connection's own lookup.
33
40
 
@@ -49,8 +56,13 @@ without the address check, a client could make the API read its own network. The
49
56
  connection's lookup, so a name that resolves differently a second time cannot slip past it, and a literal
50
57
  address is checked before connecting because no lookup happens for one.
51
58
 
52
- **What this costs:** no scopes. A tool acts with everything the user may do, and adding scopes is a change to
53
- this module and to the consent page.
59
+ Scopes are per module and per kind of access because a client that only reads should not be able to change
60
+ anything, and because a list worded per tool would grow past what a consent page can make readable. The call is
61
+ refused at the HTTP edge, with the scopes it needs, because that is the answer MCP's step-up flow reads: the
62
+ client asks the user for more and comes back.
63
+
64
+ **What this costs:** a new module's tools need their scopes written in two places, the tool file and
65
+ `mcp.scopes.ts`.
54
66
 
55
67
  ## Applies to
56
68
 
@@ -72,7 +84,9 @@ web app's `/consent` route.
72
84
 
73
85
  ## Enforcement
74
86
 
75
- **Tests.** `mcp.controller.spec.ts` walks discovery from the 401, the authorization, consent and the token, then
87
+ **Tests.** `mcp.controller.spec.ts` fails when a tool the server lists has no scope in `TOOL_SCOPES`, lets a
88
+ read-only token read and refuses its write with `insufficient_scope`,
89
+ keeps the scope through a refresh, and refuses an unknown scope. It also walks discovery from the 401, the authorization, consent and the token, then
76
90
  connects the SDK's own client and asserts a tool returns only the public fields. It also refuses an unknown
77
91
  client without redirecting, a missing or plain PKCE, another resource, a wrong verifier, a code spent twice,
78
92
  another client's refresh token, and each token where the other belongs. `https-client-metadata.adapter.spec.ts`
@@ -0,0 +1,77 @@
1
+ # Social sign-in
2
+
3
+ ## Rule
4
+
5
+ Sign in with a provider through the API, never from a client: the client opens
6
+ `/api/auth/social/<provider>?client=web|mobile`, the API runs OpenID Connect with `openid-client` behind the
7
+ `IdentityProviders` port, and the client comes back signed in. Add a provider in the adapter's `settings()`, its
8
+ variables in `env.ts`, and its button on each client.
9
+
10
+ Store each trip in `social_sign_in`: its id is OAuth's `state`, and the browser that started it holds a secret
11
+ in the `social_sign_in` cookie that the callback must present. Use PKCE wherever the provider's metadata announces
12
+ it, and a nonce always. Turn Apple's `form_post` callback into a redirect to the same address, so the cookie
13
+ comes with it.
14
+
15
+ Return the web to the path it asked for, only a path on the web app, with session cookies. Return a mobile app
16
+ to `MOBILE_APP_SCHEME://social` with a one-time code it exchanges at `/api/auth/social/exchange` for its tokens,
17
+ within two minutes. A mobile app opens the trip with `expo-web-browser`'s `openAuthSessionAsync`.
18
+
19
+ Recognise a returning user by the provider's subject, kept in `identity`. For a first sign-in, link by email
20
+ only when the provider says `email_verified`:
21
+
22
+ - an account whose email was confirmed is the same person: link it;
23
+ - an account whose email was never confirmed loses its password and every session, then is linked: the
24
+ provider's proof wins over whoever chose that password;
25
+ - no account: create one, confirmed and without a password, with its profile.
26
+
27
+ Refuse a provider email that is not verified.
28
+
29
+ When the project has a mobile app and offers Google, offer Apple too before publishing on iOS.
30
+
31
+ ## Rationale
32
+
33
+ The API keeps the client secret and validates the ID token, so neither a browser nor an app ever holds a
34
+ provider's secret or decides what an ID token says.
35
+
36
+ The cookie binds the trip to one browser. Without it, someone could start a sign-in with their own provider
37
+ account and get a victim to open the callback, signing the victim into the attacker's account, which then
38
+ records whatever the victim does. The mobile code exists so that tokens never travel in a URL, where logs and
39
+ history keep them.
40
+
41
+ The linking rules close a known attack: register a password account with somebody's email before they arrive,
42
+ then wait for them to sign in with a provider. A library this project once used shipped exactly that hole. An
43
+ account nobody confirmed belongs to whoever proves the inbox, and that proof is what the provider gives.
44
+
45
+ The App Store asks an app offering a third-party login for its primary account to offer an equivalent
46
+ privacy-preserving login too (App Review Guideline 4.8); Sign in with Apple is one.
47
+
48
+ **What this costs:** the trip leaves the app for the system browser and comes back, rather than a native sheet.
49
+ In a project that does not verify emails, no password account is ever confirmed, so the first time its owner
50
+ signs in with a provider for that email, the password goes: the owner keeps the account through the provider,
51
+ and cannot tell them apart from whoever registered the email first.
52
+ And a provider without its variables in `.env` answers that it is not configured, until someone registers the
53
+ application with it.
54
+
55
+ ## Applies to
56
+
57
+ `src/domain/auth/` for identities and trips, `social.controller.ts`, `src/infra/social/`, and the social buttons on
58
+ the web and mobile.
59
+
60
+ ## Examples
61
+
62
+ ```
63
+ ✅ a href={auth.socialSignInUrl('google', 'web', redirect)}
64
+ ❌ window.location = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=…' // the client talks to Google
65
+
66
+ ✅ link only when email_verified, and void an unconfirmed account's password
67
+ ❌ link whenever the emails match
68
+ ```
69
+
70
+ ## Enforcement
71
+
72
+ **Tests.** `social.use-case.spec.ts` covers each linking rule and refuses a callback from another browser, a
73
+ second use and another provider. `openid-identity-providers.adapter.spec.ts` runs the real adapter against a
74
+ local OpenID provider: PKCE, state, nonce, a forged code, and Apple's client secret signed with the `.p8` key.
75
+ `social.controller.spec.ts` covers both clients over HTTP.
76
+
77
+ **Review only.** That a new provider's ID token is validated by `openid-client` before anything reads it.
package/README.md CHANGED
@@ -58,7 +58,7 @@ terminal a missing answer is an error rather than a default, so nothing importan
58
58
  silence:
59
59
 
60
60
  ```sh
61
- npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
61
+ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp --email --no-social
62
62
  ```
63
63
 
64
64
  | Flag | What it answers |
@@ -67,6 +67,8 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
67
67
  | `--alone` or `--monorepo` | How a single type is laid out |
68
68
  | `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants. Multi-tenant ships the `multi-tenancy` conventions; the generated code itself is not tenant-aware yet |
69
69
  | `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
70
+ | `--email` or `--no-email` | With an api: email verification and password reset, by a 6-digit code sent through the `Mailer` port, which writes to the log until you give it a provider |
71
+ | `--social google,apple` or `--no-social` | With an api and a web or mobile: sign-in with Google, Apple, or both |
70
72
  | `--skip-install` | Stops after writing the files |
71
73
 
72
74
  ## Commands
@@ -106,13 +108,13 @@ a month apart are the same project.
106
108
 
107
109
  | File | What it is |
108
110
  |---|---|
109
- | `.prumo/` | The conventions, in ten areas. Only the areas the answers call for are copied |
111
+ | `.prumo/` | The conventions, in twelve areas. Only the areas the answers call for are copied |
110
112
  | `.prumo/INDEX.md` | One line per document. Generated by the tool, and safe to regenerate |
111
113
  | `AGENTS.md` | The pointer into `.prumo/`, read by Codex, Cursor, Copilot, Gemini CLI and others |
112
114
  | `CLAUDE.md` | An `@AGENTS.md` import, for Claude Code |
113
115
 
114
- The ten areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
115
- `multi-tenancy` and `mcp`. Only `core` ships unconditionally, because everything in it is true of every type;
116
+ The twelve areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
117
+ `multi-tenancy`, `mcp`, `email` and `social`. Only `core` ships unconditionally, because everything in it is true of every type;
116
118
  the rest follow the answers, since advice about a platform the project does not have is worse than
117
119
  no advice at all.
118
120
 
package/dist/asker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as prompt from '@clack/prompts';
2
+ import { SOCIAL_PROVIDERS } from "./features.js";
2
3
  const TYPES = ['api', 'web', 'mobile', 'site'];
3
4
  function answered(value) {
4
5
  if (prompt.isCancel(value)) {
@@ -28,5 +29,17 @@ export function terminalAsker(validateName) {
28
29
  message: 'Let AI assistants use the API through MCP, signed in as the user?',
29
30
  initialValue: false,
30
31
  })),
32
+ email: async () => answered(await prompt.confirm({
33
+ message: 'Verify emails and let users reset their password?',
34
+ initialValue: false,
35
+ })),
36
+ social: async () => answered(await prompt.multiselect({
37
+ message: 'Social sign-in? Leave all unselected for none',
38
+ options: SOCIAL_PROVIDERS.map((provider) => ({
39
+ value: provider,
40
+ label: provider === 'google' ? 'Google' : 'Apple',
41
+ })),
42
+ required: false,
43
+ })),
31
44
  };
32
45
  }
package/dist/cli.js CHANGED
@@ -34,6 +34,10 @@ async function runNew(args, json) {
34
34
  'single-tenant': { type: 'boolean', default: false },
35
35
  mcp: { type: 'boolean', default: false },
36
36
  'no-mcp': { type: 'boolean', default: false },
37
+ email: { type: 'boolean', default: false },
38
+ 'no-email': { type: 'boolean', default: false },
39
+ social: { type: 'string' },
40
+ 'no-social': { type: 'boolean', default: false },
37
41
  'skip-install': { type: 'boolean', default: false },
38
42
  json: { type: 'boolean', default: false },
39
43
  },
@@ -62,11 +66,21 @@ async function runNew(args, json) {
62
66
  singleTenant: values['single-tenant'],
63
67
  mcp: values.mcp,
64
68
  noMcp: values['no-mcp'],
69
+ email: values.email,
70
+ noEmail: values['no-email'],
71
+ social: values.social,
72
+ noSocial: values['no-social'],
65
73
  }, interactive ? terminalAsker((input) => projectProblem(input, cwd)) : undefined, cwd);
66
74
  if (!json && answers.types.length > 1) {
67
75
  prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
68
76
  }
69
- const { target, ...chosen } = answers;
77
+ const { target, warnings, ...chosen } = answers;
78
+ // Without --json a person is reading; with it, the warnings travel in the result instead.
79
+ if (!json) {
80
+ for (const warning of warnings) {
81
+ prompt.log.warn(warning);
82
+ }
83
+ }
70
84
  await generate({
71
85
  ...requireAssets(assetsFor(import.meta.dirname)),
72
86
  target,
@@ -77,7 +91,7 @@ async function runNew(args, json) {
77
91
  if (!json) {
78
92
  prompt.outro(`Created ${answers.name}.`);
79
93
  }
80
- return { data: { ...chosen, target, installed: install }, text: '' };
94
+ return { data: { ...chosen, target, installed: install, warnings }, text: '' };
81
95
  }
82
96
  async function runClean(args, json) {
83
97
  const { values } = parseArgs({
package/dist/commands.js CHANGED
@@ -18,6 +18,13 @@ export const COMMANDS = [
18
18
  { flag: '--single-tenant', description: 'No multi-tenancy conventions' },
19
19
  { flag: '--mcp', description: 'An MCP server on the api, authorized through the web' },
20
20
  { flag: '--no-mcp', description: 'No MCP server' },
21
+ {
22
+ flag: '--email',
23
+ description: 'Email verification and password reset, by a code sent through the Mailer port',
24
+ },
25
+ { flag: '--no-email', description: 'Neither' },
26
+ { flag: '--social <list>', description: 'Comma-separated: google, apple' },
27
+ { flag: '--no-social', description: 'No social sign-in' },
21
28
  { flag: '--skip-install', description: 'Do not run pnpm install' },
22
29
  JSON_OPTION,
23
30
  ],
package/dist/compose.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative, sep } from 'node:path';
4
+ import { applyFeatures, NONE } from "./features.js";
4
5
  import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
5
- import { applyMcp } from "./mcp.js";
6
6
  const CLIENTS = ['web', 'mobile'];
7
7
  // One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
8
8
  const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
@@ -182,14 +182,14 @@ async function assertCatalogCovers(target, catalog) {
182
182
  }
183
183
  }
184
184
  }
185
- export async function composeWorkspace({ templates, target, types, mcp = false, }) {
185
+ export async function composeWorkspace({ templates, target, types, features = NONE, }) {
186
186
  await mkdir(target, { recursive: true });
187
187
  await copyTemplate(join(templates, 'workspace'), target);
188
188
  const apps = [];
189
189
  for (const type of types) {
190
190
  const app = await copyApp(templates, target, type);
191
191
  // Before the catalog is collected, so a dependency that leaves never reaches it.
192
- await applyMcp(app, type, mcp);
192
+ await applyFeatures(app, type, features);
193
193
  apps.push(app);
194
194
  }
195
195
  const clients = CLIENTS.filter((client) => types.includes(client));
package/dist/context.js CHANGED
@@ -12,6 +12,8 @@ export function areasFor(answers) {
12
12
  ...(answers.architecture === 'monorepo' ? ['monorepo'] : []),
13
13
  ...(answers.multiTenant ? ['multi-tenancy'] : []),
14
14
  ...(answers.mcp ? ['mcp'] : []),
15
+ ...(answers.email ? ['email'] : []),
16
+ ...(answers.social.length > 0 ? ['social'] : []),
15
17
  ];
16
18
  }
17
19
  async function titleOf(path) {
@@ -36,6 +38,8 @@ export async function writeContext(knowledge, root, answers) {
36
38
  architecture: answers.architecture,
37
39
  multiTenant: answers.multiTenant,
38
40
  mcp: answers.mcp,
41
+ email: answers.email,
42
+ social: answers.social,
39
43
  };
40
44
  await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
41
45
  await writeFile(join(root, 'AGENTS.md'), `# ${answers.name}\n\nThis project's conventions live in \`.prumo/\`. Read [\`.prumo/INDEX.md\`](.prumo/INDEX.md) before changing how something is done.\n`);