diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx index 19117fb..040c898 100644 --- a/app/components/ui/input.tsx +++ b/app/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef( { + const authenticator = createAuthenticator(request) const providerName = ProviderNameSchema.parse(params.provider) const label = providerConfigs[providerName].label @@ -31,7 +32,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { timing.time('get oauth profile', 'Get OAuth Profile') const authResult = await authenticator - .authenticate(providerName, request, { throwOnError: true }) + .authenticate(providerName, request) .then( (data) => ({ success: true, data }) as const, (error) => ({ success: false, error }) as const, @@ -132,9 +133,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } // check if any user owns this connection's email, bind to that user and login - const emailOwner = await db.query.userTable.findFirst({ - where: (userTable, { eq }) => eq(userTable.email, profile.email), - }) + let emailOwner + if (profile.email) { + const email = profile.email + emailOwner = await db.query.userTable.findFirst({ + where: (userTable, { eq }) => eq(userTable.email, email), + }) + } if (emailOwner) { timing.time('insert connection', 'Relate connection to user') @@ -173,7 +178,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { profile: { email: profile.email, imageUrl: profile.imageUrl, - displayName: profile.name, + displayName: profile.name ?? undefined, username: profile.username, }, }), diff --git a/app/routes/_auth+/auth.$provider.tsx b/app/routes/_auth+/auth.$provider.tsx index eb9672b..167b648 100644 --- a/app/routes/_auth+/auth.$provider.tsx +++ b/app/routes/_auth+/auth.$provider.tsx @@ -3,11 +3,12 @@ import { GeneralErrorBoundary, generalNotFoundHandler, } from '#app/components/error-boundary.tsx' -import { authenticator } from '#app/utils/auth/connections.server.ts' +import { createAuthenticator } from '#app/utils/auth/connections.server.ts' import { ProviderNameSchema } from '#app/utils/auth/connections.tsx' import { setRedirectCookie } from '#app/utils/redirect.server.ts' export const action = async ({ request, params }: ActionFunctionArgs) => { + const authenticator = createAuthenticator(request) const providerName = ProviderNameSchema.parse(params.provider) try { diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx index 80fdfe4..dd4eef4 100644 --- a/app/routes/_auth+/onboarding.tsx +++ b/app/routes/_auth+/onboarding.tsx @@ -80,7 +80,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } export const action = async ({ request }: ActionFunctionArgs) => { - const { providerId, providerName, profile } = await requireData(request) + const { providerId, providerName } = await requireData(request) const formData = await request.formData() const submission = await parseWithZod(formData, { @@ -98,21 +98,23 @@ export const action = async ({ request }: ActionFunctionArgs) => { path: ['username'], }, ) - .transform(async ({ displayName, username, imageUrl, rememberMe }) => { - const session = await signUpWithConnection({ - connection: { - provider_id: providerId, - provider_name: providerName, - }, - user: { - email: profile.email, - username, - display_name: displayName, - imageUrl, - }, - }) - return { session, rememberMe } - }), + .transform( + async ({ email, displayName, username, imageUrl, rememberMe }) => { + const session = await signUpWithConnection({ + connection: { + provider_id: providerId, + provider_name: providerName, + }, + user: { + email, + username, + display_name: displayName, + imageUrl, + }, + }) + return { session, rememberMe } + }, + ), async: true, }) @@ -186,10 +188,20 @@ export default function OnBoarding() { /> ) : null} -
-

Email

-

{data.email}

-
+ = { github: new GitHubProvider(), - discord: new DiscordProvider(), } -export const authenticator = new Authenticator( - connectionSessionStorage, -) +export const createAuthenticator = (request: Request) => { + const authenticator = new Authenticator() -for (const [providerName, provider] of Object.entries(providers)) { - authenticator.use(provider.getAuthStrategy(), providerName) + for (const [providerName, provider] of Object.entries(providers)) { + authenticator.use(provider.getAuthStrategy(request), providerName) + } + + return authenticator } export function resolveConnectionInfo({ diff --git a/app/utils/auth/connections.tsx b/app/utils/auth/connections.tsx index a32d2c3..d5a4647 100644 --- a/app/utils/auth/connections.tsx +++ b/app/utils/auth/connections.tsx @@ -5,12 +5,8 @@ import { Icon } from '#app/components/ui/icon.tsx' import { useIsPending } from '../ui.ts' export const GITHUB_PROVIDER_NAME = 'github' -export const DISCORD_PROVIDER_NAME = 'discord' -export const providerNames = [ - GITHUB_PROVIDER_NAME, - DISCORD_PROVIDER_NAME, -] as const +export const providerNames = [GITHUB_PROVIDER_NAME] as const export const ProviderNameSchema = z.enum(providerNames) export type ProviderName = z.infer @@ -23,10 +19,6 @@ export const providerConfigs: Record = { label: 'Github', icon: , }, - [DISCORD_PROVIDER_NAME]: { - label: 'Discord', - icon: , - }, } export const ProviderConnectionForm = ({ diff --git a/app/utils/auth/onboarding.server.ts b/app/utils/auth/onboarding.server.ts index dfa1cce..6ced2be 100644 --- a/app/utils/auth/onboarding.server.ts +++ b/app/utils/auth/onboarding.server.ts @@ -19,7 +19,7 @@ export const onboardingCookie = createTypedCookie({ providerId: z.string(), providerName: z.string(), profile: z - .object({ email: z.string().email() }) + .object({ email: z.string().email().optional() }) .merge(onboardingFormSchema.omit({ rememberMe: true }).partial()), }) .nullable(), diff --git a/app/utils/auth/onboarding.ts b/app/utils/auth/onboarding.ts index e2a5682..368b797 100644 --- a/app/utils/auth/onboarding.ts +++ b/app/utils/auth/onboarding.ts @@ -1,6 +1,7 @@ import { z } from 'zod' export const onboardingFormSchema = z.object({ + email: z.string().email(), username: z.string(), displayName: z.string(), imageUrl: z.string().url().optional(), diff --git a/app/utils/auth/providers/discord.server.ts b/app/utils/auth/providers/discord.server.ts deleted file mode 100644 index f4fd33c..0000000 --- a/app/utils/auth/providers/discord.server.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type DiscordProfile, DiscordStrategy } from 'remix-auth-discord' -import { z } from 'zod' -import { cachified, longLivedCache } from '#app/utils/cache.server.ts' -import { env } from '#app/utils/env.server.ts' -import { type ServerTiming } from '#app/utils/timings.server.ts' -import { type AuthProvider } from './model.ts' - -const DiscordUserSchema = z.object({ username: z.string() }) -type DiscordUser = z.infer -const getDisplayName = (user: DiscordUser) => user.username -const getImageUr = (profile: DiscordProfile) => { - const avaterHash = profile.photos?.[0].value - if (!avaterHash) { - return undefined - } - - return `https://cdn.discordapp.com/avatars/${profile.id}/${avaterHash}.png` -} - -export class DiscordProvider implements AuthProvider { - getAuthStrategy() { - return new DiscordStrategy( - { - clientID: env.DISCORD_CLIENT_ID, - clientSecret: env.DISCORD_CLIENT_SECRET, - callbackURL: '/auth/discord/callback', - scope: ['identify', 'email', 'guilds'], - }, - async ({ profile }) => { - const id = profile.id - const email = profile.__json.email - if (!email) { - throw new Error('Email is a must') - } - const username = profile.__json.username - const name = profile.displayName - const imageUrl = getImageUr(profile) - - return { - id, - email, - username, - name, - imageUrl, - } - }, - ) - } - - async resolveConnectionInfo( - providerId: string, - { timing }: { timing?: ServerTiming } = {}, - ) { - const result = await cachified({ - key: `connection-info:discord:${providerId}`, - cache: longLivedCache, - ttl: 1000 * 60, - swr: 1000 * 60 * 60 * 24 * 7, - timing, - getFreshValue: async (context) => { - const response = await fetch( - `https://discord.com/api/users/${providerId}`, - { - headers: { - Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, - }, - }, - ) - const rawJson = await response.json() - const result = DiscordUserSchema.safeParse(rawJson) - - if (!result.success) { - context.metadata.ttl = 0 - } - - return result - }, - }) - - if (!result.success) { - return { - connectionUserDisplayName: 'Unknown' as const, - profileLink: null, - } - } - - return { - connectionUserDisplayName: getDisplayName(result.data), - profileLink: null, - } - } -} diff --git a/app/utils/auth/providers/github.server.ts b/app/utils/auth/providers/github.server.ts index 2156491..c63d1c7 100644 --- a/app/utils/auth/providers/github.server.ts +++ b/app/utils/auth/providers/github.server.ts @@ -5,31 +5,91 @@ import { env } from '#app/utils/env.server.ts' import { type ServerTiming } from '#app/utils/timings.server.ts' import { type AuthProvider } from './model.ts' +const getDisplayName = (profile: Profile) => profile.login +const getProfileLink = (profile: Profile) => + `https://github.com/${profile.login}` + // pick needed from here: https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user -const GithubUserSchema = z.object({ login: z.string() }) -type GithubUser = z.infer -const getDisplayName = (user: GithubUser) => user.login -const getProfileLink = (user: GithubUser) => `https://github.com/${user.login}` +type Profile = z.infer +const ProfileSchema = z.object({ + avatar_url: z.string().url(), + login: z.string(), + name: z.string().nullable(), + id: z.number(), +}) +const getProfile = async (accessToken: string) => { + const response = await fetch('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${accessToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + const result = ProfileSchema.safeParse(await response.json()) + if (!result.success) { + throw new Error( + 'Corrupted github profile, make sure the schema is in sync with lastet github api schema', + ) + } + return result.data +} + +const EmailsSchema = z.array( + z.object({ + email: z.string(), + primary: z.boolean(), + verified: z.boolean(), + }), +) +const getEmails = async (accessToken: string) => { + const response = await fetch('https://api.github.com/user/emails', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + const result = EmailsSchema.safeParse(await response.json()) + if (!result.success) { + throw new Error( + 'Corrupted github emails, make sure the schema is in sync with lastet github api schema', + ) + } + + const emails = result.data + .filter(({ verified }) => verified) + .sort((a, b) => { + return -(+a.primary - +b.primary) + }) + .map(({ email }) => email) + + return emails +} export class GitHubProvider implements AuthProvider { - getAuthStrategy() { + getAuthStrategy(request: Request) { return new GitHubStrategy( { - clientID: env.GITHUB_CLIENT_ID, + clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, - callbackURL: '/auth/github/callback', + redirectURI: new URL('/auth/github/callback', request.url), + scopes: ['user'], }, - async ({ profile }) => { - const email = profile.emails[0]!.value.trim().toLowerCase() - const username = profile.displayName - const imageUrl = profile.photos[0]?.value + async ({ tokens }) => { + const accessToken = tokens.accessToken() + const [profile, emails] = await Promise.all([ + getProfile(accessToken), + getEmails(accessToken), + ]) return { - email, - id: profile.id, - username, - name: profile.name.givenName, - imageUrl, + email: emails[0], + id: profile.id.toString(), + username: profile.login, + name: profile.name, + imageUrl: profile.avatar_url, } }, ) @@ -50,7 +110,7 @@ export class GitHubProvider implements AuthProvider { `https://api.github.com/user/${providerId}`, ) const rawJson = await response.json() - const result = GithubUserSchema.safeParse(rawJson) + const result = ProfileSchema.safeParse(rawJson) if (!result.success) { context.metadata.ttl = 0 diff --git a/app/utils/auth/providers/model.ts b/app/utils/auth/providers/model.ts index 8a5026e..c3e30ad 100644 --- a/app/utils/auth/providers/model.ts +++ b/app/utils/auth/providers/model.ts @@ -1,16 +1,16 @@ -import { type Strategy } from 'remix-auth' +import { type Strategy } from 'remix-auth/strategy' import { type ServerTiming } from '#app/utils/timings.server.ts' export type ProviderUser = { id: string - email: string + email?: string username?: string - name?: string + name?: string | null imageUrl?: string } export interface AuthProvider { - getAuthStrategy(): Strategy + getAuthStrategy(request: Request): Strategy resolveConnectionInfo( profileId: string, options?: { timing?: ServerTiming }, diff --git a/package.json b/package.json index 2cfca33..709f1ef 100644 --- a/package.json +++ b/package.json @@ -79,9 +79,9 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "reading-time": "1.5.0", - "remix-auth": "3.7.0", + "remix-auth": "4.1.0", "remix-auth-discord": "1.4.1", - "remix-auth-github": "1.7.0", + "remix-auth-github": "3.0.2", "remix-utils": "7.7.0", "sonner": "1.7.1", "source-map-support": "0.5.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dbf2ab..7971991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,14 +186,14 @@ importers: specifier: 1.5.0 version: 1.5.0 remix-auth: - specifier: 3.7.0 - version: 3.7.0(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2)) + specifier: 4.1.0 + version: 4.1.0 remix-auth-discord: specifier: 1.4.1 version: 1.4.1(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2)) remix-auth-github: - specifier: 1.7.0 - version: 1.7.0(@remix-run/server-runtime@2.15.2(typescript@5.7.2))(remix-auth@3.7.0(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2))) + specifier: 3.0.2 + version: 3.0.2(remix-auth@4.1.0) remix-utils: specifier: 7.7.0 version: 7.7.0(@remix-run/node@2.15.2(typescript@5.7.2))(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/router@1.21.0)(crypto-js@4.2.0)(react@19.0.0)(zod@3.24.1) @@ -1392,6 +1392,9 @@ packages: '@mdx-js/mdx@3.0.0': resolution: {integrity: sha512-Icm0TBKBLYqroYbNW3BPnzMGn+7mwpQOK310aZ7+fkCtiU3aqv2cdcX+nd0Ydo3wI5Rx8bX2Z2QmGb/XcAClCw==} + '@mjackson/headers@0.9.0': + resolution: {integrity: sha512-1WFCu2iRaqbez9hcYYI611vcH1V25R+fDfOge/CyKc8sdbzniGfy/FRhNd3DgvFF4ZEEX2ayBrvFHLtOpfvadw==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} @@ -1658,6 +1661,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2919,6 +2940,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arctic@3.1.2: + resolution: {integrity: sha512-rZfIac3RK0j99/Z87rS3QajLxpKs/PPoHsOEZe+NiuJhpWslHqmvGWyqZSTUD3aRQ6FJY4YnUO6UabOiuOZtkQ==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -5922,11 +5946,11 @@ packages: peerDependencies: '@remix-run/server-runtime': ^2.1.0 - remix-auth-github@1.7.0: - resolution: {integrity: sha512-xuy/DW44y/eWU+vUsf9NlQUtLhayMZEJszgaVr1txwjA0OzpPee5qNxPQ9RBg8WdnY3pMWck5m070MW5Jt7nxg==} + remix-auth-github@3.0.2: + resolution: {integrity: sha512-3XxykdwMrcPSyMsdGtBDl3DBc19gJM3t7q/1uzfz3g/SJRsxEytjGiQ17ztKykebCGM454Z0lVJMvSb+LF/yHA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=20.0.0} peerDependencies: - '@remix-run/server-runtime': ^1.0.0 || ^2.0.0 - remix-auth: ^3.4.0 + remix-auth: ^4.0.0 remix-auth-oauth2@1.11.2: resolution: {integrity: sha512-5ORP+LMi5CVCA/Wb8Z+FCAJ73Uiy4uyjEzhlVwNBfdAkPOnfxzoi+q/pY/CrueYv3OniCXRM35ZYqkVi3G1UPw==} @@ -5940,6 +5964,10 @@ packages: '@remix-run/react': ^1.0.0 || ^2.0.0 '@remix-run/server-runtime': ^1.0.0 || ^2.0.0 + remix-auth@4.1.0: + resolution: {integrity: sha512-Xdy42clt+g79GCn+Wl1+B6S/yWnvrStnk62vo1pGuRuUwHC+pjmeEE52ZRkRPuhLGqsQnoDD9TVz/wfEJAGF8g==} + engines: {node: '>=20.0.0'} + remix-flat-routes@0.6.5: resolution: {integrity: sha512-VvPak+LCxL4Fm6Kb/nqPLipB71k9p+GXpzRNPVxs9FmCeJ7hxVmQ3HQMpStzuRQyAh0PMkaX6mBiRlCRHTCYHw==} hasBin: true @@ -7859,6 +7887,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@mjackson/headers@0.9.0': {} + '@mjackson/node-fetch-server@0.2.0': {} '@nasa-gcn/remix-seo@2.0.1(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2))': @@ -8210,6 +8240,25 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.0(@opentelemetry/api@1.9.0) + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -9698,6 +9747,12 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arctic@3.1.2: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + arg@5.0.2: {} argparse@1.0.10: @@ -13331,11 +13386,12 @@ snapshots: - '@remix-run/react' - supports-color - remix-auth-github@1.7.0(@remix-run/server-runtime@2.15.2(typescript@5.7.2))(remix-auth@3.7.0(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2))): + remix-auth-github@3.0.2(remix-auth@4.1.0): dependencies: - '@remix-run/server-runtime': 2.15.2(typescript@5.7.2) - remix-auth: 3.7.0(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2)) - remix-auth-oauth2: 1.11.2(@remix-run/server-runtime@2.15.2(typescript@5.7.2))(remix-auth@3.7.0(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/server-runtime@2.15.2(typescript@5.7.2))) + '@mjackson/headers': 0.9.0 + arctic: 3.1.2 + debug: 4.3.7 + remix-auth: 4.1.0 transitivePeerDependencies: - supports-color @@ -13354,6 +13410,8 @@ snapshots: '@remix-run/server-runtime': 2.15.2(typescript@5.7.2) uuid: 8.3.2 + remix-auth@4.1.0: {} + remix-flat-routes@0.6.5(@remix-run/dev@2.15.2(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/serve@2.15.2(typescript@5.7.2))(@types/node@22.10.2)(typescript@5.7.2)(vite@5.4.11(@types/node@22.10.2))): dependencies: '@remix-run/dev': 2.15.2(@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2))(@remix-run/serve@2.15.2(typescript@5.7.2))(@types/node@22.10.2)(typescript@5.7.2)(vite@5.4.11(@types/node@22.10.2))