diff --git a/.gitignore b/.gitignore index 9bb52d4..c55999a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies and build output node_modules/ .next/ +next-env.d.ts out/ dist/ @@ -9,6 +10,7 @@ coverage/ playwright-report/ test-results/ *.tsbuildinfo +generated/ # Environment and secrets .env diff --git a/README.md b/README.md index de2afe5..5dbab8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Status -This repository contains the development foundation and initial App Router shell for a public-facing Next.js application. The root landing page, layout, and global styles are ready for feature development; authentication, database models, migrations, API routes, and browser workflows have **not** been created yet. The approved product decisions are recorded in [docs/product-spec.md](docs/product-spec.md) and [docs/implementation-plan.md](docs/implementation-plan.md). +This repository contains the development foundation and initial App Router shell for a public-facing Next.js application. Members can register and sign in with a display name, email address, and password. The approved product decisions are recorded in [docs/product-spec.md](docs/product-spec.md) and [docs/implementation-plan.md](docs/implementation-plan.md). ## Repository map @@ -21,7 +21,7 @@ This repository contains the development foundation and initial App Router shell ## Getting Started 1. Install [Git](https://git-scm.com/downloads), [Node.js 26.8.2](https://nodejs.org/en/download), and [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine with the Compose plugin). Node 26 is the project’s active-LTS line; npm 11 is bundled with it. -2. Copy the local configuration template. It contains only safe local service credentials; replace `AUTH_SECRET` before authentication work begins. +2. Copy the local configuration template. It contains only safe local service credentials. Generate a unique `AUTH_SECRET` for your local environment with `npx auth secret`, then keep it out of version control. ```sh cp .env.example .env @@ -62,7 +62,7 @@ To install dependencies, start local services, and launch the development server ## Database migrations -Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. No product schema exists yet. Once a model is approved, create and review a development migration with: +Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. The initial account migration creates the `User` table with a unique email address and password hash. Once a model is approved, create and review a development migration with: ```sh npm run prisma:migrate -- --name describe_the_change diff --git a/app/Navbar.css b/app/Navbar.css index 86272f5..aca85b9 100644 --- a/app/Navbar.css +++ b/app/Navbar.css @@ -1,25 +1,42 @@ -nav { - background-color: rgba(75, 207, 141, 0.473); - color: black; -} - -ul { - list-style-type: none; - margin: 1rem; - padding: 0; - display: flex; - justify-content: space-around; -} - -li { - padding: 15px; -} - -a { - color: black; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} +nav { + background-color: rgba(75, 207, 141, 0.473); + color: black; +} + +ul { + list-style-type: none; + margin: 1rem; + padding: 0; + display: flex; + justify-content: space-around; +} + +li { + padding: 15px; +} + +a { + color: black; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav form { + margin: 0; +} + +nav button { + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; + font: inherit; +} + +nav button:hover { + text-decoration: underline; +} diff --git a/app/Navbar.tsx b/app/Navbar.tsx index a13d528..0de4baa 100644 --- a/app/Navbar.tsx +++ b/app/Navbar.tsx @@ -1,22 +1,44 @@ -import React from 'react'; -import './Navbar.css'; - -type navbarProps = { - links: { text: string; url: string }[]; -}; - -const Navbar: React.FC = ({ links }) => { - return ( - - ); -}; - -export default Navbar; +import Link from 'next/link' +import { signOut } from '../auth' + +import './Navbar.css' + +type NavbarProps = { + session: { user?: { name?: string | null } } | null +} + +export default function Navbar({ session }: NavbarProps) { + return ( + + ) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c52987a --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '../../../../auth' + +export const { GET, POST } = handlers diff --git a/app/globals.css b/app/globals.css index 5972a07..de8cfb5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,7 +4,7 @@ --ink: #20251f; --muted: #5d675d; --line: #cdd5c8; - --accent: #476a45; + --accent: #3cb4bd; --accent-soft: #dfe9d9; } @@ -132,6 +132,115 @@ h3 { color: var(--muted); } +.account-page { + display: grid; + min-height: calc(90vh - 7rem); + place-items: center; + padding: 1rem 1.5rem 5rem; +} + +.account-panel { + width: min(100%, 31rem); + padding: 2rem; + border: 1px solid var(--line); + background: #fffdf8; +} + +.account-panel__back { + display: inline-block; + margin-bottom: 3rem; + color: var(--muted); + font-size: 0.875rem; +} + +.account-panel h2 { + max-width: 14ch; + margin-top: 0.5rem; + font-size: 2rem; +} + +.account-panel__intro { + margin-top: .25rem; + color: var(--muted); +} + +.account-form { + display: grid; + gap: 1.25rem; + margin-top: 2rem; +} + +.form-error { + margin: 0; + padding: 0.75rem; + border-left: 4px solid #9b2c2c; + color: #6e2020; + background: #fff1f1; +} + +.form-field { + display: grid; + gap: 0.5rem; +} + +.form-field label { + font-size: 0.875rem; + font-weight: 700; +} + +.form-field input { + width: 100%; + min-height: 2.5rem; + padding: 0.425rem 0.75rem; + border: 1px solid var(--line); + border-radius: 0; + color: var(--ink); + background: #fffdf8; + font: inherit; +} + +.form-field input:focus-visible, +.account-form button:focus-visible, +.account-panel a:focus-visible { + outline: 3px solid #236e69; + outline-offset: 3px; +} + +.form-field__hint { + color: var(--muted); + font-size: 0.875rem; +} + +.account-form button { + min-height: 2.75rem; + border: 1px solid var(--accent); + border-radius: 0; + color: #fff; + background: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.account-form button:hover { + background: #468a7b; +} + +.account-form button:disabled { + cursor: wait; + opacity: 0.7; +} + +.account-panel__switch { + margin-top: 1.75rem; + color: var(--muted); +} + +.account-panel__switch a { + color: var(--accent); + font-weight: 700; +} + @media (min-width: 48rem) { .hero { padding: 5rem 1.5rem; @@ -160,4 +269,8 @@ h3 { .principles__list span { grid-row: auto; } + + .account-panel { + padding: 2rem; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 8b87e99..5173d12 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,9 @@ import type { ReactNode } from 'react' import './globals.css' import Navbar from './Navbar' +import { auth } from '../auth' + +export default async function RootLayout({ export const metadata: Metadata = { title: 'SideCause', @@ -10,16 +13,17 @@ export const metadata: Metadata = { 'Complete community service projects and level up!', } - const navLinks = [ { text: 'SideCause', url: '/' }, { text: 'Posts', url: '/posts' }, - { text: 'Login', url: '/' } + { text: 'Login', url: '/login' } ]; export default function RootLayout({ children, }: Readonly<{ children: ReactNode }>) { + const session = await auth() + return ( diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx new file mode 100644 index 0000000..268cc7c --- /dev/null +++ b/app/login/LoginForm.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useActionState, useState } from 'react' + +import { initialLoginFormState } from '../../lib/auth/form-state' +import { loginAction } from './actions' + +export function LoginForm() { + const [state, formAction, isPending] = useActionState( + loginAction, + initialLoginFormState, + ) + const [email, setEmail] = useState(initialLoginFormState.values.email) + + return ( +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ + setEmail(event.target.value)} + required + /> +
+
+ + +
+ +
+ ) +} diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..4c90b97 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,44 @@ +'use server' + +import { AuthError } from 'next-auth' + +import { signIn } from '../../auth' +import { + initialLoginFormState, + type LoginFormState, +} from '../../lib/auth/form-state' +import { validateLogin } from '../../lib/auth/validation' + +export async function loginAction( + _previousState: LoginFormState, + formData: FormData, +): Promise { + const values = { email: String(formData.get('email') ?? '').trim() } + let credentials: { email: string; password: string } + + try { + credentials = validateLogin({ + email: values.email, + password: String(formData.get('password') ?? ''), + }) + } catch { + return { + error: 'Enter a valid email address and password.', + values, + } + } + + try { + await signIn('credentials', { ...credentials, redirectTo: '/' }) + } catch (error) { + if (error instanceof AuthError) { + return { + error: 'The email address or password is incorrect.', + values, + } + } + throw error + } + + return initialLoginFormState +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..ed7d4a3 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' + +import { LoginForm } from './LoginForm' + +export default function LoginPage() { + return ( +
+
+

Welcome back!

+

Log In

+ + + +

+ New to SideCause? Create an account +

+
+
+ ) +} diff --git a/app/register/RegisterForm.tsx b/app/register/RegisterForm.tsx new file mode 100644 index 0000000..3240009 --- /dev/null +++ b/app/register/RegisterForm.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useActionState, useState } from 'react' + +import { initialRegisterFormState } from '../../lib/auth/form-state' +import { registerAction } from './actions' + +export function RegisterForm() { + const [state, formAction, isPending] = useActionState( + registerAction, + initialRegisterFormState, + ) + const [displayName, setDisplayName] = useState( + initialRegisterFormState.values.displayName, + ) + const [email, setEmail] = useState(initialRegisterFormState.values.email) + + return ( +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ + setDisplayName(event.target.value)} + required + /> +
+
+ + setEmail(event.target.value)} + required + /> +
+
+ + +

Use at least 8 characters.

+
+ +
+ ) +} diff --git a/app/register/actions.ts b/app/register/actions.ts new file mode 100644 index 0000000..de0c056 --- /dev/null +++ b/app/register/actions.ts @@ -0,0 +1,84 @@ +'use server' + +import { Prisma } from '../../generated/prisma/client' +import { AuthError } from 'next-auth' + +import { signIn } from '../../auth' +import { + initialRegisterFormState, + type RegisterFormState, +} from '../../lib/auth/form-state' +import { assertWithinAuthRateLimit } from '../../lib/auth/rate-limit' +import { hashPassword } from '../../lib/auth/password' +import { validateRegistration } from '../../lib/auth/validation' +import { prisma } from '../../lib/prisma' + +export async function registerAction( + _previousState: RegisterFormState, + formData: FormData, +): Promise { + const values = { + displayName: String(formData.get('displayName') ?? '').trim(), + email: String(formData.get('email') ?? '').trim(), + } + let input: { displayName: string; email: string; password: string } + + try { + input = validateRegistration({ + displayName: values.displayName, + email: values.email, + password: String(formData.get('password') ?? ''), + }) + } catch { + return { + error: + 'Enter a display name, valid email address, and password of at least 8 characters.', + values, + } + } + + const { displayName, email, password } = input + const normalizedValues = { displayName, email } + + try { + assertWithinAuthRateLimit(`register:${email}`) + } catch { + return { + error: 'Too many account creation attempts. Please try again later.', + values: normalizedValues, + } + } + + try { + await prisma.user.create({ + data: { displayName, email, passwordHash: await hashPassword(password) }, + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return { + error: + 'An account already exists with this email address. Log in instead.', + values: normalizedValues, + } + } + throw error + } + + try { + await signIn('credentials', { email, password, redirectTo: '/' }) + } catch (error) { + if (error instanceof AuthError) { + return { + error: + 'Your account was created, but we could not log you in. Please log in.', + values: normalizedValues, + } + } + throw error + } + + return initialRegisterFormState +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..7e89410 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' + +import { RegisterForm } from './RegisterForm' + +export default function RegisterPage() { + return ( +
+
+

Join the cause

+

Create an Account

+ + + +

+ Already have an account? Log in +

+
+
+ ) +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..94e10a2 --- /dev/null +++ b/auth.ts @@ -0,0 +1,41 @@ +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' + +import { prisma } from './lib/prisma' +import { + assertWithinAuthRateLimit, + clearAuthRateLimit, +} from './lib/auth/rate-limit' +import { verifyPassword } from './lib/auth/password' +import { validateLogin } from './lib/auth/validation' + +export const { handlers, signIn, signOut, auth } = NextAuth({ + session: { strategy: 'jwt' }, + providers: [ + Credentials({ + credentials: { + email: { type: 'email' }, + password: { type: 'password' }, + }, + async authorize(credentials) { + try { + const { email, password } = validateLogin({ + email: String(credentials.email ?? ''), + password: String(credentials.password ?? ''), + }) + assertWithinAuthRateLimit(`login:${email}`) + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !(await verifyPassword(password, user.passwordHash))) { + return null + } + + clearAuthRateLimit(`login:${email}`) + return { id: user.id, name: user.displayName, email: user.email } + } catch { + return null + } + }, + }), + ], +}) diff --git a/lib/auth/form-state.ts b/lib/auth/form-state.ts new file mode 100644 index 0000000..cca7dbc --- /dev/null +++ b/lib/auth/form-state.ts @@ -0,0 +1,19 @@ +export type LoginFormState = { + error: string | null + values: { email: string } +} + +export const initialLoginFormState: LoginFormState = { + error: null, + values: { email: '' }, +} + +export type RegisterFormState = { + error: string | null + values: { displayName: string; email: string } +} + +export const initialRegisterFormState: RegisterFormState = { + error: null, + values: { displayName: '', email: '' }, +} diff --git a/lib/auth/password.ts b/lib/auth/password.ts new file mode 100644 index 0000000..cab82d2 --- /dev/null +++ b/lib/auth/password.ts @@ -0,0 +1,70 @@ +import { argon2, randomBytes, timingSafeEqual } from 'node:crypto' + +const memory = 19 * 1024 +const passes = 2 +const parallelism = 1 +const tagLength = 32 + +function derivePasswordKey(password: string, nonce: Buffer): Promise { + return new Promise((resolve, reject) => { + argon2( + 'argon2id', + { message: password, nonce, parallelism, tagLength, memory, passes }, + (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + ) + }) +} + +export async function hashPassword(password: string): Promise { + const nonce = randomBytes(16) + const derivedKey = await derivePasswordKey(password, nonce) + + return `argon2id$${memory}$${passes}$${parallelism}$${nonce.toString('base64url')}$${derivedKey.toString('base64url')}` +} + +export async function verifyPassword( + password: string, + storedHash: string, +): Promise { + const [ + algorithm, + storedMemory, + storedPasses, + storedParallelism, + nonceValue, + keyValue, + ] = storedHash.split('$') + + if ( + algorithm !== 'argon2id' || + !storedMemory || + !storedPasses || + !storedParallelism || + !nonceValue || + !keyValue + ) { + return false + } + + const nonce = Buffer.from(nonceValue, 'base64url') + const expectedKey = Buffer.from(keyValue, 'base64url') + const actualKey = await new Promise((resolve, reject) => { + argon2( + 'argon2id', + { + message: password, + nonce, + parallelism: Number(storedParallelism), + tagLength: expectedKey.length, + memory: Number(storedMemory), + passes: Number(storedPasses), + }, + (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + ) + }) + + return ( + actualKey.length === expectedKey.length && + timingSafeEqual(actualKey, expectedKey) + ) +} diff --git a/lib/auth/rate-limit.ts b/lib/auth/rate-limit.ts new file mode 100644 index 0000000..b33812e --- /dev/null +++ b/lib/auth/rate-limit.ts @@ -0,0 +1,23 @@ +const attempts = new Map() +const windowMs = 15 * 60 * 1000 +const maximumAttempts = 10 + +export function assertWithinAuthRateLimit(key: string) { + const now = Date.now() + const current = attempts.get(key) + + if (!current || current.resetAt <= now) { + attempts.set(key, { count: 1, resetAt: now + windowMs }) + return + } + + if (current.count >= maximumAttempts) { + throw new Error('Too many attempts. Please try again later.') + } + + current.count += 1 +} + +export function clearAuthRateLimit(key: string) { + attempts.delete(key) +} diff --git a/lib/auth/validation.ts b/lib/auth/validation.ts new file mode 100644 index 0000000..144ddda --- /dev/null +++ b/lib/auth/validation.ts @@ -0,0 +1,41 @@ +const registrationError = + 'Please enter a display name, valid email address, and password of at least 8 characters.' + +export type RegistrationInput = { + displayName: string + email: string + password: string +} + +export function validateRegistration( + input: RegistrationInput, +): RegistrationInput { + const displayName = input.displayName.trim() + const email = input.email.trim().toLowerCase() + const password = input.password + + if ( + displayName.length < 1 || + displayName.length > 80 || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || + email.length > 254 || + password.length < 8 || + password.length > 128 + ) { + throw new Error(registrationError) + } + + return { displayName, email, password } +} + +export function validateLogin( + input: Pick, +) { + const email = input.email.trim().toLowerCase() + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || input.password.length < 1) { + throw new Error('Invalid email or password.') + } + + return { email, password: input.password } +} diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..473d1fd --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,23 @@ +import { PrismaPg } from '@prisma/adapter-pg' + +import { PrismaClient } from '../generated/prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +function createPrismaClient() { + const connectionString = process.env.DATABASE_URL + + if (!connectionString) { + throw new Error('DATABASE_URL is required.') + } + + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} diff --git a/package-lock.json b/package-lock.json index 1a66989..93a7536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "sidecause", "version": "0.1.0", "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", + "glob": "^13.0.6", "next": "^16.3.5", + "next-auth": "^5.0.0-beta.32", + "pg": "^8.23.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -18,15 +22,17 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/pg": "^8.23.1", + "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", "eslint": "^9.39.5", - "eslint-config-next": "^16.3.6", + "eslint-config-next": "^16.3.8", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.9.9", "prisma": "^7.10.0", - "testcontainers": "^11.14.0", + "testcontainers": "^12.2.0", "typescript": "5.9", "vitest": "^4.1.11" }, @@ -104,6 +110,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1535,15 +1570,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.6.tgz", - "integrity": "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", + "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1584,9 +1619,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1600,9 +1635,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1616,9 +1651,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1635,9 +1670,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1654,9 +1689,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1673,9 +1708,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1692,9 +1727,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1708,9 +1743,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -1781,6 +1816,15 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1808,6 +1852,18 @@ "node": ">=20" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -1855,7 +1911,6 @@ "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -1889,6 +1944,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, "node_modules/@prisma/engines": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", @@ -2823,12 +2887,22 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -2877,17 +2951,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", - "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2900,15 +2974,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2916,16 +2990,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", - "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2941,14 +3015,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", - "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2963,14 +3037,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", - "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2981,9 +3055,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", - "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -2998,15 +3072,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", - "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3023,9 +3097,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", - "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -3037,16 +3111,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", - "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3104,16 +3178,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", - "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3128,13 +3202,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", - "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3934,6 +4008,78 @@ "node": ">= 14" } }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4356,9 +4502,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", - "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4471,9 +4617,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", - "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -4491,10 +4637,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.23", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.427", - "node-releases": "^2.0.55", + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { @@ -4649,9 +4795,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "funding": [ { "type": "opencollective", @@ -5375,9 +5521,9 @@ } }, "node_modules/dockerode": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", - "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5386,11 +5532,10 @@ "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", - "tar-fs": "^2.1.4", - "uuid": "^10.0.0" + "tar-fs": "^2.1.4" }, "engines": { - "node": ">= 8.0" + "node": ">= 14.17" } }, "node_modules/dockerode/node_modules/readable-stream": { @@ -5505,9 +5650,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.433", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", - "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, @@ -5862,13 +6007,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.6.tgz", - "integrity": "sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", + "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.6", + "@next/eslint-plugin-next": "16.3.8", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6304,9 +6449,9 @@ } }, "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "devOptional": true, "license": "MIT" }, @@ -6679,13 +6824,13 @@ } }, "node_modules/get-port": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", - "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6754,22 +6899,17 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6788,27 +6928,37 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7706,6 +7856,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8418,7 +8577,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -8538,12 +8696,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -8557,14 +8715,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -8590,6 +8748,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -8620,9 +8805,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.56", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", - "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -8639,6 +8824,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8923,28 +9117,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -8960,6 +9155,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9079,6 +9372,64 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10032,6 +10383,15 @@ "dev": true, "license": "ISC" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -10338,9 +10698,9 @@ } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -10481,9 +10841,9 @@ } }, "node_modules/testcontainers": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.14.0.tgz", - "integrity": "sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.2.0.tgz", + "integrity": "sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA==", "dev": true, "license": "MIT", "dependencies": { @@ -10493,15 +10853,18 @@ "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", - "docker-compose": "^1.4.2", - "dockerode": "^4.0.10", - "get-port": "^7.2.0", + "docker-compose": "^1.5.0", + "dockerode": "^5.0.1", + "get-port": "^5.1.1", "proper-lockfile": "^4.1.2", "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.1.2", - "tmp": "^0.2.5", - "undici": "^7.24.5" + "tar-fs": "^3.1.3", + "tmp": "^0.2.7", + "undici": "^8.10.2" + }, + "engines": { + "node": ">= 22.22" } }, "node_modules/text-decoder": { @@ -10815,16 +11178,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", - "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10858,20 +11221,19 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -10960,21 +11322,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/valibot": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", @@ -11523,6 +11870,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 2016e72..b0d03c2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev", + "postinstall": "prisma generate", "build": "next build", "start": "next start", "format": "prettier --write .", @@ -28,8 +29,12 @@ "verify": "npm run format:check && npm run lint && npm run typecheck && npm run coverage" }, "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", + "glob": "^13.0.6", "next": "^16.3.5", + "next-auth": "^5.0.0-beta.32", + "pg": "^8.23.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -38,24 +43,26 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/pg": "^8.23.1", + "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", "eslint": "^9.39.5", - "eslint-config-next": "^16.3.6", + "eslint-config-next": "^16.3.8", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.9.9", "prisma": "^7.10.0", - "testcontainers": "^11.14.0", + "testcontainers": "^12.2.0", "typescript": "5.9", "vitest": "^4.1.11" }, "allowScripts": { "cpu-features@0.0.10": true, - "prisma@7.10.0": true, "protobufjs@7.6.6": true, "ssh2@1.17.0": true, "unrs-resolver@1.12.2": true, + "prisma@7.10.0": true, "@prisma/engines@7.10.0": true } } diff --git a/prisma/migrations/20261005212439_add_user_accounts/migration.sql b/prisma/migrations/20261005212439_add_user_accounts/migration.sql new file mode 100644 index 0000000..0f81541 --- /dev/null +++ b/prisma/migrations/20261005212439_add_user_accounts/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a215472..1e426b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,17 @@ -// Product models belong to the application implementation phase. +generator client { + provider = "prisma-client" + output = "../generated/prisma" +} + datasource db { provider = "postgresql" } + +model User { + id String @id @default(cuid()) + email String @unique + displayName String + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/scripts/dev.sh b/scripts/dev.sh index fc89d0e..af95f3d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -6,5 +6,9 @@ cd "$project_root" npm ci npm run docker:up -exec npm run dev -npm run docker:down \ No newline at end of file + +server_status=0 +npm run dev || server_status=$? +npm run docker:down + +exit "$server_status" diff --git a/tests/unit/account-pages.test.tsx b/tests/unit/account-pages.test.tsx new file mode 100644 index 0000000..1806653 --- /dev/null +++ b/tests/unit/account-pages.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { loginAction, registerAction } = vi.hoisted(() => ({ + loginAction: vi.fn(), + registerAction: vi.fn(), +})) + +vi.mock('../../app/login/actions', () => ({ loginAction })) +vi.mock('../../app/register/actions', () => ({ registerAction })) + +import LoginPage from '../../app/login/page' +import RegisterPage from '../../app/register/page' + +afterEach(cleanup) + +describe('account page templates', () => { + it('links the login page to account creation', () => { + render() + + expect( + screen + .getByRole('link', { name: 'Create an account' }) + .getAttribute('href'), + ).toBe('/register') + }) + + it('links the account-creation page back to login', () => { + render() + + expect( + screen.getByRole('link', { name: 'Log in' }).getAttribute('href'), + ).toBe('/login') + }) + + it('keeps the email address and explains a failed login', async () => { + loginAction.mockResolvedValueOnce({ + error: 'The email address or password is incorrect.', + values: { email: 'maya@example.com' }, + }) + render() + + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'maya@example.com' }, + }) + fireEvent.submit( + screen.getByRole('button', { name: 'Log in' }).form!, + ) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'The email address or password is incorrect.', + ) + expect(screen.getByLabelText('Email address').value).toBe( + 'maya@example.com', + ) + }) + + it('keeps non-sensitive registration fields and explains a duplicate email', async () => { + registerAction.mockResolvedValueOnce({ + error: + 'An account already exists with this email address. Log in instead.', + values: { displayName: 'Maya Chen', email: 'maya@example.com' }, + }) + render() + + fireEvent.change(screen.getByLabelText('Display name'), { + target: { value: 'Maya Chen' }, + }) + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'maya@example.com' }, + }) + fireEvent.submit( + screen.getByRole('button', { + name: 'Create account', + }).form!, + ) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'An account already exists with this email address. Log in instead.', + ) + expect(screen.getByLabelText('Display name').value).toBe( + 'Maya Chen', + ) + expect(screen.getByLabelText('Email address').value).toBe( + 'maya@example.com', + ) + }) +}) diff --git a/tests/unit/auth-credentials.test.ts b/tests/unit/auth-credentials.test.ts new file mode 100644 index 0000000..1e6e5f8 --- /dev/null +++ b/tests/unit/auth-credentials.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { hashPassword, verifyPassword } from '../../lib/auth/password' +import { validateRegistration } from '../../lib/auth/validation' + +describe('registration validation', () => { + it('normalizes a valid registration input', () => { + expect( + validateRegistration({ + displayName: ' Maya Chen ', + email: ' MAYA@example.com ', + password: 'correct-horse-battery-staple', + }), + ).toEqual({ + displayName: 'Maya Chen', + email: 'maya@example.com', + password: 'correct-horse-battery-staple', + }) + }) + + it('rejects malformed credentials before they reach persistence', () => { + expect(() => + validateRegistration({ + displayName: '', + email: 'not-an-email', + password: 'short', + }), + ).toThrow( + 'Please enter a display name, valid email address, and password of at least 8 characters.', + ) + }) +}) + +describe('password hashing', () => { + it('verifies the original password without storing it as plaintext', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + + expect(hash).not.toContain('correct-horse-battery-staple') + await expect( + verifyPassword('correct-horse-battery-staple', hash), + ).resolves.toBe(true) + }) + + it('rejects an incorrect password', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + + await expect(verifyPassword('wrong-password', hash)).resolves.toBe(false) + }) +}) diff --git a/tests/unit/navbar.test.tsx b/tests/unit/navbar.test.tsx new file mode 100644 index 0000000..de01583 --- /dev/null +++ b/tests/unit/navbar.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../auth', () => ({ signOut: vi.fn() })) + +import Navbar from '../../app/Navbar' + +afterEach(cleanup) + +describe('Navbar', () => { + it('shows Login to signed-out visitors', () => { + render() + + expect( + screen.getByRole('link', { name: 'Login' }).getAttribute('href'), + ).toBe('/login') + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) + + it('replaces Login with the member name and Log out for signed-in members', () => { + render() + + expect(screen.getByText('Ada Lovelace')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Log out' })).not.toBeNull() + expect(screen.queryByRole('link', { name: 'Login' })).toBeNull() + }) +})