@stjoseph/prumo 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.prumo-templates/api/auth.md +3 -2
  2. package/.prumo-templates/core/testing.md +4 -3
  3. package/.prumo-templates/email/codes.md +62 -0
  4. package/.prumo-templates/email/mailer.md +47 -0
  5. package/.prumo-templates/mcp/server.md +19 -5
  6. package/.prumo-templates/monorepo/tasks.md +1 -1
  7. package/.prumo-templates/social/sign-in.md +77 -0
  8. package/README.md +9 -7
  9. package/dist/asker.js +13 -0
  10. package/dist/cli.js +25 -2
  11. package/dist/commands.js +7 -0
  12. package/dist/compose.js +6 -3
  13. package/dist/context.js +4 -0
  14. package/dist/features.js +234 -0
  15. package/dist/generate.js +17 -3
  16. package/dist/next-steps.js +24 -0
  17. package/dist/questions.js +64 -1
  18. package/package.json +1 -1
  19. package/templates/api/.env.example +18 -0
  20. package/templates/api/README.md +19 -0
  21. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  22. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  23. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  24. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  25. package/templates/api/package.json +1 -0
  26. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  27. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  28. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  29. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  30. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  31. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  32. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  33. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  34. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  36. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  37. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  39. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  40. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  41. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  42. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  43. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  46. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  47. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  48. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  49. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  50. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  52. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  53. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  54. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  55. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  56. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  57. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  58. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  59. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  60. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  61. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  62. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  63. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  65. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  66. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  67. package/templates/api/src/infra/config/env.ts +18 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  73. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  74. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  77. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  78. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  79. package/templates/api/src/infra/di/email.di.ts +23 -0
  80. package/templates/api/src/infra/di/index.ts +4 -0
  81. package/templates/api/src/infra/di/social.di.ts +17 -0
  82. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  83. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  84. package/templates/api/src/infra/http/app.ts +4 -0
  85. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  89. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  90. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  91. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  92. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  93. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  94. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  95. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  96. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  97. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  98. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  99. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  100. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  101. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  102. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  103. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  104. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  105. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  106. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  107. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  108. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  109. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  110. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  111. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  112. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  114. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  115. package/templates/api/test/support/test-app.ts +65 -0
  116. package/templates/mobile/README.md +16 -3
  117. package/templates/mobile/package.json +3 -2
  118. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  119. package/templates/mobile/src/api-contract/auth.ts +63 -3
  120. package/templates/mobile/src/api-contract/index.ts +4 -0
  121. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  122. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  125. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  126. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  127. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  128. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  129. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  130. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  131. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  132. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  133. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  134. package/templates/web/README.md +5 -0
  135. package/templates/web/src/api-contract/api-error.ts +4 -0
  136. package/templates/web/src/api-contract/auth.ts +63 -3
  137. package/templates/web/src/api-contract/index.ts +4 -0
  138. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  139. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  141. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  142. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  143. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  144. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  145. package/templates/web/src/routeTree.gen.ts +73 -2
  146. package/templates/web/src/routes/forgot-password.tsx +17 -0
  147. package/templates/web/src/routes/reset-password.tsx +20 -0
  148. package/templates/web/src/routes/sign-in.tsx +10 -3
  149. package/templates/web/src/routes/sign-up.tsx +4 -0
  150. package/templates/web/src/routes/verify-email.tsx +20 -0
  151. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  152. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  153. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  154. package/templates/workspace/README.md +10 -0
  155. package/dist/mcp.js +0 -107
@@ -0,0 +1,234 @@
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, READMEs included. 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 {/* … */}, in an env file with #, and in Markdown as <!-- … -->, so a block may
149
+ // open and close any of these ways.
150
+ const start = new RegExp(`^\\s*(// prumo:${feature}|\\{/\\* prumo:${feature} \\*/\\}|# prumo:${feature}|<!-- prumo:${feature} -->)$`);
151
+ const end = new RegExp(`^\\s*(// prumo:end-${feature}|\\{/\\* 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
+ export function cutFeatures(text, enabled, file = 'a file') {
184
+ return FEATURE_NAMES.reduce((current, feature) => cutFeature(current, feature, enabled[feature], file), text);
185
+ }
186
+ async function sourceFiles(directory) {
187
+ if (!existsSync(directory)) {
188
+ return [];
189
+ }
190
+ const entries = await readdir(directory, { withFileTypes: true, recursive: true });
191
+ return entries
192
+ .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
193
+ .map((entry) => join(entry.parentPath, entry.name));
194
+ }
195
+ export async function applyFeatures(app, type, enabled) {
196
+ const sources = [
197
+ ...(await sourceFiles(join(app, 'src'))),
198
+ ...(await sourceFiles(join(app, 'test'))),
199
+ // The settings a feature needs are cut with it, and so is what the README says about it.
200
+ join(app, '.env.example'),
201
+ join(app, 'README.md'),
202
+ ];
203
+ const manifest = join(app, 'package.json');
204
+ let pkg = await readFile(manifest, 'utf8');
205
+ const dropped = [];
206
+ for (const feature of FEATURE_NAMES) {
207
+ if (enabled[feature]) {
208
+ continue;
209
+ }
210
+ for (const file of FEATURES[feature].files[type] ?? []) {
211
+ await rm(join(app, file), { recursive: true, force: true });
212
+ }
213
+ for (const dependency of FEATURES[feature].dependencies[type] ?? []) {
214
+ dropped.push(dependency);
215
+ for (const section of ['dependencies', 'devDependencies']) {
216
+ pkg = setJsonc(pkg, [section, dependency], undefined);
217
+ }
218
+ }
219
+ }
220
+ await writeFile(manifest, pkg);
221
+ for (const source of sources) {
222
+ if (!existsSync(source)) {
223
+ continue;
224
+ }
225
+ const text = await readFile(source, 'utf8');
226
+ const cut = cutFeatures(text, enabled, source);
227
+ if (dropped.some((dependency) => cut.includes(`'${dependency}`))) {
228
+ throw new Error(`${source} still imports a package that was removed, outside its marker`);
229
+ }
230
+ if (cut !== text) {
231
+ await writeFile(source, cut);
232
+ }
233
+ }
234
+ }
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);
@@ -0,0 +1,24 @@
1
+ import { relative } from 'node:path';
2
+ // What to type after `prumo new`, built from the scripts the templates ship rather than written per case. Every app
3
+ // and every workspace root answers to `pnpm dev`; an API's `dev` first offers to create the database, and a mobile
4
+ // app opens only in a development build, which its `ios` and `android` scripts make.
5
+ export function nextSteps({ target, cwd, types, architecture, installed, }) {
6
+ const steps = [];
7
+ const directory = relative(cwd, target);
8
+ if (directory !== '') {
9
+ steps.push([`cd ${/\s/.test(directory) ? `"${directory}"` : directory}`]);
10
+ }
11
+ if (!installed) {
12
+ steps.push(['pnpm install']);
13
+ }
14
+ if (types.includes('mobile')) {
15
+ const run = architecture === 'alone' ? 'pnpm' : 'pnpm --filter mobile';
16
+ steps.push([`${run} ios`, 'or android: the development build the mobile app opens in']);
17
+ }
18
+ steps.push([
19
+ 'pnpm dev',
20
+ types.includes('api') ? 'offers to create the database first' : undefined,
21
+ ]);
22
+ const width = Math.max(...steps.map(([command]) => command.length));
23
+ return steps.map(([command, why]) => why === undefined ? command : `${command.padEnd(width)} # ${why}`);
24
+ }
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.1",
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
@@ -32,6 +32,25 @@ it spawned, stops them, and frees the port. Anything it did not start is named a
32
32
 
33
33
  Authentication is the API's own: `/api/auth/sign-up`, `sign-in`, `refresh`, `sign-out` and `session`. The web
34
34
  receives httpOnly cookies; a native client sends `X-Auth-Transport: bearer` and receives the tokens in the body.
35
+ <!-- prumo:email -->
36
+
37
+ An account must confirm its email before it can sign in. `/api/auth/email/verification` and `/api/auth/email/verify`
38
+ send and check the 6-digit code, and `/api/auth/password/forgot` and `/api/auth/password/reset` do the same for a new
39
+ password. No mail is sent yet: the `Mailer` port's only adapter, `LogMailer`, writes each mail to this API's log, in
40
+ a line starting `[mail] to`, so read the code there. Sending real mail is another adapter behind the same port; see
41
+ `.prumo/email/mailer.md`.
42
+ <!-- prumo:end-email -->
43
+ <!-- prumo:social -->
44
+
45
+ Sign-in with a provider starts at `/api/auth/social/<provider>` and comes back to its `callback`. Until a provider's
46
+ settings are filled in `.env`, it answers `provider_not_configured`; `.env.example` lists them and says where each
47
+ one comes from.
48
+ <!-- prumo:end-social -->
49
+ <!-- prumo:mcp -->
50
+
51
+ The MCP server answers at `/api/mcp`. This API is also the OAuth authorization server that issues its tokens: an MCP
52
+ client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the web app.
53
+ <!-- prumo:end-mcp -->
35
54
 
36
55
  ## Everyday commands
37
56
 
@@ -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
+ }