@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.
@@ -0,0 +1,84 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import { mail } from '@stravigor/signal'
3
+ import { extractUserId } from '@stravigor/database'
4
+ import type { Context } from '@stravigor/http'
5
+ import JinaManager from '../jina_manager.ts'
6
+ import { createSignedToken, verifySignedToken } from '../tokens.ts'
7
+ import { JinaEvents } from '../types.ts'
8
+
9
+ /** Send a verification email for the given user. */
10
+ export async function sendVerificationEmail(user: unknown): Promise<void> {
11
+ const config = JinaManager.config
12
+ const actions = JinaManager.actions
13
+ const userId = extractUserId(user)
14
+ const email = actions.emailOf(user)
15
+
16
+ const token = createSignedToken(
17
+ { sub: userId, typ: 'email-verify', email },
18
+ config.verification.expiration
19
+ )
20
+
21
+ const verifyUrl = `${config.prefix}/email/verify/${encodeURIComponent(token)}`
22
+
23
+ await mail
24
+ .to(email)
25
+ .subject('Verify Your Email')
26
+ .template('jina.verify-email', { verifyUrl, expiration: config.verification.expiration })
27
+ .send()
28
+ }
29
+
30
+ /** POST /email/send — resend verification email. */
31
+ export async function sendVerificationHandler(ctx: Context): Promise<Response> {
32
+ const user = ctx.get('user')
33
+ const actions = JinaManager.actions
34
+
35
+ if (actions.isEmailVerified!(user)) {
36
+ return ctx.json({ message: 'Email already verified.' })
37
+ }
38
+
39
+ await sendVerificationEmail(user)
40
+ return ctx.json({ message: 'Verification email sent.' })
41
+ }
42
+
43
+ /** GET /email/verify/:token — verify the email. */
44
+ export async function verifyEmailHandler(ctx: Context): Promise<Response> {
45
+ const token = ctx.params.token as string | undefined
46
+ if (!token) {
47
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
48
+ }
49
+
50
+ let payload: { sub: string | number; typ: string; email: string }
51
+ try {
52
+ payload = verifySignedToken(token)
53
+ } catch {
54
+ return ctx.json({ message: 'Invalid or expired verification link.' }, 422)
55
+ }
56
+
57
+ if (payload.typ !== 'email-verify') {
58
+ return ctx.json({ message: 'Invalid token type.' }, 422)
59
+ }
60
+
61
+ const user = await JinaManager.actions.findById(payload.sub)
62
+ if (!user) {
63
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
64
+ }
65
+
66
+ // Verify the email still matches
67
+ if (JinaManager.actions.emailOf(user) !== payload.email) {
68
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
69
+ }
70
+
71
+ const actions = JinaManager.actions
72
+
73
+ if (actions.isEmailVerified!(user)) {
74
+ return ctx.json({ message: 'Email already verified.' })
75
+ }
76
+
77
+ await actions.markEmailVerified!(user)
78
+
79
+ if (Emitter.listenerCount(JinaEvents.EMAIL_VERIFIED) > 0) {
80
+ Emitter.emit(JinaEvents.EMAIL_VERIFIED, { user, ctx }).catch(() => {})
81
+ }
82
+
83
+ return ctx.json({ message: 'Email verified.' })
84
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,67 @@
1
+ import JinaManager from './jina_manager.ts'
2
+ import { createSignedToken, verifySignedToken } from './tokens.ts'
3
+ import { generateSecret, totpUri, verifyTotp, base32Decode, generateRecoveryCodes } from './totp.ts'
4
+
5
+ /**
6
+ * Jina helper — convenience API for auth flow utilities.
7
+ *
8
+ * @example
9
+ * import { jina } from '@stravigor/jina'
10
+ *
11
+ * const token = jina.signedToken({ sub: user.id, typ: 'custom' }, 60)
12
+ * const payload = jina.verifyToken(token)
13
+ *
14
+ * const { secret, qrUri } = jina.generateTwoFactorSecret(user)
15
+ * const valid = await jina.verifyTwoFactorCode(secret, code)
16
+ */
17
+ export const jina = {
18
+ /** Check if a feature is enabled. */
19
+ hasFeature(feature: string): boolean {
20
+ return JinaManager.hasFeature(feature as any)
21
+ },
22
+
23
+ /** Create a signed, encrypted token with an expiration. */
24
+ signedToken(
25
+ data: { sub: string | number; typ: string; [key: string]: unknown },
26
+ expiresInMinutes: number
27
+ ): string {
28
+ return createSignedToken(data, expiresInMinutes)
29
+ },
30
+
31
+ /** Verify and decode a signed token. Throws if expired or tampered. */
32
+ verifyToken<T extends Record<string, unknown> = Record<string, unknown>>(token: string): T {
33
+ return verifySignedToken<T>(token)
34
+ },
35
+
36
+ /** Generate a TOTP secret and QR URI for the given user. */
37
+ generateTwoFactorSecret(user: unknown): { secret: string; qrUri: string } {
38
+ const config = JinaManager.config.twoFactor
39
+ const { base32 } = generateSecret()
40
+ const email = JinaManager.actions.emailOf(user)
41
+
42
+ const uri = totpUri({
43
+ secret: base32,
44
+ issuer: config.issuer,
45
+ account: email,
46
+ digits: config.digits,
47
+ period: config.period,
48
+ })
49
+
50
+ return { secret: base32, qrUri: uri }
51
+ },
52
+
53
+ /** Verify a TOTP code against a base32 secret. */
54
+ async verifyTwoFactorCode(base32Secret: string, code: string): Promise<boolean> {
55
+ const config = JinaManager.config.twoFactor
56
+ const secretBytes = base32Decode(base32Secret)
57
+ return verifyTotp(secretBytes, code, {
58
+ digits: config.digits,
59
+ period: config.period,
60
+ })
61
+ },
62
+
63
+ /** Generate a set of single-use recovery codes. */
64
+ generateRecoveryCodes(count?: number): string[] {
65
+ return generateRecoveryCodes(count ?? JinaManager.config.twoFactor.recoveryCodes)
66
+ },
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ // Manager & provider
2
+ export { default, default as JinaManager } from './jina_manager.ts'
3
+ export { default as JinaProvider } from './jina_provider.ts'
4
+
5
+ // Helper
6
+ export { jina } from './helpers.ts'
7
+
8
+ // Actions
9
+ export { defineActions } from './actions.ts'
10
+
11
+ // Middleware
12
+ export { verified } from './middleware/verified.ts'
13
+ export { confirmed } from './middleware/confirmed.ts'
14
+ export { twoFactorChallenge } from './middleware/two_factor_challenge.ts'
15
+
16
+ // Handlers (for manual route registration)
17
+ export { registerHandler } from './handlers/register.ts'
18
+ export { loginHandler } from './handlers/login.ts'
19
+ export { logoutHandler } from './handlers/logout.ts'
20
+ export { forgotPasswordHandler } from './handlers/forgot_password.ts'
21
+ export { resetPasswordHandler } from './handlers/reset_password.ts'
22
+ export { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
23
+ export {
24
+ enableTwoFactorHandler,
25
+ confirmTwoFactorHandler,
26
+ disableTwoFactorHandler,
27
+ twoFactorChallengeHandler,
28
+ } from './handlers/two_factor.ts'
29
+ export { confirmPasswordHandler } from './handlers/confirm_password.ts'
30
+ export { updatePasswordHandler } from './handlers/update_password.ts'
31
+ export { updateProfileHandler } from './handlers/update_profile.ts'
32
+
33
+ // Errors
34
+ export { JinaError, MissingActionError, ValidationError } from './errors.ts'
35
+
36
+ // Types
37
+ export type {
38
+ Feature,
39
+ JinaActions,
40
+ JinaConfig,
41
+ JinaEvent,
42
+ RegistrationData,
43
+ RateLimitConfig,
44
+ } from './types.ts'
45
+ export { JinaEvents } from './types.ts'
46
+
47
+ // TOTP utilities
48
+ export {
49
+ generateSecret,
50
+ totpUri,
51
+ verifyTotp,
52
+ generateRecoveryCodes,
53
+ base32Encode,
54
+ base32Decode,
55
+ } from './totp.ts'
56
+
57
+ // Token utilities
58
+ export { createSignedToken, verifySignedToken } from './tokens.ts'
@@ -0,0 +1,200 @@
1
+ import { inject, Configuration, ConfigurationError } from '@stravigor/kernel'
2
+ import { Router, compose, rateLimit, auth, guest, session } from '@stravigor/http'
3
+ import type { Handler, Middleware } from '@stravigor/http'
4
+ import { MissingActionError } from './errors.ts'
5
+ import type { JinaActions, JinaConfig, Feature } from './types.ts'
6
+ import { registerHandler } from './handlers/register.ts'
7
+ import { loginHandler } from './handlers/login.ts'
8
+ import { logoutHandler } from './handlers/logout.ts'
9
+ import { forgotPasswordHandler } from './handlers/forgot_password.ts'
10
+ import { resetPasswordHandler } from './handlers/reset_password.ts'
11
+ import { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
12
+ import {
13
+ enableTwoFactorHandler,
14
+ confirmTwoFactorHandler,
15
+ disableTwoFactorHandler,
16
+ twoFactorChallengeHandler,
17
+ } from './handlers/two_factor.ts'
18
+ import { confirmPasswordHandler } from './handlers/confirm_password.ts'
19
+ import { updatePasswordHandler } from './handlers/update_password.ts'
20
+ import { updateProfileHandler } from './handlers/update_profile.ts'
21
+ import { confirmed } from './middleware/confirmed.ts'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Default config
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const DEFAULTS: JinaConfig = {
28
+ features: ['registration', 'login', 'logout', 'password-reset'],
29
+ prefix: '',
30
+ mode: 'session',
31
+ rateLimit: {
32
+ login: { max: 5, window: 60 },
33
+ register: { max: 3, window: 60 },
34
+ forgotPassword: { max: 3, window: 60 },
35
+ verifyEmail: { max: 3, window: 60 },
36
+ twoFactor: { max: 5, window: 60 },
37
+ },
38
+ passwords: { expiration: 60 },
39
+ verification: { expiration: 60 },
40
+ confirmation: { timeout: 10_800 },
41
+ twoFactor: { issuer: 'Strav', digits: 6, period: 30, recoveryCodes: 8 },
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Manager
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Wrap a handler with middleware via compose. */
49
+ function withMiddleware(mw: Middleware[], handler: Handler): Handler {
50
+ return mw.length > 0 ? compose(mw, handler) : handler
51
+ }
52
+
53
+ @inject
54
+ export default class JinaManager {
55
+ private static _config: JinaConfig
56
+ private static _actions: JinaActions
57
+
58
+ constructor(config: Configuration) {
59
+ const raw = config.get('jina', {}) as Partial<JinaConfig>
60
+ JinaManager._config = { ...DEFAULTS, ...raw } as JinaConfig
61
+ }
62
+
63
+ // ── Accessors ────────────────────────────────────────────────────────
64
+
65
+ static get config(): JinaConfig {
66
+ if (!JinaManager._config) {
67
+ throw new ConfigurationError(
68
+ 'JinaManager not configured. Resolve it through the container first.'
69
+ )
70
+ }
71
+ return JinaManager._config
72
+ }
73
+
74
+ static get actions(): JinaActions {
75
+ if (!JinaManager._actions) {
76
+ throw new ConfigurationError('Jina actions not set. Pass actions to JinaProvider.')
77
+ }
78
+ return JinaManager._actions
79
+ }
80
+
81
+ /** Set the user-defined actions contract. */
82
+ static useActions(actions: JinaActions): void {
83
+ JinaManager._actions = actions
84
+ }
85
+
86
+ /** Check whether a feature is enabled. */
87
+ static hasFeature(feature: Feature): boolean {
88
+ return JinaManager._config.features.includes(feature)
89
+ }
90
+
91
+ // ── Validation ───────────────────────────────────────────────────────
92
+
93
+ /** Verify that all required actions are provided for enabled features. */
94
+ static validateActions(): void {
95
+ const a = JinaManager._actions
96
+ const has = (f: Feature) => JinaManager.hasFeature(f)
97
+
98
+ if (has('email-verification')) {
99
+ if (!a.isEmailVerified) throw new MissingActionError('isEmailVerified', 'email-verification')
100
+ if (!a.markEmailVerified)
101
+ throw new MissingActionError('markEmailVerified', 'email-verification')
102
+ }
103
+
104
+ if (has('two-factor')) {
105
+ if (!a.twoFactorSecretOf) throw new MissingActionError('twoFactorSecretOf', 'two-factor')
106
+ if (!a.setTwoFactorSecret) throw new MissingActionError('setTwoFactorSecret', 'two-factor')
107
+ if (!a.recoveryCodesOf) throw new MissingActionError('recoveryCodesOf', 'two-factor')
108
+ if (!a.setRecoveryCodes) throw new MissingActionError('setRecoveryCodes', 'two-factor')
109
+ }
110
+
111
+ if (has('update-profile')) {
112
+ if (!a.updateProfile) throw new MissingActionError('updateProfile', 'update-profile')
113
+ }
114
+ }
115
+
116
+ // ── Route registration ───────────────────────────────────────────────
117
+
118
+ /** Build a rate limit middleware from a config key. */
119
+ private static rl(key: keyof JinaConfig['rateLimit']): Middleware {
120
+ const cfg = JinaManager._config.rateLimit[key]
121
+ return rateLimit({ max: cfg.max, window: cfg.window * 1000 })
122
+ }
123
+
124
+ /**
125
+ * Register all Jina routes on the given router.
126
+ *
127
+ * @param router - The router instance.
128
+ * @param options - `only` or `except` to selectively register routes.
129
+ */
130
+ static routes(router: Router, options?: { only?: Feature[]; except?: Feature[] }): void {
131
+ const enabled = (f: Feature): boolean => {
132
+ if (!JinaManager.hasFeature(f)) return false
133
+ if (options?.only) return options.only.includes(f)
134
+ if (options?.except) return !options.except.includes(f)
135
+ return true
136
+ }
137
+
138
+ const prefix = JinaManager._config.prefix
139
+
140
+ const middleware = JinaManager._config.mode === 'session' ? [session()] : []
141
+
142
+ router.group({ prefix, middleware }, r => {
143
+ if (enabled('registration')) {
144
+ r.post('/register', withMiddleware([guest(), JinaManager.rl('register')], registerHandler))
145
+ }
146
+
147
+ if (enabled('login')) {
148
+ r.post('/login', withMiddleware([guest(), JinaManager.rl('login')], loginHandler))
149
+ }
150
+
151
+ if (enabled('logout')) {
152
+ r.post('/logout', withMiddleware([auth()], logoutHandler))
153
+ }
154
+
155
+ if (enabled('password-reset')) {
156
+ r.post(
157
+ '/forgot-password',
158
+ withMiddleware([guest(), JinaManager.rl('forgotPassword')], forgotPasswordHandler)
159
+ )
160
+ r.post('/reset-password', withMiddleware([guest()], resetPasswordHandler))
161
+ }
162
+
163
+ if (enabled('email-verification')) {
164
+ r.post(
165
+ '/email/send',
166
+ withMiddleware([auth(), JinaManager.rl('verifyEmail')], sendVerificationHandler)
167
+ )
168
+ r.get('/email/verify/:token', verifyEmailHandler)
169
+ }
170
+
171
+ if (enabled('two-factor')) {
172
+ r.post('/two-factor/enable', withMiddleware([auth(), confirmed()], enableTwoFactorHandler))
173
+ r.post('/two-factor/confirm', withMiddleware([auth()], confirmTwoFactorHandler))
174
+ r.delete('/two-factor', withMiddleware([auth(), confirmed()], disableTwoFactorHandler))
175
+ r.post(
176
+ '/two-factor/challenge',
177
+ withMiddleware([JinaManager.rl('twoFactor')], twoFactorChallengeHandler)
178
+ )
179
+ }
180
+
181
+ if (enabled('password-confirmation')) {
182
+ r.post('/confirm-password', withMiddleware([auth()], confirmPasswordHandler))
183
+ }
184
+
185
+ if (enabled('update-password')) {
186
+ r.put('/password', withMiddleware([auth()], updatePasswordHandler))
187
+ }
188
+
189
+ if (enabled('update-profile')) {
190
+ r.put('/profile', withMiddleware([auth()], updateProfileHandler))
191
+ }
192
+ })
193
+ }
194
+
195
+ /** Clear all state. For testing. */
196
+ static reset(): void {
197
+ JinaManager._config = undefined as any
198
+ JinaManager._actions = undefined as any
199
+ }
200
+ }
@@ -0,0 +1,25 @@
1
+ import { ServiceProvider } from '@stravigor/kernel'
2
+ import type { Application } from '@stravigor/kernel'
3
+ import { Router } from '@stravigor/http'
4
+ import JinaManager from './jina_manager.ts'
5
+ import type { JinaActions } from './types.ts'
6
+
7
+ export default class JinaProvider extends ServiceProvider {
8
+ readonly name = 'jina'
9
+ override readonly dependencies = ['auth', 'session', 'encryption', 'mail']
10
+
11
+ constructor(private actions: JinaActions) {
12
+ super()
13
+ }
14
+
15
+ override register(app: Application): void {
16
+ app.singleton(JinaManager)
17
+ }
18
+
19
+ override boot(app: Application): void {
20
+ app.resolve(JinaManager)
21
+ JinaManager.useActions(this.actions)
22
+ JinaManager.validateActions()
23
+ JinaManager.routes(app.resolve(Router))
24
+ }
25
+ }
@@ -0,0 +1,27 @@
1
+ import type { Session, Middleware } from '@stravigor/http'
2
+ import JinaManager from '../jina_manager.ts'
3
+
4
+ /**
5
+ * Require the user to have confirmed their password recently.
6
+ * Returns 423 (Locked) if the confirmation has expired.
7
+ *
8
+ * @example
9
+ * router.delete('/account', auth(), confirmed(), deleteAccountHandler)
10
+ */
11
+ export function confirmed(): Middleware {
12
+ return (ctx, next) => {
13
+ const session = ctx.get<Session>('session')
14
+ const confirmedAt = session.get<number>('_jina_confirmed_at')
15
+
16
+ if (!confirmedAt) {
17
+ return ctx.json({ message: 'Password confirmation required.' }, 423)
18
+ }
19
+
20
+ const timeout = JinaManager.config.confirmation.timeout * 1000
21
+ if (Date.now() - confirmedAt > timeout) {
22
+ return ctx.json({ message: 'Password confirmation has expired.' }, 423)
23
+ }
24
+
25
+ return next()
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ import type { Session, Middleware } from '@stravigor/http'
2
+ import JinaManager from '../jina_manager.ts'
3
+
4
+ /**
5
+ * Require a completed two-factor challenge for the current session.
6
+ * Returns 403 if the user has 2FA enabled but hasn't completed the challenge.
7
+ *
8
+ * Useful for protecting sensitive routes beyond the initial login.
9
+ *
10
+ * @example
11
+ * router.post('/transfer', auth(), twoFactorChallenge(), transferHandler)
12
+ */
13
+ export function twoFactorChallenge(): Middleware {
14
+ return (ctx, next) => {
15
+ const user = ctx.get('user')
16
+ const actions = JinaManager.actions
17
+
18
+ // If 2FA is not enabled for this user, let them through
19
+ if (!actions.twoFactorSecretOf) return next()
20
+ const secret = actions.twoFactorSecretOf(user)
21
+ if (!secret) return next()
22
+
23
+ // Check if there's still a pending 2FA challenge in the session
24
+ const session = ctx.get<Session>('session')
25
+ if (session.has('_jina_2fa_email')) {
26
+ return ctx.json({ message: 'Two-factor authentication required.' }, 403)
27
+ }
28
+
29
+ return next()
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import type { Middleware } from '@stravigor/http'
2
+ import JinaManager from '../jina_manager.ts'
3
+
4
+ /**
5
+ * Require the authenticated user to have a verified email address.
6
+ * Returns 403 if the email is not verified.
7
+ *
8
+ * @example
9
+ * router.group({ middleware: [auth(), verified()] }, r => {
10
+ * r.get('/dashboard', dashboardHandler)
11
+ * })
12
+ */
13
+ export function verified(): Middleware {
14
+ return (ctx, next) => {
15
+ const user = ctx.get('user')
16
+ const actions = JinaManager.actions
17
+
18
+ if (!actions.isEmailVerified!(user)) {
19
+ return ctx.json({ message: 'Email not verified.' }, 403)
20
+ }
21
+
22
+ return next()
23
+ }
24
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { encrypt } from '@stravigor/kernel'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Signed token payloads
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface TokenPayload {
8
+ /** User identifier. */
9
+ sub: string | number
10
+ /** Token purpose. */
11
+ typ: string
12
+ /** Issued at (unix ms). */
13
+ iat: number
14
+ /** Expiration (minutes from iat). */
15
+ exp: number
16
+ /** Extra data. */
17
+ [key: string]: unknown
18
+ }
19
+
20
+ /**
21
+ * Create a signed, encrypted token using `encrypt.seal()`.
22
+ * Tamper-proof and opaque — the user cannot read or modify the payload.
23
+ *
24
+ * @param data - The payload to sign.
25
+ * @param expiresInMinutes - Token lifetime in minutes.
26
+ */
27
+ export function createSignedToken(
28
+ data: { sub: string | number; typ: string; [key: string]: unknown },
29
+ expiresInMinutes: number
30
+ ): string {
31
+ const payload: TokenPayload = {
32
+ ...data,
33
+ iat: Date.now(),
34
+ exp: expiresInMinutes,
35
+ }
36
+ return encrypt.seal(payload)
37
+ }
38
+
39
+ /**
40
+ * Verify and decode a signed token. Throws if expired or tampered.
41
+ *
42
+ * @returns The original payload.
43
+ * @throws If the token is invalid, expired, or tampered.
44
+ */
45
+ export function verifySignedToken<T = TokenPayload>(token: string): T {
46
+ const payload = encrypt.unseal<TokenPayload>(token)
47
+
48
+ if (!payload || typeof payload !== 'object' || !payload.iat) {
49
+ throw new Error('Invalid token payload.')
50
+ }
51
+
52
+ if (payload.exp) {
53
+ const expiresAt = payload.iat + payload.exp * 60_000
54
+ if (Date.now() > expiresAt) {
55
+ throw new Error('Token has expired.')
56
+ }
57
+ }
58
+
59
+ return payload as unknown as T
60
+ }