@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.
- 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/monorepo/tasks.md +1 -1
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +9 -7
- package/dist/asker.js +13 -0
- package/dist/cli.js +25 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +6 -3
- package/dist/context.js +4 -0
- package/dist/features.js +234 -0
- package/dist/generate.js +17 -3
- package/dist/next-steps.js +24 -0
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/README.md +19 -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/README.md +16 -3
- package/templates/mobile/package.json +3 -2
- 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/README.md +5 -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/templates/workspace/README.md +10 -0
- package/dist/mcp.js +0 -107
package/dist/features.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
package/templates/api/README.md
CHANGED
|
@@ -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
|
|
3
|
+
export class Migration20261005111545_create_user_profile_session extends Migration {
|
|
4
4
|
|
|
5
|
-
override name = '
|
|
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
|
|
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
|
|
3
|
+
export class Migration20261005111547_create_authorization extends Migration {
|
|
4
4
|
|
|
5
|
-
override name = '
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|