From 23588563c7634ab7369e2b645aac6c44891a0bfc Mon Sep 17 00:00:00 2001 From: Ajibose Date: Sat, 4 Oct 2025 17:47:28 +0100 Subject: [PATCH 1/2] Implement user authentication endpoints --- backend/.gitignore | 5 + backend/package.json | 29 ++++-- backend/prisma/dev.db | Bin 0 -> 40960 bytes .../20251004160526_init/migration.sql | 11 ++ .../20251004161955_init_v2/migration.sql | 12 +++ .../migration.sql | 24 +++++ backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 34 +++++++ backend/src/controllers/authController.ts | 94 ++++++++++++++++++ backend/src/index.ts | 12 ++- backend/src/middleware/auth.ts | 34 +++++++ backend/src/prisma/client.ts | 7 ++ backend/src/routes/auth.ts | 19 ++++ backend/src/types/auth.ts | 5 + backend/src/utils/jwt.ts | 23 +++++ backend/src/utils/password.ts | 10 ++ 16 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/prisma/dev.db create mode 100644 backend/prisma/migrations/20251004160526_init/migration.sql create mode 100644 backend/prisma/migrations/20251004161955_init_v2/migration.sql create mode 100644 backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/controllers/authController.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/prisma/client.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/types/auth.ts create mode 100644 backend/src/utils/jwt.ts create mode 100644 backend/src/utils/password.ts diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..126419d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/src/generated/prisma diff --git a/backend/package.json b/backend/package.json index 5536262..e3727ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,28 +5,37 @@ "main": "src/index.ts", "scripts": { "start": "ts-node src/index.ts", - "dev": "ts-node --watch src/index.ts", + "dev": "ts-node src/index.ts", "build": "tsc", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", "test": "jest", "lint": "eslint src/**/*.ts" }, "dependencies": { "@chenaikit/core": "workspace:*", - "express": "^4.18.0", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.18.0", + "express-rate-limit": "^8.1.0", "helmet": "^7.0.0", - "dotenv": "^16.0.0" + "jsonwebtoken": "^9.0.2", + "zod": "^4.1.11" }, "devDependencies": { - "@types/express": "^4.17.0", + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.0", - "@types/node": "^20.0.0", - "typescript": "^5.0.0", - "ts-node": "^10.9.0", - "jest": "^29.0.0", + "@types/express": "^4.17.0", + "@types/express-rate-limit": "^6.0.2", "@types/jest": "^29.0.0", - "eslint": "^8.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0" + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0" } } diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..7774595466a5b8551bd7e3ce17c6869082a19a90 GIT binary patch literal 40960 zcmeI*%Wvbx831rnT3NOlhYJ|4fwn;jVHdWrD~nH&L{WIT67AJmk`+C=RxSh|Lz}C& zDA|tN90ViV-U{?kV2@3YMUeu%7Raf8K+$56OMwD8CdfI*4k_8Ihuv&9L6M{%Vd}vd z&iBo4NLr>Sx$<`29U$82^jpS&=84+`NfHlenji=Y&oMl&;F+A<-@^BolfUfCCW_cT z@2Ec0^F4ppSx>U{;)N-BP*Xp!ds~%kZa2)$$4d36rOTO<0IZVf&emoa@y6C@)n=9g8 z6trUX{y5oz-_u8icO0jy+96JMlJKN>=%IcN_vgAYtB;HUveiMH&SIBpuAp75PG_}U zwU)2anOeDwqv?KWrK%Q6lZMe**TvG;t~{=iIVe`DWfe=(@#+5RYNy9Z@0 zw^s~Yx8jp@6LeYI)o|R5R-xlH+y{*0k4u6+DB{%e8Xm?n&ZnHqRQ*M@lHUV5qgFC% zR*NM{(WQ-h_sDuMCSI?JUybS>>J3rbLUT`-XSRyD!mYN^Lih2S3F-@SH5g~(KaO*B zrnQ-Ve+}b&{W$*b$9NQqMK{#DDg2}F+!U}*AA9GHZ`Lug8s4xKpIvWeG$uZd+jhNU zWa|b#;JutK4}FtbSH02wUH_`$bqzKWVKt_s&V}>_Mjs!C zr)&7FUDjGWCR@XPAGHViz-=MCa;rNDXLP$w7dPY$P}kF~&afTy^zwIEtysZFS}nWS zAQMYuLQ5M@*qgS3#evh=#bsgqT4LFMJ+X@C@S8s+sP3Ep#CI@(00@8p2!H?xfB*=9 z00@8p2!Oz%!04@TtTtMWR0txnVj`Q9WRpuuoPd&oY@npV%ITybGICl_(iUUL29suO z*$@QB;5kV$1UZc?iQ{-dv3XpJWszdbvgI!o)0~nP1O?fMwM9kb6;2_a|7qoyA9D;R zvJ4}zX^t0V-LP#vIN78hZ^wPNorZk^&=JNm;b) zq~e$y&&j5e#%;ienKfjDEWu8T4r8ziZ>6QQA@U}R zyCoQ=XyCAszkTpBKUNV%9iPk?=qFrIc0O7qgOqqbCB?Q`0ed7x+fL(@I46l?@JYtz za1Sw$g8R;yCeBSfE*+#46g2!d_`}pQg8Gk`-s*T`2ne-id9zRHP=&}O*yN~ah6_y0o_MNq%OH!y(!2!H?xfB*=900@8p2!H?x zfWZHUz-cJDlNv?VBUbCka(Rzu{Ann4I+kD!STqm@_&R>}0XoCi@;A}8)oI<~%sVV| zN0knDkJ!>vNkKi=8)gfI#)-Ur^0;tXL|Qvv&-I?3o;!_lH=p9=68F$HGL4M?p%8ps zzz?|3PRQs^@-p!8Q0Y43z-{+jYKXnFqqZOTnz-e1RT52s>+ZGcZryg;DJ9h()){wS ze5wx5+Qwd`xo4|&tK(RfWTnn`3(Av8+_l#4{rCSv)L#ke6MO>`2!H?xfB*=900@8p z2!H?xfB*=9z&BUmt?&+c;|Yt`3#hk75pweRfo3sVkY)Ah_AvEss)U@A-4l};J~|Ux!%n5=K7L~4 zT;uTcNnYCXtamI;%4gfH&;#7U%A^G(tA{6pMyXIyPRiQ_wl~-p)nT?&+|R1}yxAxo zXpf}*y~jW6oz)+jM~C>U7?nN$^Zz9EIe~v*0s#;J0T2KI5C8!X009sH0T2KI5cu{B ztdZf{WcV&Q{hvNVQ{9SAJ%)!M00JNY0w4eaAOHd&00JNY0w4ea-ywnV_y6A~sNYie zzeC-Eu7CgtfB*=900@8p2!H?xfB*=9z-tQ}hoZzNvJ;7Xl}q+-{GLAl{{Z~v1M>Ml dHoyP7V{$gqYhBuZ<9GhyNNazfa3ngl`fp}p$h-gm literal 0 HcmV?d00001 diff --git a/backend/prisma/migrations/20251004160526_init/migration.sql b/backend/prisma/migrations/20251004160526_init/migration.sql new file mode 100644 index 0000000..6d2c1ba --- /dev/null +++ b/backend/prisma/migrations/20251004160526_init/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/backend/prisma/migrations/20251004161955_init_v2/migration.sql b/backend/prisma/migrations/20251004161955_init_v2/migration.sql new file mode 100644 index 0000000..44abc13 --- /dev/null +++ b/backend/prisma/migrations/20251004161955_init_v2/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token"); diff --git a/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql b/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql new file mode 100644 index 0000000..edd73ee --- /dev/null +++ b/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `token` on the `RefreshToken` table. All the data in the column will be lost. + - Added the required column `tokenHash` to the `RefreshToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_RefreshToken" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "id", "userId") SELECT "createdAt", "expiresAt", "id", "userId" FROM "RefreshToken"; +DROP TABLE "RefreshToken"; +ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken"; +CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..1413fea --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,34 @@ +generator client { + provider = "prisma-client-js" + output = "../src/generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +enum Role { + user + admin +} + +model User { + id String @id @default(cuid()) + email String @unique + password String + role Role @default(user) + createdAt DateTime @default(now()) + refreshTokens RefreshToken[] +} + + +model RefreshToken { + id Int @id @default(autoincrement()) + tokenHash String @unique + userId String + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + expiresAt DateTime +} + diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts new file mode 100644 index 0000000..1729454 --- /dev/null +++ b/backend/src/controllers/authController.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import { hashPassword, comparePassword } from '../utils/password'; +import { generateAccessToken } from '../utils/jwt'; +import { prisma } from '../prisma/client'; +import { UserPayload } from '../types/auth'; +import crypto from 'crypto'; +import { z } from 'zod'; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + role: z.enum(['user', 'admin']).optional(), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +export class AuthController { + async register(req: Request, res: Response) { + try { + const { email, password, role } = registerSchema.parse(req.body); + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) return res.status(400).json({ message: 'Email already registered' }); + + const hashed = await hashPassword(password); + const user = await prisma.user.create({ + data: { email, password: hashed, role: role || 'user' }, + }); + + res.status(201).json({ message: 'User registered', userId: user.id }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Registration failed' }); + } + } + + async login(req: Request, res: Response) { + try { + const { email, password } = loginSchema.parse(req.body); + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) return res.status(400).json({ message: 'Invalid credentials' }); + + const valid = await comparePassword(password, user.password); + if (!valid) return res.status(400).json({ message: 'Invalid credentials' }); + + const payload: UserPayload = { id: user.id, email: user.email, role: user.role }; + const accessToken = generateAccessToken(payload); + const refreshTokenRaw = crypto.randomBytes(64).toString('hex'); + const refreshTokenHash = await hashPassword(refreshTokenRaw); + + await prisma.refreshToken.create({ + data: { + tokenHash: refreshTokenHash, + userId: user.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + res.json({ accessToken, refreshToken: refreshTokenRaw }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Login failed' }); + } + } + + async refreshToken(req: Request, res: Response) { + try { + const { token } = req.body; + if (!token) return res.status(401).json({ message: 'Refresh token missing' }); + + const tokens = await prisma.refreshToken.findMany({ include: { user: true } }); + let matched = null; + for (const t of tokens) { + if (await comparePassword(token, t.tokenHash)) { + matched = t; + break; + } + } + + if (!matched) return res.status(403).json({ message: 'Invalid refresh token' }); + if (matched.expiresAt < new Date()) return res.status(403).json({ message: 'Refresh token expired' }); + + const payload: UserPayload = { + id: matched.user.id, + email: matched.user.email, + role: matched.user.role, + }; + const accessToken = generateAccessToken(payload); + res.json({ accessToken }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Token refresh failed' }); + } + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 36cd77d..15c2794 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,12 +5,13 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; +import authRoutes from './routes/auth'; // Load environment variables dotenv.config(); const app: express.Application = express(); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 5000; // Middleware app.use(helmet()); @@ -52,11 +53,14 @@ app.post('/api/fraud/detect', (req, res) => { }); }); + +app.use('/api/auth', authRoutes); + // Start server app.listen(PORT, () => { - console.log(`🚀 ChenAIKit Backend running on port ${PORT}`); - console.log(`📊 Health check: http://localhost:${PORT}/api/health`); - console.log(`📋 See .github/ISSUE_TEMPLATE/ for backend development tasks`); + console.log(`ChenAIKit Backend running on port ${PORT}`); + console.log(`Health check: http://localhost:${PORT}/api/health`); + console.log(`See .github/ISSUE_TEMPLATE/ for backend development tasks`); }); export default app; \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..bbd9bad --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyAccessToken } from '../utils/jwt'; +import { UserPayload } from '../types/auth'; + +declare global { + namespace Express { + interface Request { + user?: UserPayload; + } + } +} + +export const authenticate = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers['authorization']; + const token = authHeader?.split(' ')[1]; + + if (!token) return res.status(401).json({ message: 'Access token missing' }); + + try { + req.user = verifyAccessToken(token); + next(); + } catch { + return res.status(403).json({ message: 'Invalid or expired token' }); + } +}; + +export const authorize = (roles: Array<'user' | 'admin'>) => { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ message: 'Forbidden' }); + } + next(); + }; +}; diff --git a/backend/src/prisma/client.ts b/backend/src/prisma/client.ts new file mode 100644 index 0000000..96aa183 --- /dev/null +++ b/backend/src/prisma/client.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '../generated/prisma'; + +export const prisma = new PrismaClient(); + +process.on('beforeExit', async () => { + await prisma.$disconnect(); +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..23afbe3 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { AuthController } from '../controllers/authController'; + +const router = Router(); +const controller = new AuthController(); + + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { message: 'Too many requests, try again later.' }, +}); + +router.post('/register', authLimiter, controller.register.bind(controller)); +router.post('/login', authLimiter, controller.login.bind(controller)); +router.post('/refresh', authLimiter, controller.refreshToken.bind(controller)); + +export default router; diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..f3e349a --- /dev/null +++ b/backend/src/types/auth.ts @@ -0,0 +1,5 @@ +export interface UserPayload { + id: string; + email: string; + role: 'user' | 'admin'; +} diff --git a/backend/src/utils/jwt.ts b/backend/src/utils/jwt.ts new file mode 100644 index 0000000..10dcbba --- /dev/null +++ b/backend/src/utils/jwt.ts @@ -0,0 +1,23 @@ +import jwt from 'jsonwebtoken'; +import { UserPayload } from '../types/auth'; + +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret'; +const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret'; +const ACCESS_TOKEN_EXP = process.env.ACCESS_TOKEN_EXPIRATION || '15m'; +const REFRESH_TOKEN_EXP = process.env.REFRESH_TOKEN_EXPIRATION || '7d'; + +export const generateAccessToken = (payload: UserPayload) => { + return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXP }); +}; + +export const generateRefreshToken = (payload: UserPayload) => { + return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXP }); +}; + +export const verifyAccessToken = (token: string) => { + return jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload; +}; + +export const verifyRefreshToken = (token: string) => { + return jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; +}; diff --git a/backend/src/utils/password.ts b/backend/src/utils/password.ts new file mode 100644 index 0000000..34449b3 --- /dev/null +++ b/backend/src/utils/password.ts @@ -0,0 +1,10 @@ +import bcrypt from 'bcrypt'; +const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS) || 12; + +export const hashPassword = async (password: string): Promise => { + return bcrypt.hash(password, SALT_ROUNDS); +}; + +export const comparePassword = async (password: string, hash: string): Promise => { + return bcrypt.compare(password, hash); +}; From 2750f63380b647434063553e06cce082f8ba9167 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Sat, 4 Oct 2025 17:50:40 +0100 Subject: [PATCH 2/2] Remove local dev.db from version control --- backend/prisma/dev.db | Bin 40960 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 backend/prisma/dev.db diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db deleted file mode 100644 index 7774595466a5b8551bd7e3ce17c6869082a19a90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40960 zcmeI*%Wvbx831rnT3NOlhYJ|4fwn;jVHdWrD~nH&L{WIT67AJmk`+C=RxSh|Lz}C& zDA|tN90ViV-U{?kV2@3YMUeu%7Raf8K+$56OMwD8CdfI*4k_8Ihuv&9L6M{%Vd}vd z&iBo4NLr>Sx$<`29U$82^jpS&=84+`NfHlenji=Y&oMl&;F+A<-@^BolfUfCCW_cT z@2Ec0^F4ppSx>U{;)N-BP*Xp!ds~%kZa2)$$4d36rOTO<0IZVf&emoa@y6C@)n=9g8 z6trUX{y5oz-_u8icO0jy+96JMlJKN>=%IcN_vgAYtB;HUveiMH&SIBpuAp75PG_}U zwU)2anOeDwqv?KWrK%Q6lZMe**TvG;t~{=iIVe`DWfe=(@#+5RYNy9Z@0 zw^s~Yx8jp@6LeYI)o|R5R-xlH+y{*0k4u6+DB{%e8Xm?n&ZnHqRQ*M@lHUV5qgFC% zR*NM{(WQ-h_sDuMCSI?JUybS>>J3rbLUT`-XSRyD!mYN^Lih2S3F-@SH5g~(KaO*B zrnQ-Ve+}b&{W$*b$9NQqMK{#DDg2}F+!U}*AA9GHZ`Lug8s4xKpIvWeG$uZd+jhNU zWa|b#;JutK4}FtbSH02wUH_`$bqzKWVKt_s&V}>_Mjs!C zr)&7FUDjGWCR@XPAGHViz-=MCa;rNDXLP$w7dPY$P}kF~&afTy^zwIEtysZFS}nWS zAQMYuLQ5M@*qgS3#evh=#bsgqT4LFMJ+X@C@S8s+sP3Ep#CI@(00@8p2!H?xfB*=9 z00@8p2!Oz%!04@TtTtMWR0txnVj`Q9WRpuuoPd&oY@npV%ITybGICl_(iUUL29suO z*$@QB;5kV$1UZc?iQ{-dv3XpJWszdbvgI!o)0~nP1O?fMwM9kb6;2_a|7qoyA9D;R zvJ4}zX^t0V-LP#vIN78hZ^wPNorZk^&=JNm;b) zq~e$y&&j5e#%;ienKfjDEWu8T4r8ziZ>6QQA@U}R zyCoQ=XyCAszkTpBKUNV%9iPk?=qFrIc0O7qgOqqbCB?Q`0ed7x+fL(@I46l?@JYtz za1Sw$g8R;yCeBSfE*+#46g2!d_`}pQg8Gk`-s*T`2ne-id9zRHP=&}O*yN~ah6_y0o_MNq%OH!y(!2!H?xfB*=900@8p2!H?x zfWZHUz-cJDlNv?VBUbCka(Rzu{Ann4I+kD!STqm@_&R>}0XoCi@;A}8)oI<~%sVV| zN0knDkJ!>vNkKi=8)gfI#)-Ur^0;tXL|Qvv&-I?3o;!_lH=p9=68F$HGL4M?p%8ps zzz?|3PRQs^@-p!8Q0Y43z-{+jYKXnFqqZOTnz-e1RT52s>+ZGcZryg;DJ9h()){wS ze5wx5+Qwd`xo4|&tK(RfWTnn`3(Av8+_l#4{rCSv)L#ke6MO>`2!H?xfB*=900@8p z2!H?xfB*=9z&BUmt?&+c;|Yt`3#hk75pweRfo3sVkY)Ah_AvEss)U@A-4l};J~|Ux!%n5=K7L~4 zT;uTcNnYCXtamI;%4gfH&;#7U%A^G(tA{6pMyXIyPRiQ_wl~-p)nT?&+|R1}yxAxo zXpf}*y~jW6oz)+jM~C>U7?nN$^Zz9EIe~v*0s#;J0T2KI5C8!X009sH0T2KI5cu{B ztdZf{WcV&Q{hvNVQ{9SAJ%)!M00JNY0w4eaAOHd&00JNY0w4ea-ywnV_y6A~sNYie zzeC-Eu7CgtfB*=900@8p2!H?xfB*=9z-tQ}hoZzNvJ;7Xl}q+-{GLAl{{Z~v1M>Ml dHoyP7V{$gqYhBuZ<9GhyNNazfa3ngl`fp}p$h-gm