@strav/jina 0.1.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/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @stravigor/jina
2
+
3
+ Headless authentication flows for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Registration, login, logout, password reset, email verification, two-factor authentication (TOTP), password confirmation, and profile updates — all as JSON API endpoints.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @stravigor/jina
9
+ bun strav install jina
10
+ ```
11
+
12
+ Requires `@stravigor/core` as a peer dependency.
13
+
14
+ ## Setup
15
+
16
+ ```ts
17
+ import { defineActions } from '@stravigor/jina'
18
+ import User from './models/user'
19
+
20
+ const actions = defineActions<User>({
21
+ async createUser(data) { return User.create(data) },
22
+ async findByEmail(email) { return User.query().where('email', email).first() },
23
+ async findById(id) { return User.find(id) },
24
+ passwordHashOf(user) { return user.password },
25
+ emailOf(user) { return user.email },
26
+ async updatePassword(user, pw) { user.password = pw; await user.save() },
27
+ })
28
+ ```
29
+
30
+ ```ts
31
+ import { JinaProvider } from '@stravigor/jina'
32
+
33
+ app.use(new JinaProvider(actions))
34
+ ```
35
+
36
+ ## Routes
37
+
38
+ Routes are registered automatically:
39
+
40
+ | Method | Path | Feature |
41
+ |--------|------|---------|
42
+ | POST | `/register` | registration |
43
+ | POST | `/login` | login |
44
+ | POST | `/logout` | logout |
45
+ | POST | `/forgot-password` | password-reset |
46
+ | POST | `/reset-password` | password-reset |
47
+ | POST | `/email/send` | email-verification |
48
+ | GET | `/email/verify/:token` | email-verification |
49
+ | POST | `/two-factor/enable` | two-factor |
50
+ | POST | `/two-factor/confirm` | two-factor |
51
+ | DELETE | `/two-factor` | two-factor |
52
+ | POST | `/two-factor/challenge` | two-factor |
53
+ | POST | `/confirm-password` | password-confirmation |
54
+ | PUT | `/password` | update-password |
55
+ | PUT | `/profile` | update-profile |
56
+
57
+ ## Middleware
58
+
59
+ ```ts
60
+ import { verified, confirmed, twoFactorChallenge } from '@stravigor/jina'
61
+
62
+ router.group({ middleware: [auth(), verified()] }, r => {
63
+ r.delete('/account', compose([confirmed()], deleteAccountHandler))
64
+ })
65
+ ```
66
+
67
+ ## Documentation
68
+
69
+ See the full [Jina guide](../../guides/jina.md).
70
+
71
+ ## License
72
+
73
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@strav/jina",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Headless authentication flows for the Strav framework",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./*": "./src/*.ts"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "stubs/",
14
+ "package.json",
15
+ "tsconfig.json"
16
+ ],
17
+ "peerDependencies": {
18
+ "@strav/kernel": "0.1.0",
19
+ "@strav/http": "0.1.0",
20
+ "@strav/signal": "0.1.0",
21
+ "@strav/database": "0.1.0"
22
+ },
23
+ "scripts": {
24
+ "test": "bun test tests/",
25
+ "typecheck": "tsc --noEmit"
26
+ }
27
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { JinaActions } from './types.ts'
2
+
3
+ /**
4
+ * Type-safe identity function for defining Jina actions.
5
+ * Zero runtime cost — just provides autocompletion and type checking.
6
+ *
7
+ * @example
8
+ * import { defineActions } from '@stravigor/jina'
9
+ * import { User } from '../models/user'
10
+ *
11
+ * export default defineActions<User>({
12
+ * createUser: async (data) => User.create({ ... }),
13
+ * findByEmail: (email) => User.query().where('email', email).first(),
14
+ * findById: (id) => User.find(id),
15
+ * passwordHashOf: (user) => user.password,
16
+ * emailOf: (user) => user.email,
17
+ * updatePassword: async (user, pw) => { user.password = await encrypt.hash(pw); await user.save() },
18
+ * })
19
+ */
20
+ export function defineActions<TUser = unknown>(actions: JinaActions<TUser>): JinaActions<TUser> {
21
+ return actions
22
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { StravError } from '@stravigor/kernel'
2
+
3
+ /** Base error for all Jina errors. */
4
+ export class JinaError extends StravError {}
5
+
6
+ /** Thrown when a required action is missing for an enabled feature. */
7
+ export class MissingActionError extends JinaError {
8
+ constructor(action: string, feature: string) {
9
+ super(`Jina action "${action}" is required when the "${feature}" feature is enabled.`)
10
+ }
11
+ }
12
+
13
+ /** Thrown when input validation fails. */
14
+ export class ValidationError extends JinaError {
15
+ constructor(
16
+ message: string,
17
+ public readonly errors: Record<string, string> = {}
18
+ ) {
19
+ super(message)
20
+ }
21
+ }
@@ -0,0 +1,30 @@
1
+ import { encrypt, Emitter } from '@stravigor/kernel'
2
+ import type { Context, Session } from '@stravigor/http'
3
+ import JinaManager from '../jina_manager.ts'
4
+ import { JinaEvents } from '../types.ts'
5
+
6
+ export async function confirmPasswordHandler(ctx: Context): Promise<Response> {
7
+ const body = await ctx.body<{ password?: string }>()
8
+
9
+ if (!body.password) {
10
+ return ctx.json({ message: 'Password is required.' }, 422)
11
+ }
12
+
13
+ const user = ctx.get('user')
14
+ const hash = JinaManager.actions.passwordHashOf(user)
15
+ const valid = await encrypt.verify(body.password, hash)
16
+
17
+ if (!valid) {
18
+ return ctx.json({ message: 'Invalid password.' }, 422)
19
+ }
20
+
21
+ // Store confirmation timestamp in session
22
+ const session = ctx.get<Session>('session')
23
+ session.set('_jina_confirmed_at', Date.now())
24
+
25
+ if (Emitter.listenerCount(JinaEvents.PASSWORD_CONFIRMED) > 0) {
26
+ Emitter.emit(JinaEvents.PASSWORD_CONFIRMED, { user, ctx }).catch(() => {})
27
+ }
28
+
29
+ return ctx.json({ message: 'Password confirmed.' })
30
+ }
@@ -0,0 +1,37 @@
1
+ import { mail } from '@stravigor/signal'
2
+ import { extractUserId } from '@stravigor/database'
3
+ import type { Context } from '@stravigor/http'
4
+ import JinaManager from '../jina_manager.ts'
5
+ import { createSignedToken } from '../tokens.ts'
6
+
7
+ export async function forgotPasswordHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{ email?: string }>()
9
+
10
+ if (!body.email) {
11
+ return ctx.json({ message: 'Email is required.' }, 422)
12
+ }
13
+
14
+ // Always return success to prevent email enumeration
15
+ const user = await JinaManager.actions.findByEmail(body.email)
16
+
17
+ if (user) {
18
+ const config = JinaManager.config
19
+ const userId = extractUserId(user)
20
+ const email = JinaManager.actions.emailOf(user)
21
+
22
+ const token = createSignedToken(
23
+ { sub: userId, typ: 'password-reset', email },
24
+ config.passwords.expiration
25
+ )
26
+
27
+ const resetUrl = `${ctx.url.origin}${config.prefix}/reset-password?token=${encodeURIComponent(token)}`
28
+
29
+ await mail
30
+ .to(email)
31
+ .subject('Reset Your Password')
32
+ .template('jina.reset-password', { resetUrl, expiration: config.passwords.expiration })
33
+ .send()
34
+ }
35
+
36
+ return ctx.json({ message: 'If an account exists, a reset link has been sent.' })
37
+ }
@@ -0,0 +1,63 @@
1
+ import { encrypt, Emitter } from '@stravigor/kernel'
2
+ import { AccessToken } from '@stravigor/http'
3
+ import type { Context, Session } from '@stravigor/http'
4
+ import JinaManager from '../jina_manager.ts'
5
+ import { JinaEvents } from '../types.ts'
6
+
7
+ export async function loginHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{ email?: string; password?: string }>()
9
+ const { email, password } = body
10
+
11
+ // Validate
12
+ if (!email || !password) {
13
+ return ctx.json({ message: 'Email and password are required.' }, 422)
14
+ }
15
+
16
+ // Find user
17
+ const user = await JinaManager.actions.findByEmail(email)
18
+ if (!user) {
19
+ return ctx.json({ message: 'Invalid credentials.' }, 401)
20
+ }
21
+
22
+ // Verify password
23
+ const hash = JinaManager.actions.passwordHashOf(user)
24
+ const valid = await encrypt.verify(password, hash)
25
+ if (!valid) {
26
+ return ctx.json({ message: 'Invalid credentials.' }, 401)
27
+ }
28
+
29
+ // Two-factor challenge
30
+ if (JinaManager.hasFeature('two-factor') && JinaManager.actions.twoFactorSecretOf) {
31
+ const secret = JinaManager.actions.twoFactorSecretOf(user)
32
+ if (secret) {
33
+ // Store the user email in session for the challenge step
34
+ const session = ctx.get<Session>('session')
35
+ session.set('_jina_2fa_email', email)
36
+ return ctx.json({ two_factor: true })
37
+ }
38
+ }
39
+
40
+ return completeLogin(ctx, user)
41
+ }
42
+
43
+ /** Finalize login — authenticate session or issue token. */
44
+ export async function completeLogin(ctx: Context, user: unknown): Promise<Response> {
45
+ const config = JinaManager.config
46
+
47
+ if (config.mode === 'session') {
48
+ const session = ctx.get<Session>('session')
49
+ session.authenticate(user)
50
+ await session.regenerate()
51
+ }
52
+
53
+ if (Emitter.listenerCount(JinaEvents.LOGIN) > 0) {
54
+ Emitter.emit(JinaEvents.LOGIN, { user, ctx }).catch(() => {})
55
+ }
56
+
57
+ if (config.mode === 'token') {
58
+ const { token, accessToken } = await AccessToken.create(user, 'login')
59
+ return ctx.json({ user, token, accessToken })
60
+ }
61
+
62
+ return ctx.json({ user })
63
+ }
@@ -0,0 +1,23 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import { Session } from '@stravigor/http'
3
+ import type { Context } from '@stravigor/http'
4
+ import JinaManager from '../jina_manager.ts'
5
+ import { JinaEvents } from '../types.ts'
6
+
7
+ export async function logoutHandler(ctx: Context): Promise<Response> {
8
+ const user = ctx.get('user')
9
+
10
+ if (Emitter.listenerCount(JinaEvents.LOGOUT) > 0) {
11
+ Emitter.emit(JinaEvents.LOGOUT, { user, ctx }).catch(() => {})
12
+ }
13
+
14
+ if (JinaManager.config.mode === 'session') {
15
+ const response = ctx.json({ message: 'Logged out.' })
16
+ return Session.destroy(ctx, response)
17
+ }
18
+
19
+ // Token mode: the client should discard the token.
20
+ // Optionally we could revoke the token here, but the auth middleware
21
+ // already attaches the accessToken to the context.
22
+ return ctx.json({ message: 'Logged out.' })
23
+ }
@@ -0,0 +1,72 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import { AccessToken } from '@stravigor/http'
3
+ import type { Context, Session } from '@stravigor/http'
4
+ import JinaManager from '../jina_manager.ts'
5
+ import { ValidationError } from '../errors.ts'
6
+ import { JinaEvents } from '../types.ts'
7
+ import { sendVerificationEmail } from './verify_email.ts'
8
+
9
+ export async function registerHandler(ctx: Context): Promise<Response> {
10
+ const body = await ctx.body<Record<string, unknown>>()
11
+ const { name, email, password, password_confirmation } = body as {
12
+ name?: string
13
+ email?: string
14
+ password?: string
15
+ password_confirmation?: string
16
+ }
17
+
18
+ // Validate
19
+ const errors: Record<string, string> = {}
20
+ if (!name || typeof name !== 'string') errors.name = 'Name is required.'
21
+ if (!email || typeof email !== 'string') errors.email = 'Email is required.'
22
+ if (!password || typeof password !== 'string') errors.password = 'Password is required.'
23
+ else if (password.length < 8) errors.password = 'Password must be at least 8 characters.'
24
+ if (password !== password_confirmation) errors.password_confirmation = 'Passwords do not match.'
25
+
26
+ if (Object.keys(errors).length > 0) {
27
+ return ctx.json({ message: 'Validation failed.', errors }, 422)
28
+ }
29
+
30
+ // Check if email is already taken
31
+ const existing = await JinaManager.actions.findByEmail(email!)
32
+ if (existing) {
33
+ return ctx.json(
34
+ { message: 'Validation failed.', errors: { email: 'Email already taken.' } },
35
+ 422
36
+ )
37
+ }
38
+
39
+ // Create user
40
+ const user = await JinaManager.actions.createUser({
41
+ name: name!,
42
+ email: email!,
43
+ password: password!,
44
+ ...body,
45
+ })
46
+
47
+ const config = JinaManager.config
48
+
49
+ // Authenticate
50
+ if (config.mode === 'session') {
51
+ const session = ctx.get<Session>('session')
52
+ session.authenticate(user)
53
+ await session.regenerate()
54
+ }
55
+
56
+ // Emit
57
+ if (Emitter.listenerCount(JinaEvents.REGISTERED) > 0) {
58
+ Emitter.emit(JinaEvents.REGISTERED, { user, ctx }).catch(() => {})
59
+ }
60
+
61
+ // Auto-send verification email if feature enabled
62
+ if (JinaManager.hasFeature('email-verification')) {
63
+ sendVerificationEmail(user).catch(() => {})
64
+ }
65
+
66
+ if (config.mode === 'token') {
67
+ const { token, accessToken } = await AccessToken.create(user, 'registration')
68
+ return ctx.json({ user, token, accessToken }, 201)
69
+ }
70
+
71
+ return ctx.json({ user }, 201)
72
+ }
@@ -0,0 +1,56 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Context } from '@stravigor/http'
3
+ import JinaManager from '../jina_manager.ts'
4
+ import { verifySignedToken } from '../tokens.ts'
5
+ import { JinaEvents } from '../types.ts'
6
+
7
+ export async function resetPasswordHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{
9
+ token?: string
10
+ password?: string
11
+ password_confirmation?: string
12
+ }>()
13
+
14
+ // Validate input
15
+ if (!body.token) {
16
+ return ctx.json({ message: 'Token is required.' }, 422)
17
+ }
18
+ if (!body.password || body.password.length < 8) {
19
+ return ctx.json({ message: 'Password must be at least 8 characters.' }, 422)
20
+ }
21
+ if (body.password !== body.password_confirmation) {
22
+ return ctx.json({ message: 'Passwords do not match.' }, 422)
23
+ }
24
+
25
+ // Verify token
26
+ let payload: { sub: string | number; typ: string; email: string }
27
+ try {
28
+ payload = verifySignedToken(body.token)
29
+ } catch {
30
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
31
+ }
32
+
33
+ if (payload.typ !== 'password-reset') {
34
+ return ctx.json({ message: 'Invalid token type.' }, 422)
35
+ }
36
+
37
+ // Find user
38
+ const user = await JinaManager.actions.findById(payload.sub)
39
+ if (!user) {
40
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
41
+ }
42
+
43
+ // Verify the email still matches (prevents token reuse after email change)
44
+ if (JinaManager.actions.emailOf(user) !== payload.email) {
45
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
46
+ }
47
+
48
+ // Update password
49
+ await JinaManager.actions.updatePassword(user, body.password)
50
+
51
+ if (Emitter.listenerCount(JinaEvents.PASSWORD_RESET) > 0) {
52
+ Emitter.emit(JinaEvents.PASSWORD_RESET, { user, ctx }).catch(() => {})
53
+ }
54
+
55
+ return ctx.json({ message: 'Password has been reset.' })
56
+ }
@@ -0,0 +1,171 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Context, Session } from '@stravigor/http'
3
+ import JinaManager from '../jina_manager.ts'
4
+ import { JinaEvents } from '../types.ts'
5
+ import {
6
+ generateSecret,
7
+ totpUri,
8
+ verifyTotp,
9
+ base32Decode,
10
+ generateRecoveryCodes,
11
+ } from '../totp.ts'
12
+ import { completeLogin } from './login.ts'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // POST /two-factor/enable — generate a TOTP secret
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export async function enableTwoFactorHandler(ctx: Context): Promise<Response> {
19
+ const user = ctx.get('user')
20
+ const actions = JinaManager.actions
21
+ const config = JinaManager.config.twoFactor
22
+
23
+ // Don't allow re-enabling if already confirmed
24
+ const existingSecret = actions.twoFactorSecretOf!(user)
25
+ if (existingSecret) {
26
+ return ctx.json({ message: 'Two-factor authentication is already enabled.' }, 409)
27
+ }
28
+
29
+ const { base32 } = generateSecret()
30
+ const email = actions.emailOf(user)
31
+
32
+ const uri = totpUri({
33
+ secret: base32,
34
+ issuer: config.issuer,
35
+ account: email,
36
+ digits: config.digits,
37
+ period: config.period,
38
+ })
39
+
40
+ // Store the unconfirmed secret in the session (not persisted to user yet)
41
+ const session = ctx.get<Session>('session')
42
+ session.set('_jina_2fa_secret', base32)
43
+
44
+ return ctx.json({ secret: base32, qr_uri: uri })
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // POST /two-factor/confirm — confirm 2FA setup with a valid code
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export async function confirmTwoFactorHandler(ctx: Context): Promise<Response> {
52
+ const body = await ctx.body<{ code?: string }>()
53
+ if (!body.code) {
54
+ return ctx.json({ message: 'Verification code is required.' }, 422)
55
+ }
56
+
57
+ const user = ctx.get('user')
58
+ const actions = JinaManager.actions
59
+ const config = JinaManager.config.twoFactor
60
+
61
+ const session = ctx.get<Session>('session')
62
+ const pendingSecret = session.get<string>('_jina_2fa_secret')
63
+ if (!pendingSecret) {
64
+ return ctx.json({ message: 'No pending two-factor setup. Call enable first.' }, 422)
65
+ }
66
+
67
+ // Verify the code against the pending secret
68
+ const secretBytes = base32Decode(pendingSecret)
69
+ const valid = await verifyTotp(secretBytes, body.code, {
70
+ digits: config.digits,
71
+ period: config.period,
72
+ })
73
+
74
+ if (!valid) {
75
+ return ctx.json({ message: 'Invalid verification code.' }, 422)
76
+ }
77
+
78
+ // Persist the secret to the user
79
+ await actions.setTwoFactorSecret!(user, pendingSecret)
80
+
81
+ // Generate recovery codes
82
+ const codes = generateRecoveryCodes(config.recoveryCodes)
83
+ await actions.setRecoveryCodes!(user, codes)
84
+
85
+ // Clean up session
86
+ session.forget('_jina_2fa_secret')
87
+
88
+ if (Emitter.listenerCount(JinaEvents.TWO_FACTOR_ENABLED) > 0) {
89
+ Emitter.emit(JinaEvents.TWO_FACTOR_ENABLED, { user, ctx }).catch(() => {})
90
+ }
91
+
92
+ return ctx.json({ message: 'Two-factor authentication enabled.', recovery_codes: codes })
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // DELETE /two-factor — disable 2FA
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export async function disableTwoFactorHandler(ctx: Context): Promise<Response> {
100
+ const user = ctx.get('user')
101
+ const actions = JinaManager.actions
102
+
103
+ await actions.setTwoFactorSecret!(user, null)
104
+ await actions.setRecoveryCodes!(user, [])
105
+
106
+ if (Emitter.listenerCount(JinaEvents.TWO_FACTOR_DISABLED) > 0) {
107
+ Emitter.emit(JinaEvents.TWO_FACTOR_DISABLED, { user, ctx }).catch(() => {})
108
+ }
109
+
110
+ return ctx.json({ message: 'Two-factor authentication disabled.' })
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // POST /two-factor/challenge — verify TOTP code during login
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export async function twoFactorChallengeHandler(ctx: Context): Promise<Response> {
118
+ const body = await ctx.body<{ code?: string; recovery_code?: string }>()
119
+ const session = ctx.get<Session>('session')
120
+ const actions = JinaManager.actions
121
+ const config = JinaManager.config.twoFactor
122
+
123
+ // Retrieve the pending login email from the session
124
+ const pendingEmail = session.get<string>('_jina_2fa_email')
125
+ if (!pendingEmail) {
126
+ return ctx.json({ message: 'No pending two-factor challenge.' }, 422)
127
+ }
128
+
129
+ const user = await actions.findByEmail(pendingEmail)
130
+ if (!user) {
131
+ return ctx.json({ message: 'Invalid challenge.' }, 422)
132
+ }
133
+
134
+ const secret = actions.twoFactorSecretOf!(user)
135
+ if (!secret) {
136
+ return ctx.json({ message: 'Two-factor authentication is not enabled.' }, 422)
137
+ }
138
+
139
+ // Try TOTP code first
140
+ if (body.code) {
141
+ const secretBytes = base32Decode(secret)
142
+ const valid = await verifyTotp(secretBytes, body.code, {
143
+ digits: config.digits,
144
+ period: config.period,
145
+ })
146
+
147
+ if (!valid) {
148
+ return ctx.json({ message: 'Invalid two-factor code.' }, 422)
149
+ }
150
+ }
151
+ // Try recovery code
152
+ else if (body.recovery_code) {
153
+ const codes = actions.recoveryCodesOf!(user)
154
+ const index = codes.indexOf(body.recovery_code)
155
+ if (index === -1) {
156
+ return ctx.json({ message: 'Invalid recovery code.' }, 422)
157
+ }
158
+
159
+ // Remove the used recovery code
160
+ codes.splice(index, 1)
161
+ await actions.setRecoveryCodes!(user, codes)
162
+ } else {
163
+ return ctx.json({ message: 'A two-factor code or recovery code is required.' }, 422)
164
+ }
165
+
166
+ // Clean up pending state
167
+ session.forget('_jina_2fa_email')
168
+
169
+ // Complete the login
170
+ return completeLogin(ctx, user)
171
+ }
@@ -0,0 +1,39 @@
1
+ import { encrypt, Emitter } from '@stravigor/kernel'
2
+ import type { Context } from '@stravigor/http'
3
+ import JinaManager from '../jina_manager.ts'
4
+ import { JinaEvents } from '../types.ts'
5
+
6
+ export async function updatePasswordHandler(ctx: Context): Promise<Response> {
7
+ const body = await ctx.body<{
8
+ current_password?: string
9
+ password?: string
10
+ password_confirmation?: string
11
+ }>()
12
+
13
+ // Validate
14
+ if (!body.current_password) {
15
+ return ctx.json({ message: 'Current password is required.' }, 422)
16
+ }
17
+ if (!body.password || body.password.length < 8) {
18
+ return ctx.json({ message: 'New password must be at least 8 characters.' }, 422)
19
+ }
20
+ if (body.password !== body.password_confirmation) {
21
+ return ctx.json({ message: 'Passwords do not match.' }, 422)
22
+ }
23
+
24
+ const user = ctx.get('user')
25
+ const hash = JinaManager.actions.passwordHashOf(user)
26
+ const valid = await encrypt.verify(body.current_password, hash)
27
+
28
+ if (!valid) {
29
+ return ctx.json({ message: 'Current password is incorrect.' }, 422)
30
+ }
31
+
32
+ await JinaManager.actions.updatePassword(user, body.password)
33
+
34
+ if (Emitter.listenerCount(JinaEvents.PASSWORD_UPDATED) > 0) {
35
+ Emitter.emit(JinaEvents.PASSWORD_UPDATED, { user, ctx }).catch(() => {})
36
+ }
37
+
38
+ return ctx.json({ message: 'Password updated.' })
39
+ }
@@ -0,0 +1,17 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Context } from '@stravigor/http'
3
+ import JinaManager from '../jina_manager.ts'
4
+ import { JinaEvents } from '../types.ts'
5
+
6
+ export async function updateProfileHandler(ctx: Context): Promise<Response> {
7
+ const data = await ctx.body<Record<string, unknown>>()
8
+ const user = ctx.get('user')
9
+
10
+ await JinaManager.actions.updateProfile!(user, data)
11
+
12
+ if (Emitter.listenerCount(JinaEvents.PROFILE_UPDATED) > 0) {
13
+ Emitter.emit(JinaEvents.PROFILE_UPDATED, { user, ctx }).catch(() => {})
14
+ }
15
+
16
+ return ctx.json({ message: 'Profile updated.' })
17
+ }