@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.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- 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
|
|
56
|
-
reset
|
|
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
|
-
**
|
|
59
|
-
is **asynchronous** and its matchers need no setup.
|
|
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
|
|
15
|
-
`
|
|
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
|
-
|
|
53
|
-
|
|
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`
|
|
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
|
|
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
|
|
115
|
-
`multi-tenancy` and `
|
|
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,
|
|
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
|
|
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`);
|