diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts new file mode 100644 index 0000000..05b1228 --- /dev/null +++ b/apps/journal/app/lib/auth.server.ts @@ -0,0 +1,234 @@ +import { randomUUID, randomBytes } from "node:crypto"; +import { eq, and, gt, isNull } from "drizzle-orm"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import type { + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticatorTransportFuture, +} from "@simplewebauthn/types"; +import { getDb } from "./db"; +import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal"; + +const RP_NAME = "trails.cool"; +const RP_ID = process.env.DOMAIN ?? "localhost"; +const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`; + +// --- Registration --- + +export async function startRegistration(email: string, username: string) { + const db = getDb(); + + // Check for existing email/username + const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); + if (existingEmail) throw new Error("Email already in use"); + + const [existingUsername] = await db.select().from(users).where(eq(users.username, username)); + if (existingUsername) throw new Error("Username already taken"); + + const userId = randomUUID(); + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: RP_ID, + userID: new TextEncoder().encode(userId), + userName: username, + userDisplayName: username, + attestationType: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + return { options, userId }; +} + +export async function finishRegistration( + userId: string, + email: string, + username: string, + response: RegistrationResponseJSON, + challenge: string, +) { + const db = getDb(); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new Error("Registration verification failed"); + } + + const { credential } = verification.registrationInfo; + const domain = process.env.DOMAIN ?? "localhost"; + + // Create user and credential in a transaction + await db.insert(users).values({ + id: userId, + email, + username, + domain, + }); + + await db.insert(credentials).values({ + id: randomUUID(), + userId, + credentialId: Buffer.from(credential.id), + publicKey: Buffer.from(credential.publicKey), + counter: credential.counter, + transports: response.response.transports, + }); + + return userId; +} + +// --- Passkey Login --- + +export async function startAuthentication() { + const options = await generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: "preferred", + }); + + return options; +} + +export async function finishAuthentication( + response: AuthenticationResponseJSON, + challenge: string, +) { + const db = getDb(); + + const credentialIdBuffer = Buffer.from(response.rawId, "base64url"); + + // Find the credential + const [cred] = await db + .select() + .from(credentials) + .where(eq(credentials.credentialId, credentialIdBuffer)); + + if (!cred) throw new Error("Credential not found"); + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + credential: { + id: Buffer.from(cred.credentialId).toString("base64url"), + publicKey: new Uint8Array(cred.publicKey), + counter: cred.counter, + transports: cred.transports as AuthenticatorTransportFuture[] | undefined, + }, + }); + + if (!verification.verified) { + throw new Error("Authentication verification failed"); + } + + // Update counter + await db + .update(credentials) + .set({ counter: verification.authenticationInfo.newCounter }) + .where(eq(credentials.id, cred.id)); + + return cred.userId; +} + +// --- Magic Links --- + +export async function createMagicToken(email: string): Promise { + const db = getDb(); + + // Check user exists + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) throw new Error("No account found for this email"); + + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + await db.insert(magicTokens).values({ + id: randomUUID(), + email, + token, + expiresAt, + }); + + return token; +} + +export async function verifyMagicToken(token: string): Promise { + const db = getDb(); + + const [record] = await db + .select() + .from(magicTokens) + .where( + and( + eq(magicTokens.token, token), + gt(magicTokens.expiresAt, new Date()), + isNull(magicTokens.usedAt), + ), + ); + + if (!record) throw new Error("Invalid or expired magic link"); + + // Mark as used + await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where(eq(magicTokens.id, record.id)); + + // Find user + const [user] = await db.select().from(users).where(eq(users.email, record.email)); + if (!user) throw new Error("User not found"); + + return user.id; +} + +// --- Sessions --- + +import { createCookieSessionStorage } from "react-router"; + +const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production"; + +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, // 30 days + secrets: [sessionSecret], + }, +}); + +export async function createSession(userId: string, request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", userId); + return sessionStorage.commitSession(session); +} + +export async function getSessionUser(request: Request) { + const db = getDb(); + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + const userId = session.get("userId"); + if (!userId) return null; + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + return user ?? null; +} + +export async function destroySession(request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + return sessionStorage.destroySession(session); +} diff --git a/apps/journal/app/lib/db.ts b/apps/journal/app/lib/db.ts new file mode 100644 index 0000000..8fafd8c --- /dev/null +++ b/apps/journal/app/lib/db.ts @@ -0,0 +1,10 @@ +import { createDb, type Database } from "@trails-cool/db"; + +let _db: Database | null = null; + +export function getDb(): Database { + if (!_db) { + _db = createDb(); + } + return _db; +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 102b402..93980c1 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -1,3 +1,10 @@ -import { type RouteConfig, index } from "@react-router/dev/routes"; +import { type RouteConfig, index, route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [ + index("routes/home.tsx"), + route("auth/register", "routes/auth.register.tsx"), + route("auth/login", "routes/auth.login.tsx"), + route("auth/verify", "routes/auth.verify.tsx"), + route("auth/logout", "routes/auth.logout.tsx"), + route("users/:username", "routes/users.$username.tsx"), +] satisfies RouteConfig; diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx new file mode 100644 index 0000000..6be8e85 --- /dev/null +++ b/apps/journal/app/routes/auth.login.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { data, redirect } from "react-router"; +import type { Route } from "./+types/auth.login"; +import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server"; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.json(); + const { step, response, challenge, email } = formData; + + try { + if (step === "start-passkey") { + const options = await startAuthentication(); + return data({ step: "challenge", options }); + } + + if (step === "finish-passkey") { + const userId = await finishAuthentication(response, challenge); + const cookie = await createSession(userId, request); + return redirect("/", { headers: { "Set-Cookie": cookie } }); + } + + if (step === "magic-link") { + const token = await createMagicToken(email); + // In production, send email. For dev, log the link. + const link = `${process.env.ORIGIN ?? "http://localhost:3000"}/auth/verify?token=${token}`; + console.log(`[Magic Link] ${email}: ${link}`); + return data({ step: "magic-link-sent" }); + } + + return data({ error: "Invalid step" }, { status: 400 }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 400 }); + } +} + +export default function LoginPage() { + const [mode, setMode] = useState<"passkey" | "magic-link">("passkey"); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [magicLinkSent, setMagicLinkSent] = useState(false); + + const handlePasskeyLogin = async () => { + setError(null); + setLoading(true); + + try { + const startResp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "start-passkey" }), + }); + const startData = await startResp.json(); + + if (startData.error) { + setError(startData.error); + setLoading(false); + return; + } + + const { startAuthentication: startWebAuthn } = await import("@simplewebauthn/browser"); + const webAuthnResp = await startWebAuthn(startData.options); + + const finishResp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "finish-passkey", + response: webAuthnResp, + challenge: startData.options.challenge, + }), + }); + + if (finishResp.redirected) { + window.location.href = finishResp.url; + return; + } + + const finishData = await finishResp.json(); + if (finishData.error) setError(finishData.error); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const handleMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const resp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "magic-link", email }), + }); + const result = await resp.json(); + + if (result.error) { + setError(result.error); + } else { + setMagicLinkSent(true); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Sign In

+ + {mode === "passkey" && ( +
+ + +
+ +
+
+ )} + + {mode === "magic-link" && !magicLinkSent && ( +
+

+ We'll send a login link to your email. +

+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + + +
+ +
+
+ )} + + {magicLinkSent && ( +
+

+ Check your email! We sent a login link to {email}. +

+

+ The link expires in 15 minutes. +

+
+ )} + + {error && ( +

{error}

+ )} + +

+ Don't have an account?{" "} + + Register + +

+
+ ); +} diff --git a/apps/journal/app/routes/auth.logout.tsx b/apps/journal/app/routes/auth.logout.tsx new file mode 100644 index 0000000..44e9cf2 --- /dev/null +++ b/apps/journal/app/routes/auth.logout.tsx @@ -0,0 +1,8 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.logout"; +import { destroySession } from "~/lib/auth.server"; + +export async function action({ request }: Route.ActionArgs) { + const cookie = await destroySession(request); + return redirect("/auth/login", { headers: { "Set-Cookie": cookie } }); +} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx new file mode 100644 index 0000000..6a2fda3 --- /dev/null +++ b/apps/journal/app/routes/auth.register.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { data, redirect } from "react-router"; +import type { Route } from "./+types/auth.register"; +import { startRegistration, finishRegistration, createSession } from "~/lib/auth.server"; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.json(); + const { step, email, username, response, challenge, userId } = formData; + + try { + if (step === "start") { + const { options, userId } = await startRegistration(email, username); + return data({ step: "challenge", options, userId }); + } + + if (step === "finish") { + const newUserId = await finishRegistration(userId, email, username, response, challenge); + const cookie = await createSession(newUserId, request); + return redirect("/", { headers: { "Set-Cookie": cookie } }); + } + + return data({ error: "Invalid step" }, { status: 400 }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 400 }); + } +} + +export default function RegisterPage() { + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + // Step 1: Get registration options from server + const startResp = await fetch("/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "start", email, username }), + }); + const startData = await startResp.json(); + + if (startData.error) { + setError(startData.error); + setLoading(false); + return; + } + + // Step 2: Create passkey via browser + const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser"); + const webAuthnResp = await startWebAuthn(startData.options); + + // Step 3: Send response to server + const finishResp = await fetch("/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "finish", + email, + username, + response: webAuthnResp, + challenge: startData.options.challenge, + userId: startData.userId, + }), + }); + + if (finishResp.redirected) { + window.location.href = finishResp.url; + return; + } + + const finishData = await finishResp.json(); + if (finishData.error) { + setError(finishData.error); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Create Account

+

+ Register with a passkey — no password needed. +

+ +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setUsername(e.target.value.toLowerCase())} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="alice" + /> +

+ Your handle will be @{username || "..."}@{typeof window !== "undefined" ? window.location.hostname : "trails.cool"} +

+
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/journal/app/routes/auth.verify.tsx b/apps/journal/app/routes/auth.verify.tsx new file mode 100644 index 0000000..9343e8e --- /dev/null +++ b/apps/journal/app/routes/auth.verify.tsx @@ -0,0 +1,32 @@ +import { redirect, data } from "react-router"; +import type { Route } from "./+types/auth.verify"; +import { verifyMagicToken, createSession } from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const token = url.searchParams.get("token"); + + if (!token) { + return data({ error: "Missing token" }, { status: 400 }); + } + + try { + const userId = await verifyMagicToken(token); + const cookie = await createSession(userId, request); + // Redirect to home with prompt to add passkey + return redirect("/?add-passkey=1", { headers: { "Set-Cookie": cookie } }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 400 }); + } +} + +export default function VerifyPage() { + return ( +
+

Invalid or expired magic link.

+ + Request a new one + +
+ ); +} diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index b38fab1..83d01ec 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -1,4 +1,6 @@ +import { data } from "react-router"; import type { Route } from "./+types/home"; +import { getSessionUser } from "~/lib/auth.server"; export function meta(_args: Route.MetaArgs) { return [ @@ -7,11 +9,41 @@ export function meta(_args: Route.MetaArgs) { ]; } -export default function Home() { +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + return data({ user: user ? { username: user.username, displayName: user.displayName } : null }); +} + +export default function Home({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + return (

trails.cool

Your outdoor activity journal

+ + {user ? ( + + ) : ( + + )}
); } diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx new file mode 100644 index 0000000..797dc46 --- /dev/null +++ b/apps/journal/app/routes/users.$username.tsx @@ -0,0 +1,75 @@ +import { data } from "react-router"; +import type { Route } from "./+types/users.$username"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { eq } from "drizzle-orm"; +import { getSessionUser } from "~/lib/auth.server"; + +export async function loader({ params, request }: Route.LoaderArgs) { + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.username, params.username)); + + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + + return data({ + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + domain: user.domain, + createdAt: user.createdAt.toISOString(), + }, + isOwn, + }); +} + +export function meta({ data: loaderData }: Route.MetaArgs) { + const username = (loaderData as { user: { username: string } })?.user?.username ?? "User"; + return [{ title: `@${username} — trails.cool` }]; +} + +export default function UserProfilePage({ loaderData }: Route.ComponentProps) { + const { user, isOwn } = loaderData; + + return ( +
+
+
+ {user.username[0]?.toUpperCase()} +
+
+

+ {user.displayName ?? user.username} +

+

+ @{user.username}@{user.domain} +

+ {user.bio &&

{user.bio}

} +
+
+ + {isOwn && ( +
+
+ +
+
+ )} + +
+

Routes

+

No routes yet.

+
+
+ ); +} diff --git a/apps/journal/package.json b/apps/journal/package.json index 26db5b3..ecb6454 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -11,22 +11,25 @@ "lint": "eslint ." }, "dependencies": { - "@trails-cool/db": "workspace:*", - "@trails-cool/ui": "workspace:*", - "@trails-cool/types": "workspace:*", - "@trails-cool/map": "workspace:*", - "@trails-cool/gpx": "workspace:*", - "@trails-cool/i18n": "workspace:*", - "drizzle-orm": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-router": "catalog:", "@react-router/node": "catalog:", "@react-router/serve": "catalog:", - "isbot": "^5.1.0" + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", + "@trails-cool/db": "workspace:*", + "@trails-cool/gpx": "workspace:*", + "@trails-cool/i18n": "workspace:*", + "@trails-cool/map": "workspace:*", + "@trails-cool/types": "workspace:*", + "@trails-cool/ui": "workspace:*", + "drizzle-orm": "catalog:", + "isbot": "^5.1.0", + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "catalog:" }, "devDependencies": { "@react-router/dev": "catalog:", + "@simplewebauthn/types": "^12.0.0", "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/apps/journal/vite.config.ts b/apps/journal/vite.config.ts index 76b193c..5b05dd6 100644 --- a/apps/journal/vite.config.ts +++ b/apps/journal/vite.config.ts @@ -1,9 +1,15 @@ import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; +import path from "node:path"; export default defineConfig({ plugins: [tailwindcss(), reactRouter()], + resolve: { + alias: { + "~": path.resolve(__dirname, "./app"), + }, + }, server: { port: 3000, }, diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 262d81e..d9d0153 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -63,16 +63,16 @@ ## 7. Journal — Auth -- [ ] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table -- [ ] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created) -- [ ] 7.3 Implement passkey login flow (WebAuthn get → session created) -- [ ] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token) -- [ ] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created) +- [x] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table +- [x] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created) +- [x] 7.3 Implement passkey login flow (WebAuthn get → session created) +- [x] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token) +- [x] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created) - [ ] 7.6 Implement "Add passkey" prompt after magic link login on new device -- [ ] 7.7 Implement session middleware (validate cookie, load user in loader context) -- [ ] 7.8 Implement logout (POST /api/auth/logout, invalidate session) -- [ ] 7.9 Implement user profile page (GET /users/:username) -- [ ] 7.10 Store federated identity format (@user@domain) in user record +- [x] 7.7 Implement session middleware (validate cookie, load user in loader context) +- [x] 7.8 Implement logout (POST /api/auth/logout, invalidate session) +- [x] 7.9 Implement user profile page (GET /users/:username) +- [x] 7.10 Store federated identity format (@user@domain) in user record ## 8. Journal — Route Management diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a678b..cfd3763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,12 @@ importers: '@react-router/serve': specifier: 'catalog:' version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 + '@simplewebauthn/server': + specifier: ^13.3.0 + version: 13.3.0 '@trails-cool/db': specifier: workspace:* version: link:../../packages/db @@ -204,6 +210,9 @@ importers: '@react-router/dev': specifier: 'catalog:' version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@simplewebauthn/types': + specifier: ^12.0.0 + version: 12.0.0 '@tailwindcss/vite': specifier: 'catalog:' version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) @@ -911,6 +920,9 @@ packages: '@noble/hashes': optional: true + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -943,9 +955,49 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1138,6 +1190,17 @@ packages: cpu: [x64] os: [win32] + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + + '@simplewebauthn/types@12.0.0': + resolution: {integrity: sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1448,6 +1511,10 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2427,6 +2494,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -2496,6 +2570,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2649,11 +2726,21 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + turbo@2.8.20: resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==} hasBin: true @@ -3360,6 +3447,8 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3390,8 +3479,106 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@levischuck/tiny-cbor@0.2.11': {} + '@mjackson/node-fetch-server@0.2.0': {} + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -3559,6 +3746,21 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + + '@simplewebauthn/types@12.0.0': {} + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.2.2': @@ -3878,6 +4080,12 @@ snapshots: array-flatten@1.1.1: {} + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assertion-error@2.0.1: {} babel-dead-code-elimination@1.0.12: @@ -4678,6 +4886,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -4735,6 +4949,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -4909,6 +5125,10 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@1.14.1: {} + + tslib@2.8.1: {} + tsx@4.21.0: dependencies: esbuild: 0.27.4 @@ -4916,6 +5136,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + turbo@2.8.20: optionalDependencies: '@turbo/darwin-64': 2.8.20