| 1 | import type { Context } from "hono"; |
| 2 | import { z } from "zod"; |
| 3 | import type { AppEnv } from "../env"; |
| 4 | import { MAX_PASSWORD, MIN_PASSWORD } from "../config"; |
| 5 | import { ApiError } from "../http/errors"; |
| 6 | |
| 7 | const email = z.string().trim().toLowerCase().email().max(254); |
| 8 | const password = z.string().min(MIN_PASSWORD, `Password must be at least ${MIN_PASSWORD} characters.`).max(MAX_PASSWORD); |
| 9 | |
| 10 | export const RegisterSchema = z.object({ |
| 11 | email, |
| 12 | password, |
| 13 | displayName: z.string().trim().max(80).optional(), |
| 14 | }); |
| 15 | |
| 16 | export const LoginSchema = z.object({ |
| 17 | email, |
| 18 | password: z.string().min(1).max(MAX_PASSWORD), |
| 19 | }); |
| 20 | |
| 21 | export const ForgotSchema = z.object({ email }); |
| 22 | export const ResendSchema = z.object({ email }); |
| 23 | |
| 24 | export const ResetSchema = z.object({ |
| 25 | token: z.string().min(10).max(256), |
| 26 | password, |
| 27 | }); |
| 28 | |
| 29 | export const VerifyQuerySchema = z.object({ |
| 30 | token: z.string().min(10).max(256), |
| 31 | }); |
| 32 | |
| 33 | export const ProfileSchema = z |
| 34 | .object({ |
| 35 | displayName: z.string().trim().max(80).optional(), |
| 36 | bio: z.string().trim().max(500).optional(), |
| 37 | avatarUrl: z.union([z.string().url().max(500), z.literal("")]).optional(), |
| 38 | handle: z.string().trim().min(3).max(30).optional(), |
| 39 | }) |
| 40 | .strict(); |
| 41 | |
| 42 | export const PasswordChangeSchema = z.object({ |
| 43 | currentPassword: z.string().min(1).max(MAX_PASSWORD), |
| 44 | newPassword: password, |
| 45 | }); |
| 46 | |
| 47 | const userCode = z.string().trim().min(4).max(20); |
| 48 | |
| 49 | export const DevicePollSchema = z.object({ deviceCode: z.string().min(10).max(256) }); |
| 50 | export const DeviceApproveSchema = z.object({ userCode }); |
| 51 | export const DeviceCodeQuerySchema = z.object({ userCode }); |
| 52 | |
| 53 | function firstIssue(error: z.ZodError): string { |
| 54 | const issue = error.issues[0]; |
| 55 | if (!issue) return "Some fields are invalid."; |
| 56 | const path = issue.path.join("."); |
| 57 | return path ? `${path}: ${issue.message}` : issue.message; |
| 58 | } |
| 59 | |
| 60 | export async function parseBody<S extends z.ZodTypeAny>(c: Context<AppEnv>, schema: S): Promise<z.infer<S>> { |
| 61 | let raw: unknown; |
| 62 | try { |
| 63 | raw = await c.req.json(); |
| 64 | } catch { |
| 65 | throw new ApiError(400, "invalid_json", "Request body must be valid JSON."); |
| 66 | } |
| 67 | const result = schema.safeParse(raw); |
| 68 | if (!result.success) throw new ApiError(422, "invalid_input", firstIssue(result.error)); |
| 69 | return result.data; |
| 70 | } |
| 71 | |
| 72 | export function parseQuery<S extends z.ZodTypeAny>(c: Context<AppEnv>, schema: S): z.infer<S> { |
| 73 | const params = Object.fromEntries(new URL(c.req.url).searchParams); |
| 74 | const result = schema.safeParse(params); |
| 75 | if (!result.success) throw new ApiError(422, "invalid_input", firstIssue(result.error)); |
| 76 | return result.data; |
| 77 | } |
| 78 |