@stjoseph/prumo 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/monorepo/tasks.md +1 -1
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +9 -7
- package/dist/asker.js +13 -0
- package/dist/cli.js +25 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +6 -3
- package/dist/context.js +4 -0
- package/dist/features.js +234 -0
- package/dist/generate.js +17 -3
- package/dist/next-steps.js +24 -0
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/README.md +19 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +16 -3
- package/templates/mobile/package.json +3 -2
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/README.md +5 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/templates/workspace/README.md +10 -0
- package/dist/mcp.js +0 -107
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { fakeOidcServer } from '@test/support/fake-oidc-server'
|
|
2
|
+
import { testEnv } from '@test/support/setup'
|
|
3
|
+
// One line, so its marker takes all of it.
|
|
4
|
+
import * as jose from 'jose' // prumo:apple
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error' // prumo:google
|
|
7
|
+
import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
|
|
8
|
+
|
|
9
|
+
let server: Awaited<ReturnType<typeof fakeOidcServer>>
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await server.close()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function callback(provider: string, code: string, state: string): URL {
|
|
16
|
+
return new URL(
|
|
17
|
+
`http://localhost:3000/api/auth/social/${provider}/callback?code=${code}&state=${state}`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// prumo:google
|
|
22
|
+
describe('OpenIdIdentityProviders with Google', () => {
|
|
23
|
+
async function google() {
|
|
24
|
+
server = await fakeOidcServer({ pkce: true })
|
|
25
|
+
|
|
26
|
+
return new OpenIdIdentityProviders(
|
|
27
|
+
{ ...testEnv(), GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret' },
|
|
28
|
+
{ issuers: { google: server.issuer }, insecure: true },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('sends PKCE, state and nonce, and reads the verified profile back', async () => {
|
|
33
|
+
const providers = await google()
|
|
34
|
+
const redirect = await providers.redirect('google', 'state-1')
|
|
35
|
+
const url = new URL(redirect.url)
|
|
36
|
+
const code = server.authorize(redirect.url, {
|
|
37
|
+
sub: 'g-1',
|
|
38
|
+
email: 'ana@example.com',
|
|
39
|
+
email_verified: true,
|
|
40
|
+
name: 'Ana',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
|
44
|
+
expect(url.searchParams.get('redirect_uri')).toBe(
|
|
45
|
+
'http://localhost:3000/api/auth/social/google/callback',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const profile = await providers.profile('google', {
|
|
49
|
+
callbackUrl: callback('google', code, 'state-1'),
|
|
50
|
+
state: 'state-1',
|
|
51
|
+
codeVerifier: redirect.codeVerifier,
|
|
52
|
+
nonce: redirect.nonce,
|
|
53
|
+
name: null,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(profile).toEqual({
|
|
57
|
+
subject: 'g-1',
|
|
58
|
+
email: 'ana@example.com',
|
|
59
|
+
emailVerified: true,
|
|
60
|
+
name: 'Ana',
|
|
61
|
+
})
|
|
62
|
+
expect(server.tokenRequests[0]?.get('client_secret')).toBe('google-secret')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('refuses another state, another nonce and a code it did not issue', async () => {
|
|
66
|
+
const providers = await google()
|
|
67
|
+
const redirect = await providers.redirect('google', 'state-1')
|
|
68
|
+
const base = {
|
|
69
|
+
state: 'state-1',
|
|
70
|
+
codeVerifier: redirect.codeVerifier,
|
|
71
|
+
nonce: redirect.nonce,
|
|
72
|
+
name: null,
|
|
73
|
+
}
|
|
74
|
+
const claims = { sub: 'g-1', email: 'ana@example.com', email_verified: true }
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
providers.profile('google', {
|
|
78
|
+
...base,
|
|
79
|
+
callbackUrl: callback('google', server.authorize(redirect.url, claims), 'other-state'),
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
82
|
+
await expect(
|
|
83
|
+
providers.profile('google', {
|
|
84
|
+
...base,
|
|
85
|
+
nonce: 'other-nonce',
|
|
86
|
+
callbackUrl: callback('google', server.authorize(redirect.url, claims), 'state-1'),
|
|
87
|
+
}),
|
|
88
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
89
|
+
await expect(
|
|
90
|
+
providers.profile('google', {
|
|
91
|
+
...base,
|
|
92
|
+
callbackUrl: callback('google', 'forged', 'state-1'),
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('is not configured without its client id and secret', () => {
|
|
98
|
+
expect(new OpenIdIdentityProviders(testEnv()).isConfigured('google')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// prumo:end-google
|
|
103
|
+
// prumo:apple
|
|
104
|
+
describe('OpenIdIdentityProviders with Apple', () => {
|
|
105
|
+
it('signs its client secret with the .p8 key, asks for a form post, and reads "true" as verified', async () => {
|
|
106
|
+
server = await fakeOidcServer({ pkce: false })
|
|
107
|
+
const { privateKey, publicKey } = await jose.generateKeyPair('ES256', { extractable: true })
|
|
108
|
+
const providers = new OpenIdIdentityProviders(
|
|
109
|
+
{
|
|
110
|
+
...testEnv(),
|
|
111
|
+
APPLE_CLIENT_ID: 'com.example.web',
|
|
112
|
+
APPLE_TEAM_ID: 'TEAM123456',
|
|
113
|
+
APPLE_KEY_ID: 'KEY1234567',
|
|
114
|
+
// As it sits in .env: one line, its breaks written as \n.
|
|
115
|
+
APPLE_PRIVATE_KEY: (await jose.exportPKCS8(privateKey)).replaceAll('\n', '\\n'),
|
|
116
|
+
},
|
|
117
|
+
{ issuers: { apple: server.issuer }, insecure: true },
|
|
118
|
+
)
|
|
119
|
+
const redirect = await providers.redirect('apple', 'state-1')
|
|
120
|
+
const url = new URL(redirect.url)
|
|
121
|
+
const code = server.authorize(redirect.url, {
|
|
122
|
+
sub: 'a-1',
|
|
123
|
+
email: 'ana@privaterelay.appleid.com',
|
|
124
|
+
email_verified: 'true',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(url.searchParams.get('response_mode')).toBe('form_post')
|
|
128
|
+
expect(url.searchParams.get('code_challenge')).toBeNull()
|
|
129
|
+
expect(redirect.codeVerifier).toBeNull()
|
|
130
|
+
|
|
131
|
+
const profile = await providers.profile('apple', {
|
|
132
|
+
callbackUrl: callback('apple', code, 'state-1'),
|
|
133
|
+
state: 'state-1',
|
|
134
|
+
codeVerifier: null,
|
|
135
|
+
nonce: redirect.nonce,
|
|
136
|
+
name: 'Ana Apple',
|
|
137
|
+
})
|
|
138
|
+
const secret = server.tokenRequests[0]?.get('client_secret') ?? ''
|
|
139
|
+
const { payload } = await jose.jwtVerify(
|
|
140
|
+
secret,
|
|
141
|
+
await jose.importSPKI(await jose.exportSPKI(publicKey), 'ES256'),
|
|
142
|
+
{
|
|
143
|
+
issuer: 'TEAM123456',
|
|
144
|
+
subject: 'com.example.web',
|
|
145
|
+
audience: 'https://appleid.apple.com',
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(profile).toMatchObject({ subject: 'a-1', emailVerified: true, name: 'Ana Apple' })
|
|
150
|
+
expect(jose.decodeProtectedHeader(secret)).toMatchObject({ alg: 'ES256', kid: 'KEY1234567' })
|
|
151
|
+
expect(payload.exp).toBeGreaterThan(Date.now() / 1000)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
// prumo:end-apple
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createServer, type Server } from 'node:http'
|
|
3
|
+
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
|
|
4
|
+
|
|
5
|
+
type Grant = { nonce: string; challenge: string | null; claims: Record<string, unknown> }
|
|
6
|
+
|
|
7
|
+
// A minimal OpenID Connect provider on localhost: discovery, keys, and a token endpoint that signs an ID token for
|
|
8
|
+
// the code it handed out. Enough to run the real adapter end to end, which no test can do against Google or Apple.
|
|
9
|
+
export async function fakeOidcServer({ pkce }: { pkce: boolean }) {
|
|
10
|
+
const { privateKey, publicKey } = await generateKeyPair('RS256')
|
|
11
|
+
const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }
|
|
12
|
+
const grants = new Map<string, Grant>()
|
|
13
|
+
const tokenRequests: URLSearchParams[] = []
|
|
14
|
+
let issuer = ''
|
|
15
|
+
|
|
16
|
+
const server: Server = createServer(async (request, response) => {
|
|
17
|
+
const url = new URL(request.url ?? '/', issuer)
|
|
18
|
+
const json = (status: number, body: unknown) => {
|
|
19
|
+
response.writeHead(status, { 'content-type': 'application/json' })
|
|
20
|
+
response.end(JSON.stringify(body))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (url.pathname === '/.well-known/openid-configuration') {
|
|
24
|
+
return json(200, {
|
|
25
|
+
issuer,
|
|
26
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
27
|
+
token_endpoint: `${issuer}/token`,
|
|
28
|
+
jwks_uri: `${issuer}/jwks`,
|
|
29
|
+
response_types_supported: ['code'],
|
|
30
|
+
subject_types_supported: ['public'],
|
|
31
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
32
|
+
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
|
33
|
+
...(pkce && { code_challenge_methods_supported: ['S256'] }),
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (url.pathname === '/jwks') {
|
|
38
|
+
return json(200, { keys: [jwk] })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (url.pathname === '/token') {
|
|
42
|
+
let body = ''
|
|
43
|
+
for await (const chunk of request) body += chunk
|
|
44
|
+
const form = new URLSearchParams(body)
|
|
45
|
+
tokenRequests.push(form)
|
|
46
|
+
|
|
47
|
+
const grant = grants.get(form.get('code') ?? '')
|
|
48
|
+
const verifier = form.get('code_verifier')
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
grant === undefined ||
|
|
52
|
+
(grant.challenge !== null &&
|
|
53
|
+
createHash('sha256')
|
|
54
|
+
.update(verifier ?? '')
|
|
55
|
+
.digest('base64url') !== grant.challenge)
|
|
56
|
+
) {
|
|
57
|
+
return json(400, { error: 'invalid_grant' })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
grants.delete(form.get('code') ?? '')
|
|
61
|
+
|
|
62
|
+
const idToken = await new SignJWT({ nonce: grant.nonce, ...grant.claims })
|
|
63
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'k1' })
|
|
64
|
+
.setIssuer(issuer)
|
|
65
|
+
.setAudience(form.get('client_id') ?? '')
|
|
66
|
+
.setIssuedAt()
|
|
67
|
+
.setExpirationTime('5m')
|
|
68
|
+
.sign(privateKey)
|
|
69
|
+
|
|
70
|
+
return json(200, { access_token: 'access', token_type: 'Bearer', id_token: idToken })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
json(404, {})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
77
|
+
issuer = `http://127.0.0.1:${(server.address() as { port: number }).port}`
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
issuer: new URL(issuer),
|
|
81
|
+
tokenRequests,
|
|
82
|
+
// What the provider would do after the user signs in: remember the request and hand back a code.
|
|
83
|
+
authorize(authorizationUrl: string, claims: Record<string, unknown>): string {
|
|
84
|
+
const params = new URL(authorizationUrl).searchParams
|
|
85
|
+
const code = crypto.randomUUID()
|
|
86
|
+
|
|
87
|
+
grants.set(code, {
|
|
88
|
+
nonce: params.get('nonce') ?? '',
|
|
89
|
+
challenge: params.get('code_challenge'),
|
|
90
|
+
claims,
|
|
91
|
+
})
|
|
92
|
+
return code
|
|
93
|
+
},
|
|
94
|
+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { FakeAccessTokens } from '@test/support/fakes/fake-access-tokens'
|
|
2
|
+
import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
|
|
3
|
+
import { FakeOneTimeCodes } from '@test/support/fakes/fake-one-time-codes' // prumo:email
|
|
2
4
|
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
5
|
import { FakePasswordHasher } from '@test/support/fakes/fake-password-hasher'
|
|
4
6
|
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
7
|
+
import { InMemoryEmailCodeRepository } from '@test/support/fakes/in-memory-email-code.repository' // prumo:email
|
|
5
8
|
import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
|
|
6
9
|
import { InMemorySessionRepository } from '@test/support/fakes/in-memory-session.repository'
|
|
7
10
|
import { InMemoryUserRepository } from '@test/support/fakes/in-memory-user.repository'
|
|
11
|
+
import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
|
|
12
|
+
import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
|
|
8
13
|
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
14
|
+
import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case' // prumo:email
|
|
15
|
+
import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case' // prumo:email
|
|
9
16
|
import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
|
|
10
17
|
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
11
18
|
import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
|
|
12
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type SessionTokens,
|
|
21
|
+
StartSessionUseCase,
|
|
22
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
23
|
+
import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case' // prumo:email
|
|
13
24
|
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
14
25
|
|
|
15
26
|
// Every auth use case wired to in-memory ports, sharing one set of them as the container would.
|
|
@@ -22,21 +33,62 @@ export function authFakes() {
|
|
|
22
33
|
const opaque = new FakeOpaqueTokens()
|
|
23
34
|
const transactions = new ImmediateTransactionManager()
|
|
24
35
|
const startSession = new StartSessionUseCase(sessions, tokens, opaque)
|
|
36
|
+
// prumo:email
|
|
37
|
+
const mailer = new FakeMailer()
|
|
38
|
+
const codes = new InMemoryEmailCodeRepository()
|
|
39
|
+
const oneTimeCodes = new FakeOneTimeCodes()
|
|
40
|
+
const issueEmailCode = new IssueEmailCodeUseCase(codes, oneTimeCodes, mailer, transactions)
|
|
41
|
+
const verifyEmail = new VerifyEmailUseCase(users, codes, oneTimeCodes, transactions, startSession)
|
|
42
|
+
// prumo:end-email
|
|
43
|
+
const signUp = new SignUpUseCase(
|
|
44
|
+
users,
|
|
45
|
+
hasher,
|
|
46
|
+
transactions,
|
|
47
|
+
new CreateProfileUseCase(profiles),
|
|
48
|
+
startSession,
|
|
49
|
+
issueEmailCode, // prumo:email
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Signs up and, where the email must be confirmed first, confirms it: a session either way.
|
|
53
|
+
async function register(request: SignUpDto): Promise<SessionTokens> {
|
|
54
|
+
const started = await signUp.execute(request)
|
|
55
|
+
|
|
56
|
+
// prumo:email
|
|
57
|
+
if (started === null) {
|
|
58
|
+
return verifyEmail.execute({ email: request.email, code: mailer.lastCode(request.email) })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// prumo:end-email
|
|
62
|
+
if (started === null) {
|
|
63
|
+
throw new Error('Sign-up opened no session')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return started
|
|
67
|
+
}
|
|
25
68
|
|
|
26
69
|
return {
|
|
27
70
|
users,
|
|
28
71
|
sessions,
|
|
29
72
|
profiles,
|
|
30
73
|
hasher,
|
|
31
|
-
signUp
|
|
74
|
+
signUp,
|
|
75
|
+
register,
|
|
76
|
+
signIn: new SignInUseCase(users, hasher, startSession),
|
|
77
|
+
refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
|
|
78
|
+
signOut: new SignOutUseCase(sessions),
|
|
79
|
+
// prumo:email
|
|
80
|
+
mailer,
|
|
81
|
+
verifyEmail,
|
|
82
|
+
requestPasswordReset: new RequestPasswordResetUseCase(users, issueEmailCode),
|
|
83
|
+
resetPassword: new ResetPasswordUseCase(
|
|
32
84
|
users,
|
|
85
|
+
codes,
|
|
86
|
+
sessions,
|
|
87
|
+
oneTimeCodes,
|
|
33
88
|
hasher,
|
|
34
89
|
transactions,
|
|
35
|
-
new CreateProfileUseCase(profiles),
|
|
36
90
|
startSession,
|
|
37
91
|
),
|
|
38
|
-
|
|
39
|
-
refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
|
|
40
|
-
signOut: new SignOutUseCase(sessions),
|
|
92
|
+
// prumo:end-email
|
|
41
93
|
}
|
|
42
94
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
|
|
2
|
+
|
|
3
|
+
// Keeps what would have been sent, so a test reads the code the way a person reads their inbox.
|
|
4
|
+
export class FakeMailer implements Mailer {
|
|
5
|
+
readonly sent: Mail[] = []
|
|
6
|
+
|
|
7
|
+
async send(mail: Mail): Promise<void> {
|
|
8
|
+
this.sent.push(mail)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lastCode(to: string): string {
|
|
12
|
+
const mail = this.sent.findLast((each) => each.to === to)
|
|
13
|
+
const code = mail === undefined ? undefined : /\b\d{6}\b/.exec(mail.text)?.[0]
|
|
14
|
+
|
|
15
|
+
if (code === undefined) {
|
|
16
|
+
throw new Error(`No code was sent to ${to}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
2
|
+
|
|
3
|
+
export class FakeOneTimeCodes implements OneTimeCodes {
|
|
4
|
+
private next = 100000
|
|
5
|
+
|
|
6
|
+
create(): string {
|
|
7
|
+
this.next += 1
|
|
8
|
+
return String(this.next)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(code: string): string {
|
|
12
|
+
return `digest:${code}`
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
|
|
2
|
+
import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
|
|
3
|
+
|
|
4
|
+
export class InMemoryEmailCodeRepository implements EmailCodeRepository {
|
|
5
|
+
readonly rows = new Map<string, EmailCode>()
|
|
6
|
+
|
|
7
|
+
async lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
|
|
8
|
+
return this.rows.get(`${userId}:${purpose}`) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async save(code: EmailCode): Promise<void> {
|
|
12
|
+
code.id ??= crypto.randomUUID()
|
|
13
|
+
this.rows.set(`${code.userId}:${code.purpose}`, code)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -16,4 +16,12 @@ export class InMemorySessionRepository implements SessionRepository {
|
|
|
16
16
|
session.id ??= crypto.randomUUID()
|
|
17
17
|
this.rows.set(session.id, session)
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
async revokeAllFor(userId: string, now: Date): Promise<void> {
|
|
21
|
+
for (const session of this.rows.values()) {
|
|
22
|
+
if (session.userId === userId) {
|
|
23
|
+
session.revoke(now)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
19
27
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
|
+
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
4
|
+
import type { Identity } from '@/domain/auth/entities/identity.entity'
|
|
5
|
+
import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
6
|
+
import type {
|
|
7
|
+
IdentityProviders,
|
|
8
|
+
ProviderCallback,
|
|
9
|
+
ProviderName,
|
|
10
|
+
ProviderProfile,
|
|
11
|
+
ProviderRedirect,
|
|
12
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
13
|
+
import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
|
|
14
|
+
import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
|
|
15
|
+
import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
|
|
16
|
+
import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
|
|
17
|
+
import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
|
|
18
|
+
import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
|
|
19
|
+
import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
|
|
20
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
21
|
+
import { FakeAccessTokens } from './fake-access-tokens'
|
|
22
|
+
|
|
23
|
+
export class InMemoryIdentityRepository implements IdentityRepository {
|
|
24
|
+
readonly rows: Identity[] = []
|
|
25
|
+
|
|
26
|
+
async findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
|
|
27
|
+
return this.rows.find((row) => row.provider === provider && row.subject === subject) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async save(identity: Identity): Promise<void> {
|
|
31
|
+
identity.id ??= crypto.randomUUID()
|
|
32
|
+
this.rows.push(identity)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InMemorySocialSignInRepository implements SocialSignInRepository {
|
|
37
|
+
readonly rows = new Map<string, SocialSignIn>()
|
|
38
|
+
|
|
39
|
+
async lockById(id: string): Promise<SocialSignIn | null> {
|
|
40
|
+
return this.rows.get(id) ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async save(signIn: SocialSignIn): Promise<void> {
|
|
44
|
+
signIn.id ??= crypto.randomUUID()
|
|
45
|
+
this.rows.set(signIn.id, signIn)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A provider that answers what the test tells it to, and remembers what it was asked.
|
|
50
|
+
export class FakeIdentityProviders implements IdentityProviders {
|
|
51
|
+
next: ProviderProfile = {
|
|
52
|
+
subject: 'google-sub-1',
|
|
53
|
+
email: 'ana@example.com',
|
|
54
|
+
emailVerified: true,
|
|
55
|
+
name: 'Ana',
|
|
56
|
+
}
|
|
57
|
+
lastCallback: ProviderCallback | undefined
|
|
58
|
+
|
|
59
|
+
constructor(private readonly configured: ProviderName[] = ['google', 'apple']) {}
|
|
60
|
+
|
|
61
|
+
isConfigured(provider: ProviderName): boolean {
|
|
62
|
+
return this.configured.includes(provider)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
|
|
66
|
+
return {
|
|
67
|
+
url: `https://${provider}.test/authorize?state=${state}`,
|
|
68
|
+
codeVerifier: 'verifier',
|
|
69
|
+
nonce: 'nonce',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async profile(_provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
|
|
74
|
+
this.lastCallback = callback
|
|
75
|
+
return this.next
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function socialFakes() {
|
|
80
|
+
const auth = authFakes()
|
|
81
|
+
const identities = new InMemoryIdentityRepository()
|
|
82
|
+
const signIns = new InMemorySocialSignInRepository()
|
|
83
|
+
const providers = new FakeIdentityProviders()
|
|
84
|
+
const opaque = new FakeOpaqueTokens()
|
|
85
|
+
const transactions = new ImmediateTransactionManager()
|
|
86
|
+
const startSession = new StartSessionUseCase(auth.sessions, new FakeAccessTokens(), opaque)
|
|
87
|
+
const linkIdentity = new LinkIdentityUseCase(
|
|
88
|
+
auth.users,
|
|
89
|
+
identities,
|
|
90
|
+
auth.sessions,
|
|
91
|
+
transactions,
|
|
92
|
+
new CreateProfileUseCase(auth.profiles),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...auth,
|
|
97
|
+
identities,
|
|
98
|
+
signIns,
|
|
99
|
+
providers,
|
|
100
|
+
linkIdentity,
|
|
101
|
+
start: new StartSocialSignInUseCase(providers, signIns, opaque),
|
|
102
|
+
complete: new CompleteSocialSignInUseCase(
|
|
103
|
+
providers,
|
|
104
|
+
signIns,
|
|
105
|
+
opaque,
|
|
106
|
+
transactions,
|
|
107
|
+
linkIdentity,
|
|
108
|
+
startSession,
|
|
109
|
+
),
|
|
110
|
+
exchange: new ExchangeSocialCodeUseCase(signIns, opaque, transactions, startSession),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
|
|
2
|
+
import { testEnv, testOrm } from '@test/support/setup'
|
|
3
|
+
import type { DependencyContainer } from 'tsyringe'
|
|
4
|
+
import { MAILER } from '@/domain/auth/ports/mailer.port' // prumo:email
|
|
5
|
+
import type { Env } from '@/infra/config/env'
|
|
6
|
+
import { createContainer } from '@/infra/di'
|
|
7
|
+
import { buildApp } from '@/infra/http/app'
|
|
8
|
+
|
|
9
|
+
export const ORIGIN = 'http://localhost:5173'
|
|
10
|
+
export const PASSWORD = 'correct-horse-battery'
|
|
11
|
+
|
|
12
|
+
// The application as a test meets it: the real container over the test database, with a mailer whose inbox the
|
|
13
|
+
// test can read.
|
|
14
|
+
export async function testApp({
|
|
15
|
+
env = testEnv(),
|
|
16
|
+
configure,
|
|
17
|
+
}: {
|
|
18
|
+
env?: Env
|
|
19
|
+
configure?: (container: DependencyContainer) => void
|
|
20
|
+
} = {}) {
|
|
21
|
+
const container = createContainer({ env, orm: testOrm() })
|
|
22
|
+
// prumo:email
|
|
23
|
+
const mailer = new FakeMailer()
|
|
24
|
+
|
|
25
|
+
container.register(MAILER, { useValue: mailer })
|
|
26
|
+
// prumo:end-email
|
|
27
|
+
configure?.(container)
|
|
28
|
+
|
|
29
|
+
const app = await buildApp(container)
|
|
30
|
+
|
|
31
|
+
// Signs up through the real routes and, where the email must be confirmed first, confirms it. Returns the
|
|
32
|
+
// response that delivered the session: cookies for the web, tokens in the body for a bearer client.
|
|
33
|
+
async function signUp({ bearer = false, email = `${crypto.randomUUID()}@example.com` } = {}) {
|
|
34
|
+
const headers = bearer ? { 'x-auth-transport': 'bearer' } : { origin: ORIGIN }
|
|
35
|
+
let response = await app.inject({
|
|
36
|
+
method: 'POST',
|
|
37
|
+
url: '/api/auth/sign-up',
|
|
38
|
+
headers,
|
|
39
|
+
payload: { name: 'Ana', email, password: PASSWORD },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// prumo:email
|
|
43
|
+
if (response.body.includes('verificationRequired')) {
|
|
44
|
+
response = await app.inject({
|
|
45
|
+
method: 'POST',
|
|
46
|
+
url: '/api/auth/email/verify',
|
|
47
|
+
headers,
|
|
48
|
+
payload: { email, code: mailer.lastCode(email) },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// prumo:end-email
|
|
53
|
+
return { response, email }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
app,
|
|
58
|
+
signUp,
|
|
59
|
+
mailer, // prumo:email
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cookieHeader(response: { cookies: { name: string; value: string }[] }): string {
|
|
64
|
+
return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
|
|
65
|
+
}
|
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.18 or later, pnpm 10.26 or later, and the API running.
|
|
6
|
-
|
|
5
|
+
Node 22.18 or later, pnpm 10.26 or later, and the API running. To open the app, a simulator or a device with a
|
|
6
|
+
development build of it, made on this machine with Xcode for iOS or Android Studio for Android. Expo Go cannot open
|
|
7
|
+
it, because MMKV's native code is not part of Expo Go.
|
|
7
8
|
|
|
8
9
|
## Running it
|
|
9
10
|
|
|
10
11
|
```sh
|
|
11
12
|
pnpm install
|
|
12
|
-
pnpm
|
|
13
|
+
pnpm ios # or pnpm android: builds the development build, installs it, and serves it
|
|
14
|
+
pnpm dev # afterwards, until a native dependency changes
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+
`pnpm ios` and `pnpm android` run `expo run:ios` and `expo run:android`, which generate the native project the first
|
|
18
|
+
time and compile it, so the first run is the slow one. `pnpm dev` then only serves the JavaScript, and the installed
|
|
19
|
+
build opens it, because `expo-dev-client` makes `expo start` target the development build rather than Expo Go.
|
|
20
|
+
Build again after adding or upgrading a package with native code.
|
|
21
|
+
|
|
15
22
|
`.env` comes with the generated project, copied from `.env.example`; `EXPO_PUBLIC_API_URL` points at the API. It is
|
|
16
23
|
not committed, so on a fresh clone run `cp .env.example .env`.
|
|
17
24
|
|
|
@@ -26,11 +33,17 @@ Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals:
|
|
|
26
33
|
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
27
34
|
|
|
28
35
|
On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
|
|
36
|
+
<!-- prumo:email -->
|
|
37
|
+
|
|
38
|
+
Signing up first asks for the 6-digit code that confirms the email. No mail is sent yet: the API writes it to its
|
|
39
|
+
log, in a line starting `[mail] to`, and a forgotten password works the same way.
|
|
40
|
+
<!-- prumo:end-email -->
|
|
29
41
|
|
|
30
42
|
## Everyday commands
|
|
31
43
|
|
|
32
44
|
| Command | What it does |
|
|
33
45
|
|---|---|
|
|
46
|
+
| `pnpm ios` · `pnpm android` | Builds and installs the development build, then serves it. Xcode or Android Studio |
|
|
34
47
|
| `pnpm test` | Jest with `jest-expo`, rendering components against a fake transport; no API needed |
|
|
35
48
|
| `pnpm lint` | Biome |
|
|
36
49
|
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "expo start",
|
|
8
8
|
"dev": "node scripts/ports.mjs -- expo start",
|
|
9
|
-
"ios": "
|
|
10
|
-
"android": "
|
|
9
|
+
"ios": "node scripts/ports.mjs -- expo run:ios",
|
|
10
|
+
"android": "node scripts/ports.mjs -- expo run:android",
|
|
11
11
|
"bundle": "expo export --platform ios --platform android",
|
|
12
12
|
"lint": "biome check",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"class-variance-authority": "0.7.1",
|
|
23
23
|
"expo": "57.0.22",
|
|
24
24
|
"expo-constants": "57.0.18",
|
|
25
|
+
"expo-dev-client": "57.0.19",
|
|
25
26
|
"expo-linking": "57.0.10",
|
|
26
27
|
"expo-router": "57.0.21",
|
|
27
28
|
"expo-secure-store": "57.0.4",
|
|
@@ -5,6 +5,7 @@ type ProblemDocument = {
|
|
|
5
5
|
status?: number
|
|
6
6
|
detail?: string
|
|
7
7
|
requestId?: string
|
|
8
|
+
code?: string
|
|
8
9
|
errors?: FieldErrors
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -15,6 +16,8 @@ export class ApiError extends Error {
|
|
|
15
16
|
readonly detail: string | undefined,
|
|
16
17
|
readonly requestId: string | undefined,
|
|
17
18
|
readonly errors: FieldErrors,
|
|
19
|
+
// A stable name for a refusal the client acts on, such as `email_not_verified`.
|
|
20
|
+
readonly code?: string,
|
|
18
21
|
) {
|
|
19
22
|
super(detail ?? title)
|
|
20
23
|
}
|
|
@@ -35,6 +38,7 @@ export class ApiError extends Error {
|
|
|
35
38
|
problem.detail,
|
|
36
39
|
problem.requestId ?? requestId,
|
|
37
40
|
problem.errors ?? {},
|
|
41
|
+
problem.code,
|
|
38
42
|
)
|
|
39
43
|
}
|
|
40
44
|
}
|