@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
@@ -0,0 +1,230 @@
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
+ // What a project may leave out. The templates ship with every one of them; a project that answered no receives the
6
+ // template without it: these files deleted, these dependencies removed, and every line or block marked
7
+ // `// prumo:<feature>` cut. A feature a type does not list has nothing to cut there.
8
+ export const FEATURE_NAMES = ['mcp', 'email', 'social', 'google', 'apple'];
9
+ export const SOCIAL_PROVIDERS = ['google', 'apple'];
10
+ export const NONE = {
11
+ mcp: false,
12
+ email: false,
13
+ social: false,
14
+ google: false,
15
+ apple: false,
16
+ };
17
+ export function enabledFor(answers) {
18
+ return {
19
+ mcp: answers.mcp,
20
+ email: answers.email,
21
+ social: answers.social.length > 0,
22
+ google: answers.social.includes('google'),
23
+ apple: answers.social.includes('apple'),
24
+ };
25
+ }
26
+ export const FEATURES = {
27
+ // The API's MCP is its server and the OAuth authorization server that issues its tokens, with the table that
28
+ // holds an authorization in a migration of its own, so a project without MCP never creates it.
29
+ mcp: {
30
+ files: {
31
+ api: [
32
+ 'src/infra/mcp',
33
+ 'src/domain/oauth',
34
+ 'src/infra/oauth',
35
+ 'src/infra/di/oauth.di.ts',
36
+ 'src/infra/http/fetch-bridge.ts',
37
+ 'src/infra/http/controllers/mcp.controller.ts',
38
+ 'src/infra/http/controllers/oauth.controller.ts',
39
+ 'src/infra/database/mikroorm/entities/authorization.schema.ts',
40
+ 'src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts',
41
+ 'migrations/Migration20261005111547_create_authorization.ts',
42
+ 'test/domain/oauth',
43
+ 'test/infra/oauth',
44
+ 'test/infra/http/controllers/mcp.controller.spec.ts',
45
+ ],
46
+ web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
47
+ },
48
+ dependencies: {
49
+ api: ['@modelcontextprotocol/client', '@modelcontextprotocol/server'],
50
+ web: [],
51
+ },
52
+ },
53
+ // Verification and password reset: a code sent through the Mailer port, its table in a migration of its own.
54
+ email: {
55
+ files: {
56
+ api: [
57
+ 'src/domain/auth/entities/email-code.entity.ts',
58
+ 'src/domain/auth/ports/mailer.port.ts',
59
+ 'src/domain/auth/ports/one-time-codes.port.ts',
60
+ 'src/domain/auth/repositories/email-code.repository.ts',
61
+ 'src/domain/auth/errors/email-not-verified.error.ts',
62
+ 'src/domain/auth/errors/invalid-code.error.ts',
63
+ 'src/domain/auth/dto/email-request.dto.ts',
64
+ 'src/domain/auth/dto/verify-email.dto.ts',
65
+ 'src/domain/auth/dto/reset-password.dto.ts',
66
+ 'src/domain/auth/use-cases/issue-email-code.use-case.ts',
67
+ 'src/domain/auth/use-cases/request-email-verification.use-case.ts',
68
+ 'src/domain/auth/use-cases/verify-email.use-case.ts',
69
+ 'src/domain/auth/use-cases/request-password-reset.use-case.ts',
70
+ 'src/domain/auth/use-cases/reset-password.use-case.ts',
71
+ 'src/infra/email',
72
+ 'src/infra/di/email.di.ts',
73
+ 'src/infra/http/controllers/auth-email.controller.ts',
74
+ 'src/infra/database/mikroorm/entities/email-code.schema.ts',
75
+ 'src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts',
76
+ 'migrations/Migration20261005111549_create_email_code.ts',
77
+ 'test/domain/auth/use-cases/email.use-case.spec.ts',
78
+ 'test/infra/email',
79
+ 'test/infra/http/controllers/auth-email.controller.spec.ts',
80
+ 'test/support/fakes/fake-mailer.ts',
81
+ 'test/support/fakes/fake-one-time-codes.ts',
82
+ 'test/support/fakes/in-memory-email-code.repository.ts',
83
+ ],
84
+ web: [
85
+ 'src/features/auth/verify-email-form.tsx',
86
+ 'src/features/auth/forgot-password-form.tsx',
87
+ 'src/features/auth/reset-password-form.tsx',
88
+ 'src/routes/verify-email.tsx',
89
+ 'src/routes/forgot-password.tsx',
90
+ 'src/routes/reset-password.tsx',
91
+ 'test/features/auth/email-flows.spec.tsx',
92
+ ],
93
+ mobile: [
94
+ 'src/features/auth/verify-email-form.tsx',
95
+ 'src/features/auth/forgot-password-form.tsx',
96
+ 'src/features/auth/reset-password-form.tsx',
97
+ 'src/app/(auth)/verify-email.tsx',
98
+ 'src/app/(auth)/forgot-password.tsx',
99
+ 'src/app/(auth)/reset-password.tsx',
100
+ 'test/features/auth/email-flows.spec.tsx',
101
+ ],
102
+ },
103
+ dependencies: {},
104
+ },
105
+ // Sign-in with a provider: the OpenID adapter, the trip there and back, and the identities it links, with their
106
+ // tables in a migration of their own. Google and Apple are cut by their markers alone.
107
+ social: {
108
+ files: {
109
+ api: [
110
+ 'src/domain/auth/entities/identity.entity.ts',
111
+ 'src/domain/auth/entities/social-sign-in.entity.ts',
112
+ 'src/domain/auth/ports/identity-providers.port.ts',
113
+ 'src/domain/auth/repositories/identity.repository.ts',
114
+ 'src/domain/auth/repositories/social-sign-in.repository.ts',
115
+ 'src/domain/auth/errors/provider-not-configured.error.ts',
116
+ 'src/domain/auth/errors/provider-email-unverified.error.ts',
117
+ 'src/domain/auth/errors/social-sign-in-failed.error.ts',
118
+ 'src/domain/auth/use-cases/link-identity.use-case.ts',
119
+ 'src/domain/auth/use-cases/start-social-sign-in.use-case.ts',
120
+ 'src/domain/auth/use-cases/complete-social-sign-in.use-case.ts',
121
+ 'src/domain/auth/use-cases/exchange-social-code.use-case.ts',
122
+ 'src/infra/social',
123
+ 'src/infra/di/social.di.ts',
124
+ 'src/infra/http/controllers/social.controller.ts',
125
+ 'src/infra/database/mikroorm/entities/identity.schema.ts',
126
+ 'src/infra/database/mikroorm/entities/social-sign-in.schema.ts',
127
+ 'src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts',
128
+ 'src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts',
129
+ 'migrations/Migration20261005111550_create_identity_social_sign_in.ts',
130
+ 'test/domain/auth/use-cases/social.use-case.spec.ts',
131
+ 'test/infra/social',
132
+ 'test/infra/http/controllers/social.controller.spec.ts',
133
+ 'test/support/fakes/social-fakes.ts',
134
+ 'test/support/fake-oidc-server.ts',
135
+ ],
136
+ web: ['src/features/auth/social-buttons.tsx', 'test/features/auth/social-buttons.spec.tsx'],
137
+ mobile: [
138
+ 'src/features/auth/social-buttons.tsx',
139
+ 'test/features/auth/social-buttons.spec.tsx',
140
+ ],
141
+ },
142
+ dependencies: { api: ['openid-client'] },
143
+ },
144
+ google: { files: {}, dependencies: {} },
145
+ apple: { files: {}, dependencies: {} },
146
+ };
147
+ export function cutFeature(text, feature, keep, file = 'a file') {
148
+ // Inside JSX a comment is written {/* … */}, and in an env file with #, so a block may open and close any of
149
+ // these ways.
150
+ const start = new RegExp(`^\\s*(// prumo:${feature}|\\{/\\* prumo:${feature} \\*/\\}|# prumo:${feature})$`);
151
+ const end = new RegExp(`^\\s*(// prumo:end-${feature}|\\{/\\* prumo:end-${feature} \\*/\\}|# prumo:end-${feature})$`);
152
+ // A marked import keeps its marker as a trailing comment, which the import sorter carries along.
153
+ const marked = new RegExp(`\\s*(//|#) prumo:${feature}$`);
154
+ const kept = [];
155
+ let inBlock = false;
156
+ for (const line of text.split('\n')) {
157
+ if (start.test(line)) {
158
+ if (inBlock) {
159
+ throw new Error(`${file} opens a prumo:${feature} block inside another`);
160
+ }
161
+ inBlock = true;
162
+ }
163
+ else if (end.test(line)) {
164
+ if (!inBlock) {
165
+ throw new Error(`${file} closes a prumo:${feature} block it never opened`);
166
+ }
167
+ inBlock = false;
168
+ }
169
+ else if (marked.test(line)) {
170
+ if (keep) {
171
+ kept.push(line.replace(marked, ''));
172
+ }
173
+ }
174
+ else if (keep || !inBlock) {
175
+ kept.push(line);
176
+ }
177
+ }
178
+ if (inBlock) {
179
+ throw new Error(`${file} leaves a prumo:${feature} block open`);
180
+ }
181
+ return kept.join('\n');
182
+ }
183
+ async function sourceFiles(directory) {
184
+ if (!existsSync(directory)) {
185
+ return [];
186
+ }
187
+ const entries = await readdir(directory, { withFileTypes: true, recursive: true });
188
+ return entries
189
+ .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
190
+ .map((entry) => join(entry.parentPath, entry.name));
191
+ }
192
+ export async function applyFeatures(app, type, enabled) {
193
+ const sources = [
194
+ ...(await sourceFiles(join(app, 'src'))),
195
+ ...(await sourceFiles(join(app, 'test'))),
196
+ // The settings a feature needs are cut with it.
197
+ join(app, '.env.example'),
198
+ ];
199
+ const manifest = join(app, 'package.json');
200
+ let pkg = await readFile(manifest, 'utf8');
201
+ const dropped = [];
202
+ for (const feature of FEATURE_NAMES) {
203
+ if (enabled[feature]) {
204
+ continue;
205
+ }
206
+ for (const file of FEATURES[feature].files[type] ?? []) {
207
+ await rm(join(app, file), { recursive: true, force: true });
208
+ }
209
+ for (const dependency of FEATURES[feature].dependencies[type] ?? []) {
210
+ dropped.push(dependency);
211
+ for (const section of ['dependencies', 'devDependencies']) {
212
+ pkg = setJsonc(pkg, [section, dependency], undefined);
213
+ }
214
+ }
215
+ }
216
+ await writeFile(manifest, pkg);
217
+ for (const source of sources) {
218
+ if (!existsSync(source)) {
219
+ continue;
220
+ }
221
+ const text = await readFile(source, 'utf8');
222
+ const cut = FEATURE_NAMES.reduce((current, feature) => cutFeature(current, feature, enabled[feature], source), text);
223
+ if (dropped.some((dependency) => cut.includes(`'${dependency}`))) {
224
+ throw new Error(`${source} still imports a package that was removed, outside its marker`);
225
+ }
226
+ if (cut !== text) {
227
+ await writeFile(source, cut);
228
+ }
229
+ }
230
+ }
package/dist/generate.js CHANGED
@@ -6,8 +6,8 @@ import { join } from 'node:path';
6
6
  import { composeWorkspace, copyTemplate } from "./compose.js";
7
7
  import { writeContext } from "./context.js";
8
8
  import { SHELL } from "./doctor.js";
9
+ import { applyFeatures, enabledFor } from "./features.js";
9
10
  import { setJsonc } from "./jsonc.js";
10
- import { applyMcp } from "./mcp.js";
11
11
  import { schemeFor, targetProblem } from "./names.js";
12
12
  import { CliError } from "./output.js";
13
13
  async function rewrite(path, change) {
@@ -25,6 +25,19 @@ async function nameMobileApp(app, name) {
25
25
  });
26
26
  }
27
27
  const SECRET_LINE = /^JWT_SECRET=.*$/m;
28
+ const SCHEME_LINES = /^# The mobile app's scheme.*\nMOBILE_APP_SCHEME=.*\n/m;
29
+ // Social sign-in sends a mobile app back by its scheme, the one app.json was given. Without a mobile app the setting
30
+ // names nothing, so it goes. Without social sign-in its marker already took it.
31
+ async function settleMobileScheme(api, scheme) {
32
+ for (const file of ['.env.example', '.env']) {
33
+ const path = join(api, file);
34
+ if (existsSync(path)) {
35
+ await rewrite(path, (text) => scheme === null
36
+ ? text.replace(SCHEME_LINES, '')
37
+ : text.replace(/^MOBILE_APP_SCHEME=.*$/m, `MOBILE_APP_SCHEME=${scheme}`));
38
+ }
39
+ }
40
+ }
28
41
  // `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
29
42
  // Only the secret differs: a sample value is public, so every generated API draws its own.
30
43
  async function writeLocalEnv(app, type) {
@@ -115,7 +128,7 @@ async function write({ templates, knowledge, target, answers, install, childOutp
115
128
  const [only] = answers.types;
116
129
  if (answers.architecture === 'alone' && only !== undefined) {
117
130
  await copyTemplate(join(templates, only), target);
118
- await applyMcp(target, only, answers.mcp);
131
+ await applyFeatures(target, only, enabledFor(answers));
119
132
  await nameProject(target, answers.name);
120
133
  await writeLocalEnv(target, only);
121
134
  if (only === 'api') {
@@ -130,7 +143,7 @@ async function write({ templates, knowledge, target, answers, install, childOutp
130
143
  templates,
131
144
  target,
132
145
  types: answers.types,
133
- mcp: answers.mcp,
146
+ features: enabledFor(answers),
134
147
  });
135
148
  await nameProject(target, answers.name);
136
149
  for (const type of answers.types) {
@@ -138,6 +151,7 @@ async function write({ templates, knowledge, target, answers, install, childOutp
138
151
  }
139
152
  if (answers.types.includes('api')) {
140
153
  await nameCompose(join(target, 'apps', 'api'), answers.name);
154
+ await settleMobileScheme(join(target, 'apps', 'api'), answers.types.includes('mobile') ? schemeFor(answers.name) : null);
141
155
  }
142
156
  if (answers.types.includes('mobile')) {
143
157
  await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
package/dist/questions.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SOCIAL_PROVIDERS } from "./features.js";
1
2
  import { projectAt, targetProblem, validateProjectName } from "./names.js";
2
3
  import { CliError } from "./output.js";
3
4
  const TYPES = ['api', 'web', 'mobile', 'site'];
@@ -19,6 +20,17 @@ function parseTypes(value) {
19
20
  }
20
21
  return [...new Set(types)];
21
22
  }
23
+ function parseProviders(value) {
24
+ const providers = value
25
+ .split(',')
26
+ .map((provider) => provider.trim())
27
+ .filter((provider) => provider !== '');
28
+ const unknown = providers.filter((provider) => !SOCIAL_PROVIDERS.includes(provider));
29
+ if (providers.length === 0 || unknown.length > 0) {
30
+ throw new CliError('invalid_input', `Unknown provider: ${unknown.join(', ') || '(none)'}. Choose from ${SOCIAL_PROVIDERS.join(', ')}, or pass --no-social.`);
31
+ }
32
+ return [...new Set(providers)];
33
+ }
22
34
  export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
23
35
  if (flags.alone && flags.monorepo) {
24
36
  throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
@@ -26,6 +38,12 @@ export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
26
38
  if (flags.mcp && flags.noMcp) {
27
39
  throw new CliError('invalid_input', 'Choose --mcp or --no-mcp, not both.');
28
40
  }
41
+ if (flags.email && flags.noEmail) {
42
+ throw new CliError('invalid_input', 'Choose --email or --no-email, not both.');
43
+ }
44
+ if (flags.social !== undefined && flags.noSocial) {
45
+ throw new CliError('invalid_input', 'Choose --social or --no-social, not both.');
46
+ }
29
47
  if (flags.multiTenant && flags.singleTenant) {
30
48
  throw new CliError('invalid_input', 'Choose --multi-tenant or --single-tenant, not both.');
31
49
  }
@@ -96,5 +114,50 @@ export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
96
114
  else {
97
115
  missing('--mcp or --no-mcp');
98
116
  }
99
- return { name, types, architecture, multiTenant, mcp, target };
117
+ // Verification and password reset are the API's; every client of it then shows their screens.
118
+ let email = false;
119
+ if (!types.includes('api')) {
120
+ if (flags.email) {
121
+ throw new CliError('invalid_input', '--email needs api in --types.');
122
+ }
123
+ }
124
+ else if (flags.email || flags.noEmail) {
125
+ email = flags.email;
126
+ }
127
+ else if (asker !== undefined) {
128
+ email = await asker.email();
129
+ }
130
+ else {
131
+ missing('--email or --no-email');
132
+ }
133
+ // A provider sends the user back to a screen, so social sign-in needs a client beside the API.
134
+ const socialCapable = types.includes('api') && (types.includes('web') || types.includes('mobile'));
135
+ let social = [];
136
+ if (!socialCapable) {
137
+ if (flags.social !== undefined) {
138
+ throw new CliError('invalid_input', '--social needs api, and web or mobile, in --types.');
139
+ }
140
+ }
141
+ else if (flags.social !== undefined) {
142
+ social = parseProviders(flags.social);
143
+ }
144
+ else if (flags.noSocial) {
145
+ social = [];
146
+ }
147
+ else if (asker !== undefined) {
148
+ social = await asker.social();
149
+ }
150
+ else {
151
+ missing('--social <providers> or --no-social');
152
+ }
153
+ const warnings = [];
154
+ if (types.includes('mobile') && social.includes('google') && !social.includes('apple')) {
155
+ warnings.push('The App Store asks an app offering Google sign-in to offer an equivalent privacy-preserving login too ' +
156
+ '(App Review Guideline 4.8); Sign in with Apple is one. Add apple to --social before publishing on iOS.');
157
+ }
158
+ if (social.length > 0 && !email) {
159
+ warnings.push('Without --email no password account is ever confirmed, so the first sign-in with a provider for an existing ' +
160
+ "account's email removes its password: the owner keeps the account through the provider. --email avoids it.");
161
+ }
162
+ return { name, types, architecture, multiTenant, mcp, email, social, target, warnings };
100
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -11,3 +11,21 @@ POSTGRES_PORT=5432
11
11
  JWT_SECRET=replace-this-with-at-least-32-characters
12
12
  API_URL=http://localhost:3000
13
13
  WEB_ORIGIN=http://localhost:5173
14
+ # prumo:social
15
+ # The mobile app's scheme, as in app.json: where it is sent back after signing in with a provider.
16
+ MOBILE_APP_SCHEME=app
17
+ # prumo:end-social
18
+ # prumo:google
19
+ # From a Google Cloud OAuth client of type "Web application", with this redirect URI authorised:
20
+ # http://localhost:3000/api/auth/social/google/callback
21
+ # GOOGLE_CLIENT_ID=
22
+ # GOOGLE_CLIENT_SECRET=
23
+ # prumo:end-google
24
+ # prumo:apple
25
+ # From an Apple Services ID with Sign in with Apple and a key for it. Register this return URL on the Services ID,
26
+ # with the address Apple can reach your API at: <API_URL>/api/auth/social/apple/callback
27
+ # APPLE_CLIENT_ID=
28
+ # APPLE_TEAM_ID=
29
+ # APPLE_KEY_ID=
30
+ # APPLE_PRIVATE_KEY=
31
+ # prumo:end-apple
@@ -1,14 +1,14 @@
1
1
  import { Migration } from '@mikro-orm/migrations';
2
2
 
3
- export class Migration20261002210629_create_user_profile_session extends Migration {
3
+ export class Migration20261005111545_create_user_profile_session extends Migration {
4
4
 
5
- override name = 'Migration20261002210629_create_user_profile_session';
5
+ override name = 'Migration20261005111545_create_user_profile_session';
6
6
 
7
7
  override up(): void | Promise<void> {
8
- this.addSql(`create table "user" ("id" uuid not null default uuidv7(), "email" text not null, "password_hash" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
8
+ this.addSql(`create table "user" ("id" uuid not null default uuidv7(), "email" text not null, "password_hash" text null, "email_verified_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
9
9
  this.addSql(`alter table "user" add constraint "user_email_unique" unique ("email");`);
10
10
 
11
- this.addSql(`create table "session" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "token_hash" text not null, "client_id" text null, "resource" text null, "previous_token_hash" text null, "rotated_at" timestamptz null, "expires_at" timestamptz not null, "revoked_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
11
+ this.addSql(`create table "session" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "token_hash" text not null, "client_id" text null, "resource" text null, "scope" text null, "previous_token_hash" text null, "rotated_at" timestamptz null, "expires_at" timestamptz not null, "revoked_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
12
12
  this.addSql(`create index "session_user_id_index" on "session" ("user_id");`);
13
13
 
14
14
  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"));`);
@@ -1,11 +1,11 @@
1
1
  import { Migration } from '@mikro-orm/migrations';
2
2
 
3
- export class Migration20261002210631_create_authorization extends Migration {
3
+ export class Migration20261005111547_create_authorization extends Migration {
4
4
 
5
- override name = 'Migration20261002210631_create_authorization';
5
+ override name = 'Migration20261005111547_create_authorization';
6
6
 
7
7
  override up(): void | Promise<void> {
8
- this.addSql(`create table "authorization" ("id" uuid not null default uuidv7(), "client_id" text not null, "client_name" text not null, "redirect_uri" text not null, "code_challenge" text not null, "client_state" text null, "resource" text not null, "status" text not null, "user_id" uuid null, "code_hash" text null, "session_id" uuid null, "expires_at" timestamptz not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
8
+ this.addSql(`create table "authorization" ("id" uuid not null default uuidv7(), "client_id" text not null, "client_name" text not null, "redirect_uri" text not null, "code_challenge" text not null, "client_state" text null, "resource" text not null, "scope" text not null, "status" text not null, "user_id" uuid null, "code_hash" text null, "session_id" uuid null, "expires_at" timestamptz not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
9
9
  this.addSql(`create index "authorization_user_id_index" on "authorization" ("user_id");`);
10
10
 
11
11
  this.addSql(`alter table "authorization" add constraint "authorization_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
@@ -0,0 +1,18 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20261005111549_create_email_code extends Migration {
4
+
5
+ override name = 'Migration20261005111549_create_email_code';
6
+
7
+ override up(): void | Promise<void> {
8
+ this.addSql(`create table "email_code" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "purpose" text not null, "code_hash" text not null, "attempts_left" int not null, "expires_at" timestamptz not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
9
+ this.addSql(`alter table "email_code" add constraint "email_code_user_id_purpose_unique" unique ("user_id", "purpose");`);
10
+
11
+ this.addSql(`alter table "email_code" add constraint "email_code_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
12
+ }
13
+
14
+ override down(): void | Promise<void> {
15
+ this.addSql(`drop table if exists "email_code" cascade;`);
16
+ }
17
+
18
+ }
@@ -0,0 +1,25 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20261005111550_create_identity_social_sign_in extends Migration {
4
+
5
+ override name = 'Migration20261005111550_create_identity_social_sign_in';
6
+
7
+ override up(): void | Promise<void> {
8
+ this.addSql(`create table "social_sign_in" ("id" uuid not null default uuidv7(), "provider" text not null, "client" text not null, "return_to" text not null, "browser_hash" text not null, "code_verifier" text null, "nonce" text not null, "status" text not null, "user_id" uuid null, "exchange_hash" text null, "expires_at" timestamptz not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
9
+ this.addSql(`create index "social_sign_in_user_id_index" on "social_sign_in" ("user_id");`);
10
+
11
+ this.addSql(`create table "identity" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "provider" text not null, "subject" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
12
+ this.addSql(`create index "identity_user_id_index" on "identity" ("user_id");`);
13
+ this.addSql(`alter table "identity" add constraint "identity_provider_subject_unique" unique ("provider", "subject");`);
14
+
15
+ this.addSql(`alter table "social_sign_in" add constraint "social_sign_in_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
16
+
17
+ this.addSql(`alter table "identity" add constraint "identity_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
18
+ }
19
+
20
+ override down(): void | Promise<void> {
21
+ this.addSql(`drop table if exists "social_sign_in" cascade;`);
22
+ this.addSql(`drop table if exists "identity" cascade;`);
23
+ }
24
+
25
+ }
@@ -31,6 +31,7 @@
31
31
  "fastify": "5.12.5",
32
32
  "fastify-type-provider-zod": "7.0.0",
33
33
  "jose": "6.2.12",
34
+ "openid-client": "6.8.8",
34
35
  "reflect-metadata": "0.2.2",
35
36
  "tsyringe": "4.10.0",
36
37
  "zod": "4.6.4"
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod'
2
+
3
+ export const emailRequestSchema = z.strictObject({ email: z.email().max(254) })
4
+
5
+ export type EmailRequestDto = z.infer<typeof emailRequestSchema>
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const resetPasswordSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ code: z.string().regex(/^\d{6}$/),
6
+ password: z.string().min(8).max(128),
7
+ })
8
+
9
+ export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const verifyEmailSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ code: z.string().regex(/^\d{6}$/),
6
+ })
7
+
8
+ export type VerifyEmailDto = z.infer<typeof verifyEmailSchema>
@@ -0,0 +1,46 @@
1
+ // Short enough that guessing is a race against the clock, long enough to switch to the mail app and back.
2
+ export const EMAIL_CODE_LIFETIME_MS = 15 * 60 * 1000
3
+ export const EMAIL_CODE_ATTEMPTS = 5
4
+
5
+ export type EmailCodePurpose = 'verify-email' | 'reset-password'
6
+
7
+ export type CodeOutcome = 'valid' | 'wrong' | 'expired'
8
+
9
+ // One live code per user and purpose: sending another replaces it, and the old one stops working.
10
+ export class EmailCode {
11
+ id: string
12
+ userId: string
13
+ purpose: EmailCodePurpose
14
+ codeHash: string
15
+ attemptsLeft: number
16
+ expiresAt: Date
17
+ createdAt: Date
18
+ updatedAt: Date
19
+
20
+ constructor(userId: string, purpose: EmailCodePurpose, codeHash: string, now: Date) {
21
+ this.userId = userId
22
+ this.purpose = purpose
23
+ this.renew(codeHash, now)
24
+ }
25
+
26
+ renew(codeHash: string, now: Date): void {
27
+ this.codeHash = codeHash
28
+ this.attemptsLeft = EMAIL_CODE_ATTEMPTS
29
+ this.expiresAt = new Date(now.getTime() + EMAIL_CODE_LIFETIME_MS)
30
+ }
31
+
32
+ // Five wrong guesses out of a million leave nothing to brute-force: the code dies, and a new one must be sent.
33
+ check(codeHash: string, now: Date): CodeOutcome {
34
+ if (this.attemptsLeft <= 0 || this.expiresAt <= now) {
35
+ return 'expired'
36
+ }
37
+
38
+ if (codeHash === this.codeHash) {
39
+ this.attemptsLeft = 0
40
+ return 'valid'
41
+ }
42
+
43
+ this.attemptsLeft -= 1
44
+ return this.attemptsLeft === 0 ? 'expired' : 'wrong'
45
+ }
46
+ }
@@ -0,0 +1,18 @@
1
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
2
+
3
+ // A user as a provider knows them. The provider's subject, not the email, is what recognises them next time: an
4
+ // email can change at the provider, a subject cannot.
5
+ export class Identity {
6
+ id: string
7
+ userId: string
8
+ provider: ProviderName
9
+ subject: string
10
+ createdAt: Date
11
+ updatedAt: Date
12
+
13
+ constructor(userId: string, provider: ProviderName, subject: string) {
14
+ this.userId = userId
15
+ this.provider = provider
16
+ this.subject = subject
17
+ }
18
+ }
@@ -7,9 +7,9 @@ export const ROTATION_GRACE_MS = 10 * 1000
7
7
 
8
8
  export type RefreshOutcome = 'valid' | 'superseded' | 'reused' | 'expired'
9
9
 
10
- // A session the user started in this application has neither. One an OAuth client obtained names the client and
11
- // the resource its access tokens are for.
12
- export type Grant = { clientId: string; resource: string }
10
+ // A session the user started in this application has none of these. One an OAuth client obtained names the client,
11
+ // the resource its access tokens are for, and the scopes the user granted it, space-separated as OAuth writes them.
12
+ export type Grant = { clientId: string; resource: string; scope: string }
13
13
 
14
14
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
15
15
 
@@ -19,6 +19,7 @@ export class Session {
19
19
  tokenHash: string
20
20
  clientId: string | null = null
21
21
  resource: string | null = null
22
+ scope: string | null = null
22
23
  previousTokenHash: string | null = null
23
24
  rotatedAt: Date | null = null
24
25
  expiresAt: Date
@@ -31,6 +32,7 @@ export class Session {
31
32
  this.tokenHash = tokenHash
32
33
  this.clientId = grant?.clientId ?? null
33
34
  this.resource = grant?.resource ?? null
35
+ this.scope = grant?.scope ?? null
34
36
  this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
35
37
  }
36
38