From 139928c74bec6eb3e58278fb7e4febbd9fb14e27 Mon Sep 17 00:00:00 2001 From: Wojciech Polak Date: Sat, 12 Sep 2026 21:53:19 +0200 Subject: [PATCH 1/3] refactor: share duplicated auth helpers and cut complexity Deduplicate logic that had been copied across the server, Gate, Manager, the framework packages and the example apps, and reduce the branching in the worst complexity hotspots. --- .fallowrc.json | 2 +- examples/angular/package.json | 1 + examples/angular/src/app/login-utils.ts | 29 +- examples/angular/src/server.ts | 32 +- examples/angular/src/signin-utils.ts | 37 +- examples/angular/tests/signin-utils.test.ts | 3 +- examples/fastify/src/app.ts | 32 +- examples/fastify/src/auth.ts | 57 +- examples/fastify/src/signin-utils.ts | 37 +- examples/nextjs/package.json | 1 + examples/nextjs/scripts/ensure-env.mjs | 121 +--- examples/nextjs/src/app/api/signin/route.ts | 40 +- examples/nextjs/src/app/login/page.tsx | 65 +-- examples/nextjs/src/app/login/signin.ts | 76 +-- examples/nextjs/src/app/login/url.ts | 52 +- examples/nextjs/src/app/verify-email/route.ts | 435 +-------------- examples/nextjs/tests/protected-page.test.mjs | 20 +- examples/nuxt/app/utils/login.ts | 53 +- examples/nuxt/nuxt.config.ts | 21 +- examples/nuxt/package.json | 2 + examples/nuxt/scripts/ensure-env.mjs | 129 +---- examples/nuxt/server/api/signin.post.ts | 3 +- examples/nuxt/server/api/signin.ts | 39 +- examples/nuxt/tests/signin.test.ts | 3 +- examples/photos/Dockerfile | 2 + examples/photos/package.json | 2 + examples/photos/scripts/ensure-env.mjs | 123 +--- examples/photos/src/app/api/signin/route.ts | 47 +- examples/photos/src/app/login/page.tsx | 65 +-- examples/photos/src/app/login/signin.ts | 76 +-- examples/photos/src/app/login/url.ts | 13 + examples/photos/src/app/verify-email/route.ts | 457 +-------------- examples/photos/tests/example-app.test.ts | 7 +- gate/dev/bootstrap-manager-stack.mjs | 61 -- gate/docker-compose.yml | 5 +- gate/package.json | 1 + gate/src/app.ts | 86 +-- gate/src/config.ts | 194 +++---- gate/tests/dev-template.test.ts | 12 +- manager/dev/bootstrap-local-managed-stack.mjs | 55 +- manager/dev/bootstrap-managed-stack.mjs | 61 +- manager/dev/managed-stack.mjs | 63 +++ manager/docker-compose.yml | 5 +- manager/src/app.ts | 330 +++++------ manager/src/deployment.test.ts | 19 +- packages/angular/src/lib/core.ts | 36 +- packages/config-core/src/index.ts | 250 +++++---- packages/config-core/src/runtime.ts | 11 +- packages/core/src/flags.ts | 31 + packages/core/src/html.ts | 16 + packages/core/src/index.ts | 8 +- packages/core/src/token.ts | 22 +- packages/core/src/types.ts | 16 + packages/core/src/verify-email.ts | 26 + packages/example-ui/env-file.d.ts | 20 + packages/example-ui/env-file.js | 154 +++++ packages/example-ui/package.json | 18 +- packages/example-ui/signin.d.ts | 13 + packages/example-ui/signin.js | 57 ++ packages/nextjs/src/authMiddleware.ts | 35 +- .../nextjs/src/components/login/actions.ts | 12 + .../src/components/verifyEmail/route.test.ts | 99 ++++ .../src/components/verifyEmail/route.ts | 525 +++++++++++++++++ packages/nextjs/src/index.ts | 10 +- packages/nextjs/src/lib/auth.ts | 53 +- packages/nuxt/src/module.ts | 109 ++-- .../runtime/server/routes/verify-email.get.ts | 10 +- .../nuxt/src/runtime/server/utils/auth.ts | 32 +- pnpm-lock.yaml | 528 +++++++++--------- scripts/dev-manager.mjs | 73 ++- server/src/perEmailSignInLimiter.ts | 40 +- server/src/sessionRevocationStore.ts | 43 +- server/src/timestampFileStore.ts | 105 ++++ server/src/verificationTokenReplayStore.ts | 41 +- .../tests/example-apps-magic-link.shared.ts | 62 +- 75 files changed, 2336 insertions(+), 3093 deletions(-) delete mode 100644 gate/dev/bootstrap-manager-stack.mjs create mode 100644 manager/dev/managed-stack.mjs create mode 100644 packages/core/src/flags.ts create mode 100644 packages/core/src/html.ts create mode 100644 packages/core/src/verify-email.ts create mode 100644 packages/example-ui/env-file.d.ts create mode 100644 packages/example-ui/env-file.js create mode 100644 packages/example-ui/signin.d.ts create mode 100644 packages/example-ui/signin.js create mode 100644 packages/nextjs/src/components/verifyEmail/route.test.ts create mode 100644 packages/nextjs/src/components/verifyEmail/route.ts create mode 100644 server/src/timestampFileStore.ts diff --git a/.fallowrc.json b/.fallowrc.json index f517796..26e4cd6 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -8,10 +8,10 @@ "examples/gate-private2-static/public/assets/*", "examples/nextjs/tests/*.mjs", "examples/nuxt/tests/*.mjs", - "gate/dev/bootstrap-manager-stack.mjs", "gate/dev/render-magic-gate-config.mjs", "manager/dev/bootstrap-local-managed-stack.mjs", "manager/dev/bootstrap-managed-stack.mjs", + "packages/core/test/fixtures/*.mjs", "packages/nuxt/src/runtime/app/**/*.ts", "packages/nuxt/src/runtime/server/routes/*.ts", "tests/e2e/src/run-web-server.mjs" diff --git a/examples/angular/package.json b/examples/angular/package.json index 44ca525..ae160cc 100644 --- a/examples/angular/package.json +++ b/examples/angular/package.json @@ -22,6 +22,7 @@ "@angular/ssr": "22.1.5", "@magic-link-sso/angular": "workspace:*", "@magic-link-sso/config-core": "workspace:*", + "@magic-link-sso/core": "workspace:*", "dotenv": "^17.4.2", "express": "^5.2.1", "magic-sso-example-ui": "workspace:*", diff --git a/examples/angular/src/app/login-utils.ts b/examples/angular/src/app/login-utils.ts index bc4efb4..c052e85 100644 --- a/examples/angular/src/app/login-utils.ts +++ b/examples/angular/src/app/login-utils.ts @@ -25,23 +25,20 @@ export function getAppOrigin(request: Request | null | undefined): string { return 'http://localhost:3004'; } +const loginErrorMessages: Record = { + 'invalid-session': 'Your session could not be verified. Please sign in again.', + 'missing-verification-token': 'The sign-in link is incomplete. Please request a new email.', + 'session-verification-failed': + 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.', + 'session-verification-misconfigured': + 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.', + 'verify-email-failed': + 'We could not complete sign-in from that email link. Please request a new one.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', +}; + export function getLoginErrorMessage(errorCode: string | undefined): string | undefined { - switch (errorCode) { - case 'invalid-session': - return 'Your session could not be verified. Please sign in again.'; - case 'missing-verification-token': - return 'The sign-in link is incomplete. Please request a new email.'; - case 'session-verification-failed': - return 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.'; - case 'session-verification-misconfigured': - return 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.'; - case 'verify-email-failed': - return 'We could not complete sign-in from that email link. Please request a new one.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - default: - return undefined; - } + return typeof errorCode === 'string' ? loginErrorMessages[errorCode] : undefined; } export function normaliseReturnUrl(returnUrl: string | undefined, appOrigin: string): string { diff --git a/examples/angular/src/server.ts b/examples/angular/src/server.ts index 25bcc7b..bcb36a4 100644 --- a/examples/angular/src/server.ts +++ b/examples/angular/src/server.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { escapeHtml, readCookieValue, safeCompare } from '@magic-link-sso/config-core/runtime'; +import { isVerifyEmailPreviewResponse, isVerifyEmailResponse } from '@magic-link-sso/core'; import express, { type NextFunction as ExpressNextFunction, type Request as ExpressRequest, @@ -27,7 +28,8 @@ import { verifyAuthToken, verifyRequestAuth, } from '@magic-link-sso/angular'; -import { buildFailureResult, readMessage, readServerUrlConfigError } from './signin-utils'; +import { buildFailureResult, readMessage } from 'magic-sso-example-ui/signin'; +import { readServerUrlConfigError } from './signin-utils'; export { AngularAppEngine } from '@angular/ssr'; interface SignInRequestBody { @@ -50,14 +52,6 @@ interface VerifyOtpRequestBody { returnUrl?: string; } -interface VerifyEmailResponse { - accessToken: string; -} - -interface VerifyEmailPreviewResponse { - email: string; -} - type AsyncExpressHandler = ( request: ExpressRequest, response: ExpressResponse, @@ -158,26 +152,6 @@ function readVerifyOtpRequestBody(value: unknown): VerifyOtpRequestBody { }; } -function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { - return ( - typeof value === 'object' && - value !== null && - 'accessToken' in value && - typeof value.accessToken === 'string' && - value.accessToken.length > 0 - ); -} - -function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { - return ( - typeof value === 'object' && - value !== null && - 'email' in value && - typeof value.email === 'string' && - value.email.length > 0 - ); -} - function buildLoginRedirectUrl(request: ExpressRequest, returnUrl: string, error?: string): string { const loginUrl = new URL('/login', getRequestOrigin(request)); loginUrl.searchParams.set('returnUrl', returnUrl); diff --git a/examples/angular/src/signin-utils.ts b/examples/angular/src/signin-utils.ts index c1382ab..189b23f 100644 --- a/examples/angular/src/signin-utils.ts +++ b/examples/angular/src/signin-utils.ts @@ -1,41 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -interface MessagePayload { - message: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function readMessage(value: unknown): string | null { - if (!isRecord(value)) { - return null; - } - - const message = value['message']; - return typeof message === 'string' && message.length > 0 ? message : null; -} - -export function buildFailureResult( - payload: unknown, - fallback = 'Failed to send verification email.', -): MessagePayload { - return { - message: readMessage(payload) ?? fallback, - }; -} +import { readServerUrlConfigError as readSharedServerUrlConfigError } from 'magic-sso-example-ui/signin'; export function readServerUrlConfigError(serverUrl: string, requestOrigin: string): string | null { - try { - const parsedServerUrl = new URL(serverUrl); - if (parsedServerUrl.origin === requestOrigin) { - return 'MAGICSSO_SERVER_URL points to this Angular app. Set it to the Magic Link SSO server, usually http://localhost:3000 for local development.'; - } - } catch { - return 'MAGICSSO_SERVER_URL must be an absolute URL.'; - } - - return null; + return readSharedServerUrlConfigError(serverUrl, requestOrigin, 'Angular'); } diff --git a/examples/angular/tests/signin-utils.test.ts b/examples/angular/tests/signin-utils.test.ts index 002d6d5..1d22753 100644 --- a/examples/angular/tests/signin-utils.test.ts +++ b/examples/angular/tests/signin-utils.test.ts @@ -2,7 +2,8 @@ // Copyright (C) 2026 Wojciech Polak import { describe, expect, it } from 'vitest'; -import { buildFailureResult, readMessage, readServerUrlConfigError } from '../src/signin-utils'; +import { buildFailureResult, readMessage } from 'magic-sso-example-ui/signin'; +import { readServerUrlConfigError } from '../src/signin-utils'; describe('Angular signin utilities', () => { it('reads a non-empty message field from payloads', () => { diff --git a/examples/fastify/src/app.ts b/examples/fastify/src/app.ts index 93e789f..6f9946b 100644 --- a/examples/fastify/src/app.ts +++ b/examples/fastify/src/app.ts @@ -4,6 +4,7 @@ import cookie from '@fastify/cookie'; import formbody from '@fastify/formbody'; import { readCookieValue, safeCompare } from '@magic-link-sso/config-core/runtime'; +import { isVerifyEmailPreviewResponse, isVerifyEmailResponse } from '@magic-link-sso/core'; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; import { createHmac, randomBytes } from 'node:crypto'; import { readFile } from 'node:fs/promises'; @@ -26,7 +27,8 @@ import { renderProtectedPage, renderVerifyEmailConfirmationPage, } from './html.js'; -import { buildFailureResult, readMessage, readServerUrlConfigError } from './signin-utils.js'; +import { buildFailureResult, readMessage } from 'magic-sso-example-ui/signin'; +import { readServerUrlConfigError } from './signin-utils.js'; interface SignInBody { email?: string; @@ -58,14 +60,6 @@ interface VerifyOtpBody { returnUrl?: string; } -interface VerifyEmailResponse { - accessToken: string; -} - -interface VerifyEmailPreviewResponse { - email: string; -} - interface SignInSuccessResponse { message: string; otpChallengeId?: string; @@ -95,26 +89,6 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } -function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { - return ( - typeof value === 'object' && - value !== null && - 'accessToken' in value && - typeof value.accessToken === 'string' && - value.accessToken.length > 0 - ); -} - -function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { - return ( - typeof value === 'object' && - value !== null && - 'email' in value && - typeof value.email === 'string' && - value.email.length > 0 - ); -} - function isSignInSuccessResponse(value: unknown): value is SignInSuccessResponse { return ( typeof value === 'object' && diff --git a/examples/fastify/src/auth.ts b/examples/fastify/src/auth.ts index 9e2c6bb..41abfbc 100644 --- a/examples/fastify/src/auth.ts +++ b/examples/fastify/src/auth.ts @@ -2,13 +2,14 @@ // Copyright (C) 2026 Wojciech Polak import { + parseBooleanFlag, + type AuthPayload as CoreAuthPayload, buildAuthCookieOptions as buildCoreAuthCookieOptions, buildLoginTarget as buildCoreLoginTarget, buildVerifyUrl as buildCoreVerifyUrl, normaliseReturnUrl as normaliseCoreReturnUrl, readCookieValue, - verifyAuthToken as verifyCoreAuthToken, - type AuthPayload as CoreAuthPayload, + verifyAuthTokenWithOptionalIssuer as verifyCoreAuthTokenWithOptionalIssuer, } from '@magic-link-sso/core'; export type AuthPayload = CoreAuthPayload; @@ -66,24 +67,7 @@ function readPositiveInteger(value: string | undefined): number | undefined { } function readBoolean(value: string | undefined): boolean { - if (typeof value !== 'string') { - return false; - } - - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return false; - } + return parseBooleanFlag(value); } function readCookiePath(value: string | undefined): string { @@ -123,12 +107,7 @@ export async function verifyAuthToken( secret: Uint8Array, options: VerifyAuthTokenOptions, ): Promise { - return typeof options.expectedIssuer === 'string' - ? verifyCoreAuthToken(token, secret, { - expectedAudience: options.expectedAudience, - expectedIssuer: options.expectedIssuer, - }) - : null; + return verifyCoreAuthTokenWithOptionalIssuer(token, secret, options); } export async function verifyRequestAuth( @@ -199,19 +178,17 @@ export function buildLoginTarget(appOrigin: string, returnTarget: string, scope? }); } +const loginErrorMessages: Record = { + 'missing-verification-token': 'The sign-in link is incomplete. Please request a new email.', + 'session-verification-failed': + 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.', + 'session-verification-misconfigured': + 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.', + 'verify-email-failed': + 'We could not complete sign-in from that email link. Please request a new one.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', +}; + export function getLoginErrorMessage(errorCode: string | undefined): string | undefined { - switch (errorCode) { - case 'missing-verification-token': - return 'The sign-in link is incomplete. Please request a new email.'; - case 'session-verification-failed': - return 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.'; - case 'session-verification-misconfigured': - return 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.'; - case 'verify-email-failed': - return 'We could not complete sign-in from that email link. Please request a new one.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - default: - return undefined; - } + return typeof errorCode === 'string' ? loginErrorMessages[errorCode] : undefined; } diff --git a/examples/fastify/src/signin-utils.ts b/examples/fastify/src/signin-utils.ts index f53b077..668df54 100644 --- a/examples/fastify/src/signin-utils.ts +++ b/examples/fastify/src/signin-utils.ts @@ -1,41 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -interface MessagePayload { - message: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function readMessage(value: unknown): string | null { - if (!isRecord(value)) { - return null; - } - - const message = value['message']; - return typeof message === 'string' && message.length > 0 ? message : null; -} - -export function buildFailureResult( - payload: unknown, - fallback = 'Failed to send verification email.', -): MessagePayload { - return { - message: readMessage(payload) ?? fallback, - }; -} +import { readServerUrlConfigError as readSharedServerUrlConfigError } from 'magic-sso-example-ui/signin'; export function readServerUrlConfigError(serverUrl: string, requestOrigin: string): string | null { - try { - const parsedServerUrl = new URL(serverUrl); - if (parsedServerUrl.origin === requestOrigin) { - return 'MAGICSSO_SERVER_URL points to this Fastify app. Set it to the Magic Link SSO server, usually http://localhost:3000 for local development.'; - } - } catch { - return 'MAGICSSO_SERVER_URL must be an absolute URL.'; - } - - return null; + return readSharedServerUrlConfigError(serverUrl, requestOrigin, 'Fastify'); } diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 5888483..c853976 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@magic-link-sso/config-core": "workspace:*", + "@magic-link-sso/core": "workspace:*", "@magic-link-sso/nextjs": "workspace:*", "magic-sso-example-ui": "workspace:*", "next": "^16.3.0", diff --git a/examples/nextjs/scripts/ensure-env.mjs b/examples/nextjs/scripts/ensure-env.mjs index 99d9c86..12835a8 100644 --- a/examples/nextjs/scripts/ensure-env.mjs +++ b/examples/nextjs/scripts/ensure-env.mjs @@ -1,113 +1,14 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -import { access, copyFile } from 'node:fs/promises'; -import { readFile, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; - -const defaultSourcePath = new URL('../.env.local.example', import.meta.url); -const defaultTargetPath = new URL('../.env.local', import.meta.url); -const generatedPlaceholderValues = new Map([ - ['MAGICSSO_JWT_SECRET', new Set(['replace-me-with-a-long-random-jwt-secret'])], - ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], -]); - -async function fileExists(path) { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function parseEnvKeys(content) { - const keys = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - if (match) { - keys.add(match[1]); - } - } - - return keys; -} - -function parseEnvValues(content) { - const values = new Map(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (match) { - values.set(match[1], match[2]); - } - } - - return values; -} - -function mergeEnvContent(sourceContent, targetContent) { - const sourceValues = parseEnvValues(sourceContent); - const targetKeys = parseEnvKeys(targetContent); - let changed = false; - const targetLines = targetContent.split('\n').map((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (!match) { - return line; - } - - const [, key, value] = match; - const placeholderValues = generatedPlaceholderValues.get(key); - const sourceValue = sourceValues.get(key); - if ( - placeholderValues !== undefined && - sourceValue !== undefined && - placeholderValues.has(value) && - sourceValue !== value - ) { - changed = true; - return `${key}=${sourceValue}`; - } - - return line; - }); - const missingLines = sourceContent.split('\n').filter((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - return match ? !targetKeys.has(match[1]) : false; - }); - - if (missingLines.length > 0) { - changed = true; - } - - return changed - ? `${targetLines.join('\n').replace(/\n?$/u, '\n')}${missingLines.join('\n')}${ - missingLines.length > 0 ? '\n' : '' - }` - : targetContent; -} - -export async function ensureEnvFile( - sourcePath = defaultSourcePath, - targetPath = defaultTargetPath, -) { - if (await fileExists(targetPath)) { - const [sourceContent, targetContent] = await Promise.all([ - readFile(sourcePath, 'utf8'), - readFile(targetPath, 'utf8'), - ]); - const mergedContent = mergeEnvContent(sourceContent, targetContent); - if (mergedContent === targetContent) { - return; - } - - await writeFile(targetPath, mergedContent, 'utf8'); - return; - } - - await copyFile(sourcePath, targetPath); -} - -const executedPath = process.argv[1]; -if (typeof executedPath === 'string' && pathToFileURL(executedPath).href === import.meta.url) { - await ensureEnvFile(); -} +import { bootstrapExampleEnvFile } from 'magic-sso-example-ui/env-file'; + +export const ensureEnvFile = await bootstrapExampleEnvFile({ + envPath: '../.env.local', + examplePath: '../.env.local.example', + generatedPlaceholderValues: new Map([ + ['MAGICSSO_JWT_SECRET', new Set(['replace-me-with-a-long-random-jwt-secret'])], + ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], + ]), + moduleUrl: import.meta.url, +}); diff --git a/examples/nextjs/src/app/api/signin/route.ts b/examples/nextjs/src/app/api/signin/route.ts index 5cfd429..97ed117 100644 --- a/examples/nextjs/src/app/api/signin/route.ts +++ b/examples/nextjs/src/app/api/signin/route.ts @@ -2,6 +2,7 @@ // Copyright (C) 2026 Wojciech Polak import { NextRequest, NextResponse } from 'next/server'; +import { normaliseReturnUrl } from '@magic-link-sso/nextjs'; import { sendMagicLink } from '../../login/signin'; function acceptsJson(request: NextRequest): boolean { @@ -9,32 +10,15 @@ function acceptsJson(request: NextRequest): boolean { return typeof accept === 'string' && accept.includes('application/json'); } -function getErrorMessage(errorCode: string): string { - switch (errorCode) { - case 'invalid-signin-request': - return 'The sign-in form was incomplete. Please try again.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - case 'signin-request-failed': - default: - return 'We could not send the sign-in email. Please try again.'; - } -} - -function normaliseReturnUrl(returnUrl: string | null, origin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return origin; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return new URL(returnUrl, origin).toString(); - } +const signInErrorMessages: Record = { + 'invalid-signin-request': 'The sign-in form was incomplete. Please try again.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', +}; - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === origin ? parsedUrl.toString() : origin; - } catch { - return origin; - } +function getErrorMessage(errorCode: string): string { + return ( + signInErrorMessages[errorCode] ?? 'We could not send the sign-in email. Please try again.' + ); } function buildLoginRedirect( @@ -64,10 +48,10 @@ export async function POST(request: NextRequest): Promise { const scopeValue = formData.get('scope'); const scope = typeof scopeValue === 'string' && scopeValue.trim().length > 0 ? scopeValue : undefined; + const returnUrlValue = formData.get('returnUrl'); const returnUrl = normaliseReturnUrl( - typeof formData.get('returnUrl') === 'string' - ? (formData.get('returnUrl') as string) - : null, + typeof returnUrlValue === 'string' ? returnUrlValue : undefined, + request.nextUrl.origin, request.nextUrl.origin, ); diff --git a/examples/nextjs/src/app/login/page.tsx b/examples/nextjs/src/app/login/page.tsx index 76c0dfb..e12c49b 100644 --- a/examples/nextjs/src/app/login/page.tsx +++ b/examples/nextjs/src/app/login/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; +import { normaliseReturnUrl } from '@magic-link-sso/nextjs'; import React from 'react'; import { getAppOrigin } from './url'; import LoginForm from './LoginForm'; @@ -16,52 +17,30 @@ type LoginPageProps = { }>; }; +const loginErrorMessages: Record = { + 'invalid-session': 'Your session could not be verified. Please sign in again.', + 'missing-verification-token': 'The sign-in link is incomplete. Please request a new email.', + 'session-verification-failed': + 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.', + 'session-verification-misconfigured': + 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.', + 'verify-email-failed': + 'We could not complete sign-in from that email link. Please request a new one.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', + 'invalid-signin-request': 'The sign-in form was incomplete. Please try again.', + 'signin-request-failed': 'We could not send the sign-in email. Please try again.', +}; + +const loginSuccessMessages: Record = { + 'verification-email-sent': 'Verification email sent', +}; + function getLoginErrorMessage(errorCode: string | undefined): string | undefined { - switch (errorCode) { - case 'invalid-session': - return 'Your session could not be verified. Please sign in again.'; - case 'missing-verification-token': - return 'The sign-in link is incomplete. Please request a new email.'; - case 'session-verification-failed': - return 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.'; - case 'session-verification-misconfigured': - return 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.'; - case 'verify-email-failed': - return 'We could not complete sign-in from that email link. Please request a new one.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - case 'invalid-signin-request': - return 'The sign-in form was incomplete. Please try again.'; - case 'signin-request-failed': - return 'We could not send the sign-in email. Please try again.'; - default: - return undefined; - } + return typeof errorCode === 'string' ? loginErrorMessages[errorCode] : undefined; } function getLoginSuccessMessage(successCode: string | undefined): string | undefined { - switch (successCode) { - case 'verification-email-sent': - return 'Verification email sent'; - default: - return undefined; - } -} - -function normaliseReturnUrl(returnUrl: string | undefined, appOrigin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return appOrigin; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return new URL(returnUrl, appOrigin).toString(); - } - - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === appOrigin ? parsedUrl.toString() : appOrigin; - } catch { - return appOrigin; - } + return typeof successCode === 'string' ? loginSuccessMessages[successCode] : undefined; } export const metadata: Metadata = { @@ -83,7 +62,7 @@ export default async function LoginPage({ const requestedReturnUrl = Array.isArray(returnUrlValue) ? returnUrlValue[0] : returnUrlValue; const requestedSuccess = Array.isArray(successValue) ? successValue[0] : successValue; const requestedScope = Array.isArray(scopeValue) ? scopeValue[0] : scopeValue; - const returnUrl = normaliseReturnUrl(requestedReturnUrl, appOrigin); + const returnUrl = normaliseReturnUrl(requestedReturnUrl, appOrigin, appOrigin); const initialError = getLoginErrorMessage(requestedError); const initialSuccess = getLoginSuccessMessage(requestedSuccess); diff --git a/examples/nextjs/src/app/login/signin.ts b/examples/nextjs/src/app/login/signin.ts index a045e2a..768225c 100644 --- a/examples/nextjs/src/app/login/signin.ts +++ b/examples/nextjs/src/app/login/signin.ts @@ -1,24 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -function readErrorMessage(data: unknown): string | undefined { - if ( - typeof data === 'object' && - data !== null && - 'message' in data && - typeof data.message === 'string' && - data.message.length > 0 - ) { - return data.message; - } - - return undefined; -} - -async function readResponseMessage(response: Response): Promise { - const payload = (await response.json().catch(() => null)) as unknown; - return readErrorMessage(payload); -} +import { sendMagicLink as requestMagicLink } from '@magic-link-sso/nextjs'; export interface SignInResult { code?: string; @@ -28,17 +11,6 @@ export interface SignInResult { success: boolean; } -function readOtpMetadata(value: unknown): Pick { - if (typeof value !== 'object' || value === null) { - return {}; - } - const challengeId = Reflect.get(value, 'otpChallengeId'); - const length = Reflect.get(value, 'otpLength'); - return typeof challengeId === 'string' && typeof length === 'number' - ? { otpChallengeId: challengeId, otpLength: length } - : {}; -} - export async function sendMagicLink( email: string, returnUrl: string, @@ -54,44 +26,20 @@ export async function sendMagicLink( }; } - const normalizedScope = typeof scope === 'string' ? scope.trim() : ''; - - try { - const response = await fetch(new URL('/signin', serverUrl), { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - email, - returnUrl, - verifyUrl, - ...(normalizedScope.length > 0 ? { scope: normalizedScope } : {}), - }), - cache: 'no-store', - }); - if (!response.ok) { - const serverMessage = await readResponseMessage(response); - console.error('Error sending magic link:', { - serverMessage, - status: response.status, - }); - return { - success: false, - code: 'signin-request-failed', - message: serverMessage ?? 'Failed to send verification email.', - }; - } - - return { success: true, ...readOtpMetadata(await response.json().catch(() => null)) }; - } catch (error: unknown) { - if (error instanceof Error) { - console.error('Error sending magic link:', { message: error.message }); - } + const result = await requestMagicLink(email, returnUrl, scope, { verifyUrl }); + if (!result.success) { + console.error('Error sending magic link:', { message: result.message }); return { success: false, code: 'signin-request-failed', - message: 'Failed to send verification email.', + message: result.message, }; } + + return { + success: true, + ...(typeof result.otpChallengeId === 'string' && typeof result.otpLength === 'number' + ? { otpChallengeId: result.otpChallengeId, otpLength: result.otpLength } + : {}), + }; } diff --git a/examples/nextjs/src/app/login/url.ts b/examples/nextjs/src/app/login/url.ts index 134829a..4b510b9 100644 --- a/examples/nextjs/src/app/login/url.ts +++ b/examples/nextjs/src/app/login/url.ts @@ -1,26 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -function isDirectUseEnabled(value: string | undefined): boolean { - if (typeof value !== 'string') { - return false; - } - - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return false; - } -} +import { buildLoginTarget as buildCoreLoginTarget, parseBooleanFlag } from '@magic-link-sso/core'; export function getAppOrigin(host: string, forwardedProtocol?: string | null): string { const protocol = forwardedProtocol ?? (host.startsWith('localhost') ? 'http' : 'https'); @@ -28,28 +9,11 @@ export function getAppOrigin(host: string, forwardedProtocol?: string | null): s } export function buildLoginTarget(appOrigin: string, scope?: string): string { - const returnUrl = new URL('/', appOrigin).toString(); - const normalizedScope = typeof scope === 'string' ? scope.trim() : ''; - - if (isDirectUseEnabled(process.env.MAGICSSO_DIRECT_USE)) { - const serverUrl = process.env.MAGICSSO_SERVER_URL; - if (typeof serverUrl === 'string' && serverUrl.length > 0) { - const loginUrl = new URL('/signin', serverUrl); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (normalizedScope.length > 0) { - loginUrl.searchParams.set('scope', normalizedScope); - } - const verifyUrl = new URL('/verify-email', appOrigin); - verifyUrl.searchParams.set('returnUrl', returnUrl); - loginUrl.searchParams.set('verifyUrl', verifyUrl.toString()); - return loginUrl.toString(); - } - } - - const loginUrl = new URL('/login', appOrigin); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (normalizedScope.length > 0) { - loginUrl.searchParams.set('scope', normalizedScope); - } - return `${loginUrl.pathname}${loginUrl.search}`; + return buildCoreLoginTarget({ + appOrigin, + directUse: parseBooleanFlag(process.env.MAGICSSO_DIRECT_USE), + returnUrl: '/', + ...(typeof scope === 'string' ? { scope } : {}), + serverUrl: process.env.MAGICSSO_SERVER_URL ?? '', + }); } diff --git a/examples/nextjs/src/app/verify-email/route.ts b/examples/nextjs/src/app/verify-email/route.ts index 4a343f8..de104be 100644 --- a/examples/nextjs/src/app/verify-email/route.ts +++ b/examples/nextjs/src/app/verify-email/route.ts @@ -1,438 +1,17 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -import { escapeHtml, safeCompare } from '@magic-link-sso/config-core/runtime'; -import { buildAuthCookieOptions, getJwtSecret, verifyAuthToken } from '@magic-link-sso/nextjs'; -import { randomBytes } from 'node:crypto'; -import { NextRequest, NextResponse } from 'next/server'; +import { VerifyEmailRoute, type VerifyEmailRouteOptions } from '@magic-link-sso/nextjs'; +import type { NextRequest, NextResponse } from 'next/server'; -interface VerifyEmailResponse { - accessToken: string; -} - -interface VerifyEmailPreviewResponse { - email: string; -} - -const verifyCsrfCookieName = 'magic-sso-verify-csrf'; -const verifyTokenCookieName = 'magic-sso-verify-token'; - -function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { - return ( - typeof value === 'object' && - value !== null && - 'accessToken' in value && - typeof value.accessToken === 'string' && - value.accessToken.length > 0 - ); -} - -function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { - return ( - typeof value === 'object' && - value !== null && - 'email' in value && - typeof value.email === 'string' && - value.email.length > 0 - ); -} - -function normaliseReturnUrl(returnUrl: string | null, origin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return '/'; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return returnUrl; - } - - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === origin ? parsedUrl.toString() : '/'; - } catch { - return '/'; - } -} - -function buildLoginRedirect(request: NextRequest, returnUrl: string, error?: string): NextResponse { - const loginUrl = new URL('/login', request.nextUrl.origin); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (typeof error === 'string') { - loginUrl.searchParams.set('error', error); - } - return NextResponse.redirect(loginUrl); -} - -function createVerifyCsrfToken(): string { - return randomBytes(32).toString('base64url'); -} - -function hasValidVerifyCsrfToken(submittedToken: string, cookieToken: string): boolean { - return safeCompare(submittedToken, cookieToken); -} - -function buildVerifyCookieOptions(request: NextRequest): { - httpOnly: true; - path: '/verify-email'; - sameSite: 'strict'; - secure: boolean; -} { - return { - httpOnly: true, - path: '/verify-email', - sameSite: 'strict', - secure: request.nextUrl.protocol === 'https:', - }; -} - -function getPreviewSecret(): string | null { - const previewSecret = process.env.MAGICSSO_PREVIEW_SECRET; - return typeof previewSecret === 'string' && previewSecret.length > 0 ? previewSecret : null; -} - -function clearVerifyCookie(response: NextResponse, request: NextRequest): void { - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyCsrfCookieName, - value: '', - maxAge: 0, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyTokenCookieName, - value: '', - maxAge: 0, - }); -} - -function renderConfirmationPage(email: string, returnUrl: string, csrfToken: string): string { - return ` - - - - - Confirm Sign In | Magic Link SSO Next.js - - - -
-

Verify Email

-

Continue sign-in

-

Review the email address below, then continue to finish signing in.

-
-

Email

- - - - -
-
- -`; -} - -async function previewVerificationEmail( - token: string, -): Promise { - const serverUrl = process.env.MAGICSSO_SERVER_URL; - if (typeof serverUrl !== 'string' || serverUrl.length === 0) { - console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); - return 'misconfigured'; - } - - const verifyUrl = new URL('/verify-email', serverUrl); - verifyUrl.searchParams.set('token', token); - const previewSecret = getPreviewSecret(); - if (previewSecret === null) { - console.error('Verify-email route is missing MAGICSSO_PREVIEW_SECRET.'); - return 'misconfigured'; - } - - try { - const response = await fetch(verifyUrl, { - headers: { - accept: 'application/json', - 'x-magic-sso-preview-secret': previewSecret, - }, - cache: 'no-store', - }); - if (!response.ok) { - return null; - } - - const payload: unknown = await response.json(); - return isVerifyEmailPreviewResponse(payload) ? payload : null; - } catch (error) { - console.error('Verify-email preview failed unexpectedly.', error); - return null; - } -} +const options: VerifyEmailRouteOptions = { + pageTitle: 'Confirm Sign In | Magic Link SSO Next.js', +}; export async function GET(request: NextRequest): Promise { - const token = request.nextUrl.searchParams.get('token'); - const returnUrl = normaliseReturnUrl( - request.nextUrl.searchParams.get('returnUrl'), - request.nextUrl.origin, - ); - if (typeof token !== 'string' || token.length === 0) { - return buildLoginRedirect(request, returnUrl, 'missing-verification-token'); - } - - const payload = await previewVerificationEmail(token); - if (payload === 'misconfigured') { - return buildLoginRedirect(request, returnUrl, 'verify-email-misconfigured'); - } - if (payload === null) { - return buildLoginRedirect(request, returnUrl, 'verify-email-failed'); - } - - const csrfToken = createVerifyCsrfToken(); - const response = new NextResponse(renderConfirmationPage(payload.email, returnUrl, csrfToken), { - headers: { - 'cache-control': 'no-store', - 'content-type': 'text/html; charset=utf-8', - }, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyCsrfCookieName, - value: csrfToken, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyTokenCookieName, - value: token, - }); - return response; + return VerifyEmailRoute(request, options); } export async function POST(request: NextRequest): Promise { - const formData = await request.formData(); - const submittedToken = formData.get('token'); - const returnUrlValue = formData.get('returnUrl'); - const returnUrl = normaliseReturnUrl( - typeof returnUrlValue === 'string' ? returnUrlValue : null, - request.nextUrl.origin, - ); - const submittedCsrfToken = formData.get('csrfToken'); - const cookieCsrfToken = request.cookies.get(verifyCsrfCookieName)?.value; - const cookieToken = request.cookies.get(verifyTokenCookieName)?.value; - const token = - typeof submittedToken === 'string' - ? typeof cookieToken === 'string' && submittedToken !== cookieToken - ? null - : submittedToken - : cookieToken; - - if ( - typeof token !== 'string' || - token.length === 0 || - typeof submittedCsrfToken !== 'string' || - typeof cookieCsrfToken !== 'string' || - !hasValidVerifyCsrfToken(submittedCsrfToken, cookieCsrfToken) - ) { - const response = buildLoginRedirect(request, returnUrl, 'verify-email-failed'); - clearVerifyCookie(response, request); - return response; - } - - const serverUrl = process.env.MAGICSSO_SERVER_URL; - if (typeof serverUrl !== 'string' || serverUrl.length === 0) { - console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); - const response = buildLoginRedirect(request, returnUrl, 'verify-email-misconfigured'); - clearVerifyCookie(response, request); - return response; - } - - const verifyUrl = new URL('/verify-email', serverUrl); - - try { - const response = await fetch(verifyUrl, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - }, - body: JSON.stringify({ token }), - cache: 'no-store', - }); - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as unknown; - const serverMessage = - typeof payload === 'object' && - payload !== null && - 'message' in payload && - typeof payload.message === 'string' - ? payload.message - : undefined; - console.error('Verify-email request to SSO server failed.', { - serverMessage, - status: response.status, - }); - const redirectResponse = buildLoginRedirect(request, returnUrl, 'verify-email-failed'); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const payload: unknown = await response.json(); - if (!isVerifyEmailResponse(payload)) { - console.error( - 'Verify-email route received an invalid response payload from the SSO server.', - ); - const redirectResponse = buildLoginRedirect(request, returnUrl, 'verify-email-failed'); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const jwtSecret = getJwtSecret(); - if (jwtSecret === null) { - console.error( - 'Verify-email route cannot validate the auth token because MAGICSSO_JWT_SECRET is missing.', - ); - const redirectResponse = buildLoginRedirect( - request, - returnUrl, - 'verify-email-misconfigured', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const verifiedAccessToken = await verifyAuthToken(payload.accessToken, jwtSecret, { - expectedAudience: request.nextUrl.origin, - expectedIssuer: new URL(serverUrl).origin, - }); - if (verifiedAccessToken === null) { - console.error( - 'Verify-email route rejected the auth token returned by the SSO server. Check that MAGICSSO_JWT_SECRET matches the server JWT secret.', - ); - const redirectResponse = buildLoginRedirect( - request, - returnUrl, - 'session-verification-failed', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const redirectUrl = new URL(returnUrl, request.nextUrl.origin); - const redirectResponse = NextResponse.redirect(redirectUrl); - clearVerifyCookie(redirectResponse, request); - redirectResponse.cookies.set(buildAuthCookieOptions(payload.accessToken)); - return redirectResponse; - } catch (error) { - console.error('Verify-email route failed unexpectedly.', error); - const redirectResponse = buildLoginRedirect(request, returnUrl, 'verify-email-failed'); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } + return VerifyEmailRoute(request, options); } diff --git a/examples/nextjs/tests/protected-page.test.mjs b/examples/nextjs/tests/protected-page.test.mjs index cf968e7..62aff40 100644 --- a/examples/nextjs/tests/protected-page.test.mjs +++ b/examples/nextjs/tests/protected-page.test.mjs @@ -80,7 +80,8 @@ test('login flow sends sign-in requests with a client verify callback', async () assert.match(loginForm, /accept:\s*'application\/json'/u); assert.match(loginForm, /data-submit-spinner/u); assert.match(loginForm, /button-spinner-visible/u); - assert.match(loginSignin, /new URL\('\/signin', serverUrl\)/u); + assert.match(loginSignin, /from '@magic-link-sso\/nextjs'/u); + assert.match(loginSignin, /requestMagicLink\(email, returnUrl, scope, \{ verifyUrl \}\)/u); assert.match(signInRoute, /buildLoginRedirect/u); assert.match(signInRoute, /success: 'verification-email-sent'/u); assert.match(signInRoute, /otpChallengeId/u); @@ -94,20 +95,11 @@ test('login flow sends sign-in requests with a client verify callback', async () assert.match(loginForm, /otpCode\.maxLength = otpLength/u); assert.match(loginForm, /otpCode\.placeholder = otpLength === 6 \? '123456' : ''/u); assert.match(verifyOtpRoute, /VerifyEmailOtpRoute/u); - assert.match(verifyEmailRoute, /verify-email/u); + assert.match(verifyEmailRoute, /VerifyEmailRoute/u); + assert.match(verifyEmailRoute, /from '@magic-link-sso\/nextjs'/u); + assert.match(verifyEmailRoute, /export async function GET/u); assert.match(verifyEmailRoute, /export async function POST/u); - assert.match(verifyEmailRoute, /method:\s*'POST'/u); - assert.match(verifyEmailRoute, /'content-type':\s*'application\/json'/u); - assert.match(verifyEmailRoute, /Continue sign-in/u); - assert.match(verifyEmailRoute, /id="email-value"/u); - assert.doesNotMatch(verifyEmailRoute, /readonly/u); - assert.doesNotMatch(verifyEmailRoute, /name="token"/u); - assert.match(verifyEmailRoute, /magic-sso-verify-token/u); - assert.match(verifyEmailRoute, /@media \(prefers-color-scheme: dark\)/u); - assert.match(verifyEmailRoute, /cookies\.set/u); - assert.match(verifyEmailRoute, /buildAuthCookieOptions/u); - assert.match(verifyEmailRoute, /MAGICSSO_PREVIEW_SECRET/u); - assert.match(verifyEmailRoute, /x-magic-sso-preview-secret/u); + assert.match(verifyEmailRoute, /pageTitle: 'Confirm Sign In \| Magic Link SSO Next\.js'/u); }); test('login form includes dedicated dark-mode styles', async () => { diff --git a/examples/nuxt/app/utils/login.ts b/examples/nuxt/app/utils/login.ts index ac2abf8..0f6c29c 100644 --- a/examples/nuxt/app/utils/login.ts +++ b/examples/nuxt/app/utils/login.ts @@ -1,30 +1,25 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak +import { + buildLoginTarget as buildCoreLoginTarget, + buildVerifyUrl as buildCoreVerifyUrl, + normaliseReturnUrl as normaliseCoreReturnUrl, +} from '@magic-link-sso/core'; + export function normaliseReturnUrl( value: string | string[] | undefined, appOrigin: string, ): string { const returnUrl = Array.isArray(value) ? value[0] : value; - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return appOrigin; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return new URL(returnUrl, appOrigin).toString(); - } - - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === appOrigin ? parsedUrl.toString() : appOrigin; - } catch { - return appOrigin; - } + const normalised = normaliseCoreReturnUrl({ appOrigin, fallback: appOrigin, returnUrl }); + // `normaliseCoreReturnUrl` canonicalises a bare-origin fallback to a trailing + // slash; this app links back to the bare origin instead. + return normalised === `${appOrigin}/` && returnUrl !== '/' ? appOrigin : normalised; } export function buildVerifyUrl(appOrigin: string, returnUrl: string): string { - const verifyUrl = new URL('/verify-email', appOrigin); - verifyUrl.searchParams.set('returnUrl', returnUrl); - return verifyUrl.toString(); + return buildCoreVerifyUrl(appOrigin, returnUrl); } export function buildLoginTarget( @@ -34,23 +29,11 @@ export function buildLoginTarget( serverUrl: string, scope?: string, ): string { - const returnUrl = new URL(returnPath, appOrigin).toString(); - const normalizedScope = typeof scope === 'string' ? scope.trim() : ''; - - if (directUse && serverUrl.length > 0) { - const loginUrl = new URL('/signin', serverUrl); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (normalizedScope.length > 0) { - loginUrl.searchParams.set('scope', normalizedScope); - } - loginUrl.searchParams.set('verifyUrl', buildVerifyUrl(appOrigin, returnUrl)); - return loginUrl.toString(); - } - - const loginUrl = new URL('/login', appOrigin); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (normalizedScope.length > 0) { - loginUrl.searchParams.set('scope', normalizedScope); - } - return `${loginUrl.pathname}${loginUrl.search}`; + return buildCoreLoginTarget({ + appOrigin, + directUse, + returnUrl: returnPath, + ...(typeof scope === 'string' ? { scope } : {}), + serverUrl, + }); } diff --git a/examples/nuxt/nuxt.config.ts b/examples/nuxt/nuxt.config.ts index e57f720..b40a4e7 100644 --- a/examples/nuxt/nuxt.config.ts +++ b/examples/nuxt/nuxt.config.ts @@ -1,25 +1,10 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -function readBooleanEnv(value: string | undefined): boolean { - if (typeof value !== 'string') { - return false; - } +import { parseBooleanFlag } from '@magic-link-sso/core'; - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return false; - } +function readBooleanEnv(value: string | undefined): boolean { + return parseBooleanFlag(value); } export default defineNuxtConfig({ diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json index 898e237..ef2d9bf 100644 --- a/examples/nuxt/package.json +++ b/examples/nuxt/package.json @@ -14,7 +14,9 @@ "typecheck": "nuxi prepare && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.typecheck.json" }, "dependencies": { + "@magic-link-sso/core": "workspace:*", "@magic-link-sso/nuxt": "workspace:*", + "h3": "^1.15.11", "magic-sso-example-ui": "workspace:*", "nuxt": "^4.5.2", "vue": "^3.5.41" diff --git a/examples/nuxt/scripts/ensure-env.mjs b/examples/nuxt/scripts/ensure-env.mjs index 0bca1a0..6bc1403 100644 --- a/examples/nuxt/scripts/ensure-env.mjs +++ b/examples/nuxt/scripts/ensure-env.mjs @@ -1,115 +1,20 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -import { access, copyFile, readFile, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; - -const defaultSourcePath = new URL('../.env.example', import.meta.url); -const defaultTargetPath = new URL('../.env', import.meta.url); -const generatedPlaceholderValues = new Map([ - [ - 'MAGICSSO_JWT_SECRET', - new Set(['replace-me-with-a-long-random-jwt-secret', 'VERY-VERY-LONG-RANDOM-JWT-SECRET']), - ], - ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], -]); - -async function fileExists(path) { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function parseEnvKeys(content) { - const keys = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - if (match) { - keys.add(match[1]); - } - } - - return keys; -} - -function parseEnvValues(content) { - const values = new Map(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (match) { - values.set(match[1], match[2]); - } - } - - return values; -} - -function mergeEnvContent(sourceContent, targetContent) { - const sourceValues = parseEnvValues(sourceContent); - const targetKeys = parseEnvKeys(targetContent); - let changed = false; - const targetLines = targetContent.split('\n').map((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (!match) { - return line; - } - - const [, key, value] = match; - const placeholderValues = generatedPlaceholderValues.get(key); - const sourceValue = sourceValues.get(key); - if ( - placeholderValues !== undefined && - sourceValue !== undefined && - placeholderValues.has(value) && - sourceValue !== value - ) { - changed = true; - return `${key}=${sourceValue}`; - } - - return line; - }); - const missingLines = sourceContent.split('\n').filter((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - return match ? !targetKeys.has(match[1]) : false; - }); - - if (missingLines.length > 0) { - changed = true; - } - - return changed - ? `${targetLines.join('\n').replace(/\n?$/u, '\n')}${missingLines.join('\n')}${ - missingLines.length > 0 ? '\n' : '' - }` - : targetContent; -} - -export async function ensureEnvFile( - sourcePath = defaultSourcePath, - targetPath = defaultTargetPath, -) { - if (!(await fileExists(targetPath))) { - await copyFile(sourcePath, targetPath); - return; - } - - const [sourceContent, targetContent] = await Promise.all([ - readFile(sourcePath, 'utf8'), - readFile(targetPath, 'utf8'), - ]); - const mergedContent = mergeEnvContent(sourceContent, targetContent); - if (mergedContent === targetContent) { - return; - } - - await writeFile(targetPath, mergedContent, 'utf8'); -} - -const executedPath = process.argv[1]; -if (typeof executedPath === 'string' && pathToFileURL(executedPath).href === import.meta.url) { - await ensureEnvFile(); -} +import { bootstrapExampleEnvFile } from 'magic-sso-example-ui/env-file'; + +export const ensureEnvFile = await bootstrapExampleEnvFile({ + envPath: '../.env', + examplePath: '../.env.example', + generatedPlaceholderValues: new Map([ + [ + 'MAGICSSO_JWT_SECRET', + new Set([ + 'replace-me-with-a-long-random-jwt-secret', + 'VERY-VERY-LONG-RANDOM-JWT-SECRET', + ]), + ], + ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], + ]), + moduleUrl: import.meta.url, +}); diff --git a/examples/nuxt/server/api/signin.post.ts b/examples/nuxt/server/api/signin.post.ts index 7cf3669..a3fd7d4 100644 --- a/examples/nuxt/server/api/signin.post.ts +++ b/examples/nuxt/server/api/signin.post.ts @@ -2,7 +2,8 @@ // Copyright (C) 2026 Wojciech Polak import { createError, getRequestURL, readBody } from 'h3'; -import { buildFailureResult, readMessage, readServerUrlConfigError } from './signin'; +import { buildFailureResult, readMessage } from 'magic-sso-example-ui/signin'; +import { readServerUrlConfigError } from './signin'; interface SignInRequestBody { email?: string; diff --git a/examples/nuxt/server/api/signin.ts b/examples/nuxt/server/api/signin.ts index 0992bd5..bb2fea8 100644 --- a/examples/nuxt/server/api/signin.ts +++ b/examples/nuxt/server/api/signin.ts @@ -1,43 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -interface MessagePayload { - message: string; -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== 'object' || value === null) { - return null; - } - - return value as Record; -} - -export function readMessage(value: unknown): string | null { - const record = asRecord(value); - const message = record?.message; - - return typeof message === 'string' && message.length > 0 ? message : null; -} - -export function buildFailureResult( - payload: unknown, - fallback = 'Failed to send verification email.', -): MessagePayload { - return { - message: readMessage(payload) ?? fallback, - }; -} +import { readServerUrlConfigError as readSharedServerUrlConfigError } from 'magic-sso-example-ui/signin'; export function readServerUrlConfigError(serverUrl: string, requestOrigin: string): string | null { - try { - const parsedServerUrl = new URL(serverUrl); - if (parsedServerUrl.origin === requestOrigin) { - return 'MAGICSSO_SERVER_URL points to this Nuxt app. Set it to the Magic Link SSO server, usually http://localhost:3000 for local development.'; - } - } catch { - return 'MAGICSSO_SERVER_URL must be an absolute URL.'; - } - - return null; + return readSharedServerUrlConfigError(serverUrl, requestOrigin, 'Nuxt'); } diff --git a/examples/nuxt/tests/signin.test.ts b/examples/nuxt/tests/signin.test.ts index 52e8b81..816db42 100644 --- a/examples/nuxt/tests/signin.test.ts +++ b/examples/nuxt/tests/signin.test.ts @@ -2,7 +2,8 @@ // Copyright (C) 2026 Wojciech Polak import { describe, expect, it } from 'vitest'; -import { buildFailureResult, readMessage, readServerUrlConfigError } from '../server/api/signin'; +import { buildFailureResult, readMessage } from 'magic-sso-example-ui/signin'; +import { readServerUrlConfigError } from '../server/api/signin'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; diff --git a/examples/photos/Dockerfile b/examples/photos/Dockerfile index 78f471a..ee0b4e0 100644 --- a/examples/photos/Dockerfile +++ b/examples/photos/Dockerfile @@ -8,11 +8,13 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/config-core/package.json packages/config-core/package.json COPY packages/core/package.json packages/core/package.json COPY examples/photos/package.json examples/photos/package.json +COPY packages/example-ui/package.json packages/example-ui/package.json COPY packages/nextjs/package.json packages/nextjs/package.json RUN pnpm install --ignore-scripts --filter example-app-photos... --frozen-lockfile COPY packages/config-core packages/config-core COPY packages/core packages/core COPY examples/photos examples/photos +COPY packages/example-ui packages/example-ui COPY packages/nextjs packages/nextjs RUN pnpm --filter @magic-link-sso/config-core build && pnpm --filter example-app-photos build diff --git a/examples/photos/package.json b/examples/photos/package.json index 82a2297..90aa424 100644 --- a/examples/photos/package.json +++ b/examples/photos/package.json @@ -15,7 +15,9 @@ }, "dependencies": { "@magic-link-sso/config-core": "workspace:*", + "@magic-link-sso/core": "workspace:*", "@magic-link-sso/nextjs": "workspace:*", + "magic-sso-example-ui": "workspace:*", "next": "^16.3.0", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/examples/photos/scripts/ensure-env.mjs b/examples/photos/scripts/ensure-env.mjs index 4cc1069..278a1fa 100644 --- a/examples/photos/scripts/ensure-env.mjs +++ b/examples/photos/scripts/ensure-env.mjs @@ -1,114 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -import { access, copyFile } from 'node:fs/promises'; -import { readFile, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; - -const defaultSourcePath = new URL('../.env.local.example', import.meta.url); -const defaultTargetPath = new URL('../.env.local', import.meta.url); -const generatedPlaceholderValues = new Map([ - ['MAGICSSO_JWT_SECRET', new Set(['replace-me-with-a-long-random-jwt-secret'])], - ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], - ['MAGICSSO_PUBLIC_ORIGIN', new Set(['http://localhost:3001'])], -]); - -async function fileExists(path) { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function parseEnvKeys(content) { - const keys = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - if (match) { - keys.add(match[1]); - } - } - - return keys; -} - -function parseEnvValues(content) { - const values = new Map(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (match) { - values.set(match[1], match[2]); - } - } - - return values; -} - -function mergeEnvContent(sourceContent, targetContent) { - const sourceValues = parseEnvValues(sourceContent); - const targetKeys = parseEnvKeys(targetContent); - let changed = false; - const targetLines = targetContent.split('\n').map((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (!match) { - return line; - } - - const [, key, value] = match; - const placeholderValues = generatedPlaceholderValues.get(key); - const sourceValue = sourceValues.get(key); - if ( - placeholderValues !== undefined && - sourceValue !== undefined && - placeholderValues.has(value) && - sourceValue !== value - ) { - changed = true; - return `${key}=${sourceValue}`; - } - - return line; - }); - const missingLines = sourceContent.split('\n').filter((line) => { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); - return match ? !targetKeys.has(match[1]) : false; - }); - - if (missingLines.length > 0) { - changed = true; - } - - return changed - ? `${targetLines.join('\n').replace(/\n?$/u, '\n')}${missingLines.join('\n')}${ - missingLines.length > 0 ? '\n' : '' - }` - : targetContent; -} - -export async function ensureEnvFile( - sourcePath = defaultSourcePath, - targetPath = defaultTargetPath, -) { - if (await fileExists(targetPath)) { - const [sourceContent, targetContent] = await Promise.all([ - readFile(sourcePath, 'utf8'), - readFile(targetPath, 'utf8'), - ]); - const mergedContent = mergeEnvContent(sourceContent, targetContent); - if (mergedContent === targetContent) { - return; - } - - await writeFile(targetPath, mergedContent, 'utf8'); - return; - } - - await copyFile(sourcePath, targetPath); -} - -const executedPath = process.argv[1]; -if (typeof executedPath === 'string' && pathToFileURL(executedPath).href === import.meta.url) { - await ensureEnvFile(); -} +import { bootstrapExampleEnvFile } from 'magic-sso-example-ui/env-file'; + +export const ensureEnvFile = await bootstrapExampleEnvFile({ + envPath: '../.env.local', + examplePath: '../.env.local.example', + generatedPlaceholderValues: new Map([ + ['MAGICSSO_JWT_SECRET', new Set(['replace-me-with-a-long-random-jwt-secret'])], + ['MAGICSSO_PREVIEW_SECRET', new Set(['replace-me-with-a-long-random-preview-secret'])], + ['MAGICSSO_PUBLIC_ORIGIN', new Set(['http://localhost:3001'])], + ]), + moduleUrl: import.meta.url, +}); diff --git a/examples/photos/src/app/api/signin/route.ts b/examples/photos/src/app/api/signin/route.ts index 641f568..d4fec2a 100644 --- a/examples/photos/src/app/api/signin/route.ts +++ b/examples/photos/src/app/api/signin/route.ts @@ -2,41 +2,25 @@ // Copyright (C) 2026 Wojciech Polak import { NextRequest, NextResponse } from 'next/server'; +import { normaliseReturnUrl } from '@magic-link-sso/nextjs'; import { getDemoScopeForEmail } from '../../login/demo-emails'; import { sendMagicLink } from '../../login/signin'; -import { resolveAppOrigin } from '../../login/url'; +import { resolveRequestAppOrigin } from '../../login/url'; function acceptsJson(request: NextRequest): boolean { const accept = request.headers.get('accept'); return typeof accept === 'string' && accept.includes('application/json'); } -function getErrorMessage(errorCode: string): string { - switch (errorCode) { - case 'invalid-signin-request': - return 'The sign-in form was incomplete. Please try again.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - case 'signin-request-failed': - default: - return 'We could not send the sign-in email. Please try again.'; - } -} - -function normaliseReturnUrl(returnUrl: string | null, origin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return origin; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return new URL(returnUrl, origin).toString(); - } +const signInErrorMessages: Record = { + 'invalid-signin-request': 'The sign-in form was incomplete. Please try again.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', +}; - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === origin ? parsedUrl.toString() : origin; - } catch { - return origin; - } +function getErrorMessage(errorCode: string): string { + return ( + signInErrorMessages[errorCode] ?? 'We could not send the sign-in email. Please try again.' + ); } function buildLoginRedirect( @@ -61,13 +45,7 @@ function buildLoginRedirect( export async function POST(request: NextRequest): Promise { const formData = await request.formData(); - const appOrigin = resolveAppOrigin({ - explicitPublicOrigin: process.env.MAGICSSO_PUBLIC_ORIGIN, - fallbackOrigin: request.nextUrl.origin, - forwardedHost: request.headers.get('x-forwarded-host'), - forwardedProtocol: request.headers.get('x-forwarded-proto'), - host: request.headers.get('host'), - }); + const appOrigin = resolveRequestAppOrigin(request); const email = formData.get('email'); const verifyUrl = formData.get('verifyUrl'); const scopeValue = formData.get('scope'); @@ -77,7 +55,8 @@ export async function POST(request: NextRequest): Promise { ? scopeValue.trim() : undefined; const returnUrl = normaliseReturnUrl( - typeof returnUrlValue === 'string' ? returnUrlValue : null, + typeof returnUrlValue === 'string' ? returnUrlValue : undefined, + appOrigin, appOrigin, ); diff --git a/examples/photos/src/app/login/page.tsx b/examples/photos/src/app/login/page.tsx index 1c2f360..10676cd 100644 --- a/examples/photos/src/app/login/page.tsx +++ b/examples/photos/src/app/login/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; +import { normaliseReturnUrl } from '@magic-link-sso/nextjs'; import React from 'react'; import { getDemoEmailsFromEnv } from './demo-emails'; import { resolveAppOrigin } from './url'; @@ -17,52 +18,30 @@ type LoginPageProps = { }>; }; +const loginErrorMessages: Record = { + 'invalid-session': 'Your session could not be verified. Please sign in again.', + 'missing-verification-token': 'The sign-in link is incomplete. Please request a new email.', + 'session-verification-failed': + 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.', + 'session-verification-misconfigured': + 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.', + 'verify-email-failed': + 'We could not complete sign-in from that email link. Please request a new one.', + 'verify-email-misconfigured': 'This app is missing required SSO verify-email configuration.', + 'invalid-signin-request': 'The sign-in form was incomplete. Please try again.', + 'signin-request-failed': 'We could not send the sign-in email. Please try again.', +}; + +const loginSuccessMessages: Record = { + 'verification-email-sent': 'Verification email sent', +}; + function getLoginErrorMessage(errorCode: string | undefined): string | undefined { - switch (errorCode) { - case 'invalid-session': - return 'Your session could not be verified. Please sign in again.'; - case 'missing-verification-token': - return 'The sign-in link is incomplete. Please request a new email.'; - case 'session-verification-failed': - return 'The app could not verify the returned sign-in token. Check that MAGICSSO_JWT_SECRET matches the SSO server.'; - case 'session-verification-misconfigured': - return 'This app is missing MAGICSSO_JWT_SECRET, so it cannot verify sign-in tokens.'; - case 'verify-email-failed': - return 'We could not complete sign-in from that email link. Please request a new one.'; - case 'verify-email-misconfigured': - return 'This app is missing required SSO verify-email configuration.'; - case 'invalid-signin-request': - return 'The sign-in form was incomplete. Please try again.'; - case 'signin-request-failed': - return 'We could not send the sign-in email. Please try again.'; - default: - return undefined; - } + return typeof errorCode === 'string' ? loginErrorMessages[errorCode] : undefined; } function getLoginSuccessMessage(successCode: string | undefined): string | undefined { - switch (successCode) { - case 'verification-email-sent': - return 'Verification email sent'; - default: - return undefined; - } -} - -function normaliseReturnUrl(returnUrl: string | undefined, appOrigin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return appOrigin; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return new URL(returnUrl, appOrigin).toString(); - } - - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === appOrigin ? parsedUrl.toString() : appOrigin; - } catch { - return appOrigin; - } + return typeof successCode === 'string' ? loginSuccessMessages[successCode] : undefined; } export const metadata: Metadata = { @@ -88,7 +67,7 @@ export default async function LoginPage({ const requestedReturnUrl = Array.isArray(returnUrlValue) ? returnUrlValue[0] : returnUrlValue; const requestedSuccess = Array.isArray(successValue) ? successValue[0] : successValue; const requestedScope = Array.isArray(scopeValue) ? scopeValue[0] : scopeValue; - const returnUrl = normaliseReturnUrl(requestedReturnUrl, appOrigin); + const returnUrl = normaliseReturnUrl(requestedReturnUrl, appOrigin, appOrigin); const initialError = getLoginErrorMessage(requestedError); const initialSuccess = getLoginSuccessMessage(requestedSuccess); const demoEmails = getDemoEmailsFromEnv(); diff --git a/examples/photos/src/app/login/signin.ts b/examples/photos/src/app/login/signin.ts index 0fb0c3b..768225c 100644 --- a/examples/photos/src/app/login/signin.ts +++ b/examples/photos/src/app/login/signin.ts @@ -1,24 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -function readErrorMessage(data: unknown): string | undefined { - if ( - typeof data === 'object' && - data !== null && - 'message' in data && - typeof data.message === 'string' && - data.message.length > 0 - ) { - return data.message; - } - - return undefined; -} - -async function readResponseMessage(response: Response): Promise { - const payload: unknown = await response.json().catch(() => null); - return readErrorMessage(payload); -} +import { sendMagicLink as requestMagicLink } from '@magic-link-sso/nextjs'; export interface SignInResult { code?: string; @@ -28,17 +11,6 @@ export interface SignInResult { success: boolean; } -function readOtpMetadata(value: unknown): Pick { - if (typeof value !== 'object' || value === null) { - return {}; - } - const challengeId = Reflect.get(value, 'otpChallengeId'); - const length = Reflect.get(value, 'otpLength'); - return typeof challengeId === 'string' && typeof length === 'number' - ? { otpChallengeId: challengeId, otpLength: length } - : {}; -} - export async function sendMagicLink( email: string, returnUrl: string, @@ -54,44 +26,20 @@ export async function sendMagicLink( }; } - const normalizedScope = typeof scope === 'string' ? scope.trim() : ''; - - try { - const response = await fetch(new URL('/signin', serverUrl), { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - email, - returnUrl, - verifyUrl, - ...(normalizedScope.length > 0 ? { scope: normalizedScope } : {}), - }), - cache: 'no-store', - }); - if (!response.ok) { - const serverMessage = await readResponseMessage(response); - console.error('Error sending magic link:', { - serverMessage, - status: response.status, - }); - return { - success: false, - code: 'signin-request-failed', - message: serverMessage ?? 'Failed to send verification email.', - }; - } - - return { success: true, ...readOtpMetadata(await response.json().catch(() => null)) }; - } catch (error: unknown) { - if (error instanceof Error) { - console.error('Error sending magic link:', { message: error.message }); - } + const result = await requestMagicLink(email, returnUrl, scope, { verifyUrl }); + if (!result.success) { + console.error('Error sending magic link:', { message: result.message }); return { success: false, code: 'signin-request-failed', - message: 'Failed to send verification email.', + message: result.message, }; } + + return { + success: true, + ...(typeof result.otpChallengeId === 'string' && typeof result.otpLength === 'number' + ? { otpChallengeId: result.otpChallengeId, otpLength: result.otpLength } + : {}), + }; } diff --git a/examples/photos/src/app/login/url.ts b/examples/photos/src/app/login/url.ts index a487232..fb4f273 100644 --- a/examples/photos/src/app/login/url.ts +++ b/examples/photos/src/app/login/url.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak +import type { NextRequest } from 'next/server'; + export function getAppOrigin(host: string, forwardedProtocol?: string | null): string { const protocol = forwardedProtocol ?? (host.startsWith('localhost') ? 'http' : 'https'); return `${protocol}://${host}`; @@ -53,3 +55,14 @@ export function resolveAppOrigin(options: { return 'http://localhost:5001'; } + +/** Resolve the public origin this app should use when answering `request`. */ +export function resolveRequestAppOrigin(request: NextRequest): string { + return resolveAppOrigin({ + explicitPublicOrigin: process.env.MAGICSSO_PUBLIC_ORIGIN, + fallbackOrigin: request.nextUrl.origin, + forwardedHost: request.headers.get('x-forwarded-host'), + forwardedProtocol: request.headers.get('x-forwarded-proto'), + host: request.headers.get('host'), + }); +} diff --git a/examples/photos/src/app/verify-email/route.ts b/examples/photos/src/app/verify-email/route.ts index e74718a..402b118 100644 --- a/examples/photos/src/app/verify-email/route.ts +++ b/examples/photos/src/app/verify-email/route.ts @@ -1,458 +1,19 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak -import { escapeHtml, safeCompare } from '@magic-link-sso/config-core/runtime'; -import { buildAuthCookieOptions, getJwtSecret, verifyAuthToken } from '@magic-link-sso/nextjs'; -import { randomBytes } from 'node:crypto'; -import { NextRequest, NextResponse } from 'next/server'; -import { resolveAppOrigin } from '../login/url'; +import { VerifyEmailRoute, type VerifyEmailRouteOptions } from '@magic-link-sso/nextjs'; +import type { NextRequest, NextResponse } from 'next/server'; +import { resolveRequestAppOrigin } from '../login/url'; -interface VerifyEmailResponse { - accessToken: string; -} - -interface VerifyEmailPreviewResponse { - email: string; -} - -const verifyCsrfCookieName = 'magic-sso-verify-csrf'; -const verifyTokenCookieName = 'magic-sso-verify-token'; - -function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { - return ( - typeof value === 'object' && - value !== null && - 'accessToken' in value && - typeof value.accessToken === 'string' && - value.accessToken.length > 0 - ); -} - -function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { - return ( - typeof value === 'object' && - value !== null && - 'email' in value && - typeof value.email === 'string' && - value.email.length > 0 - ); -} - -function normaliseReturnUrl(returnUrl: string | null, origin: string): string { - if (typeof returnUrl !== 'string' || returnUrl.length === 0) { - return '/'; - } - if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { - return returnUrl; - } - - try { - const parsedUrl = new URL(returnUrl); - return parsedUrl.origin === origin ? parsedUrl.toString() : '/'; - } catch { - return '/'; - } -} - -function buildLoginRedirect(appOrigin: string, returnUrl: string, error?: string): NextResponse { - const loginUrl = new URL('/login', appOrigin); - loginUrl.searchParams.set('returnUrl', returnUrl); - if (typeof error === 'string') { - loginUrl.searchParams.set('error', error); - } - return NextResponse.redirect(loginUrl); -} - -function createVerifyCsrfToken(): string { - return randomBytes(32).toString('base64url'); -} - -function hasValidVerifyCsrfToken(submittedToken: string, cookieToken: string): boolean { - return safeCompare(submittedToken, cookieToken); -} - -function buildVerifyCookieOptions(request: NextRequest): { - httpOnly: true; - path: '/verify-email'; - sameSite: 'strict'; - secure: boolean; -} { - return { - httpOnly: true, - path: '/verify-email', - sameSite: 'strict', - secure: request.nextUrl.protocol === 'https:', - }; -} - -function getPreviewSecret(): string | null { - const previewSecret = process.env.MAGICSSO_PREVIEW_SECRET; - return typeof previewSecret === 'string' && previewSecret.length > 0 ? previewSecret : null; -} - -function clearVerifyCookie(response: NextResponse, request: NextRequest): void { - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyCsrfCookieName, - value: '', - maxAge: 0, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyTokenCookieName, - value: '', - maxAge: 0, - }); -} - -function renderConfirmationPage(email: string, returnUrl: string, csrfToken: string): string { - return ` - - - - - Confirm Sign In | Magic Link SSO Photos Demo - - - -
-

Verify Email

-

Continue sign-in

-

Review the email address below, then continue to finish signing in.

-
-

Email

- - - - -
-
- -`; -} - -async function previewVerificationEmail( - token: string, -): Promise { - const serverUrl = process.env.MAGICSSO_SERVER_URL; - if (typeof serverUrl !== 'string' || serverUrl.length === 0) { - console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); - return 'misconfigured'; - } - - const verifyUrl = new URL('/verify-email', serverUrl); - verifyUrl.searchParams.set('token', token); - const previewSecret = getPreviewSecret(); - if (previewSecret === null) { - console.error('Verify-email route is missing MAGICSSO_PREVIEW_SECRET.'); - return 'misconfigured'; - } - - try { - const response = await fetch(verifyUrl, { - headers: { - accept: 'application/json', - 'x-magic-sso-preview-secret': previewSecret, - }, - cache: 'no-store', - }); - if (!response.ok) { - return null; - } - - const payload: unknown = await response.json(); - return isVerifyEmailPreviewResponse(payload) ? payload : null; - } catch (error) { - console.error('Verify-email preview failed unexpectedly.', error); - return null; - } -} +const options: VerifyEmailRouteOptions = { + pageTitle: 'Confirm Sign In | Magic Link SSO Photos Demo', + resolveAppOrigin: resolveRequestAppOrigin, +}; export async function GET(request: NextRequest): Promise { - const appOrigin = resolveAppOrigin({ - explicitPublicOrigin: process.env.MAGICSSO_PUBLIC_ORIGIN, - fallbackOrigin: request.nextUrl.origin, - forwardedHost: request.headers.get('x-forwarded-host'), - forwardedProtocol: request.headers.get('x-forwarded-proto'), - host: request.headers.get('host'), - }); - const token = request.nextUrl.searchParams.get('token'); - const returnUrl = normaliseReturnUrl(request.nextUrl.searchParams.get('returnUrl'), appOrigin); - if (typeof token !== 'string' || token.length === 0) { - return buildLoginRedirect(appOrigin, returnUrl, 'missing-verification-token'); - } - - const payload = await previewVerificationEmail(token); - if (payload === 'misconfigured') { - return buildLoginRedirect(appOrigin, returnUrl, 'verify-email-misconfigured'); - } - if (payload === null) { - return buildLoginRedirect(appOrigin, returnUrl, 'verify-email-failed'); - } - - const csrfToken = createVerifyCsrfToken(); - const response = new NextResponse(renderConfirmationPage(payload.email, returnUrl, csrfToken), { - headers: { - 'cache-control': 'no-store', - 'content-type': 'text/html; charset=utf-8', - }, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyCsrfCookieName, - value: csrfToken, - }); - response.cookies.set({ - ...buildVerifyCookieOptions(request), - name: verifyTokenCookieName, - value: token, - }); - return response; + return VerifyEmailRoute(request, options); } export async function POST(request: NextRequest): Promise { - const formData = await request.formData(); - const appOrigin = resolveAppOrigin({ - explicitPublicOrigin: process.env.MAGICSSO_PUBLIC_ORIGIN, - fallbackOrigin: request.nextUrl.origin, - forwardedHost: request.headers.get('x-forwarded-host'), - forwardedProtocol: request.headers.get('x-forwarded-proto'), - host: request.headers.get('host'), - }); - const submittedToken = formData.get('token'); - const returnUrlValue = formData.get('returnUrl'); - const returnUrl = normaliseReturnUrl( - typeof returnUrlValue === 'string' ? returnUrlValue : null, - appOrigin, - ); - const submittedCsrfToken = formData.get('csrfToken'); - const cookieCsrfToken = request.cookies.get(verifyCsrfCookieName)?.value; - const cookieToken = request.cookies.get(verifyTokenCookieName)?.value; - const token = - typeof submittedToken === 'string' - ? typeof cookieToken === 'string' && submittedToken !== cookieToken - ? null - : submittedToken - : cookieToken; - - if ( - typeof token !== 'string' || - token.length === 0 || - typeof submittedCsrfToken !== 'string' || - typeof cookieCsrfToken !== 'string' || - !hasValidVerifyCsrfToken(submittedCsrfToken, cookieCsrfToken) - ) { - const response = buildLoginRedirect(appOrigin, returnUrl, 'verify-email-failed'); - clearVerifyCookie(response, request); - return response; - } - - const serverUrl = process.env.MAGICSSO_SERVER_URL; - if (typeof serverUrl !== 'string' || serverUrl.length === 0) { - console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); - const response = buildLoginRedirect(appOrigin, returnUrl, 'verify-email-misconfigured'); - clearVerifyCookie(response, request); - return response; - } - - const verifyUrl = new URL('/verify-email', serverUrl); - - try { - const response = await fetch(verifyUrl, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - }, - body: JSON.stringify({ token }), - cache: 'no-store', - }); - if (!response.ok) { - const payload: unknown = await response.json().catch(() => null); - const serverMessage = - typeof payload === 'object' && - payload !== null && - 'message' in payload && - typeof payload.message === 'string' - ? payload.message - : undefined; - console.error('Verify-email request to SSO server failed.', { - serverMessage, - status: response.status, - }); - const redirectResponse = buildLoginRedirect( - appOrigin, - returnUrl, - 'verify-email-failed', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const payload: unknown = await response.json(); - if (!isVerifyEmailResponse(payload)) { - console.error( - 'Verify-email route received an invalid response payload from the SSO server.', - ); - const redirectResponse = buildLoginRedirect( - appOrigin, - returnUrl, - 'verify-email-failed', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const jwtSecret = getJwtSecret(); - if (jwtSecret === null) { - console.error( - 'Verify-email route cannot validate the auth token because MAGICSSO_JWT_SECRET is missing.', - ); - const redirectResponse = buildLoginRedirect( - appOrigin, - returnUrl, - 'verify-email-misconfigured', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const verifiedAccessToken = await verifyAuthToken(payload.accessToken, jwtSecret, { - expectedAudience: appOrigin, - expectedIssuer: new URL(serverUrl).origin, - }); - if (verifiedAccessToken === null) { - console.error( - 'Verify-email route rejected the auth token returned by the SSO server. Check that MAGICSSO_JWT_SECRET matches the server JWT secret.', - ); - const redirectResponse = buildLoginRedirect( - appOrigin, - returnUrl, - 'session-verification-failed', - ); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } - - const redirectUrl = new URL(returnUrl, appOrigin); - const redirectResponse = NextResponse.redirect(redirectUrl); - clearVerifyCookie(redirectResponse, request); - redirectResponse.cookies.set(buildAuthCookieOptions(payload.accessToken)); - return redirectResponse; - } catch (error) { - console.error('Verify-email route failed unexpectedly.', error); - const redirectResponse = buildLoginRedirect(appOrigin, returnUrl, 'verify-email-failed'); - clearVerifyCookie(redirectResponse, request); - return redirectResponse; - } + return VerifyEmailRoute(request, options); } diff --git a/examples/photos/tests/example-app.test.ts b/examples/photos/tests/example-app.test.ts index e9c7871..f2cae63 100644 --- a/examples/photos/tests/example-app.test.ts +++ b/examples/photos/tests/example-app.test.ts @@ -22,11 +22,12 @@ describe('Photos example app', () => { expect(globalsCss).toContain('.field-input::placeholder'); }); - it('keeps the verify-email screen aligned with dark-mode auto styling', async () => { + it('delegates the verify-email screen to the shared Next.js route', async () => { const verifyEmailRoute = await readFile(verifyEmailRoutePath, 'utf8'); - expect(verifyEmailRoute).toContain('@media (prefers-color-scheme: dark)'); - expect(verifyEmailRoute).toContain('Continue sign-in'); + expect(verifyEmailRoute).toContain('VerifyEmailRoute'); + expect(verifyEmailRoute).toContain("from '@magic-link-sso/nextjs'"); + expect(verifyEmailRoute).toContain('resolveAppOrigin: resolveRequestAppOrigin'); }); it('keeps OTP exchange server-side for an already-open Photos session', async () => { diff --git a/gate/dev/bootstrap-manager-stack.mjs b/gate/dev/bootstrap-manager-stack.mjs deleted file mode 100644 index ebd3678..0000000 --- a/gate/dev/bootstrap-manager-stack.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { - applyManagerState, - buildRuntimePlan, - loadManagerRuntimeSettings, - loadManagerStateOrEmpty, - resetManagerStateApplyMetadata, - saveManagerState, -} from '/app/manager/dist/index.js'; - -const runtimeDirectory = '/app/runtime'; -const baseConfigTemplatePath = '/app/server/magic-sso.base.toml.template'; -const baseConfigPath = '/app/runtime/magic-sso.base.toml'; -const managerConfigTemplatePath = '/app/manager/manager.toml.template'; -const managerConfigPath = '/app/runtime/manager.toml'; -const managerStateTemplatePath = '/app/manager/manager-state.json.template'; -const managerStatePath = '/app/runtime/manager-state.json'; - -function renderTemplate(templatePath, outputPath) { - const template = readFileSync(templatePath, 'utf8'); - const rendered = template.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, key) => { - const value = process.env[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`Missing required env var for managed dev bootstrap: ${key}`); - } - - return value; - }); - writeFileSync(outputPath, rendered, 'utf8'); - return rendered; -} - -mkdirSync(runtimeDirectory, { recursive: true }); - -renderTemplate(baseConfigTemplatePath, baseConfigPath); -renderTemplate(managerConfigTemplatePath, managerConfigPath); - -if (!existsSync(managerStatePath)) { - renderTemplate(managerStateTemplatePath, managerStatePath); -} - -const settings = loadManagerRuntimeSettings({ - configFilePath: managerConfigPath, -}); -const state = loadManagerStateOrEmpty(settings); -const currentRuntimePlan = buildRuntimePlan(state, settings); -const baseConfigChangedFromTemplate = - typeof state.metadata.lastAppliedBaseConfigHash === 'string' && - state.metadata.lastAppliedBaseConfigHash !== currentRuntimePlan.baseConfigHash; -const bootstrapState = baseConfigChangedFromTemplate - ? resetManagerStateApplyMetadata(state) - : state; - -if (baseConfigChangedFromTemplate) { - saveManagerState(settings.paths.stateFile, bootstrapState); -} - -await applyManagerState(bootstrapState, { - ...settings, - reload: undefined, -}); diff --git a/gate/docker-compose.yml b/gate/docker-compose.yml index 1373c48..06af872 100644 --- a/gate/docker-compose.yml +++ b/gate/docker-compose.yml @@ -87,12 +87,13 @@ services: restart: unless-stopped volumes: - ./runtime:/app/runtime - - ./dev/bootstrap-manager-stack.mjs:/app/manager/bootstrap-manager-stack.mjs:ro + - ../manager/dev/bootstrap-managed-stack.mjs:/app/manager/dev/bootstrap-managed-stack.mjs:ro + - ../manager/dev/managed-stack.mjs:/app/manager/dev/managed-stack.mjs:ro - ./dev/manager-state.json.template:/app/manager/manager-state.json.template:ro - ./dev/manager.toml.template:/app/manager/manager.toml.template:ro - ./dev/magic-sso.toml.template:/app/server/magic-sso.base.toml.template:ro command: > - sh -c "node /app/manager/bootstrap-manager-stack.mjs && node dist/main.js" + sh -c "node /app/manager/dev/bootstrap-managed-stack.mjs && node dist/main.js" private1-upstream: build: diff --git a/gate/package.json b/gate/package.json index 3c2704a..48fdc11 100644 --- a/gate/package.json +++ b/gate/package.json @@ -17,6 +17,7 @@ "@fastify/cookie": "^11.1.2", "@fastify/formbody": "^8.0.2", "@magic-link-sso/config-core": "workspace:*", + "@magic-link-sso/core": "workspace:*", "dotenv": "^17.4.2", "fastify": "^5.11.3", "ioredis": "^5.11.1", diff --git a/gate/src/app.ts b/gate/src/app.ts index 879412e..134c6a3 100644 --- a/gate/src/app.ts +++ b/gate/src/app.ts @@ -4,6 +4,7 @@ import cookie from '@fastify/cookie'; import formbody from '@fastify/formbody'; import { safeCompare } from '@magic-link-sso/config-core/runtime'; +import { isVerifyEmailPreviewResponse, isVerifyEmailResponse } from '@magic-link-sso/core'; import Fastify, { type FastifyInstance, type FastifyReply, @@ -82,14 +83,6 @@ interface VerifyEmailOtpBody { code?: string; } -interface VerifyEmailResponse { - accessToken: string; -} - -interface VerifyEmailPreviewResponse { - email: string; -} - interface SignInSuccessResponse { message: string; otpChallengeId?: string; @@ -626,26 +619,6 @@ function applyProxiedResponseSecurityHeaders( } } -function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { - return ( - typeof value === 'object' && - value !== null && - 'accessToken' in value && - typeof value.accessToken === 'string' && - value.accessToken.length > 0 - ); -} - -function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { - return ( - typeof value === 'object' && - value !== null && - 'email' in value && - typeof value.email === 'string' && - value.email.length > 0 - ); -} - function isSignInSuccessResponse(value: unknown): value is SignInSuccessResponse { return ( typeof value === 'object' && @@ -832,47 +805,50 @@ function createOtpChallengeCookie(value: OtpChallengeCookie, config: GateConfig) return `${payload}.${signature}`; } -function readOtpChallengeCookie( - value: string | undefined, - config: GateConfig, -): OtpChallengeCookie | null { +/** Verify the HMAC on a signed cookie payload and return its decoded body. */ +function readSignedCookiePayload(value: string | undefined, config: GateConfig): unknown { if (typeof value !== 'string') { - return null; + return undefined; } + const [payload, signature, ...rest] = value.split('.'); if (typeof payload !== 'string' || typeof signature !== 'string' || rest.length > 0) { - return null; + return undefined; } + const expectedSignature = createHmac('sha256', config.jwtSecret) .update(payload) .digest('base64url'); if (!safeCompare(signature, expectedSignature)) { - return null; + return undefined; } + try { - const parsed: unknown = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); - const challengeId = - typeof parsed === 'object' && parsed !== null - ? Reflect.get(parsed, 'challengeId') - : undefined; - const returnUrl = - typeof parsed === 'object' && parsed !== null - ? Reflect.get(parsed, 'returnUrl') - : undefined; - const otpLength = - typeof parsed === 'object' && parsed !== null - ? Reflect.get(parsed, 'otpLength') - : undefined; - return typeof challengeId === 'string' && - typeof returnUrl === 'string' && - typeof otpLength === 'number' && - Number.isInteger(otpLength) && - otpLength > 0 - ? { challengeId, otpLength, returnUrl: normaliseReturnUrl(returnUrl, config) } - : null; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as unknown; } catch { + return undefined; + } +} + +function readOtpChallengeCookie( + value: string | undefined, + config: GateConfig, +): OtpChallengeCookie | null { + const parsed = readSignedCookiePayload(value, config); + if (typeof parsed !== 'object' || parsed === null) { return null; } + + const challengeId = Reflect.get(parsed, 'challengeId'); + const returnUrl = Reflect.get(parsed, 'returnUrl'); + const otpLength = Reflect.get(parsed, 'otpLength'); + return typeof challengeId === 'string' && + typeof returnUrl === 'string' && + typeof otpLength === 'number' && + Number.isInteger(otpLength) && + otpLength > 0 + ? { challengeId, otpLength, returnUrl: normaliseReturnUrl(returnUrl, config) } + : null; } async function readResponsePayload(response: Response): Promise { diff --git a/gate/src/config.ts b/gate/src/config.ts index 98b6a73..c2dc053 100644 --- a/gate/src/config.ts +++ b/gate/src/config.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (C) 2026 Wojciech Polak +import { validateConfiguredSecret } from '@magic-link-sso/config-core'; import { readFileSync } from 'node:fs'; import { isIP } from 'node:net'; import { parse as parseToml } from 'smol-toml'; @@ -230,6 +231,47 @@ function parseIpv4Address(hostname: string): [number, number, number, number] | return [first, second, third, fourth]; } +/** Classify a private/reserved IPv4 target, or `undefined` for a routable one. */ +function getPrivateIpv4Reason( + address: readonly [number, number, number, number], +): string | undefined { + const [first, second] = address; + if (first === 0) { + return 'unspecified IPv4 target'; + } + if (first === 10 || first === 127) { + return 'private or loopback IPv4 target'; + } + if (first === 100 && second >= 64 && second <= 127) { + return 'carrier-grade NAT IPv4 target'; + } + if (first === 169 && second === 254) { + return 'link-local or metadata-service IPv4 target'; + } + if (first === 172 && second >= 16 && second <= 31) { + return 'private IPv4 target'; + } + + return first === 192 && second === 168 ? 'private IPv4 target' : undefined; +} + +const privateIpv6Prefixes = ['fc', 'fd', 'fe8', 'fe9', 'fea', 'feb']; + +/** Classify a private/reserved IPv6 target, or `undefined` for a routable one. */ +function getPrivateIpv6Reason(hostname: string): string | undefined { + if (hostname === '::' || hostname === '::1') { + return 'unspecified or loopback IPv6 target'; + } + + return privateIpv6Prefixes.some((prefix) => hostname.startsWith(prefix)) + ? 'private or link-local IPv6 target' + : undefined; +} + +/** + * Explain why `hostname` points somewhere the Gate should not proxy to, so the + * operator is warned about an unroutable or SSRF-prone upstream. + */ function getPrivateTargetReason(hostname: string): string | undefined { const normalizedHostname = stripIpv6Brackets(hostname).toLowerCase(); if (normalizedHostname === 'localhost' || normalizedHostname.endsWith('.localhost')) { @@ -238,44 +280,10 @@ function getPrivateTargetReason(hostname: string): string | undefined { const ipv4Address = parseIpv4Address(normalizedHostname); if (ipv4Address !== null) { - const [first, second] = ipv4Address; - if (first === 0) { - return 'unspecified IPv4 target'; - } - if (first === 10 || first === 127) { - return 'private or loopback IPv4 target'; - } - if (first === 100 && second >= 64 && second <= 127) { - return 'carrier-grade NAT IPv4 target'; - } - if (first === 169 && second === 254) { - return 'link-local or metadata-service IPv4 target'; - } - if (first === 172 && second >= 16 && second <= 31) { - return 'private IPv4 target'; - } - if (first === 192 && second === 168) { - return 'private IPv4 target'; - } - } - - if (isIP(normalizedHostname) === 6) { - if (normalizedHostname === '::' || normalizedHostname === '::1') { - return 'unspecified or loopback IPv6 target'; - } - if ( - normalizedHostname.startsWith('fc') || - normalizedHostname.startsWith('fd') || - normalizedHostname.startsWith('fe8') || - normalizedHostname.startsWith('fe9') || - normalizedHostname.startsWith('fea') || - normalizedHostname.startsWith('feb') - ) { - return 'private or link-local IPv6 target'; - } + return getPrivateIpv4Reason(ipv4Address); } - return undefined; + return isIP(normalizedHostname) === 6 ? getPrivateIpv6Reason(normalizedHostname) : undefined; } function buildGateTargetWarning( @@ -363,17 +371,7 @@ const placeholderSecretsByField = new Map>([ ]); function parseConfiguredSecret(value: string, fieldName: string): string { - const trimmedValue = value.trim(); - if (trimmedValue.length < MIN_SECRET_LENGTH) { - throw new Error(`${fieldName} must be at least ${MIN_SECRET_LENGTH} characters long.`); - } - - const placeholderValues = placeholderSecretsByField.get(fieldName); - if (placeholderValues?.has(trimmedValue)) { - throw new Error(`${fieldName} must be replaced with a real secret value.`); - } - - return value; + return validateConfiguredSecret(value, fieldName, placeholderSecretsByField.get(fieldName)); } function joinPath(left: string, right: string): string { @@ -491,80 +489,48 @@ export function normaliseReturnUrl( return parsedUrl.toString(); } +/** + * Drop the entries whose value is `undefined`, so an optional TOML key that was + * never set stays absent from the config input instead of overriding a default. + */ +type DefinedProperties = { + [TKey in keyof TValue]?: Exclude; +}; + +function definedEntries(value: TValue): DefinedProperties { + // Object.fromEntries widens to Record; the surviving entries + // are the same keys the caller passed in, with `undefined` filtered out. + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => typeof entry !== 'undefined'), + ) as DefinedProperties; +} + function mapGateTomlToInput(config: z.infer): GateConfigInput { - const input: GateConfigInput = { + return { jwtSecret: config.auth.jwtSecret, publicOrigin: config.gate.publicOrigin, previewSecret: config.auth.previewSecret, serverUrl: config.auth.serverUrl, upstreamUrl: config.gate.upstreamUrl, + ...definedEntries({ + cookieMaxAge: config.cookie?.maxAge, + cookieName: config.cookie?.name, + cookiePath: config.cookie?.path, + directUse: config.gate.directUse, + mode: config.gate.mode, + namespace: config.gate.namespace, + port: config.gate.port, + publicPathPrefix: config.gate.publicPathPrefix, + rateLimitKeyPrefix: config.gate.rateLimitKeyPrefix, + rateLimitMax: config.gate.rateLimitMax, + rateLimitRedisUrl: config.gate.rateLimitRedisUrl, + rateLimitWindowMs: config.gate.rateLimitWindowMs, + requestTimeoutMs: config.gate.requestTimeoutMs, + trustProxy: config.gate.trustProxy, + upstreamBasePath: config.gate.upstreamBasePath, + wsEnabled: config.gate.wsEnabled, + }), }; - - if (typeof config.cookie?.maxAge === 'number') { - input.cookieMaxAge = config.cookie.maxAge; - } - - if (typeof config.cookie?.name === 'string') { - input.cookieName = config.cookie.name; - } - - if (typeof config.cookie?.path === 'string') { - input.cookiePath = config.cookie.path; - } - - if (typeof config.gate.directUse === 'boolean') { - input.directUse = config.gate.directUse; - } - - if (typeof config.gate.mode !== 'undefined') { - input.mode = config.gate.mode; - } - - if (typeof config.gate.namespace === 'string') { - input.namespace = config.gate.namespace; - } - - if (typeof config.gate.port === 'number') { - input.port = config.gate.port; - } - - if (typeof config.gate.publicPathPrefix === 'string') { - input.publicPathPrefix = config.gate.publicPathPrefix; - } - - if (typeof config.gate.rateLimitKeyPrefix === 'string') { - input.rateLimitKeyPrefix = config.gate.rateLimitKeyPrefix; - } - - if (typeof config.gate.rateLimitMax === 'number') { - input.rateLimitMax = config.gate.rateLimitMax; - } - - if (typeof config.gate.rateLimitRedisUrl === 'string') { - input.rateLimitRedisUrl = config.gate.rateLimitRedisUrl; - } - - if (typeof config.gate.rateLimitWindowMs === 'number') { - input.rateLimitWindowMs = config.gate.rateLimitWindowMs; - } - - if (typeof config.gate.requestTimeoutMs === 'number') { - input.requestTimeoutMs = config.gate.requestTimeoutMs; - } - - if (typeof config.gate.trustProxy === 'boolean') { - input.trustProxy = config.gate.trustProxy; - } - - if (typeof config.gate.upstreamBasePath === 'string') { - input.upstreamBasePath = config.gate.upstreamBasePath; - } - - if (typeof config.gate.wsEnabled === 'boolean') { - input.wsEnabled = config.gate.wsEnabled; - } - - return input; } function parseGateToml(fileContents: string, filePath: string): GateConfigInput { diff --git a/gate/tests/dev-template.test.ts b/gate/tests/dev-template.test.ts index ee319fa..cb81e71 100644 --- a/gate/tests/dev-template.test.ts +++ b/gate/tests/dev-template.test.ts @@ -46,7 +46,7 @@ describe('gate dev bootstrap template', () => { it('uses a dedicated manager template and managed-site seed state', () => { const managerTemplate = readGateFile('dev/manager.toml.template'); const managerStateTemplate = readGateFile('dev/manager-state.json.template'); - const bootstrapScript = readGateFile('dev/bootstrap-manager-stack.mjs'); + const compose = readGateFile('docker-compose.yml'); expect(managerTemplate).toContain('managedSiteIds = ["private1", "private2"]'); expect(managerTemplate).toContain('integrityKey = "${MANAGER_AUDIT_INTEGRITY_KEY}"'); @@ -58,9 +58,13 @@ describe('gate dev bootstrap template', () => { expect(managerTemplate).toContain('trustProxy = true'); expect(managerStateTemplate).toContain('PRIVATE1_ALLOWED_EMAIL'); expect(managerStateTemplate).toContain('PRIVATE2_ALLOWED_EMAIL'); - expect(bootstrapScript).toContain('applyManagerState'); - expect(bootstrapScript).toContain('reload: undefined'); - expect(bootstrapScript).toContain('resetManagerStateApplyMetadata'); + expect(compose).toContain( + '../manager/dev/bootstrap-managed-stack.mjs:/app/manager/dev/bootstrap-managed-stack.mjs:ro', + ); + expect(compose).toContain( + '../manager/dev/managed-stack.mjs:/app/manager/dev/managed-stack.mjs:ro', + ); + expect(compose).toContain('node /app/manager/dev/bootstrap-managed-stack.mjs'); }); it('passes the gate auth render values through compose', () => { diff --git a/manager/dev/bootstrap-local-managed-stack.mjs b/manager/dev/bootstrap-local-managed-stack.mjs index 7b48293..e2db09e 100644 --- a/manager/dev/bootstrap-local-managed-stack.mjs +++ b/manager/dev/bootstrap-local-managed-stack.mjs @@ -1,16 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { dirname, join } from 'node:path'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { - applyManagerState, - buildRuntimePlan, - loadManagerRuntimeSettings, - loadManagerStateOrEmpty, - resetManagerStateApplyMetadata, - saveManagerState, -} from '../dist/index.js'; +import { applyBootstrappedManagerState, renderEnvTemplate } from './managed-stack.mjs'; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const managerDirectory = dirname(scriptDirectory); @@ -19,42 +12,12 @@ const managerConfigPath = process.env.MAGICSSO_MANAGER_CONFIG_FILE ?? join(runtimeDirectory, 'manager.toml'); const managerStatePath = join(runtimeDirectory, 'manager-state.json'); -function writeStateIfMissing(templatePath, outputPath) { - if (existsSync(outputPath)) { - return; - } - - const template = readFileSync(templatePath, 'utf8'); - const rendered = template.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, key) => { - const value = process.env[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`Missing required env var for local manager bootstrap: ${key}`); - } - - return value; - }); - writeFileSync(outputPath, rendered, 'utf8'); -} - -writeStateIfMissing(join(scriptDirectory, 'manager-state.json.template'), managerStatePath); - -const settings = loadManagerRuntimeSettings({ - configFilePath: managerConfigPath, -}); -const state = loadManagerStateOrEmpty(settings); -const currentRuntimePlan = buildRuntimePlan(state, settings); -const baseConfigChangedFromTemplate = - typeof state.metadata.lastAppliedBaseConfigHash === 'string' && - state.metadata.lastAppliedBaseConfigHash !== currentRuntimePlan.baseConfigHash; -const bootstrapState = baseConfigChangedFromTemplate - ? resetManagerStateApplyMetadata(state) - : state; - -if (baseConfigChangedFromTemplate) { - saveManagerState(settings.paths.stateFile, bootstrapState); +if (!existsSync(managerStatePath)) { + renderEnvTemplate( + join(scriptDirectory, 'manager-state.json.template'), + managerStatePath, + 'local manager bootstrap', + ); } -await applyManagerState(bootstrapState, { - ...settings, - reload: undefined, -}); +await applyBootstrappedManagerState(managerConfigPath); diff --git a/manager/dev/bootstrap-managed-stack.mjs b/manager/dev/bootstrap-managed-stack.mjs index 380ea25..2379be2 100644 --- a/manager/dev/bootstrap-managed-stack.mjs +++ b/manager/dev/bootstrap-managed-stack.mjs @@ -1,63 +1,24 @@ // SPDX-License-Identifier: GPL-3.0-or-later -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { - applyManagerState, - buildRuntimePlan, - loadManagerRuntimeSettings, - loadManagerStateOrEmpty, - resetManagerStateApplyMetadata, - saveManagerState, -} from '/app/manager/dist/index.js'; +import { existsSync, mkdirSync } from 'node:fs'; +import { applyBootstrappedManagerState, renderEnvTemplate } from './managed-stack.mjs'; +const context = 'managed manager bootstrap'; const runtimeDirectory = '/app/runtime'; -const baseConfigTemplatePath = '/app/server/magic-sso.base.toml.template'; -const baseConfigPath = '/app/runtime/magic-sso.base.toml'; -const managerConfigTemplatePath = '/app/manager/manager.toml.template'; const managerConfigPath = '/app/runtime/manager.toml'; -const managerStateTemplatePath = '/app/manager/manager-state.json.template'; const managerStatePath = '/app/runtime/manager-state.json'; -function renderTemplate(templatePath, outputPath) { - const template = readFileSync(templatePath, 'utf8'); - const rendered = template.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, key) => { - const value = process.env[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`Missing required env var for managed manager bootstrap: ${key}`); - } - - return value; - }); - writeFileSync(outputPath, rendered, 'utf8'); - return rendered; -} - mkdirSync(runtimeDirectory, { recursive: true }); -renderTemplate(baseConfigTemplatePath, baseConfigPath); -renderTemplate(managerConfigTemplatePath, managerConfigPath); +renderEnvTemplate( + '/app/server/magic-sso.base.toml.template', + '/app/runtime/magic-sso.base.toml', + context, +); +renderEnvTemplate('/app/manager/manager.toml.template', managerConfigPath, context); if (!existsSync(managerStatePath)) { - renderTemplate(managerStateTemplatePath, managerStatePath); -} - -const settings = loadManagerRuntimeSettings({ - configFilePath: managerConfigPath, -}); -const state = loadManagerStateOrEmpty(settings); -const currentRuntimePlan = buildRuntimePlan(state, settings); -const baseConfigChangedFromTemplate = - typeof state.metadata.lastAppliedBaseConfigHash === 'string' && - state.metadata.lastAppliedBaseConfigHash !== currentRuntimePlan.baseConfigHash; -const bootstrapState = baseConfigChangedFromTemplate - ? resetManagerStateApplyMetadata(state) - : state; - -if (baseConfigChangedFromTemplate) { - saveManagerState(settings.paths.stateFile, bootstrapState); + renderEnvTemplate('/app/manager/manager-state.json.template', managerStatePath, context); } -await applyManagerState(bootstrapState, { - ...settings, - reload: undefined, -}); +await applyBootstrappedManagerState(managerConfigPath); diff --git a/manager/dev/managed-stack.mjs b/manager/dev/managed-stack.mjs new file mode 100644 index 0000000..78ba71d --- /dev/null +++ b/manager/dev/managed-stack.mjs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import { readFileSync, writeFileSync } from 'node:fs'; +import { + applyManagerState, + buildRuntimePlan, + loadManagerRuntimeSettings, + loadManagerStateOrEmpty, + resetManagerStateApplyMetadata, + saveManagerState, +} from '../dist/index.js'; + +/** + * Render a `${ENV_VAR}` placeholder template with values from `process.env`. + * + * @param {string} templatePath Template file to read. + * @param {string} outputPath Destination file to write. + * @param {string} context Human-readable bootstrap name used in error messages. + * @returns {string} The rendered template contents. + */ +export function renderEnvTemplate(templatePath, outputPath, context) { + const template = readFileSync(templatePath, 'utf8'); + const rendered = template.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, key) => { + const value = process.env[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Missing required env var for ${context}: ${key}`); + } + + return value; + }); + writeFileSync(outputPath, rendered, 'utf8'); + return rendered; +} + +/** + * Load manager state for `managerConfigPath` and apply it once, resetting the + * apply metadata first when the base config template changed underneath us. + * + * @param {string} managerConfigPath Path to the rendered `manager.toml`. + * @returns {Promise} + */ +export async function applyBootstrappedManagerState(managerConfigPath) { + const settings = loadManagerRuntimeSettings({ + configFilePath: managerConfigPath, + }); + const state = loadManagerStateOrEmpty(settings); + const currentRuntimePlan = buildRuntimePlan(state, settings); + const baseConfigChangedFromTemplate = + typeof state.metadata.lastAppliedBaseConfigHash === 'string' && + state.metadata.lastAppliedBaseConfigHash !== currentRuntimePlan.baseConfigHash; + const bootstrapState = baseConfigChangedFromTemplate + ? resetManagerStateApplyMetadata(state) + : state; + + if (baseConfigChangedFromTemplate) { + saveManagerState(settings.paths.stateFile, bootstrapState); + } + + await applyManagerState(bootstrapState, { + ...settings, + reload: undefined, + }); +} diff --git a/manager/docker-compose.yml b/manager/docker-compose.yml index cd8b921..abbf8e6 100644 --- a/manager/docker-compose.yml +++ b/manager/docker-compose.yml @@ -67,12 +67,13 @@ services: restart: unless-stopped volumes: - ./runtime:/app/runtime - - ./dev/bootstrap-managed-stack.mjs:/app/manager/bootstrap-managed-stack.mjs:ro + - ./dev/bootstrap-managed-stack.mjs:/app/manager/dev/bootstrap-managed-stack.mjs:ro + - ./dev/managed-stack.mjs:/app/manager/dev/managed-stack.mjs:ro - ./dev/manager-state.json.template:/app/manager/manager-state.json.template:ro - ./dev/manager.toml.template:/app/manager/manager.toml.template:ro - ./dev/magic-sso.base.toml.template:/app/server/magic-sso.base.toml.template:ro command: > - sh -c "node /app/manager/bootstrap-managed-stack.mjs && node dist/main.js" + sh -c "node /app/manager/dev/bootstrap-managed-stack.mjs && node dist/main.js" depends_on: mailpit: condition: service_started diff --git a/manager/src/app.ts b/manager/src/app.ts index 93caf83..b4027b0 100644 --- a/manager/src/app.ts +++ b/manager/src/app.ts @@ -895,6 +895,61 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + kind: Exclude; + message: string; + nextState: Parameters[1]; + }, + ): { site: ReturnType } { + try { + const { kind, message, nextState } = mutate(); + const persistedState = persistAuditedMutation(request, nextState, { + changedSiteIds: [siteId], + kind, + message, + }); + return { site: getManagedSiteDetails(persistedState, settings, siteId) }; + } catch (error) { + throw mapRouteError(error); + } + } + + /** + * Re-render a site page carrying the failure notice for a rejected form post, + * falling back to the plain failure page when the site page itself cannot be + * rendered (for example when the site no longer exists). + */ + function renderSiteMutationFailure( + reply: FastifyReply, + siteId: string, + error: unknown, + editorState?: ManagerSitePageEditorState, + ): void { + const routeError = mapRouteError(error); + const message = routeError.message; + const statusCode = getErrorStatusCode(routeError, 400); + try { + renderSitePage( + reply, + settings, + siteId, + { kind: 'error', text: message }, + { editorState, statusCode }, + ); + } catch { + renderUiFailure(reply, statusCode, message); + } + } + if (isBearerTokenAuthConfig(settings.service.auth)) { app.get( '/login', @@ -1071,30 +1126,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { const parsedBody = accessReplaceSchema.parse(request.body); assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = replaceSiteGrants( - state, - settings, - request.params.siteId, - parsedBody.grants.map((grant) => ({ - email: grant.email, - scopes: parseScopes(grant.fullAccess, grant.scopes), - })), - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'access-replaced', message: `Replaced all grants on ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: replaceSiteGrants( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + parsedBody.grants.map((grant) => ({ + email: grant.email, + scopes: parseScopes(grant.fullAccess, grant.scopes), + })), + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1769,28 +1749,21 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { const parsedBody = grantInputSchema.parse(request.body); assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = updateSiteGrant( - state, - settings, - request.params.siteId, - parsedBody.email, - parseScopes(parsedBody.fullAccess, parsedBody.scopes), - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'grant-saved', message: `Saved grant for ${parsedBody.email.trim().toLowerCase()} on ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: updateSiteGrant( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + parsedBody.email, + parseScopes(parsedBody.fullAccess, parsedBody.scopes), + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1802,28 +1775,21 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { const parsedBody = grantPatchSchema.parse(request.body); assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = updateSiteGrant( - state, - settings, - request.params.siteId, - request.params.email, - parseScopes(parsedBody.fullAccess, parsedBody.scopes), - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'grant-saved', message: `Saved grant for ${request.params.email.trim().toLowerCase()} on ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: updateSiteGrant( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + request.params.email, + parseScopes(parsedBody.fullAccess, parsedBody.scopes), + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1832,26 +1798,19 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = revokeSiteGrant( - state, - settings, - request.params.siteId, - request.params.email, - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'grant-revoked', message: `Revoked grant for ${request.params.email.trim().toLowerCase()} on ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: revokeSiteGrant( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + request.params.email, + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1863,27 +1822,20 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { const parsedBody = scopesReplaceSchema.parse(request.body); assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = replaceSiteScopes( - state, - settings, - request.params.siteId, - parsedBody.scopes, - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'scope-catalog-replaced', message: `Replaced the scope catalog on ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: replaceSiteScopes( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + parsedBody.scopes, + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1895,27 +1847,20 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { const parsedBody = addScopeSchema.parse(request.body); assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = addSiteScope( - state, - settings, - request.params.siteId, - parsedBody.scope, - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'scope-added', message: `Added scope ${parsedBody.scope.trim()} to ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: addSiteScope( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + parsedBody.scope, + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); @@ -1924,26 +1869,19 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, ): Promise<{ site: ReturnType }> => { - try { + return respondWithSiteMutation(request, request.params.siteId, () => { assertApiEditingAllowed(settings); - const state = loadManagerStateOrEmpty(settings); - const nextState = removeSiteScope( - state, - settings, - request.params.siteId, - request.params.scope, - ); - const persistedState = persistAuditedMutation(request, nextState, { - changedSiteIds: [request.params.siteId], + return { kind: 'scope-removed', message: `Removed scope ${request.params.scope.trim()} from ${request.params.siteId}.`, - }); - return { - site: getManagedSiteDetails(persistedState, settings, request.params.siteId), + nextState: removeSiteScope( + loadManagerStateOrEmpty(settings), + settings, + request.params.siteId, + request.params.scope, + ), }; - } catch (error) { - throw mapRouteError(error); - } + }); }, ); diff --git a/manager/src/deployment.test.ts b/manager/src/deployment.test.ts index 449b5d0..f9c62bc 100644 --- a/manager/src/deployment.test.ts +++ b/manager/src/deployment.test.ts @@ -62,6 +62,7 @@ describe('manager managed-mode deployment example', () => { const localGateTemplate = readManagerFile('dev/magic-gate.local.toml.template'); const bootstrapScript = readManagerFile('dev/bootstrap-managed-stack.mjs'); const localBootstrapScript = readManagerFile('dev/bootstrap-local-managed-stack.mjs'); + const sharedBootstrapModule = readManagerFile('dev/managed-stack.mjs'); expect(baseConfigTemplate).toContain('id = "manager-admin"'); expect(baseConfigTemplate).toContain( @@ -98,17 +99,19 @@ describe('manager managed-mode deployment example', () => { expect(gateTemplate).toContain('upstreamUrl = "${MAGIC_GATE_RENDER_UPSTREAM_URL}"'); expect(localGateTemplate).toContain('port = ${MANAGER_PUBLIC_PORT}'); expect(localGateTemplate).toContain('publicOrigin = "${MAGIC_GATE_RENDER_PUBLIC_ORIGIN}"'); - expect(bootstrapScript).toContain('applyManagerState'); - expect(bootstrapScript).toContain('buildRuntimePlan'); - expect(bootstrapScript).toContain('reload: undefined'); - expect(bootstrapScript).toContain('resetManagerStateApplyMetadata'); - expect(bootstrapScript).toContain('saveManagerState'); - expect(bootstrapScript).toContain( + expect(sharedBootstrapModule).toContain('applyManagerState'); + expect(sharedBootstrapModule).toContain('buildRuntimePlan'); + expect(sharedBootstrapModule).toContain('reload: undefined'); + expect(sharedBootstrapModule).toContain('resetManagerStateApplyMetadata'); + expect(sharedBootstrapModule).toContain('saveManagerState'); + expect(sharedBootstrapModule).toContain( 'lastAppliedBaseConfigHash !== currentRuntimePlan.baseConfigHash', ); + expect(bootstrapScript).toContain('applyBootstrappedManagerState'); + expect(bootstrapScript).toContain("from './managed-stack.mjs'"); expect(localBootstrapScript).toContain('process.env.MAGICSSO_MANAGER_CONFIG_FILE'); - expect(localBootstrapScript).toContain('resetManagerStateApplyMetadata'); - expect(localBootstrapScript).toContain('reload: undefined'); + expect(localBootstrapScript).toContain('applyBootstrappedManagerState'); + expect(localBootstrapScript).toContain("from './managed-stack.mjs'"); }); it('ships a published-image production manager stack', () => { diff --git a/packages/angular/src/lib/core.ts b/packages/angular/src/lib/core.ts index 66beb84..be99ec7 100644 --- a/packages/angular/src/lib/core.ts +++ b/packages/angular/src/lib/core.ts @@ -2,14 +2,15 @@ // Copyright (C) 2026 Wojciech Polak import { + parseBooleanFlag, + type AuthPayload as CoreAuthPayload, buildAuthCookieOptions as buildCoreAuthCookieOptions, buildLoginTarget as buildCoreLoginTarget, buildVerifyUrl as buildCoreVerifyUrl, - exchangeEmailOtp as exchangeCoreEmailOtp, normaliseReturnUrl as normaliseCoreReturnUrl, readCookieValue, - verifyAuthToken as verifyCoreAuthToken, - type AuthPayload as CoreAuthPayload, + verifyAuthTokenWithOptionalIssuer as verifyCoreAuthTokenWithOptionalIssuer, + exchangeEmailOtp as exchangeCoreEmailOtp, } from '@magic-link-sso/core'; export type AuthPayload = CoreAuthPayload; @@ -92,27 +93,7 @@ function readEnvString(name: string): string | undefined { } function readBoolean(value: boolean | string | undefined, fallback: boolean): boolean { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return fallback; - } - } - - return fallback; + return parseBooleanFlag(value, fallback); } function readPositiveInteger(value: number | string | undefined): number | undefined { @@ -194,12 +175,7 @@ export async function verifyAuthToken( secret: Uint8Array, options: VerifyAuthTokenOptions, ): Promise { - return typeof options.expectedIssuer === 'string' - ? verifyCoreAuthToken(token, secret, { - expectedAudience: options.expectedAudience, - expectedIssuer: options.expectedIssuer, - }) - : null; + return verifyCoreAuthTokenWithOptionalIssuer(token, secret, options); } export async function exchangeEmailOtp( diff --git a/packages/config-core/src/index.ts b/packages/config-core/src/index.ts index 552a484..95851e6 100644 --- a/packages/config-core/src/index.ts +++ b/packages/config-core/src/index.ts @@ -617,20 +617,36 @@ const placeholderSecretsByField = new Map>([ ['auth.otp.secret', new Set(['replace-me-with-a-dedicated-long-random-otp-secret'])], ]); -function parseConfiguredSecret(value: string, fieldName: string): string { +/** Shortest secret any Magic Link SSO component accepts. */ +export const MIN_CONFIGURED_SECRET_LENGTH = MIN_SECRET_LENGTH; + +/** + * Validate a configured secret: long enough, and not one of the placeholder + * values the shipped example configs use for `fieldName`. + */ +export function validateConfiguredSecret( + value: string, + fieldName: string, + placeholderValues?: ReadonlySet, +): string { const trimmedValue = value.trim(); - if (trimmedValue.length < MIN_SECRET_LENGTH) { - throw new Error(`${fieldName} must be at least ${MIN_SECRET_LENGTH} characters long.`); + if (trimmedValue.length < MIN_CONFIGURED_SECRET_LENGTH) { + throw new Error( + `${fieldName} must be at least ${MIN_CONFIGURED_SECRET_LENGTH} characters long.`, + ); } - const placeholderValues = placeholderSecretsByField.get(fieldName); - if (placeholderValues?.has(trimmedValue)) { + if (placeholderValues?.has(trimmedValue) === true) { throw new Error(`${fieldName} must be replaced with a real secret value.`); } return value; } +function parseConfiguredSecret(value: string, fieldName: string): string { + return validateConfiguredSecret(value, fieldName, placeholderSecretsByField.get(fieldName)); +} + function validateDistinctSecrets( jwtSecret: string, emailSecret: string, @@ -964,6 +980,33 @@ function parseHostedAuthPathOrUrl( ); } +/** + * Overlay a copy section's string overrides on top of its defaults. Only keys the + * defaults declare are considered, so an unknown override key cannot leak into + * the resolved copy. + */ +function mergeCopySection( + defaults: TSection, + overrides: Partial> | undefined, +): TSection { + if (typeof overrides === 'undefined') { + return defaults; + } + + const merged: TSection = { ...defaults }; + // Every copy section is a flat record of strings, so a present override is + // always the right type for its key; TypeScript cannot narrow that through + // the generic key. + for (const key of Object.keys(defaults) as (keyof TSection)[]) { + const override = overrides[key]; + if (typeof override === 'string') { + merged[key] = override as TSection[keyof TSection]; + } + } + + return merged; +} + function resolveHostedAuthPageCopy( value: unknown, defaults: HostedAuthPageCopy, @@ -980,65 +1023,9 @@ function resolveHostedAuthPageCopy( return { lang: parsedCopy.data.lang ?? defaults.lang, - signin: { - confirmationHelpText: - parsedCopy.data.signin?.confirmationHelpText ?? - defaults.signin.confirmationHelpText, - confirmationPageTitle: - parsedCopy.data.signin?.confirmationPageTitle ?? - defaults.signin.confirmationPageTitle, - confirmationTitle: - parsedCopy.data.signin?.confirmationTitle ?? defaults.signin.confirmationTitle, - emailLabel: parsedCopy.data.signin?.emailLabel ?? defaults.signin.emailLabel, - emailPlaceholder: - parsedCopy.data.signin?.emailPlaceholder ?? defaults.signin.emailPlaceholder, - helpText: parsedCopy.data.signin?.helpText ?? defaults.signin.helpText, - otpHelpText: parsedCopy.data.signin?.otpHelpText ?? defaults.signin.otpHelpText, - otpLabel: parsedCopy.data.signin?.otpLabel ?? defaults.signin.otpLabel, - otpPlaceholder: - parsedCopy.data.signin?.otpPlaceholder ?? defaults.signin.otpPlaceholder, - otpSubmitButton: - parsedCopy.data.signin?.otpSubmitButton ?? defaults.signin.otpSubmitButton, - pageTitle: parsedCopy.data.signin?.pageTitle ?? defaults.signin.pageTitle, - skipLink: parsedCopy.data.signin?.skipLink ?? defaults.signin.skipLink, - submitButton: parsedCopy.data.signin?.submitButton ?? defaults.signin.submitButton, - title: parsedCopy.data.signin?.title ?? defaults.signin.title, - useDifferentEmailButton: - parsedCopy.data.signin?.useDifferentEmailButton ?? - defaults.signin.useDifferentEmailButton, - }, - verifyEmail: { - continueButton: - parsedCopy.data.verifyEmail?.continueButton ?? defaults.verifyEmail.continueButton, - emailLabel: parsedCopy.data.verifyEmail?.emailLabel ?? defaults.verifyEmail.emailLabel, - helpText: parsedCopy.data.verifyEmail?.helpText ?? defaults.verifyEmail.helpText, - pageTitle: parsedCopy.data.verifyEmail?.pageTitle ?? defaults.verifyEmail.pageTitle, - title: parsedCopy.data.verifyEmail?.title ?? defaults.verifyEmail.title, - }, - feedback: { - failedToSendEmail: - parsedCopy.data.feedback?.failedToSendEmail ?? defaults.feedback.failedToSendEmail, - forbidden: parsedCopy.data.feedback?.forbidden ?? defaults.feedback.forbidden, - invalidOrExpiredToken: - parsedCopy.data.feedback?.invalidOrExpiredToken ?? - defaults.feedback.invalidOrExpiredToken, - invalidOrExpiredOtp: - parsedCopy.data.feedback?.invalidOrExpiredOtp ?? - defaults.feedback.invalidOrExpiredOtp, - invalidOrUntrustedReturnUrl: - parsedCopy.data.feedback?.invalidOrUntrustedReturnUrl ?? - defaults.feedback.invalidOrUntrustedReturnUrl, - invalidOrUntrustedVerifyUrl: - parsedCopy.data.feedback?.invalidOrUntrustedVerifyUrl ?? - defaults.feedback.invalidOrUntrustedVerifyUrl, - invalidRequest: - parsedCopy.data.feedback?.invalidRequest ?? defaults.feedback.invalidRequest, - tooManyRequests: - parsedCopy.data.feedback?.tooManyRequests ?? defaults.feedback.tooManyRequests, - verificationEmailSent: - parsedCopy.data.feedback?.verificationEmailSent ?? - defaults.feedback.verificationEmailSent, - }, + signin: mergeCopySection(defaults.signin, parsedCopy.data.signin), + verifyEmail: mergeCopySection(defaults.verifyEmail, parsedCopy.data.verifyEmail), + feedback: mergeCopySection(defaults.feedback, parsedCopy.data.feedback), }; } @@ -1123,57 +1110,40 @@ export function stringifyMagicSsoTomlConfig(config: MagicSsoTomlConfig): string return stringifyToml(config); } -export function parseMagicSsoConfigToml(fileContents: string, filePath: string): AppConfig { - const parsedConfig = parseMagicSsoTomlConfig(fileContents, filePath); +type ConfiguredSite = AppConfig['sites'][number]; - const serverConfig = parsedConfig.server ?? {}; - const cookieConfig = parsedConfig.cookie ?? {}; - const rateLimitConfig = parsedConfig.rateLimit ?? {}; - const reloadConfig = serverConfig.reload; - const securityStateConfig = serverConfig.securityState ?? {}; - const appUrl = new URL(serverConfig.appUrl ?? 'http://localhost:3000'); - const hostedAuthCopy = resolveHostedAuthPageCopy( - parsedConfig.hostedAuth.copy, - createDefaultHostedAuthPageCopy(), - 'hostedAuth.copy', - ); - const hostedAuthBranding = resolveHostedAuthBranding( - parsedConfig.hostedAuth.branding, - createDefaultHostedAuthBranding(), - 'hostedAuth.branding', - ); - const jwtSecret = parseConfiguredSecret(parsedConfig.auth.jwtSecret, 'auth.jwtSecret'); - const emailSecret = parseConfiguredSecret(parsedConfig.auth.emailSecret, 'auth.emailSecret'); - const csrfSecret = parseConfiguredSecret(parsedConfig.auth.csrfSecret, 'auth.csrfSecret'); - const previewSecret = parseConfiguredSecret( - parsedConfig.auth.previewSecret, - 'auth.previewSecret', - ); - const otpSecret = - typeof parsedConfig.auth.otp.secret === 'string' - ? parseConfiguredSecret(parsedConfig.auth.otp.secret, 'auth.otp.secret') - : undefined; - const otpExpirationSeconds = parseDurationToSeconds(parsedConfig.auth.otp.expiration); - const emailExpirationSeconds = parseDurationToSeconds(parsedConfig.auth.emailExpiration); - if (parsedConfig.auth.otp.enabled && otpExpirationSeconds > emailExpirationSeconds) { - throw new Error('auth.otp.expiration must not exceed auth.emailExpiration.'); +type RawSecurityStateConfig = NonNullable< + NonNullable['securityState'] +>; + +/** Resolve the security-state adapter, validating the Redis URL when required. */ +function resolveSecurityStateConfig( + securityStateConfig: Partial, +): AppConfig['securityState'] { + const adapter = securityStateConfig.adapter ?? 'file'; + if (adapter === 'redis' && typeof securityStateConfig.redisUrl !== 'string') { + throw new Error( + 'server.securityState.redisUrl must be configured when server.securityState.adapter = "redis".', + ); } - const securityStateAdapter = securityStateConfig.adapter ?? 'file'; - const securityStateRedisUrl = - securityStateAdapter === 'redis' - ? parseRedisUrl( - securityStateConfig.redisUrl ?? - (() => { - throw new Error( - 'server.securityState.redisUrl must be configured when server.securityState.adapter = "redis".', - ); - })(), - 'server.securityState.redisUrl', - ) - : undefined; - validateDistinctSecrets(jwtSecret, emailSecret, csrfSecret, previewSecret, otpSecret); - const sites = parsedConfig.sites.map((site) => ({ + return { + adapter, + keyPrefix: securityStateConfig.keyPrefix ?? 'magic-sso', + redisUrl: + adapter === 'redis' + ? parseRedisUrl(securityStateConfig.redisUrl ?? '', 'server.securityState.redisUrl') + : undefined, + }; +} + +/** Resolve one `[[sites]]` entry against the server-wide hosted-auth defaults. */ +function buildConfiguredSite( + site: MagicSsoTomlConfig['sites'][number], + hostedAuthCopy: HostedAuthPageCopy, + hostedAuthBranding: HostedAuthBranding, +): ConfiguredSite { + return { id: site.id, origins: parseOrigins(site.origins, `sites[${site.id}].origins`), allowedRedirectUris: parseAllowedRedirectUris( @@ -1191,10 +1161,14 @@ export function parseMagicSsoConfigToml(fileContents: string, filePath: string): hostedAuthBranding, `sites[${site.id}].hostedAuth.branding`, ), - })); + }; +} +/** Reject duplicate site ids, shared origins, and off-origin redirect URIs. */ +function assertSitesAreConsistent(sites: readonly ConfiguredSite[]): void { const siteIds = new Set(); const siteOriginOwners = new Map(); + for (const site of sites) { if (siteIds.has(site.id)) { throw new Error(`sites contains a duplicate id: ${site.id}`); @@ -1220,6 +1194,50 @@ export function parseMagicSsoConfigToml(fileContents: string, filePath: string): } } } +} + +export function parseMagicSsoConfigToml(fileContents: string, filePath: string): AppConfig { + const parsedConfig = parseMagicSsoTomlConfig(fileContents, filePath); + + const serverConfig = parsedConfig.server ?? {}; + const cookieConfig = parsedConfig.cookie ?? {}; + const rateLimitConfig = parsedConfig.rateLimit ?? {}; + const reloadConfig = serverConfig.reload; + const securityStateConfig = serverConfig.securityState ?? {}; + const appUrl = new URL(serverConfig.appUrl ?? 'http://localhost:3000'); + const hostedAuthCopy = resolveHostedAuthPageCopy( + parsedConfig.hostedAuth.copy, + createDefaultHostedAuthPageCopy(), + 'hostedAuth.copy', + ); + const hostedAuthBranding = resolveHostedAuthBranding( + parsedConfig.hostedAuth.branding, + createDefaultHostedAuthBranding(), + 'hostedAuth.branding', + ); + const jwtSecret = parseConfiguredSecret(parsedConfig.auth.jwtSecret, 'auth.jwtSecret'); + const emailSecret = parseConfiguredSecret(parsedConfig.auth.emailSecret, 'auth.emailSecret'); + const csrfSecret = parseConfiguredSecret(parsedConfig.auth.csrfSecret, 'auth.csrfSecret'); + const previewSecret = parseConfiguredSecret( + parsedConfig.auth.previewSecret, + 'auth.previewSecret', + ); + const otpSecret = + typeof parsedConfig.auth.otp.secret === 'string' + ? parseConfiguredSecret(parsedConfig.auth.otp.secret, 'auth.otp.secret') + : undefined; + const otpExpirationSeconds = parseDurationToSeconds(parsedConfig.auth.otp.expiration); + const emailExpirationSeconds = parseDurationToSeconds(parsedConfig.auth.emailExpiration); + if (parsedConfig.auth.otp.enabled && otpExpirationSeconds > emailExpirationSeconds) { + throw new Error('auth.otp.expiration must not exceed auth.emailExpiration.'); + } + const securityState = resolveSecurityStateConfig(securityStateConfig); + validateDistinctSecrets(jwtSecret, emailSecret, csrfSecret, previewSecret, otpSecret); + + const sites = parsedConfig.sites.map((site) => + buildConfiguredSite(site, hostedAuthCopy, hostedAuthBranding), + ); + assertSitesAreConsistent(sites); return { appPort: serverConfig.appPort ?? 3000, @@ -1264,11 +1282,7 @@ export function parseMagicSsoConfigToml(fileContents: string, filePath: string): : { secret: parseConfiguredSecret(reloadConfig.secret, 'server.reload.secret'), }, - securityState: { - adapter: securityStateAdapter, - keyPrefix: securityStateConfig.keyPrefix ?? 'magic-sso', - redisUrl: securityStateRedisUrl, - }, + securityState, serveRootLandingPage: serverConfig.serveRootLandingPage ?? true, signInEmailRateLimitMax: rateLimitConfig.signInEmailMax ?? 5, signInEmailRateLimitStoreDir: diff --git a/packages/config-core/src/runtime.ts b/packages/config-core/src/runtime.ts index e41a484..abdd372 100644 --- a/packages/config-core/src/runtime.ts +++ b/packages/config-core/src/runtime.ts @@ -1,5 +1,5 @@ import { timingSafeEqual } from 'node:crypto'; -export { readCookieValue, type ReadCookieValueOptions } from '@magic-link-sso/core'; +export { escapeHtml, readCookieValue, type ReadCookieValueOptions } from '@magic-link-sso/core'; export function safeCompare(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); @@ -10,12 +10,3 @@ export function safeCompare(left: string, right: string): boolean { return timingSafeEqual(leftBuffer, rightBuffer); } - -export function escapeHtml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} diff --git a/packages/core/src/flags.ts b/packages/core/src/flags.ts new file mode 100644 index 0000000..e7a438e --- /dev/null +++ b/packages/core/src/flags.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +const TRUTHY_FLAG_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSY_FLAG_VALUES = new Set(['0', 'false', 'no', 'off']); + +/** + * Interpret an environment variable or runtime-config entry as a boolean flag. + * + * Booleans pass through; strings are matched case-insensitively against the + * accepted spellings (`1`/`true`/`yes`/`on` and `0`/`false`/`no`/`off`). + * Anything else, including an unrecognised spelling, answers `fallback`. + */ +export function parseBooleanFlag(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') { + return value; + } + if (typeof value !== 'string') { + return fallback; + } + + const normalised = value.trim().toLowerCase(); + if (TRUTHY_FLAG_VALUES.has(normalised)) { + return true; + } + if (FALSY_FLAG_VALUES.has(normalised)) { + return false; + } + + return fallback; +} diff --git a/packages/core/src/html.ts b/packages/core/src/html.ts new file mode 100644 index 0000000..fb3e47d --- /dev/null +++ b/packages/core/src/html.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +/** + * Escape the five characters that change meaning inside HTML text nodes and + * double- or single-quoted attribute values, so untrusted strings can be + * interpolated into a server-rendered template. + */ +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 545597a..e16f3d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,9 +3,12 @@ export { buildAuthCookieOptions, readCookieValue } from './cookie.js'; export { MagicSsoConfigurationError } from './errors.js'; +export { parseBooleanFlag } from './flags.js'; +export { isVerifyEmailPreviewResponse, isVerifyEmailResponse } from './verify-email.js'; +export { escapeHtml } from './html.js'; export { exchangeEmailOtp } from './otp.js'; export { buildLoginTarget, buildVerifyUrl, normaliseReturnUrl } from './return-url.js'; -export { toSecretKey, verifyAuthToken } from './token.js'; +export { toSecretKey, verifyAuthToken, verifyAuthTokenWithOptionalIssuer } from './token.js'; export type { AuthCookieOptions, AuthPayload, @@ -22,4 +25,7 @@ export type { NormaliseReturnUrlOptions, ReadCookieValueOptions, VerifyAuthTokenOptions, + VerifyEmailPreviewResponse, + VerifyEmailResponse, + VerifyOptionalIssuerOptions, } from './types.js'; diff --git a/packages/core/src/token.ts b/packages/core/src/token.ts index fb4a276..1299aa2 100644 --- a/packages/core/src/token.ts +++ b/packages/core/src/token.ts @@ -3,7 +3,7 @@ import { jwtVerify, type JWTPayload } from 'jose'; import { MagicSsoConfigurationError } from './errors.js'; -import type { AuthPayload, VerifyAuthTokenOptions } from './types.js'; +import type { AuthPayload, VerifyAuthTokenOptions, VerifyOptionalIssuerOptions } from './types.js'; function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; @@ -68,3 +68,23 @@ export async function verifyAuthToken( return null; } } + +/** + * Verify a session token only when the caller could resolve an expected issuer. + * + * Framework adapters derive the issuer from configuration that may be missing, + * and a token must never be accepted without one, so an absent issuer rejects + * instead of verifying against whatever the token claims. + */ +export async function verifyAuthTokenWithOptionalIssuer( + token: string, + secret: Uint8Array, + options: VerifyOptionalIssuerOptions, +): Promise { + return typeof options.expectedIssuer === 'string' + ? verifyAuthToken(token, secret, { + expectedAudience: options.expectedAudience, + expectedIssuer: options.expectedIssuer, + }) + : null; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7c4124e..7db28b9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -19,6 +19,12 @@ export interface VerifyAuthTokenOptions { expectedIssuer: string; } +export interface VerifyOptionalIssuerOptions { + expectedAudience: string; + /** Omitted or `undefined` when the framework adapter could not resolve one. */ + expectedIssuer?: string | undefined; +} + export interface AuthCookieOptions { httpOnly: true; maxAge?: number; @@ -103,3 +109,13 @@ export interface BuildLoginTargetOptions { scope?: string; serverUrl?: string; } + +/** Body of a successful `POST /verify-email` exchange. */ +export interface VerifyEmailResponse { + accessToken: string; +} + +/** Body of a successful `GET /verify-email` preview request. */ +export interface VerifyEmailPreviewResponse { + email: string; +} diff --git a/packages/core/src/verify-email.ts b/packages/core/src/verify-email.ts new file mode 100644 index 0000000..4c85c89 --- /dev/null +++ b/packages/core/src/verify-email.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +import type { VerifyEmailPreviewResponse, VerifyEmailResponse } from './types.js'; + +/** Narrow a `POST /verify-email` body to the session token the server issued. */ +export function isVerifyEmailResponse(value: unknown): value is VerifyEmailResponse { + return ( + typeof value === 'object' && + value !== null && + 'accessToken' in value && + typeof value.accessToken === 'string' && + value.accessToken.length > 0 + ); +} + +/** Narrow a `GET /verify-email` preview body to the recipient it describes. */ +export function isVerifyEmailPreviewResponse(value: unknown): value is VerifyEmailPreviewResponse { + return ( + typeof value === 'object' && + value !== null && + 'email' in value && + typeof value.email === 'string' && + value.email.length > 0 + ); +} diff --git a/packages/example-ui/env-file.d.ts b/packages/example-ui/env-file.d.ts new file mode 100644 index 0000000..656d1b2 --- /dev/null +++ b/packages/example-ui/env-file.d.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +export declare function mergeEnvContent( + sourceContent: string, + targetContent: string, + generatedPlaceholderValues: ReadonlyMap>, +): string; +export declare function ensureEnvFile( + sourcePath: string | URL, + targetPath: string | URL, + generatedPlaceholderValues: ReadonlyMap>, +): Promise; + +export declare function bootstrapExampleEnvFile(options: { + envPath: string; + examplePath: string; + generatedPlaceholderValues: ReadonlyMap>; + moduleUrl: string; +}): Promise<(sourcePath?: string | URL, targetPath?: string | URL) => Promise>; diff --git a/packages/example-ui/env-file.js b/packages/example-ui/env-file.js new file mode 100644 index 0000000..698c96a --- /dev/null +++ b/packages/example-ui/env-file.js @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +import { access, copyFile, readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +/** + * Bootstrap helper shared by the example apps' `scripts/ensure-env.mjs`. Each + * app owns its own file names and placeholder values; the merge rules live here. + */ + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u; + +async function fileExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function parseEnvKeys(content) { + const keys = new Set(); + for (const line of content.split('\n')) { + const match = line.match(ENV_ASSIGNMENT); + if (match) { + keys.add(match[1]); + } + } + + return keys; +} + +function parseEnvValues(content) { + const values = new Map(); + for (const line of content.split('\n')) { + const match = line.match(ENV_ASSIGNMENT); + if (match) { + values.set(match[1], match[2]); + } + } + + return values; +} + +/** + * Merge the example env file into an existing one: refresh values still left at + * a known placeholder and append any keys the target is missing. + * + * @param {string} sourceContent Contents of the committed example env file. + * @param {string} targetContent Contents of the developer's env file. + * @param {ReadonlyMap>} generatedPlaceholderValues + * Placeholder values, per key, that may be replaced from the example file. + * @returns {string} The merged contents, or `targetContent` when unchanged. + */ +export function mergeEnvContent(sourceContent, targetContent, generatedPlaceholderValues) { + const sourceValues = parseEnvValues(sourceContent); + const targetKeys = parseEnvKeys(targetContent); + let changed = false; + const targetLines = targetContent.split('\n').map((line) => { + const match = line.match(ENV_ASSIGNMENT); + if (!match) { + return line; + } + + const [, key, value] = match; + const placeholderValues = generatedPlaceholderValues.get(key); + const sourceValue = sourceValues.get(key); + if ( + placeholderValues !== undefined && + sourceValue !== undefined && + placeholderValues.has(value) && + sourceValue !== value + ) { + changed = true; + return `${key}=${sourceValue}`; + } + + return line; + }); + const missingLines = sourceContent.split('\n').filter((line) => { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/u); + return match ? !targetKeys.has(match[1]) : false; + }); + + if (missingLines.length > 0) { + changed = true; + } + + return changed + ? `${targetLines.join('\n').replace(/\n?$/u, '\n')}${missingLines.join('\n')}${ + missingLines.length > 0 ? '\n' : '' + }` + : targetContent; +} + +/** + * Create the app's env file from its example, or merge new keys and refreshed + * placeholder values into an existing one. + * + * @param {string | URL} sourcePath Committed example env file. + * @param {string | URL} targetPath Developer env file to create or update. + * @param {ReadonlyMap>} generatedPlaceholderValues + * @returns {Promise} + */ +export async function ensureEnvFile(sourcePath, targetPath, generatedPlaceholderValues) { + if (!(await fileExists(targetPath))) { + await copyFile(sourcePath, targetPath); + return; + } + + const [sourceContent, targetContent] = await Promise.all([ + readFile(sourcePath, 'utf8'), + readFile(targetPath, 'utf8'), + ]); + const mergedContent = mergeEnvContent(sourceContent, targetContent, generatedPlaceholderValues); + if (mergedContent === targetContent) { + return; + } + + await writeFile(targetPath, mergedContent, 'utf8'); +} + +/** + * Wire up one example app's env bootstrap: bind its example/env file names and + * placeholder values, and run the bootstrap immediately when the module was + * invoked as a script rather than imported by a test. + * + * @param {{ + * envPath: string, + * examplePath: string, + * generatedPlaceholderValues: ReadonlyMap>, + * moduleUrl: string, + * }} options Paths are resolved relative to `moduleUrl`. + * @returns {Promise<(sourcePath?: string | URL, targetPath?: string | URL) => Promise>} + */ +export async function bootstrapExampleEnvFile(options) { + const defaultSourcePath = new URL(options.examplePath, options.moduleUrl); + const defaultTargetPath = new URL(options.envPath, options.moduleUrl); + const ensure = async (sourcePath = defaultSourcePath, targetPath = defaultTargetPath) => { + await ensureEnvFile(sourcePath, targetPath, options.generatedPlaceholderValues); + }; + + const executedPath = process.argv[1]; + if ( + typeof executedPath === 'string' && + pathToFileURL(executedPath).href === options.moduleUrl + ) { + await ensure(); + } + + return ensure; +} diff --git a/packages/example-ui/package.json b/packages/example-ui/package.json index 79cb540..76628ab 100644 --- a/packages/example-ui/package.json +++ b/packages/example-ui/package.json @@ -5,8 +5,12 @@ "description": "Shared UI assets for Magic Link SSO TypeScript examples", "files": [ "assets", + "env-file.d.ts", + "env-file.js", "index.d.ts", "index.js", + "signin.d.ts", + "signin.js", "styles.css" ], "type": "module", @@ -15,6 +19,14 @@ "types": "./index.d.ts", "default": "./index.js" }, + "./env-file": { + "types": "./env-file.d.ts", + "default": "./env-file.js" + }, + "./signin": { + "types": "./signin.d.ts", + "default": "./signin.js" + }, "./styles.css": "./styles.css", "./assets/*": { "types": "./svg.d.ts", @@ -22,9 +34,9 @@ } }, "scripts": { - "format": "oxfmt package.json index.js index.d.ts index.test.ts svg.d.ts", - "format:check": "oxfmt --check package.json index.js index.d.ts index.test.ts svg.d.ts", - "lint": "oxlint index.js index.test.ts", + "format": "oxfmt package.json index.js index.d.ts index.test.ts env-file.js env-file.d.ts signin.js signin.d.ts svg.d.ts", + "format:check": "oxfmt --check package.json index.js index.d.ts index.test.ts env-file.js env-file.d.ts signin.js signin.d.ts svg.d.ts", + "lint": "oxlint index.js index.test.ts env-file.js signin.js", "test": "vitest run" }, "devDependencies": { diff --git a/packages/example-ui/signin.d.ts b/packages/example-ui/signin.d.ts new file mode 100644 index 0000000..1e144f4 --- /dev/null +++ b/packages/example-ui/signin.d.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +export declare function readMessage(value: unknown): string | null; +export declare function buildFailureResult( + payload: unknown, + fallback?: string, +): { message: string }; +export declare function readServerUrlConfigError( + serverUrl: string, + requestOrigin: string, + appName: string, +): string | null; diff --git a/packages/example-ui/signin.js b/packages/example-ui/signin.js new file mode 100644 index 0000000..271cad5 --- /dev/null +++ b/packages/example-ui/signin.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +/** + * Sign-in response helpers shared by the TypeScript example apps. Each example + * re-exports these from its own `signin-utils` module so the framework-specific + * wording stays local while the parsing rules stay in one place. + */ + +/** + * Read a non-empty `message` string out of an arbitrary JSON response body. + * + * @param {unknown} value Parsed response body. + * @returns {string | null} The message, or `null` when there is none. + */ +export function readMessage(value) { + if (typeof value !== 'object' || value === null) { + return null; + } + + const message = /** @type {Record} */ (value)['message']; + return typeof message === 'string' && message.length > 0 ? message : null; +} + +/** + * Build a failure payload, preferring the server's own message. + * + * @param {unknown} payload Parsed response body. + * @param {string} [fallback] Message to use when the body carries none. + * @returns {{ message: string }} + */ +export function buildFailureResult(payload, fallback = 'Failed to send verification email.') { + return { + message: readMessage(payload) ?? fallback, + }; +} + +/** + * Explain why `serverUrl` cannot be the Magic Link SSO server for this app. + * + * @param {string} serverUrl Value of `MAGICSSO_SERVER_URL`. + * @param {string} requestOrigin Origin the example app itself is serving from. + * @param {string} appName Framework name used in the error message. + * @returns {string | null} The misconfiguration message, or `null` when valid. + */ +export function readServerUrlConfigError(serverUrl, requestOrigin, appName) { + try { + const parsedServerUrl = new URL(serverUrl); + if (parsedServerUrl.origin === requestOrigin) { + return `MAGICSSO_SERVER_URL points to this ${appName} app. Set it to the Magic Link SSO server, usually http://localhost:3000 for local development.`; + } + } catch { + return 'MAGICSSO_SERVER_URL must be an absolute URL.'; + } + + return null; +} diff --git a/packages/nextjs/src/authMiddleware.ts b/packages/nextjs/src/authMiddleware.ts index 70c45d8..9546263 100644 --- a/packages/nextjs/src/authMiddleware.ts +++ b/packages/nextjs/src/authMiddleware.ts @@ -28,7 +28,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -import { buildLoginTarget as buildCoreLoginTarget } from '@magic-link-sso/core'; +import { buildLoginTarget as buildCoreLoginTarget, parseBooleanFlag } from '@magic-link-sso/core'; import { getCookieName, getCookiePath, @@ -36,6 +36,7 @@ import { getPublicOrigin, getServerIssuer, isTrustProxyEnabled, + readFirstHeaderValue, verifyAuthToken, } from './lib/auth'; @@ -53,36 +54,6 @@ export interface AuthMiddlewareOptions { excludedPaths?: readonly string[]; } -function isDirectUseEnabled(value: string | undefined): boolean { - if (typeof value !== 'string') { - return false; - } - - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return false; - } -} - -function readFirstHeaderValue(value: string | null): string | null { - if (typeof value !== 'string' || value.length === 0) { - return null; - } - - const [firstValue] = value.split(',', 1); - return typeof firstValue === 'string' && firstValue.length > 0 ? firstValue.trim() : null; -} - export function getExcludedPaths(options?: AuthMiddlewareOptions): readonly string[] { const configuredPaths = options?.excludedPaths; if (typeof configuredPaths === 'undefined') { @@ -132,7 +103,7 @@ function buildLoginUrlWithError( ): URL { const target = buildCoreLoginTarget({ appOrigin: request.nextUrl.origin, - directUse: isDirectUseEnabled(process.env.MAGICSSO_DIRECT_USE), + directUse: parseBooleanFlag(process.env.MAGICSSO_DIRECT_USE), returnUrl: pathname, ...(typeof scope === 'string' ? { scope } : {}), ...(typeof process.env.MAGICSSO_SERVER_URL === 'string' diff --git a/packages/nextjs/src/components/login/actions.ts b/packages/nextjs/src/components/login/actions.ts index 63ab487..c30a96e 100644 --- a/packages/nextjs/src/components/login/actions.ts +++ b/packages/nextjs/src/components/login/actions.ts @@ -48,6 +48,14 @@ export type SendMagicLinkResult = export type VerifyEmailOtpResult = { success: true } | { message: string; success: false }; +export interface SendMagicLinkOptions { + /** + * Absolute URL the SSO server should send the recipient to for confirmation + * instead of its own hosted verify page. + */ + verifyUrl?: string; +} + interface ErrorMessageResponse { message?: string; } @@ -85,6 +93,7 @@ export async function sendMagicLink( email: string, returnUrl: string, scope?: string, + options?: SendMagicLinkOptions, ): Promise { const serverUrl = process.env.MAGICSSO_SERVER_URL; if (typeof serverUrl !== 'string' || serverUrl.length === 0) { @@ -103,6 +112,9 @@ export async function sendMagicLink( email, returnUrl, ...(normalizedScope.length > 0 ? { scope: normalizedScope } : {}), + ...(typeof options?.verifyUrl === 'string' && options.verifyUrl.length > 0 + ? { verifyUrl: options.verifyUrl } + : {}), }), cache: 'no-store', }); diff --git a/packages/nextjs/src/components/verifyEmail/route.test.ts b/packages/nextjs/src/components/verifyEmail/route.test.ts new file mode 100644 index 0000000..64ee6c6 --- /dev/null +++ b/packages/nextjs/src/components/verifyEmail/route.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Copyright (C) 2026 Wojciech Polak + +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { VerifyEmailRoute } from './route'; + +const serverUrl = 'https://sso.example.com'; + +function createGetRequest(url: string): NextRequest { + return new NextRequest(url, { method: 'GET' }); +} + +describe('VerifyEmailRoute', () => { + beforeEach(() => { + process.env.MAGICSSO_SERVER_URL = serverUrl; + process.env.MAGICSSO_PREVIEW_SECRET = 'preview-secret'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.MAGICSSO_SERVER_URL; + delete process.env.MAGICSSO_PREVIEW_SECRET; + }); + + it('redirects to the login page when the magic link carries no token', async () => { + const response = await VerifyEmailRoute( + createGetRequest('https://app.example.com/verify-email?returnUrl=/albums'), + ); + + expect(response.status).toBe(307); + const location = new URL(response.headers.get('location') ?? ''); + expect(location.pathname).toBe('/login'); + expect(location.searchParams.get('error')).toBe('missing-verification-token'); + expect(location.searchParams.get('returnUrl')).toBe('/albums'); + }); + + it('renders a confirmation page that posts the token back from a cookie', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ email: 'user@example.com' }), + ); + + const response = await VerifyEmailRoute( + createGetRequest('https://app.example.com/verify-email?token=abc&returnUrl=/albums'), + { pageTitle: 'Confirm Sign In | Example' }, + ); + const html = await response.text(); + + expect(response.headers.get('content-type')).toContain('text/html'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(html).toContain('Confirm Sign In | Example'); + expect(html).toContain('Continue sign-in'); + expect(html).toContain('id="email-value"'); + expect(html).toContain('user@example.com'); + expect(html).toContain('@media (prefers-color-scheme: dark)'); + // The one-time token travels in an HttpOnly cookie, never in the form. + expect(html).not.toContain('name="token"'); + expect(response.cookies.get('magic-sso-verify-token')?.value).toBe('abc'); + expect(response.cookies.get('magic-sso-verify-csrf')?.value).toBeTruthy(); + }); + + it('reads the preview secret header when previewing the email address', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({ email: 'user@example.com' })); + + await VerifyEmailRoute(createGetRequest('https://app.example.com/verify-email?token=abc')); + + const [requestUrl, init] = fetchMock.mock.calls[0] ?? []; + expect(String(requestUrl)).toBe(`${serverUrl}/verify-email?token=abc`); + expect(new Headers(init?.headers).get('x-magic-sso-preview-secret')).toBe('preview-secret'); + }); + + it('uses the configured origin resolver for the login redirect', async () => { + const response = await VerifyEmailRoute( + createGetRequest('http://127.0.0.1:5001/verify-email'), + { resolveAppOrigin: () => 'https://photos.example.com' }, + ); + + expect(response.headers.get('location')).toContain('https://photos.example.com/login'); + }); + + it('rejects a POST whose CSRF token does not match the cookie', async () => { + const formData = new FormData(); + formData.set('csrfToken', 'submitted'); + formData.set('returnUrl', '/albums'); + const request = new NextRequest('https://app.example.com/verify-email', { + method: 'POST', + body: formData, + }); + request.cookies.set('magic-sso-verify-csrf', 'cookie'); + request.cookies.set('magic-sso-verify-token', 'abc'); + + const response = await VerifyEmailRoute(request); + + expect(response.headers.get('location')).toContain('error=verify-email-failed'); + expect(response.cookies.get('magic-sso-verify-token')?.value).toBe(''); + }); +}); diff --git a/packages/nextjs/src/components/verifyEmail/route.ts b/packages/nextjs/src/components/verifyEmail/route.ts new file mode 100644 index 0000000..2d214e2 --- /dev/null +++ b/packages/nextjs/src/components/verifyEmail/route.ts @@ -0,0 +1,525 @@ +/** + * packages/nextjs/src/components/verifyEmail/route.ts + * + * @license MIT + * + * MIT License + * + * Magic Link SSO Copyright (C) 2026 Wojciech Polak + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { NextResponse, type NextRequest } from 'next/server'; +import { + escapeHtml, + isVerifyEmailPreviewResponse, + isVerifyEmailResponse, + type VerifyEmailPreviewResponse, +} from '@magic-link-sso/core'; +import { buildAuthCookieOptions, getJwtSecret, verifyAuthToken } from '../../lib/auth'; + +export interface VerifyEmailRouteOptions { + /** Document title for the confirmation page this route renders. */ + pageTitle?: string; + /** + * Resolve the public origin the app answers on. Defaults to the request + * URL's own origin; pass a resolver when the app sits behind a proxy. + */ + resolveAppOrigin?: (request: NextRequest) => string; +} + +const DEFAULT_PAGE_TITLE = 'Confirm Sign In'; + +/** Constant-time comparison of the double-submit CSRF token pair. */ +function safeCompare(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +const verifyCsrfCookieName = 'magic-sso-verify-csrf'; +const verifyTokenCookieName = 'magic-sso-verify-token'; + +function normaliseReturnUrl(returnUrl: string | null, origin: string): string { + if (typeof returnUrl !== 'string' || returnUrl.length === 0) { + return '/'; + } + if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { + return returnUrl; + } + + try { + const parsedUrl = new URL(returnUrl); + return parsedUrl.origin === origin ? parsedUrl.toString() : '/'; + } catch { + return '/'; + } +} + +function buildLoginRedirect(appOrigin: string, returnUrl: string, error?: string): NextResponse { + const loginUrl = new URL('/login', appOrigin); + loginUrl.searchParams.set('returnUrl', returnUrl); + if (typeof error === 'string') { + loginUrl.searchParams.set('error', error); + } + return NextResponse.redirect(loginUrl); +} + +function createVerifyCsrfToken(): string { + return randomBytes(32).toString('base64url'); +} + +function hasValidVerifyCsrfToken(submittedToken: string, cookieToken: string): boolean { + return safeCompare(submittedToken, cookieToken); +} + +function buildVerifyCookieOptions(request: NextRequest): { + httpOnly: true; + path: '/verify-email'; + sameSite: 'strict'; + secure: boolean; +} { + return { + httpOnly: true, + path: '/verify-email', + sameSite: 'strict', + secure: request.nextUrl.protocol === 'https:', + }; +} + +function getPreviewSecret(): string | null { + const previewSecret = process.env.MAGICSSO_PREVIEW_SECRET; + return typeof previewSecret === 'string' && previewSecret.length > 0 ? previewSecret : null; +} + +function clearVerifyCookie(response: NextResponse, request: NextRequest): void { + response.cookies.set({ + ...buildVerifyCookieOptions(request), + name: verifyCsrfCookieName, + value: '', + maxAge: 0, + }); + response.cookies.set({ + ...buildVerifyCookieOptions(request), + name: verifyTokenCookieName, + value: '', + maxAge: 0, + }); +} + +function renderConfirmationPage( + email: string, + returnUrl: string, + csrfToken: string, + pageTitle: string, +): string { + return ` + + + + + ${escapeHtml(pageTitle)} + + + +
+

Verify Email

+

Continue sign-in

+

Review the email address below, then continue to finish signing in.

+
+

Email

+ + + + +
+
+ +`; +} + +async function previewVerificationEmail( + token: string, +): Promise { + const serverUrl = process.env.MAGICSSO_SERVER_URL; + if (typeof serverUrl !== 'string' || serverUrl.length === 0) { + console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); + return 'misconfigured'; + } + + const verifyUrl = new URL('/verify-email', serverUrl); + verifyUrl.searchParams.set('token', token); + const previewSecret = getPreviewSecret(); + if (previewSecret === null) { + console.error('Verify-email route is missing MAGICSSO_PREVIEW_SECRET.'); + return 'misconfigured'; + } + + try { + const response = await fetch(verifyUrl, { + headers: { + accept: 'application/json', + 'x-magic-sso-preview-secret': previewSecret, + }, + cache: 'no-store', + }); + if (!response.ok) { + return null; + } + + const payload: unknown = await response.json(); + return isVerifyEmailPreviewResponse(payload) ? payload : null; + } catch (error) { + console.error('Verify-email preview failed unexpectedly.', error); + return null; + } +} + +async function renderVerifyEmailPage( + request: NextRequest, + appOrigin: string, + pageTitle: string, +): Promise { + const token = request.nextUrl.searchParams.get('token'); + const returnUrl = normaliseReturnUrl(request.nextUrl.searchParams.get('returnUrl'), appOrigin); + if (typeof token !== 'string' || token.length === 0) { + return buildLoginRedirect(appOrigin, returnUrl, 'missing-verification-token'); + } + + const payload = await previewVerificationEmail(token); + if (payload === 'misconfigured') { + return buildLoginRedirect(appOrigin, returnUrl, 'verify-email-misconfigured'); + } + if (payload === null) { + return buildLoginRedirect(appOrigin, returnUrl, 'verify-email-failed'); + } + + const csrfToken = createVerifyCsrfToken(); + const response = new NextResponse( + renderConfirmationPage(payload.email, returnUrl, csrfToken, pageTitle), + { + headers: { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + }, + }, + ); + response.cookies.set({ + ...buildVerifyCookieOptions(request), + name: verifyCsrfCookieName, + value: csrfToken, + }); + response.cookies.set({ + ...buildVerifyCookieOptions(request), + name: verifyTokenCookieName, + value: token, + }); + return response; +} + +type VerificationOutcome = + | { accessToken: string; kind: 'success' } + | { + error: + | 'session-verification-failed' + | 'verify-email-failed' + | 'verify-email-misconfigured'; + kind: 'error'; + }; + +/** + * Read the submitted verification token, rejecting the submission when the + * double-submit CSRF pair does not match or the posted token disagrees with the + * one this app handed out. + */ +function readVerifiedSubmissionToken(request: NextRequest, formData: FormData): string | null { + const submittedCsrfToken = formData.get('csrfToken'); + const cookieCsrfToken = request.cookies.get(verifyCsrfCookieName)?.value; + if ( + typeof submittedCsrfToken !== 'string' || + typeof cookieCsrfToken !== 'string' || + !hasValidVerifyCsrfToken(submittedCsrfToken, cookieCsrfToken) + ) { + return null; + } + + const submittedToken = formData.get('token'); + const cookieToken = request.cookies.get(verifyTokenCookieName)?.value; + const token = + typeof submittedToken === 'string' + ? typeof cookieToken === 'string' && submittedToken !== cookieToken + ? null + : submittedToken + : cookieToken; + return typeof token === 'string' && token.length > 0 ? token : null; +} + +/** Read the access token out of a successful `/verify-email` response. */ +async function readVerifiedAccessToken(response: Response): Promise { + const payload: unknown = await response.json(); + if (!isVerifyEmailResponse(payload)) { + console.error( + 'Verify-email route received an invalid response payload from the SSO server.', + ); + return null; + } + + return payload.accessToken; +} + +/** Report why the SSO server refused the verification token. */ +async function logVerificationRejection(response: Response): Promise { + const payload: unknown = await response.json().catch(() => null); + const serverMessage = + typeof payload === 'object' && + payload !== null && + 'message' in payload && + typeof payload.message === 'string' + ? payload.message + : undefined; + console.error('Verify-email request to SSO server failed.', { + serverMessage, + status: response.status, + }); +} + +/** Exchange a one-time verification token for a verified session token. */ +async function completeVerification( + token: string, + appOrigin: string, +): Promise { + const serverUrl = process.env.MAGICSSO_SERVER_URL; + if (typeof serverUrl !== 'string' || serverUrl.length === 0) { + console.error('Verify-email route is missing MAGICSSO_SERVER_URL.'); + return { error: 'verify-email-misconfigured', kind: 'error' }; + } + + const jwtSecret = getJwtSecret(); + if (jwtSecret === null) { + console.error( + 'Verify-email route cannot validate the auth token because MAGICSSO_JWT_SECRET is missing.', + ); + return { error: 'verify-email-misconfigured', kind: 'error' }; + } + + try { + const response = await fetch(new URL('/verify-email', serverUrl), { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ token }), + cache: 'no-store', + }); + if (!response.ok) { + await logVerificationRejection(response); + return { error: 'verify-email-failed', kind: 'error' }; + } + + const accessToken = await readVerifiedAccessToken(response); + if (accessToken === null) { + return { error: 'verify-email-failed', kind: 'error' }; + } + + const verifiedAccessToken = await verifyAuthToken(accessToken, jwtSecret, { + expectedAudience: appOrigin, + expectedIssuer: new URL(serverUrl).origin, + }); + if (verifiedAccessToken === null) { + console.error( + 'Verify-email route rejected the auth token returned by the SSO server. Check that MAGICSSO_JWT_SECRET matches the server JWT secret.', + ); + return { error: 'session-verification-failed', kind: 'error' }; + } + + return { accessToken, kind: 'success' }; + } catch (error) { + console.error('Verify-email route failed unexpectedly.', error); + return { error: 'verify-email-failed', kind: 'error' }; + } +} + +/** Send the visitor back to the login page and drop the one-time verify cookies. */ +function failVerification( + request: NextRequest, + appOrigin: string, + returnUrl: string, + error: string, +): NextResponse { + const response = buildLoginRedirect(appOrigin, returnUrl, error); + clearVerifyCookie(response, request); + return response; +} + +async function submitVerifyEmailPage( + request: NextRequest, + appOrigin: string, +): Promise { + const formData = await request.formData(); + const returnUrlValue = formData.get('returnUrl'); + const returnUrl = normaliseReturnUrl( + typeof returnUrlValue === 'string' ? returnUrlValue : null, + appOrigin, + ); + + const token = readVerifiedSubmissionToken(request, formData); + if (token === null) { + return failVerification(request, appOrigin, returnUrl, 'verify-email-failed'); + } + + const outcome = await completeVerification(token, appOrigin); + if (outcome.kind === 'error') { + return failVerification(request, appOrigin, returnUrl, outcome.error); + } + + const redirectResponse = NextResponse.redirect(new URL(returnUrl, appOrigin)); + clearVerifyCookie(redirectResponse, request); + redirectResponse.cookies.set(buildAuthCookieOptions(outcome.accessToken)); + return redirectResponse; +} + +/** + * Drop-in App Router handler for the client-side `/verify-email` callback. + * + * `GET` renders a confirmation page for the one-time token in the magic link, + * and `POST` exchanges the confirmed token for a session cookie. Export it from + * `app/verify-email/route.ts` as both `GET` and `POST`. + */ +export async function VerifyEmailRoute( + request: NextRequest, + options: VerifyEmailRouteOptions = {}, +): Promise { + const appOrigin = options.resolveAppOrigin?.(request) ?? request.nextUrl.origin; + return request.method === 'POST' + ? submitVerifyEmailPage(request, appOrigin) + : renderVerifyEmailPage(request, appOrigin, options.pageTitle ?? DEFAULT_PAGE_TITLE); +} diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 80a4359..088b5b8 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -27,16 +27,24 @@ */ export { sendMagicLink, verifyEmailOtp } from './components/login/actions'; -export type { SendMagicLinkResult, VerifyEmailOtpResult } from './components/login/actions'; +export type { + SendMagicLinkOptions, + SendMagicLinkResult, + VerifyEmailOtpResult, +} from './components/login/actions'; export { VerifyEmailOtpRoute } from './components/login/otpRoute'; export { LogoutRoute } from './components/logout/route'; +export { VerifyEmailRoute } from './components/verifyEmail/route'; +export type { VerifyEmailRouteOptions } from './components/verifyEmail/route'; export type { AuthPayload, VerifyAuthTokenOptions } from './lib/auth'; export { buildAuthCookieOptions, + buildVerifyUrl, getCookieMaxAge, getCookieName, getJwtSecret, hasSameOriginMutationSource, + normaliseReturnUrl, redirectToLogin, verifyAuthToken, verifyToken, diff --git a/packages/nextjs/src/lib/auth.ts b/packages/nextjs/src/lib/auth.ts index 4ff7214..ca15ffc 100644 --- a/packages/nextjs/src/lib/auth.ts +++ b/packages/nextjs/src/lib/auth.ts @@ -27,9 +27,12 @@ */ import { - buildAuthCookieOptions as buildCoreAuthCookieOptions, - verifyAuthToken as verifyCoreAuthToken, + parseBooleanFlag, type AuthPayload as CoreAuthPayload, + buildAuthCookieOptions as buildCoreAuthCookieOptions, + buildVerifyUrl as buildCoreVerifyUrl, + normaliseReturnUrl as normaliseCoreReturnUrl, + verifyAuthTokenWithOptionalIssuer as verifyCoreAuthTokenWithOptionalIssuer, } from '@magic-link-sso/core'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -106,24 +109,7 @@ export function getServerIssuer(): string | null { } function readBooleanEnv(value: string | undefined): boolean { - if (typeof value !== 'string') { - return false; - } - - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return false; - } + return parseBooleanFlag(value); } export function getPublicOrigin(): string | null { @@ -222,12 +208,7 @@ export async function verifyAuthToken( secret: Uint8Array, options: VerifyAuthTokenOptions, ): Promise { - return typeof options.expectedIssuer === 'string' - ? verifyCoreAuthToken(token, secret, { - expectedAudience: options.expectedAudience, - expectedIssuer: options.expectedIssuer, - }) - : null; + return verifyCoreAuthTokenWithOptionalIssuer(token, secret, options); } export async function verifyToken(): Promise { @@ -274,3 +255,23 @@ export function redirectToLogin(returnUrl: string, scope?: string): never { } redirect(`${loginUrl.pathname}${loginUrl.search}`); } + +/** + * Constrain `returnUrl` to `appOrigin`, answering `fallback` for anything that + * points elsewhere, carries credentials, or fails to parse. + */ +export function normaliseReturnUrl( + returnUrl: string | undefined, + appOrigin: string, + fallback: string = '/', +): string { + const normalised = normaliseCoreReturnUrl({ appOrigin, fallback, returnUrl }); + return normalised === new URL('/', appOrigin).toString() && fallback === appOrigin + ? fallback + : normalised; +} + +/** Build this app's `/verify-email` URL for an already-normalised return URL. */ +export function buildVerifyUrl(appOrigin: string, returnUrl: string): string { + return buildCoreVerifyUrl(appOrigin, returnUrl); +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 25a4e03..2db3460 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -14,6 +14,46 @@ import type { MagicSsoModuleOptions } from './types'; export type { MagicSsoModuleOptions } from './types'; +/** Read a nested runtime-config table, treating anything else as absent. */ +function readConfigTable(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +/** Apply the module defaults once, so the server and public tables stay in step. */ +function resolveModuleOptions(options: MagicSsoModuleOptions) { + return { + authEverywhere: options.authEverywhere ?? false, + cookieMaxAge: options.cookieMaxAge, + cookieName: options.cookieName ?? 'token', + cookiePath: options.cookiePath ?? '/', + directUse: options.directUse ?? false, + excludedPaths: options.excludedPaths ?? DEFAULT_EXCLUDED_PATHS, + jwtSecret: options.jwtSecret ?? '', + publicOrigin: options.publicOrigin ?? '', + serverUrl: options.serverUrl ?? '', + trustProxy: options.trustProxy ?? false, + }; +} + +const serverHandlers: ReadonlyArray<{ + handler: string; + method: 'get' | 'post'; + route: string; +}> = [ + { handler: './runtime/server/routes/logout.post', method: 'post', route: '/logout' }, + { handler: './runtime/server/routes/verify-email.get', method: 'get', route: '/verify-email' }, + { + handler: './runtime/server/routes/verify-email.post', + method: 'post', + route: '/verify-email', + }, + { + handler: './runtime/server/routes/verify-email-otp.post', + method: 'post', + route: '/verify-email/otp', + }, +]; + const magicSsoModule: NuxtModule = defineNuxtModule({ meta: { @@ -35,47 +75,17 @@ const magicSsoModule: NuxtModule = setup(options, nuxt) { const resolver = createResolver(import.meta.url); const runtimeConfig = nuxt.options.runtimeConfig; - const configuredPublicRuntimeConfig = - typeof runtimeConfig.public === 'object' && runtimeConfig.public !== null - ? runtimeConfig.public - : {}; - const configuredMagicSso = - typeof runtimeConfig.magicSso === 'object' && runtimeConfig.magicSso !== null - ? runtimeConfig.magicSso - : {}; - const configuredPublicMagicSso = - typeof configuredPublicRuntimeConfig.magicSso === 'object' && - configuredPublicRuntimeConfig.magicSso !== null - ? configuredPublicRuntimeConfig.magicSso - : {}; + const configuredPublicRuntimeConfig = readConfigTable(runtimeConfig.public); + const configuredMagicSso = readConfigTable(runtimeConfig.magicSso); + const configuredPublicMagicSso = readConfigTable( + configuredPublicRuntimeConfig['magicSso'], + ); + const { jwtSecret, ...publicOptions } = resolveModuleOptions(options); - runtimeConfig.magicSso = { - serverUrl: options.serverUrl ?? '', - jwtSecret: options.jwtSecret ?? '', - cookieName: options.cookieName ?? 'token', - cookiePath: options.cookiePath ?? '/', - cookieMaxAge: options.cookieMaxAge, - directUse: options.directUse ?? false, - publicOrigin: options.publicOrigin ?? '', - trustProxy: options.trustProxy ?? false, - excludedPaths: options.excludedPaths ?? DEFAULT_EXCLUDED_PATHS, - authEverywhere: options.authEverywhere ?? false, - ...configuredMagicSso, - }; + runtimeConfig.magicSso = { ...publicOptions, jwtSecret, ...configuredMagicSso }; runtimeConfig.public = { ...configuredPublicRuntimeConfig, - magicSso: { - serverUrl: options.serverUrl ?? '', - cookieName: options.cookieName ?? 'token', - cookiePath: options.cookiePath ?? '/', - cookieMaxAge: options.cookieMaxAge, - directUse: options.directUse ?? false, - publicOrigin: options.publicOrigin ?? '', - trustProxy: options.trustProxy ?? false, - excludedPaths: options.excludedPaths ?? DEFAULT_EXCLUDED_PATHS, - authEverywhere: options.authEverywhere ?? false, - ...configuredPublicMagicSso, - }, + magicSso: { ...publicOptions, ...configuredPublicMagicSso }, }; addImportsDir(resolver.resolve('./runtime/app/composables')); @@ -84,26 +94,9 @@ const magicSsoModule: NuxtModule = path: resolver.resolve('./runtime/app/middleware/auth'), global: options.authEverywhere === true, }); - addServerHandler({ - route: '/logout', - handler: resolver.resolve('./runtime/server/routes/logout.post'), - method: 'post', - }); - addServerHandler({ - route: '/verify-email', - handler: resolver.resolve('./runtime/server/routes/verify-email.get'), - method: 'get', - }); - addServerHandler({ - route: '/verify-email', - handler: resolver.resolve('./runtime/server/routes/verify-email.post'), - method: 'post', - }); - addServerHandler({ - route: '/verify-email/otp', - handler: resolver.resolve('./runtime/server/routes/verify-email-otp.post'), - method: 'post', - }); + for (const { handler, method, route } of serverHandlers) { + addServerHandler({ handler: resolver.resolve(handler), method, route }); + } }, }); diff --git a/packages/nuxt/src/runtime/server/routes/verify-email.get.ts b/packages/nuxt/src/runtime/server/routes/verify-email.get.ts index 0098262..2151db8 100644 --- a/packages/nuxt/src/runtime/server/routes/verify-email.get.ts +++ b/packages/nuxt/src/runtime/server/routes/verify-email.get.ts @@ -2,6 +2,7 @@ // Copyright (C) 2026 Wojciech Polak import { randomBytes } from 'node:crypto'; +import { escapeHtml } from '@magic-link-sso/core'; import { defineEventHandler, getQuery, @@ -42,15 +43,6 @@ function createVerifyCsrfToken(): string { return randomBytes(32).toString('base64url'); } -function escapeHtml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - function renderConfirmationPage( email: string, token: string, diff --git a/packages/nuxt/src/runtime/server/utils/auth.ts b/packages/nuxt/src/runtime/server/utils/auth.ts index 5ff37b9..372c9db 100644 --- a/packages/nuxt/src/runtime/server/utils/auth.ts +++ b/packages/nuxt/src/runtime/server/utils/auth.ts @@ -3,9 +3,10 @@ import { getCookie, getRequestURL, type H3Event } from 'h3'; import { + parseBooleanFlag, buildLoginTarget as buildCoreLoginTarget, normaliseReturnUrl as normaliseCoreReturnUrl, - verifyAuthToken as verifyCoreAuthToken, + verifyAuthTokenWithOptionalIssuer as verifyCoreAuthTokenWithOptionalIssuer, } from '@magic-link-sso/core'; import { DEFAULT_EXCLUDED_PATHS } from '../../../constants'; import type { AuthPayload, MagicSsoModuleOptions, MagicSsoResolvedConfig } from '../../../types'; @@ -44,27 +45,7 @@ function readAbsoluteOrigin(value: unknown, fallback: string): string { } function readBoolean(value: unknown, fallback: boolean): boolean { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return fallback; - } - } - - return fallback; + return parseBooleanFlag(value, fallback); } function readPositiveInteger(value: unknown): number | undefined { @@ -306,12 +287,7 @@ export async function verifyAuthToken( secret: Uint8Array, options: VerifyAuthTokenOptions, ): Promise { - return typeof options.expectedIssuer === 'string' - ? verifyCoreAuthToken(token, secret, { - expectedAudience: options.expectedAudience, - expectedIssuer: options.expectedIssuer, - }) - : null; + return verifyCoreAuthTokenWithOptionalIssuer(token, secret, options); } export async function verifyRequestAuth(event: H3Event): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1db2e6..6b2f30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,13 +42,13 @@ importers: version: 20.0.2 oxfmt: specifier: latest - version: 0.64.0 + version: 0.67.0 oxlint: specifier: latest - version: 1.79.0 + version: 1.82.0 turbo: specifier: latest - version: 2.10.11 + version: 2.10.12 vitest: specifier: ^4.1.10 version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -82,6 +82,9 @@ importers: '@magic-link-sso/config-core': specifier: workspace:* version: link:../../packages/config-core + '@magic-link-sso/core': + specifier: workspace:* + version: link:../../packages/core dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -210,6 +213,9 @@ importers: '@magic-link-sso/config-core': specifier: workspace:* version: link:../../packages/config-core + '@magic-link-sso/core': + specifier: workspace:* + version: link:../../packages/core '@magic-link-sso/nextjs': specifier: workspace:* version: link:../../packages/nextjs @@ -250,15 +256,21 @@ importers: examples/nuxt: dependencies: + '@magic-link-sso/core': + specifier: workspace:* + version: link:../../packages/core '@magic-link-sso/nuxt': specifier: workspace:* version: link:../../packages/nuxt + h3: + specifier: ^1.15.11 + version: 1.15.11 magic-sso-example-ui: specifier: workspace:* version: link:../../packages/example-ui nuxt: specifier: ^4.5.2 - version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) vue: specifier: ^3.5.41 version: 3.5.41(typescript@6.0.3) @@ -281,9 +293,15 @@ importers: '@magic-link-sso/config-core': specifier: workspace:* version: link:../../packages/config-core + '@magic-link-sso/core': + specifier: workspace:* + version: link:../../packages/core '@magic-link-sso/nextjs': specifier: workspace:* version: link:../../packages/nextjs + magic-sso-example-ui: + specifier: workspace:* + version: link:../../packages/example-ui next: specifier: ^16.3.0 version: 16.3.0(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) @@ -330,6 +348,9 @@ importers: '@magic-link-sso/config-core': specifier: workspace:* version: link:../packages/config-core + '@magic-link-sso/core': + specifier: workspace:* + version: link:../packages/core dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -534,7 +555,7 @@ importers: version: 6.2.8 nuxt: specifier: ^4.5.2 - version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -616,10 +637,10 @@ importers: version: 25.9.5 mailparser: specifier: latest - version: 3.9.15 + version: 3.9.25 smtp-server: specifier: latest - version: 3.19.3 + version: 3.19.11 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2300,246 +2321,246 @@ packages: '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} - '@oxfmt/binding-android-arm-eabi@0.64.0': - resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} + '@oxfmt/binding-android-arm-eabi@0.67.0': + resolution: {integrity: sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.64.0': - resolution: {integrity: sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==} + '@oxfmt/binding-android-arm64@0.67.0': + resolution: {integrity: sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.64.0': - resolution: {integrity: sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==} + '@oxfmt/binding-darwin-arm64@0.67.0': + resolution: {integrity: sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.64.0': - resolution: {integrity: sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==} + '@oxfmt/binding-darwin-x64@0.67.0': + resolution: {integrity: sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.64.0': - resolution: {integrity: sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==} + '@oxfmt/binding-freebsd-x64@0.67.0': + resolution: {integrity: sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': - resolution: {integrity: sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==} + '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': + resolution: {integrity: sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.64.0': - resolution: {integrity: sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==} + '@oxfmt/binding-linux-arm-musleabihf@0.67.0': + resolution: {integrity: sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.64.0': - resolution: {integrity: sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==} + '@oxfmt/binding-linux-arm64-gnu@0.67.0': + resolution: {integrity: sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.64.0': - resolution: {integrity: sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==} + '@oxfmt/binding-linux-arm64-musl@0.67.0': + resolution: {integrity: sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.64.0': - resolution: {integrity: sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==} + '@oxfmt/binding-linux-ppc64-gnu@0.67.0': + resolution: {integrity: sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.64.0': - resolution: {integrity: sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==} + '@oxfmt/binding-linux-riscv64-gnu@0.67.0': + resolution: {integrity: sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.64.0': - resolution: {integrity: sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==} + '@oxfmt/binding-linux-riscv64-musl@0.67.0': + resolution: {integrity: sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.64.0': - resolution: {integrity: sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==} + '@oxfmt/binding-linux-s390x-gnu@0.67.0': + resolution: {integrity: sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.64.0': - resolution: {integrity: sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==} + '@oxfmt/binding-linux-x64-gnu@0.67.0': + resolution: {integrity: sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.64.0': - resolution: {integrity: sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==} + '@oxfmt/binding-linux-x64-musl@0.67.0': + resolution: {integrity: sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.64.0': - resolution: {integrity: sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==} + '@oxfmt/binding-openharmony-arm64@0.67.0': + resolution: {integrity: sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.64.0': - resolution: {integrity: sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==} + '@oxfmt/binding-win32-arm64-msvc@0.67.0': + resolution: {integrity: sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.64.0': - resolution: {integrity: sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==} + '@oxfmt/binding-win32-ia32-msvc@0.67.0': + resolution: {integrity: sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.64.0': - resolution: {integrity: sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==} + '@oxfmt/binding-win32-x64-msvc@0.67.0': + resolution: {integrity: sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.79.0': - resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.79.0': - resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.79.0': - resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.79.0': - resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.79.0': - resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': - resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.79.0': - resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.79.0': - resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.79.0': - resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.79.0': - resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.79.0': - resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.79.0': - resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.79.0': - resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.79.0': - resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.79.0': - resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.79.0': - resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.79.0': - resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.79.0': - resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.79.0': - resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3374,33 +3395,33 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@turbo/darwin-64@2.10.11': - resolution: {integrity: sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA==} + '@turbo/darwin-64@2.10.12': + resolution: {integrity: sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.10.11': - resolution: {integrity: sha512-R0a0CvGAeYYsBgPIgFNB3agGXh6qukjduNhFlwVVX1Ss2IdBJLXmgjytNGmo084bLKS0B6UdLRwKhXMHKKaObQ==} + '@turbo/darwin-arm64@2.10.12': + resolution: {integrity: sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.10.11': - resolution: {integrity: sha512-dGlY2vg7jpsLjGS1bf9sD/cw1sGMAYbeHGQKGRFxd5Zaj+Ufbj8cNXl/vIit8ueCU97zhL/znn60ZV5iSfZAxw==} + '@turbo/linux-64@2.10.12': + resolution: {integrity: sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ==} cpu: [x64] os: [android, linux] - '@turbo/linux-arm64@2.10.11': - resolution: {integrity: sha512-eSP9+jjsSCBs2x0QpJZlp49dSD1EIMwXH7PsMIhdWk7w6IThhuKJ+jL95JQ2IW7tRjRmdh8gKcKQtrHLD5R5lA==} + '@turbo/linux-arm64@2.10.12': + resolution: {integrity: sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA==} cpu: [arm64] os: [android, linux] - '@turbo/windows-64@2.10.11': - resolution: {integrity: sha512-4aD7edogJ8arK8DOyvjTI2KKwmchD5M/WM4BZgidrtFf7aSgn8Ce2rJnDwmriw980c2cKlHnhXtlGy38VtKB8g==} + '@turbo/windows-64@2.10.12': + resolution: {integrity: sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.10.11': - resolution: {integrity: sha512-m8tJkIrTrbQ9O1uHxV0GUq623Zg1678xGna8eMsy4KbygUX/wVFgdZDYV/AxyoyaW/U7nyKoBvaDVwm22p9xqA==} + '@turbo/windows-arm64@2.10.12': + resolution: {integrity: sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA==} cpu: [arm64] os: [win32] @@ -3649,8 +3670,8 @@ packages: '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} - '@zone-eu/mailsplit@5.4.15': - resolution: {integrity: sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==} + '@zone-eu/mailsplit@5.4.16': + resolution: {integrity: sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -4244,9 +4265,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - deepmerge-ts@7.1.6: - resolution: {integrity: sha512-gQhL1ksGBLQbHeAo47YU6cs2ahd3Pv+8PFYoWogNZbQnNwQyOh9Ad5kbeHFiWwbKdeWQJ5Z7Y7mwb9ew2hXBLw==} - engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -4355,9 +4376,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding-japanese@2.2.0: - resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} - engines: {node: '>=8.10.0'} + encoding-japanese@2.3.0: + resolution: {integrity: sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==} + engines: {node: '>=18.0.0'} end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -4753,8 +4774,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-to-text@10.0.0: - resolution: {integrity: sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==} + html-to-text@10.0.1: + resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==} engines: {node: '>=20.19.0'} htmlparser2@10.1.0: @@ -5039,8 +5060,8 @@ packages: libbase64@1.3.0: resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} - libmime@5.4.2: - resolution: {integrity: sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==} + libmime@5.4.3: + resolution: {integrity: sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==} libqp@2.1.1: resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} @@ -5278,8 +5299,9 @@ packages: magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} - mailparser@3.9.15: - resolution: {integrity: sha512-/5hgybKDMKFS05jAh8OPeEE7N53lj8nF6ywlo89VPjF2TtwqIQbjaxumQWnJIkmaagnJ6u1eansMOViHFxIXwQ==} + mailparser@3.9.25: + resolution: {integrity: sha512-gMoe9VXhxDRL0tVcqr52gar1Pr0g8QSRIqkrJGMt5FpUKUNlBVPG1rNLNWIzgBWVK6UE9/4J7q78Y5iDNfYwqA==} + engines: {node: '>=20.0.0'} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -5492,6 +5514,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + nodemailer@10.0.8: + resolution: {integrity: sha512-uCZ0AtFDI46sHwJ6mhwNuIoA/KiTC0ZCzX7g01941+H9VMIXEiZjtgjQkzoBRTeQba0cIrKfwk31q99PfcIMRw==} + engines: {node: '>=20.0.0'} + nodemailer@9.0.5: resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} engines: {node: '>=6.0.0'} @@ -5635,8 +5661,8 @@ packages: rolldown: optional: true - oxfmt@0.64.0: - resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} + oxfmt@0.67.0: + resolution: {integrity: sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5648,8 +5674,8 @@ packages: vite-plus: optional: true - oxlint@1.79.0: - resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6352,9 +6378,9 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} - smtp-server@3.19.3: - resolution: {integrity: sha512-1WkMlusxxy0Pdx3Bj4+nxzXLakON3EvV8+e0ZD/u6weNkHiX/ih2KGRV2A6Hg54Z30CQjE1OvNs79EZ0r0fBkw==} - engines: {node: '>=18.18.0'} + smtp-server@3.19.11: + resolution: {integrity: sha512-WJb7oVhS3n1oUZfzWVF5MUMZpUGU5utagVIyEPUDYNGNFsjVxquXPlzO4kYob6oTab3wXDcE353yyWV96d8HWg==} + engines: {node: '>=20.0.0'} sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -6553,8 +6579,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} tinyrainbow@3.1.1: @@ -6592,8 +6618,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.10.11: - resolution: {integrity: sha512-yQfwQVoRXwOuyX1LxiJFBFNg6VfuYh+/RyZLd82+isgyLkBXw3S5XRRzvcck1FAjSCG5sVyLd+O1eDMvYa3J7g==} + turbo@2.10.12: + resolution: {integrity: sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw==} hasBin: true type-fest@2.19.0: @@ -8747,7 +8773,7 @@ snapshots: - rolldown - unplugin - '@nuxt/nitro-server@4.5.2(28a64af2568609f15f5d534635b546f3)': + '@nuxt/nitro-server@4.5.2(12d5a6aa88318025f92a4504228b74f8)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) @@ -8766,7 +8792,7 @@ snapshots: mocked-exports: 0.1.1 nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(srvx@0.11.22)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) nostics: 1.2.0 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 @@ -8779,7 +8805,7 @@ snapshots: vue-bundle-renderer: 2.3.2 vue-devtools-stub: 0.1.0 optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@8.0.1) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8833,7 +8859,7 @@ snapshots: - webpack - xml2js - '@nuxt/nitro-server@4.5.2(904a97bf97f9cacf63f93ce8a878bdca)': + '@nuxt/nitro-server@4.5.2(df9c0b79715b30eda542a988aaa2aa94)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) @@ -8852,7 +8878,7 @@ snapshots: mocked-exports: 0.1.1 nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(srvx@0.11.22)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) nostics: 1.2.0 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 @@ -8865,7 +8891,7 @@ snapshots: vue-bundle-renderer: 2.3.2 vue-devtools-stub: 0.1.0 optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@8.0.1) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8937,7 +8963,7 @@ snapshots: rc9: 3.0.1 std-env: 4.2.0 - '@nuxt/vite-builder@4.5.2(1d09341627d18eb3b63429939848f475)': + '@nuxt/vite-builder@4.5.2(46d96098afc88af5641597321bd7b84b)': dependencies: '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) @@ -8955,7 +8981,7 @@ snapshots: knitwork: 1.3.0 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.9 pathe: 2.0.3 pkg-types: 2.3.1 @@ -8967,7 +8993,7 @@ snapshots: unenv: 2.0.0-rc.24 vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) vite-node: 6.0.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) - vite-plugin-checker: 0.14.5(oxlint@1.79.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) + vite-plugin-checker: 0.14.5(oxlint@1.82.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) vue: 3.5.41(typescript@6.0.3) vue-bundle-renderer: 2.3.2 optionalDependencies: @@ -9000,7 +9026,7 @@ snapshots: - vue-tsc - yaml - '@nuxt/vite-builder@4.5.2(c96eae011dff551a886e8d703b048575)': + '@nuxt/vite-builder@4.5.2(c41992607f021d40c105085b3e4e1b42)': dependencies: '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) @@ -9018,7 +9044,7 @@ snapshots: knitwork: 1.3.0 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.9 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9030,7 +9056,7 @@ snapshots: unenv: 2.0.0-rc.24 vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) vite-node: 6.0.0(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) - vite-plugin-checker: 0.14.5(oxlint@1.79.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) + vite-plugin-checker: 0.14.5(oxlint@1.82.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) vue: 3.5.41(typescript@6.0.3) vue-bundle-renderer: 2.3.2 optionalDependencies: @@ -9137,118 +9163,118 @@ snapshots: '@oxc-project/types@0.144.0': {} - '@oxfmt/binding-android-arm-eabi@0.64.0': + '@oxfmt/binding-android-arm-eabi@0.67.0': optional: true - '@oxfmt/binding-android-arm64@0.64.0': + '@oxfmt/binding-android-arm64@0.67.0': optional: true - '@oxfmt/binding-darwin-arm64@0.64.0': + '@oxfmt/binding-darwin-arm64@0.67.0': optional: true - '@oxfmt/binding-darwin-x64@0.64.0': + '@oxfmt/binding-darwin-x64@0.67.0': optional: true - '@oxfmt/binding-freebsd-x64@0.64.0': + '@oxfmt/binding-freebsd-x64@0.67.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.64.0': + '@oxfmt/binding-linux-arm-musleabihf@0.67.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.64.0': + '@oxfmt/binding-linux-arm64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.64.0': + '@oxfmt/binding-linux-arm64-musl@0.67.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.64.0': + '@oxfmt/binding-linux-ppc64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.64.0': + '@oxfmt/binding-linux-riscv64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.64.0': + '@oxfmt/binding-linux-riscv64-musl@0.67.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.64.0': + '@oxfmt/binding-linux-s390x-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.64.0': + '@oxfmt/binding-linux-x64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.64.0': + '@oxfmt/binding-linux-x64-musl@0.67.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.64.0': + '@oxfmt/binding-openharmony-arm64@0.67.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.64.0': + '@oxfmt/binding-win32-arm64-msvc@0.67.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.64.0': + '@oxfmt/binding-win32-ia32-msvc@0.67.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.64.0': + '@oxfmt/binding-win32-x64-msvc@0.67.0': optional: true - '@oxlint/binding-android-arm-eabi@1.79.0': + '@oxlint/binding-android-arm-eabi@1.82.0': optional: true - '@oxlint/binding-android-arm64@1.79.0': + '@oxlint/binding-android-arm64@1.82.0': optional: true - '@oxlint/binding-darwin-arm64@1.79.0': + '@oxlint/binding-darwin-arm64@1.82.0': optional: true - '@oxlint/binding-darwin-x64@1.79.0': + '@oxlint/binding-darwin-x64@1.82.0': optional: true - '@oxlint/binding-freebsd-x64@1.79.0': + '@oxlint/binding-freebsd-x64@1.82.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.79.0': + '@oxlint/binding-linux-arm-musleabihf@1.82.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.79.0': + '@oxlint/binding-linux-arm64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.79.0': + '@oxlint/binding-linux-arm64-musl@1.82.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.79.0': + '@oxlint/binding-linux-ppc64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.79.0': + '@oxlint/binding-linux-riscv64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.79.0': + '@oxlint/binding-linux-riscv64-musl@1.82.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.79.0': + '@oxlint/binding-linux-s390x-gnu@1.82.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.79.0': + '@oxlint/binding-linux-x64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-x64-musl@1.79.0': + '@oxlint/binding-linux-x64-musl@1.82.0': optional: true - '@oxlint/binding-openharmony-arm64@1.79.0': + '@oxlint/binding-openharmony-arm64@1.82.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.79.0': + '@oxlint/binding-win32-arm64-msvc@1.82.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.79.0': + '@oxlint/binding-win32-ia32-msvc@1.82.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.79.0': + '@oxlint/binding-win32-x64-msvc@1.82.0': optional: true '@parcel/watcher-android-arm64@2.6.0': @@ -9775,22 +9801,22 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 - '@turbo/darwin-64@2.10.11': + '@turbo/darwin-64@2.10.12': optional: true - '@turbo/darwin-arm64@2.10.11': + '@turbo/darwin-arm64@2.10.12': optional: true - '@turbo/linux-64@2.10.11': + '@turbo/linux-64@2.10.12': optional: true - '@turbo/linux-arm64@2.10.11': + '@turbo/linux-arm64@2.10.12': optional: true - '@turbo/windows-64@2.10.11': + '@turbo/windows-64@2.10.12': optional: true - '@turbo/windows-arm64@2.10.11': + '@turbo/windows-arm64@2.10.12': optional: true '@tybys/wasm-util@0.10.3': @@ -10137,10 +10163,10 @@ snapshots: '@zeit/schemas@2.36.0': {} - '@zone-eu/mailsplit@5.4.15': + '@zone-eu/mailsplit@5.4.16': dependencies: libbase64: 1.3.0 - libmime: 5.4.2 + libmime: 5.4.3 libqp: 2.1.1 abbrev@3.0.1: {} @@ -10707,7 +10733,7 @@ snapshots: deep-extend@0.6.0: {} - deepmerge-ts@7.1.6: {} + deepmerge-ts@8.0.2: {} deepmerge@4.3.1: {} @@ -10793,7 +10819,7 @@ snapshots: encodeurl@2.0.0: {} - encoding-japanese@2.2.0: {} + encoding-japanese@2.3.0: {} end-of-stream@1.4.5: dependencies: @@ -11250,10 +11276,10 @@ snapshots: html-escaper@2.0.2: {} - html-to-text@10.0.0: + html-to-text@10.0.1: dependencies: '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) - deepmerge-ts: 7.1.6 + deepmerge-ts: 8.0.2 dom-serializer: 2.0.0 htmlparser2: 10.1.0 selderee: 0.12.0 @@ -11505,9 +11531,9 @@ snapshots: libbase64@1.3.0: {} - libmime@5.4.2: + libmime@5.4.3: dependencies: - encoding-japanese: 2.2.0 + encoding-japanese: 2.3.0 iconv-lite: 0.7.3 libbase64: 1.3.0 libqp: 2.1.1 @@ -11751,16 +11777,16 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.1 - mailparser@3.9.15: + mailparser@3.9.25: dependencies: - '@zone-eu/mailsplit': 5.4.15 - encoding-japanese: 2.2.0 + '@zone-eu/mailsplit': 5.4.16 + encoding-japanese: 2.3.0 he: 1.2.0 - html-to-text: 10.0.0 + html-to-text: 10.0.1 iconv-lite: 0.7.3 - libmime: 5.4.2 + libmime: 5.4.3 linkify-it: 5.0.2 - nodemailer: 9.0.5 + nodemailer: 10.0.8 punycode.js: 2.3.1 tlds: 1.261.0 @@ -12051,6 +12077,8 @@ snapshots: node-releases@2.0.53: {} + nodemailer@10.0.8: {} + nodemailer@9.0.5: {} nopt@8.1.0: @@ -12087,16 +12115,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.6(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@6.7.14)(commander@14.0.3)(magicast@0.5.4) '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1)(magic-string@1.2.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.2(28a64af2568609f15f5d534635b546f3) + '@nuxt/nitro-server': 4.5.2(df9c0b79715b30eda542a988aaa2aa94) '@nuxt/schema': 4.5.2 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.2(1d09341627d18eb3b63429939848f475) + '@nuxt/vite-builder': 4.5.2(46d96098afc88af5641597321bd7b84b) '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/shared': 3.5.41 chokidar: 5.0.0 @@ -12229,16 +12257,16 @@ snapshots: - xml2js - yaml - nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.79.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@8.0.1))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@8.0.1))(@oxc-project/types@0.144.0)(@parcel/watcher@2.6.0)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.41)(cac@6.7.14)(commander@14.0.3)(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.142.0)(oxlint@1.82.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.4))(rollup@4.62.4)(sass@1.101.0)(srvx@0.11.22)(terser@5.50.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.6(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@6.7.14)(commander@14.0.3)(magicast@0.5.4) '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1)(magic-string@1.2.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.2(904a97bf97f9cacf63f93ce8a878bdca) + '@nuxt/nitro-server': 4.5.2(12d5a6aa88318025f92a4504228b74f8) '@nuxt/schema': 4.5.2 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.2(c96eae011dff551a886e8d703b048575) + '@nuxt/vite-builder': 4.5.2(c41992607f021d40c105085b3e4e1b42) '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/shared': 3.5.41 chokidar: 5.0.0 @@ -12475,51 +12503,51 @@ snapshots: oxc-parser: 0.142.0 rolldown: 1.2.4 - oxfmt@0.64.0: + oxfmt@0.67.0: dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.64.0 - '@oxfmt/binding-android-arm64': 0.64.0 - '@oxfmt/binding-darwin-arm64': 0.64.0 - '@oxfmt/binding-darwin-x64': 0.64.0 - '@oxfmt/binding-freebsd-x64': 0.64.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 - '@oxfmt/binding-linux-arm64-gnu': 0.64.0 - '@oxfmt/binding-linux-arm64-musl': 0.64.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 - '@oxfmt/binding-linux-riscv64-musl': 0.64.0 - '@oxfmt/binding-linux-s390x-gnu': 0.64.0 - '@oxfmt/binding-linux-x64-gnu': 0.64.0 - '@oxfmt/binding-linux-x64-musl': 0.64.0 - '@oxfmt/binding-openharmony-arm64': 0.64.0 - '@oxfmt/binding-win32-arm64-msvc': 0.64.0 - '@oxfmt/binding-win32-ia32-msvc': 0.64.0 - '@oxfmt/binding-win32-x64-msvc': 0.64.0 - - oxlint@1.79.0: + '@oxfmt/binding-android-arm-eabi': 0.67.0 + '@oxfmt/binding-android-arm64': 0.67.0 + '@oxfmt/binding-darwin-arm64': 0.67.0 + '@oxfmt/binding-darwin-x64': 0.67.0 + '@oxfmt/binding-freebsd-x64': 0.67.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.67.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.67.0 + '@oxfmt/binding-linux-arm64-gnu': 0.67.0 + '@oxfmt/binding-linux-arm64-musl': 0.67.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.67.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.67.0 + '@oxfmt/binding-linux-riscv64-musl': 0.67.0 + '@oxfmt/binding-linux-s390x-gnu': 0.67.0 + '@oxfmt/binding-linux-x64-gnu': 0.67.0 + '@oxfmt/binding-linux-x64-musl': 0.67.0 + '@oxfmt/binding-openharmony-arm64': 0.67.0 + '@oxfmt/binding-win32-arm64-msvc': 0.67.0 + '@oxfmt/binding-win32-ia32-msvc': 0.67.0 + '@oxfmt/binding-win32-x64-msvc': 0.67.0 + + oxlint@1.82.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.79.0 - '@oxlint/binding-android-arm64': 1.79.0 - '@oxlint/binding-darwin-arm64': 1.79.0 - '@oxlint/binding-darwin-x64': 1.79.0 - '@oxlint/binding-freebsd-x64': 1.79.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 - '@oxlint/binding-linux-arm-musleabihf': 1.79.0 - '@oxlint/binding-linux-arm64-gnu': 1.79.0 - '@oxlint/binding-linux-arm64-musl': 1.79.0 - '@oxlint/binding-linux-ppc64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-musl': 1.79.0 - '@oxlint/binding-linux-s390x-gnu': 1.79.0 - '@oxlint/binding-linux-x64-gnu': 1.79.0 - '@oxlint/binding-linux-x64-musl': 1.79.0 - '@oxlint/binding-openharmony-arm64': 1.79.0 - '@oxlint/binding-win32-arm64-msvc': 1.79.0 - '@oxlint/binding-win32-ia32-msvc': 1.79.0 - '@oxlint/binding-win32-x64-msvc': 1.79.0 + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 package-json-from-dist@1.0.1: {} @@ -13367,10 +13395,10 @@ snapshots: smol-toml@1.8.0: {} - smtp-server@3.19.3: + smtp-server@3.19.11: dependencies: ipv6-normalize: 1.0.1 - nodemailer: 9.0.5 + nodemailer: 10.0.8 punycode.js: 2.3.1 sonic-boom@4.2.1: @@ -13561,7 +13589,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - tinypool@2.1.0: {} + tinypool@2.1.2: {} tinyrainbow@3.1.1: {} @@ -13587,14 +13615,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.10.11: + turbo@2.10.12: optionalDependencies: - '@turbo/darwin-64': 2.10.11 - '@turbo/darwin-arm64': 2.10.11 - '@turbo/linux-64': 2.10.11 - '@turbo/linux-arm64': 2.10.11 - '@turbo/windows-64': 2.10.11 - '@turbo/windows-arm64': 2.10.11 + '@turbo/darwin-64': 2.10.12 + '@turbo/darwin-arm64': 2.10.12 + '@turbo/linux-64': 2.10.12 + '@turbo/linux-arm64': 2.10.12 + '@turbo/windows-64': 2.10.12 + '@turbo/windows-arm64': 2.10.12 type-fest@2.19.0: {} @@ -13810,7 +13838,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.14.5(oxlint@1.79.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(oxlint@1.82.0)(typescript@6.0.3)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -13821,7 +13849,7 @@ snapshots: tiny-invariant: 1.3.3 vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) optionalDependencies: - oxlint: 1.79.0 + oxlint: 1.82.0 typescript: 6.0.3 vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)): diff --git a/scripts/dev-manager.mjs b/scripts/dev-manager.mjs index 250bea0..1b0c45c 100644 --- a/scripts/dev-manager.mjs +++ b/scripts/dev-manager.mjs @@ -151,6 +151,18 @@ export function resolveManagerDevPaths(root = repositoryRoot) { }; } +/** + * Read one env var, falling back to `fallback` when it is not set. + * + * @param {NodeJS.ProcessEnv} env + * @param {string} key + * @param {string} fallback + * @returns {string} + */ +function readEnvValue(env, key, fallback) { + return env[key] ?? fallback; +} + /** * @param {NodeJS.ProcessEnv} env * @param {string} root @@ -168,24 +180,42 @@ export function buildManagerDevEnvironment(env, root = repositoryRoot) { ...envFileValues, ...env, }; - const managerPublicPort = mergedEnv.MANAGER_PUBLIC_PORT ?? '4306'; - const managerUpstreamPort = mergedEnv.MANAGER_UPSTREAM_PORT ?? '4311'; - const serverOrigin = mergedEnv.MANAGER_DEV_SERVER_ORIGIN ?? 'http://127.0.0.1:3000'; - const publicOrigin = - mergedEnv.MANAGER_DEV_PUBLIC_ORIGIN ?? `http://manager.localhost:${managerPublicPort}`; - const gateUpstreamOrigin = - mergedEnv.MANAGER_DEV_GATE_UPSTREAM_ORIGIN ?? `http://127.0.0.1:${managerUpstreamPort}`; - const mailpitSmtpPort = mergedEnv.MAILPIT_SMTP_PORT ?? '1025'; - const photosOrigin = mergedEnv.MANAGER_DEV_PHOTOS_ORIGIN ?? 'http://photos.localhost:5001'; - const photosOwnerEmail = mergedEnv.PHOTOS_OWNER_EMAIL ?? 'owner@example.com'; - const photosFriendEmail = mergedEnv.PHOTOS_FRIEND_EMAIL ?? 'friend@example.com'; - const photosFamilyEmail = mergedEnv.PHOTOS_FAMILY_EMAIL ?? 'family@example.com'; + const managerPublicPort = readEnvValue(mergedEnv, 'MANAGER_PUBLIC_PORT', '4306'); + const managerUpstreamPort = readEnvValue(mergedEnv, 'MANAGER_UPSTREAM_PORT', '4311'); + const serverOrigin = readEnvValue( + mergedEnv, + 'MANAGER_DEV_SERVER_ORIGIN', + 'http://127.0.0.1:3000', + ); + const publicOrigin = readEnvValue( + mergedEnv, + 'MANAGER_DEV_PUBLIC_ORIGIN', + `http://manager.localhost:${managerPublicPort}`, + ); + const gateUpstreamOrigin = readEnvValue( + mergedEnv, + 'MANAGER_DEV_GATE_UPSTREAM_ORIGIN', + `http://127.0.0.1:${managerUpstreamPort}`, + ); + const mailpitSmtpPort = readEnvValue(mergedEnv, 'MAILPIT_SMTP_PORT', '1025'); + const photosOrigin = readEnvValue( + mergedEnv, + 'MANAGER_DEV_PHOTOS_ORIGIN', + 'http://photos.localhost:5001', + ); + const photosOwnerEmail = readEnvValue(mergedEnv, 'PHOTOS_OWNER_EMAIL', 'owner@example.com'); + const photosFriendEmail = readEnvValue(mergedEnv, 'PHOTOS_FRIEND_EMAIL', 'friend@example.com'); + const photosFamilyEmail = readEnvValue(mergedEnv, 'PHOTOS_FAMILY_EMAIL', 'family@example.com'); const otpEnabled = mergedEnv.MAGICSSO_OTP_ENABLED === 'true'; return { ...mergedEnv, - MAGIC_GATE_RENDER_COOKIE_MAX_AGE: mergedEnv.MAGICSSO_COOKIE_MAX_AGE ?? '3600', - MAGIC_GATE_RENDER_COOKIE_NAME: mergedEnv.MAGICSSO_COOKIE_NAME ?? 'magic-sso', + MAGIC_GATE_RENDER_COOKIE_MAX_AGE: readEnvValue( + mergedEnv, + 'MAGICSSO_COOKIE_MAX_AGE', + '3600', + ), + MAGIC_GATE_RENDER_COOKIE_NAME: readEnvValue(mergedEnv, 'MAGICSSO_COOKIE_NAME', 'magic-sso'), MAGIC_GATE_RENDER_DIRECT_USE: 'false', MAGIC_GATE_RENDER_JWT_SECRET: mergedEnv.MAGICSSO_JWT_SECRET, MAGIC_GATE_RENDER_MODE: 'subdomain', @@ -202,14 +232,21 @@ export function buildManagerDevEnvironment(env, root = repositoryRoot) { MAGICSSO_CONFIG_FILE: join(paths.managerRuntimeDirectory, 'magic-sso.runtime.toml'), MAGICSSO_MANAGER_CONFIG_FILE: paths.managerConfigFilePath, MAGICSSO_OTP_ENABLED: otpEnabled ? 'true' : 'false', - MAGICSSO_OTP_SECRET: mergedEnv.MAGICSSO_OTP_SECRET ?? DEFAULT_MANAGER_DEV_OTP_SECRET, + MAGICSSO_OTP_SECRET: readEnvValue( + mergedEnv, + 'MAGICSSO_OTP_SECRET', + DEFAULT_MANAGER_DEV_OTP_SECRET, + ), MAILPIT_SMTP_PORT: mailpitSmtpPort, MANAGER_DEV_PUBLIC_ORIGIN: publicOrigin, MANAGER_DEV_PHOTOS_ORIGIN: photosOrigin, MANAGER_DEV_SERVER_ORIGIN: serverOrigin, - MANAGER_DEV_SMTP_HOST: mergedEnv.MANAGER_DEV_SMTP_HOST ?? '127.0.0.1', - MANAGER_AUDIT_INTEGRITY_KEY: - mergedEnv.MANAGER_AUDIT_INTEGRITY_KEY ?? DEFAULT_MANAGER_AUDIT_INTEGRITY_KEY, + MANAGER_DEV_SMTP_HOST: readEnvValue(mergedEnv, 'MANAGER_DEV_SMTP_HOST', '127.0.0.1'), + MANAGER_AUDIT_INTEGRITY_KEY: readEnvValue( + mergedEnv, + 'MANAGER_AUDIT_INTEGRITY_KEY', + DEFAULT_MANAGER_AUDIT_INTEGRITY_KEY, + ), MANAGER_DEV_GATE_UPSTREAM_ORIGIN: gateUpstreamOrigin, MANAGER_PUBLIC_PORT: managerPublicPort, MANAGER_UPSTREAM_PORT: managerUpstreamPort, diff --git a/server/src/perEmailSignInLimiter.ts b/server/src/perEmailSignInLimiter.ts index 9f5692e..a1f33f3 100644 --- a/server/src/perEmailSignInLimiter.ts +++ b/server/src/perEmailSignInLimiter.ts @@ -18,8 +18,9 @@ */ import { createHash, randomUUID } from 'node:crypto'; -import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { collectRecentTimestamps } from './timestampFileStore.js'; export interface SignInAttemptLimitResult { allowed: boolean; @@ -43,41 +44,6 @@ function buildAttemptFilePath(directory: string, nowMs: number): string { return join(directory, `${nowMs}-${randomUUID()}.txt`); } -async function readRecentAttempts(directory: string, windowStartMs: number): Promise { - const recentAttempts: number[] = []; - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.txt')) { - continue; - } - - const filePath = join(directory, entry.name); - - let contents: string; - try { - contents = await readFile(filePath, 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - continue; - } - - throw error; - } - - const attemptTimestampMs = Number.parseInt(contents.trim(), 10); - if (Number.isFinite(attemptTimestampMs) && attemptTimestampMs > windowStartMs) { - recentAttempts.push(attemptTimestampMs); - continue; - } - - await rm(filePath, { force: true }); - } - - recentAttempts.sort((left, right) => left - right); - return recentAttempts; -} - function calculateRetryAfterSeconds( oldestAttemptMs: number | undefined, nowMs: number, @@ -150,7 +116,7 @@ export async function createFilePerEmailSignInLimiter(options: { await mkdir(keyDirectory, { mode: 0o700, recursive: true }); await chmod(keyDirectory, 0o700); - const recentAttempts = await readRecentAttempts(keyDirectory, windowStartMs); + const recentAttempts = await collectRecentTimestamps(keyDirectory, windowStartMs); if (recentAttempts.length >= options.signInEmailRateLimitMax) { return { allowed: false, diff --git a/server/src/sessionRevocationStore.ts b/server/src/sessionRevocationStore.ts index fe0d9ac..97092b8 100644 --- a/server/src/sessionRevocationStore.ts +++ b/server/src/sessionRevocationStore.ts @@ -17,46 +17,15 @@ * with this program. If not, see . */ -import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { buildTimestampFilePath, pruneExpiredTimestampFiles } from './timestampFileStore.js'; export interface SessionRevocationStore { isRevoked(jti: string): Promise; revoke(jti: string, expiresAt: number): Promise; } -function buildSessionFilePath(directory: string, jti: string): string { - return join(directory, `${encodeURIComponent(jti)}.txt`); -} - -async function pruneExpiredSessions(directory: string, nowMs: number): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.txt')) { - continue; - } - - const filePath = join(directory, entry.name); - - let contents: string; - try { - contents = await readFile(filePath, 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - continue; - } - - throw error; - } - - const expiresAt = Number.parseInt(contents.trim(), 10); - if (Number.isFinite(expiresAt) && expiresAt <= nowMs) { - await rm(filePath, { force: true }); - } - } -} - export async function createFileSessionRevocationStore(options: { directory: string; pruneIntervalMs?: number; @@ -71,11 +40,11 @@ export async function createFileSessionRevocationStore(options: { async isRevoked(jti: string): Promise { const nowMs = Date.now(); if (nowMs >= nextPruneAt) { - await pruneExpiredSessions(directory, nowMs); + await pruneExpiredTimestampFiles(directory, nowMs); nextPruneAt = nowMs + pruneIntervalMs; } - const filePath = buildSessionFilePath(directory, jti); + const filePath = buildTimestampFilePath(directory, jti); try { const contents = await readFile(filePath, 'utf8'); const expiresAt = Number.parseInt(contents.trim(), 10); @@ -94,7 +63,7 @@ export async function createFileSessionRevocationStore(options: { } }, async revoke(jti: string, expiresAt: number): Promise { - await writeFile(buildSessionFilePath(directory, jti), `${expiresAt}\n`, { + await writeFile(buildTimestampFilePath(directory, jti), `${expiresAt}\n`, { flag: 'w', mode: 0o600, }); diff --git a/server/src/timestampFileStore.ts b/server/src/timestampFileStore.ts new file mode 100644 index 0000000..cf9e6d6 --- /dev/null +++ b/server/src/timestampFileStore.ts @@ -0,0 +1,105 @@ +/** + * server/src/timestampFileStore.ts + * + * Magic Link SSO Copyright (C) 2026 Wojciech Polak + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +import { readdir, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Shared primitives for the file-backed security stores. Each store keeps one + * `.txt` file per record whose only content is a millisecond timestamp, so + * scanning, parsing and pruning are the same operation everywhere. + */ + +interface TimestampFileEntry { + filePath: string; + /** Parsed file contents, or `Number.NaN` when the file held no valid integer. */ + timestampMs: number; +} + +/** Build the per-record file path used by every timestamp-backed store. */ +export function buildTimestampFilePath(directory: string, key: string): string { + return join(directory, `${encodeURIComponent(key)}.txt`); +} + +/** + * Read every `*.txt` record in `directory`. Records that disappear mid-scan are + * skipped rather than failing the whole sweep. + */ +async function readTimestampFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const timestampFiles: TimestampFileEntry[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.txt')) { + continue; + } + + const filePath = join(directory, entry.name); + + let contents: string; + try { + contents = await readFile(filePath, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + continue; + } + + throw error; + } + + timestampFiles.push({ + filePath, + timestampMs: Number.parseInt(contents.trim(), 10), + }); + } + + return timestampFiles; +} + +/** Remove every record in `directory` whose expiry timestamp has passed. */ +export async function pruneExpiredTimestampFiles(directory: string, nowMs: number): Promise { + for (const { filePath, timestampMs } of await readTimestampFiles(directory)) { + if (Number.isFinite(timestampMs) && timestampMs <= nowMs) { + await rm(filePath, { force: true }); + } + } +} + +/** + * Return the timestamps still inside the window starting at `windowStartMs`, in + * ascending order, removing every record that has fallen out of it. + */ +export async function collectRecentTimestamps( + directory: string, + windowStartMs: number, +): Promise { + const recentTimestamps: number[] = []; + + for (const { filePath, timestampMs } of await readTimestampFiles(directory)) { + if (Number.isFinite(timestampMs) && timestampMs > windowStartMs) { + recentTimestamps.push(timestampMs); + continue; + } + + await rm(filePath, { force: true }); + } + + recentTimestamps.sort((left, right) => left - right); + return recentTimestamps; +} diff --git a/server/src/verificationTokenReplayStore.ts b/server/src/verificationTokenReplayStore.ts index 3916968..75a0242 100644 --- a/server/src/verificationTokenReplayStore.ts +++ b/server/src/verificationTokenReplayStore.ts @@ -17,45 +17,14 @@ * with this program. If not, see . */ -import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { buildTimestampFilePath, pruneExpiredTimestampFiles } from './timestampFileStore.js'; export interface VerificationTokenReplayStore { consume(jti: string, expiresAt: number): Promise; } -function buildTokenFilePath(directory: string, jti: string): string { - return join(directory, `${encodeURIComponent(jti)}.txt`); -} - -async function pruneExpiredTokens(directory: string, nowMs: number): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.txt')) { - continue; - } - - const filePath = join(directory, entry.name); - - let contents: string; - try { - contents = await readFile(filePath, 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - continue; - } - - throw error; - } - - const expiresAt = Number.parseInt(contents.trim(), 10); - if (Number.isFinite(expiresAt) && expiresAt <= nowMs) { - await rm(filePath, { force: true }); - } - } -} - export function createInMemoryVerificationTokenReplayStore(): VerificationTokenReplayStore { process.emitWarning( 'Using the in-memory verification token replay store does not survive process restarts. Prefer the default file-backed store or a persistent adapter in real deployments.', @@ -92,12 +61,12 @@ export async function createFileVerificationTokenReplayStore(options: { async consume(jti: string, expiresAt: number): Promise { const nowMs = Date.now(); if (nowMs >= nextPruneAt) { - await pruneExpiredTokens(directory, nowMs); + await pruneExpiredTimestampFiles(directory, nowMs); nextPruneAt = nowMs + pruneIntervalMs; } try { - await writeFile(buildTokenFilePath(directory, jti), `${expiresAt}\n`, { + await writeFile(buildTimestampFilePath(directory, jti), `${expiresAt}\n`, { flag: 'wx', mode: 0o600, }); diff --git a/tests/e2e/tests/example-apps-magic-link.shared.ts b/tests/e2e/tests/example-apps-magic-link.shared.ts index 04d0ccc..52bdbbf 100644 --- a/tests/e2e/tests/example-apps-magic-link.shared.ts +++ b/tests/e2e/tests/example-apps-magic-link.shared.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { exampleApps, type ExampleAppDefinition } from './helpers/apps.js'; import { clearMailbox, expectNoMessagesForRecipient } from './helpers/mail-sink.js'; import { @@ -21,17 +21,7 @@ export function registerMagicLinkSuites(flowMode: FlowMode): void { test.describe(`${app.displayName} example app (${flowMode})`, () => { for (const protectedPath of app.protectedPaths) { test(`signs in through ${protectedPath}`, async ({ page, request }) => { - const magicLink = await requestMagicLink( - page, - request, - app, - app.allowedEmail, - protectedPath, - flowMode, - ); - - await page.goto(magicLink); - await completeMagicLinkSignIn(page, app.allowedEmail); + await signInThroughMagicLink(page, request, app, protectedPath, flowMode); const authCookie = await expectAuthCookie(page, app); await app.expectAuthenticated(page, app.allowedEmail, protectedPath); @@ -47,9 +37,8 @@ export function registerMagicLinkSuites(flowMode: FlowMode): void { protectedPath, flowMode, ); - const tamperedLink = buildTamperedMagicLink(magicLink); - await page.goto(tamperedLink); + await page.goto(buildTamperedMagicLink(magicLink)); await expectTamperedTokenResult(page, app, `${app.appUrl}${protectedPath}`); await expectNoAuthCookie(page, app); @@ -61,17 +50,7 @@ export function registerMagicLinkSuites(flowMode: FlowMode): void { if (app.name === 'nextjs') { test(`logs out cleanly from ${protectedPath}`, async ({ page, request }) => { - const magicLink = await requestMagicLink( - page, - request, - app, - app.allowedEmail, - protectedPath, - flowMode, - ); - - await page.goto(magicLink); - await completeMagicLinkSignIn(page, app.allowedEmail); + await signInThroughMagicLink(page, request, app, protectedPath, flowMode); await app.expectAuthenticated(page, app.allowedEmail, protectedPath); await page.getByRole('button', { name: 'Logout' }).click(); @@ -88,17 +67,7 @@ export function registerMagicLinkSuites(flowMode: FlowMode): void { page, request, }) => { - const magicLink = await requestMagicLink( - page, - request, - app, - app.allowedEmail, - protectedPath, - flowMode, - ); - - await page.goto(magicLink); - await completeMagicLinkSignIn(page, app.allowedEmail); + await signInThroughMagicLink(page, request, app, protectedPath, flowMode); const authCookie = await expectAuthCookie(page, app); await app.expectAuthenticated(page, app.allowedEmail, protectedPath); @@ -166,6 +135,27 @@ export function registerMagicLinkSuites(flowMode: FlowMode): void { } } +/** Request a magic link for the app's allowed email and follow it to a signed-in page. */ +async function signInThroughMagicLink( + page: Page, + request: APIRequestContext, + app: ExampleAppDefinition, + protectedPath: string, + flowMode: FlowMode, +): Promise { + const magicLink = await requestMagicLink( + page, + request, + app, + app.allowedEmail, + protectedPath, + flowMode, + ); + + await page.goto(magicLink); + await completeMagicLinkSignIn(page, app.allowedEmail); +} + function buildTamperedMagicLink(magicLink: string): string { const tamperedLink = new URL(magicLink); const token = tamperedLink.searchParams.get('token'); From 175d3bff273858ad7bcd26d2a9c3af255f363d04 Mon Sep 17 00:00:00 2001 From: Wojciech Polak Date: Sat, 12 Sep 2026 21:53:25 +0200 Subject: [PATCH 2/3] docs: add Magic Link SSO social preview logo --- .../logos/MagicLinkSSO-social-preview.png | Bin 0 -> 101327 bytes .../logos/MagicLinkSSO-social-preview.svg | 61 ++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/assets/logos/MagicLinkSSO-social-preview.png create mode 100644 docs/assets/logos/MagicLinkSSO-social-preview.svg diff --git a/docs/assets/logos/MagicLinkSSO-social-preview.png b/docs/assets/logos/MagicLinkSSO-social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..b05da8b569824fab265448069027dab36d325ae1 GIT binary patch literal 101327 zcmeFZgsPNO8GJu~v3m97 zn~aHL#0T52V|FqEy;U8A1f(=(HctsO6SBX-&z!Ts7 z*g&7M%l=3od*ituiD+%gKP3G{SCwKW<9DpA3}U|mj3gfrd!^Y&3#)S4j07|E>;Wd4 zKgV$Xi#hn+AuA_#5#1Jtp#(H1F$n7de*N5T9eZ(!1spcz3+w;8U6QnLmlxInPR_e& z+I;4N51NqM#|(QChKPq3+W$iJd^+&4-kJOPXzyZ0m$B?l>iEw`I~QQ!g%P0cAxi5% zE63YP;LrO%{e7wzb~?=nT$d-05dsvxDm+Ci0I-l^)BhkPF{TZ8T{0N}u^aFwv#ueR zf5ZYCnJPF#r}59i(M~-7!CFQV1OkDu+r9wd=ub}HNL2nP*PdaC_Tdau;(v?nn%18C zt~1YSuO@Sp&R#JkP$@nE{KAoG1PFMWUmyKnW^zwU%S^znUu0dX%~7=CxqA5QT>{a& zC5|`$Up*k3qAlx*~WXtAlT+G~UC#~lR; zis`1h9#pwvMBhiy6 zN9zAvv$;+Cy&}vKA23x|-taIxY}_UB|6mTmlXK3Ryu6PjR|!G?KsnR>pL-t;Ko-vKDtdb=S0j+ZXK|xb>7(VZVKaccr`4H2(Pl9X`aTwLa)SkX3>|Q%`2`{uR7h&!#F+=igO(ZieE; z|9eBc2n8YMe^_D4M5gkeo9o2sVgFgu(UKoP`Y-gKHUj|NztB}7IRM^&p>4?3|Nn*m z537GdTIFEZycedXHU524x>-`5b^U*t=s$c;kN^EKz<*Ek*0sp0F0%jT=pWv){9~3^ zEd2P_?3X3}2b=}xbumpOQ<}7Y!+-ln0)Mg{pB)&f{eM4IalE-PPB`-ZZzOmAhXnSU z2AWv@_I5s79^T)VsQ$UM)$^uv?tWzZ<1Leto;;Fk=^8`-QG&6+{K)2$`?R8So=GAC)NM(xdfa`DTvHuRHtSwbTDyqfLx#(Om_L=*q_ynMRJ@puK;sbn!Alf9s}A zvN7dBz2W1)#4ngff_S$m|K6SK|L*4&y46|#(W+AaM@%BBnVwdM^lt#4{{he~{bib0yB3&@`#*d) zw*1J0y(0j&za@YFJb9$qX5MP!`x1%rKd6$!{y{}(ta~9>er#G?gn*w2-#KsMMx$~u zUCA)>zH0-C&;Q2=tJKPDOat^%0(s;t9p9YE3QoGP(#qRU1zy{YjqN}3GX@%#C?PsK zz`lO=QzP1Rezx9kTcaY7$>Bsc_r(9Qz@8r}e_L18k1~y5@LR8glQ_XW_2<66*#4;J zQ;!3ebP#a6|98(J{}7rMP3P1XbGUT_x9$7kVzgN5HFt+{Z*k zAl!!EcmJ~h>K{I)#jo@rh9oPX@JpfEO(^>2KFoO8yV^g5T&b zj2@+H0IZTISY6(yb2~?C9ZpC)>{iwE7O|8HBlnYc}1}oh5V#VpHv0zIq4o&p68?KWc z_TmaIqhT8#m2X9@wlDP{9^2-hvlL1toH~Drprjs`V9kK6Vsp>8T;E6OBK=HLhS@o} zcwa?;kH7XsjkDl#zUP0Lqs<5pyT(7T zS)3iinkeCW>LHWY>2V3&0~5wKhAMY)5;rCLYe#>c32UB8kj2L*f2ntB&8$10Y@_Nt zUpy3xLpeM;>eRgNHs=vZ;wN>jW?H)$ud z;ozje;Aq{W+`Y^R@4NO6vPQx)M>wDQ$Mr8?&=YE#biS1)`^bil%jS)inYzf`2?o*^ z1#2EX%?4uv*p_n^ZF zw6r2#-rKjbZ{Km!P4}&4gcV4B6W%(mXCs}U32?fV+VS~3Gw63dMHmf&8&BME>b?e$ zPR%fq%Dd3mEr7hngnn5rKGPkS%NcuKFQq9k`IH5j)^V9Rck z!#Ag?XclExkyzz76iE4EgzC>6Qw$-{?rU?hE#2VP@d$t>^c{G7CzLiY_<sy&vv2ZTe{6U}J(1lI=*MWp_OiE*9XmF3}_%RtNxmz>MjIteHtKi09)x-z1E9 zvYnVa9GGm=yz!0{`>_tMLGy2nN21jgBr6XxGuy*l9< zV-A=Oi23l$YdAiT9B&1_HZw}GqnH}m4FAIVD~E}fQAvnqbI$#1=ayt*P$S1r&1{i7 z26%s|*mckv%aB+B3t&(Mo#R!G9d~L1QXB@PF)%(x0XDqKilaD5HaUdoQ8oi|b z$l+oVqex!vRabn_CrZ0h!A_x3a=6tW#n*#63e4A$OP1nVHnY(mp!v%}g)2~J2__!cWf@Y6@8h7CTP3yq6)`d(NY{y>ho%8yzatJR6$`?kI1g`6<4 z_Z-cV<_27WSHNGVJ{f92GGzt@fPZNW-!)a`I&ZxsF2?DgL^OeQHk_aOq-P~rI2d&E z*ldd-NjDsSY4_au61^)lV3SaQqFUmHQ?V8liX-{Yt-Qm>wl1TNYO!i-oGa?Eavfx) z|MTYyP577Ae<;T}kfT2-Lfb7B+C<~f>g>I1E15gAf#$>6@j)fUA|Ogzouz&w>8_=< zZ{gc{6ujvv61k_ap}C!?kJ>9)&dxD=0Vft9P1OCC*spP96tsb}s7|}@`=$kYc*6uZ z@%pI!6=%01-cg+Of~-vr#Uc^%lJqb2^7jH zT=1AgB>uR?dnYL=x2LZP3f*=v4*UBDH)2STl80g` zK?@j2beoBSq@?(Lc?VOxp}Qe9n527V(=O6%hC5ASC;o~T7@Dd-8}Gc#scG_4XHR(Y zI3pE6!%ZI-D)hHBvjJmG-Z4#<8ws!$46e3Nzs@+ew7RL^3Iw#&FJ}RxRs5GZqFG$- zt6=j$Q8bQ56!E0ZRc$jzlX74)O6Yo`2$GI~GuO`H=OB$=RNYC~Zn%oXo&57q>G_tj z^P|1bO6odv`-3@Krp<2agD3K*=QnhdWF{Acp2E_fsAw?Lsu`UB0-;MoV^vFHsWd+G z@{e1Fxj*75hf6DMxJ>7 zXpSWT=tKfYR@?&U+u1X#HLC@vwVd952F++XPj0&L`{ zGv(u~5Xke?qj^^$%fw;>L7Z1IMuMzPaVL018T8bOxS@prU@tkYK+zs+s*97 zg%TWZh0}>AiL7MfLX$D+^_FHfB2nacVsX1t22aBgC3uhz1kydckTl6@9@#g=DcEFN z5sC0;hUKL$pW|bG1>;qwg^bhsj}(0=lsrg2;@q<`7Cj$)eCGq{_i}#Z1Myq|fJ+NT z#X-)wWdYFfC06B77W<=SPV%-vhwKlvQE-D;%@pbJ)&~!7pa@!}(zm46-TU0;5=qR!{ zBb2pp?S&q#z`yd!v2;aLg)q5fMH2KmVXEK_=pXQNAh$C{F{pA#%YfbZ1~>ZCj(2Bx z1{~4##!{)EahA@ot5@sQFbVQkq>mBGrOqs`ZFf!LPP*9hY4s*C$pd10ZL%ig$Y=B0 z{z6X|kTPNpJ6$K2;Mn2~dy1_bq3{GBrVABhFI;ezeAFcTSwH=};EcYs7Ok#29$DLQ z4MvjrF~PUQ_+NI&$NQP;vWh&rw^i<-<^!k!O2cAT_NjarxcH+}20Zb*_d<$7N~}y4 zhJBw0Hg$a_eGKjD1qlIOV_%s;An`CFm7OW#q+{i1Jl^2=7~o%-Vi~#S7n;G?v8W;F z;&4C=u!IvOC| zDK>_jJxy8!S*5LdB;8Lr2%p{JV6bbckuq?2Y6}L^-bjg9tfaMFuaojc-dKc6@4d_L z7xiQ;!Nez{`^2w#UVG6eobGaib*o`@If!XQZ6CIm7}zoY!Z@-Lm6>2m>~C$YWPyzvqjT?? z!Cx_XR!n+np5BU9^RD1xNj|iA`U4?89e(GuopLC}zx$F3>A{H(fcdgf<+L;VoU$%& z|8ZVmhmWftXBESmLQU{{XiW4x_E@Z#Rq;^n#wTon!R{$%!`mkG9d=f@mX5=9NmnLg zu7B0Xjm`AIbFfBipge;4JP87P`6|cAf^?>qya6p) zU!F^cGmvkcUILeD%aGUP2F!@9>We^H#F;TICkyObkmnZ}{`lNq>AH>Q+%e;WwBa5} zA(kSmH)!p2WNtR>-iurba{U=bZQ=QFZP^+-BV#cInEMNPlBx11HqazEb7mKRLRKtj8HOid}1F6P0z%s4)& zmIVc(rjOzaAE}9bCVs|M=aFzVhBpR5HKmK6l#DO^o80w+YDpN=D(gTfZaBojYc`Ss9)99e@CBqN}RWY z@B+}*d~_Al$_n3`-`yKcEli(x>faw_#ad|_or%U^GeM;+7#S$;Q5Zmxdl`?j>h zfs7q*8FCsQx^z9QfU~|or7!=g@(j~#5q)VPw^^~VUE#)IPlaoD82kPmvco~Mgyx6( zef0D3zfAL~K#TjaJ4^xl@7;c%KCidJFvzw0U2TbLa{%jH^cYCLr0_3LeC{ZXw*lJ~ z3e^nRKDa|rJ*)WJhje}1_P5wlw~QvH<31ElDe^*rbF1eId(&s|z0m4$&Iyy1n1yZT zgQZ5r2S62XU#KoB*}nI(Vj?6>^>;J5)O#|qIsdz{SLb(wXFU^tM|o7-Ml)Bh9O^Gj zl8k=APk-b3=3}!W*Vg-Y(XxHgI*9%BY%cVnn?~GbatSrcaX!{*0 zLY#Fq*@|tYDr)F96X~CcW3$F#B_*P+S{tzPG!Leu;Taet%Ec_Mfst9x@=fKxK(+hY z%weB4TLkHWv9D7%*f7n?{rCJY7$y!*&z62>T_I>FrfZ!55*7r^?rdSfr?>_N`P2vP zE5`27aao)V2}ml`hSgi(bVcvpQ~zp?{hS^6U}HCA-WE(qQDD~1@f08Onk@BGsD(Sj zDmHG8xS()qFL`ERr5hA_F*^qsP+B98f8zhR#bnC{vQELPK{zRVCHD@GP7@cijgKK? zqL92Dq?QI`fXFjCc3M@<5a!lle!tV{<4RCD>#ncN2X`_&kSIJa&Qb9v%c|UPEjt84 zQ9kQ_*Lk|0SZ;Uf{rk()`OsEhEiC|^iW7r=rVmg%;8>X5XVsY18~1zng2#-B>dHyY ze+rW`LOmXu_1kgU^ANb45y<%~aezbN=7tyHATtrFTU*sz$1wAoGhORZcQ``+E_8yp zBWY6jt6T(v{$l(l^o|x$34JkgrSz50>6>R_(AypsCWw8BxLj_ZaW3E=!RwDgK5WkSjP|=I=}_DV``%>4X|IY;6i9+Elo( zBU9nKdPlQ7M=vgeY{TvkSc1(L#>!Wk? zMn>Z9c>kp*rkytmA1(Aq4J>|799Zm;0=41uL4%LT+i#O2-849HO-orGTOHTtGA^uE z!R5;*I2ZJ?6{GcSoCcatKtauDO=21FzO4;H)C~5cnJRB0TB<|%?)sk1AMarYbWtM0 zDxvC-1^^pXBB0D)tW6a#W=>5Kh_=L~D)F^}0!Uma|EO~@dD7losurJrYLhz%KTLjv ziMhNqCO{3Rovd50TG{d?hq~fc!;s)aE+k| zeWVdYaMJG2GItyMLZ6cnC?P5Pdt8F%{gshfY`f8J`$MCh&K#k$#d)~WgzSsftT)ND zX4Sro<$qeq_gcQ6(3zQbE)8V0y~pkUQXfM?t$0PT>x)Wb5+G@CVzfEzpEFt9FOn3N zoR(20o0aM@p@ENRX%IJU<@5l0!j|@ewsSaJ&^I^^QtA&FC5a$b!4NZ1Y(LEu1pxuk zh;MmUW(w=O9dwMZKY!&FL;X_Qf|2zC+(oscVUB2VN=TNdQj^y@0Kbo#{;ML+5cf%Af9qC{ar~z5< z^T0_pHC~7gwP+sjDE#|j1Qf$OMJk*Jh$@05%soMq@d)*yv;kxVL`37evoY!AlVR!L z8frQ7TI#U!>E@Cum%9{SuQ_q04|;!@6+kqHU64A*Q0p(YwN)ldw!9$V9Au+p`qA8_ ztvaq^nCa$B*Pk@?jy;sv&sz-c{StP=Q{gBaPWu=tc;}(RL(fC88mwIHTk4n4#XAoM zEUL~=&y-SpH%S$b#4xm#;?yU!uqJWpQT3l<6hH-Z|32^kW&sE`nTx7PkX?I0N#^nJ zCGP}Wd2e!ECOh=Lz^g30LKNGNpUy#k)Sk)7z*;dxqlkm7_F32V^)vJ3M`HRIz-sy& zO!2HmZ|SR)w^6Cy+T*j$Vc#mmJ-wrEV*^WT)O~ACgrOXm9P@0&+wv|fBt5TDn({P= z#vVZ~YbGQ;QmE4*)#CUU%NOwMEcn6J2^2upvaER?2VJFV*((G`&7^FbZJ3)r%~bFw z;9fqcjS_+x-@WUSry+#rKU?KVldPh}rYb3MlnXE4Cuw2D zdd8>k+g~fXf0ja-x!H!=qeu|QHncZ-e+UtH@uSd^{T^8c*p3WID5uOwFz-4M4WK{} zGtV6ue*slI#)UtrYnJx+@Bj^JmJ)LTS{?v81*|Kcf!G|OZ}%3u=GOW`du14P^9tW> zKbFtRF`Ihepo`hFVd}GU9(X9hAET)Pg0ipB!#(IZ@#7a^wzan?X>tkmfJ_QB0)!B7 zh~?*JmTFzr&j}#{!1sV-I&nr=&UARhlF`o8`*vLKEN6>)e$f5WtuN<&gJTR2;Xnog zru|4Ul$gVt{*1$)fLhs)wVMM<=WgCUiObVZhATv{9{{5t(c}p*03qie*tw2-{rSo+iv>z%8O&53MDlY5oV=V>4C#cdeqs^#?;fz+{Lq(v zDPC}lZ}u7+QW&>zoCl>8HiL-@s~`?3=SKy`54zRXNo;^jqVm8Og(OHrX4gMobkNEi z-z;&bUxxI5rBTL0O}PYGq{u-GSO!p_MlcUxdY}!o1HWZCE-6K~lv`z|m=pQ-i410) z_YO@}zcAUKyJ9OD@FO~0PD~@HaUy*&z8e88B%pvQ*|$H&Yb#_r`$8J>&WbY;I&AJX zI0oRdu0F&w_(WH!4;Mt%PpI~dY8p5R8M(P*kc1004U^Q z?-6o7N%PgTR>V5q=Lhs2#&_v?c4VO}N9dpnz7|V3e~q0nypW#%ao(dWhHc`;mL%aR z?k7M$C4VQWwC1ZroIXswi#8GCnTj3>8AOMS$X}hGNnp`)FR8bO@s(?3wxQ?D%Z+Op z0JGF?bR|)!C?%5w<-{ur=_jz-c$iA${F?iHT+RGe2rhpZ;Z13g!ydKCb%c-?!BNjsUHm9hISSs*Ykk@i z&nI}bD0UxddFQI_jqz)VGPV1_2|GRm)YHJXTocV==q8>hsr!J_WOq%xk3%qjI-0c6 zC@bn=Q5ITCrcO)D>>pqFm556l_FNw%{RRQu0%KVGR-?d=>>E^jY(I$Z_3&t&{o%Z5 z8V*QLfQedFf==qAQR%#gad`2?HbfnjG1k3G+Mi9b9NT$Ez&$rrtqO@!?XoPthl>xDhxThJ-gSnuK^7mrE#)f3*Ig zUO%>((QNm+a9w5nspOf58fJWqxahB}$0qIhSBAmo=BMg%0=7&o!QMh8AguKALY|vP zg!=QfL$WN9=l0bGzaNTsQJdCFo?Ol`{4v^fdGV*{K7r6p8#{4gG*c_2m2H^8AK#)R0d0MOA1<3hJ|5@G6CNVQ$g1SwH#8Hlo?tyVcqwN< za<8LD>AVK)`R9-0bd0FPTUbb zRQDR<=xf}@dpD=d$T0ETn+h}?6ZqMSA~lP6ldGf|M2M>{a|6bk&6gpKn-YVqzT)V4 zV#`=|&6DEhz*hkTGO!|jb-_z^QVRoyCuC~R zGfFjFhTKBRkROx`} zC*`;-!n)6)h8b{qiigYj-VS!kfYFb}f9>o`xx4V<>I{bKX^OERx6S0GpEm@1nmuZT zhQ>TUEC^l6`%@kMloBP>6+-ea_i{k?i4oMk=XWNud=-ul`?On42V?=KC>BVC65#Cs zI0rTTD(BU}i3^4X2{5k7o{JN#m|o+iE^Y&4Y29`=1Oo3%46*+z$g^&6DC}t(6f!s5 z=h;w_dp_k-O@}AmvdTTBO(GC)V}82EjX_sVs;!fbnlPU5-~}eS#q(lDUr!Qk#g>K= zlaDP<;L@R^h4&fFr#;_pR305sUaTdf!%0+9ex+-u>P)%(x)E%FKyP`$hUlO|LV)q3 z&v{H{`dWUyHy1@S4^M)zAZaYAN_@ zEB#m;j{!d|^ZT#y7SbsTUp^c0+VkDh&##Zx9xb&gfjg$J=HAdY!|%pUj>U5*fjaOfD4*j@Mb(ni2DiwW0gZAhE20{!1A}bLU#)&DAeTmrDFFz4Z>ZxRa3jfe3F&J_gZhH`!(TpNPf8U8$^6w<`IXJI`RNNO2l z-EZHU;(n9+YxTERzFM`w_0XrrvjRHGvEpU5rrATl@Q#hefwvMw`vTt=c}QlO9$3Tq z@dD3FUMCVfo6iZ0J0U~zXmhyHA@`usf)w3(G|%T{lAMTEz--cq%g!0+wk10jHymCt>1t3@szKfNrCW6 zw<|3z58jM&dVpWM_6#!rDz{FBu&4Kx-}~vY=Zd4XEcEwShkQtm8VA~Ny-x$g69K|#m7Y+3<$?3ko^V=f%pLT{pSiR!RZcsu8(LrROPj*s!rX%~mn$s<&&0lW zo^O;`h@RWN=f4qA0rP#qsZ4pWKBVpAxr2K(yM*?HJo}@oeP_jQ3esPMcF}iI|Kzfo(n?pze!ge^W8O9*qG3r%$bNO$_n+w`~3R)3|q;=vVE!PiG=A8vyv z1_V2+tCfHEq(sEwMKeKm@mx{SapINAf`i&T=j=gw6n-fCXVCL7@p1Do6k`C;XXUxx zR7=*mE(Pc~&Jup<^en4|<;`Zush?~(DL9N7x1lLwkxlTXdI+!Pqq_+-g4CL~bCaMM zk71o?8_kGCmr~ZLZ-fwKVf%(;D%Os`M30zv791zU(hB$yY{BRCy(#@8(dM1gl%;1* z+Odk$%?`NvACj8#kyHG%)V{E%f-b3`JzzEh4{5Ok3cW<5F|r0w~p4cTkJKQRXpAHQRwkri6|;0LuhVP21sTR^A4 zgE#xYc&n=;(K7Sx09s!SM*;xVll+o$bAqQr`4dK3YNijom$q}lx+?GHKM2PYk0aPF z_OHZ)^BUeqe>DoDxHxrqt2X{~XMu@#nFm$1@R&}#r^O7fpa>MxX5ny<5Kx;)YrN?i z&Pg#Is&T7W-fdChGYAT-uI}GLwT0YO^jMT|A?wseBV}X^?l*A)tJhmy!NbDbl}XUa z@S7U-XrWw+C5LxsOXNsSZSw;<=U1|NHRb_hOb^mkx2Jg`RiCPs0$*BO+%r2Y@N9FOOOW+d zdl$gIhB!%)Kk665YxnkyFy6YI-5FGHd^H;IDx9naxlxT=Nzpp+B$Bmd4vt}_pn;}H z#~zTFbbnft!*!%?U%MCf(4eOPo$I?2DHguE{ZY6xv|1(Wn$TU>E-79iVgy( zL4Fh`YB8=^y(4aYJ%|=!$jPv|?~}l(#R_e}$K0V-w>$W>eZ#67H(@bvi;^PP%2w{y zkG>7(cD=QnTdWyQB#e-w$+Ke#60ccX!xs2c!v*j3D)7WeTTn#yz<)pto->8t@aJNX z*P!}=+4{Cser8#@*LmOo6}a?U2y|IOGlC*Qq@Wxj;3oDAbkyPixSi%?E|2V+j12}4 z;@MO)x9^+h*4nlMRupqTaFAw4C41{=;BxIZf1lFa;@jk{kk%erA%ImRw5N;nb%x5= zSgs3+{Nij-`Tl?)blj4QC?-%5t4M^!@C^FycK-C83zHHH^mzMA|I z?r*7?ABP*%J#l6chn|I+ZIB(mm6GZEv7Y?oNmQ5YxfKYA$7nnrK750b$*#&IaJ_G(JD3l}lo<<^={&zg$rKAD7Km8Hn zQh}*u({O$UL9#tNgNVor7@zYZnZh)0St1T(gs1)GTz5au&8R-mH9Wun+|jhy*|6h_ zM@C^NzZw!@2&PC#Fji?e=zvJwgQ%C+U= z+RM&6=ptjho1%;0+#w7f{}$O~r;@(hu*IvfCXh96#=H>urZOq zx$~f)e@=0 zPQQQH9x?b+ELac!(T8jbZ9Fqgy~OyZ!neN0*H05Zc$n|Robny88spOTQ>1rx3~a&+|khMz}(FEo>& zn}_MjfPQ(GX7J&-;^hjcTUXO=z~nQ4e0ZP`-4*_Y^Rk74-g_%>j&k_Wqqauh5UAtaN+^zduguLyfFEM-nc1*%sAw40KmCOS#|tN zUs#U6%ELu5VU|4KE9HiaUc7AjI5yDGI?3{1VHvY-JvjL?JvsMeRPXz49iWwSn+__G zFCWJ&NH}M;^vA7@yItv>|HrKaB1Pw#xelv6D+IOsi~_SQzvwxdT-@B6nM1xY)?@hW z(aK8Jmq&^Z!cWO1#odn=Y_e5tiOQGaUeEgylrNrbjq01lWVm1f1r|O#i7SQuvAejtEimBB|c<1??{~#!X;o)Ay@NzwIC{3J3S5qZ;F&tZz1x=BULkMx%OP z2=vtrxm0Y?{x%wO%+ScC9D2{iY_PQO!JrX1|G@HdJlg=f{cmI0RVuKC@ST%8pWtf8 zy2+wWyKgdkM$nc>~ZDKBbm@qb={9Y>Sl7BTe06_zlftaIQoEQf0 z)dExVm%?-q=UN!Ku8?x;(jE=VM!$nw(ard{_RSc>^>l!62gmX*af6E0mq)(JCwPP7 zp-qC3Im7fTYIHD?)>~slMP?c~hVJSFpNRLmr+Ks=&t2U42|qHAcdEu*@lEj2_BhpB zukH)EVve#jPeQ~1`>{y~6918Yn91WK z>1Get@m~lIp7SCLdwyXzPR!xOPlm+3_S7n^88}cbX4EZ(MH>^NlpsUF5&c5Likd({#ZzeBb7{Lc;JG`5 z8mG9y$_wzI(YS(0s_C;!Yql+K=8vM|B9sN1c zw=NXtj36SfL1kll2%1htg;mlw%B=@x0jx(3+`=4koHI8xJ}oiEh2IH|@2CE2y3g?) zGpPKi@RUoYkf&Zt1QKR%;q-?f?h6OTTOYaDnI)m}y!pq^?AMXV$rvN>x#H?>BeB$; z7vFM>XOk`8BuJ&M-|BJzLF~1&<%(J22yDbEbF{AI0b_uU;Y9C-BD_1S?Ai@pFO3BH z6J*PcDkGX@aVf-@heMcY-E@BQ?YhSi!bjW{Oms_fqv`X!#*>G3+=_br_DPw z;A6)bNhgv*{=Dft)a8En^Zjb2@4CdFS5E`#R_-Yjex->y6IDJdx1XVX&%?W52uDO; zx=Ab7tD!>1`LUn3^%lka*B;(l>c|W}btye(()HdS1|BkypUp*$Ko8s~uo!PKj3Yhtby)nQRT2Cs?j9qwHZkDf6NQ>s zB9t!H)MF0tX$O0;6JGHl#?!p$uP&zWwJW+c_<%K-tky0PD*%l$9mj{H8vT&YBvN)C z)F~0xUC}Uk-bf{1GD(V1CH$6LYWEButIV)GD)19}9+z`A`#$XqOSk;#Dr#F_|03DQ z>XajZ#A_dDcHWbv@+k9o2v)oU>iq(~D14dvB-PqP`~!)!Y%Sxu!i#t<^|eVwt^A4z zZt-pP6hF3C+VwZN*}5$|YA{uwJM-YE_1L=2tirn@nv*e(6(ZE#Tj_iS*pDbaXN{x& z-&no^Xe$}N-z8L)`r>G`r0rZ9auC97Mfm#!eKn7-y;#ETHy_SO5PlT0z)9$2JmjK@ zKDpw$)yMRB!X8k~HZozU3&$!K3{mQz|4GVnI`iAH-`Ti2*4{rESjxOz(}x-xm%)y; zBIuu(56TS-M?Ew>U*tS*doL31n>reaDjh!4`8c`7eevM++Q2io6M_t1jG(rJc~L+5 zJ7k>Q>~IW>92IibPj3j0d%duiiJS)eF|DzKc1L{Q9vJx^$T>!W(tUt8zh2#bXYmhri(Q`y6i8bv^C%_PUO!&erVgHbO1!oh;h7Y5*q= zQu-MM9~MhO0M`Da<~xF#9`nm-wJNse^uf{2Ln+Eu!%FKgCHBFk39h;YW1d(xveHxH;TbY{bkM+s) zlT^`*44N}i&jQHO7_`M=$%c3ZKK2QiMmTLo5wRo(-F?j#<83PC!60kcz{S)4zgd7J zmMq8Hl7{&vGWl3CUM(u0xzx;CMRCDLYg-Yx11Gfi_~N*Aobv2MR=EctkM$YnI~uxW zNRrRN>=WlV>yueFIMaF8ebl}(OCmb?=BeP@+;a& z!V(9FIP66tj-xU!fJa|_#XaZzQv+rtq21p7;A z9);9Ec*+-LwiyGhqT@IzlJkTgPMFMDhP&gt|>v}lip{DW0uafSVq8tO#Gke9HhuKW|Hw2Gm zLntnOcf`v25oDi}ke)myD)P7O6%-d2okSX{*ycR9^DV;LHvTzxMY`S!4M{s|VSLl~ z6&Kg|Qp{ujfUh`@DLt)rc{oB9eOM(#n228c@o~1=f3I~JoQXmziZH>=ReFbmxXS60 zwFykNg7t*Rq@*es(+JE2hQ`^m(EfU;5#MS^v*z;8Ty9s5G@Ff@UteEs_4ga{kBI{h zK0oI8l|m23WHzp=ztvo5Azv#!-yvNq4<4O!WEn}YgZltQEy+!21DHg5ST+zlD^}UG z@t>r^n-^+c3IO{=L~gkUxMyWunSwSN$7K(Vl)i5GAPZ%`}XM45B zn)hmYZL)OkpUH@Z)MxCPY954@Y(6;4@ag?MaS;n{8||Zd05AQc-LSv@`14g1wKZk~ z(LZ^?(zUK!>_CD0ehromu+la?6z9GnqWZ1WPsIFQMKoCFeX0%P2R!gOzs+W3-tv&& zZ_nhfNJ4rYNd)XSVOkyf(ThW6Y${?XVM*E|drW9N2?Owid(fST8{P_%snsJrT`F=! zC;srHqnjj_W>^jbb)XSfN)s&9)~8n-0E^?rn%*0nV$IA`m?GrWEH%;BP(>kG<-j%n)<4R>HfGHK$F4hl(tYu{31 zB%_x9T!4TG0x>G_!3-2#y0Lv|x( z!B4#7$?rrQnr>dyy+FC7PV?b0vQXQag7IQ$aA_3SufH=Z#V}(74$lBFiJkfk`>XH4 zyK3FZpIVmuSna(>m~fwSO04r&mN(-|@Y_PhIP1X!x8DPq9ccpr^lz!5bJ|DAJLum` zA2;BS3C1wKcN2p?v72-jA&z;!|37SfbyU;u8}>Ffx?8#=1%`kiwEX6M)42OE&JO3q?)vrht98Mz=wwUo^gbe2+e{{=EeOF z`X>6s@x+CZ#ade6LKr^3&$5bx#*I=3g3oOCru2Wh_(?unz=`$j?9iT?aMjMmd`x>B zc$9T{t3*72!!$)VBV=`#z?hIZY(D4}ji5yHjgFid#L8IE(R`3vw$jb}?j$K1kEFn4 zsdfd#Kh-Im9!NUVPdpiQDe!r=d!JyJPU`d7xVDB}u8uipOMXwQ6MMpVQ-3+UnyY!I z_3M1sRY8wq%3ZZB*?ZFpI9_fYa^H?av&@E7Ahy0dxq8MlSH0OQQ6n5(P#ZiJ|noGO0;0lCOPHot~I9SZ_ ze0jD{hF3KQ3I4&q4yp0*V%gXcp_ywD`3B8WP@J%(aXRrBwOvtp+5KGI&lelPlr+rp zq*Y1{Lw_MK+wZ+*B1zbpPEUrnkK2z{U#p%PGbiMkFKPx6e#CiAJV0<(|2RO^yt3|P z5;G(r3eHJkI_ca{;iT5~{2@P(W-N$C_{7dP@=vzsJ?&K?^bZ10vyQ=jKA{$?Ll?Hq zS~iI>fopXV7Q}|qnZh)=CT%6RCy-_^?B)>5V_P%TCk%VH5t}lZIyZYX zop&#Kn}|a`fXCMmh9E!a9dZ;^uoZkX7)&S6S10l)jY&GxM7I3bvvZ4&U-M0;5QAQ2 zvW}7dl#|x=W~IpT58KS1B7OkrgM}djG+Nw|!Nt1|9;>MM-91nHhDmKn`T%KZmj~pISpl zax`{35tz)#c%)}EGxheFTPz^}& zrIV=ewF;>nUfaMWlEi%wcYK=Yy7nOj8_o&h@B=U@T?oV_3iwN@LZ)(D@|zDqt{aGlY~DQ3 zZGBnehNi3N_}JwhM6C`q)Ohz!n_po7w zQiiL6PV+JhKO}znYUP#P+MM$COinRj*%VK9bfeMgkI)sMEckfVcUoCR4w!0i9!PZN zOx<^WkL;w;zFwb6X2N{3jrSN~Sr$%4DmzGrWHUvad#o-?CI*g1H$^`oUdhl98J9eG zl3Op*DRVKcZsv>od=rbh`L^|dW%@j7vtwsllmizUD}FL|G&uLRP(xDk z5gQ}-ZGDks%DA;bU%)Q{q5OT1?Yw!2>!|KDlYfiLS}@7qFvj>#7`yL*Rgcxhd?5`y zc?tTh@5DW-9zVJrgrW1K9eDBnwHObSF0j!32eA?4Q!H_rwqNU%(xUYuI71?iu3O?- z9uI*_@DMoj4AjWKqb#wr|1KO~LBV6-$-fvF36gy^VdCYN7WyHOoI-Eh%-oe96F>&ON>gxXb`rFn4zQ~AL8OpGsQVKDKiW6|CaX!)w!DG6F%dF(CJv`RnC@%m3Fm6 z3!A{TzlgRnj|(U-7b8#ft4KpU6egXmQ}zl>emGm3@(aD^iti)%!8WLt4bMAv9AM0U z+>$WT1UHSE=F`n-L82waW2z4)g$J*S{XUYDw{zV)icm2!fZV!AW1hxyD@d>G*1gjA36G4F zq)jtke<6rq$zrth)!!Hm8pPkNS?rn3#bRX*^IJDjH^`4B7i|UX-++TYTVWj$n-fNce?O3opp2>+7N$ zPmy|WYbW2Ld%kv--;F9%qHch!lau!34(-cxFKL--A<&nX;{9_%OHY#o4Q4DgaMKb|~&?idLpX}%15(TevjBnKWJX_F{ytiOVt4WL2*WJZ{MG`OQl+0u`A+IS{uJEE{S4^t4y!ZW*&3C6gM_5 znZj|Fx#f-+i$VG@9yW%+^`R%YJze-dR%D>*vN_IVeyjFPJGSN6eE_#bl;cuI_lolp zy82^9tY9)MMkO8dK#_3Lth36?-QBbh(jmG(_U*|fN{L5L$|F6&$9hMb%=ko6{Mio+ zn?wFcLN@VnnfE8dGWS7Z0@AP8lxYjJ`|o&`wZ8xAHg0yaQGx&6A^o`k|BD(+;)b(_ z_Rai{eyvP{#d5m~NY{_Lz7=r^=TmC*#b{hagae9A99;i_y2 zRJZO2iOZn=9ITHrMCkV@6P=bsxUE{n1=*6ewN#5SCJxqbkZr&0YxOg41@AaexxRwF z#i$-h=U-mvDB>Z2{fhh<^n*yLflD)s&(&u_eZt%l=-SLRnNZt1N~P$wpHUTEL*6*r z!pC0OOP%~S0BhWjJj~^=AM=sG^+fH_b8fGV7N+fy#)|#HT1uLLj-Pv3E@aTcOP@Eg z6`oE<;yo~-w2K!7-=BX!uinp}J=X}tx5_^i`KM#vBE#~K!A`;)9=~do-hIsk$)=;u zX)YHeA8%JX41EEgysR)?nYr61M^Y>@c_;guScATj_PcM^_TJ-ynr9y4=f`v+idLBo{h}*0n#_e)5v4NImY}X* z4Fy><+P!^#9_dUm%0KI4Ei(3w+@X;zT)@uF3TW$hWY6Pxa30d|Evbup?kL%9@q3_f zTN>TRXIn~in=dF3aB*6Q>#i)^~F{hjJ^4gTvPPjA~B0PE6^oR*1y zcDcVKNpV^K{q~BL8<$%=gn0EwAcEb9dBxCl%-?6aZYYzQ55ag+R5jQdsJrR87OXD7 zrA6Ox2L!2JG1&1tYJ^^<%KJaPeCjI?06@a4!<$OK>Ve|Su2|w zwoAtLbbTK3F=Kb3w#6rkc(rCm;uVHL_BwPp_hNZHF!cPVMP)A^!mj!h_=<*kG^q4_ zA%hPUW11TH;uI($EL!Kt`a=|Cd%W27`TfJuq=o#7fc!IT*OV;a1#x1X2Z#Rnx1m`# zh%J+-(GC4Ewv8>~?Z6s56JmKC4)%y zrr)}zx<5?vy>dPu(_R&EGa>hM`4zB^Sjq8=7eh0%N43U^s74c*)V-2Ea9V%Ddf>?J zJ-OxHn+aM^-e>!fILFpIHy3|965zcR?91ZOL?q0dQjlLJUylR~@-K`xAG51v(II=} zPKeq4UN9K=5m6_qVT}x@{%pxt?uzb*{c3r&qe$K^o_+L6_Hs~s@pw{+AR|p=`ux@H ziXnMS&-bjU9Nn{TN?4hKi+1yY89BA9^Nm(P+#{Nb!IYAP*R%jB4p)zK`Gj&0Jm#=q z{fjy3Qv`W|RyK?`Q#5cP_L_%!d{blsAfp`qkhANL!SRF>U5 zZ(Q0cBe%%1I%b>CtbgzBhcqqem~f|8;zYiDnZh0I1D%oVL)V)c)_bkuHEZjmK@Ybm z>W1!F8v#`b7&tR%41(V63(0!W)5?=5(*{2N{nhsgp+PA5V5FIdDDxEgE!;ts=tG5(SR`K;YMfo^7>%{;sO2Mf)I{T2NrhJ3w})XF73Gb#|J>M z&Mc`jtsrRsXiTHm*Q@BOH^p!0<5xjO)=l(}t{td#>;oga?2If0?uyM=WjoB-M6cH; zhYHTt#h&)7Wk+~qVkzwOH4Bm!RlCm_zCV@NkIb~C%O}$10m_IKVEWZBM*WXq5wSx< z21x&geLQ}Td)unsP!Wqcs&>&cq9FOi%~Pp)mNAbcmS6Y-BtLC_TGlL5zzKlk>s^gZ z&y~(3Zlx=&jrzZ&#m|tyF>c+;t>CEfx%dE`n;jAc@!)HE_sgk)sjJIa|zefsMfZx_PSHyl6rM(LePgn7Fw`W!aA6SlGxlAHO0C zj(lcD8lURaY7*}@vz(@hdlwh0C=4FwbAx6yaP<+pBSVM}Ppze0j~*Fm zUsG(hu#EXr5GEr6AIx8HXg4pP#`}&7UEThR@t?zv{$l(TmpvZcGj)R5DAq@6sn=JjB$L}THy_k-7@)Gey*XTFGINjxhSrTEK=Eq~JUK$DSg zAYX12u82_GFUlXcwsU>kZ=nbajCHSoCMyg0?@{#x$ROQaV@!0`@50K=m-`xhT z_gp&WeOR9@96h$4+>39V=+Ehx^1`|(x1Jkm`)|?7`kt0WZ6x-6(-KrZ9LFas+JM_3 z7xTddL%!sv#cc`R^a(|fOIz;IvL??T0w5V{b63PM0^#HO+ybSz>|zOI=09N{EniF_ z%n6uvU?Q7G6w9C5l~}qz>yb7x;9#j0|9}_J+C52^yC1fYM{`nLmP7G{DyCQKaw~6t z1b02?=<))Or?@zswGnssSdDV~j67SWqBh+>pS{bLd-Sv4IkchwvER=ugz?g97D=uy zwfuPmNV>;tQ0K^!K5S4dS9obbwR-bhM@uYox$0J!Z!Y1OAI?AN^)I_3En!w}prP4; z6st6;o*dy9M>*ngLF10M`>@23JeQAqj$p&(F@KTCAK@YMe*WJPFNVdB7o+f|Ckn1E zIKB*32-4ozoCHYDRIS7`R$^lsxq|mHe={Wp0HDYVh$V*VpCWnZ+4Q#0wx>R_{9^?3 ziOKOE>&*F>CAqK)Yap50cZB3ztw`!DQh#+OU2N53VCAzTZwBnI+RB2Dq&Vd1ykcMZ zmfR8cxB2CJk7Gf_IBHrITI=a}Krp*4x(Qqp&)F36U1V=|NK@eSq5Arr8?}vNy%F79 zwZ0zSa@WG}-V(fI<%L?4PdVEVK-$Hll~HKokPrZOaej$`(Qfl^tnFa^ z#PnslVTo`E`nCV*($3j0-(62Hd+ds#ke^FH+jN2%|i5puYZlM zC%ECjo}|=B#tiJ`+07Z07pvSw++<6gMtt;idIR-%Xe?Nn2+h~6ZH2`7{^|snI2IRv zU6Um_e{N584Lb}IbHm)l1US)R){p0VZ>Y2fiN<>awl2Wn>eDxT4?iP9zf$F&eXCMv zZvp$=nF~-fo6zstF->^=R0Fd7mh)& z$YHfmRKgim*By6kCx{cuGE~alqDFD0+?EAb=BGn1MUOwv#eQTRk!i){=`#~QTz2a# zZ|_MOoH|4ETR#=N+T~$-b*XP;GwT@yOE^H>Pe=X0mdCV~0C5IIh}0vhC0_<^F6sIB zIL8guqBkAYe^)^7d!V&3ekBRsu=KE)7<`kvLec#x6w?1`e4R*&WaIW9#X8_O%?M+n zq_#|3tu3)omGq-?;AiyrCGP0CZzNY!yeV#-gd-n0QxDfbfQ$9Ks~s1VxM|d~V{H&( zDy)V(JE2X;MD{81BYrNZ8*aQkD43O+*cqM>m`RQ@FbHC zR4`pi&%ly5kSNGatnNf%l98>CQX8D;VgIa`gxM0+kOvowJil&lE?IbJ#!)2i6VTB+ zxp}Y+_Eo$z+JRe*Z+1N>^4*{O4xNkiZSrMDqJN?}xAyIwVY$qEB5!bhbrr_z1u5Ob zFX}4Lie|>9o`j5AkVU>>&KqB25TSRo>M1{eC<^(K5#3)=T*XxL@gJ5l0Np5UEys_p+!EGxOD`wd{aFFjabH;@ekvH5Cbc2{ z?xHav%IxoY2svBeOB!b*gbxG@=p+MxLP27@5EYfWieZT6kmg>il#KNN=SBvjQk$Ii zQtZmDjd3gH%Vk9SXC;bVib+tnXxiemPem?6sU8-`i9#L-Mphshnvh6A(%WONQ=XT# zMFh3zy{(RZyDb9SAmrhD$j$vUOAmk!)4>31&EpibR8|$UNXb$Fby4DIkk|!XHS5Fr1kpYphnWZ3);|=uDBt>m- zUlCoruqLIZ2K2~Mvod!?#J~dm56&+%EsY6#r3<;BK!S$PC5)R?RB*%JH`qUc_CcS# zXr{F+Fn5m5euRU3C$kMxAm6DM!C+yGA@!%MhnsIgyv!N`z<8 z|ItJv1}>S7t^z&o20lBp9lDvEhmHS+k{AmL0O++;v0k>G7)3i{-_mbr9$wjVz>1by zNU!^-=N|wG?>j{FU5nZ#xgtEVy|K#QcsS?U{nHky8k*def9xroL=E-fm(Q>;0lMt( z+8b3Sl1_pwD`)79xU2$|LBJdWd{c^E5#-Pe5R=P0Qq8CCLQmq>>oZ8Tag{xCwNu3G z?nfMXNVCzgo~>{~{*|MS>1y{9ZEHI}XTmJ|d?YDwJ0iXk;Z|~w zTF|Dg>jDa0Q2=|e(3aunauF<%bObeK@NU_(HSAc$ak?6m!&RD!94k5_SmlkFbeT$#@u)L*C zAxJU7zJotKaUqG;bVIx^B|k$!oZO6H?9S%2W!dX@JdW@>>T`2R)Arnl4OW;Kk>jXI zMl?el3T=8r){o=83t2+JP^M*Tc>K^r`;(4`nwR8if)PSN=;?5R33|aqB^in6(qJ^p z>Jgb7m-SJ~J|&Yi9nKSolv@De=uf2&uV|!I{PVV{a_TfZ&h!bcs>Ur*Y981d1j6Pi z*d;RP(TYiKg<^Nsq*Q8bx4Ojf0ao^4QE{>Ahk9nKX??AQCt_3qHKc`cAwLOy#r*0p zSg%3>x%jrj|HS2mkDE=B?d5a3zf1bR2s`nebVU4CgwEIB%)EnEMO@T7R_xrcY`KW1 z2ex)2wo*GXgH%1AjL6E_^!T_&s;J1Gk&rzL!=BbcFED^MD#XkGc>$i4HIjQQnZp*- zkH3Ll0#bevidj(MdNd>gE=|z7N=wRCc8fg#Cuk!1ClO*PrCW6+-<3bvpy|m%Q-&(7 zE9qS~Qa3V|(a!Ca)<2V*2UE2_ITO+E0Iv;_C!PYhBn~u?R}}}G*Vw^NB_-WAx^kKO zwp89(*iDh*Xgjn&1@_~DrS6%+YR$;+sm=?{mSUv!(Dd$CQ#TeW z$$jms2#b(gFQwgnF0uHkHuO#X0x9E(r3x%!o$O`ss;^G9t~r2jHk|)z%pO=l*UKD? zybN!Ry*}7?iD|+@M->7xsQQV4C+TDU?ViW3Kn`~4HpJ~Qky#o4z^ym91lNV6DqN-z z#*itC{v35;9b1NiQ4rOn5>*296=!Sr{fnO9iYnCHcK(btS^^z;VgB?)ttZmaRC`Cx ziYB27NWO9yWj&4u6Wjo>laG+syN(I%YazKRy^Cx3eLm*>QO63o`Nr@ikI3IuT6!CR z2TFZ?sUfoLg97~{z5_Y^Y3IOBn>Fs>Rj~FP$|>;*WiFbcYC~-muHr8K3sjka2T%u3 zrfaEe5m=3{oSg7)IJprmfwQLSQ?RYQoEOQ3(zP%1n=P%8NoQbljYW1Q#E_(%e7ft& z_eaZsvZry|>-7nd?X(9Z7BfhsQp@g5P^`& z&EXb$OM)`KC?4(n7pkR=b)e&@GeHQF6ne9cNu3#Lfm*b-R<8CI73cC}?JPl)V(}wp zS86VP44pL|D~0TFF^=-HnslX08PsV5^NRCy-<5J$2^Jgy#GR-@^bow;1jO2^!FCxB z(%%ozbBg771)~!O_q~sybDzA5oKnwe9eLT<$^Qx+l;zquhMj&aan5d`RnBj4$LEaFJ0wram*6SyAoDDmIArQ_X>?9fEoebpMsa6|eM~7p|(s$Q~UM-L{V3$SVxLJeC zLOK5ajz0+e?Lk7_wCNvuEy0S3dNok1Y{MXkn4FV_E0Ji1KV;cKsZlRPRnQE8ytr}r zj18Xj#2H@y*d1&O;gUmXMgfnW-Mx21h0zCHMsrgp zYB2~CekN}A&F7`~#gMNFiKOA&i-wOL#W<=iSuu{bkUO z5rEG#YyQA-G8^B!+w@!bYW(`LGoSm$+s?1562~$)^V>8dj_v3|vV&d2FBA!H!TDk5 z@TP*#!DIUeOD!h~NdDC$UW;sM0cldiT=GMjS_y;ANoMQcw~i5&lwXDsM8f8Yjo-y~ zW$x5*hI2HL5pgb%zfV{fufy@Bo3tVF8eBw&vkaj<94vB!N69KJ>UA#RGZsF`i@9Bf zy|jN3*aZEDe*yJ`6d%F7M|tdtUYhd@LQM4CHv=TQ0FPi_yV@{UmDV?7hXv%*+{)wqI;ep(ty zQ!}SpEp@~jiW3>=dRtRmcv4-yySvZjs$RP}>$T#!4Fn|nUGdzyyJy;Ty0!fx?DvGO z3XV6Yf&1Pg!dWGH*&z7cQ1F#RkS)XQjId+EZcYGQhucXF<_h`enBFe5os*LGw3NGz zoQH`EF_pPCm1NsfQAhVprSh6XNbnxm&fZpP7Af>(5zI!nRewReW5yd_cH%x-NRBFV z;_Y_ho}GkrbMf}-3J$^IU>sGyY2FV%)z!t`C4NTERG9pTw^zE3DIN96Wk#A!b~euk zX8sk!Y3eUDRu#_UsWt^}*prxlwZr*ip28}uBY&}(9=0wsns#LoJazei5#s!<@Fut_$=a zo<`h11S_WQe>tF#mRjwdU9+sV=@2c;Et@tFdGnEGN01`V!4OH}KOBp)#>s>H{!JU< zTWFJ&VdgBfjcZVZRLkA6B>ZMZz6bO;O>;>Z#>&87fFr?4E$Z+fX78vy>Ht@X6QlX z$_0Uv#UcmoQ9~V(3<3Z^WG;G~wH%GO9#5v0v+~IxrT)cKsVB^6%KZRSc-jgEm;C(2 zC|3zLX&q2Q-QzI6X+n+8UU;a?o;#+i(}500(w#%!BsfaPs(wfq}%|1@5F=u@{70Mu7HV6it83N8I|C?>k;rlI8U{2fs}h9OZ|osr!FY z8Lx=RdFcz^-d*d_;z09b=+r-p!3U8{;zRp%i)ku1D?RK(QDP)^g^L@!iVsE=0&isx za}En;^L+OQ>m}4(MTcyM4O1?C_c;W~KkR#5HXbMElu&)W)84FuTRrEgk0DJVfgri{1J-c43j_DDcJ2ls zTr~vW@@iz^wR^&PfZbQ59VU3E1^I4u234ZGd7fI~H+mpA;Vb3VeK07Qm|4Z5=G5voro?K)f z0Qwj8Z4+xLlSwD3EslDwmRLgrQPvKtL{D!Vzg-z_m%l&ifG$5Aq7kKCeli7RTZvmG za_Y}^Ki9)vR@bsW?g}Bq;3+3duh5P|fRHcVj5x~gV zC>ZjjBuN~`v7UjXY`I?Kr7`FM$iq`csIVSjF-k5vcK2l+0vc^%lGEEluSxfms1DG* z65xxEmGi`_s)vz_!!P!fLNh^s6cXsBy1};GuJKn@1bk|)t>=D$d^^L5t<-Nw7r){l zbvA3H#|(@J(3OnQi3x9bcV*5*!uE7ftWeh1WQft_|eK72mZ_58|Kh^GCP zQ4mGwxhXgVO~~=p0T!oJQY$8V4K6PnX3^<*-53Oi{P@ExA!GfSXd+o+<%$dzNvC7_ z*aBOnTh^Jd64SY7f=E>Qps*sBZMEZ~?`In4eQb%eLT3$mqhO)_;loEv!g)j^&jE&j zi3AJzN95EWXuv@6ubejPuWXQ@1LsPJP>!gdWf$CM2v{T;om)P;QsPFyXJTQI{`iSUjf6~3MgGi zHp4cPIQ6U%15|?j<1{bs{su_hM$=OF#3(*B)UZA@(35?_4hXOLV*M;y@j!GW3HUzw zahMrXIYoK#(Ds&puT&|8WevyYSU-A?OB3kD7XegXFsA@z>qpx5J5RuSG;cNpoRuFj zXl9^3pj1TZpMXIi8t`rJVrDj=iADgr+@|f5cv?Px2_{WU-g2*X`V@FIJ{RcL3|o6Q zY001>VQBRVOQ>*+1=jQPp#&bgnE#k0;1{~gd8w|XJ>`0kr99_)uZy5^B-z$zaVlM6 zH5q=A%h4-G$A+Zw=wb^X#s5DD*1!XI*9-x-M1iYmgfD6wnmX2tw>Y04P<;sLVFJD9`uVHOqkG{s_o(<-?j{kdNLR;*2@mT3j! zz+3smf|n^g0~Ej2AjT?=+qw4w^QpvQ@;g$Zra6x~7qQ^zXYR{x(# zrawn4&g+jEg3=Z;e)GELLKioUfX+d!PYX@zTrNPJ3dm(#F!xtYQeQpxtIodW z)XCvs*p}nzC6nX4nb#cN+CFepw0}Fp>fbD&p??GL?4L71Vc^}tr_43FMq15> zC;K4Bb0D1z*9^57Ybm53@ROgUxyF;(f$Wre`4AX!MCGkOWR+&{LKkyzX+?eY_8&3al$xwp?~|sM z%XdAlWBoR-i#XoAP}@=>V{o}UM-QOx^Wz(;HTS(87(G1v>0k&M(<;nUOn58nGkTbB z@?Pcb{iWO;62rD4U9#QIYVmW@pT=x&ht5~b)!cvl(E#YANm5OIwhSD*WXhgl+Q+_H zU!s~E=$0fpTRLSIw_QAu$=1|ujEoKXt;hBqf-dB4k@#uSQGP?oB#XdT;N}fkx8^F8 z{d35Lmb@=Bv;X@tod#FVMQoUssd&3$=)D4Uuww!)Z$^YVS~|+pst%R<>L8_)tm<@` zOm3P2aOZI?2jYsALNPgj9?dz~YVocR>6`A~ECs}rhF;#;l;94zFaTYrzQJXODuBU# z@-rJaM>COnZ1khcclCf?^UAPGmf-(O3I*OgerUB);nY2X#|D_rb%2+|tO-6geEMBl z4kNjKNQw&CYB5^dw_ocB#YYWx0U$j0lX~fjY-pFb^O2dO+xWQ2 z;sYeM_D@1WN7ZA_FGQMcUy_}8qZk;**^t%Wj628u0!dHWtIE8rAn6P$^GbJx68BW+ zH;ei;V_u>I2lW~#Q*EIX%1KZgvj<6TTeIhN&gbsw6EIHpo5;ouEcWP+i>?*wITagv z7yql~RkSc>ph(DnZXeG*aKEs3{c3z}r}^4-$C#4*!RW3B;$BM!=np-c&-0u+4j_gT zauC}wR2Z&&>VK1hDJG|d#%nyOGnOQ)z{KaI>%@w^2o;i<<$b5+8Vf{x(EvQhkgL0w zHH=2069WUs$cPHX691-%@F87K%p~#IOA>PJ4*%^pxM=UQ8&OH3JZ$fUTb&}GvKAjZ zl0#oHUWl89|62b{!0_2#4u`5DxO97QWF?EiJOldJ*Hu*fE*9h2YPNCmg4#UH{QoTsZNJq2?`S>*Ez8}JQS_QTuExreNx<)NYsbh0amU6NpX}8CCOhU_08lk6C_wkPk;&JF z^oYOYYR3OEg!|;t(R9Mc7}o`UOy0pQ12n1a$Y^>1-vM3$IEm_1m`Yi^5OG?Xv=Exk zUmRux{36ChcScJ;$JY#2LtWnUM)6*^UU^fd(xu zE$jl1KEy&UV;lox0e@AwTGs`9#{PBkZ-9vDuh}U}o8L_A!Q)Av;ZhTwg13)B;UN0a zw%zpl#Sa2pxfY9`@uJOec}UYLBFjwKaMJ zFV9F&BMxmLj6l)bf;Z~}JQltm$d9g#h9rPRfgBMtVfIp*88wRIXCy=q`QMI|2Aer1 zip`03XDu?=Hk27=Kc{X>S#22G9j{kIN4F^XEhlnzbK?HAc0rgjWVoO1>}N%JX4TX0 zda=&bx^(BL+L9d5WTyaPseQ=oz3QLjp&t%EjEe)p`G>l(pK4-5Nshw&j4W)Wu1sRp zDo5p>$G@-`CIK9srSBDqtk>@W^##qn#&*uF zY9$3E%69r?dhJsZrPn(=&f^XhGi_uRU~*_Cd)b`7;*@-tRn8MbcM5WJz%%Ebb!MqxIR|tFCi@~fi4SYYqJT;F@IpXSzkg; zSm2Hfo{4W(Er>5F*(27T6_iud3crs)EZ6|8D?nW8fEXh2WxMU_xe_`P>lJAen-2WD ztsP?M_zGZaYddu{x4*QF_!S~b5UYi$Yo#3Rf1VL@kisXxZojZzr{J8|{X=izjRYkKPJ4jm0{R8?qZMlE*qWV8kUt$T|MlY0qw z9xA&M2}Ta$GrHyuFMR-IERVDo0(-UWs1dLC!nv2cc$ z!)28C5^L>3sJwF{!R7pH8u`Qup3vT(ERIr}=5N||=YG4Y04MEKVp~)Ng=R^##nAyi zonP`LXUT%T&4k-0K^NM?Jso>0#GPzhFLnZ)6!u=1eUxvd+@9wLeA>>EucPu-YGo&A zOdPRRej~U0L-2v*#Tpcqh+6|)Z6jrt9~`9;GBFUj(wcOq`o3yk`TL!otEG0dZFBpcYSn=C|7N zMe$}FKNs!#pQXdA(5)8W@ws2XQ&Fe9262nfP})6_>^LW~FEBsgvw)rZ$AhV3jX# zUn2eqqgpFSwwXA0#QrJ#C3Q9JJW^O;eOxTi-u&bH5uc4cZ}EyaBrvt?aR!PMkWsp@ z4~nI$7aO?wAid@lAdo62;xZ$f#m48x8D-TkHego2iy1Kd! zn)d^!YLO?D3b-EI7IP2xX7lfXv@+Q>BsU3sX>Y5o&Wtpkr=#v=Mg52i_lJW{7vV8{ zwOluCx~RLKIW@^`vFa^?#7kezuv2x%;IfbSl47kA43!50*kEzPy9-hB2tSh8 zJwIuX0uVWXg6czQEI%=n&FR27v`(y_$qLO4>|})MlxKYmve32R3MSyv&Z05j2pwAw zZt8x#Q{i$_Tc39k?Zi>Li@sXtwrU@=JR|CO9GGzJUfjCz($o! ze^ZkE4Q}MTmMCL*AW5I=Me~t31i4f%wA5NicU2(fcpuFft-PRBw4Bmk4iQyq@wI)p zeAgVhf~JdIUi3$FU;_Q;KbshF4BJOZ%+~HNFodVd ziI;y?UmE_0f)n-reb7+70nn+*;U@w)j#WS8Meyi$Qltc-rJ@H}zCtMF9D$?Cm258K zS}#7gQa&5+HEbMEH;hp@;oCU1fLH9saK9*jUujKstR6(5w{LqRn{!+xDA9L7i2iu* zgSjb`hi%8B^Mo+a-`~G2N}3(Od{`UbwjQvawSvH=T~ka<7k^u__&Jxh*OrCtMSkDd zFa`TAU06{75+1k6f$gk9f|RU3q~kXUgweBRY7h$aBX+GO2QwE_?=;?3RrPyI%r|#3 zV0Kh#^M~a1b^2Y<8vRJKBtWm$|Jg#o?CI<)%%45QgTd7f|6ea;E883S6l60fk8pK5 z7U>G$r?iU^fc5pu%l{;QHipjZ1>{pR9Jb(c&LDe$}(9p3O%tF=Zt_&kwo0U#^5z4#{Z1 zl`zj++;r8Ol<4#WL?97J*VU^a%*dE3dx8G*2CQbm+jitwF+G9pH!#D$|KR3#> zQBd9F8lf_O7eWvV#!M1`@64fYqj!nPbkYOojtK7T`QAoTaHDU zOLYYgT-S`U+MZu0Z&x|}8r3?DFLr$Wg86L07q*A#W1qTM9V711YJ0qOnCK{Q>MTk6 za)oalQJpmw**d?Zp##Vwpf`CVrm6&H9`d%BNMHugUlq>`p9lC)&)%J?djXjk_z;4( z6Bm*Ih)(y~24TLmzWU2#MVVpW9wgtK(P0N{)hpB@Ia~hBlD+nrbavZv+qkWhIuRd+ zyQr}XeAUv+J7UQ72_Cz2_O2X;^j~uPfPz~M`^4lD1F_rR&oUxBlRtNS*sg$IH{rBS zvx~P+Cg;(G6$@{XSKSp$YrEpUBjKBJ02+4G2=$`aqPV5+(XSWn{_>V};srYPwhBKb z89y&3F*0R`0C}Bw$GQNL&vLGVJX8E}atPvkx>rzPmLprD)0M^FjjjiO20_QGX5AmI z^p;gT7YwrA8O+J7o*SB2FJq$K;0l@dEPR!C&S!eQ)#ib4kN&G>JyiLBaxBF|TF{p7 zE4gB)9_}(xH*jJ7XQ$g!M#P^elg(2qm1pP^IQ8hJAC#A#-g=*0)=?Jw8E^PJ?hd#- zZJYZ-vX&IyW_N?Av^>a-=E}`zNskWfMM3s^gIt@)MsDh*3fz+r@9t_oi;-7F{a^?2 zgd$0?4mN1QV$~7T;8!r8JLQc51WGM?3B12aXD@yl&%T+-sJq^~gx;140_>wO8SxMD zpF&yKP%(=g`iipSAPyEB*?K;^Fc1a8h0rBvSm7`_QT~s(=mAxg(n5P}Em2t$nNjZ^ zB-@@u-#t_S%##AxrY;Gc11^MSYgfk2XVSm<(X_Z;5LJ3@j@E(!AD%kT;2j`Y$oX(W zNfpgXab})#;71T2`9?^pO-r>>U;wZ&qC+>3n+E8v&0V@Rqg|t+;1UC`tf-(`|Ex%5 zQq~H3o`GG`I{ox^ExjX;Aa+Hhc0LSm8G9KWn+)750K}PbFFpli*JJ_UIw~mncK=2= zSKRQgaN;0ytw=CwF)clyByV__=}>`>V*i78jaDWMg6w+oKk;%%-cJ)rhbf*il0EpV zUV77Yg@>aryrXO*TwDQtUn=PjG=v0y&Z0g z|890|c5e{cW07|TOsq+vVLkmj+>KzGp0LXfZ)MVgXG<@3K3Ei+L76@Gd*P7$>&4@< z!zjVl01?dkOfIY5tLu8&M-DGs6L_H{zIk1K1FyzKZpntC=vBx0?Hl6AN*lh&J~D}@tDfUs~4*K3*mjtoR2-7wzJCU_SK zL2^%WQcnsUCjym9S7H^Y`PMgSAgGP2+kh@DU*cm!4X&>dM8HQEL0l-t6S(UoJ2 zkn!+GCh^~*0Cb?D%kuB%@@)u1AXp5B69vMtxR*1voZim99I<<^8O$d;I>hPPec1h^ zt>dx?zbqt0qf}K=&tmCDOiHP5`S*JpBE|kl{s00xT6#|;i3Kvhk|sYs)v~O|-9A3k zonydsZTq@*7d9SzuyE;!N%lJSz(~|lf*JOD?|1^3+YpN26ba)bAN@Hby#)S`J?v=& zNzQI!X*xR4c}#(v9QN%Cw> z*OC^n#Sr0@$)i$y1>5#T8cLCOf1X0{tooL3Fx<0g1OBcNg~nS5v3F0tky2skU*8-N zH$Lh74v5o6J!#O6rOOC}m*TtZm=LIUljYs@>sbVO)+OPY0f+XpZZk3myDo~T_#7MR z;zr#_FYqE$8{#O#O!$=WK`IdvXG)))<&AMe1u79`AC<%0;cFE{t}SRHwojv83F(2i z{kO{SJj)ut&x{986Ni*20J4?OshsC)PAV5GioYa@w=*fd=AYm|`YHUD0%QxMttu}{ z4zE;YG%Acpl7hIQ!;L5DY=(i>gC|T$iSv{sR53>lI%js9;fE49MNf#AZ*uv3=6F}P z7*@8t>Ei#e_0>^L$KU(gU?YZvG>nvz93q0`C_zyHrKCoPAV`Un#71|CgaQ%<2na(` zavKr~(xub@X{2G~_)S0G&pF@kIln)6AO78|?(^K|x%XZnGp+rIKjiy-iQxjll}s3a z%$)Idbaqmd#g7DwLIFHE_o-|n!k9<`wGm^H+U|+-4KI91S z;QPA<4{GA4xr5oxG{z0f5qw~*bvWJ%s@MSyXJobOraA#j>RDFBkErkTXezeEj6R_}(Rd#%026 zAqAi{(T^2pz})69z8sH+!6(~^PI=@!^LpIv2S6S<*wK^N+X)}K89sk%Wo23gGwK1nDZ_9; zG5!>qN`@%-L~yfNjuq1x`Rr zIU$*8BI1?oHJd_-ErIjf%2sb$3CGaBtLF|kQbA-D^7uA%wuSBCg|1pSv5h@{b53GF zA@J@eH~OU={e_MjvWlMnLyvxS?UK1MM7K)oGCg*g?wQ)D87%xb!J+)at0jXXqrA=f zC_mgx2OXg5ufs${VB&L?`|~1$zp3Rl)rwevZH3u9-yb`;s*|+;0?qO7c1LA%8yxeUwp(X;*9j_v~ zyL{QYdA}QV;;6FyHV;C`ydo}$~*pM2aVtJqh^gh5#q?@e5(=yaTB;n6l41!F65utas|3#uvqNlt@pO ztOI@$jXKC9!IXX1>2|kM@zi&3P;xy$@*OZ$7n!#Ix}^C?Y3CG}K7Z&Zc=It<1eBX0 zi?P7834Nq11G9p=*a&z~ZDgS{ZX7R@>0`{oOJbEuc^oOIXKcQg8^o1#Rx{I{!0?7@ zVvGu_)ru{?zkfV|PnZ?uZGU4)0+HVZx`briVzYGqfCBNeWUh3XJ)`u`w|CA`dmFEi z#&N72R;>6E`N>QcZ2{oDr_$TjA7*}(w;D48u}Nk-egbEHH;U=pv7$MEy}rcGwS&|N zB!owW@!Zt+$PeNpsZQ&b`d8B$xzDV$^Y0jbp3WT%)mBn8a*p2cgin>*bMAEk+Vx@z zk`4AK1bhLO=TGS|S3IjnJzT}HtT)wEqnXT(1O<9=52!e(uCS@pGaS^SC_Oj<75}Im zvyBBZ+2qmo#l5r9O`PhPP=|Xfc<X_L4RXfO07>`{*E?`J%LpblwGKChdRwZd`4$oB*3aX!uLF%^M#}P zRQ!kRa((})I~lb!_Wh@Llhbchgo!d~H=OFD`tGP4(}f-?5_m zPFnX^G^oxa!q8y}iXbK|@cr2Z5c6wkIUfp<;BhunfHi|?o{`uMc}BL{Ax9if))TU) z?k3Fkm>%*=ZsM@(i<3|Fw?z++j%L4Uic3q6DW^CM50wHCMcwaq;FlCZu1sb8UR&+K zP24H26Ne7>J-N8%>T@m+|5mK=xEA6xP5We7oh{x{j!~)1;{AtZGB;>sS5Vv6n%vtP z(*3&ar}zg3>yR=~Pp0y1N=V;5t}H>hgHUN|Lv29wJP2E_%lu?|=X`i4U;OO0*SeD8 z%e_lJVb8}(Hp}OB!nf%)(hffDIlgP{IDs9*hOawoqDK3SIrIjnX}9Tllp@j$0{HH# zJT}qIKCP2EqX^k+<2#v$zrZ;7>>d44u`~v}Dj(wGWt?>s%B^(svHA6i1}$66A&_t% zm>V4c8MR0m5MKm~k)?wwhFV0aGmcFvU#zLyV4A7+G4KPz$b75kXO{PXV;1VqQol{@ z(;ey7s1Ew|&QL1%wl|F=-ub{8>e-g*eT9u#o69MBv^dTdMP6F+9`L8Ae1CdFBX%*P3|=J&yVB9feut*~VX!b+ z81U?9Esk?XaKQEHm6ueU`NtIb;zY&DZ~0KV$aMw|Ick6VCNK;xCiZ{b_nQt8RU^uT zK!byQ@_y$cTZbBtUL=c%%sRL5Q9^|0jgpnu*7&>eE8<(MnXfkTwz53kAxs&^ZXB)_ zZe@92C|!S5iK3E4`>`YiuU%*#p=E4ZQLr<+q;}n(!YpV%dz$HRpbF!=VChZyrpZ&y zU1hUv{9w^$vSvTvNx*_h@&lTI5#0frF3_#uD3zIM2&@)Z*qt1U&uIAM#Q55QQ6Mw_ zScUb3`_PRh!DEti!RmCore>FykFV3(Z<@I$;5K@rERSm>pBFdVS)*%Y{^<8RGS$kt z-Q75AVe~fR_Z|f&--bkbKq^*)jhHv`eUXdz$pu?lj65%ltJO^dl3ufXk}g-dMfSl; zn~yJ8h=}^KM%6v(AoyLg3wkJ~q8#hsuKr`U^F>AVv-AkiSn0`g;%Bu$3Bb+2;^xI7 zWL0tM!3zMe?M^XrSO#?Z&n5T$^}GN2Ba`)}xIQ|BwkcMWYQf5nIm4cH!KuCkG|hMJsjZkH{+$j zmg3aetwLJFqG$S+_I3;UgfOBYjgnmU*!clP)JBKG#h7X?&pCbyheWse)mQPA&lj0M zsFQ;^auWmCp{q-Gl7eTu871YLbEY!ufkwlQhkGp!Y;zq@Hv5U}6VEX@8QX6CMnLId zZ}j3x3)RX+UdZP?PTjBsRwaqwLv9P+)pPJ~wYOpUdG=21F*TbE>i+9q>;3_{pE$bC zPkSG&2k+iFoL=NY4nz%oT%=6MSJBjrM=VxDX;_a$x3F8q0Yg)Bf=C*cK% zPf`FI$#R7jtVRjYqzz7CJ4{){%4K}QPZ}VjIVJ9XwC*;RKi>Vs(X?cm&{RuNoWro4 zF~>to;@43nSqOBst3TT&%iH5 z%Mh|o8`T?HvL0&dKbLTlHeoI&Q0!qngU?Hs{Ic1GxqNZDBMhOs^|9hZ%y-~wn*Pa$ z%E;&h5%(FUxNmxz0kd^L{IS2@<)Q+Gl!?qsQS&j==qf@^8l;V=#t>f^h*fp^z{Q@nQ+)+{ z9BXTm{dD1DV)D-Z)VO!&jNMF~hi-svp3GK3yHd#&mQCB>3h{iH?t^qslR8f%Fyh6U zhgE#gyV;iXQ+}^{Ph@5Ta3A7(`_w+h9$A7aYG{5+?Mc?=MrBGr|0f)*sfzPFc}Kyq zDFPKJ0WP+W2(%1!raX7ny_w1wzRWhPDe!vvM}Qw6EZnVGuPeqU>l;}cxej>(j>da< zNRg$)%gbLrVZoXCo6LXt=w<5?_JaKaZ^JS*3WvY4UG^qLaw7RWe8;ASdcEN8=Y*SN zeY6WViPd_Dz1tS{iSHfvl2$~nBJj(OT_nBjBlz{K58)jj6AfbZpYp|oofzbXNN!0@ zi+wQw8H#y1FrC=*E89Hh>^93mZ*O}A?Iw9dO+4Zl5Q`K-=4nx;eS50<0VzF=0m!ma zdwBvht;khfDkdb!F_tP!i+Rk*NYAC6iqcSAbA3Rk_a;~8%xv{SB>DbBiG;E=wpl2j zDwV5QvyLvI@aQnJGlXN}V7-0H;2f6R`WcE=I=LD#Mr7aI+MUI;P+hD>l<(#LsgaI~T@e(6DFiH<>tddg@U);xd~~MuE}EXNJc*piLL}M|$NwBcZ6_WlAAE zBj&gn=3wcOnD@j=y#KSQ7yH_#Slef3kLzWG%EqDsxF=DPv)G4mhQHrpAiZQ;teFh9 zOut-vrtJDqV*p(}S-w*v%XdMWyjzFPJ-eL)?d-5%+`d`iODlA7(139Ht&hLf!giAb z8pu_4Cxc{I*m?V(Dmxl@<0mkSOci{tzT`n~Vj8MV#$4~{u3H5b*(4!yJ*wrvW)aY} zTaRAavV4AHjtBSzb{5yFCvSK1f@&-<-DV=aN#Al>t=vlgQO;gP=%X24<3JnsVn@cE z;>*9URQ9nxG-M1g%Z~-K;=A8oMcVQst}`FI0G^0=Ici|J8KDXsUe>8>j*Md&1r{;x zpWxJtIiY}la?<9(QcfgQYcnTt+N(}o&dcZ3)+=6KE_Ul!NXspZ-Cf$?E`0&P>gDL& zi7F))PE&kpjaV1g>)5`UB-}_7ApXJA>rt!JSIKx6OUSPGI!UX4JW;iI>+ddmZ^GRJ z<{qeE9H>lt^Wsf*Ptw#PIsz+J>YvQ6U+aBPL+1oTNzx}J&yV0{e>1J@T7|s`Ioa!GGHfYKDInfXfK+})vd2@;t@sPaDq&;r-`Hjr_iGwc5?dHi1~MeD+wVb~7z zHE;;+h6{J}!H7->nk(NsyzixQtkzQL(StRa`CZjEgi0P(SwOH(d5a`w3ok|#kB-T2E_d)g?P zvKK&|ZgWPz;}g?)ReQW8Sh@V9Xlz!7)OSwRXcHn_ModROFccPI&o|vl*Y9`1QicZI zfT3L$g*be@kfZZ@k;o<7jK@4jSp}P|*NA5EAoMmAKu`ClGf_r0HrvL3gZ<*Ee2w>} z#^g?#(>AtS8S``+R%VeB1$jG~CqhBZ-n+xUwKvc1;9vH&^q%~oV@~AmiRt z-Fn%Er3qRBeA@1bd}`9OPvr#?pIifz>b>SR!T*1)&+ipI73g}vTCLq2gl?k!9*D;s z3jnQ!55M39YmEj{K`DS-rYFR(Iu+2bGx44{<|0luI+pn4_5^R4uvbG2#6jqEECn$1 zM*MSUCg4Q`xiXrE9}Yq=qvJhBpP?dUK0q8;7&SEtj1G&s6*YCfVr|dkz5qLK+>rG? zkeu|MP{Q=Ar0!V6`W}&umEvipTc!t)>HEHByM3lT-i>0Q{6E zO$&AJ>&U6eEI5DNSJ3=q;j6;au+|F>RK$7gS)E;-qO4=~yEK`rPiB6h{JOpL0k)B?(iRwX(h^M~I0F7Y!0GZdMvXOM>T!SbHPh~Q zC;@HiSYceF(DN~jbV?KVd9t^fJLhC1pN>aNdp=L2EpG^=?Z@R+adq>O>O%WxM*KhC z|E@nl)9OKtHd#lg&TBZ+CM&k4In0H_MMjTU#$R_dS6L(uMOay3z~n|}(w1l;C-$;X zXzbb9bC*8NnfA5*24|k)k=2l!Y4tkfjL+Md$%v=jZ6Vk{;|#Ud;J4wMCA21rH6YPU z;TzWA5|~BQX}%3Cp;(+Theh)v;%>f(B1`91{~g=xFa#uF8{XTPoxt!z_|lz^I8y!2~623`^Mm{2w8vX?F+ukc##P}f%^P`M4CMtozBED1#*sR%;(9)()u6&{M4u>B2Bmr_8$cHF zP_lz3{1a-F#T1+WQC)wWg8`U`I$(Kd$QggI`X64_i!?{*&ivO4Fn;ogXE6X@j^CR9 z=Rj4wdWA*2AWg-9?6mRE=&EvT5_jazWA9%&D8Pe^ymn{Q>Yk$kw zP&MC(<1(uD++-Z?`W!{2TH|CrbVk1ITI;J=U-C`2|lALpjO%Hl||wm+tO}u3DHCdX@6_ zCN#8NL#R_)-+1B;Zkai`t=AGzMeg#x!TuvFcCK1()-*3^)V0y@!bZ5?^y6|_1~cl9 zm->|{q4QedmUDLn-BAQBrt4%DqA-_M; z3!e{(Edl_u-Xt(q_r-)kXBX&FVua0!_ZB?@>5z`$0*3?!NssbnCAOQ~n`+bSqkr0v z`1qBFB7lm1qcgEF4|hk#Jb!!$#E;?)Azg?_QRqT+GqA3sPWoU+I#&D*enO#igP-vvChT9@N~PH<~&7a+SIh zTM>WEdq~WANTpE0!^6!iqt3jV*fj&(8^7c04zmf{0i-xKJ&Ds>%O1rW*^5V2Y=0I+ z?Pq=RbkC3SB3}J$H&XfL^nE$rvXYq5`jwE(2TD%DoAk18reKib`i9q~%UeoJr`SzK z$hFI04B2pJ;(11{fw@`l!OSJ!7M#t#YXe68&vuc!6#TD{&5nqs2%Y>=R@vg3%J>L1 z$#}e`6?t=lPffx0Tf(3$?bkr9IR^VL(O&@<7z$3m&FBA-$+v0&wdZ_EYyJ8=D4ukL zamuBt@~0`uv;lN%Kc;EZc@y_MM~4ThIyp7_T|)gfFCVc1k-C&d%p2-h=OTcc*nQ1Rghu1Ge8_b8Y*E%f7?taL+gkffr&Kc_XDJs>r zLQcdL49Ko?sfd|m*R+xa#BDXDmAn;pb;xp93IcZ+G~7Q!xI%f6gtKGcy!P_(lvzf& zpeIp=;hcX%Ev2cfr!>BgZThCw;cAP;I0fh&bSOcKJ#^Huvn8{sFfyAWEs)t1sc`RU z(Yu|*sqw0nP0Z#*-CNism2bLYYz;d4H*x51DVn!QrYG_fU|k$dE6*h|A!pD3pc0thwd;>-4`5x-#wtTqzQm*r2iqwksB?s@$@6$iW0T7fm84;7g(&3Ol z;fTHRshzs1RF1k7pm+%CROfYYvvYoLAs()xlw=*=#&m3yv$xz!$xqki6&6p%Y%($Q zEm-B^-nZtxx%r20^V>^TV5I+}!P?&6)V%x?m=Uip`YoEtL&2gCO4b4Wyt@ zhVqWk!A4U3OMCv~r>x`cR;b71dawS0H-x{so56HLPm{ub%lrS&uGp>))>==9@H`Ru zqeuIPF;jSLl|=)tk(JDkDV~z}>Vk`7g}r^g5|H2#Kt1e&lSanA$+a&mm9h}K`yg>H zq007PLt}q&4Xt=GTo6G?$#rSAwZn|znl>iNP?$B6qGt$h$H=e4d38?~ucm%jrcA-z ztL}~`CtoBsq(g(ESdT9srnz+;`3TwJ&@AU$jLX9%SdR;R_~T|djDOFEPjXUZwByXE zu<{H?6 z@QnZIweWv7BmcD|_3g2-smr+8%<|0z$P+a71v4YH)V)+RLX_Y0$v?8;6cV*l?r^sT|J~=;yu3+ z^tl+_aCwD})?VuedWr(OW|qlR;9lzD;r(bNh;qTXK^=#K+g*?h33R?Ty1CXiSNV{U z$I|f@z%$=?i)ar)4fXe`Qh9Y}6m4VsP#fNq5JpA$|G5mkCrO=Drm*;M@+e-{nUssz ztyeVAvexLwt)ZIDgp0$S5@L2nS9mnTeRBJvR}i+Joo`aZIVrH=>TGpnHJs6_;QC9$`;IUZ*zo^I5 zwI^&ez@QcFSC8rPgGlO>aC55Am?Mgxp)~(N{-`bLqt|jNh&ElW&g+Uq%yj zItzn0bO>M0JY|{z<=?46vF{W^{f-|ag+I3zayKno>#m~9J}ESNoCj#3vQwG*7Xa1xO+BXhS(nk?!|#w7N2v$HBJtrI+=E}9eo8JE8joL`|sT5P_E~r;;p|BxIqf6SC~PU1|_L&9&&fRbg@#e`p^1r)IY9w_coFUR%IJ&FluWUif6Xvn_R6^{ie(|ALjqF4M7Ys zE*^GKsguZjHL*_$&Oc_0N*g1q-rM261xo;Fpa+p!d;V(bwkp0ys}dZjbpdoPRp`S3 zecH3vmMOB!mR^F7LJqB7Ub;k>Qa*dSV{+@KzhO~9W4|VE2m2|;)?5%%!+T02Ih*AyQN-TOr1XFLD3X*XJtGfTXl)1b2U(}ZJsN8O8n zI%SQjRfkHKa%^Nr>>eA=Y^QWq+%7pgJ`GvDSVry`Q7klRhPLhVoarCGO!<|PP|*oK zyKcBRi~tqyLGOJS6<(!u+u=#}Ri9Jen6po}RsdU{qj=C~d%2ZIeWif#NM zZ^@}`xf6DPeg>$RqV-J{{}b%<}yO_WuC?R{>@egrbBQv|4Ik|8tbc#xq-f(YD|_lkznrGmbv42!sfZtX75{}#tkJL3Psc*mv)$#rr(Npw9I%IkUHS< zAS038?t%F=2q$+F7BNKkqd-=xkM0f;`|RXd-UoxW+e0}8ui6lDl5H>s{8dO}CD5)r zzUh0$lb;q({yhEm`JvnUUsYG;r)E4z^~4sxnRxjs^6azEEqr$`3)(crBxLRjT5T^7 z8(Ue9E2oGNvH&5$LK-;v`-_?mP?zteLCC+Dy_guPrD^-gt-wUl;{*)315F}oIixU+ z4J!Mxbo)(0ei9kKlRJ_e@x+HSIFaXLO_r-Jx9s?2sqbFL=<-HoNa(pWvGo~bKKIk% z*MU*LaMyRf@P?V{PGt^7$<3A##uUb>FVzQptA-e^X#v^E?v}ODv>*p=j93n}G!7QB zJ+2b6zAfFWlo5Qhyi6L<ROVd)Vy*ZtekXQYNbUf@{`gw^>Bde#S)xSD0?eg+{EGilOSfm zMTlR-o>J`z8`v=7W2<-W-D}%*%%h<79-LBciE6zVZ9U%3eaU^jX(M-hw>D4HsgE-p z^?*6(O=h1;s1s=-Zw_0eMydQPzor-Y;KF9{=#f4S$r%-N^;huyk-Vsk*?#g^NMhnF zvZi;Y-WU0!6gbiO^}N!>7NDoC&HVRU&BOt4xBJsdrGc`9M+JHDTon>J6Rn`urX%f~ zS!&ptp0STI8o#5z!9gIZgU<$+2pLHQie#hCjrvtB)_U@T?^e;mR5j3Xo{j9z^c#MuYc`uH8qT0%F`~~kwp+1O6Hb2Uo7Kd0#TAvOM zf~I#5VZAQKrwgddKlf#Uge5cj`DWdX&Iy>rdRS6W(iQ&NSrZl@BkWv+5TsKr4z=*F%3+izXye3qSJOi+S$&Ph<8qb6yVR`}3jBYn=}>XV->V$RY%u)Th*vb%(IqkK2B z{B1zvX8obMaWDtqBGUln->(}b1Z`<0u}*P}%6#GW#JJ|2zn!^`?b;P2AKa1wLkryH zXSBN`hvnxwKnDEQfVJ-L_J6tlEDvRaLc?$Ss+7w`&*`w^tx31)Df+ORsD_ehEjrN9 zFSPWhGj`E8x9D_B5dAbtAePFwlGn%a!;9R#yNp{mYu+G71QV2~%QQ8BA?}VqGZp!= zZvi`iEjAcVhUbWlbXGOfaY;fV?X29nO=ah+#_7+c4eTAFI?N)xPsQyH(DvzKInVg@ znf9fPvulG(MJ5G#;W+%S5Cq-%DxqQ?f89m;_90jkod3S>DZBXTRnp}+!v#?tD+-J! zLY93DxQ=)iem5wA1>C@H6(JY0-fUh!=OflIRm0l$YIZyqiLXL!H!248jqx}0et(kN zI=m5o#dSeSe=mqeYRw05-_f@CxOD8Oisf{_=Hx?PiRl(Qa;TqB;gx<)AKAn3x9E^^p&EW%M8HPh%2(pv^y1@xLs;S$2h%g4*hE@HY$quBIN*DnT%{nzMs% zUocFb{{=Lz{BijP*Z@+;Kf{X7Fx=FXFZ7T3u*x603+Cq)5x&jy6~-TTeRu0M7`Woc z-ir8;d1+;g4f)Qv6%~;`z#_1nW6ObOtRskpxUR?st#5_Ot5dn5tY{d?tLrcUVoG^XD`emv0jK9*J9H;1s!_+P+G97}(@C1-v5sfRn|4SSqDlF#uC{jd&%cM5=~K4x zEL@tp$3w6Fk9_$BHV{HK{5nNXEXVBvn{l?52zEtYtyZ^HfV^ITX9)%$ zeYwyA&}+9~$&gPxX^lPVF2~~;nwj1ErcF>|*OH*NJQXpm-o@d17g@0ykSC^0kvXWw2k50zZ2dtXW zyO@~$VsSP+D!)7=Q3^k2pN)aykh-+aB~35x7RN}%l~|iwh2mLf&old{k|M`!qNggp z(2Ffy`Q5)4xq4$2kT%;-olu;B%%B<>$MmV~lcl!&`=Eb}!AtzGiq(z=_ zQdFHS>2)1K>FpR*wK!ku)+yj!R@rZd$bh>|3MOqkUGhDJwgb>4KQc_vNy z##ED0Q!PKDA;n9RXE6u+QL`-qY&?|2R8n3tx@fFQqa{9mam3_2aq9Ck%GStDf&(3? zHllB;lEI^S{`>-K=w{HR@-M%`OjOl;jH2%A+lz>yN7IT&=e(=fdcvxu`PC8S{> zB2=&z99@FFTjTzr0sce5*H8j}Mz%3=S5QP}h52diM(%#@kaOkx=)#w_};cyOJ zB8V!|ob$uG5(*&*li;?GI33p*@&2=(w=wOhs}B7sR|d;c^h#z51j}zIrdnbN9uiRU zVlikC8fRi$r2_`WJN0N!(1stmb{(?{?o&6>^bBHN*Oxo`3o(w>7)SVwD3zl>_{cI@ zJlDbVGm6sRjl9Orau8jP_c-s_cOWW=GTxb0?oaJ^-{2wK)^rrVH(m-ScEzCHVB&;*Y_^L>q zgdAuj(q|nTZZD~#`{yJoU(0-&cd`NA!R1B!f0_uq&*5xQM=sS~?OWZdkUzDvZIlUD zCokZgc0fu@jI$OU6hu`6eoLCJ*p4gJLd0Ahqq=Q^zD#%ft*6QSb5zuP1~}A9PmgPy z$s$pe#VZ(Fh}~)SvtPgk@>evoO|4BsULl=%deMv+r4mNNIO@1=o5)<5W=fK`LILr} zpinZ9krlxrPJfptXLt6avE3?xz(1$uV`~HuXG9`;-e%(=?`RQ`u|SKjH9)NZ$Mr7Q ziUtfhGQ*Ozgly_3Eoy}bYf>V^H*BVVTynbgS&l8-^=|?Rg zTx+L}e_C3~m*tR!!*HNt^+}Z5i@M_ye2)S-lpF7lx$iqR%8>8pRfoS=w7OvRd#+-Q zpp`-Q=zD_8@-doCe1QxbSB^^J5T#>)XWP(*Ah&~nb-HoM9lpZZo1LR&9e`$&q6gQ zB1fVRwS-R9SkSaR__ddZ$``@3)U;KGc84q%g*(K^(ezL_!>;M4U8id$Q+`+_)VBPE z3nVTx&Qm5PCO07nn*5S+O9va-MDGJn#6gxLXw!#l6j9d*3ySUDmQT*^Xyg}1578Ss zQ-d5LZ8!UYd!$9k@}D}}4Yy4V;v~Cc#bZ6;{0F=3@wQ7VP8uO|OSZ(R#P`W4j^p7p zMpplk@%D8>QzTr|-h|bD*;b83iw)5!KraVe3^Lzk00X2-VzJlWDWy6~E)jCV>)`EU z4|4n3SPQ26U@8D_dL6oxpeGbj&^iH-=w%^E+j0P9mgQvppBZAfdHu3@G4=74wXD4N zx73+k{l+N(ro1!JPUo`}=0E*VBcq%5V~QM|mwt?HS8*Js%dVIH3bh;x9#TRDjO3Xg zoLeELKrkB*i9?F<4Sj9xtC9ckhudpAH=MK7S^7*&fklm)Ej3|vA84&7VP*RM?H?B` z=7;_JeDY#Q-DPDnQ8dYdu2IBA#3 ztDo4pV6&$>?uFC0tqbPW^=M)8?#$q)Ef%ki`P4@5b+07K*Y?7v^!|(R@SiYS-k71V zTu$0UbNQO%%UJCTy&AXYJUOXEAs%+0J-YtzC|!&S_7^p2&(lB`qM2z5@*+%aW5S=Y zV|K~p;^lS%x4`<>9S7N*GG|T$Y^DW(# zZrs}GtC6}-oTu{m*3>^O0rFcIUnJyjfXj~k7vMg))`6WSjz_6r!)!2jGdEme@fg)G z2ZYToJ07_XZI5Gx7mYun9Rl5Zrdhu@$DfLiyw!-;vSum)ju2XWq4cO-`X1yiTMud` zd#w|gl~ylk@djufEUfcNkcY*rg;yE*{POcWlJcRlhn6mQv5o7=B9O&P2|m~G&gC3pcsqro?9yNVVp<6F)#U86WC1MG?Yb~wM_dm@$9 zx}n?OycfsFM*q>Ggx)B7Au+ezdYRN8m@ji~Q#QG$(639xX%OC_i6Ad7h-_9oGfJzp z5P?r&6dU{;Y8a}>uubpYrO(f$t39U_vVR^q%MJ1dYYqi8sNW%8XmhrAG{%N%7X6!#=Jk{uS z$!#@0GwaBgK|BYD!42*G?B^kX$&ymW)vrQOGB=`9)G@n1;qioLl#wp3bRw)qj>@P#PJHY4 z{PNTm(y-~bz2qsO5%Z^P=@CvtPh84$Q=Y*H=z$jNLHBz1;U-bUU9n9PSB3{kxhqvG ziyl>m>Ee&tAg8BEkuo*^gcJU%IbYN_ET z%ENicZn-aJ*75dsOd|K1g-g9#Jly*HUpcvUcPj?1iF$7Bq8R>2MSp9Wep~(7Ws?l5vs~mnSp;nas^O%PJ=bF?Q-;ADh!v%2LwW#rH zO2EH5u2FOZTa{6lMTN7B9HYAY3AZqq^a>;Hpfl{aX(i#D~>Hvl*J`s*=Edd_IZ8+c9F*EoU zbd52N$`-!BVr!wy9Qq8FLlacCF*FWYe}|?q%%{+4;IRl@%0mHtn>K!k9lW^^iTE*R zsbD&Pi|t@x?Q9|76=%jq3u|A z@NMRF{Arc%we3{ovfGLr1{oeeMp zJc94rSzs6_w8>mPus+EVm#f|RkI8E=HrVjK))oUp)XGK#O76W2%F|@Yy_HV%G>PI77FQw_W3~5|% zy=DQxa)$r3p!2Khp-?j$a>ocO2y18uaS^N7(;tL-WXg-8?3>=xkW}9R7_*9LDSJGo z-Br~q+tFV;eDLbmbFD+O8j2zx(p4}@t$gz*EeUer2w;)1W$e0tOR{zq0Up1IetG(B|hq2Wxw zQ=|J&P$Aq?@iw7uW6eAhY<^?Wg)0VSh^GNHMoI~uQ@Md^Y}02|ScR6jbeUi_b2VNf z?QC8eJuP#L^ExO=OEYFvQxh8O?B~bmRWJ_&z5CKkK1k0hJqJoLe|9z_*`^i^ldT%T z^*YJC_=3Kf7_B-Kea7hSu5gpMabUqz-R+^^K+M(OZzW58NJ7Y^KFvnYn%+F|=O3FD z7|w6ho;+d=(JCcz{6$PZF8)JIaz#^YIc{r~d~kgst~CllSLmn`b%N^|E>2NOv6@z< zbF0GjBkOo1zoDcl>~apX9y_BCe5v0XtOR)OH%*J0nMxV6i9Z^%qNu-XCVm%idOOA-upOXlO?ai0~tcsl3Lt?L#I9334LJr zVWq{9;SsGw_c*OphiAW*{wK@e8s4qcU%_qf3yQqXdNo*4R!e`g1}|~{c!r3h8xBjk zM)7ED!VM?*^B65nNAuAleBO}`Vpff<#gkKWT>QL+V1kq>Wrv2q)OD<8cR?Cxmn4=H zy!v)I_)Op2e}1CBdFw^0uIM9la_7Cmk}&bbI=c-MkFda5o#m;ga_b*Ewbm+V--$BT zz&ZTeJCT5a)hs~yG9u*@Pz)p+{iWH-ZbHH)d9I8d(Qe`)Z&Z3$fhqhwR;G{inP~4Z zLmurC%P@O*e9hv|XS{>ESOl5qi|rcN||`CRi^MId+W zAJtN&7Z|quVNR`}PZz_XRM=Mq@bGn2D4Br)0}r#8r$V56<3{Zp ziJxACzWwxC57J+_w)yv!!$P#_+HV2K>~M22 z*GBBUZ)sK|plb<1s#iQlUOV;o-+-o#9;3G#-ls62sWYJM*AiL7d)ut2?4={3BJn*M z!y3l3U*9g#)*pea|JW`Rc;L?m&iW;g(L5DrA$mpJc(qUA5ZQc(3{z{ZxO@0ufqJpt zU|A(6HNFLf*0`N%j|hrgz&n-L)-_J=QC>nAJFrikf!r!h&sCOJjT)v5QTfa;PUPSE z9r`0U_x>fib2PQ#Gt5~|W`6zQt@{l+LW)9d#^ z-y9)Roo2=|Y3=m#a3DbKE-_I{ttr_ARXH`5X=oB7UM=ZR8su_D)-C8xpEtbrAr1Oq zrZUL~o(22w!0T6|5--3`JSDC{4yhcK@bj?7GX7T_m8N9NK zEjs7*-&;uxSs2m)DB>nLog7GWr3BwM^io#E$NEF1cQoJIju0nMHXRb?4V%8O!=9N! z6>=KH$E%CVSU}D6;ONw#>`;Q$^Z>ALXWirSy&|#(VA5HALT|AoWApD0C@uUI$^?Hs z?WhJ*_%vK|4@R(yQ);BU6X!=*UX=q`jZE9QnXM}PKQ z&H{D9JWn6M&Kg^99Hq-Rbjpu8kjj=K#f$G+Q1(ZE$G)`8qAGi`$o$At+1Gv|y(0R2 z!@<)ah}Lc!d;hB-TA`K4e=M1z;-m%sRge@SLVHe!edSbeLa~V;N^JtBiLDL`@p-tx zXm^0x853cvgXjAr?{X|&xI&Y7Boomr$$HCZfLCIdt9y{r%D6TfFqU;D=w`|P#BJ3+I_Hh@#GEYPe z&|313x96&%5|tOYVH%f{PdrqE9b@_INy6+7ovsx+m2SGuOHxdtg&BwkqZ79n_U3Bu zwbk;%>&~u|!3Yg3B*-D;2f2L+Hkm}ZHGNK_NIb z0=H}gE>lbDXGIZY?htiW+LTagrT{$pN>s?mJEZmhqw1}rnttE^@r{-SX^^l`kx)`< z2$%?>bPhxs1f*kZItk%LC@KijNR93ujY^A*7%@QUhQZj__8sv4ob&tr;hE>~jO)Jc z$JN(;J?$Gk4kjaviMLL4;^eQhkT`Io2IFQC3~6m@DkxVMEv&JL%%VtzWH?WXUw_$6 z+i7H=Nhg7OF^Zi_8BXzV={>2uqMp-Od6)kl%4}L%Q`#SD9zWVOu_Jsp$t+DDV|L&f z13p1>l?mGjJ4np=dia*s(cogpG?so{tr{Nvb6^7gj&$b$m@v3Z>KvTIcpNi&u9@il zntKwb&*M6p!PdL^Ub0o7V|`stcFXHH4ehlpp=kFHSH4Z!Q7>Xy*1=Vkq~9pAEZB_r zvTdIPV8)6K8M7onAbgxu)t+ExQr`=s^&ft-O|LolgpaY)qLieXWaO#mc3sSsiSDYY zh)l3katP*yg=ZndC%)tFfQvOO5jt&;Gn2-u(lXk=(dV3>47UnU_h$5BwWgi9)7R0a zE!vY<=F^*(c^JZ?Vtqu%C0%EOdG`HWTi&O80P^lE73h$OPfniSv}bz|_Nr+H^0P~6 zFAntf(iU0mCNYuh`Ph(}j)%(AgbnL7yMb(Eqi+c~|K$L5u9RoeUtVudQj4iHKf)Mj z<939-@;GJ$D$fGEZmS+1JW0vkqaBt5MaGVzU4g7Fhq{R!Z;{=j2J6jQ+S)#hrx`Gx z$CR-@A1FrLz3n>JpK%@`3HlB^tl*=5djo7}oKvX1m0Nt-g+A>3Lk{%YVwjQ$-FvQb zc~hZp=L3M>lr$LmX#SBLJ^$K4V`laSdSVeJ_shLah{+Y}nlAtq|ESw2DvY!RqARI; z?UdnvyB+D$sI-3ELuQ`twT^+4=N)0uuhic0od^0%$x{7 zhbU(UrVzOw>ab0z9%;*;I%at}Tmn__R;3+952gVLs=XQ;5BB#2P5n}~q(WL+Pu6A` zdKxPOC6jlUGKTE)+Ezt0|9yrIv_-eyVY|y=5lGwoK%{-H=fIYrBi(fn4W{+lxZTyw z;np9r@OBR-NTO2OI_^J5uISxJvBn+GAF=;bsnbmo*WxV?+?Qj=XOdm$!c>B)#2r?X^u z63x!{Sb*yy`&LF+7VOGnWawYdT%MuP5*qQ$=L}QOYL4XdX;%Z=5I$tyfh8ewir3IiI_-+7XJdB21Ign zXG^hWWYY72w3g{5KUCoB{1uSSbT^awOTXPr6njN}FCCl<7GBu)Tu7NQ@*U&d)-WLR zJk0JvxewZLPNaz*Ee*NS&}g1HZtOiU(V9LZV>Ac$B|GOW)FTPJ0mtc_rfdt z50B(mJGL$a(9v(`s!#9L1a`L|=@m5cM3w!;WfPD0=^Vfq-VaDgQN21W=L&?l}?9 z?kzSINv+Yz7^$4JH@9NRfdIaFF2v}a1ZR@`I$xNW`4r0AqfV?{R@(}>*|h(>uitmi zq)GnfCJkvG-s+tWg3O7`*NhByeC|K76X>ZmXxr`ed9ej5I z$r}DV{!xgO#Oj^sw)2!(O_Xend65xIlDn)cB~05$DW!b;;{NsLqRclvonKSguu9CA z>C!GLvp>u5v{lo%-jNY+cqBd-be(mcnU&U%-5joKb8&k%ltCwMgL(kTzB$F~Ud)3b z$x#5>;yC8`9hf{E?saXt-6Z5gIB{?f5CP{p8T`=pC>-dp-%p#M45dOD!-+z8HuO?T0XzY6YS**t(?zz_Ybkic5C$7f@sI5kqR$XRlG^Ln1 z63Wy}Pg#SL)v1q?1*~w(YvNeUiL#Yock(7VCJeNM{X`b&dRPoCuV6`_fh#ep}jo_8?cJ zt$j&7_B)ceR()ZM>v}pwK!-C2)?GE4G~8$XC^(T6|GoBavj@`O`If`wGpSDW#s)IZ(HxWw`nj_6~AKAGwjgw z3g+W{yZu0t*%X#Z9}zghzB|GAExb^}i&I$Qo|70I!0TFS2Gg6!_VxrL(5%Rv|FZo< z&V?2Pb3(-hF-57Z1k!ZhJE${(pS(V{yGK-!r83a9DfOrKf5^Gd9L-#Qa>s0VTn8h=y&)wTVy! zLHCFI0n-VB%xuhLKDbk_8h76P{CM>5Z+4%^L-v@qhx}W6SAu4;m$c$J>8`!|Suo#0 zj2tx@;=HEzcx!H9@2*A97G+l6E|N+QWS83x;v1R>zUoKbFD)SjZ4J!#i-Vc%YUK=gf}|scuN4bO;3nNUlX812#?O zz}V2+h8yd4Xow^3`07cK@HBXjL|tQ|LZb!m+I(|}H;*)E%`Bi*DoWweY#qPqEJkVC z`Ao^`qhVbUsK*GZB>l{P2BxwIAm#;cz*1b;nk*mb`3psqw#e_Pm zWw=(|h6iTY7TJY*%yQSp+)h{RF*wWQi;<3lX>flJmd4LV$`m)FSW4+fd!VEXxs-8V0Yj~>!fV$hQc*bSITI8x>JmiB+JLkFofNq) zotB#;QL!g39Y-)-+hxRV05iU8%GaNk%mH)ay!lo5#(+~Z!|H=!}E>Sj=rOO4T=vQ z)#{|@(&g!^Aa-Q|1=GA{I;!&r2f^<$PXx7C*7&4*X82LOk*s`;t%m4FBidYrN2X3p zD7S|Xi?VJQaND4AohM+lJ5gVm0boDcGH;I7^$4g;#(23PlXP83mR9-=hatYz%)$do z^^oF>xB#UVKG{54a$+PEu9>l$8sVx*xABE1Y5zzR@2P=Qem^N0C7VIG)m%xIP zG05-oCG8sfjq3j$Q?DiD5?*L*E5a!e5+&V6Qv*1EvZ^obu|W5>8*>dhDHWpZoK)Nl z7)yqyH0;B5(OTETslqjxVbPLp!twmf&#m{fB)Lg#u+2Nt$;(4|QD%HMs9g<7X3Sep zxM!GM1mLTx$6B)6t~5}Mg~aZY?wCjg_S4l&if&*oHBz9Uq;)er%Ks6=p5a^dPAZ+|Se)Qpt1WlYa>(8n%VVKQR9D%rY3H!oXwP*G6=){$S1wg~*57Lf z0hZUNWJz^-dd%2DlrD%?3Cw&rY~NFuaw-pTmLw&d$E`RCNiqxCQy6Kw(*y?wP-m`;Z4OUkF@C z4>9*l11kK!shIM$_roO$_r2*b%X2?tAyMxxf?TVBdG&JrzN8{+>N3XMw!#yxCqF*p zI-|W&=#f5qzXi;KjpLoheJ^TJ5W0jB`4)_+9b-a zg9c*qu{m1)RV6>`iUUP2JxxEj^G9jW0e5`De*>OVfk?K6tjy*%MPV!UYegnGDS9{n ze;!(&td*__A1IzsM^eaM{&U)l66r9Nq3>`Xo?JS`9CBFh2?_5&&LqUWSV4d-r^}zl zNcyN#$l$mqM5Rfg-lCI-!I_m>0!r#xrkv?_u)+Di1bU611H>HG9-PyXn3wr*0NKG0 z$;fP+Pro!ykynCc7k$?}?B4=POR@f~>Ja$-ZdHGCt@{f> z?^t19t_b6iBwEh^qX&;E9Mu+to!Yu#xdyDU&=Gvr#Tb1hWg4F{?YDDar+9qz3uS_K zXT(XQtSuOEcIhYcAhL(uze>e0L1+AyTovi+j{Jo>tjIQwg}ih*;^b28l>a@pN|{;c z7)(AF3n8Kw4|HD=)Y8q;` z&i&s6Whh@MnI?}!F;X15cm8+X=1*!<{5X9ITA>n48N9PA3#C;JwE3M)@i-|;p#3yx zapGAYSC=nFj@E>QRSUD6zDM-z{z{v}v;t@1Mvi-QFv?T(gx;#*Q#^3=zqPvMmhu)g zdqd{sNdu6!-1-0e!*pX_9#+vET_3C)aRNqd!RS+d^s6GIYPUh|er%{f?@rhei{It;3LOp*1GWn;9WK39Bb?2s+3GTaTO|@d_N(Z+f6;Zsv zEXlWLk7R&%as8^0TmAIjhKIM~ABD%c=;%gNkesZ`j|snD-`<^2hB$*Tmr9pri!F~o zRcbwDQMv@5s5>_0(FR|Qkmp@JPB@Y*aHd`!=aj_(AE!@Gm|e9Bg3Y66T>+K9}(@IQ#vsxVFDW?&WBsCoT7rXRJQFu~%q zl7DgX^6gMwM!$`(EvVV5LDm{T+)J#S?a6lZpKXW2Xwdyj777k`ic0|BzEJqfJoPpK z#=<$abKY&PGK%sVJ+U(~JKkNphr06o+?`KYwG{WRU+tzqC0)us4yw?a!wi_6cm|5k zUtg}oiF8L%-e2|Mj0=Vc`ohNx2iNUQT!*?(_~owhbW}{#nD&jUy7+{^6UwE6?@pPU zDwjPU<2C$1HlpIaaHajmj2fgE>1E-+^M@Hg?~rAuH+0t@9Z5J5E)>)0%9U5Ho+Hp% zJ^vIAyKFFow@`CfLU{nw9wG$dmu{cn^86{Te4fWJYF=e|U~D2;N4pUvhL)=)QC(wR zULS7&X0#PnUyb;DnEK;RU;K&aD=?luI$0orMocXJgMozVM0N10IW*dbz~usbMe|mv zoaYc5#C!sx%V$e$5H$Lrn4jt^Yo&^^-~d{TLp22%Zb0GC8pVN)qPL#SOyvs2 z|JMru*E-n%um9csXf!Zk3-V%6UiIbFi(9|S9ACl|om=WYuTOGII+ z?H;OmtQFip<~mvA`HoYeSLn>Zt0&lUJiv=T-V1#r+8OC;At*D(kL>jsZ*;w30Tl$f z65OhQ{;T7!ugPw(t*okOMY_XGT|ISuwPc)*)CE0%rA1Azp8)I58Q*eU9gUB$&pw-q zF?{M?U}pVCL+<C%_ zR7sQYvE$b#VuWHj1CwpX?bv6MNciN9J)^O3(9$-N@Yi{Tj-q zHacX}286x}0gOv`Ry_1bLXFHRh z{_cqL3-G$OLDyn*;C3R}Z4J!}_#%$0N2B(CDcm&%D!CUU?=?+Nm%CO!sJ>n^7{>-l zgp_)qhr9~(p}{qAUURiO?Pk7XGD^#k;XVl1AaU0QP3(EgRX z5uYTAC7t4NxF8uVk|iBxXlSLqdTHmfe-<7$mu zt)ikp+I_O}EBB>8F_k0kV`&BML|tXs+qAf&JjvMUrLS-u0CRkRy8NU+ExVyBnMKgN z66FmtdJ+3s zb@#yz-1{;E+gstTBjflKs?H5rPC%M^sOis<2v+K3O75Rz?$i3--N=S=@M|SVDbcQa zH8RQ&K}v9U#nLU+ng~ki^*Yi~6pVYx#4<$e;zi{!UHrY^kHq_7^hXP0js4Qq%Z!?C z7Xt|;d^v?d&jI(QX63CPRsJ5d*$G9w8Uywv+*SvuIvOtmwgf8ZFkBQ*AT_menCiG` zq?;c0^nx-~^pcURX^nDGuNLx|S{tOe%@-qyYCun}8MB7QW}P94$r@1+F zM18tzr4*pLSCndMnIuv_`1|$yOFhQ(k}-4E-p{KSYk%Jpo^dAX2tY#PLTiiU^;4<` znJEyQC?YOxaSOcfo_Qw_Z|T!xS)9lwq!n z7El8lB}Hn<)BtIJaOxcv9@ zyoOvD9pr=X;gt}1Fa{kbVmu+c%0w&^eKpmB^>uc0HQtx6Jp`EumFQa?kUJ@3NkjnF z|6QOg(lFX8BPoR<&5v*KTCZ_6F4LZ;fjt!h*qE(=5mfBhn#X+%{mF-6?sUE8ZxQT6 zRn5+=F{8~r4rjZlz;>eGviN1zqjB#`mgHBNNI$>GKV@x_w#>b%bJzPC9NRqZAj6<~ zGkXj`1JlWCrt&6q`5|6nib(@em z1XY+8#KdnH^Q69h+2=!h!AOB245Th+zLqrq9(X1Ip+Va1+)-7tAw&1YRH?aphi>vhz>=Un|t z!q6*8#WUB}ZI9SfTqK?pPbzjsNkT^7PGqB9*<;VqI{rJAEQQmEEQ??ExVJ%f+tDX% z@TbDf#^qV-S_6f*pHMZ=5*ab-D#{1)5RqIZz*Mt8-U7;Tu(6pm!-GM9{bt4?n}h)a zzzdASj$3bdh^ggY>Ec60H!7JIU83{%LP^GBZW?9&oc+6JAZI%R`HPS8jd(=xuJmdb z4Kx|NPOnDJaSv_Pz7SR1&_pVef*zC=Pc)zh(wWt*MHCn=t0m5*ouhkW8FT%Wm9Av# z&$7oYK(s<3R*|@}IaM8gX^PJTI*0>or^Kmq3>bX49|m4H?xk+#qA|B_O?(RyLtR>P~%Ku;yWO zrw*cQ0psOq3^+O(6l|QeVgs?t!n;>hscj0y`bIoU!Y;PD0V3^LkjCki{e^8fM8%@Q zOSAx6VS2fJMO53ftraZ3kME$KVo$V3PGg8+E&=d_T`_uPFrO^A|8Hi9L^+-6TZHFQ)HiE*Z+@Mu4P^S4y zUm9{+BY%L+A$)qhVPb6R|G6(%5$j@Z>f_=S$Q6gx=Tri}`H#h~JLv@e8(f zv)#2oB#S{q=yVBRAq(QKV3~Wi1vJ53C07>EM2ceS+4LT?d)kITeTK@kY8ElG7zHN} zw0iOudfzfSV&OyCmb=L4vMT^?*R8W@C;$U?|Au6k5l;m>Ax}1xm!heYd9VW3D?xda zX;NUDz3~(x85gVNReH0>B9&bepkc)Cn7;<^CLiN>I$|cp*?=kN?>nXlYZKXPPq-o_ zK85utY&}I@>DcIro!`BHe$X;g`(#ZS)_A~N)T)Ed&BQ8(g{7t%nBJ>rXgd1M8#ldi zKoFs2pA0g1Zo)>$hf10!;_cVS-+`hN_SL*Ypt)LZo*JsQM>7!#P4o$rlloFsfo{F( zvIkl_8@~8c%2&?%ff9a3uJtRlaMkBX@BKD;a|s|-No!e^ZJ{yBivB%<`p+ zsg~oX+A_R|pknB~FR2M%+V8qI1-5roE*_{_3VqBOb!AZSH{Ec&e*wTs>I-O=e6ba) z3^?u{=2lgnm+hFpB!1NA1PwiLvKDQaWGFwMTtnML)ZTk(6U#U z7&E<;DW2A55D|M3%4=##FJpA3w4X>{N}XiFD3HU*QoLi@;r@U%29mmEIj#a3f%&tgQK?em&J~daZBexSbS_#@SPk+7-aj^Mu zfIYKP{bz1n6bs??G=(T2dd>ZJ?O^^ApHFb{DlQXBD069D+)&GuPKCkqnhkkzT*J7k|x-%1C*6ZJu@%myFKcw_D!O4vrqopC2 z=I=c8fElQj#4Ao?QKN>I)@8phboI6;5!oHC%v!7b#?4GuwGK7Nyb|2!06DW-XYHDg z|HIGEG<{n2+tX=)caoi-j-hM!mW201;8GFIEnal=A#mysResNgj9}a2V@Dlpmi*B);^#O0eZRK9W z`bJw{yYeU6m0{S@Y|@qQkdFG~W_Y1?dAXQ%C5k7bIdLK*SX@!!RBu+@It>+d%b&Ri z^hd-6;wq{W4t(8DTmeYLB^7_tvUR$%&(|R(lZ&YFPB~}f`ekv>Q^Ev8`|20 zV9=<_ZlQj0dwe^b&*d@6RtDbAaeIs*pKB((^1d8RJIPo%<`O4<&mnA z8R{YdI?i^@;>kB188*cSh}$4%(qk5EOxsqNn17a4n9@wqnN!kz8pF%|OCC|6hf+TE zk;E3uwEL(PzLnVBl8_{q_8XKTev!1^_K2|Y@W;dY}$<{ zf&j-yiq`Ue>BI+#p03|W2nYQb1BiT>|4v-43n*qOrTYLgevmQ=HA3v^^;D5-2C>J| z8B(nO1(#gp{?>ICyC|jZG@dA8hoqc}+@pJt0Q;xS(}fmarp28WHTWvriJt(<6A0%* zJNh`8c{3fP9I(WOhgC4bD zGuC~mAG)^&fZaC2cOm0BmrDUc@vVLpmkURNo=jS2_%=#67uK>cQbl*Sg!V@?3eNUhpgEFcs;8i=9$kHNLO=4uSuc0UpY`dF?DeR4B>7?9>C=$X2YMS2(2+cl*%_oC+d zYbaX#Bxcb*YaV@(TTUge&ETs##7bK2d-URu2|eT<&vF>VAfNZ3NP@cKX?{mr(+io+ z#%a$>vGW(UlMt>8bV0N2dkXRZOZ;D5Q_8=TgFVOLQcE(Pk}CKltQd+-Qy$lvE=egQ z{r)@a*{BwBm4!HDdY%EmiC|c(EEc}}91AoVjHiDGA0wlu_}m3RS|=oRmYtMVoT6gP zEs<$`^$leiuLy!9d8iuw8|GEx3r*7kW0ai-dqgK1^?LAJGZxGG+m?}ai@6gO?Ja~Z>ELX~g-)Z@+^NeD=ZPJ6^vi&Ejn zxUX{_NW3n9u2}D?yCM0jnZo|1cksfGEJTLGf9LZNE69RWKI(xrRJd}(fX5g&T%0qn z_&`?cMZX(v99~VN zbio)_o0!mdSN5&S-hAg{3u6$)?t}kqAOSYXwPcj7t*F){bZoHbENg@kD7+yT8 zt_8~uheC;ADuA3JHki4ul(XB)C?&jb;rQZW`f&8X)ya?mR0zO(&uhUB5q?pejz*ocRU_dd}1_lrw}s_SCYyVYk6 zhfHb@7W@Btse1;62#}s(+4F9+H?5W)nNMyYM-P0R$QSI*&m`*a+1cfLe_R8rYtgM6 zYxb6=!y=o-B`kF$kIG-A7mhQ%!1=^ol)w#?yWn=GKOMc2AWEjks`eG%aEfx_M_Wzs_T__#zMaXu?-|5 zkgYf`gotcIQ*t>@h|^Srw&06^&)`Pi3jW0cQ&$m0Az;mTmYwZJvO6-n5Wv!E)$q<^ zoDyac1D7%BQfZap*|`ZTyrmG9fjjPFKXXr6o9~QRs;Zk9;awMHA60 z_vGpjPNWiN`f{VSfL#U@;XYI^LJqF@s747HvUpCBS+&_+4OA3l?`;Xziu^cuiQ>+6 zgulrd>Qd~`yhW)7T+c5+u*n+(XY|+8^V?TV{6M^pQhws&2@36$To$rlUjM zTqRg5GWGb{H;wGUjI3)>8gII9Xxz)EdXn%4S)ha4E#Gkk zN@c;xb)GXh5l_S?JX+Rt$M1mDDFdXN37%WoPrDeA`b6FrO_cM_%=)@(_^uTsWBwz* z*wLfZF2RDBCPsEaGq4NUy{0fP1|`MxM5Q(@f}+rzn$LQj&MP=O+)`bZAWXtiG9NM8 zg`g(UB~(wyOO?Ll-{!FX8?CE%UcBBT*NYFQV6M%Ks!@;K-2A%jwDPikv0`ugK*CzV z51D2%wS822$%tGKSKL_8#e03ck{y3{?Tv_$P12-qHNR4d{oQoqB|Gb%V@la{HB@mu zswV{x6!ls9`}+NA;8JljT7s=NBx*{y+Q4o~!dS#&^1b_uVIovHA_jNuu zdr;5ElH9Y&t5+qGdO5n)qnXg~-miIQ?QpJ$w(qndjPmF1C2o*3an%BQ%qt&@zss+P*yqjXJ zHrxUwg{gQ3tSA{u>XG|zkak67^!AN(U5~%7B7Ibh$YySYm{F)vl4=Hi5Wi*~k-FAh zaoSA+IJF4s97O{k&WBFi1h0!RgW6T?-(FF^u4@i6u3>_ z2)_NZ6o%@(xJ>|&w2~yE${S}6$479TR%{(jVXcZ!unf>K9(qVlltu>YzFg`#^I~qk z@UR@nRRjrczf|Sm=&f*^UAmEa=Va-r+Gpj7j*0j+_^Sx_w>Pa1q!8*Gxfrh#r&?tU!|;fGG-Er$%*%#|kGc2avG?d)pOV zo7#lmlU5;Z{5Wt)aKAPobbWx}MfN{*lx(oAh-i=Slnx7}MAy0_Ax3t5`DU0zaPGle z0IT_h^NLrg?DVlBBf?`V%3H^q(M5%uC9AKBbbaiWV#8>FQKHwLPhEeU<@PwI z_=D3*U%6V3tU#RPN5-b5Y%6~L}@1^$3rXT-FBZEBw?CbPMH0-J;J(Ll8We3Lpt3*E5SJ;@CAA#4JP@Vei;AfAHi4jzHAc#vl8r zq(0^qy;=vDw)@H?a}ENqx&6TAl1avkC!+sxTgQ|N6}7%KKju|K=_J=0#C?SHL7}~3l1!tSJQd}-OrNPNhL_HjwbFeVWpdSpk%bi z%IKjT7{Zqh!M!&eRy5qGw#I3(lW*T;x4uSmob5oX+4RuSU=tMMXcA3$Dx2r*)ay`_ zx@TK|)j~(WQblD>`96h~b-8N*&J&iws;6YOI#bpZcriJau+3)`Ezj!!dx1P=-%;a= ze_^gXG;O{PPE+Y*Vhu->n1p3`;5Lqeeo^s{syMh6cP_M&l=VSR~= zLHStCw18Wtkb4-+?s<{^Z0jQ?8qr(#6BGL<{MHX# zxliz8m3hV`PKefGL;ISg75cWx*k50_sbbt^UYo@8g~gKoz(uN=I;OJN=A!hdSR^ho zX+ihiafR<9k0C(b=6S%jb2YB|UtaJd`9!wdoi}jB{pCl-_MmNxExfg*LT!-WJ)Y2$)g-EEeB6O9OHNIvmnU-cLKQ-YFZ7-&&%_s#9WngA+F|I3Ub_^-`X@Xm3dln z4{8D9{!+OyhL!X?1d@-HE8dEhft z!R`K}b-KdTyzkwk^`=sN(wJTQ#dv99n7l?zyo%lqcu@6yk2EpO|NnXc5{-j44ng(n z%8S&hY%PSErFjuZk9#v--EY3nPOa~Z?143KgaSJ4@rw)JS-vSty@E2 zu8VktRC_70cx(0Zp+^@A7bnsnsFN*VJRMv*^NaUgt0Cr67{NT9`}SXYpNpl~UggX| zdE#N|=7%F|f>(zP z57O(Xf60@^Pn?XUpNZnN4t-SG;t$$aBJDGQ5A{G~-p=O9#$&FQYY#c8iv17GMDT(V zH1s>thQQb*6Je=u11bP#b@u4DN;#fXUwq;si{u(@PoP5LIVp|k$#a2bNYOvz zSGZoaaxq#OQ|pO!h;b?XqdrFJfx@zNI;;stHTAvQ2_{x(#MY}tasV2!J*}u(YBHOb z_O+)pWdD_^tF1c=VrIh8l^i;02+%G%f9Qr{joHd8%pC%!yl=hBu&;+y{iaw?ULUXd z_o}tui(K41n`560(tB6KMW>p^gYo>(T?ozGKYFfN_Gq@#DAC{P&j6%>_Z?e(SbzSlGZBID4(nqmz{ki;5Zys#h@?|Hm zixNI$>TU~GRq3|m4zrwEGgU6YISPt=Ur3f9y>59P-=FAIhu=B`e?xid%xqn1=5H|_ zXpB!o{zU&iSkBeqD60>#xEqh5azg;tX_ zq~8Hl^3uJj!z;C9d%&J_7rn{f^=E$*6QiL!Lf<|Swwhv5lg@$*5ZVr(>DOal*cnwr z`#;WH6g3x4`M*thNj$UDMbt~1_%$B#w{1l!Qa$z@?AM`ODmp&l)vPg%%hLtgyU!c@@dW{I7~C^k8Dn}Qgx=~ssi4cBqrLbChuP*2c{yHs?Z=;1 zOl>KWR*_P~jkr`U8@OY{J69Wuapy}41wSCqBiQCTz>p!NNmN5gDu#Y*zEUh3%(-|| za{$@ID3c{96Gk6lf7>KKw?%`K{NirB8~O$7W3+Fs>(0>arKTjgSLiP9>v*3JC5m&( z2AO?ClPK8RH%LFMKh^KE-C{o5tv!sT71ViRw_AyqWSL&0P3R^%lZR%T9^MaF|8nDM z*sT(2h$W1&kZanl2l|+%i=Ni(opk3-co^5UHKjSL-%%wV0U${QvD<6WJ)R(*;EgrX z-7xYJ-oh(@yl>7r9Q$`y%Nyqb+Krvzh9X1vmDIOa1{)8*`(>q?EIjvO+d}!wmpIH@ zbCSOW4&F}$7ae@#CY4S2Fc6JI8-N{7F5x6P2pqRG#-ZKBhZhX%!qD|;LV+6YT-4O=o5oV9^n}mnT<~{zec**`!4qvU!F=YlxxQ?+gw|_y zzl{C*r}XRLV2*-htsMZP;={95<(-t`EwZH>@h#=c_?ODzBl52?DG@Zf#|qZ3j}i6z zlG&=R%|-dI{yAg2U{9#oAAJtfpM~v(rBX#^pKZAF3wDZFcXV{(Yzw~Ma1&JQ+w9r8 z5PBpKJH+||v`r7%R;xR@lX$S&QullTBGSSnJ}7m!I4kxJzA4GE*dK6_iT=JDTbTOm z#0>9)?}szo=XbV{?{2i!dq2%4l;TEYDlRt=x(DWNnqVAmZIW09_ih;-d`*!J$V=^T z^+g4Lko};yx0c{+Ig(Qsey7x?q2{}*@bsxue_!iNV^Xhe;Jp=l;N`w`qO*%JuL;1#Nm>nZTb%$*(S=C>$`n9 zlpLhb*B`!iFv@Vzb_1UxdgGjn!NQtEbO5=)18-h|*b9dz&YOS(yh~eD-h+voTb*kq z$U>9ZN%JOhw*Nw#ow$KI$Vhy~4BMWX6*~`3TDo`l(<>x~*@qi$pybJpEW+E^hOrUv z`4H-RcG^WaHl&V+eIFMc;sit%ZrA8;5MhqP&xeCb+It0K_=rfQ`3`(@VAy&e#(iA>wP)n^ zz9NaIzLAZOmwLD6s=Hp6ch}FW(;EpQvQ=)xHqks2%iGE~cdhw;>uIpOHdIe6L?#~$%i^@N|}SEkZ4J2bN ztlx`3|G764X0bmOaH)e(dB)(opL%K4qcJl~o4PTqcUfMw^_o0?rSA)sBak0t!rcbQ`-@Kw*f)}3-yKodh!P3ijR!6kse$dAdP-+C03vD2S% zDy$fT*D~IxW+*|LS*ogV*Iwc!$4VDr;10r1dl{c!bK5-brERNfx__t_xX_$dg1ic` zf^BSY9;#Hw!1`$UE|?!F=Y+fbi+#=hNxNy>#kLEudI91kCVs%r0lhqph%#?IjL??g zvTV~v*j+NKMKG{^RUltV7)+0kx6mNTu1wM?L+ZW9cYd_baox(NyrA}@(= zQ9hvF93Wn1u96475LNo8?1r8Y&GUO0c46i%fOh`+S{L%#?!Q>|^h4>zW1UPS0Ct}v zu44Ov^2qBKb8)edruc;dh~+3r9*o>yGof~{4j1urWm9y!zL+HV<_ zA`^k&b=VfW6aAok9bCD)Rxb0SR8b5KHm5&R$=w}7C#w13eG|O`3aqaF!|OoABaXw5 z3~mHa#tF{W&@=5wv%e-1OTHzGlH{jD=K@owa}tSCa)@R<1K3SMA{t#2xB09)SCUJa zc`hSRy|vFiT+|*=jcU2=nSCwNJfBAFk=-+PdHFBZ-O}HJ_~K%|1dGH$eR212JBT!N zBiVQf3Zb;&{TvIi*djeum5np{jGiy z_a4K1y6${>e{SXqk^Uo&x97u`r3dLlvn2_^lzvYeQ4l2>tiz{5sN9(?P4*TzyD+VY3u^xEh1~Z){0y--vV{)G0C_}`@GPr2q zrKQCCW1@=$YESLF9jRu9S%nnMQ125U9rimUxba(91 z4aP$^N=Yi+ORGr7(%rpM3oOf7@O{tkoG*Ok{@?e^H8a;-Gi%X1sPr>BNdYdvEEoN) zgJHW%R`x#krx=*EeH_&9uSEuH`SJW&b_+%kMTjk8u=ld2WESvf{;0?4VD5T#N)bJ4 zHXn7q6zz5Fb7snB48!OP`dd(#Q8P;Kf>J9%U6Z9I4n!;r3%!kJ^8|Y4G?xJAzV{j! zM0oXQjJp3wGfeMt1wwb@m z6BFB#YOaJ|GZ2X&6B2`OFXtcrG-dTWUoo|11ikCa|FB>~-#oqFxsbMa(d1rqaCBi0Gh+`jM|l3X*Z&RvCC{w&K$*HRJRq@3~xYz*bWEQv7yp}XUCi_e_AUmaiu zg~}E;Ab)LG13cU5Pb;Ghfkv0y9JwTwC-eapnk8<>VH-ktlP6_gP=(*kBTu^J<#f!> zOvASalt)SBII+b>j)qK~?&iT)#NA%EeVqFhgvyH)qq2C_-<_Os=byBbqbU3UPCu$r zgl1uUwC8kOZFm32VQzDnpXKjL>M^PefBhz#Fs34sgpb-!&hAz%nn0Hb=6hIB#)r1^ zyU9zi{tR;#FwSXygNZC2+33F07?s0xe30AT)({9E*`dz1(KIyAdC~1`o*kn$PEY>5 zhdAJ|f9unajIUW0WbACE#0)!{5}v_EpDD@!51q21w1L|V;HSMjH9qLc5~ubiGcP5l z_O(BMsUT-d>PzYTSwS_XXk2p#-PO);Dl_nDdkpl@VlowF9rA@zt+OPARO4fkP2Xi< zYM(j_j<4wn!s!gf_`N^;Rj$J>c446>_0Wl+qKwyk@0Tz`m>*v~&`Rx)=76C2W#N~f z*8hTeE#7SzvinkO=0q1ByuxpkN{cw4Y6fNKze+k zkhtYtPlE5FIs^kpKtqqER$07KYP`bf2RzH`QNFUm^kp(cufn{35FeHe)6Bb}SyDLG zyD9jN)XRO?1J}c5l<@q|u;Iq5?KFP=YB2se$_sjKBjvvQ*8RkQH^PMK+P-Ll1+VpK zJ;q;WK^0vU_@rMR_Bg7xJwJjIgD-C8eiFBFbu{1h^`39^(MYy)`W|NIsBw)FFsCNd zuK=GV_s-LEgog~16Ak^VwD50}^eGQ}d_;DTEc;(Stn14^Glt$pm{AGZyL3t$pjqgb z7rqWH2K8p~V(;D6Y!W{fzD912Y%4z`gvstWN3~ zM$G7pX#_{h+MSk5N+lljWkLY+8N`G8$th>~qLN9cVU%1}--h+*_X%c?vesy)pOlQz z74PqDQ?GtAM$6M&|2^XiN3A=^=-#Q9Jk~=TN;~p8ST>l9v9yT+BD1i+R2?B=Y2=EF z!QhE6woh$!V80g9cnkA!;Wxa~2a|SPw=3Yz%7viQKxalx6tsjmkmU|hdAIENn(NEa zFL@7Y>Q-_8D9Si6;1^Th!v&oKs!lc3x=A#6!Uzbdh1Y40vBP%$rv~Wd70Ne?mmq?3 zjx4u3(#|t|L02uHhQnRLz@y8BNREc@(3HQNxoD0?J#(xhkt1G80`Twwh zI*&NW05jY;V!bMMD#_moyprBFA$s`&1^`dW>an)@VgVlc(O5A-#u>NPC*FUDyVWLOm8 zm*Lo=IQxPi+|Kz!z;(XTVIeq>{?rjj?RbrYZ#KXBW$k-h zKaFssXsC!)=Qop?-t9_L94(WXMaHk|^O2J!hdK=QY701-E*M!GV04g>dMGQ+^) zg47iv#ca!r4He#Sg+d9=!_q1GPfXvncq4OT4x>2GfwTU9)vYT1Xp81H< zUfa`Lm!g8%%V{dV(SFK6C3A?A8qM5y$^l(BQ@wZaRd+)UuDNR$^$(IlJnemS!_Wzz z|6&~ShZdwOni`2Fo6D;~gt`Byjro5l@?~N|NU zVJuQb$kBLG<9(Ie=L>dgZC-ECTkJeQnHoQlobgK?RT1<)Q-Z`@Ca`(Yd@R#iqS)z% zcmu)OhxG7?R1|iLQ%RME{f@j(`uR2IFXBjEj6r(KL0VVao6@cnQ!vIVx2*wHH!cTF zKc)xv_9_^?a1L}ZXks~J{|6Tj{$+)d!I_H}xED=WsyD-~u-U+Ay=!KApe;jsW{3{h zlqH_PVmOWcggX8po=8}&4qj;sZRLOh{Ao0-F`8%-uTv?B0lClL7XH}-%DuFv@i(!uN$JkSmY?>9m`%#f5PgM~?EL63mpaKbd~w z^1tX{^v{KApEdQLL`Xnqmy#TNdXIc9Iz)femdPP%MHQR(tz3cC?6m5@ST-K~9Kn1d z{dGK5?i{~k?woi@U?!2iS0qdpPo)D4Iv&q45%HMv!fJ4oFs#_ z8ZoB%mxu4>5(3=DCu_%#7_v|OtevOWInb~W|f&ujmOa)*K5%)3Wh zJxvRj_mK^f57!JnpK0;xQI`UA3=PrVwB(%z(fPdPPvcJNg;v(>-p4#LR+AOkdS+Dl zSh~uYx$X}eyxsO*dEeDr!Kw9R#D99m?!>0PzkuketzMCB4rQs2Etf$Hfqg8Wi zY4OqKq{p=?cneS6m?1|mAg{HGr zY651j&{Y_T$83T5sLZZ5X+=owvP5c^Va3Ne6@v3Y_hUvgtxtw?j86rlE8Y+b%C~>C zz<*>?ZZYT{#*$<$OxSK&SxNIeACL+o{Q7-PEQ^hR%goYEQg7zE+_KgxOy<`qckK88 zBxK@%=?$7NBgi!1v9meU&}>E}@JRC~I`x?C%bH(!Y>gBAJ7*}>2r=7DNm+ZRWG?5G ztFJu>v9P^t=89t&+vJ(n$7yHxL+7B6&jIoEN`S*^mofH?vO|5-E?02tnKOk9>UTAl z{bbX5v#<_AnLCO?OkyML@xF;rCd@IjCfk!jqWk+W31(cpEgUQ)HFG5JWd1Zm5Si@N z%%oQ#AGX+sWpqOpPQ(xA5T-2!Ypt%kyn)**(6E&lQjyZSaL{#ic-i;{hP3<_C+6JJ z;F8L$KLa{(A&r@wX{LJd4P!*Ku6y-D(ck(phVhfe0XNyki|y=ea0%#2;L_*?!*&mdAQur{zm#7>_jN-M>xK9-bhuR_)C$u5igItc zT8cM&>ru&Ms-Gt*EZ^6BUl4PgE*CLDa0zfQI<-X|nQ-JUQ;hjAqw*)DIZoaNnkJ=6b8 z*JkFMYU9+tAcniuu3&h&_Bo_N*WCQa3DNJcznqpH35iK|^j@t3yo%e!s5@f`2V)3w zW!xHuYy+t%**oOzbiI}j?x<)xt(k;8 z`n+fDZL&oM#4PeGbU|AWVi*lrx}V`2V<6z4ApS!Gb3<`12;SN=co{Q`3)hG_!#%HZSch%0a7&hoNN1 z5BFE1m+00D`l^W^KJXqm7Bv-RU6i<=mB`$_0eY^{ns1Y_VU&6O{&Pog-<4nmzC(gZ zHpB?$<~oVC+RdxPVK+_0IBD_H&rY*x4PRj6#?W(D8&i_AOx^aqMi3Y=HQ~JAJHPDa z%HS16dWQdLr-JV6V*SFdHoNIiyf~m_36JS?zQ4VDC)87K6IVVglYtRz z&qS0%B4|S#^1(?hh#pvQUt3VdIE*hq@PD-ccg!Q<@H|~y+!L^XumxM1sSzIL-PGFG z%gjQK?x=1q%)Ac5r@_~QKp2%QJ{M^OZehg!jKY~Y^3Q4)%lt*jDEoa+WN|oKiobyQE zJust0I!VUgF-JS>Gb^opeNw(4V5-&R8(SB?8@r$8Yix^_R$HnKdor8rC!8WUcob4K z>b-$&K;O4U5*u`A*g|@VePvbk+Mf5DI-#dU0K#dNnR^KGoU4@8-Wz?#)bD?3Nxnh9 z$M>|u@BtC4)P)^cAF%Pp|38Y zVg0p#hogK=xXty2s*NO;DC9@);oP&;cPY=8!_yAR34f2zvpws?UBtO{pNJ)&M}`2| z5JXBDPO$K8c@Vq#Z%|647 zTvJGSUANC;NB{FRvC*fV2-G|^Sein2Pgd)*N7Fi1~VTj>wi zWE6-rI5x)rb9dW*9tV7Y?%K7nhivEpCjYdSohQ^N=CvvrVq`T_O|Np5vsBUfA3^9P z#0qy~M(Cx|Hse**zq26W=rEAFC;yd~+`?pQ9AAxD^`W`gs&FI%fn8`Fs~0mq^nrJ3Yo3V}4u1(~68V)gAqASAew~wZMEW>c z0KqXpgdKa`9Gnm-c|?TF$O#i@W=wG!*IdXop1ThF!X_ZGV+ zll6!kLjg@2G}&ekpM`L=p880H(9imn{<(|71vfLaoi&Ot4$Zeroe-`){aABOwWy^c zkDe_&XitvMK9tLL+b^EhZUD7YZ-%uAtXg$z0^YOz#qs5$gq1BJ5~BhpdaN!Zq0>~? z!9{hAs11dlcSfHxA7;TYi0O6|%YV#PQtpnf-Gk+Y`k%eVP{}y4?|BY^$wk9-z#!)3?k2Q%O$-cB z;I4}2{sj1$NMK43&q7`&BS%n!NFy;x`sKqVAl9y|>}N z={J$ymWs7_?pMPnbqo{ta6F?JmGRu&UNU`og{OTm#Nvfh(7~BIQPdHO=lMZFrjEHy zw?@OoBfm!X{aT$uS3jxs>I+y<-*ZM1ll#%#;DDnYZ+CA7h&#%VaVr~icn8FxKxD8V zQ}!V6l7(`)?b)F-$$Lt!+Gx6MmJaW6D}&f`j6abx*>xBtFuvj1UzTcelcZYLge$=q zt%a%`?}mt1D56J?<0As!ms-*^x!Av=Y5eWk2%HIpX9f=3pceZ-)G8i2Q9>*<{7563 z+!Z&#BinD`rE!mj3F17$2csS<%8>4420ZuoKE$53K(D_OHcn410;pz~yx07RlYxjq z#!iNAC}_s;EG-z~`&UG&vObcd?INuHy?T>IKx+oG&{FdLC3ma$-?wy=Z|ciIOP`$i zmC5iP7G}N6g)etu@0>_mT#hdWs;u9pvAkfrTksu!hTP;Hq%Njf6CeqYght377mC^BjG`p+Zp1?x80N@u=67Cvx* z*v*5F;2l?+t|c`E|6zlR#-%O%DIX@c2(MJSE{B$SyvC}6Ewt^8@fx;GaOH*sD*??w zT}cjX&i?sgFh{sh8Y1)5QXbnYvj3-mv6r~Dl&p*rA_-}Az}QeLH|O7>1!KHWr$@pT zre&3h^+$?iu^mx?ri|Ji9XUki{sN^6--jB2^xKLuv~?z3YeZ5{j62ky9a^D>5)sQz zW*+e*_azJ1miCxzaf6<@_U z?weesYOY*16Dj!Nv3k@P-|LvK@^_or$O`*guQLiV)z#RHfeXa^@3QG`F1v}UG=gYz zo%Cx3Q9XC17B)uBCn?APDs=<^%ZRTqYW`HFvYc@7CXG?yIoBfe~Wl- z@oa`%_yj@2Y!hKk{Ki!n)isF#ri|3V(*DzsPVFf9Bp=@2S#GQl^y5G4<+`=rcO!R9 ztD}PQV>V_LWr9H0AEID^Y+}DzEt)!*=?e%nh{u|M0etQCcSvN$K=?tVj~MV3=((dM z)t|)?ws|vOFDruENlypMwj`dpuanImg$=Gmork$+O~9h%cBIa-c+c2N!D`DJy(FzQ zg1fn?JCqX@^8Vvy;#nC+L&U;DPG&KvLglw)N~J1j(!X(@k^K-ni~~Pe^AcB z9Iz6i>DwO1KlTj7dULlkMAHhbwdW>p6n)*#0r)zU)KqX1Lq#~J4r6sB>>CX5c=r4o zNs$+6BS{O1X0qUoF--K)l7PvgPu}FX>{P44VVY76)uGVS#K9$K>;5^aT(ErGV;6(| z?zE)Wb_z(^`S_?m$_)u5u~Ydt!_7jSk%OTV<)Kb1h z52=UB=wHy=+!+J7*lFwS%JZ($x;x4Qg$22s0~BM9=65{QXo`W_(cQyDZRog)r2(zp z!xeBnb+X!l%`?71vSehRSrdmBTfoz&=fRsB1^zF}i?Xwcp_iW6Hn{+Ng$`$Bj9*JFn2bcPGl?U0MAqqkvk`y5*SBhwKs#`R%?7faKh z1mE(cN8#m=MojI!9e$ifjYU)L9K-srfqeZBqC+M*x7sI2iWg&`^~QeRU4`jt3AzNd zjn`#D7IEmZc`6BPK{RCWn4mzZuptA@$iWRl9`Ad!nC0(3DP-tY&~`wW4u2?C=-~}i zvvA-6bAH+ZGyK&^Un5cN$mZS&Azn6{q>P5n1PlEtv5QR%*d(y01rFZ|4NB>6EntB3 z)&d5wP@KX8aK|&xfz=OL@Z;Ok0ToR?RF(jY6`9(L3Vydvr8+jh5GcxBP68U6!aopd z*qsPL&V0G2l5E!Fzg0cDKPn@N`8?n;E;^VwDs7$Z@jw@tSa8TM0?GMSa$#5HG06#g zNpt#|W5bx0_F6Pb0Q3^gv!kAll2+clH?xI37>*AGHby+*xNToO;orRYvg6b=#HZli zZ-({2QnFcp>rp`kY8m>T7}x}BxQC(j!Fp9*=i$c#q-?fA%&Mj3E^bp({@9p_O+j+}rH)*-k< z5qc%hqwtqJL|Ij4CVME5gNmC=*EKW8F-{fj5rMvRt=IKO{NuF3A`bv4*ODtu& zy+Oy)WTMUKm(-Of6HvULaGk0|jZYw&m=Gd5Y=d5tW%3@als0gTiduk7)wy-Nd^4h` zLh!yLX{?zV;01Y(D|bp!(2LfMhAx6tF`1J$*?!AxP-l`19SJ2%s?}y^5JOR}P^dU9 zGPnf1eofku{WMpol!tPMyq8rIJu0_Ksw8zBbpvGt_Fdq-*OFa~&^Vm4n(Q{|s88^)^H_^y&F($+tsF@#a*)Cu#ATl;6zMpv2 zLyY%2Lhr9!n9YpW292s;4h9Ntjq2%l7m|wGXbROZD+z-o$ApN?f&`4AKH0TzI4aXF zM8jmUmX~y6d8PlK<+;n=2>r|y+Mq^bUy_t*iDYwn zg+r=kqb4O04Z*EHkCcl@-aUZ$zeyDb{=d)&dW{7TxeUF z)w2C0;;Gqy%9B^f!F9|3mKW6W9mv%FH$j!j>cghk7q{Z)p9;4pDEywworB=Z1LTyq zR-|W2`O?h626T99f*3jYV(nS2`dA`qPh;QB(B7jWyxVct9Ve>8U`Lwe4VC@BHpClo z`z~s+FaEUA--eY;cuyqEx#g4WUFASqY{(KFx@%8b zhZX1lNp@!acs|7$-k9<2_|yjxRVotZGivv%#EAR~EHjg7&mN#4lk@5j3tD0=khO;l zx>rxIQpmtPS$7aAeZ3qzb5}cWxQa7rcy!zR2VR}Y{o_W*@y~-}#=E%NlP#}~-AY(M zm=DBZ-Dc|-F@CnAC68kQzV`lAip0(vWsCH<)Sql(vzQ*^sR%4!Qd4nTt_9u*pl)G_ zX8Fp~{p#_>$Vr`+!7IE!m#Q?pv+hO~R11E*Webfxb1V5pzKb{XG3^;?2p-uH#03J?Ex^ z3_>>?ujw0nt%O%R=ZF|9x);zc!l?n{ygo+`a}lgtv;~ewO2bFGL~A1&4wK?le(r3X zb&z^f$#h2rz9)iOXm506Dh)JyAbOPUnsiP;mGz9`svil9u377if2CKwPz-XdvO(FNpt{Q+3#Xl&uJpOvDF`aDKrwpxi0E&@lqof zIl3ReEZ`eD5vS-5&+I*qhS^L|9*<1D1YNjgBwzm5)-5bJ{ID^ z|Fl@?Noa0sN4xuQc+itr^mAvxsJq|Kiu3}M6019I`7#0B2ArO05ZCrCIz_c1hUlgV z`=jr6SfDAgxVBOr2ml`g0nH--mu^B{R74sMy9tzz*;CAp9vD=Bi@gMy-&3H0MNYhz ziFl7~Vuy|0BP+h7Y6-Xzga>oTUVf$kIy`ng=LDgbk4kftTCZ*Fxupe-1G+8ib}C9H zm9noEQ)Z`z$Ty{XM_R4EsPR5~*`TyMGaV`ueRC4OeSOMf<3xg0yE-XhgExho$FHz zUvWK?73XCB0zP>kn1s9KYq{L0Qh-l~RcE}7xFw-H1xAM{{&S>o9Tut)P|Xs5!ApyL z2^!`_CX_>de15_~Eew_3C$Oi+Y02e(iUSlaA;5A~F60j7?Z?x5>beWrhx&9xcMGmo zo{&Jx)$b~5pm_pvS%%CYyffU?9Dmb&nW+yb2Br<;e~HdUC&^&RFMhe3i0a82Y#`d< z-!%u20n!IWt`Ex&Jf?G`cq0VQ`vWk6zD11Q5fi;E<-=**nkAp4!m;!40ahVK!v!mi zv-yA!$-&O2h~V0cy}c0*!1%!T(L3m{Jg#DnZip*p*HPTORq33E>M!u+srFSwbvVbR zf;kJJ69XL0jAbjZ4KwMRWaurS$jq=Q*Av{13QU7F9*9#ZhJ#v?# zbd=dR`sK8HN0&J1o8l)sYTVJCq?V)nrdpqoC(`R_wtbWmr~W@y22XYvFuF4Z(nr7c zEybIFM*%M(c&pjSEpQl)BsG=u0(Dvgu=6Y`OpQ1Z`|zH(<5;^yT~GsX3q+@gB=VtU z3V`a@S0GT~Ykevpwy$#@l)D1~^rpe`S1SQs1@Y7C3&m1R3FO)dg zO%>5@)Da8V43qMsvF7Fpo|z*@rI1~f9DCswa8NfFZCopKK?Y6LUE7@XtLk5Ka~H8F zIWgQmf8lLfQTy!SKKipZi0V_|7AVHs56^`k4+RB5mjRv?&5{N$eb8iCt+=R<|J{V+Y|r+^_rAy>aCvk6{g=I6u^8W z?LDstrhj=PVW(BlKW0}_4;-C%X|J4MT0r)O^9O4XNs$X=E=+jw+ZtRGj~5G(x3s|2 zglJdW?qx%y%y^$GPGlqaI*$wQXN+I_9mZEU7(S&IT}CItdqnmwGhC*nKRY8R&)l!r z5)(!A%tl+RS&lxQ*pP&5hotZ|U|csZcy?sXsHx%t=Q?uw2RbQ4Dv{B_Ny1Cd2U|Bz z-+rw0H_>4eykjWjXlK5bX)o;s9AkuZk5q|uwhG_p8p?FTt z@8=DQjB)!%<-sp8teU%hZkRiI&KDGme*sTLhEVF>AMN|9=JJWKGhZ{e^VL6I*|=S; zo$(Kx5HbR_#~{AB0}G7x*;nHrAhpb^|K>gkr7Zke%~e~nnma0fT#vEca@}Ww;##ht zg_bC%(8vBTsaVGD=D**Goos$KjI4>-?jZOs*7}lmwyALK`jd{{1XiOx9ZVo;+zXRv z9YsjaE0pAdOD|G+AaQ&a=CGVyGLi|-?|yBOiXO~k(l@d||M5`C(*MHZthimDXfZS5 zX@~4g7C$}r>AC*7(MlNkQI3pk0l!orHU%Ybo_X-x)A_aO&3MHO^=$wlx1qmY4t=u- zJrPd}obpP9%1jJv~%UF68c*51rK(!*DQYu$45=XJ<#7YN$`TN8RiS1v`x}!e# zSZZnA`RiFGJQQ3t_^H6VQY4!*Ka`*{zq61WF87qQo%@E36u&9letL3hHJ$4*S*EaA0F!Pf}9lP`FI}?6NdHkrb8;^I8P7=Bc0*S|W4fGbTaR zPhK^+9-SUDZMWfa4g%kQNIoE$$@^s7J&F+^TfyX@hGucipEOfJRI$Kj^v}0(zD0gB zwEgp+ptzHv)vWPi>OdOda_i3~Fox@o(rcm*_?$fp+4|n3fGz@R4X(iS-)SK__ZeNg zR97CnV*q(|v1e(-v{hO=Ys-f%RF-^-3Xq*W;%284lf=`cx_{AYRN<}i_}3OFgQD@J zj@C_z5mN9od8378&~*dK+2^)mwP&Na71a^&H70bKOIG)h;u%%B5pI2NR=2!$G${=xRc~KZmpd@1C*|eix!sgI7pdX26SEJu1uvK) zr(Xj^%ZVbJ9?7ngsWw~#cnQt@9W!Kz%wOWwbv9w%^>nxSPAyBDbeTwCXQd&(XZ>iL zh@0!JmmMVu<<<|i*l5vdxt#(NGYlPTXYG=Y@59rnvHx@}G^4&?D*oY-*;sQ2;g@T& zPk(@nq2kG=>JKyTljg>it!@0rn4O>=gq0|fzdv0{^aK+;TAN^#3G=$Ihl)1n;Xl5w zJ66EafZi!tP4(-vB)KN@&V3m zCTU+ycl0J5`f#o$gFhOY1sw5>ebKD^Q3E&$-8yrdjSIe@GFu5O2A#A`+WlPz!augt z&A)l)^06A~pMjOkNJxZy;ekqiP-4GiETQ|I9&sgh*5VRr7}|nn=P9k5;_qGGdC*ad z^_E@T`?J0ZwP(Ak%d_?cq zBW?U;fE+dXzWZ8c15$P;%J(~SrAymlF>(O%xg*eRBRl%~uEwlal;$KQy=qL7oY`|! z7R|;+cHQ&{cHzQXt=l9mU6Bf-d!R4H!nt;tO^_pIa6*7X#wlbjUOoelJmG@$#Q5&( z1yb+G2aiVuYi82&<6L;p-W70MsG_XfB8#Qjlv$wYKBMBn&v*xeX%+~L$PAKp59GfGQq9j*1NFM`)X zILa?>kln^-&r;&UIN6n|GtL5}8q=a+$Q^&z>PU-8(a&9No5!>O zzR-&_IYzPo2O@F?lk`Ep|J4GxEbyQ`m8gf3xEBGr8dxgjSq>F8WTX?^zAIVYj?*XM z{FK-HIZ@Xg#$i?>`(&(0|}n*g1rz3%?a%OzYOk$V#?|EX!G@>IKvRnfQrJFObC>?0>Y6v#b@ z_A*$o9U!BN?qp0RnynyvW3pxj_vX^zdM*NRzBK1>Ox#MD5hyhMUay|yf_M+C=~$x) z?qUIg=F?eWvKUf7ZA>w#Ws)dI;CZE9)Cm5h2a9GT)cqRkzFV@W-SvaG z-v+TcsiYctsKVzOOe@A0J;cyAT~Pnaja-4qg@VJmH+Uy)1|$W!MP-+%ANwx_%(pc^f}J^oWj#AJfFHKD1OSasSzCWpe5Z0E zrxTfHGB??*(Ag-hitZd8^`<>trm@s`VK2?FiKlzWWzaP|`~y6@BrbC4#`2$nl%n+9 zhrK*4;VQBV7QS?P3gVrpLEiMGyIA^}2@9A8r2DvK&$aLrFi1LR(eGdKk)sJ_Tz|x~ zH?%yurdDvwQz$B|xKO*!)Ct-=&5G78FVKUbA=Sq!l^{L6QIv{F*UFbfUL5A2=%C0b ze24-bUu{$E;Yx05Y7ojYM+O~Foe3jJ8UDF3;Si`!t=j&S-#leu9rcA2oBfNi-sTcI zM0v?ixrENiGeju2ao;9~e-~ETLLZ3>a(`$r;J^q{;WBbem*^T#R(uDH>EPA&EI(ub4sR^8gG*gwdDokxj`Pb8#|>|Fs?Kn4;alN5v((CC=gmLSI1!R^|%1 z8jqjwFEc;hb?^;q-c)YX8%Ze)(q5UJ<%a7+v?*QP$16&huP(?yYhJLu+v?)qE3i&Pf^ThJLFy}>rYx+<}A z!upm25Hn$WM-FYgL|Yo*iU<<2`T*PsFQl44_ln20+4Qh+Vyn8GQDBrSpUi=41fkMQP53^|&$XX{`{RM_S%r zrb1$lW)&@rY!^dg4|9E~mGqLdUTD&7%|c}1S}rr+zL}zF59n0$vM$o`;!2R>e5TuW z(Lu72)ejnQm8MQ_MC?1ih?WZOx*^XCx!fEs{)>Lf5HhyZ{2JP^X0zmk?ZqD#g z)Q8mj7PZKP*GAe$NAUI#B)9a>Zpbx>*5H%a;Z91Qt$vxtUfL3@$U6Gz-UN4I7tFzIsg25o-Q(RXsUuwO&}{-EM^!EJn^eF#yWbLr^T!r1W6ueZ z^T-6MbBSs8>7y9E-*n$(CuufFq}~&tq*YfiTlD^}^`RE)1azOHq4l%S{hEUEdhRt@ zI({6Au{8iIJmPtFYQzM?c3b+Gk4pmM^iJz&>~E?d0yLU0^h4Y&scFx!q~hv462{G# zO2DW9;hAy+tWOea6H?0QSUP4o+i^Ny{qaD+rO=gazW^N$QSja!{q~Q=*fn~uR%o24Zu5r7S0{>uYM5f?Pc~T?QpTPm3ha!Nje&Ax~9Wv*k=wM zF(=6hb~`0sbOli06(w*aT-rUBvh`3= zsD-YF+S4y2tj`Fft{bLYW%9Rb5(;~)3UBjg$Ck^8s|)8d)E#7Y_m!$(9ssJ< z3PPCS8+%k<Pzy5v^fUECJh8dJ*Rw2>@tqBM zWrSEvB5GI+<;W@cN>`QPBpr6eOboFMbDlp8(LF8R&GbSlzwqNK80PzKrtgI|w7}!H z?dF*BnXR>^s7tqzc$8xlp-n(&aTqWTFq_j_(dvs~>~Z)D*(Vm3aK%fR;eh(nk_W## zZNUS?vxgORh_!zCZ#gR-9MQ8|YlHPe5ZNsra*1lUHPck;!M_dlU%LwBZdV*w2CGn! zT#kJf?&SJbYy%k3=jUsgz)d9agS-JyQ)kyqw^Jq{pYmk%-RRi%Gm{PZC?Bf+l&Pvj7Jj&`}!Z^tnkUj zIY+FMfy*27_38_>_;M&Z*5Wz2*E&njy(=&I7LQQv8^p(b{+`|p3u(+tapU(X!tTK} z?~B$CYuDQz&`BGDSB8gQlIe+s-2M4V{CWFGQ9%H5lpf|Q5gM><6Uu=ht9AxPUw?n- zf47LPsPhP(Li&LOJ0N6#JLmSc^KQ3{mK1iiA3;Qh*L>8a)m$ieSdizZr8*A6OtRoX z^q|sPfcb(cnPULcw%>ktHREin7l)(f$^@>A;2HC4+zEYuQ@h!kdf2of{Hrk(20C>W zhw=4h(E_TB1HUPzfXDEkPYEz`mpxiNBXv;RZ^P@pX)hbaVLOf0*o_2O-UYpx7(jqy zy80Q_X^65>;|(L11cFHk8I*H-f<@{UU5r)|!jI}5-f=i4-_;f#x_3;)O4mL^=!Zm% zK0>XIa3|$5&U%vWwzi1Og<})>YI(==-t5rW=Saz@I8DG(U!)>^w>(GgV&QL&Q-KR= ztjzb8g%gXOJweO^zb$7#g$BeL0e8Xyg7@e*I{Xf07k!gU3WMz)g{inH}0@aAfhA8cz&pL6Gl*zBlA(qQv6f?~gmlviXoouJ>@iB3S*#~f@fB;g{++QRC z+cu1U`GPl#BY(+<)fM~MZ+Y~A!gKGd0Gu@V!fz9(ka^}B6S3;VDPg!g#6e=Ks1uww zzYlhffYG#PauOBv?bZ=0-fwL~#ntyZt%`nBaN-ohSTvTtwYZ^11;6n0f6olJpJ^Sak71xiNZqV1BITdS> z*C*wWYrEF34wVM@3)MD0s0#CDsz}mH2ahXd=c@*Ca(cpaaU8nqn|dGAVXe!B7yXu- z**tfcyePK=>?&@K9W2XE4A#Cg$xRBPaQKuFs@N(+ol(gE6*Y#JVT*iEZDAeVyNuDP zTQNxSq<4q;gb6Ed2vY*G5A;VrTXO}>*`YMP7pW){yrC4*p_8ZvnGv~Y6sR-0_#FGR z1^+UD}io>$;fb}_@z(z z)OhgUSyx4bk5Wvf!d~h*(=c!pM|k^+9;bcb-1b^NBJBqMZ4XM(J9|8T&M!kp1U%`m za3+mzWMKzrlKts~ZQDPcrPDxRR~h^;VdAC*=A&~%0@;3T}{NRQq2_t zB#59`!mmnRo?(E=p0aWIp2jY!fDSsXD;n@TtY#D9Y&D_hCHk0cz1#`8GDQ=kBE!kX z@EWYr(cG@6@w}46u1p+gOGo2i`XJ@a4VDSqQp;a{{k>$Q4QxMKdbT{%MGQL(&K|`R zjATr?M-)uo#hB6#@H4$;gXbqNEuu;0kAI37HyZP-AK8S%E9jPg zg5Zt*2Y>4Gs}0{px)0Gl09bwPA<`sFDyUT@jE--YB=SkFI`7K}HOAvUAWW4UR?M^r zXI)(msno%j5q{Jbih#JY4nC1o&?3+#&B!V~Y{#xit|B~zvqR$Z6QDGnR;vmSqs|Eu zBLt@5Qp*llqfr9$)fBEEA@gv!ZMyxB?WgU|w4uP;L-GruxjbghsH%r^8=rbGyjdEig2SHF ze=!kQ_k3+yco%uMO1PCCTeJS8;0#zFEFW=e6efL%t}jO`S>ktbkXZtSuW1^DQ_ddL zGcnrUgPB8Ev-TIv!v#!HkfA?#H5;eT)n(SIwjV4*B4nq8r zS|pU%4%r=&eU81zTZP>Ra{i=!6&&+*96Df^_AvP*9YFCOKbbFR@v-08a@lBA{rgIg zU@~iXUi1zfFn*6^kq5!i@fVZYBYy27=eplW{Y|iS+U&Q{S4OL-Rwd^W_FUdG`f3|L z(v?>?pAnz0gJ(|!^`ZUS+G1zA0c}TG;((Lm3(ASx!tFEeXukqo-x!1JKh)*C z)G>`ET_uicwoC^Rc5AEc_OU_}QHN$csE;v1-4-r+*}vJzz>PRh8R5)}E=ni!xcBSY~$9xka)$INTqN21} zSV*ba`$t3&fs1iYVyK9Cj#xMRb!3$;GPqZLStlVoT!8b0NUJalHcVfHqrOcNc$7+8 zIX|aow>Jk9S{1Ly{W3FXn0+n2Moi%lSUBNaeVd__kQ3mmm6xL47XyUiM zk>54{i&rA+_>x$%LxsT4n4MFfENKHdHp_}x?bcf^?mLx_4NdEaT<8p_S@;NLZw)v4 zTDf41B0W;FAkc_}P6^d$A0{q40fQ5t8(rM2fbpc+%k~naQWc-6N@$i#SA$KmSg{!CGuX_QZ!>7>|`vtMV31zhFwjH74e6`$UoZBnN+ zOWb~ zhkFL<*N^WYFczck&91^Gl$$R4i~vq|A^?z+mn;EsQP6aqospaM_0Qp8%hRFZ`?D>t z^y8C9irLa~eHsR#Wv!)QK-aG`GwCWvLsAC#$PdX88|m`_Aav zvZ)Ba#^M_0SDo8FoYEGv+5|zfYbzo@%wR_}Q&e+Y?5)0R7f21*oeKW4Y;G@q^4GrQ zdirNiC;N_xYbV6L9lEODQjHtEnCmDLcvyFEFg9n(fHTEA zN^?hsj$(x9#97aHD!jBxVQrt#g&2;~sz3}J&6SwA%5&K+on@cB5#63uS(S*mnLn&; zu=aU5{$JnhkG+!W{oZ%eIzmUEO{n9m=X;}4%Xw!tS=dS)t!kVf=VB@0IL}0|ctzu> z_YJ&T7f+FCpoa+vwcDS(pCnDoT^+^n_W4A-a8G%jwV--2Pf@M)`Pq~-sl~3$#@KL$ z^rAz{=b4KQ-D}8h3GoAR3XhG>;3=qMb9e_Q-7i+7bf`(RJaw0GjxS+d$Mlp*Nw4+G8iS~ORN{i)FTix=aCPD=EiIJ_co>9Unv z@+I$e5O7-i=>PZSN~Xm#MYTpIbbF((-)~1ho*mnOAovBMtsH_qEp|6-P3@k-19Cz| z5G%xQdL#@wc{|n$m3c9{8$*Ai^9S#}c|jHbm(L?E657AVX7^Qd_DLqWG|XzB73USZ zbUP~A@Xn(bD=nYx@34FCFiIrkw!NFh>Ccw-UuN*ZWRmcQ1rRwFg7(A&*ESoM3*(P4+r zs*lTF4uQp(1yE9oNpxW+FF{AbPKqlK56`cC2us)>e&^Z)>48F93en72pW<)6mS%fj zDoq^h-+J+r0y(*7UmWFkxI&z&zB;16Dlr-PuaX1$wnQ^6^{ zXfxRESVhqKGag?suDPYI$!3-57)Bgq$95%zX6En9{Zy>?w$EOOQm&&@Ro_eUM`40X zW;So3kS4SN#V3wHFYkOIT!g~@+~O`g!nISjN`cCPn9tY zb|T|PR#z9D@~k-x&U*iP*)%UN3#|(76hy1?+CCz%r+3lnVm4D7qYp={ZnsYFFx6IbwfCv+%oc`yiex52CZ8h% zzkO?9JjR>tlcRCOvVA`DWR) zMdH{F1MSs1-5t0av+rag#}PTlj%gokaWDmfUNO_~L1#64_;foePWSb1_u?;2z;AzB zoVw&%OMuc+EP}4asr31{Gx`?eI^R_s3GS%)+N&4fK3BfS3WfExCw|M7Mcs2i4Sv3! zEyUC{K&RKCFxw1IYa^_={peZ76p=rY0taVo-%z0J8&p$q`}gqizRmD98or1&XQh-L zRva_or{dtZ&V(M0wOwpaavwJf=NjEBX7raHAxoQq4D7S+eFj<@h+oFKqMB(T`?s*2 zBAudr%}L*qjn3U+iPg2uQl(3!bSjgTvXq&+(>FRvP}uEA=p=MP0Oqq`&HSXc=7_w9 zSJ|ld%fJX*IqN=oo3ROPKa~N_hPbHt3!zH9zArIk=l5VQWR+_Bf{c~tZW=!J0jIo z7fhyC#AOl8^{vQoRl|-AQ&Q60b4AC^^mnj52s+E+P^w%T{{8g@+J<5*7ufjBD|8^) zt$2p3*E)19A#DEu1zry(Ax$>JQxAX)W)dbRJT2)1Yfb1J{6*F`qZAIzo0!n1X!x2~ zrtQOBu?&kB9A+6~;McjBMk?OheHQ#peiEto==CevId6`>47 zVET~b`5IT%Y?r&)GwStit$&5jaLEdHFPK1YuB_Z9?;Co zyv4CXQKzZT@k)Q5>FG=BnCct%M|Vx6X)51`rNg$Tp#uWL$#mB^aBNLpXli?f9@m== zdLkP1M3f+b@#*Er;b%SPHrAR#mAV$S7#JYv#B~obIt7~PWrD&3`Wj!6GJ1$F{Uk)I zGTP3wWj03J~g0B4MH0%bo0{c6`dKtCFaieS8VzM^B;M$2Cmne^iY?kCLGT>^#ha>c-W>1v{kyOKbDPgzpDpRYqherS;I`E#3vF#r z;{x-$$B%DD#l;y01R!I%%vCB)5LWu-mc5pclG20s@88RL z&3=u&p!6w}qC@UgR)?Sps^Po=Ls+{dh}raP?RBTs;IN^7`*>4O5xg<1;|5bFItcYB z;XxM5RNBW8n3X_UH{$5}{yz6?WVgfMoQ{|wVl4x1-zj8NX~Nvn);1?2b@{SdD5X&c zD`u1165TZNVn6x?G0=6eCL!0ez24VlrnOtO04z=^w>0&+(%fd)0KPoZvv76he{ul~ z+6}IHUvNZBRNJ*Z2nO$F7qKinx_xf(*P9Qj1p@C<5l;#qwM6-I`~CT@T0#o8qA|K+ zToUlS(Au|C z>a_ABT!0wd&70WD{_*$k`*7T-i51dUFFFzS)x5ee>hI}{qs9a?79gMQgT&x;U4T3F4$JuFWYCf-|ITs&%0 zq;==cOKPFrG4jh%^GQVxC7e+)G3}nN=H{t2hj>H?aZ$Sa;89MZ=>EOyx_RtRTcEI? zzh1u)bcsL$dbgo{;PWZqD!fuDWXXpaIVXzZDeAXRa^CN_t7K)k`XXSwF)+?1Zb2sl zS-mq@))~~~-l3lLWaE3_3qAcFFqB5XBIrf7teB!gW3dgF*pJ}?sWP@v_ZrEu1}u3; zYHu&jrjQa_b)HewFp7^&zw@9SDhnLTN(1)#bC6$D6;bmtLAY<%bkYmaHe4li2du}6 zYg7&b+jt92H!qaU%=aE>27hqp4~zOTt*cNBK98M>l|z#(YMe`7MK8?C&S&(`?DTz` zR_Ycqqx{no7Vb#3tdLus%eBeLf6be`-aDfU=HSH`cNn547{4mrDKdQ0)zh;aN$Poy zBs9qzH@(2KrYmqO`IxRZC|ZBUUikc3J??1jPwlH(2}6wpOo9AbgCv^uRKr-kV-~1b zO>10+DPK{{%*>2W=XWkRw(nx0QNbr0?+fz2mb`DXiV;&6J}cG}j@5#ld-YXVRUj31 z3=(co{-{3$tbeH+2f;olYc!II@+}Y#qN1WsJe8Dnnfg=^VqJOL19gy!ozA+FAZlpd z%!7o5x*uvFu(Eym#>uaYyHn3Cr`c~y;y)Y@i42XOVWyB+Cv%lYC|~6xm`+4vyF=Tp z2u!<+YHvR|&kFULouFKCntiSR>e?a5QF#@zAN(pabW;P>tlYjy@Yy@Vx`?jE7Fit> z(VE)k@hH|iuOfMOa5JoX++>Z|T{^MG!{U&`IeAI<0sb(y%#y%3QS*ATdQ8xqEO>;5 z=Ka@b>g(}bp(Bk1Y|3(nHl(r#W<2!xz3C7I5QYg2}LLtT-x&Kt6Dg` z^gY~Q%7+BgOE({GU)r1EA~>i}GS5mi#v`5j{?H`0mYOzciYZYvpE6e9Y9>7TlNcSA z4!3(5N7EAojfYn1cwPRuNz=PNXfIF2f8h9FRAZSE*6rVa_ndS8K;6=q&;||M$mnD1 z+d#vtp7dgGK@+h2*Ug(P_<_z|=jp$)f>wDx-{+4)XG4yJL1C3Ui|D}x7fvbXY(e85 z*4vWg0{zcO=i$;t0=|)CsrclT?fC%jolZ{juVy8}^{T?)HYxM1?j)MvN(;TUM#_Vj zV2TKv*ji&~nlci;UhL24P(EI# zT3Qx6bY~W((eJR1rjJQ_^c!iRNCpcTe(rrdbdX*xKps%fK(32=R&&zN8WJKYtB7Jr zEz|Gcf6^21qZ_u!)m@7hw-EVAytB*5NwP7j$<5toeCqem~s{hqk^2Tf0s(@+5&i9(;*VN#5Gk|}b&?)ku0T-fmU9<0^4 z+<4<}0c~Kz;a98tM9|HZ-vf52t~Z&3X>}q-XyhF5mjPQTZHG-tf`Xc#r#LyP5=JrwjSIih^E60fw-v~! z$e~8{B*^HoPRY0={>K+T#+6M7%25O5{jI;=G(uMe8XEl4r|2vBDtuc}H=C8`^&5dw z>8XIa@2olJgW5`*z4q;o5|f*5F{ybpNLGA@2~7pBuNJ~k51uPs+gga}7h7-%47KgA zF!okF!~xSvt|FdO#Q$PcE&ZL^nI=_p!5sM|FLR(LgCZ#FkLXF4of)h~Tccrf9ZC2_ z&ZJU|Twws-S;@+K>v5s)%4h+mp1`lDh{<>H;0p3?GCwy|V4=mT=#_;<9~8MD9xVC6 z4W28DLfH?g7}9G943|z)%?a!-c8w+_U%j`K#!A+ULg%;mf-%S?5#`sb6vHd8&pJs- zUgNo?-K#8bpv4|KW5{}=`Nq!ZOmmP3LggxcZEZWM^TTyRNz!2N&Tlnc+*Cd90Ap|C zr>oH@u%jhHK^=It%+P5Ko6froEv>Cj#`Djd<>fuMWN>Jb=jx=Btk7yeX}BC22UZdE zFf=zer!ktx45N2y6nyb<%0=FhSB&`$jU@}M3Zn~$U@cz5Lq%=X=KG|$#Cd)bVRH=9 zZ?|m3<-nAu-lrYmJpAcHM^fo0^I@*S@uPU7g2TH@q1__p2M2Om-XDP>E}5v>DKcp< zuNXkr90~MYA0EBQ`veypx0G4WGxQZbHPRZB796tjzDyy%w$?POj1D@>{lm%q2*key zaN2N_nngD$$U1Z8#LTXZkPu;tS6TyY6$;Pvc-B*3s@h%ptcO=o~SAz_Klo-mIHi<3~%IjgcEa1;>%_dR<9AALpU3rHHnMQX1(go+X1Ihw~Aw zv7uVPN>)jbV}B4_+F~)$%_}aTKLqmpU?z%dPxpx_poEQIkNUnwV zBTGY-%eLt^@-C8=zH9@tj)2yOIV{K zli`8dUdK>>lf4B208E=Bl-!-Po&rB@QQCV|RMZ62<)PEc+r`T5$+bY@4u|4XRCV|r z=ix$ICre*HY+&SEd7jhKt}0>a)q-`;Cc!Z^FHa|@4X?-ws9;tAOZy`>{=1IKGG zIs31mwNQhB>rLx$O90~|CS{F6-H9y&zHUN4p78Ii*pvJ!L$NfN-~~Es#C~3G?qch* z#~_ip$oSLa<7L}5wOc2^Q0(yH=D%>SX)8U1ya*!^J5%+N9%+!YmX=*F)E>1daID;a!O$ z7I1i0+F9-l0z2lidaQYfWzy*A`OchgSAu2s?Q}A>3ecX)l$|0nVXrcP#O#vqhz4E& zzBDp`R3=whGJ>(eM{N~Bz(2Uivp0Nkl{dJ%~ydRQ-Jeg*18{TQd*$CiW`o>q%gvwD#E_wOS~{|*_g$U9UN zD80-3WiUTj{>J6e72kUBFL&w{u)*ry3tfYU{x);6?(LsEXpODe7db{)H)_3qtB9B# zR-=7cZ9>ZVQ1)|Q?U&gTSy?r}tXs$oY2TWp!pizJ4p;vcKOls(7Svk zQovk*jqL>4qWrHcvY~*`*AlRT zI~M0`xFv5(IvXJ*cAJ2~1|1k~`99QZA|T7k$EV7nC-W1)U}H>P#-E2TAHj@%bW?k1|OV>Q(E=2I%gB~ zey+T;>0oNTO(3d}`oBN(Nm*#zX!(p%H>GVHPYhV}OKfdzX`8FVaKD04*(LB@PD3sp zk;so1@bz*U(yTZSrzziI%(2w`WeWP7wXM7hRVI=;VY95;;DA}^N8f~4)x{x`8MIcuj9pn z=}_iRR@InwqeVhaUeb_s*`M+_1?fpp0ohIqSboJS-z3)JB^Xn~1>S+dQZDX^6OC?^ z;2FGMkkXhWDn1|(OHNB~ut<~k@T45BB03w7g&7xFXCF%%p%#y541@F1eotU$xpw?| z)637DN&7spJcu(ttWe#;8x)B|z^RnaT zt7wsoh_nw|Y#jw^`87*@1tkYH0K=K@VhH9LBzmRQ!s14kK5{&p17+oQsao%No_B?orlJX}h`Z3lsbReuvG8NG`I3dBzTY#gHm zBn)dR2DEnxZn?|mYeIxHW2at_kA=+z%aHsCYl{9;HkI=&pdIsU;P(d**iyZYA7@cz ztP(a4?oHKs!%hmR>YwA_C;(s?R(Z`$*uzkSL3;98Nv(hsy(L zk`<(2sJ0$BWRNv7={ixPw*HnU{~&_p_`?JifRL%gnp5Ig7@)~tr{ff7V2sa!{?SQN zXPuvEPNSn|G3QzC^kxUv`_IXfP}r5lUOZQoZFAm*_2`%weK6xO%7GJPdGFTrF!Vyq zaJXDW*V7J4?(8R6vTmSjX+V0d%|55^cWvHi7kgseNRjHg-^!DxBFmz(l8b%)Y$ByON zNg~N?l`eT`VqCC!#lWzHu%jvb9*4F&Ykd8IsmCXqJnaH%bFY=5$Rk#MOw4c&RrLDO z6%^|j|9xP}k;^^wK04SmJOMlLVppcx89me-r}VB&_Ccoha5d=|4qzT&_x-{Ik0LYb#w_-oW~f~m5(VNDrW%DwDL0~4Xt7n+OfX>Q!*yEp zWfP%}-Cc@znAnSiwkrq}@3C6j54)?Fbv$t#?!52gT+;c2ZUo_G<}{GW+f;l{n4w9c ziZGIYYT9G|tt_Xq@1w7s3`i>Z`dxgC{AevmvKVVAjlE$CD9h);{N5vnH`mVJy1|Ev zi&HwhgLjtf&j?>1BnX8tXqJuVG_S?mo=T)ajTyzXXcXJ@DFLe;bWYZ z2w31ZXLyeCbGCuYtcp3UI-CVRa`9?bnthhfY}pide(Nb7?7XIeHsItPw6jV$1|5a2 z<<+KIdDfX%yXWL}r{dzZmgWQPy#c8rK6-DaPw9|71uRlzCsnr>>|%a_k2Q57lX zh$aIaQIs}(dAP@qQor4+tjjD~vOXx#YJqS^=Q4|J%83H$xq5kMhzREjDlB-m`KT~d zLs8347xa9lVqkD8?|+`IB3aTdHq&2p!O&sC?@7_=t6u9xl7S=V5)rqKY9@(t8x@;Y z<(30-(nXlTYi=n*lU0jiop#{f&(m>pA;|e%Hwtb!7n=b;Fm?kRki(0QS7$bSx=hDH zIL>XXqK>Kgol|OJS%Ep8Hn{F#UT*n0YrQ1s8-omdsmi_9J==s_TXo+pMORlBU20Vi z|8aD|jFa*`+|Px0y|T)4W}XkmZdy=~zk?rGuQ-%9&h6Nz)A5}9c3E-xN^*A`jxSdMtA_S*i%ii?(mkR>(KWt@3+xqG=BhF z6ge6eKO_4S4S}_&91>xA}Ztqh7eZG5j6p&#w< z?+@5sg&j_9nI*eo$#KB4=;|tNzL2Y4&lOx48Td}47PQ2D{x$2f@WLQ1ta=2CKD70BMHPJrB4h7hi#`a)SQ^PXArsI3km;g|_v zSapjQPQ1XlqowP19J<j6C>@C~9FeQ^_#^uqPk`}*72G1^5V{7bErw5{*;u;;{zS5JkGv!(8yUnc z3)>h@wwa#`#aGXjCrLHj(NI#Lr%F+1?9=MG&1iPfas_rsuj(7X;!hLJ#Ffz7VoHmH zg5kXmJt7-V3+(cJFFJk98mh0q*SK4wi|(Rq2@Mzfm*y8+`5*WrhpXX+D@Ld5&Q5r0@?Y)IyW|P`ROe z*^I-3tP+hg`2pO7byR(o2R6jG)Utiqw20omRsr8N<2?B#2kmLsrrmeWdm)e;u@A!^D5)h)YjH(d$78*@mb9{ z-(5s&J443PV0(eS=6Ogqm6aMQ6OH_)B!3U=8RTj?ZKA#LJvdL|!^6jJ7$hdxCR}UT z5scQ1 znPe)rY#Y38Mh;xk5;N8qpWj@+ZB4bVACx$Y4%Yl-&^JmRV9WzT|JEn+9wb>3ar5>+ z>3J9X+ad)-MMnnqfZ{pQixM4-7HcR3pxG~LgNKXjGYj{GGYD{4goHfxC#-HNp_w6m zIV!T2O;ub58;WmLCQhYypZGD&yDDE*-SEnNDv7Zg2mxAlfjcXO6>}ZWxY0rd^}00K zSAgSW5FzY{fvm-4JNFc%_?Rwl}DU!Noh*^K1NUU3@#sLP0<;mfoR-q*oA9jpeC z5rl-2oSeB@B&GzQgpr(&YI44ZwSq=p|7<&D#7oR$2yie!v&t9~sQwedQZX`dquwTz zq_{$>qCo^WgxcX4Z!C9?XiL1fV-M~}PTX?zzPNCVG?UtAtiQ1i7ovPpT=A-(*}WrHA6t^fS%*wpZh7l*P*)`58Z%Np8ccCJVBQ}E-|kg9}pq z>+XrI0LL=(_>=|0UrN`!W?%-Gg{rRVu&8(76G%)4bZNI@PNG*T5c_o$&SkyO=hIsWyumZyqi*u9i>&l3wru-T)iYcqq{Cw)LwZgwPyJ8tzBdgen`C zS$F3zgQ)mi$q+C%JIOpeyUHk#;&GNaot6puosiHJ0{D7AkPX;WBvYtScmQ&QW2}JA z+zCQ{RJxq&3Hm`X&@6CfZSe(#{uvva6NIo3@FpQ4EPm>g+3#$J^qv`hF(`g=*Jh zO3^1|gfSqlI~5$5jz!l8n6uBKH2xJL+21UHs4$tTM0iOaRMuH@eeO-w--ryg9IiAac9Nm;l-BpF`K1~elqH!$bAkD4&-9Q4=M{|oZaN{ z#~sh25T5^Z12$gr$sf$okV|zapuOdT`NG^eV`pb4vDE!uwfJ{^8M|bU5B3$T2G-FhU*{VWd^)0vc5axdKymIaWQhf``Y-^JRW zboA=AYexz$ts5BX7C0h`B<+YR+1;V*7}mv>1)FYyC6Im!R*f;?Bi4U#?>{3Ki4!|N z_S$JO{C+gtb@nrsF0nye%-+@<0P}gctT#r9_wiAPf1GN5{fR)qsB^!2qGbtXQet7M zv@s17G#swqPDwdJEXOj$zmzW>j2!avC(igEb>LFV*u#{-F3mwlAI;-L(f zT*p`&)Efiw=&a>!%CS**gv-E70cSnRiQwvo8j#=}+r-Sw(zH^P4n6A^plWEb_t+Hg zZ#PvaG=T(~fUU*07pCw1Ksp4_dX6mc=}X z*V%mqq>}fY>?-&PKuj29)R<5PVDiWZLp@KncUReCV zz+gk{rV+@Q(c0D=VY%7fUSW^52zmSK*O8WAXJO7u0w|tNP+gFOfddGoz%PpjNse$C zs(^dnRadvjyex_TNKN%vkIO2)8r=kfX@sK029Tt(GP&=e!s|DE*aCu@r{Rm^`qIp` z58NiKa;cg-dVM^hcrhy|4@6y@4|YRo%`LJ^EDjv|5U+SgL;LbKf#EGbqy*YfM70J0 z)e-4NwZt{OtM|RSJYumnG}x<oqvWd5|Ut8&I>Z9te+q$S@xeo>RWTbyqqPth>{1s(-llfcbainW=PH?|YZk-ww!sZd;tU zD1`y@JOrY?W3D4;Pbz4tbPUefoViBC=mL>-hvTNcee?r6J_sVyoj!BM$809TdGzb^ zLX$?71RzP)V+N zwM(I8-7SzK1-pH_frVG;(}(N2&J!ct81H?6%>?C%@>G>;ryzZuuTQL zh0nA-6NJrf1F1wVBm=!Mh-}kgj1UpTohaaw}&HvSd(f#`^Ul^0f`zwynSErnkWB-1o!Z)UE zmpa=|D0^m9r_7WBSA&7{XzCHBa^s7#ZXWm7%OFQxGu}4!!~eQ;Qz!TjacX83TXFoR z{`kcLBL?UH5@%1I7btKlyu>YY&A%A>$Z|oPDTYN~|KCSKwBIH05kiL_D!?VpuXhT> z_4U)PBnWL#bBn3REPCp%6Nek{J z`=1hoGBu_L%>ThnuJuSlAHVn;)*ekcZV*kj$w{E$;@99-8IqkG z48kF6o*AWZF>{pN7tD(D|DO^Gx4h$#Pw{=&uqCr7EoKr3>b2iGdA@M3PUM$nXuH zH(!zuyXgPDT_yf)cwRx$qXBUuZ)JXQ7h3zdpoZW%-ZM@tQp_0V`*2!mv48K}z&`9S zb^M+FF4f^}(k?ISuXGwFNWA4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MAGIC LINK SSO + + + From d960c7d4203c56c9262e1f622d79de604a63bbde Mon Sep 17 00:00:00 2001 From: Wojciech Polak Date: Sat, 12 Sep 2026 22:14:13 +0200 Subject: [PATCH 3/3] chore: upgrade dependencies --- examples/angular/package.json | 20 +- examples/fastify/package.json | 2 +- examples/gate-private1-app/package.json | 2 +- examples/nextjs/package.json | 2 +- examples/photos/package.json | 2 +- gate/package.json | 2 +- manager/package.json | 2 +- packages/config-core/src/index.ts | 4 +- pnpm-lock.yaml | 670 ++++++++++++------------ pnpm-workspace.yaml | 7 +- server/package.json | 4 +- 11 files changed, 355 insertions(+), 362 deletions(-) diff --git a/examples/angular/package.json b/examples/angular/package.json index ae160cc..ed2c4a4 100644 --- a/examples/angular/package.json +++ b/examples/angular/package.json @@ -13,13 +13,13 @@ "typecheck": "tsc -p tsconfig.typecheck.json" }, "dependencies": { - "@angular/common": "22.1.3", - "@angular/core": "22.1.3", - "@angular/forms": "22.1.3", - "@angular/platform-browser": "22.1.3", - "@angular/platform-server": "22.1.3", - "@angular/router": "22.1.3", - "@angular/ssr": "22.1.5", + "@angular/common": "22.1.6", + "@angular/core": "22.1.6", + "@angular/forms": "22.1.6", + "@angular/platform-browser": "22.1.6", + "@angular/platform-server": "22.1.6", + "@angular/router": "22.1.6", + "@angular/ssr": "22.1.8", "@magic-link-sso/angular": "workspace:*", "@magic-link-sso/config-core": "workspace:*", "@magic-link-sso/core": "workspace:*", @@ -31,9 +31,9 @@ "zone.js": "^0.16.2" }, "devDependencies": { - "@angular/build": "^22.1.5", - "@angular/cli": "22.1.5", - "@angular/compiler-cli": "22.1.3", + "@angular/build": "^22.1.8", + "@angular/cli": "22.1.8", + "@angular/compiler-cli": "22.1.6", "@types/express": "^5.0.6", "@types/node": "^25.9.5", "typescript": "~6.0.3", diff --git a/examples/fastify/package.json b/examples/fastify/package.json index 54c9e2f..dc494ca 100644 --- a/examples/fastify/package.json +++ b/examples/fastify/package.json @@ -19,7 +19,7 @@ "@magic-link-sso/config-core": "workspace:*", "@magic-link-sso/core": "workspace:*", "dotenv": "^17.4.2", - "fastify": "^5.11.3", + "fastify": "^5.12.4", "magic-sso-example-ui": "workspace:*" }, "devDependencies": { diff --git a/examples/gate-private1-app/package.json b/examples/gate-private1-app/package.json index 1c1a4c8..0c89ed4 100644 --- a/examples/gate-private1-app/package.json +++ b/examples/gate-private1-app/package.json @@ -16,7 +16,7 @@ "dependencies": { "@fastify/websocket": "^11.3.0", "dotenv": "^17.4.2", - "fastify": "^5.11.3" + "fastify": "^5.12.4" }, "devDependencies": { "@types/node": "^25.9.5", diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index c853976..f4097fa 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -18,7 +18,7 @@ "@magic-link-sso/core": "workspace:*", "@magic-link-sso/nextjs": "workspace:*", "magic-sso-example-ui": "workspace:*", - "next": "^16.3.0", + "next": "^16.3.5", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/examples/photos/package.json b/examples/photos/package.json index 90aa424..f3e8d87 100644 --- a/examples/photos/package.json +++ b/examples/photos/package.json @@ -18,7 +18,7 @@ "@magic-link-sso/core": "workspace:*", "@magic-link-sso/nextjs": "workspace:*", "magic-sso-example-ui": "workspace:*", - "next": "^16.3.0", + "next": "^16.3.5", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/gate/package.json b/gate/package.json index 48fdc11..416636e 100644 --- a/gate/package.json +++ b/gate/package.json @@ -19,7 +19,7 @@ "@magic-link-sso/config-core": "workspace:*", "@magic-link-sso/core": "workspace:*", "dotenv": "^17.4.2", - "fastify": "^5.11.3", + "fastify": "^5.12.4", "ioredis": "^5.11.1", "jose": "^6.2.8", "magic-sso-example-ui": "workspace:*", diff --git a/manager/package.json b/manager/package.json index fe0187b..87048b6 100644 --- a/manager/package.json +++ b/manager/package.json @@ -28,7 +28,7 @@ "dependencies": { "@fastify/rate-limit": "^10.3.0", "@magic-link-sso/config-core": "workspace:*", - "fastify": "^5.11.3", + "fastify": "^5.12.4", "smol-toml": "^1.8.0", "zod": "^4.4.3" }, diff --git a/packages/config-core/src/index.ts b/packages/config-core/src/index.ts index 95851e6..26b84cc 100644 --- a/packages/config-core/src/index.ts +++ b/packages/config-core/src/index.ts @@ -194,7 +194,7 @@ export interface AppConfig extends HostedAuthConfig { signInPageRateLimitMax: number; signInRateLimitMax: number; sites: SiteConfig[]; - trustProxy: boolean | number; + trustProxy: boolean; verifyRateLimitMax: number; verifyTokenStoreDir: string; } @@ -486,7 +486,7 @@ const rawConfigSchema = z .optional(), serveRootLandingPage: z.boolean().optional(), signInEmailRateLimitStoreDir: z.string().min(1).optional(), - trustProxy: z.union([z.boolean(), z.number().int().nonnegative()]).optional(), + trustProxy: z.boolean().optional(), verifyTokenStoreDir: z.string().min(1).optional(), }) .strict() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2f30a..0084188 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,13 +9,18 @@ overrides: brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3' defu@<=6.1.4: '>=6.1.5' esbuild@>=0.27.3 <0.28.1: '>=0.28.1' + fast-uri@>=3.0.0 <3.1.6: ^3.1.6 + fast-uri@>=4.0.0 <4.1.3: ^4.1.3 hono@<4.12.12: '>=4.12.12' hono@>=4.0.0 <=4.12.11: '>=4.12.12' lodash@<=4.17.23: '>=4.18.0' lodash@>=4.0.0 <=4.17.23: '>=4.18.0' + next@>=16.0.0 <16.3.3: '>=16.3.3' next>postcss: ^8.5.15 - next>sharp: '>=0.35.0' + next>sharp: '>=0.35.4' nodemailer@<=9.0.0: '>=9.0.1' + qs@>=2.2.5 <6.16.0: ^6.16.0 + svgo@>=4.0.0 <4.1.0: ^4.1.0 vite@>=7.0.0 <=7.3.1: '>=7.3.2' vite@>=7.1.0 <=7.3.1: '>=7.3.2' vite@>=8.0.0 <=8.0.15: '>=8.0.16' @@ -56,26 +61,26 @@ importers: examples/angular: dependencies: '@angular/common': - specifier: 22.1.3 - version: 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + specifier: 22.1.6 + version: 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/core': - specifier: 22.1.3 - version: 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + specifier: 22.1.6 + version: 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) '@angular/forms': - specifier: 22.1.3 - version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.6 + version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/platform-browser': - specifier: 22.1.3 - version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 22.1.6 + version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)) '@angular/platform-server': - specifier: 22.1.3 - version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.6 + version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/router': - specifier: 22.1.3 - version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.6 + version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/ssr': - specifier: 22.1.5 - version: 22.1.5(1a649b1a94668818bc7633d67afdfde2) + specifier: 22.1.8 + version: 22.1.8(1ab24c3a529835890ef2451023f6d3fb) '@magic-link-sso/angular': specifier: workspace:* version: link:../../packages/angular @@ -105,14 +110,14 @@ importers: version: 0.16.2 devDependencies: '@angular/build': - specifier: ^22.1.5 - version: 22.1.5(7332a840f76740d6163049b1630d9f7c) + specifier: ^22.1.8 + version: 22.1.8(cb17382d38ea9d4c5d0caf91e6da0be4) '@angular/cli': - specifier: 22.1.5 - version: 22.1.5(@types/node@25.9.5)(chokidar@5.0.0) + specifier: 22.1.8 + version: 22.1.8(@types/node@25.9.5)(chokidar@5.0.0) '@angular/compiler-cli': - specifier: 22.1.3 - version: 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) + specifier: 22.1.6 + version: 22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3) '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -146,8 +151,8 @@ importers: specifier: ^17.4.2 version: 17.4.2 fastify: - specifier: ^5.11.3 - version: 5.11.3 + specifier: ^5.12.4 + version: 5.12.4 magic-sso-example-ui: specifier: workspace:* version: link:../../packages/example-ui @@ -177,8 +182,8 @@ importers: specifier: ^17.4.2 version: 17.4.2 fastify: - specifier: ^5.11.3 - version: 5.11.3 + specifier: ^5.12.4 + version: 5.12.4 devDependencies: '@types/node': specifier: ^25.9.5 @@ -223,8 +228,8 @@ importers: specifier: workspace:* version: link:../../packages/example-ui next: - specifier: ^16.3.0 - version: 16.3.0(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) + specifier: ^16.3.5 + version: 16.3.5(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) react: specifier: ^19.2.8 version: 19.2.8 @@ -303,8 +308,8 @@ importers: specifier: workspace:* version: link:../../packages/example-ui next: - specifier: ^16.3.0 - version: 16.3.0(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) + specifier: ^16.3.5 + version: 16.3.5(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) react: specifier: ^19.2.8 version: 19.2.8 @@ -355,8 +360,8 @@ importers: specifier: ^17.4.2 version: 17.4.2 fastify: - specifier: ^5.11.3 - version: 5.11.3 + specifier: ^5.12.4 + version: 5.12.4 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -398,8 +403,8 @@ importers: specifier: workspace:* version: link:../packages/config-core fastify: - specifier: ^5.11.3 - version: 5.11.3 + specifier: ^5.12.4 + version: 5.12.4 smol-toml: specifier: ^1.8.0 version: 1.8.0 @@ -504,8 +509,8 @@ importers: specifier: workspace:* version: link:../core next: - specifier: ^16.2.11 - version: 16.3.0(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) + specifier: '>=16.3.3' + version: 16.3.5(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0) react: specifier: ^19.2.8 version: 19.2.8 @@ -587,8 +592,8 @@ importers: specifier: ^5.0.2 version: 5.0.2 fastify: - specifier: ^5.11.3 - version: 5.11.3 + specifier: ^5.12.4 + version: 5.12.4 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -596,8 +601,8 @@ importers: specifier: ^6.2.8 version: 6.2.8 nodemailer: - specifier: ^9.0.5 - version: 9.0.5 + specifier: ^9.1.1 + version: 9.1.1 pino-pretty: specifier: ^13.1.3 version: 13.1.3 @@ -655,13 +660,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2201.5': - resolution: {integrity: sha512-DAticcJ2tw3M+D1CH4HhlCgMK5tAb0CwSsnczNMJB9QgQsBC/JhOozQTvgnyCvagitX9u+408YcwEW/Wo2pnzA==} + '@angular-devkit/architect@0.2201.8': + resolution: {integrity: sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@22.1.5': - resolution: {integrity: sha512-HiY6d5dkIdJs5grP9OHvgkf14QOcDIo+hbuT7YKuLItQ++ZxCwUabJMLCCJ5R0KrstE5NqED0dNcyk5WD01t5Q==} + '@angular-devkit/core@22.1.8': + resolution: {integrity: sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -669,12 +674,12 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@22.1.5': - resolution: {integrity: sha512-HTmo9y8wjXKtJGVTYomTjZsjWzhirpu1pPRAzsVob3eiYOuxv1qDRAgWiHXNGp5CpnbHunZweXY/WffNGOFRyA==} + '@angular-devkit/schematics@22.1.8': + resolution: {integrity: sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@angular/build@22.1.5': - resolution: {integrity: sha512-YqsbHZK3/HFmLLhwxa5SpN+3ABoVo5GFLV0fiEXCQg2KC7gw2pL15x692jxrq8gEGzT7O27bnjWkvFZ0bBHCnw==} + '@angular/build@22.1.8': + resolution: {integrity: sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^22.0.0 @@ -684,7 +689,7 @@ packages: '@angular/platform-browser': ^22.0.0 '@angular/platform-server': ^22.0.0 '@angular/service-worker': ^22.0.0 - '@angular/ssr': ^22.1.5 + '@angular/ssr': ^22.1.8 istanbul-lib-instrument: ^6.0.0 karma: ^6.4.0 less: ^4.2.0 @@ -725,38 +730,38 @@ packages: vitest: optional: true - '@angular/cli@22.1.5': - resolution: {integrity: sha512-YZkhI64INQHJkZ13h2n0/0PBrQ5ZZvFGiprrDiCOLAD0y1fehguL0PGp9HxF3ZWf+xWRyP//tIte2gmyAaiWLw==} + '@angular/cli@22.1.8': + resolution: {integrity: sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.1.3': - resolution: {integrity: sha512-QtMkjhiRd0EnmKR50bw3WbCWYTi6CmA72nnSz1BLQPpaLSi2goloCrPPniHz8fP+w2ESrmmlOWxs1Da3COgnQg==} + '@angular/common@22.1.6': + resolution: {integrity: sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/core': 22.1.3 + '@angular/core': 22.1.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@22.1.3': - resolution: {integrity: sha512-37lLaDp0RHWZ/lmJqCmIEr0HOM2D5ulHy61gqTBm7KRj3Y6ZaxR8B/JqZmeIpPzKFILVsga+NQ4A8apBUkmezw==} + '@angular/compiler-cli@22.1.6': + resolution: {integrity: sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 22.1.3 + '@angular/compiler': 22.1.6 typescript: '>=6.0 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@22.1.3': - resolution: {integrity: sha512-L8Mw2r7bGG/obqgQC+RU3mdFJ3NtLgO5gWhEC1ylcHpLCMPIAXYsMKJIL8dnS78S1wXo/omXwmJ4FiIlCwWahg==} + '@angular/compiler@22.1.6': + resolution: {integrity: sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.1.3': - resolution: {integrity: sha512-313+Xkf970AmStJE0E/zNJW/9xvDExQG+6TNltBBl+KJsW0q5dffK2w2PQfV4mtTquBqYoeHSRsms4WgjBKL8g==} + '@angular/core@22.1.6': + resolution: {integrity: sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/compiler': 22.1.3 + '@angular/compiler': 22.1.6 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -765,47 +770,47 @@ packages: zone.js: optional: true - '@angular/forms@22.1.3': - resolution: {integrity: sha512-b4ual9pgfNqcnEHord50w960DDFIytG3Qb3bu2aCgzmagvRlg9wrtwQNqY+oqY2FVq7c9McNUN7MZRcWl9HNdQ==} + '@angular/forms@22.1.6': + resolution: {integrity: sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.3 - '@angular/core': 22.1.3 - '@angular/platform-browser': 22.1.3 + '@angular/common': 22.1.6 + '@angular/core': 22.1.6 + '@angular/platform-browser': 22.1.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/platform-browser@22.1.3': - resolution: {integrity: sha512-A8McE6AclwZa2ese4jMfZZu+qZfBFQ4Hl6CaMpzJ1C6Vv6+sXkLu9pouTosJEsUE+etVdepDsqau90lhzgw3Eg==} + '@angular/platform-browser@22.1.6': + resolution: {integrity: sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/animations': 22.1.3 - '@angular/common': 22.1.3 - '@angular/core': 22.1.3 + '@angular/animations': 22.1.6 + '@angular/common': 22.1.6 + '@angular/core': 22.1.6 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/platform-server@22.1.3': - resolution: {integrity: sha512-tzbMQSwHx2oIIjWQAgRunNNz3FW+AMYiUwVtdkKBXvPSp2d/ry+/6XO6fZ0BIi3ejhwtfIJL3GlOtmPMCHTkyw==} + '@angular/platform-server@22.1.6': + resolution: {integrity: sha512-5daf/BngfYLnP416Wef1BFjbTxyMaY7TfqYZ26C29ScpZdPYStBoakVBM6J3k+ycjuJWt1JeaT1WJxfqchG0kA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.3 - '@angular/compiler': 22.1.3 - '@angular/core': 22.1.3 - '@angular/platform-browser': 22.1.3 + '@angular/common': 22.1.6 + '@angular/compiler': 22.1.6 + '@angular/core': 22.1.6 + '@angular/platform-browser': 22.1.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/router@22.1.3': - resolution: {integrity: sha512-23owvZKCpdL7Yh3EzBj4OLf3x0z+jT9b57Qk96wdwI8Lsyf9L78/RJPYCutJ5r+zq3pFM/BHVKyd+2hkfH7N6Q==} + '@angular/router@22.1.6': + resolution: {integrity: sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.3 - '@angular/core': 22.1.3 - '@angular/platform-browser': 22.1.3 + '@angular/common': 22.1.6 + '@angular/core': 22.1.6 + '@angular/platform-browser': 22.1.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/ssr@22.1.5': - resolution: {integrity: sha512-mXREJv4RJECIPixU7LxlnXFAYqemhi67vzj/V6rCXra4cTk/tMP4aYMECtR9RYcSA8npd2ej4RhaBcgHvoNM4w==} + '@angular/ssr@22.1.8': + resolution: {integrity: sha512-D/Is+0dXrFrYeWa3CGqN/inp73pvz+zMrje3wOAR22SBQSV4qPeY8Y4NuR8EflwMwsfiNAfGBYSDQs1WsEx+bQ==} peerDependencies: '@angular/common': ^22.0.0 '@angular/core': ^22.0.0 @@ -1306,8 +1311,8 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] @@ -1318,8 +1323,8 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] @@ -1329,8 +1334,8 @@ packages: engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] @@ -1339,8 +1344,8 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] @@ -1349,8 +1354,8 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] @@ -1360,8 +1365,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] @@ -1372,8 +1377,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] @@ -1384,8 +1389,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] @@ -1396,8 +1401,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] @@ -1408,8 +1413,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] @@ -1420,8 +1425,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] @@ -1432,8 +1437,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] @@ -1444,8 +1449,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] @@ -1457,8 +1462,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] @@ -1471,8 +1476,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] @@ -1485,8 +1490,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] @@ -1499,8 +1504,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] @@ -1513,8 +1518,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] @@ -1527,8 +1532,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] @@ -1541,8 +1546,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] @@ -1555,8 +1560,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] @@ -1566,8 +1571,8 @@ packages: resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} engines: {node: '>=20.9.0'} - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} '@img/sharp-webcontainers-wasm32@0.35.2': @@ -1575,8 +1580,8 @@ packages: engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] @@ -1586,8 +1591,8 @@ packages: cpu: [arm64] os: [win32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] @@ -1598,8 +1603,8 @@ packages: cpu: [ia32] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] @@ -1610,8 +1615,8 @@ packages: cpu: [x64] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -2029,57 +2034,57 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.3.0': - resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} + '@next/env@16.3.5': + resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} - '@next/swc-darwin-arm64@16.3.0': - resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} + '@next/swc-darwin-arm64@16.3.5': + resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.0': - resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} + '@next/swc-darwin-x64@16.3.5': + resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.0': - resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} + '@next/swc-linux-arm64-gnu@16.3.5': + resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.0': - resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} + '@next/swc-linux-arm64-musl@16.3.5': + resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.0': - resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} + '@next/swc-linux-x64-gnu@16.3.5': + resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.0': - resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} + '@next/swc-linux-x64-musl@16.3.5': + resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.0': - resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} + '@next/swc-win32-arm64-msvc@16.3.5': + resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.0': - resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} + '@next/swc-win32-x64-msvc@16.3.5': + resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3268,8 +3273,8 @@ packages: cpu: [x64] os: [win32] - '@schematics/angular@22.1.5': - resolution: {integrity: sha512-3UXlO4YoGgQ6nEBbCTGRRHTfQuDcKgHbRaiivzSinHzOYigsskwvloMsa0LCBCO/3uJpDIjJIyTW0XKhDti0iA==} + '@schematics/angular@22.1.8': + resolution: {integrity: sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@sec-ant/readable-stream@0.4.1': @@ -3300,8 +3305,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -4166,9 +4171,6 @@ packages: srvx: optional: true - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - css-select@6.0.0: resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} @@ -4180,10 +4182,6 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - css-what@7.0.0: resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} engines: {node: '>= 6'} @@ -4539,11 +4537,11 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} - fast-uri@4.1.2: - resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} + fast-uri@4.1.4: + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -4554,8 +4552,8 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.11.3: - resolution: {integrity: sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==} + fastify@5.12.4: + resolution: {integrity: sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -5446,8 +5444,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@16.3.0: - resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} + next@16.3.5: + resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5518,8 +5516,8 @@ packages: resolution: {integrity: sha512-uCZ0AtFDI46sHwJ6mhwNuIoA/KiTC0ZCzX7g01941+H9VMIXEiZjtgjQkzoBRTeQba0cIrKfwk31q99PfcIMRw==} engines: {node: '>=20.0.0'} - nodemailer@9.0.5: - resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + nodemailer@9.1.1: + resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==} engines: {node: '>=6.0.0'} nopt@8.1.0: @@ -6030,8 +6028,8 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -6297,8 +6295,8 @@ packages: resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -6523,8 +6521,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svgo@4.0.2: - resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} engines: {node: '>=16'} hasBin: true @@ -7288,14 +7286,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2201.5(chokidar@5.0.0)': + '@angular-devkit/architect@0.2201.8(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.5(chokidar@5.0.0) + '@angular-devkit/core': 22.1.8(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@22.1.5(chokidar@5.0.0)': + '@angular-devkit/core@22.1.8(chokidar@5.0.0)': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -7306,9 +7304,9 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@22.1.5(chokidar@5.0.0)': + '@angular-devkit/schematics@22.1.8(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.5(chokidar@5.0.0) + '@angular-devkit/core': 22.1.8(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 1.0.0 ora: 9.4.1 @@ -7316,12 +7314,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@22.1.5(7332a840f76740d6163049b1630d9f7c)': + '@angular/build@22.1.8(cb17382d38ea9d4c5d0caf91e6da0be4)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2201.5(chokidar@5.0.0) - '@angular/compiler': 22.1.3 - '@angular/compiler-cli': 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) + '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) + '@angular/compiler': 22.1.6 + '@angular/compiler-cli': 22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3) '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 @@ -7349,10 +7347,10 @@ snapshots: vite: 8.1.5(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) - '@angular/platform-server': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) - '@angular/ssr': 22.1.5(1a649b1a94668818bc7633d67afdfde2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/platform-server': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/ssr': 22.1.8(1ab24c3a529835890ef2451023f6d3fb) istanbul-lib-instrument: 6.0.3 lmdb: 3.5.6 postcss: 8.5.26 @@ -7373,15 +7371,15 @@ snapshots: - tsx - yaml - '@angular/cli@22.1.5(@types/node@25.9.5)(chokidar@5.0.0)': + '@angular/cli@22.1.8(@types/node@25.9.5)(chokidar@5.0.0)': dependencies: - '@angular-devkit/architect': 0.2201.5(chokidar@5.0.0) - '@angular-devkit/core': 22.1.5(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.5(chokidar@5.0.0) + '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) + '@angular-devkit/core': 22.1.8(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) '@inquirer/prompts': 8.5.2(@types/node@25.9.5) '@listr2/prompt-adapter-inquirer': 4.2.5(@inquirer/prompts@8.5.2(@types/node@25.9.5))(@types/node@25.9.5)(listr2@11.0.0) '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) - '@schematics/angular': 22.1.5(chokidar@5.0.0) + '@schematics/angular': 22.1.8(chokidar@5.0.0) jsonc-parser: 3.3.1 listr2: 11.0.0 npm-package-arg: 14.0.0 @@ -7395,15 +7393,15 @@ snapshots: - chokidar - supports-color - '@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': + '@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3)': + '@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3)': dependencies: - '@angular/compiler': 22.1.3 + '@angular/compiler': 22.1.6 '@babel/core': 8.0.1 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 @@ -7415,60 +7413,60 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.1.3': + '@angular/compiler@22.1.6': dependencies: tslib: 2.8.1 - '@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)': + '@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 22.1.3 + '@angular/compiler': 22.1.6 zone.js: 0.16.2 - '@angular/forms@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/forms@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 zod: 4.4.3 - '@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))': + '@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@angular/platform-server@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/platform-server@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/compiler': 22.1.3 - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/compiler': 22.1.6 + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 xhr2: 0.2.1 - '@angular/router@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/router@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/ssr@22.1.5(1a649b1a94668818bc7633d67afdfde2)': + '@angular/ssr@22.1.8(1ab24c3a529835890ef2451023f6d3fb)': dependencies: - '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/router': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/router': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@angular/platform-server': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/platform-server': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@babel/code-frame@7.29.7': dependencies: @@ -7937,7 +7935,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.1.2 + fast-uri: 4.1.4 '@fastify/cookie@11.1.2': dependencies: @@ -8000,9 +7998,9 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true '@img/sharp-darwin-x64@0.35.2': @@ -8010,9 +8008,9 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.3.1 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true '@img/sharp-freebsd-wasm32@0.35.2': @@ -8020,69 +8018,69 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true '@img/sharp-libvips-darwin-x64@1.3.1': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true '@img/sharp-libvips-linux-arm64@1.3.1': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true '@img/sharp-libvips-linux-arm@1.3.1': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true '@img/sharp-libvips-linux-s390x@1.3.1': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true '@img/sharp-libvips-linux-x64@1.3.1': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true '@img/sharp-linux-arm64@0.35.2': @@ -8090,9 +8088,9 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true '@img/sharp-linux-arm@0.35.2': @@ -8100,9 +8098,9 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.3.1 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true '@img/sharp-linux-ppc64@0.35.2': @@ -8110,9 +8108,9 @@ snapshots: '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true '@img/sharp-linux-riscv64@0.35.2': @@ -8120,9 +8118,9 @@ snapshots: '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true '@img/sharp-linux-s390x@0.35.2': @@ -8130,9 +8128,9 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true '@img/sharp-linux-x64@0.35.2': @@ -8140,9 +8138,9 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.3.1 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true '@img/sharp-linuxmusl-arm64@0.35.2': @@ -8150,9 +8148,9 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true '@img/sharp-linuxmusl-x64@0.35.2': @@ -8160,9 +8158,9 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true '@img/sharp-wasm32@0.35.2': @@ -8170,7 +8168,7 @@ snapshots: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true @@ -8180,27 +8178,27 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true '@img/sharp-win32-arm64@0.35.2': optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true '@img/sharp-win32-ia32@0.35.2': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true '@img/sharp-win32-x64@0.35.2': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@inquirer/ansi@2.0.7': {} @@ -8576,30 +8574,30 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.0': {} + '@next/env@16.3.5': {} - '@next/swc-darwin-arm64@16.3.0': + '@next/swc-darwin-arm64@16.3.5': optional: true - '@next/swc-darwin-x64@16.3.0': + '@next/swc-darwin-x64@16.3.5': optional: true - '@next/swc-linux-arm64-gnu@16.3.0': + '@next/swc-linux-arm64-gnu@16.3.5': optional: true - '@next/swc-linux-arm64-musl@16.3.0': + '@next/swc-linux-arm64-musl@16.3.5': optional: true - '@next/swc-linux-x64-gnu@16.3.0': + '@next/swc-linux-x64-gnu@16.3.5': optional: true - '@next/swc-linux-x64-musl@16.3.0': + '@next/swc-linux-x64-musl@16.3.5': optional: true - '@next/swc-win32-arm64-msvc@16.3.0': + '@next/swc-win32-arm64-msvc@16.3.5': optional: true - '@next/swc-win32-x64-msvc@16.3.0': + '@next/swc-win32-x64-msvc@16.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -9697,10 +9695,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true - '@schematics/angular@22.1.5(chokidar@5.0.0)': + '@schematics/angular@22.1.8(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.5(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.5(chokidar@5.0.0) + '@angular-devkit/core': 22.1.8(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) jsonc-parser: 3.3.1 typescript: 6.0.3 transitivePeerDependencies: @@ -9728,7 +9726,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -10199,14 +10197,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -10369,7 +10367,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -10638,14 +10636,6 @@ snapshots: optionalDependencies: srvx: 0.11.22 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - css-select@6.0.0: dependencies: boolbase: 1.0.0 @@ -10664,8 +10654,6 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} - css-what@7.0.0: {} cssesc@3.0.0: {} @@ -10986,7 +10974,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -11020,7 +11008,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.1.2 + fast-uri: 4.1.4 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -11040,9 +11028,9 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.5: {} + fast-uri@3.1.7: {} - fast-uri@4.1.2: {} + fast-uri@4.1.4: {} fast-wrap-ansi@0.2.2: dependencies: @@ -11052,7 +11040,7 @@ snapshots: fastify-plugin@6.0.0: {} - fastify@5.11.3: + fastify@5.12.4: dependencies: '@fastify/ajv-compiler': 4.0.6 '@fastify/error': 4.2.0 @@ -11913,10 +11901,10 @@ snapshots: negotiator@1.0.0: {} - next@16.3.0(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0): + next@16.3.5(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0): dependencies: - '@next/env': 16.3.0 - '@swc/helpers': 0.5.15 + '@next/env': 16.3.5 + '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.13 caniuse-lite: 1.0.30001809 postcss: 8.5.26 @@ -11924,17 +11912,17 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.0 - '@next/swc-darwin-x64': 16.3.0 - '@next/swc-linux-arm64-gnu': 16.3.0 - '@next/swc-linux-arm64-musl': 16.3.0 - '@next/swc-linux-x64-gnu': 16.3.0 - '@next/swc-linux-x64-musl': 16.3.0 - '@next/swc-win32-arm64-msvc': 16.3.0 - '@next/swc-win32-x64-msvc': 16.3.0 + '@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 '@playwright/test': 1.62.1 sass: 1.101.0 - sharp: 0.35.3(@types/node@25.9.5) + sharp: 0.35.4(@types/node@25.9.5) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -12079,7 +12067,7 @@ snapshots: nodemailer@10.0.8: {} - nodemailer@9.0.5: {} + nodemailer@9.1.1: {} nopt@8.1.0: dependencies: @@ -12825,7 +12813,7 @@ snapshots: dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - svgo: 4.0.2 + svgo: 4.1.0 postcss-unique-selectors@8.0.3(postcss@8.5.26): dependencies: @@ -12878,7 +12866,7 @@ snapshots: punycode.js@2.3.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -13280,37 +13268,37 @@ snapshots: '@img/sharp-win32-ia32': 0.35.2 '@img/sharp-win32-x64': 0.35.2 - sharp@0.35.3(@types/node@25.9.5): + sharp@0.35.4(@types/node@25.9.5): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 25.9.5 optional: true @@ -13517,12 +13505,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svgo@4.0.2: + svgo@4.1.0: dependencies: commander: 11.1.0 - css-select: 5.2.2 + css-select: 6.0.0 css-tree: 3.2.1 - css-what: 6.2.2 + css-what: 7.0.0 csso: 5.0.5 picocolors: 1.1.1 sax: 1.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec3efee..9784fab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,13 +52,18 @@ overrides: brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3' defu@<=6.1.4: '>=6.1.5' esbuild@>=0.27.3 <0.28.1: '>=0.28.1' + fast-uri@>=3.0.0 <3.1.6: ^3.1.6 + fast-uri@>=4.0.0 <4.1.3: ^4.1.3 hono@<4.12.12: '>=4.12.12' hono@>=4.0.0 <=4.12.11: '>=4.12.12' lodash@<=4.17.23: '>=4.18.0' lodash@>=4.0.0 <=4.17.23: '>=4.18.0' + next@>=16.0.0 <16.3.3: '>=16.3.3' next>postcss: ^8.5.15 - next>sharp: '>=0.35.0' + next>sharp: '>=0.35.4' nodemailer@<=9.0.0: '>=9.0.1' + qs@>=2.2.5 <6.16.0: ^6.16.0 + svgo@>=4.0.0 <4.1.0: ^4.1.0 vite@>=7.0.0 <=7.3.1: '>=7.3.2' vite@>=7.1.0 <=7.3.1: '>=7.3.2' vite@>=8.0.0 <=8.0.15: '>=8.0.16' diff --git a/server/package.json b/server/package.json index a83421f..f7bf9ad 100644 --- a/server/package.json +++ b/server/package.json @@ -31,10 +31,10 @@ "@magic-link-sso/config-core": "workspace:*", "dotenv": "^17.4.2", "ejs": "^5.0.2", - "fastify": "^5.11.3", + "fastify": "^5.12.4", "ioredis": "^5.11.1", "jose": "^6.2.8", - "nodemailer": "^9.0.5", + "nodemailer": "^9.1.1", "pino-pretty": "^13.1.3", "zod": "^4.4.3" },