@stjoseph/prumo 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/dist/mcp.js +0 -107
package/dist/features.js
ADDED
|
@@ -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
|
|
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);
|
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
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
|