@stjoseph/prumo 0.2.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 +51 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- 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 +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -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 +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -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/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- 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/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -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 +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.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/find-user.use-case.ts +20 -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 +94 -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 +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- 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-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -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/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -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 +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -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 +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -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 +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- 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/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -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 +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- 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 +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -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 +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- 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/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -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 +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- 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/app/_layout.tsx +5 -5
- 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 +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -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 +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- 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 +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- 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/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
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) {
|
|
@@ -24,7 +24,20 @@ async function nameMobileApp(app, name) {
|
|
|
24
24
|
return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
|
-
const SECRET_LINE = /^
|
|
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) {
|
|
@@ -35,9 +48,9 @@ async function writeLocalEnv(app, type) {
|
|
|
35
48
|
let env = await readFile(example, 'utf8');
|
|
36
49
|
if (type === 'api') {
|
|
37
50
|
if (!SECRET_LINE.test(env)) {
|
|
38
|
-
throw new Error(`${example} no longer carries a
|
|
51
|
+
throw new Error(`${example} no longer carries a JWT_SECRET line`);
|
|
39
52
|
}
|
|
40
|
-
env = env.replace(SECRET_LINE, `
|
|
53
|
+
env = env.replace(SECRET_LINE, `JWT_SECRET=${randomBytes(32).toString('base64url')}`);
|
|
41
54
|
}
|
|
42
55
|
await writeFile(join(app, '.env'), env);
|
|
43
56
|
}
|
|
@@ -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,8 +143,7 @@ async function write({ templates, knowledge, target, answers, install, childOutp
|
|
|
130
143
|
templates,
|
|
131
144
|
target,
|
|
132
145
|
types: answers.types,
|
|
133
|
-
|
|
134
|
-
mcp: answers.mcp,
|
|
146
|
+
features: enabledFor(answers),
|
|
135
147
|
});
|
|
136
148
|
await nameProject(target, answers.name);
|
|
137
149
|
for (const type of answers.types) {
|
|
@@ -139,6 +151,7 @@ async function write({ templates, knowledge, target, answers, install, childOutp
|
|
|
139
151
|
}
|
|
140
152
|
if (answers.types.includes('api')) {
|
|
141
153
|
await nameCompose(join(target, 'apps', 'api'), answers.name);
|
|
154
|
+
await settleMobileScheme(join(target, 'apps', 'api'), answers.types.includes('mobile') ? schemeFor(answers.name) : null);
|
|
142
155
|
}
|
|
143
156
|
if (answers.types.includes('mobile')) {
|
|
144
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
|
@@ -4,12 +4,28 @@ LOG_LEVEL=info
|
|
|
4
4
|
|
|
5
5
|
# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.
|
|
6
6
|
DATABASE_URL=MISSING
|
|
7
|
-
AUTH_DATABASE_URL=MISSING
|
|
8
7
|
# Read by docker-compose.yml, not by the API: the port Postgres is published on.
|
|
9
8
|
POSTGRES_PORT=5432
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Signs the access tokens. `prumo new` writes a random one into .env; never reuse it between environments.
|
|
11
|
+
JWT_SECRET=replace-this-with-at-least-32-characters
|
|
12
|
+
API_URL=http://localhost:3000
|
|
13
13
|
WEB_ORIGIN=http://localhost:5173
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
@@ -11,7 +11,7 @@ pnpm install
|
|
|
11
11
|
pnpm dev
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`.env` comes with the generated project, copied from `.env.example` with a random `
|
|
14
|
+
`.env` comes with the generated project, copied from `.env.example` with a random `JWT_SECRET`. It is not
|
|
15
15
|
committed, so on a fresh clone run `cp .env.example .env` and replace the secret with 32 or more random characters.
|
|
16
16
|
|
|
17
17
|
`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,
|
|
@@ -30,9 +30,8 @@ new port to `.env`, along with every URL that pointed at the old one. Without a
|
|
|
30
30
|
Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
|
|
31
31
|
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
still completes.
|
|
33
|
+
Authentication is the API's own: `/api/auth/sign-up`, `sign-in`, `refresh`, `sign-out` and `session`. The web
|
|
34
|
+
receives httpOnly cookies; a native client sends `X-Auth-Transport: bearer` and receives the tokens in the body.
|
|
36
35
|
|
|
37
36
|
## Everyday commands
|
|
38
37
|
|
|
@@ -42,7 +41,7 @@ still completes.
|
|
|
42
41
|
| `pnpm lint` | Biome |
|
|
43
42
|
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
44
43
|
| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |
|
|
45
|
-
| `pnpm db:migrate` |
|
|
44
|
+
| `pnpm db:migrate` | Runs the migrations |
|
|
46
45
|
| `pnpm db:migration:create` | Generates a migration from the entity diff, to be read and edited before committing |
|
|
47
46
|
|
|
48
47
|
## Conventions
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20261005111545_create_user_profile_session extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20261005111545_create_user_profile_session';
|
|
6
|
+
|
|
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 null, "email_verified_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
9
|
+
this.addSql(`alter table "user" add constraint "user_email_unique" unique ("email");`);
|
|
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, "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
|
+
this.addSql(`create index "session_user_id_index" on "session" ("user_id");`);
|
|
13
|
+
|
|
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"));`);
|
|
15
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_unique" unique ("user_id");`);
|
|
16
|
+
|
|
17
|
+
this.addSql(`alter table "session" add constraint "session_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
|
|
18
|
+
|
|
19
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete restrict;`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override down(): void | Promise<void> {
|
|
23
|
+
this.addSql(`alter table "session" drop constraint "session_user_id_foreign";`);
|
|
24
|
+
this.addSql(`alter table "profile" drop constraint "profile_user_id_foreign";`);
|
|
25
|
+
|
|
26
|
+
this.addSql(`drop table if exists "user" cascade;`);
|
|
27
|
+
this.addSql(`drop table if exists "session" cascade;`);
|
|
28
|
+
this.addSql(`drop table if exists "profile" cascade;`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20261005111547_create_authorization extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20261005111547_create_authorization';
|
|
6
|
+
|
|
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, "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
|
+
this.addSql(`create index "authorization_user_id_index" on "authorization" ("user_id");`);
|
|
10
|
+
|
|
11
|
+
this.addSql(`alter table "authorization" add constraint "authorization_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 "authorization" cascade;`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -13,26 +13,25 @@
|
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"test:cov": "vitest run --coverage",
|
|
15
15
|
"db:setup": "node scripts/database.mjs",
|
|
16
|
-
"db:migrate": "
|
|
16
|
+
"db:migrate": "mikro-orm migration:up",
|
|
17
17
|
"db:migration:create": "mikro-orm migration:create",
|
|
18
18
|
"prepare": "node .githooks/install.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@
|
|
22
|
-
"@better-auth/core": "1.7.7",
|
|
23
|
-
"@better-auth/expo": "1.7.7",
|
|
24
|
-
"@better-auth/mcp": "1.7.7",
|
|
21
|
+
"@fastify/cookie": "11.1.2",
|
|
25
22
|
"@fastify/cors": "11.3.0",
|
|
23
|
+
"@fastify/rate-limit": "11.2.0",
|
|
26
24
|
"@fastify/swagger": "9.9.0",
|
|
27
25
|
"@fastify/swagger-ui": "6.1.1",
|
|
28
26
|
"@mikro-orm/core": "7.2.0",
|
|
29
27
|
"@mikro-orm/migrations": "7.2.0",
|
|
30
28
|
"@mikro-orm/postgresql": "7.2.0",
|
|
31
29
|
"@modelcontextprotocol/server": "2.2.0",
|
|
32
|
-
"
|
|
30
|
+
"@node-rs/argon2": "2.2.1",
|
|
33
31
|
"fastify": "5.12.5",
|
|
34
32
|
"fastify-type-provider-zod": "7.0.0",
|
|
35
|
-
"
|
|
33
|
+
"jose": "6.2.12",
|
|
34
|
+
"openid-client": "6.8.8",
|
|
36
35
|
"reflect-metadata": "0.2.2",
|
|
37
36
|
"tsyringe": "4.10.0",
|
|
38
37
|
"zod": "4.6.4"
|
|
@@ -48,7 +47,7 @@
|
|
|
48
47
|
"@types/node": "24.13.4",
|
|
49
48
|
"@types/pg": "8.23.1",
|
|
50
49
|
"@vitest/coverage-v8": "4.1.11",
|
|
51
|
-
"
|
|
50
|
+
"pg": "8.23.0",
|
|
52
51
|
"pino-pretty": "13.1.3",
|
|
53
52
|
"typescript": "6.0.3",
|
|
54
53
|
"unplugin-swc": "1.6.0",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The server is always the one in docker-compose.yml, published on 5432 or, when that port is taken, the next free
|
|
7
7
|
// one, kept in POSTGRES_PORT. It needs no dependency, so it runs before `pnpm install` has installed anything.
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { existsSync,
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { connect } from 'node:net'
|
|
11
11
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
12
12
|
import { createInterface } from 'node:readline/promises'
|
|
@@ -259,17 +259,9 @@ async function main() {
|
|
|
259
259
|
say(`Created database ${name}.`)
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
// The image runs docker/init only for the database it creates on first start; every other one needs them too.
|
|
263
|
-
const init = join(api, 'docker', 'init')
|
|
264
|
-
for (const file of readdirSync(init)
|
|
265
|
-
.filter((entry) => entry.endsWith('.sql'))
|
|
266
|
-
.sort()) {
|
|
267
|
-
sql(name, readFileSync(join(init, file), 'utf8'))
|
|
268
|
-
}
|
|
269
|
-
|
|
270
262
|
const url = `postgresql://${USER}:${PASSWORD}@localhost:${port}/${name}`
|
|
271
|
-
writeEnv({ DATABASE_URL: url
|
|
272
|
-
say('Wrote DATABASE_URL
|
|
263
|
+
writeEnv({ DATABASE_URL: url })
|
|
264
|
+
say('Wrote DATABASE_URL to .env.')
|
|
273
265
|
|
|
274
266
|
let migrated = false
|
|
275
267
|
|