From a7a5d86e76358ca99c3d9a5a3f5cae24e94bba4c Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 21:20:12 -0700 Subject: [PATCH 01/16] chore: install workos as dependency --- bun.lockb | Bin 396079 -> 415774 bytes package.json | 1 + surface.app.ctx.ts | 22 ++++++++++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/bun.lockb b/bun.lockb index a1acdf47cde23d3367cc2ddbca688f7ffbe34286..4a1a257df49be51d749870e20e0dfc8d8ce0c337 100755 GIT binary patch delta 90582 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 diff --git a/package.json b/package.json index 5ced963..1592687 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@tanstack/react-router": "^1.115.0", "@tanstack/react-start": "^1.115.1", "@tanstack/router-plugin": "^1.115.0", + "@workos-inc/node": "^7.69.1", "bunyan": "^1.8.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/surface.app.ctx.ts b/surface.app.ctx.ts index 6dd087c..a729a55 100644 --- a/surface.app.ctx.ts +++ b/surface.app.ctx.ts @@ -5,7 +5,8 @@ import * as jwt from "hono/jwt"; import { getCookie, setCookie } from "hono/cookie"; import { CookieOptions } from "hono/utils/cookie"; import { logger } from "./logger/logger"; - +import { WorkOS } from "@workos-inc/node"; +import { env } from "./env"; import { memberServiceClient } from "./service/members/member.service.client"; /** @@ -26,12 +27,24 @@ export function cookies(c: SurfaceContext) { return { get, set }; } +// works as singleton +const workos: WorkOS | undefined = undefined; +export const getWorkOS = () => { + if (workos !== undefined) { + return workos; + } + return new WorkOS(env("WORKOS_API_KEY")); +}; + export type Dependencies = { // utils logger: typeof logger; cookies: ReturnType; jwt: typeof jwt; + // auth via workos + workos: WorkOS; + // specifically for testing, allows overwriting the rpc client rpcClientMock?: typeof hc; @@ -52,10 +65,15 @@ export const applyContext = (injections: Partial) => c.set("logger", injections.logger ?? logger); c.set("cookies", injections.cookies ?? cookies(c)); c.set("jwt", jwt); + c.set("workos", injections.workos ?? getWorkOS()); + + // For demo only, remove as this gets cooler. c.set( "memberServiceClient", - injections.memberServiceClient ?? memberServiceClient + injections.memberServiceClient ?? memberServiceClient, ); + + // for testing only c.set("rpcClientMock", injections.rpcClientMock); await next(); }); From 604841c00be4d8d30770c6a442125dba4c7f8540 Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 21:31:17 -0700 Subject: [PATCH 02/16] feat: decent start to workos install --- handlers/error.handler.ts | 24 +- service/auth/README.md | 157 ++++++++++++ service/auth/auth.endpoint.test.ts | 297 ++++++++++++++++++++++ service/auth/auth.endpoints.ts | 220 ++++++++++++++-- service/auth/auth.helpers.test.ts | 203 +++++++++++++++ service/auth/auth.helpers.ts | 107 ++++++-- service/members/member.service.client.ts | 10 +- service/members/members.endpoints.test.ts | 22 +- surface.app.ctx.ts | 2 +- 9 files changed, 981 insertions(+), 61 deletions(-) create mode 100644 service/auth/README.md create mode 100644 service/auth/auth.endpoint.test.ts create mode 100644 service/auth/auth.helpers.test.ts diff --git a/handlers/error.handler.ts b/handlers/error.handler.ts index d436510..96e4d32 100644 --- a/handlers/error.handler.ts +++ b/handlers/error.handler.ts @@ -2,6 +2,15 @@ import { SurfaceContext } from "../surface.app.ctx"; export class PingError extends Error {} +export class AuthError extends Error { + constructor( + message: string, + public readonly code: string = "AUTH_FAILED", + ) { + super(message); + } +} + export const errorHandler = (err: unknown, c: SurfaceContext): Response => { const { error } = c.var.logger; const { text, json } = c; @@ -10,7 +19,20 @@ export const errorHandler = (err: unknown, c: SurfaceContext): Response => { error(`some kind of error happened: ${err}`); return text("oh noes", 418); } + + if (err instanceof AuthError) { + error(`Authentication error: ${err.message}`, err); + return json( + { + error: "Authentication failed", + code: err.code, + message: err.message, + }, + 401, + ); + } + // unknown error error(`unhandled service error type: ${err}`, err); - return json({}, 500); + return json({ error: "Internal server error" }, 500); }; diff --git a/service/auth/README.md b/service/auth/README.md new file mode 100644 index 0000000..30d792c --- /dev/null +++ b/service/auth/README.md @@ -0,0 +1,157 @@ +# Authentication Service + +This authentication service has been updated to use WorkOS for OAuth authentication, replacing the previous fake user implementation. + +## Overview + +The authentication flow uses WorkOS AuthKit to handle user authentication via OAuth providers. Users are redirected to WorkOS for authentication, and upon successful authentication, a JWT session token is created and stored as an HTTP-only cookie. + +## Flow + +1. **Login (`/auth/login`)**: Redirects user to WorkOS authorization URL +2. **Callback (`/auth/callback`)**: Handles OAuth callback, exchanges code for user info, creates session +3. **Logout (`/auth/logout`)**: Clears session cookies and redirects + +## Files + +- `auth.endpoints.ts` - Main authentication endpoints +- `auth.helpers.ts` - Helper functions for session management and authentication middleware +- `auth.endpoint.test.ts` - Comprehensive tests for auth endpoints +- `auth.helpers.test.ts` - Tests for auth helper functions + +## Environment Variables + +Required environment variables for WorkOS integration: + +```bash +WORKOS_API_KEY=sk_test_... +WORKOS_CLIENT_ID=client_... +WORKOS_REDIRECT_URI=http://localhost:4000/api/auth/callback +JWT_SECRET=your_jwt_secret_here +``` + +## Usage + +### Authentication Endpoints + +#### `GET /auth/login` +Initiates WorkOS OAuth flow. Optionally accepts a `return` query parameter to specify redirect destination after successful authentication. + +**Query Parameters:** +- `return` (optional): URL to redirect to after successful authentication +- `test` (optional): When set to "true", returns JSON with authorization URL instead of redirecting + +**Response:** +- `302` - Redirects to WorkOS authorization URL +- `200` - Returns JSON with authorization URL (when `test=true`) + +#### `GET /auth/callback` +Handles OAuth callback from WorkOS. Exchanges authorization code for user information and creates session. + +**Query Parameters:** +- `code` (required): Authorization code from WorkOS +- `state` (optional): Original redirect destination + +**Response:** +- `302` - Redirects to original destination or home page +- `401` - Authentication failed + +#### `GET /auth/logout` +Clears user session and redirects. + +**Query Parameters:** +- `return` (optional): URL to redirect to after logout +- `test` (optional): When set to "true", returns JSON confirmation instead of redirecting + +**Response:** +- `302` - Redirects to specified location or home page +- `200` - Returns JSON confirmation (when `test=true`) + +### Helper Functions + +#### `getUser(c: SurfaceContext): Promise` +Extracts user information from the current session JWT token. + +#### `hasSession(c: SurfaceContext): Promise` +Checks if the current request has a valid session (via cookie or Authorization header). + +#### `requireAuth(c: SurfaceContext, next: Function): Promise` +Middleware to require authentication for protected routes. Returns 401 if no valid session exists. + +### Usage Examples + +#### Protecting a Route +```typescript +export const protectedRoute = new Hono() + .use(requireAuth) + .get("/protected", async (c) => { + const user = await getUser(c); + return c.json({ message: `Hello ${user?.name}` }); + }); +``` + +#### Manual Session Check +```typescript +export const optionalAuth = new Hono() + .get("/optional", async (c) => { + const session = await hasSession(c); + if (session) { + const user = await getUser(c); + return c.json({ message: `Welcome back ${user?.name}` }); + } else { + return c.json({ message: "Hello anonymous user" }); + } + }); +``` + +## User Type + +```typescript +export type User = { + id: string; + name: string; + email: string; + roles: string[]; + profilePicture?: string; +}; +``` + +## JWT Payload + +Session tokens contain the following claims: +- `sub`: User ID +- `email`: User email address +- `name`: User display name +- `iat`: Issued at timestamp +- `exp`: Expiration timestamp (24 hours from issue) + +## Testing + +The authentication system includes comprehensive tests covering: +- All authentication endpoints (login, callback, logout) +- Error handling for various failure scenarios +- Helper functions for session management +- Mock WorkOS integration + +Run tests with: +```bash +bun test ./service/auth/ +``` + +## Security Considerations + +- JWT tokens are stored as HTTP-only cookies to prevent XSS attacks +- Cookies use `sameSite: "lax"` and `secure` flag in production +- Session tokens expire after 24 hours +- All authentication errors are properly handled and logged +- WorkOS handles the actual OAuth flow, reducing attack surface + +## Migration from Fake User + +The following changes were made during the WorkOS migration: +1. Removed `fakeUser` from auth helpers +2. Updated authentication flow to use WorkOS OAuth +3. Added proper JWT session management +4. Updated error handling with `AuthError` class +5. Fixed dependency injection to support JWT mocking in tests +6. Updated member service client and tests to work with real authentication \ No newline at end of file diff --git a/service/auth/auth.endpoint.test.ts b/service/auth/auth.endpoint.test.ts new file mode 100644 index 0000000..1f37669 --- /dev/null +++ b/service/auth/auth.endpoint.test.ts @@ -0,0 +1,297 @@ +import { expect, it, mock, describe, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { sessions } from "./auth.endpoints"; +import { applyContext, SurfaceEnv } from "../../surface.app.ctx"; +import { logger } from "../../logger/logger"; +import { WorkOS } from "@workos-inc/node"; +import { errorHandler } from "../../handlers/error.handler"; + +describe("auth endpoints", () => { + // Mock logger + const mockLogger = { + ...logger, + info: mock().mockImplementation(() => null), + error: mock().mockImplementation(() => null), + } as unknown as typeof logger; + + // Mock WorkOS + const mockWorkOS = { + userManagement: { + getAuthorizationUrl: mock(), + authenticateWithCode: mock(), + }, + } as unknown as WorkOS; + + // Mock cookies + const mockCookies = { + get: mock(), + set: mock(), + }; + + // Mock JWT + const mockJwt = { + sign: mock(), + verify: mock(), + }; + + let testApp: Hono; + + beforeEach(() => { + // Reset all mocks without restoring them + mockLogger.info.mockClear(); + mockLogger.error.mockClear(); + mockCookies.get.mockClear(); + mockCookies.set.mockClear(); + mockWorkOS.userManagement.getAuthorizationUrl.mockClear(); + mockWorkOS.userManagement.authenticateWithCode.mockClear(); + mockJwt.sign.mockClear(); + mockJwt.verify.mockClear(); + + testApp = new Hono() + .use( + applyContext({ + logger: mockLogger, + cookies: mockCookies, + workos: mockWorkOS, + jwt: mockJwt, + }), + ) + .route("/", sessions) + .onError(errorHandler); + }); + + describe("GET /login", () => { + it("should redirect to WorkOS authorization URL", async () => { + const mockAuthUrl = + "https://api.workos.com/sso/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:4000/api/auth/callback"; + + mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( + mockAuthUrl, + ); + + const response = await testApp.request("/login"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe(mockAuthUrl); + expect( + mockWorkOS.userManagement.getAuthorizationUrl, + ).toHaveBeenCalledWith({ + provider: "authkit", + redirectUri: "http://localhost:4000/api/auth/callback", + clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + state: "/", + }); + expect(mockLogger.info).toHaveBeenCalledWith( + "Redirecting to WorkOS authorization URL", + ); + }); + + it("should use return query parameter as state", async () => { + const mockAuthUrl = "https://api.workos.com/sso/authorize"; + mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( + mockAuthUrl, + ); + + await testApp.request("/login?return=/dashboard"); + + expect( + mockWorkOS.userManagement.getAuthorizationUrl, + ).toHaveBeenCalledWith({ + provider: "authkit", + redirectUri: "http://localhost:4000/api/auth/callback", + clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + state: "/dashboard", + }); + }); + + it("should return JSON for test requests", async () => { + const mockAuthUrl = "https://api.workos.com/sso/authorize"; + mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( + mockAuthUrl, + ); + + const response = await testApp.request("/login?test=true"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ redirectUrl: mockAuthUrl }); + }); + + it("should handle WorkOS errors", async () => { + mockWorkOS.userManagement.getAuthorizationUrl.mockImplementation(() => { + throw new Error("WorkOS error"); + }); + + const response = await testApp.request("/login"); + + expect(response.status).toBe(401); + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to create WorkOS authorization URL", + expect.any(Error), + ); + expect(mockLogger.error).toHaveBeenCalledWith( + "Authentication error: Authentication initialization failed", + expect.any(Error), + ); + }); + }); + + describe("GET /callback", () => { + const mockUser = { + id: "user_123", + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + profilePictureUrl: "https://example.com/avatar.jpg", + }; + + beforeEach(() => { + mockWorkOS.userManagement.authenticateWithCode.mockResolvedValue({ + user: mockUser, + }); + mockJwt.sign.mockResolvedValue("mock.jwt.token"); + }); + + it("should authenticate user and create session", async () => { + const response = await testApp.request( + "/callback?code=auth_code_123&state=/dashboard", + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/dashboard"); + + expect( + mockWorkOS.userManagement.authenticateWithCode, + ).toHaveBeenCalledWith({ + code: "auth_code_123", + clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + }); + + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "user_123", { + path: "/", + httpOnly: true, + secure: false, // development mode + sameSite: "lax", + }); + + expect(mockJwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: "user_123", + email: "john.doe@example.com", + name: "John Doe", + }), + "sup4h.secr1t.jwt.🔑", + ); + + expect(mockCookies.set).toHaveBeenCalledWith( + "session", + "mock.jwt.token", + { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }, + ); + + expect(mockLogger.info).toHaveBeenCalledWith( + "User john.doe@example.com authenticated successfully", + ); + }); + + it("should redirect to home page when no state provided", async () => { + const response = await testApp.request("/callback?code=auth_code_123"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("should handle missing authorization code", async () => { + const response = await testApp.request("/callback"); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Authentication failed"); + expect(body.code).toBe("MISSING_CODE"); + expect(mockLogger.error).toHaveBeenCalledWith( + "No authorization code provided in callback", + ); + }); + + it("should handle WorkOS authentication failure", async () => { + mockWorkOS.userManagement.authenticateWithCode.mockRejectedValue( + new Error("Invalid code"), + ); + + const response = await testApp.request("/callback?code=invalid_code"); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Authentication failed"); + expect(body.code).toBe("AUTH_FAILED"); + expect(mockLogger.error).toHaveBeenCalledWith( + "WorkOS authentication failed", + expect.any(Error), + ); + }); + + it("should handle user with only email (no first/last name)", async () => { + const userWithoutName = { + ...mockUser, + firstName: null, + lastName: null, + }; + + mockWorkOS.userManagement.authenticateWithCode.mockResolvedValue({ + user: userWithoutName, + }); + + const response = await testApp.request("/callback?code=auth_code_123"); + + expect(mockJwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ + name: "john.doe@example.com", // Should fallback to email + }), + expect.any(String), + ); + }); + }); + + describe("GET /logout", () => { + it("should clear cookies and redirect", async () => { + const response = await testApp.request("/logout?return=/login"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/login"); + + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockCookies.set).toHaveBeenCalledWith("session", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockLogger.info).toHaveBeenCalledWith("User logging out"); + }); + + it("should redirect to home page by default", async () => { + const response = await testApp.request("/logout"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("should return JSON for test requests", async () => { + const response = await testApp.request("/logout?test=true"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ message: "Logged out successfully" }); + }); + }); +}); diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index a709b7b..c5719a9 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -1,38 +1,204 @@ import { Hono } from "hono"; import { SurfaceEnv } from "../../surface.app.ctx"; -import { fakeUser } from "./auth.helpers"; +import { env } from "../../env"; +import { describeRoute } from "hono-openapi"; +import { resolver } from "hono-openapi/zod"; +import { z } from "zod"; +import { AuthError } from "../../handlers/error.handler"; export type User = { id: string; name: string; email: string; roles: string[]; - profilePicture: string; + profilePicture?: string; }; +// Zod schemas for OpenAPI documentation +const loginRedirectResponse = z.object({ + redirectUrl: z.string().url(), +}); + +const logoutResponse = z.object({ + message: z.string(), +}); + export const sessions = new Hono() - .get("/login", async (c) => { - const { cookies, jwt } = c.var; - cookies.set("user_id", fakeUser.id, { path: "/", httpOnly: true }); - - // generate a jwt for session calls - const now = Math.floor(Date.now() / 1000); - const payload = { - sub: fakeUser.id, - iat: now, - exp: now + 60 * 60 * 24, - }; - - // this will throw if JWT_SECRET is empty - const token = await jwt.sign(payload, process.env.JWT_SECRET ?? ""); - cookies.set("session", token, { path: "/", httpOnly: true }); - - // redirect to location (if passed) - const redirectTo = c.req.query("return") ?? "/"; - return c.redirect(redirectTo); - }) - .get("/logout", async (c) => { - c.var.cookies.set("user_id", "", { path: "/", httpOnly: true }); - const redirectTo = c.req.query("return") ?? "/"; - return c.redirect(redirectTo); - }); + .get( + "/login", + describeRoute({ + description: "Initiate WorkOS OAuth login flow", + responses: { + 302: { + description: "Redirect to WorkOS authorization URL", + }, + 200: { + description: "Authorization URL (for testing)", + content: { + "application/json": { schema: resolver(loginRedirectResponse) }, + }, + }, + }, + }), + async (c) => { + const { workos, logger } = c.var; + + try { + const redirectTo = c.req.query("return") ?? "/"; + + // Create authorization URL with WorkOS + const authorizationUrl = workos.userManagement.getAuthorizationUrl({ + provider: "authkit", + redirectUri: env("WORKOS_REDIRECT_URI"), + clientId: env("WORKOS_CLIENT_ID"), + state: redirectTo, // Pass the return URL in state + }); + + logger.info("Redirecting to WorkOS authorization URL"); + + // For testing purposes, if test=true query param is present, return JSON instead of redirecting + if (c.req.query("test") === "true") { + return c.json({ redirectUrl: authorizationUrl }); + } + + return c.redirect(authorizationUrl); + } catch (error) { + logger.error("Failed to create WorkOS authorization URL", error); + throw new AuthError( + "Authentication initialization failed", + "INIT_FAILED", + ); + } + }, + ) + .get( + "/callback", + describeRoute({ + description: "Handle WorkOS OAuth callback and create user session", + responses: { + 302: { + description: "Redirect to original destination or home page", + }, + 400: { + description: "Missing or invalid authorization code", + }, + }, + }), + async (c) => { + const { workos, cookies, jwt, logger } = c.var; + + try { + const code = c.req.query("code"); + const state = c.req.query("state"); + + if (!code) { + logger.error("No authorization code provided in callback"); + throw new AuthError("Missing authorization code", "MISSING_CODE"); + } + + logger.info("Processing WorkOS callback with authorization code"); + + // Exchange code for user information + const { user } = await workos.userManagement.authenticateWithCode({ + code, + clientId: env("WORKOS_CLIENT_ID"), + }); + + // Create our user object from WorkOS user + const surfaceUser: User = { + id: user.id, + name: + `${user.firstName || ""} ${user.lastName || ""}`.trim() || + user.email, + email: user.email, + roles: [], // You might want to fetch roles from WorkOS organizations + profilePicture: user.profilePictureUrl ?? undefined, + }; + + // Store user ID in cookie + cookies.set("user_id", surfaceUser.id, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Generate JWT session token + const now = Math.floor(Date.now() / 1000); + const payload = { + sub: surfaceUser.id, + email: surfaceUser.email, + name: surfaceUser.name, + iat: now, + exp: now + 60 * 60 * 24, // 24 hours + }; + + const token = await jwt.sign(payload, env("JWT_SECRET")); + cookies.set("session", token, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + logger.info(`User ${surfaceUser.email} authenticated successfully`); + + // Redirect to original destination or home page + const redirectTo = state || "/"; + return c.redirect(redirectTo); + } catch (error) { + logger.error("WorkOS authentication failed", error); + + if (error instanceof AuthError) { + throw error; // Re-throw AuthError to be handled by error handler + } + + // Wrap other errors as AuthError + throw new AuthError("Authentication failed", "AUTH_FAILED"); + } + }, + ) + .get( + "/logout", + describeRoute({ + description: "Clear user session and logout", + responses: { + 302: { + description: "Redirect to specified location or home page", + }, + 200: { + description: "Logout confirmation (for testing)", + content: { + "application/json": { schema: resolver(logoutResponse) }, + }, + }, + }, + }), + async (c) => { + const { cookies, logger } = c.var; + + logger.info("User logging out"); + + // Clear all auth cookies + cookies.set("user_id", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + cookies.set("session", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + const redirectTo = c.req.query("return") ?? "/"; + + // For testing purposes, if test=true query param is present, return JSON + if (c.req.query("test") === "true") { + return c.json({ message: "Logged out successfully" }); + } + + return c.redirect(redirectTo); + }, + ); diff --git a/service/auth/auth.helpers.test.ts b/service/auth/auth.helpers.test.ts new file mode 100644 index 0000000..889a8ae --- /dev/null +++ b/service/auth/auth.helpers.test.ts @@ -0,0 +1,203 @@ +import { expect, it, mock, describe, beforeEach } from "bun:test"; +import { getUser, hasSession, requireAuth } from "./auth.helpers"; +import { SurfaceContext } from "../../surface.app.ctx"; + +describe("auth helpers", () => { + const mockLogger = { + error: mock(), + info: mock(), + }; + + const mockCookies = { + get: mock(), + set: mock(), + }; + + const mockJwt = { + verify: mock(), + sign: mock(), + }; + + const createMockContext = (overrides = {}): SurfaceContext => { + return { + var: { + logger: mockLogger, + cookies: mockCookies, + jwt: mockJwt, + ...overrides, + }, + req: { + header: mock(), + }, + json: mock(), + set: mock(), + } as unknown as SurfaceContext; + }; + + beforeEach(() => { + // Reset all mocks without restoring them + mockLogger.error.mockClear(); + mockLogger.info.mockClear(); + mockCookies.get.mockClear(); + mockCookies.set.mockClear(); + mockJwt.verify.mockClear(); + mockJwt.sign.mockClear(); + }); + + describe("getUser", () => { + it("should return user from valid session token", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + name: "John Doe", + iat: 1234567890, + exp: 1234567990, + }; + + mockCookies.get.mockReturnValue("valid.jwt.token"); + mockJwt.verify.mockResolvedValue(mockPayload); + + const context = createMockContext(); + const user = await getUser(context); + + expect(user).toEqual({ + id: "user_123", + name: "John Doe", + email: "john@example.com", + roles: [], + profilePicture: undefined, + }); + + expect(mockCookies.get).toHaveBeenCalledWith("session"); + expect(mockJwt.verify).toHaveBeenCalledWith( + "valid.jwt.token", + "sup4h.secr1t.jwt.🔑", + ); + }); + + it("should return undefined when no session token", async () => { + mockCookies.get.mockReturnValue(undefined); + + const context = createMockContext(); + const user = await getUser(context); + + expect(user).toBeUndefined(); + expect(mockJwt.verify).not.toHaveBeenCalled(); + }); + + it("should return undefined when JWT verification fails", async () => { + mockCookies.get.mockReturnValue("invalid.jwt.token"); + mockJwt.verify.mockRejectedValue(new Error("Invalid token")); + + const context = createMockContext(); + const user = await getUser(context); + + expect(user).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to get user from session", + expect.any(Error), + ); + }); + }); + + describe("hasSession", () => { + it("should return JWT payload for valid session cookie", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + name: "John Doe", + iat: 1234567890, + exp: 1234567990, + }; + + mockCookies.get.mockReturnValue("valid.jwt.token"); + mockJwt.verify.mockResolvedValue(mockPayload); + + const context = createMockContext(); + const session = await hasSession(context); + + expect(session).toEqual(mockPayload); + }); + + it("should return JWT payload for valid Bearer token", async () => { + const mockPayload = { + sub: "user_123", + email: "john@example.com", + name: "John Doe", + iat: 1234567890, + exp: 1234567990, + }; + + mockCookies.get.mockReturnValue(undefined); + + const context = createMockContext(); + context.req.header = mock().mockReturnValue("Bearer valid.jwt.token"); + + mockJwt.verify.mockResolvedValue(mockPayload); + + const session = await hasSession(context); + + expect(session).toEqual(mockPayload); + expect(context.req.header).toHaveBeenCalledWith("Authorization"); + }); + + it("should return false when no token available", async () => { + mockCookies.get.mockReturnValue(undefined); + + const context = createMockContext(); + context.req.header = mock().mockReturnValue(undefined); + + const session = await hasSession(context); + + expect(session).toBe(false); + }); + + it("should return false when JWT verification fails", async () => { + mockCookies.get.mockReturnValue("invalid.token"); + mockJwt.verify.mockRejectedValue(new Error("Invalid token")); + + const context = createMockContext(); + const session = await hasSession(context); + + expect(session).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "No valid session was found", + expect.any(Error), + ); + }); + }); + + describe("requireAuth", () => { + it("should call next() for authenticated requests", async () => { + const mockPayload = { sub: "user_123" }; + mockCookies.get.mockReturnValue("valid.token"); + mockJwt.verify.mockResolvedValue(mockPayload); + + const context = createMockContext(); + const next = mock(); + + await requireAuth(context, next); + + expect(next).toHaveBeenCalled(); + expect((context as any).session).toEqual(mockPayload); + }); + + it("should return 401 for unauthenticated requests", async () => { + mockCookies.get.mockReturnValue(undefined); + + const context = createMockContext(); + context.req.header = mock().mockReturnValue(undefined); + context.json = mock().mockReturnValue(new Response()); + + const next = mock(); + + await requireAuth(context, next); + + expect(next).not.toHaveBeenCalled(); + expect(context.json).toHaveBeenCalledWith( + { error: "Authentication required" }, + 401, + ); + }); + }); +}); diff --git a/service/auth/auth.helpers.ts b/service/auth/auth.helpers.ts index cf2c9e5..099d4d2 100644 --- a/service/auth/auth.helpers.ts +++ b/service/auth/auth.helpers.ts @@ -1,40 +1,93 @@ import { JWTPayload } from "hono/utils/jwt/types"; import { SurfaceContext } from "../../surface.app.ctx"; import { User } from "./auth.endpoints"; +import { env } from "../../env"; -export const fakeUser = { - id: "123", - name: "Alice", - email: "alice@domain.com", - roles: ["admin"], - profilePicture: - "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", -}; - +/** + * Get the current user from session or JWT token + * @param c Surface context + * @returns User object or undefined if not authenticated + */ export async function getUser(c: SurfaceContext): Promise { - const userId = c.var.cookies.get("user_id"); - if (!userId || userId === "") return undefined; - return fakeUser; + try { + const sessionToken = c.var.cookies.get("session"); + + if (!sessionToken) { + return undefined; + } + + const decoded = (await c.var.jwt.verify( + sessionToken, + env("JWT_SECRET"), + )) as JWTPayload & { + email: string; + name: string; + }; + + // Construct user from JWT payload + const user: User = { + id: decoded.sub as string, + name: decoded.name, + email: decoded.email, + roles: [], // TODO: Add role management + profilePicture: undefined, // Could be added to JWT payload if needed + }; + + return user; + } catch (error) { + c.var.logger.error("Failed to get user from session", error); + return undefined; + } } +/** + * Check if the current request has a valid session + * @param c Surface context + * @returns JWT payload if valid session exists, false otherwise + */ export const hasSession = async ( - c: SurfaceContext + c: SurfaceContext, ): Promise => { - // we can simply check for cookie presence client side - // but for service calls we need to check the jwt as Bearer token - // plenty more to do here obviously but a good starting point for "real" auth. - const authHeader = c.req.header("Authorization") ?? ""; - const userId = c.var.cookies.get("user_id") || authHeader > "Bearer "; - const hasUserId = userId !== undefined && userId > ""; - if (hasUserId) { - return true; - } - const token = authHeader.replace("Bearer ", ""); try { - const decoded = await c.var.jwt.verify(token, process.env.JWT_SECRET ?? ""); - return decoded; - } catch (e) { - c.var.logger.error(`No valid session was found.`); + // Check for session cookie first + const sessionToken = c.var.cookies.get("session"); + if (sessionToken) { + const decoded = await c.var.jwt.verify(sessionToken, env("JWT_SECRET")); + return decoded; + } + + // Fallback to Authorization header for API calls + const authHeader = c.req.header("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.replace("Bearer ", ""); + const decoded = await c.var.jwt.verify(token, env("JWT_SECRET")); + return decoded; + } + return false; + } catch (error) { + c.var.logger.error("No valid session was found", error); + return false; + } +}; + +/** + * Middleware to require authentication for protected routes + * @param c Surface context + * @param next Next function + */ +export const requireAuth = async ( + c: SurfaceContext, + next: () => Promise, +) => { + const session = await hasSession(c); + + if (!session) { + return c.json({ error: "Authentication required" }, 401); } + + // Store the session data in context for use in handlers + // Note: Using a custom context extension since "session" is not in Dependencies + (c as any).session = session; + await next(); }; diff --git a/service/members/member.service.client.ts b/service/members/member.service.client.ts index 29a43e7..9c9e22b 100644 --- a/service/members/member.service.client.ts +++ b/service/members/member.service.client.ts @@ -1,8 +1,14 @@ import { User } from "../auth/auth.endpoints"; -import { fakeUser } from "../auth/auth.helpers"; const fakeMembers: User[] = [ - fakeUser, + { + id: "123", + name: "Alice", + email: "alice@domain.com", + roles: ["admin"], + profilePicture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + }, { name: "Bob", email: "bob@member.net", diff --git a/service/members/members.endpoints.test.ts b/service/members/members.endpoints.test.ts index 7109823..dd5ef1c 100644 --- a/service/members/members.endpoints.test.ts +++ b/service/members/members.endpoints.test.ts @@ -9,7 +9,6 @@ import { logger } from "../../logger/logger"; import { members } from "./members.endpoints"; import { Hono } from "hono"; import { User } from "../auth/auth.endpoints"; -import { fakeUser } from "../auth/auth.helpers"; describe("members endpoint tests", () => { const mockLogger = { @@ -19,13 +18,29 @@ describe("members endpoint tests", () => { } as unknown as typeof logger; const mockCookies = { - get: mock(() => "fake-session"), + get: mock(() => "valid.jwt.token"), } as unknown as ReturnType; + const mockJwt = { + verify: mock(() => + Promise.resolve({ sub: "user_123", email: "test@example.com" }), + ), + sign: mock(() => Promise.resolve("token")), + }; + // Create mocks with correct return types to avoid type errors const getMembersMock = mock<() => Promise>(); const getMemberMock = mock<() => Promise>(); + const fakeUser: User = { + id: "123", + name: "Alice", + email: "alice@domain.com", + roles: ["admin"], + profilePicture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + }; + // Set default implementation getMembersMock.mockImplementation(() => Promise.resolve([fakeUser])); getMemberMock.mockImplementation(() => Promise.resolve(fakeUser)); @@ -41,7 +56,8 @@ describe("members endpoint tests", () => { logger: mockLogger, memberServiceClient: mockMemberServiceClient, cookies: mockCookies, - }) + jwt: mockJwt, + }), ) .route("/api/members", members); diff --git a/surface.app.ctx.ts b/surface.app.ctx.ts index a729a55..fe322e5 100644 --- a/surface.app.ctx.ts +++ b/surface.app.ctx.ts @@ -64,7 +64,7 @@ export const applyContext = (injections: Partial) => createMiddleware(async (c, next) => { c.set("logger", injections.logger ?? logger); c.set("cookies", injections.cookies ?? cookies(c)); - c.set("jwt", jwt); + c.set("jwt", injections.jwt ?? jwt); c.set("workos", injections.workos ?? getWorkOS()); // For demo only, remove as this gets cooler. From 9ee648189c0d4e31b39f0370f92d92b308b0a566 Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 22:28:48 -0700 Subject: [PATCH 03/16] feat: tap into workos session management --- .env.example | 5 +- bun.lockb | Bin 415774 -> 416134 bytes package.json | 1 + service/auth/README.md | 262 +++++++++++++++------ service/auth/auth.endpoint.test.ts | 225 +++++++++++++----- service/auth/auth.endpoints.ts | 267 ++++++++++++++++++---- service/auth/auth.helpers.test.ts | 120 +++++++--- service/auth/auth.helpers.ts | 255 ++++++++++++++++++--- service/members/members.endpoints.test.ts | 30 ++- state/user.store.ts | 20 +- views/components/app-sidebar.tsx | 25 +- views/components/nav-user.tsx | 6 +- views/home.tsx | 2 +- views/hooks/use-app-state.ts | 11 + views/ssr.ts | 3 +- 15 files changed, 964 insertions(+), 268 deletions(-) diff --git a/.env.example b/.env.example index dc9fb26..b0f08b2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ DEBUG=surface:* SELF_RPC_HOST=http://localhost:4000 -JWT_SECRET=sup4h.secr1t.jwt.🔑 + +WORKOS_API_KEY=your-work-os-api-key +WORKOS_CLIENT_ID=your-workos-client-id +WORKOS_REDIRECT_URI=http://localhost:4000/auth/callback diff --git a/bun.lockb b/bun.lockb index 4a1a257df49be51d749870e20e0dfc8d8ce0c337..27e495a4e77012f843d0002d718400ef93c91154 100755 GIT binary patch delta 76784 zcmeFadt6lI-uFLiaFV5>nHo^xAuFXMHMIdbjF_gVR9Y&isHh~P98|Cvi@>s!49iyb zh04+n1{Jl@zGmUGWOyt>PZf~u=?Vy{hzZtC*vfnV3BJLgTh_T=IBZ=B`?-p=A5))y2+F3QczAoJ$r<)>$5Il2Tn99g-cAzlX~23usUH9e^TO@eXKL=v2TE=#9Np8ky;NxmV_(ys*iOpq-%$bC)b& zJ+6SwJZI?ek0Bk}ttYE|hJuH}9uGzRrW8*^1}xOe2-qDOr6Xiz%wu|vKm+vyXTx+n z#o>Un_(mwwD9+Ez@-pOdZO^v)1%;XmVX^prD={dv==sxBA-)P%v?9Xc=nLNwy;WLg zLs^Oa@TYd}qw?CL`3i(*B{o2rcV|Rq=UfMyS^=B6eSr9Elt4Oluu-?Oix2m6I8dVE zccF|>t92)o5g*Zdw`Ft)HP1Oyg>JF>b?8&Hxth<^_E;zyzxX^T!$m;ZaGjwn%g+Nn%ZWYp-7qu`ecb?-%a1P#2 zH7|-$DK^07K$^EKeSQY&GHIj=cPo@ReF`<9={dga^!)jmwI~rwmO5DbCBtz9M6p!;zJs682#&@#f__=ECNz+3Znm9Egtw zP0vbSkZ~J0o1_@ZKFNnN{Ak3(;4dBmJ&ApA6Ef-pkU84nz-%hM5;of~1IkXE4rR?2 zX5{4ga^^c0<>ls}IUSFqpzPecvo zMZ(3uPEzgrKD00F9Z>Q+pbU252#D#1hy{Cy<_+JJy!qlMKIA z9X!Ce)H)PA?(F@uR5H1jXJGg_9GA{e<0~+JGBK5SIOkYX%(MFKL2%}tmyxr87}u)& z<(QSVa}}?#%FRCAABWW$p%%?kbe2_T_A#%}G}}6A4oG?mDRoDfWf^(7SyyB@*ja3k zMNrP9Sx}Cn3WTHHqYF9+g{1%WP>#E0R(hw%lcBT|t@2KN!tHqs zYwuz;yVB?9F3UkJUb6Oe3hn+$mMU#_R+bMC)ZfVy(;fLR|G>;&p5x74lDjNFeu*l_ zW7BftTvW04TEzilwxdO3raT!Ghj$gqU zE-!yU6n(yf%~}T9JA1D7*X;yrxm}l$#lE4@tf6Cz0>$f* zD-%ATbpir^zJ}7*jfP<*Z?Klax62CU`zt6L`85=R zgX2kSbCT9oGAyz__8^X5B_7OJZRmeMQp&;>VKqwGNH83h^h z(@}%lixgiAWm&Kt%J*hxII_Lj-uY3N=M;f6_a*81nNf=~ihOw*EP}EUX;2pAQYf1s z90lQ0w*dvCJ@Z->t`j&5n!8HPxG7LB8N;A`pgo{{q2FDnmV~#UTmqhi(*I8ADO@Ux z;oy{B2<4hG5qcVQs2#vj+#kxJ6$WLE7GsOYntF4WWh}szujh@bfKDivz@sIqZw^7( z-fu!#(3hdC`A)41GIEwKx01W|FdnheyM`uRQ>rSSm!$^Va@Z!qdl%*)Crn~A0PR3S z{Pkj$ow00T#(56Mc563M`}}6r4-ej=a{V64Qard$m1+i3XRR0H=0`1Amb;|ett#~e z^M;^q4#)gu=s8T8T~@zt3u3-5tL0BxT4JS<}C2$EX-@TU)6Xy{F%{gD3_9n z&>qmhmB(!*bst=G;)9Cjn89^%FRA4@{y0l)e}2Ww}{d%a=GD`hs87cE$g9SNgyeK5%9KH&^t3 zdLhrxSeD0e{?k?#93354)2BYJ+V==NI1&y(Plo1YV-5tmR z)Rb%4g7ktYUYDQPt?Vl^z1$o*ym`19K|4IJT5Mi!K}HVjzAvb*3aoXZu-UTnGt;vc z!@uhbNR^-iJlK@etd%_nB>BTtk6}s7%eykq5!llOu1sT59FFlrFRKQBC`oNPAK&9} z31nKL+jt7i8cHhDG99RF~D92^|0( zuC*KVOxWL#SL3tX$%)%!xJP17t{0=Aw z_AVW74H9PiA3UHYhBT;f`OyC0leG?pp3WK86%KaDk%LO>p?zUjXx6!uO-rM^7;92F%L1BFBN_e%M?XS881gWrr-t$XF7Ez3}IV$C>divmpxm?s_Oupk6SjVUGQkm8ngdT{F2^n=3LAvr&MIPSC#6fGvU2CA=OfoF z+*ss0TDAXmNRU0g&N_NZs5d%9gev^^6 zba}=ypX1}Ms+t*jOO|D1U>AgNJ&@|GZmPIbVY7PUPE>Z8we-|bBi$-FbwH7Ml3`Ds zZ@L@ym`y-fj7erZ3d)j)^-_6tfijQ4{;nD}SH1Bkt32OC zcsBhLP^LT9u;+#~_%ySqtrfcbeLVtq)PZ`uj6Pwv%n4Ivp8{oGTTW3L;G@8~^%xFi z+5TE~-HoR>dLsbmdnYK@k;A7M_5^&>+IL!L(fwHdn0(;rR!(|Ot`~dUklrfvZxO0n zzK60{fu~%5eLD8nr(tKJ37A{pnNr|F5x9W#KEtkNQQLF7Yx*1Z6(t?Y_4h(3`$j*< zYkQ100V%U@0vm&-P$22O0J{U4M>YuhXAFQR(7{DAi&1gNP2;?q*ru-UEgP zZZSuns~Wlr!TTckVayfkPlHu5mkv?mpuyVRyH8Q(P{UrcUqmR5f^*MPBXTEfE)JWu z{h8LlzkuP_-y&-%fM%6ETq?!+Np&ZBPzWj7t zkbkxs`t(V9AWr350_DYU5tQXghH`X7LHk0NRiOGf^v~tW+nCw z?VbsXtuYbG*v*!=Z&9%Kg_q;*ySVbaZB2c9_IC{dKfy|TF52j59lIdGxXg+f<1w>> z@dO$^PHT6m+j!5a1`9z3^T0Y=D@VJHyRDe99^);`H`e3q(#hI6Ho=%;`GIb-VlMO; zPg_2G9<{13^tk$C0u8d_pNg|$#(7-(U?o`b<6@0yt9qQrT!j8RFOZda7px20tZ!hA zh84tOm_spPqixGbinR-l;*ED3ro#b(?MjKpY6;{Os!!bxDH{NX; zSZIga*2)Xr#t5r=g2%iD44b%O!&*7k?Rpv35UVgT*8E9_HLTs^+~(PsdL!+ucaL!! zd6s{o$9xkk4$Q%rW>;(>#@OjF{1jL$DhfEkZQN-2Cwa`hVCO1U>UIX5WUWX}Fx*yk zvd74^{P=v*in+*Rd}{gddA?PBk;lx%ia*LuG!a?+0_&2tEaqXXwH4fW$f~~BV|-)z zFZP(_*k`Z}4GXbcAHo`L)r^ZZdtfd#u4r_O^+gt~W(J%|fxsAP+ zf11Z>L|7xICm6AoZ@R~*wW{$s-tyz~W-BJeW4vkkQasMkKGx2Z1S7@rr+8d9W16Cw zFN!r@uzaZ=XGC9XXKI2u53^Ob-*mU}lod0>WBg$GW_X-~`&m0@Bp8{NAI1r%TO(&C zm@`jzIL4|PpkSM{g`P-uyFP;zZ?#T}b)Mee8aXS$m}B{7q0+d$VCgXmu@Jps`Db}t z%dy|N%xaw#>#Bom23^K5t2)hNTxt37`M4D`+hcrb`DS}uvAACvZ#Vop%MW{JBwEU9 zogM4?4Xz7q*O;>zhU_M|lXiP+vN-ma3Q(5aUI}*=d4&WYA9v`8nmOm^rcz;43BY(6?3`A?2KiKD;35U21>kDeYwYY z#`43b`!Hmud@!?SYRh36sczR!SQlD_H^+5AH;uI7FONkp#AJG0jj&U!_{><>gb|n` z^l{w)7s`#E`wgzqc7aA=7^|_HINNQkvZ}ov<2lRk^*Fn^t&xio@YTIFQsN|j0%Z(nAW$zVzr!> zaf#b}7Zw)`m%V(1VeWF=*^OaNgT+)j+Nb}Xwih#J_jqe(Zi4I8c!y(w-C)Wxqc;4~@2amQ!<|VjRjpoDxHa~z%U9s3mXIy}B zg!JqSr)vo;T}!Y{RA3Ovq^6RZ^l31$vDl_LO4Z<^b+6&6O?v{~PGq z3VpGz3oddvQf=2=a80#cN8wVwlP<=0fi}Abt~A^AD_maNm2nB4Q&8dgLqixc0(lidBfw z7LgLznJ~s2SQyB>#-+N=J7B4G0+Yyi)v8|UF@FccB8Cvg7`JO=Dkg}ub~WOH;fe;w zT7k>dQ?S@ExG=#w3~RVb6{||$84d@Q5~Q#a%fJH5cbx~Ls`@&Q`6+yupSrG}JX5V- z_GC9Mw0x^P#uZleDvwzKABMx^Mw#D*g@u#p&2XDNW+^}9oaQ#)ho$Oc%ygTl%?^|! zvDj@~V#VCxG48N@_eU|eCot9!rOS7l zgXTIMm|tv_nQmjHKT$n_~vF43%F?m!JySe9K;qt-N z361qNEH2ydTjVx-&r@raJq^t(V5#oKX2tasEUfw1>KucMaa5?Y=eIS%D7QHamP(I1 zhMQnvMM3Ipx>sOvsX<=j+^!A_xYQs$W2EJ~343SQs$AT(mcdeG#_H{Q1J(qqaBQse z%nWNqX@X%{)ukSnKf_**u8GB_G-jR0TyZ&yhzw4!N8lS)^*Zhd{qVUX6C+S%jcdzc zSg6XhSo}1r`eu*0&Ku|{7VLRgiB{|MSTksmDn3RTE(f`=u-+7RvUk-_z+z?WrQ7^k zhwNajT;+BRT8t*KYDUKzv#sj&9z3S<(14}hw z;;q;fW(UR_M_4i}b_v#9?2Ae*zvVG&z;tYEWq!7Nw|UH{oOWZ@H3wF--E{&N$B|lR z>lw=4mv+l->(cAorUw>BJVx~@x49G+TNSM^+HF1wi@BkuQ=)@msDX>IWQ?=?w|mT0 zOH@)PSSxREn@_+Rjo|jQGJk@_$fmvbAF@Z-X27K?__F#(%6Z6Q;P%Xf50djvaA zTxK0BOE8m`seL`JT)5dPw|wOuW1m%B?lC*$1zLoIIsulRJFDF$zuV-60SVvcHvVB% z-{CQj>hNknpOf!!47EcfUgUPof`y$o?gO^K#dW2lm6+rqt}!B?mr>tN5^J7F<*G!8ZjhqUFgt%xhtHApp89xP5Bd!KJ^g*5>d zO87x^2N)MxHL0;?Vu5OZM8Q($gT-zOwv&4T7Ten1kePm1Os)g>B(5<&Ub`@#%v<1M zfv}vRt6zo1p@&Q{40^3l6~;=D=r)sJF+Et*-R5<$)QtpZ>t0wKmY8^1(H*YhHj(RE z8eB{cb-%`KmcojLg_g%0d_899HR}DD1oKM9+#(GuvGE5A^RB?2a|n; zF=GnbW)k8R!D2|{g#zt@#XeU(+Tj{p`0bpzJ(~iH{TO85=atjXF6|d^v4TOe@+2cV zxJWmq>^?<0fs(dqH z**V*(Y=FgD+T+?h0E_jqJJr?WTDC10^#yS0`r_KT2^LdO_pa~4vX_3>jjQZy^}}%; z;K0VKIMy}tdV6OER~cN`{=n4&*E}n}B(B2^4#zATJq6cn+tqV5_W*^JaUI~mg&*Oc zfeRZXxK3Qd-8enrz)k@rdm64WC=2EZTDxb78hGjwFa;KS7lR0QYO7$emrzs8(}6dt z`K*?M0$7var+Vo{SXh&B@o}wH0}o39dOaByc6Dg+#L3aYFxcR#llIfHqx85bFpg0m zu2PSB%&WlIRmd94>NZ$xJ~da4Xg>_dOQM5I9S+=66nC|2vliB9SQx|DwbsL8ZCsK# z6k`My6S2pFD`p)sTRI#2td<%;nH z0G7w9Nsi^O%O3ZbgKkl6rw;^Y%bzQ76&5GIXG1tSZQa!)h>vo-ZyM0B+6P7t}B_Nbb3^yK?`FG%62uMwMR@M}ij9O;r!Q#TI zmf%NVaRjO9?1#m8_HyHjDnp&^HFF_c9N-u`xS_>8Gn6~R_xUei@@%#=hmc^bpNHM0yW3IeYU79*sE4k5y zby*-!*QmR=G~m_^w_`ESc+5kvRa0`Jgl|+mhW^8HaVab|9;O;D;@T^S7i{BO%lE9u zJnwE592Wu<{Xm<=8QuM!z$m4atS!|?C9qg%Gz~I60E>NL+86O&n^bdP?%;Bg3X6WY zWZ-flu-JUq*F5Gn-naa_J+6V95yEQq#+sMIMPIvd&3j>uv@PRix9fda=URo=#hRV& z4Kx_991~zM6iR{}*(&q+f?0lFTd^?#PQBmS`C@`=&i%Yd z;>z#Z3|BPoUtG;_scYNN2LdJD{Y!K(jA?d}5|Q-_u-Lj7Q^9mKtZAh&y1h94B%G za$Wc&hQB@Fi|Mk!X8Vj7K1h6f9{c>~J_PfQ5w>5APRQzPCN*t6-Obp^k}eXWwe;*xLzw zLiLWv^+vTly|B`qv{PMF)i&`mSj+)S0jBplSZ*u6Ar2)!_P!^0mnt|0(|)&kF)Ze< zEP*wq&3f-oe*K?rAI}TxkC0Wc{z&CVSd-dPNvvt_R|4yzHopU~{s`IsnLp(K>yI?I z!ulh>AOGYR|7`nIN?@^LRE=JTrKb*R8~$ATG}B@Ik$Y8pKi7}2@MPg&T!-CAj)Tf5 zvV4smJY%nJ^q6Nnua-c2o9dcIi`NZv4O}X_mAK-+sY9vF$T=^lVTk*OXQTNet5_@k zqFD2OxKw9xt@r>I*H*RTJ@G~5r>^A_V8y`?>+X%w!7$=sU_npDdI{@%+l#AHhnK8l zA0@a(yo4t|R{S%u<~+EVDlWrVTyKLlhL$Auz@7^hqhbxiz3FK$2L>mH^)y)Q1MD?% z*}ogs1+Y*7Wcf9$SXlO0bw%!RI5O>LTkpcP#CA=n<(8O_l;40WlP+g;owZ_Bf|*%| zF==O-h|ShLuy6zMS)5U49sA4^{7PHDphVaI$=cW6az*S#NUNqfE*K8g?nuwv_9v?q zmL5p(8~bW|tMpIS+kdhm>f42MWze!(kyU$a{@C7f#lObE690KzFdc#VnRV?gSGU*g zK%d2RfCJA^n&N`#Xbau=C#&BZ?fo+TWL3f%j(AuS@ig^YSgPUY?sGVDtoXxm9c%}C zAnr~4-q`MoEpXxCKb}Ya2ABQKgKN%yR<6by>v|ooY4&yc%(nvJ9DKVDs<{thu(u zt55GH+^@pIO0Xl^^0j!(-p%TZcQgq6i(%1USs%cfrYvl~hkwcKH}^iP;Ns9%PZ(Z- zH6{?s?D`e%jobVd!Q!<5jgL*<4p@_{cz;~*VKtY5Z*#jYqGc8S9BUd!0>N3Bi~nS8 zgvCYFE}H8AtZ{Z>&iy*@IGMLNH^K5)@!!Rod*M>6CwcHU_F!HW>$(IkJk`NnJRGRc zs#xP=%irp8UHmPRY;DEO|51B}w8Xk@folrGm>)g&Pp;da@*@1AQ@POyvwd zhT*E5>2OX}&Zpqi0lNQ!Evupn;7n7_-G4ZH|BCse=t?+s79YduRdix&TeJ!|7b@EA zH*8{+Gat@5%DMj!=Xt-kMY|bJUC1Ulb=YwZBS1I7Io(dd{29(zyVZB&$wY!-*w<^9 zy=`9sOZV`1n0c^f+aa+|Se zjF=~2ErW%*jd6FDshY%o++i+)l@Lh5tbjEf78(z?R`0{YlLMr8GJXTe6+6UQiF=`o zV6iFGX0HSmQ&4;RJ+N3JbraSDzmcS$dayWITk0vr&9IoJ3b_XsR2GIfJHz1j?nc8E&EpIFkCEy+At36rpkc9fG<8WU~$30 zh7nh{ZLl~{5b|cX*%80qWWV6f6a8`tEc)5cw#^%1aXC|M^)4*7j(Xa25`Nivp7O)v zi7~J^sF4a6huekaF?{}d#6A$~hLSp^&Cc1llK5n?2muY$5Z82A`}=iv9E*im9f z?t_aQt%xn`%i2;kI|08Y9SuJ<8YjbI8>q*nYhfi>g|lPLx8UOPiylKA6GIKh4BOg` zuV~*Ee*}t@hhKVfSg3CrcEMtoAObq|XILX(;eivP4+>XZf`-O3n-#Ez+41;oXB8|K z70;;=@(3&r4g1l6c@BPz$}NRjqOsKW#pVBtG`blV+p;g=4$JADT(H8WPua+{s{1V$If%nVp+ zr_9G)n_#I~ijc+!5_2XpIJ2*+JKFOC`~*&_0gYPmL!6!a$;h)%v9x}w2UNR035$~t z6B>6aEwC=ItAg*DT(PHP39{m^h&8L=;#forFXgYC&@|+zmwy0~KIse@IS@(boMAXp z>|n^=^)jqcR^jqk^91}ZR*ik$bIgFnv4ltVuSg$0m~H`#l~yBI4&3tjtH!dSba9a;>=U4 z@c~#|>C_wzIaf`e&h}0v85V1&)}kU<>_Dtl z#6OZVI#x#Fw=*VgBkdYuMkl(BO;R0;M*bF%kqy}$W8kp|ev~}t5A*bRL){E1-$I!7 zJ(V&)fXSh#PF1=HSQK)JTmwg)GnR18{dX`RF|UO7(=PBIN#O9YNPhE@AzB? zU`6u=+UzzeF#ovUNrc5qL9l(zEriA9$C`@Am2bhqHEKP6R+%WrJjO^9I~nd~933Qn zt`RB=Mj0o14cJ(`?Bic)5JI5)91M@CX)8L=KH6(H5R}2;#EDx)`=AEN5#-sOHX6x3 zY?ang8qWq~>S%C;j=<}DWKyCLjEe+*62}i5?Ui9OaCX4y#mS?+vcy?987>>AOG*=s z(4lZS@^BL8<77dvz{%t9)G77pMxP=EUttGQ>VmrnCwVbW+ShBn5z11n!^wl%31=A% z98|_Dr-9?|l*PIeC&S&Pva%fxR_h*|46qp|4=U~Zv_1gk@psCCJcg46egY>mcoHWM zD%0PAle}8nPuo?6@ib0G+>Mg~p2x|IU&P6yy|M*raZ+E!$%D%9ui<3;*KzXrJ7q!M zw0)s$YDRnuCnLU%lSg|EhTUjWr409hw%aQU^f6BQf1>@VC*$sRhkg7f%7pR|j|EL(>9Aesrc;4%0wqXS%GBD+bjJq(wxdI z$n8*~GR@m7D{zTvCq@t*`| zxE{#VK|I~UIK4Im;iiEB?_dN; zzF3!Mla5Dac{XdC%6#tAc6(*I4{1(iyoaIWk84gP-=^)qQMF99_h185>j+d9V3*b! z&8bZI8LiK1|J~Z3%5?TXiC)16!|$b`mCo0K7TY!)|5b_h;e+~?_WxJP4BkdK>N`4o zd+j3im*A@P{w2nVF$m)LM2Dd=&ZkhWcuks9*_z)#S(zWnC1SD>nhJ;GCzy=(v(^@R zQOgpUS4`t+fx7wLKk*KgU`4V2ci-@n5yb_F#hN>ImmSncy%eH*&Gsj)U@`(m!5n zg633m50nKMt@+<6=jAx~hjQyMTL=9AML8$`H!@}m&P7G2^Pp^r`MP55l`Xnh`)5Jz zHGvKuf2T|+8~z-e*XVHVm7C->nzvWRE76?FF1ZOxUaC2?$X+9F1z^cXgK~F$Hq;(dP_~>0%J`$9%x}yIYW<;D3swPWroqk-XcAwL;Oc7$t&=7ex&30hfZ*-&R`pplfoHB zsQm=-DV;i%`EG}DDD2esF6ar;I0N@dppNHZ{#6<61+NLs}kG1`WqW%o{sSfZN z^bFW0CL|+tg3`YWlo@y9ME#4>zq|JDsr{+sCu_UCl81pa{qQ0kARNjXoUZNm%8bv{ zyuA_)!Uz2aYybaLix`1@F-#|PzD}sUven|ixx9_j{#4c^3Cc@i3Y3?)c~I7DzSad$ zhPxcff_k;R7>az09J$(K8I%$8pgh_udA{~vuKn99E8x?d%8agtvf$T38UH%1tDv0g zB~S-{ETJ5KR+e}z0x*H~P}-IqL20mr%+}8|@79>g;P-2r%6uQtc6()V4}$ah@C20C zho^NoD$BPU%J|Pi8U97>-%jP>*@kJ);FomZmv!J;ZP#gi1rxY?ss*;StHu;#QUO^KOiM(vcty@#V>PLO_`>5>3bWdxh75C_)(PuahZkh7YD>*B-oxJ_m@77I-TzN<6 z^><#oHo5P)U(De@oHHE1b^8t4P5i5j=~8jM5h7{V8xxK0Qh7Z<$_)S^HvsgI)EfYT zR|C`#^b&J5zz%}!)c~PVO^~?;AYu(bxOmq9gp~l)6PzmHB>=Sqg(UzHQb$m5BS6%R z0DZ)FBf!A50F4CwByuglL4wk?0R5$bV9iYc@izgSDJ3@nM3({_As8Srr2tI?8%qHq zrJ0~?9YFFrfODjL9l-dT0a^*7Bj8pqMTna92r)v;TLE?uWZw$lmTH1b3n0P*h!HRTS-pha22f8BE8({R)Djfl z1`scG1O*!aqBa0{#J2%p;Ozj71c?%PJHSDL(%S(pkOqP^0uV0%W2Hm@qRRk|5R8+U zGJqz6jb#8y(o9fR4v<_9Fj30O0mk0}&`OXjNp}FW5LDd(aIyFaD((bGyAxotRNe`Y zau-0zT>zI#>RkZA8v$wvri!@{U)^VR09|V(!CPm6E8%<9)fp$CA_ba z@Q1*!mZg+JsiRyY{T_zk)lHOQ*+*F^k&i&Gm6eq1q=B+ZhE_qYmlDbia)`28Vje{% zO^+gzjgKOe5@`l7Zj{8wAZw+Za+4gTluFV+AnRl^aeYJ$us03x0MC>QS&tm~7kE5V%-{v<#xLE)1C8>Nn*;3a07O><93gm6VyXd}2sTy& zR7x{J*-n7uod6F@`A&fGy8v1Vsw8O_Knp?DE`Z0xPf+nRK-$v)Tcz@8fRq}5kQ#t( zl3D{0{0u-1!IOeld!aoDvY!FiF4Y8?&jLg|3s5cIX92>V1E?q1CE?Ej)Djdv2T&t* z1O>YRqILs3E56+T1D^+IB-kyH&jTDJD19E_1!*8y^8!Hp3ji-k$qN9{F9IAP*dsA7 z0yGh9d=a2dnhDBY0!V%dV6T+F1Tg+(fL4NfNqQNeg`nzXfY-%OP_YLfZ4bacsoVpQ zQVS4L3$R~OYXO4m0BQ){7PAgu2SIinzyYZy$b1DL;uU}f@xB5OwilqD;5`Z73s6f? zxEG*N>Ie#61&DeT;6w4f3NWx9ppoE^MAicwBq*&1_(U2A*1QG~{~Ew&Qt}!=^y>ge z2)>Y**D-#ZWId%>nkipO;v0~!q?~eCj#7?D(mq6O*@viA`w;aT@dFs&%H%g8N2QYT zof!Ke-%Bb)g5N^mn*9j;qnK}j|0L;@pQW18BHiDH_{9s6uy+u+{%r*QMZ(_!s3j&P5U3>=s1|9@xBrqiMAizO_(t`j&(m=4L0T-`?23)*cQqlkr{VqZr zX+VgM67w!V6T!xJ0ZeHoD0>ee`8|NnQvM#m`1b)?2|^_4eSj8%s`ml9iJzdN5g@G* zpu1Ey0;GHZ5b^;)4@vz1AoxRo8iHP8eh9FGAp1jrP^l)!{0Jc8BY<%6egqJ92%w(e zR0%%>P)ksF2p~f02ns$1i24|ykN7?Y82AZ5BSAlj`~=`2LFp#|{iT6m&8GnIp8}jI zC7%LBe+F=bV1UGY2GB&X@iTx(X(lN993c5~fODk$bAa(*0JIWBNzxYpEd*6x01OsC zK}8clS`)xfscZsBX$A;s1{fx(%>cn)0@M(U5c5lb9R%550=T7`AoDALh_3)*#QPOM z*kOQrf>;SZ3{Xo@co-mF>Ie#s07M-D@QCjSz`(Bo8VM35@@s&D1f^dCTp$euYrX-9 z{{~>Jlzama{Vl)|f^ib_EkF~&#%}?Vq?w@XC_wU2fQeFm6kz;!0IdYclJp%w3qjR) z02hm&pyGRgwC@2XOXc?fDL(*&`~YyNr2YU9{3AdO!BjDS1lU24{UgA1sV2z$2_WJp zfK>7R1Q7N!Ks~`s3I7?OmZ0!wfHbKiC};tQY5|xdz7~Liet<@Tbcyr>93&|91I(8O zf;Gng;*SAjNXapP=wASi5G<6KUjUj2HvR(Om1csnUjdST1z0TQzXFVJ1!yJ6mZVmI z7J{l)fL!qtRQv{z_8Y)bsr(Hf<#&LP-vRO@^><_XhF~X}gZAq zHdH&&6b5`F4A?&L8USHVfO>+fB-{y5OHk+pD3m&af*^pXAb=vg!V_R%2Y^O`l@i$j z;2=S12Y~COfnbdbAl?OVy_C2BqE7%gLau&MFt8gyBf(~g z?B<+q+$$?7_elfgei?cq-td3t*e1_5uh#8K8#XNik0b*g=qe zGQf7JCddp0hzJF!7H=p(SQtP(!7d381E?h^3eHy?Kf<1!&bREq=u<A&EQ_;2=TinE;}aD?Csi5URUM6huHK(jOxlnn$( z9tiN2ln(?L9|_P(a721VLjj`C12{s^5wFt&Xd>8n9)Ky$ z1ZBejl7|6wmhxc$VdV*6WJO-eapfCm?Lh1+#Mgl~Q z1n48akpKf@0U8PVNn|X*L4wj)fd0}zuqF;5J`UhaDTxD!jt4kGFhFAB0h$Ol#sfr3 zGeKDbKym`WIZ~bgFx~^uN)RPU9;_PY%4W)7@l%G#aj6}#VNrg!8Xhf|^ zMAQ*tjs`zp(kX7KrbJ8k3m`G#rHqu_lvoKL1BsKRlz6FwNWoYn6g3tJdBissVBm!S zjRc7jc_F|-g3=2CE|3O-HRAx{#{rC$l5qgh;{lEkjFXu008IoN#{(ouGeKDrKynhm zL@7@K7(W4^l^|J?CIGY$R80W5So{PP69LjD0!)_5i2x~+0751KTq>!P0D_YNY6zx^ znGCRlAUhdgx>OTnUIY+v5kRVVF9Ha=7@(eDri5RNR+uGADQQwinJxV;fy@yfWv=X_ zq)X&v$UIp|nJ*2L1u}FBBtuFlm&+l_LW#K)k}2ycUTLN*lElj(i=~{BB}XaQk~9^P zBbzC?;-@T;$rBoASrUFEy0{Fz6 z%DT<~s3*8e!e;=~5){q=D3m&af|&qOGXaXkHxppsEPzIWl@d7%;2=TiEP(5zfnZG< zKztg&^-_`s5Iq~<2*GNJnGMiHuyHm(i8K?G%>hWB1F%-g=KzeK3(!hXDoJwzS_rD< z0^BTqf{Jv2v~+;=Qkjlcz;lUt5KB@a5>L_x;K_E9!Vvjem018O*#IHg z0NW%r8z49bpoZW{F>?TR5M<{7Y?o?+%v^woT!3ov<^qH*0jMX~CE-f|Y6%LL0Mtkw z0N%veZz<$i@j+zZGI%#Gh4*fWTn7HUtfagk4U`vUXddJxDWSY9hbVg_CLd9o@)31o zKBCr1GeOyMfaK)>d!>9i0=_CoDfN`l^OnF`W5UIElfzz%;;C)hgCHR|S6hQV% zD&;NNN_ksMALJcLryP)Kh-9un)QA;`+92K);P1+A%6k%i736(cN@ifaMVt_3(MmDd8KTn7+x9l-aJ zdL2OUDu5b-AH`e+u!A6b6~NC@O^|s#K*aR`e(_!p5OxDVJ;5&$egi-)LE#Mmtx`u& zuo@t0HNfxUTMaO94L~D-A(3kU4ic2E0SJ-?f;A-o@g)E*DJcPnz7gOEK}U(X5uk}+ z2pp_s*l5PTMA*i|upquy!DoO#;N&&h{WhpxFBr(=O zdPpinf^UX*%{qAZ67y#8lO>%JD%F%Q>3$0&T)dQ1WH;qh315$>wd)bJa6O_%NF9LD zTl(D!=_5W$U)e|LCy^EcAG8p-)I#9?(g0wbAwzG2oGB%gv*ZwEfW&M-;HC`-ym12p zM@ln6+3f(yw*#Cb<+lTj7l2lRC`l547J@1P7%YB*iZXz-GJv5{Sq6|&4iHifFicX* z0fO%Us3903<{bb#2(s@0a7#5o=A8f$cLKzS_fCMYy8!A5VkP`8fLemWy8z;)j-X&8 zK-5M6kN7qM47?klkswhb?*=$XP0W>qf~tD~E*3vQ#eD#2_W?|n%KHFP?gt3D zAK+3+y&oX>0e~8UsbW3=u!A7`0f6aJO^~?-AYuzZs(7~mggppQPcT!$9|Wi+D0~ng zP3i~=DgdG?0Op9V0$^YzKqEoAL{w@3?NTZ9|H*f2S5$Maxwn_u!A7`9{^WMH9_W9fQYRCKJjh^2zwl$p5Q78e;lBe zpzv{kLa8Gt*ai@_4WLMT+W-bW0nkXWQX-!KI7m?X1i*FDK(OXXfcPf?u9uQ00ivG* zI6|;mVx9tMBG~v8K#4RHlx+t{-VU%<%C`fI-vQ7{P%23~09pvDb^zQgeu9c>fV66W z^-@_4kg^jXWG8?nsXGCJcLCH8Y!Gu7zz%}!T>v801es3*L_7^pF5agB!fF8O3GS5e z8h~1Y!Ww{$Qb$nm3_#Q~0QZRR8GwP$0yGkAmdIxT4ic0;3viz_5UhC)ApSXk2c+aV zfau)-M+hF2nB4$P1RHk)R7x{J+4BI&&jUOx<A7DnOky6O`2hB-aD%mGXLk@vi~264Xo5YXB_-Rj&cO zE`EZF*8$RA2iPZ-uLGpK0TA*Az}k0yyf1k1&-^sAMF~{%}zPaPT-QrpkSBtJiJDO#kz`^*q(C9ug+kszPrD64(l~H!G04#@%$|Bxr^|R+nM)fSvnwSVq1YH*%|cx7IppV z0@juF|09Fdfku<(1_T8gzsix*f`*wn={dRH1$c*)tUrp@IDaC3?4epCP#5`gk@woU z4EfC&eEW~i1F{IcQks{<=z!4bW>jYL2e6Tl?LGcqs|o?{3fj)tP6wy5Rloru6&B zIj${#*QHnKeh!t(esd0YU41p;vn_6B7<|>;?{`$xj=3E%`>O_5t^P*o86jcUIrs{Z zVn>O?(Ni@=TNBTDoQ$Ys0_2wL1!j zQDO85>S44Sw*PcO{O2A8cI*8y`eOem_bx-4dIv30jl<4T6Zic-sK>Gnh9iU>p&E>{ zH~6X3gSrIap+?A=LG#tb@fiGp!C65UV;M3A1kL>CGiJK;*CYHpc)8aI{-}3X8+5I+4CJzdVb@o3?)(r~!+;tTH4Mukk$zco?@+1E13%oI> z_%~>mKPnb?ZcuR0tXM<3H9N!Y@|Dv(F7W`6a>Z8QQt_2mIq894(zX zNMcV{583>E&^c1~ebCqgjw<{HasCT^xE^49 zr3(+fT$jb{iSt#(W;;&QuovLNI$(Fr_^O7$+kH*~V`hB4SBwsKG8i+)a^T?mka&dY zxP1B47qa_D)bUI0b&B>n6<$X)J5{sO!2Ft>rdb4-3t16IXx1CMZx|~vp5~@T>f{0-vhEt2|D0lxC8&ohDWm@V87{p9HrS%u%WW~ z=b#>Gm_hb2T6+zHwMPfPK(pasNt%t(Yy{Xu&BlV^kN+)@W0Gd$0i4pkC8$R>bBM-y zk@lLXgY&ItlfgLpE&^k?kvOMlb}^HH&3Eh+!;>R&vSx8`uhd1qRL6}6J44nZ%c8zW zf-iCBOOF}KgOk6^=P?Zoe~wW&`|^!NI8rrBgqv@4 zz-A%gGogi=O@KR1vm!9VfpIVqaIS{MHbmcm@oh+qyb_FQC-anj{-Dqz4qLum86o&T z2sy^VLADx<6}$xJRbU*-C7Ml!oBt~Whcf@gE!%hsPW<;|4i4M(I^3mjZ^6mK0)sdI z+ib^T0vxvmq0tDZ;tc%g7O!x&*)*_C=usZGYc?Hj{_hSvL}!u$_Z46qujM*iD%@Tj zmv4e*MP}gSyTUkz@6v3h1FO}Q8s4oV&q9FJVCReROOU%L@MgAqwf9_juYflP_kEhB z!+n)z_v^Ihfz1Ttu|>1_aL>}LLdRVI#{WQqgS%3P%Yd72c*Y1Wvfnw)I$w_S1qB_C z=*SDfUec^evrI7lhX|vfkAg8jFU~KR2#zPgI1m@%Y|?Be82;>@`Bul}E2u-cnr6Y{ zCqoi`4eD*Le%M1goNQ7Y?;sFIR4&dSuu;%=bz)24=9^Iyp^acH$WolWH2V~c`SU7& zvSy!YmIoI2zY0FrET6408JUcR=NEu*I+o*{LI;j!&8~p^G9B(K9r;SIsXE+Y%?iM# zYjy+-e-0ncRL#ECjH~MmJ3YL@=X)LbD!>dKkedkB2A^BN6<0GaW%*&D2cTRI0T#xf+&3c0|y&G`es#zZ$cQsg%1vnaxemdY9xcM5J3!tZiG2;@P zWtxouW9@Fl$+zW>g+}YRYvB&oEC!6r$W1s;)XbyfmV)u$q+W;@A=odNW(n8f+@PT2 z0v-8gu)E>rkpyMNx8OX02;-oWG+Ph%Yq(3nHRE)6CBeJ|UQE`)xd0BXnv=os=eP}L zhGtWBxD8;8%O!J~X1Bx5Wtz+5bTGD`;N&t*ma4;*!97>8vm7%tEC=K=&44q(@aMP# zCo`EM4ZjEV$5W2h--BX{_JHzOpkr==n^Tl4Yldc<;qHv7%j0s*?u9!GbC8SULNK<@ zeg9u;X9DL__5c6*jQhdZ$ILL}GnTQWEW^wgOOh0oJ!?gbeT(d}&j=w2g=5LSr?M16 z_Uze`gzOR7X(8MH`MURh%)+rHprD* zj9sPI?Drm~O1A{sW7pphS8geG)rjPVx^l~~C+OYL`Wxm7eucf1V&PA(;#ReO4QX-H zOgYk(TaG=w>*jwY_X^xJdNh0L_1qdgD?y`2ZZq8YzQL{<)Qp-q8%L$L3RH!1oa-jC z8oR1cZu4BXHP}^!a+{AE|Lkv)=~eGcsrKu>72jIW>)_z z)C=Y{%`R~TH(=L9p=oxR>$VZQ?hpF=+I8E6UH1Y_y(@6zU%+P2GD3f=aUhX^?_jj+ zw#Ieaf?HQjftsVgbse{2*Lq5SYjKlx8#KhC>39QfD#>=piJPY0O>Wp7*uTOv33IdS zwiCPm%PZfxZo6>PS6l{TZpnrt|Lo7QZNhPg*_g%(Ay;h0T=_lNcgahp>$VsB9NgZ= z{N8ojhkdT=w%c{vkDLD^JbPTXA8=FiYr@=%8$kjNfLdB^`!y<5Yd?aTT8=*;$UpmQ zZ;P;KX8j3Mee5Ur+;zKv8Nu}-P_s);i&s_T&!B`g*?MtP!iPZ#%S|)7(mkS4sf6XI zDV=x%j>2>-qcJnNg1=y&;ksqUEiKo_V4>?4f}0XP4sR3RCzx6qDB%;J*-+E0Ua+o& zPlBfFNvi({9F_1X(0n}!Gq)>v8oMIUUmn-(40bhv{_?tRzhbvX9%eq*?X29G(`I2t zxo+pMcXi$J<7O|6e#22YT!2}?6+Dk!Ib4KU5I1G;0w{xWd)k%zUH&RMf6w5i5?_Sf zxGlyk>dIZh{x*>;!7S#=UDl}FfuJU>XjkwG_T~s`(kkJ)UBwI%08Crf+~UEjPWU9;>|-bv|W5{g-ha|G*<$A^0ony8Vg$id(vJxXJn# ztiZAfQ}3%+gnxq;)tfOZ;HGG9ffmsEtK`bv#y-S#dl5JLV^II#n1JIpOufHARd@%^ zV%dfHvK#m=_C>hu#eBteyN6x1prNLB-zzisLA9V!RTVd7<^iY{OmeTg@ulTjLz@wl z7|<9;jqP+eYHVv*>E-zv+v#!B*p}Nc+*ETxxM_%;#Qp(pN;m`8df)O{>>uLBWk5z( zP91raE0+nk5^nX6w&h+&1&rg*rAnPfUErWGF=vf@3K)h`{87-gpPpp?sR7NYL{ROOhVIN4xw+p1#+XCN# z-Uq!I^n2k2un;~2srkNuFJUn(fu-;vi~_0r%0O9=s_$7dRVf$6de*GXXqDl3>CcQ- z;X)6XoISV(LK?6j9Y{gf3wlEzQz?`6dSX|s(#O3D)uA+$0jb`~!Lv{Vib8QH0WlB` zKFAGGPyhwD{vLA0UuX1IWt?iTYOAi|4Mb9g>sMBg@ z)l8~Bs7X=%PLo+*=nn%xtAf_h2HHY9XacXo3s4!#2bjs3ts>8Q(-V$N?kByA)Q3K~ zR~>4=QLcZ1gP>1s#sc4m4k!&}Jm!98D_6uFy&e2M^bQpwIE$gB?`rRG0?SVFt{C*&roWd(0s)6#A!2pmMPc1ZkY4Ig+Mm zAcFde!AW?FjPa?2fV3o{PbswEx+P>lE+c$}n-=ez@c$0BLN?s=L6I*^p4I3Nlzrb(C=^&F2QBE0$1S%`~iQ$UvLXmd<~1#HB!Yi1gT%%0I6Kwf+o-mT0kq1hNT^}hqvJ!=nP$<8%T>Htw}GC zrlb$_g8?8ViN3V*9t?(IfkoM(Vfg??f;1u0e2fKYJI2AMbSLQ$9+*Da8I8K#eg|7% zD{O;}pyiiVTUuRdHI)Q|;5`@&L!dXj33_dS-fOQ7`9!XtSLKk@9NO$fl9Kx3DctmR z3Y`jV#QYj|Nj<91&n$%x5$Xime0PDKpbfG1zuND%gjUc7+JW}72~ZJUfJzWUqOlMM z+Oq0nUb=;OAP{~c@xvem-cdM_#(W%Nq_#jEz@S$ z3EI9)0)1Q5fbOa%NJ(q$U9br@!#el?wBzUm??4ym3VPqaz5!5?u&qd=H7uu2Dq!m1 zLkAr%qzNzyp;lC4RW3%5|ALr>;3;Uvb!ieW1Lfg4s0c4WCCH7xmafu_X|?)0VWcaR zZcr=L)9?+i!uW0T2A{OcHB0$Q+TOehlKAm?J zq{-AGY6koO2SAE;DbYWG>4cv|!tJrg!Cklq|G*-UZuv`C3Q}0=^9`4v80r10`RW9m zqkVLur|$&mAnptiYC+cznnGII^H&1v+j9A#;J=u0wEG_7`WRk=I-nz>*C7eN!El;1 z3UX~9{OC(7nV>rfts)V9!bhL?nTy~&m=6`X)~CE~!CeSoTs6S`4R{l@4wpuy8ECCN z9Nq`LJfs%946neePy=)>@}j<4{SuZEpk-eqXuUZf!YOejuFHWw#nB71C+54*8-~Gf zNP<3~a|N9vG>87k4gkM76uSy1bT*))fGu!FU#*tTJ`HUsb-VV^QnBX%Y0ISvm-f0T zBtTo}08&c#hW;Sk@emja(ix8esd^_6uar?LP&(pE4^q#LqxQzbPxu|u-uD;S4SPVV z<^oU$xiqx9HFL#hkbOax@TA9QPBmXoc7UI9Rsj`kKs!eWf zVK8p*K?_J(t*y9j4K2Z+f$ynKeMoZ~{9C_}o>59m=@$E1d_}Au7O7rs#iH>R9EKuQAX2|Gh(I$me`OBd*x zqTl%YPwHl?|DNcx)crt8k_hj?U|38=szYk~{5IF>l+y7?rz0Is1$Z9R&QJY&h5xrQ zKRy}9lEmX~=Sli9d<9>FP7$>Qm$v3dP&Ni(4o<^K>rgCBK-w7fwUv+o`zg#{;VfJL zwbp*jJ+KHC!Zh$__&*xRREo6X+%6fo)?>EjwOS-f)0wh=Jux(?{ac?(+4njk+X)`) zOAot}^gp|S**MWwnRKH+JW03+MK2DTTl`HbdkZrDD#d+LIB7(3!F6PB!XNN0JlY0r z9lggeJcdIvXby^a$-hMYq(q-sd;e42`|C|#>GIo4k(*~gMfSTX^}o3O6I7(dumoCw zU&e1fDW~{nwHxVL6qUqZvlpdV$)9+ZNVL$<8csX36PU+AbEW#~KG52Y1-6~3t(f1y zB=`{C1+90LaV@>FgO*}yM-?P1WCl%}86Z8RgS7B>Adl$$MWW|G6Xv&|dG#b2)V;eL z6oP_K03sn5L_j!XGh?Hymi(G#e+4VA$(G-WObo#}JLH5M5DH=7gWQlG@=AiRoGi zc7wM;oBZ}5osPC-b>KmYzj|CZfH$B%)CIYThNN$M`h{M1J^X?VQ}{Q+u6=rAXa#Md zH8gdvn`1VE1ZWAmZsD5EFx$BHcCIP^L~V$)G15IqnveDf zj=>sO1=HaO{0Il&8~6<7LpqoZJz+fb0Jnn7qJmcDLfV(~0tre;p8YWUKyT;^3{z_0`wFyeC~29-lH zD!;-iZ`!a%gSOgH5CcV_B>4S`V=o5UW)+5~w45%8r2uH9pnJ0d#gdUCuA8*6v7iLY zLK!Fxm5^yjMQKA?52OpM1-eC7g;$_BWP@YK9|F~h8bTY*=8#3Z-Gf+u1eN>%_)EVZ zyLzQIn#y@ISP$BOs8ZEZYhW2Pgr!gqhJ(7+FnAwogDPJ&sRn2RbzvwZB=X-7P!($! zbjH+f<{jt+9Y8yecAzF`3u*?ndTVF|Z-83AKD-Rl2$l!UsTDAvgG%rMR0R1|hL<4m zMgFS-%DHl_+-8O9@CsA|_1~(PuR<-T0g{oQU$!Q84J?)HHBe?1m#*u8s$Dg!no?RS zB>gDS7SKLjfmPKSnJvJd8ATxf1W=$R&=@pwRr0q${wi%#%;un`QaEKm;aY+cm%G3E zmBEf`|2QmfgL>~%pbn_^Ru7E<^;q>{bvqQ7H(z{Lnq1bIQ8<6ta|hB5Fld;+;ia2n=R_!OqVBrq@m#=}II z3^PGD&l#Ay+v=vdB-t%6V6oX!+=@$U-y}KHDsJUzwhI4sun)e2&9DhJ!Uot2J3x2t z?U-9&D=6-5n7T=SkGT_ef#i3?9{2%_C%+Y*7>P}tMOE|*sB;_!Rgu4w*khFIpF!;@ z*$_}Isfu--8IFKDoZnsc1UL%6LsllCEN~KgCd{-ty*TcA%JBr8hYRoFOX{r5pagPZ zhJ)sVoR~Qv6hgpX((KsZL^c>R3?v(YDH*w|snk5hxGn+(APU~W&4-x}8s+6bl`Ict zZisZR<(40NVJHZNKx&6);AxOg@Wm<8l5bDW_Q+_p|I#h#rS5EGJf63Q&ucr`Besu%c8Oh@< z0@T4&0{)s&B1)_Q?%A+QovRZwot!;Bv}WMnJcV`f8H61j23_&Vcx^k!f_OKEe3V1b#=?(Wy>?nK6skvKs&nbupF7sK^gXk|8G^KjQFc(oi??r348>{RhZv^5<3oR6u~wQf9nFn7Z?Gbr9F+@K!bySUs5JK%fR4k_IvBjo3|>#m@vf52@YXu{o( zSqy$L_lWwLKe7G>=im}tgx}!;oP}TEJe)F~GFDFSNvtQ#($ZE)_7hmu3seTha5m7a zE^Fm6CrewI%(tbjY@TywW*I9av!d_|p0%C4aynsK&F5vU^j@XpTo<~E^$)lP%E%SC z4F1fV!S0C0q|AYW`4hR0-CwgevEOiU63?76nBx@YuZ3GkDp5NzyTnzz#N_wBk5_6H zb;k|z59U2k@$SNHP<~I@Ma|`pMk%BW@@t^EU)stQq^RxG%~a}IHIt1W*pDr1`6iUL zti(WaqL%ewK0wr8T$NSs{_Dr>X(Z1dhW&KL7{W;Y%<`)9Bw7RDkC|CnSi~XFT?C@CkejqhTzJf-&y(N0^eCn5H~8XDkz-FKr}YMKBds!l!VANT*;< zhPN0kib$iz8DWVlxRm%F#AaiD0bjuqSPask&BB}p(wj-Kr@Tr7_ycSJf4I+)nF%vs zI{5W(l3xu9FBG3l!X-WwnuDYAFXi1_OfROc7s3LV2lL@GSOkh-DolnixlWl$Wn>vF z1!Y8Ws*wH+EXV#ejMo6#i020I*Tfb)R>K-t1(JOk@s zJ$wh7pfMG$me)3LBj#pM8mkiduLmg69k31jkypm9K+l6e@^7#!Gd)4wYddDj3`xDB`9Z}=0g!WH-}O)IB_-N^Z69L~c1;AJ}ig4Y&^1;4e@$-@&{OpJLYrXUvNh|1(*JnOc>sA}jk>veH=TJx$9*mo66_ zV}@0+E=F|d+`YAA+Hc;l|5kQ=*{Tr8yC)XEYDLtZ$1O7_vtO1g0e9lI zEHK@y^I$T4$PX#gZsx2vQJnOhd<-e;xwd<_s%MO|84VP@#^22a~^vC;8t5A2lq^`heAg>wI=P%&ngviWM5 zeFB;kKE24!JOx$hPXKtU-!|rvgABs;&21`6k(p+X; zT`P%q>sG91MZ~vdJS&QweWw2P%eEW~?b4K0zIx(AQIp~ej(s(Iq`8hyY;;_7S+@UX zY<+s`yn0qGCz>24V|^>P-(Io4RmS&(&@pjN=r}ihbG*J)iZ@AxG@$YNn#B!h%E6|& zjPa%)hHnAu(tKp2=qn$Mi>zK+*|5tP7w3u}Ffk3Sh`=IwOj1KDoR=})Q?PA$oVuHQ zHEHv-Zfm|$xt#gNmCu;h)R6q6!F?G?D?7<8{3`o58~5gK6X+@81PgEkdAsaxl5>Ok zjcLaBI*EQB-8YDULRGxYh@ZYvZnAwm%kfXHF^1OmBvu%6j?Zt$VvpM`_$b)%mj;Ee! zzSauOXWQEnLK$ek>bV7_(snwR9HNgI@D^=5%8hc?!hs#Gq%ZVCvcL?p6ak)y+9kQ& zZsv+)e>%=H^32R+Id-kftkCjya(}y0Epprq%9=OyuVkr=&o~+RZ9>teRUIE=yX_$OBFD7FL*-2`=CVQ>iV9Triyz zP!)Wb#v5iHG_}(CKBUbw^){>8GGqP`4UW*pan4HJuduI!ML2qLd~~^h1}3;2iOTAvpI7PcW1iIArqH|-IWtV|sUBG}S{^88 zt}B7dX8lI`cfn>%*0)T>W^}{DW>hnLel_!(F_qmgM{$fV%WTP7A*n&LOAr1T)BGOS`u?^a+?jgiyivCAM|YL>5iOg!{2iD{Nc2mQ;FtQ3#+Uf{;~EoeOp?2 zcx~kMI~2~TeY3r#)hei31!sX_Pd+)Vn37IU3HQB@hwf0XobFhq$i|hcob*da$CR|w z&6%cLfIV|X_)^cQB%PwL+S>hq%6yj?b!Q7`v+ldgW4iuAjA|8{_d^r&zzQo@8v7!I zm~xX!^&d6yM*n5Hx6)@yGy9c%-Wf$(BSY>_y^uBB?^8D5MRUsXI=!ixiMm6_Dn(2k z&CJ$T&DuRGI!lhYk6ZTLICTa~wzjxvO_hYn@+#?K`R2`c#MTNT!kFmz=rVBu<6McA z;ah&$x9|S1c1}x1$CTsMg%#S+OLm#AZJ4T?ktHo>I#1l2b>Q$CCYvYY3r>egy_MD9 z+nHd0W}S9m;wq=!vgE#%gT?(ovviD=GdH!HYDbZ9%5m!6MpAYIdn~)dit$5#amt(Z zrI(Lpt#G-lU2*oJbs#Trr(sebr*ZURJBJaxU9Tu|ZkG!6DpDu@a&l{Hi=DEy#L5%*%$B9sRVHH=RJxMwB5&qbvZ_TUrlYL3yjYqwbG^ww% zY}VZ@=8zdcxcHl{KyY-A+Wilm9cs6!Es(XU{eWQ7-pl7c%vS8I)7tipu$0+FP+wyN zxMd~X4*p?cuR5K*$wA*U4Xt3BxONDDusLz5aYUS+0`F`VImK2(WNYs+g`$n{LB* zv`QA}ZMvR+nD%(nMAD0qjzl`^>HRUq?(VvqEWOK&eTRD5Z62&7b!P$OdEVUX%snH| z2CJRl>9Sd~0fA~}ri?XaYF7;R7UPJ!lBQ%A?lfIY^)3huFw;Nhw%EUm73tjRwB(>A z`kM8BS~>WffVDJzu{WJb)9mX?KsV;wrhFfAfYW=tw0nAQr+c#^49!dP#ymqEhEtxu zYmB}c^J{^2v^z7NHVv#3yOD4evqnZkbFdp**H&h3ceYyH-*P6Jn2K`>^&cFZi%@ZF z6Bz3K&CTxI7)P2)JqR`444^W6(_Qh`dK`W()6p6y5GQTjsOp)~l3QRF_ORNr;tB4_ z)izVKCszl|gTYpqSi1PLCWtED|*~BSK zhQj?{yAVF_hOI5I_cbM&nD2YB)OijGt;!-dj$cx3TuYMBSU7{W~u&}!r9 zj@PQbQY1$k1WHBQeZB`iS|uGAGVRR;7oWSPpxi;YZ45It-=$JkBA|PgX*#lVf!Dqt z7wVaciq+^xlC+ujpAE9w@Ye0%_XxekEFOWe$5eTbn2(rGWUMf!tet5$gl6hyrVOEZhvKXy$oQepWmxt{nM>-#?Ce>0qBcN)H^N^SLd_5?&roWn zQU_-dwx)8LqtCUu*b;dvU+L5_b%r9}+`K)MjJtEgzM)oWN|x?@YGRk^{5}HqGBt-q zgPk$6-?s+gU1%8ITUL%5W|i^qiK0r`ybrcyV z&vYMIIXy3$ydPN&eDC*k7Q>6%FFN>r$hsV4R|(P{Iw+h=|y1Tl&7*8z1(VH5j?0jhK%Dd5>26TOxYt5SA6U0w%;6h|G*f;-D;g+x{qUMH8vZObMIp& z%XlkS>YFb6-WNXDR31<7cJF!x20PP4`1$+M)kpW->SmU5#+XIp$!v85w3b+Y_WT=X z?k{5bOqZw%XJ5gEoA6T&*AC?IUy4vhEG(#ifnP|76Zz5McO>GR{ zz)?<5w4HN{%t-qFm~ zPq}?>F;%Bhrp;!6jQwWmR3blSYEC2aOXHjd%l}rcj#Fk7&TD6eCgZ5cJU}i!aJ)M| z#oUZc=$f_}0xV{9ci&7<-NM%tdG`6-&sU$0k2{K-xIM3$w$n(kgNd3!N;8N^lhfyG z(=LBA*Cu~Nw1A_haC5{hHO+LZZD8O8({VaIul@vQ@I@Uh(X?>WsNa~Y&=#rT>X_-% ziQY-fT$;{mc9B`U6AcocPNLkc%g;dOkraH#$hg!SyDod*sPxqO(q!CiqTXih494>i zlW`^um1@#2q(Fu_scya_XDzGwDFfI0Mt&YD2Cvyn7dnjDp})l|BG@sr8Q$&T*{6BxxBXASc7>DO-48{_5%dOG5wmF`#*Gl!fl;8Jbg z>dnoYre&SMxcvl|FkBXy=Cg@=Czo2(#ALXBvB=Az=+joLl4dcC+Q4Y4*&aVO!Hb)NXkLPdBY|<%)Axw!^v= zTgLe2lIQ9selE@2bhcAVKb3D+DqYvVYTFT((;A_t8H@n?vz0QO{+Xv7N6v&$MHcw! zgN-xi^hY7VJ;g4Bv+D7ULO{di(ybQ95<=3KLckqehfTeC#O3Vi)YrS1ne)ht9k6Kn zAf67?cH4|$y%rzN46j;mce{U*RneYD)z=BN4Y^=^=Cn!}FsnxAdL*E`BzgYXRG7~Q z%r@W5n{S=xLm{IVSaHQ)Sm;#Rj>XTfZdL!ee8`uKF0E>xfRD0zJ9GZ!VK2K z+aWV(5nbu>Qm5zrl%?D|*~6+=rOxOUYO%k}UIO_VEOT0P$b;q6?u3WEg8&^2c6e5y(b9efiT&uQQ|6+2jg+!qT803k6@QM@M!&95Rcwl5mfX zY^PL@j%*)}j_xmE!b$b$$d<5=jv~0B>(t03EVaD8R8Ng;;XWpDDVa<4)W{aFPmMy; z+V&NEPz>QzkB)2s`{*cwj|=&hA(!eAku4W%YD=K;GtNx^N%>0!I*gh;+&<>91>7?s z+djxlm2j$OLbiZ?CKM6KDeR?XR(Rys8=brL!OxED7~Y}|%JG3r)l#-tn@qm1I3}_- zn|HpV<7L@whJVGDBj;xGE!UAxzR$7Hx`u==-)3hld5_$w(rL-=C2rqfVsUO~?Co@G zDxFG9?hYW;{g1t_jOcGZWaHy*fND5eHc)N(gOkVKI?0CKKFeTQ|34iGbFQ0v6*IbG+bTfpwoQc3Fk(yafMQJLxxwuMKT6T}vu>IteXkm_KteGZx8YspBe6NoKf&kIrt z^^;txW7(Gb+bl+)bgE<278vA?-tB)S4t!1?2Mk0)fve?@>)7{+M z;pJord#;P%OO-n1HVNx2FQ4e;csew#9f5NM?bz+Z;?R7y`--QXiF^OdY`MRC`a*&&MZ+fK1`gP|{um6{R{^;m%uFSsmY!m)N?f#yB@VIJsS75ify5ZrONznPK z&h%EzRM_~iGrGEa-x)OIs++#qGc>F@hHocwTeMGZc&R4`!v2F7uN{fmc*Y8+I=Bbz zGle%J*}Mr| zos-o-=Xr=&-x%bjEvR4k>b|drXL6o(uruPE5V6YEWMNKjwz9j2K?VH<`~Nyx{f%dj z*2pJ6$l=T~IXID@Y-(t7koL}?(+E&`$ z?G^5k%e1ZNvfYW(sYx?2lhHRfhn(ajQx$TfMRd#&lW>C?m?wUZYGPPi4#oT!t z+p^yg&_X!=XXk;I_~+uoUphZ#w!Iv+1?!r%+bClf1higlH@D%6v9HbRk}U9%xsCwe zj?B1S!x_1J$PK?(f8N*gYP^svciB`#AU?}s=O}to=7td)U%i<d_rfQ@Qd7XJ1|D$e(W-3=LAZSE;Z_r~iv?!jDN|z?b6eyoM~OCI|APbXcHP@9S+I;rL@>TC0vcFfmT0;8 z;P6iTg3uq+JNV?rr+B-<-Zw{i4<-AIG`k7vTY!LCF{1bn^%kt2f*Q^rbf@urPiy~T zVt!^!minIldDYa$Dbjn+*%BREnD@-uQj7kv=Mz0)7vMW*q7Si1h&9(w5w@D~9L8vA z_A91Da}mQgjnGk~IBQO;E?d|1&hCURN9gq?>Xenh&uaz|IOr^L(Z~%jGj|hus>%A3 zRRvGa9xLvlr{ntY(#h;&>HcD_9wCCe=H{ONjNS1x*^XJcOqV^(J5B=0MuB~-fga0} zQy{Z-pH=K((oR{;yo1)usf#-$h%*}wTE$!mHR1s5%)>`he5MSopO0*p_ix9iG(Kxg?gR8Cf6MGQb-3girMG1~+CXka*>(Cz z1|DYcvFe^^X8&ka$>7gko-5|YkEjUSn~DeNm3^-`JM6>hM*a5suu%&bA*`W!{vzPX zKL1jSkEQ)Dk~>rO+{p6Robmr{@tc)OPu)CRI#2F@rILH=qqUHFFPvcZo}j`WiJEWq z=03^Y_Ww96d|#8J2pX$zgEr~LCIo+&+>CBcb{ik##`ha?Ql1Yvmo4A;biskijp}s3 zz`*O~%1I`OJlCD^zrpfsJ=wF(&yJvml-s1!%*w<6+4mnQjK7EcuetM&YBz;PYK?x) zu!-_V`DiywIYit6km@*iIBz5mQUAy);O}uoZaRy!@1~U8a_hbHh3H~>*tbjoXaAoZ z<37<4foxP^nYHOwu8FL@&Av}?T+WZ8%&B8mm7uN&Fieupni9vUp{{21adtqq5rsPX z!SNI_l}ybOR)V`8I(3{QwSVaq|JPaae{b#o>jau=Gp9a9+hZ((k1(g0h#wAb{}keO z(vMBnGmMBQP8u}o+B4MUS(EWs>yZBtv%<=Izj7?_Uw1~F49A~iY^6MBjXENSRw`rZ@h-JC$timSkIKFb=^!3$}eJYs> z=gCfL+B_$ytGHa>G2N6{>s03x zYns}VH#%`%kl`E$EHjODDqtTKPMY^6#uLzBV)e;MT#jQ1%RYYWUF}OKa^+PfVrI=aTwGd^Dh254`Yh zw>MAC$4ApSv-utK#qV02@Y`-pniXnw>63ftrswR8$7sUO=QSVx$>L_ot@M z-#E-SWiDeZH(&hCYH7%2%5ua^zf8c^X8%o$bLI{{d>#9;9Gx?EJ=XBRL|wsQkSTwK z@FPuUUCl8gboHfK`_NC0*ORXTQ4>9N)ryV0m%(FKdc!)qYBkUHwPrXSNszPj8BLF? z+>Y;=jaP~DBXjgB%h$4|$u-Jr=PHNUc#T=i`5BLIX=W#;PS50N@!7Z*927_)P8I`B zn~>{d=Pk4J77^K%Q?$V4tRDLS_Z43K5JZJH-xRgwm5KZUS#4!&nNFL<*O7M&6MKm| zxnl0gskzB{g9v+=>NjYztGtInN5MlX{^+Z=Z@aXh?m0kEc9V1iEn1OKv*!lq(yfi> zCc3ChVP@J*`%+aK7#?OW-K5X1;-~yN-&qv(#;kvaJ}5{$uJ(D9$xFBLy_&cvd? zA~L>LNLv9{=WmxO)U^G>${F}nPLuS96&^H&*A?*ma+1?0IUQa@9FZBqJ@$6ucvM98 z3>C6)Q`V|RyWTwE<{IHVrOcGuB$i4~b)PW1V=DZKzzf8bo%p-gc)LNfm);v{#}ucn zSY6WoHLK=UdnQt<#@<4HymD z^-Y;na9zz$=JP}f4|~{~`{ri!U#x5&_U>TyV!L-on5KWzRBp$1di1|_W)po2@sCW^ zTg3OUj}Uu=@89~ygIiWj_g7}&sTa}iMDxrbYSXFIxD+LgG^K7@#Z8HStnyFj51vcr zz&{kq9f0loPi0BByC|&kE(Y2A%I&okJe~kya@8>Ff7+&c%7Q))c$XV{x|TBFt6> zxepZo%R}h)!|D=_)o-)BIk{xG_p;}gTVLwGPtbg5|2{zv>g%@&CYzV`uz#GO-z@U9 z`y(9`?NsOVB)i*0$vA74dTGj_VrE@>Z>+iR_2%+>*;Bq>@S()ywA6NMvp20bE--g- zW2N)Pl31B^RO*Z7Z5iFo^mN{~zMed^qubt>zpS|w)xDS=Aa;-LoTS|t!+oi)Wl$CI zOMW}6zQZM)emHjJ=FRQ%$FM;y!_O$Vfpe-lHbZjmokd5SyOJ=^2YGWTi;aT3HJo*m zG};8S?*=-QI~qg{q*fp1#SE0vsT+@-h+WvN8HlN!3CZY<^F_xxx5_Hr>gS#JM)~)W z4|Zmn<{63ifa!zfK$DVKWyecg1;>V5ox`F}d{4J^LW zypzqF&-Z6prwUJQ2^ds!QaK*YJCBPNDDI+peKv1y(mR%oQZs0RsrGK=oG=T9_6RGs zw0B*y%L%(WOFxqkO!h{YKEd89{Jv^^uy=%KpQ)OijvDc-8JOK0?rX+Nmb9WMvvK5# z2``=6Ylluge%EQ~uIx10?(!b{xz2XozkKjd)(wiXrV`F|4$pQJ7PAg5&$jeH6HJ<8!L5iI1)7~jzmSA1r=wHpa;FruWEOU=kuI<-uu4y`_}pvwNm}t|7Xu* z?W$^;%N|U<`To>vjy-DqSoh|E*IpGHUi*Ig^V$!a@W^>{!;ja!H?hUHKkP30?dYVd z#_SW)@q6OZvF#Hs`C(~E$$-Kc#dGtfPY>;z7z*th3N4)6JQO+*oiu&sjC{f_P6~xO zV^1qiE6ppK9?F|DbynV-;`|YA5H9NVb$WEVzGW!H-@|eyoX>b&!cSk zGSZPN~ia^X=47h@cC{ z9U2Oe#=^P9(+d@Hu(gjx6+f?dM$c)*q0p@P^X3*#FAhcEs!$#Ar4#2(nUX)JjF!#M z&yV!!9lG|gAmU>-KuNn$s3QTx?Sl+|!B$Og$6xv`Tm>?y?BVz;<9t*Fe78f;pQmA~ z|4T?r8Q)8MwVa>MXdIOQwZP+@LLmyda5bt3%dIX!74ZVAGptUUIrE%C+UA*rph)xzG+597e;{A%ML-UI#&MTZgIaJa;NIf4_F-M{5_^9PaTe}rXpDj#46>ev@ zpuOKkRl4U<#aoLiT$QyiL+RkMg|n*dCo(;DxOG*!h#8Z(VWMOgI)af%PhMtA1#~-qMWZxjgYHSUqiF5KMmj(r){#B2RBfL_H z$3JIC(4#4Ed*?N8Up9AmQ0f_ZGiDY}4u$5-oIZWttWfC2>`+n22cvB+U-0g6e5R(FQID1d(e*PV#}wY3O5whO5X)#K$R`r2Zze_`N@IS zp$fPLRmN9YKF8W)Q1$O{G#R})78I=N^nf3FMxgtl`{I8Es_`*z#-zC=jQs2|0S`ug z%h19T0ZM4Nw<4*7Q|E0=>gsg%b|eiw>h?2(s57VJGq5Q4xM1YOM@9jYM8|6rym9W4 zj`1#XTf5884$`miR=6EfF2-s};{7HD^cZim+rb@TX{NW^?OJvj*=UuYlV3b@`uzOR zAl5InNIz6F;uusTy@YVmnYLK}m>T$>PSG@e2BHU{r8DRGm&f>87%L3?`=VO)dQJ<{ z>4ah@EKKoMwCFIvpQ3o*cup{J@+QrkGlSYKBO5hf(e&x1n4x)7W=cX2P7kUS=gT~& zWi;%RyH*tJ! zuO@sO!j-boU`+{KJTHh>+B02M4$ZfA-u$4{E3FPkTWi!taj0MO38=n11J!C1UkoNq zFPuknht6lnsrXNz3g4V2RJG4Q)f_Wt<@*`33Z7d`gT4n>_?J-i)y))C69z~r} z7Vz<2&*au+Be9h6th~7eY3JmZo@DKQs0x^dD%Z}aDzbxIwd&(PBfiP{my_Ulk~0S)hh zC(oNTotB!T%gIDKXGBF%|KjO+#mu|VU~GjeoH~OHL!pys2ifuV_~*-45lQ9u3WYAe zD#(6HUh&-7q%$XfUUA`+($FJU`@J!6;knlY9q}}(@||lV97Tp|kI6IVrp=l&bJn({ zwxAP_BbiWW(j2zdOtmY#?)wkz^xAbnx3pOnWVZ%eslA4+Ry)F5x_|4kr>t)$6SU1N zYz=}*b0(!7PhsPo?-5(Erq7)0*Z#8QLF(~T3n~Nu#?-G5`eZa*gQT!HZ_b>&($Fw$ zrGGy0>9aEbVy(j8bz^YxyB<~NT!E@niwb8H&dMt;{^wo%#rUhW@2?8N-;Qc@#;4yu z&*S*~j!)ytTU227NAY>O$+lPV+&TGqMJ1t~w+03H7}fB;9aV-Cduxn^LXQ((HHjB& zg|+jvX5pX9Fj56_Zx6zaMAd@vi_gTu8Iuc-nK8Av`i`Img9)IFjzw8<7PdvTxW_Mh z&Am}6J<2|~E1)xG&YG)Y=Qarah@Q+WXVHCd={GWCQWsTc||J}AAz8#2f1MKDhUtazH)m49PK6izT z>gU#~;B;7g5hoHqp>@7L2yE_og-VfD~O|#m#fc%?#lrm-T&o?RYo0I?WrsThKPjZ4kDE@ahCB3Ps)|$zAC23kK zFZFsJ+Wc(H}erp)N1B~DK#TzqzXBWREjrVH& z&M~+%WCFzRUE{+b-tF;Q%J{7zHz@ft=7vIlqXks+qtUKtGppN%g+g7hpGGx4D-(hV za_H;9)W1A280Rak|JiR4P7@#-hh|0>C+N_sH-ik$MUR9}Lp89kun`xNu)1W`+rh+` z+7N^rgm#8^vikd$;Fj}!R9#eSwF>Qsy#OtfV*(EC6i!6hIxL*KH7N0^s4gA@P>sJn zs20DoQB9n~ZTK+S4%<*573|S%LA<$nv+^cR&kuDO9>gp8Fvxg3s__8|?_60q=yqv^{8vdr@MsK28^fp+n`6}>VVYL9&Vs{0qa9{5X zT4MOuL09!f)k5>}Z-w4aMnbxN459!kNLN%X((>D&KwqOOX%(uLxD-{m*DPO$s>Nob z8bc@i5G(=@qbkUe=mBU7sti~B7%U3Ed>>TEnc`Kq>EKNC>e@t{WY0-$UG|S%K_mTF zZ(!dio<`4q@doyvuV6o@k)NjbD`Cf99-p;(;XaPvg46To&mh^*Tx>0$qXI2kI39<(WO9D~tTgt? zj}TF_Wi`4lx(Mxpo{OqWrlTrYe4{%F+r^GQXNj-h@#i*E*x973MVK=1&d{ z@_M#y9eZHEAa;K7tU3Alp-@j!J&06~**_>~7i?9m^#OrhFf|yflV?tvJEw3)e*A*d z(_7lMYgt2z<4>&(s7CO9#MV^$wwdD(mp4(3;9m}M{E_}5w%YeGRCU>YaFF|ZsB(>O z0LxoB{;JsKkf0Q|5nkPJDXL*On101B$@@}bB`|Ad@!Uzp#i96< zrTBBQ_!FkB?Sl9(qZ(*WqZ&~0=VgC-&ZYz>M}rHB7R3;@7&88Z{2@7-0@fE z;MwXQk5;>81_{L<^Hp^V3J`zX7k|$9=jVcdJeZ4LH<<+fh-f(4@fY(dv<+2R_%Mzm zsL{@2f*y-M)adWcZr`uIgq15KTVQW@Diml;1yH{X8Y&HJC|1SJB7=As%JE}wblyG z^z+__9XgcF>mTHugX)^o57kAm9jbw_CnIQxdE9ADDh!2g&k9PDL#nFk$*2~%f!3as zH*3bMw7JFcmnrEs_qM3gi$6lYhkl|KURlT45AN!CaA)t7LH(U0yz)WOaQ~!G=oGIa zJHxr#a|cJ=t=JQZ+R|G(FyhW2*AuXsc`1V;&K+Le;Ap~ouGeBnmUFll8xnQS^ve0X z(yQZhqvsBdhJT>?$9TzuGvtT8f@aC`Du!k_NuE0_8tzBm^-_l6>9~f-3m`@=$t>AsIOs4@KEG-x^&=X@$-^?-k8!Cjsnz}INorSZY$o8+C7bGmL&Fxh*N-@>4P?Sx$O0 zE*<^6>{Bz`99%)!Mr}w>!Z^c^yZL(GI3+M>mkj2rT8SKKiF2`r`xbraZp2cnCaGuL zU$Ksj`#Igc*yyN}R{7Z zCe>w$-YAm073%~)xluzR?ps(v_l!C{J&EZhBjlxwh`1B56m1`G>ClLCqZb5jXu{|1y%srHPB$->6AhomRHcnaW;mW# zmlI9c*}-cuF3auJv9Tk@Mx4vM@^Mk;8Lw_!G~xG-UW@TGsTUg`O?a!5w`6>ld-UNv zi1TZpg3YoPLo+)Pei$p$%g)Y7`2KKj$(dPB53lY_D$Om2O3x@99dT~;V!6@qVD>ws zz3kkKa3!vBayf~fJ0a=}^bB$_Di}xffiuC2ofQo)=}!B2*=J>h zU&b}W%bwW3*--@Z(V4gc&s!VWc3h|UzP*q3BM|jGT*JMJ{Qk{w1QC*$ia{c$;tC?% z)W|-;HNsD%S6bZnLR_o@B!VM|x-VlSNbXcz0b7nMNbVC{6qx*v>Y@DQnu{w4_c*Q~ zToOaw76n(3-gUU}&CPK4;Nov#bAQQ6^I}t?&e>l1l&Je|PZp@4hlXj#?oN$5S9!6i zQTIde@W6**fAsNz74k;qM8Zq3DAkyZ@T<7`c@yx6p8!nPBCCi_Z2M7Q9`arPwE>4Wa(njIU7s0O7KRV8gW-+ zX~hU@8FaT}1@lcy#p>-@4?kHe;jxbhIpdf@M>UMcj@oXhFeJSX9P(u{lxJ zgYr4d<`caa=46F?4+w=OsJEPJy;yNHVaot-NpV(q^gzuruzN2qt(Jasg@45IFQ4HP zPx7yGbo+%~-Q1}2tmn>)Cj5MoH+)`}GuSJi7bV5IdC~CuG+J=QJ<5yCk2*`e^7&DB z2aAG=$wSlG5vPmio*NBcOg>!X&do@8b%?j*+^leip`p;(ekm$(X*q80r7-xu!TOtD z=mB1NNi^Z@nD;_SmYYSlY6v8GDH9^$i?A4JV=@vp5BHXoW;w0Ay3%NPeRe1`-m5Ro z2p=>e6w2{k({Y{dyEftqd=DEL3Z3S&nYeO&*GsqxeOI4Td7SUNmgCCvUAu7wz7t2q zV{E`R)%QK}Z#=^CU02{b%XfXz=!%^dk8u~Spj5x(3c^jGdjqx(*BHfh_Ve7bXgGGd z*2RjFjPQC~XZWr@G?edhufr8w(KILCz|t_{ayTtQ{PKmm8r3a~hHo6B_N-^9?ZibF zYfcfP$JkJaQLGUdN_u{Dg3XFA3X%32nTjVfB)a336TpD*Q7L3Wu zu+%YJok;a@EKM>}Wy*bv#R`J8nPs52S9eL25#?SQbsriRZ`F>F_j)={@D7F^Mr_d&zbT1j2a(tzsil5W9h<()6pxxEb2`2 z>iE3VV^X;vOb8~t<6p~OWJ2%#Qg^rb4x8VKH`k> z>J~>6?l{|fVQ^OXy|c9tXze^KPm?Hnb^m5K(!KgU{gZHT)zEaK;hw-!rD=k5BH^$7 z(Aq!_nb_E|AEYF_YrSj5Xhld!ZuB)mOTv1m0ZP z>#+KJ$z>UANz1Q_y2J4rgzrB7(7V-huhxbzc6HR9R>08n<1xg-k7H4lg8rU+P1K!G z81Enz?OLn>UiR1w_jO#V0E3ImKy+HL!)W1m)1_Fd4hzMb=}8z0*v#8}X(ap)tX^LI zzzpYD&%HLvBdQpmtG)7Tqi$$=u*&&;r*Y*)-BMW4GHRy{SV31Ty*A>0gB6T2jjMJ= zL3gnHkBKi!PXl1O7QNza)du-bAwS7?CCGU8jc?=#KrhUtRY_V zxC}R?Bxra4e#(tusi%|tR=pHUmG?JaZXH%ID%pU94=&YZikak|jVrivGg{r7u+;xt z%&?R{~)zPe7%!cy6p8C(-rV+Bk6rT%4kVMh3VTx_mzwOFLxSMuHco8e%m1KNaZ zqVGEDvQX$u-*p)-Hfi{Nf{W`tuJp^}$z6r(Z(eqF|7JMYdCPOL4dz~nYY>HDJ*0U) z!BS%dOaI~JL9=qLp|M6|oq!)Rh#9&I>r|{@AsDuVJsg(bRpINfSc!-CPr?z5FpZXW zR|I2#5ylvugr(N@dntUoEdTk%$GGfBW*5QwbYIjx`O2W5$ePvk5-hb}Fk5P^Uoawn z!s>(HetvbvR|LJo_+%?zg{4tKDpd6qET!U)gs^)Rx$3Emn~BSgEb=l~!AMQ1iny;U z6mcjsD}M8<13#LM*>nk*zH%gY|xzZnkyQIz`xuH%SLqiSD)mF{IP<`m}X_4>;SnU3IlG5Y)V3;w}xE>aH zu^R4nU@9`#Nb1;trI8cN;a(~+4olJeWhT4_tDnDEMwSQTn$G5~ zmHWoW*nq)QQvbo1li=0C=2r&c=ol`JkN#oNrN3cmHjoCZfBzfk1V8O7aRr^JMdT?g zmB%qD{-dtis5|P$;F8qB+swN7IM(QRw&4SA(yGAi8FyRdPek3d*y>G25p!e*mPViJ z@2ERh1?|TK+?Jk%A+JPTq@5?dx(!h`;pTvG5uo%d|F9U~A7ZJtn%U_|w>0gf94r;t zub8_MOVxD!>-MKuS`g@ZW@-0Z13xxqVPYw=tYf|UOETPda4By%ZFA^tK`2VHh$RLq@KeKHho$*Sg-ap{uioY@ zc_zy(yggoY4gN20_gXxg74C6|u8~~y!?SU*Pv%ag23N555HRfAN!iruZYHiX{QQ@a zzlWs}%1t{vz+G4wdf*fud6#-g+lvQq9ZLuX`(+XL2P}1cu-^Ay86UHo>AP82>PZ$s zI%8F%pX%}+mhxezNyxNSjn{5EDi>(NMzIO-x8{8WW$5Nn> zx0KD#6IfbHDKq=IAF#$ZS|jcWwo`%MN~}Np64r!5r!|H=9V@rddH}1i(Q11y_r{IZ z1z3NCY{1HI^vk-Bd)-E>>QB}`u>OdbbAQv2>#=eg zKJEcENyW7p?sQz54y*{=x~=xy%~AJLm)N8<{npvJ>hSgHe4jkWD9tdp=9tS3jDeqP<1 zQTL|x!9qp%4~QguyWSiAR+gScy%lwDt%>jERK$<073>v{cr?g{RpDx$En`JUm_%NW zxXZ8xHChc=ngxMhhsWZLs*qE${s?(@Q@`*JSUgPJ+CS;>CV|2Wu$ZK8`&dmKq?=OP zG~FDm5siK;u^K~$f4~aTeWFQ}@I_dI{B)aQH+e&oxNZ*CA9c9}%g!9?vI8p^E~;72 zC!53zFTnB(%@Y2?+tFqlRRk6+XQWrRHOixO_x-5*BUDRYut7fZDP5Pfc#XvsWV4y8 z{B7cdVk2~OdJ={PpTEL{`#!CetYSolI~P|!cyJ}X7fVrsJ?;nAFSw8&`b@CMu--CU z&cMnfr2i=1^{}+=F++MhpY_ORUf*~NQ7AqJ6SYh`CEU$h?|0Fs5VhXSDaq_4A zofo{}pGKR#NV|Ke&y zIX>J}ywhZP$zSwOYKq-$O|5X3S2T>XKkMHtaBT0NBu8WEzx$Kb=hdcurC7Qq(M>$f zc@isV^W!#$LNol^f8OTz!+cz0z4|ZvH^bq-R|xlb-LLqxjPNbE&d^8? z|AdSC5FSci@P=F@;=X}v1R1iVa8q>nn|`dJ8R2cX=IBvwc+^{d);x*c;JM#K!wGN4 zQyGUVQx9&!&*6&tu8Ic#!7A)WTs(lqb^1GT*CV)ibP8+ruK!>a*PKSzE4X;b3p?sP zxw60Q-wX#&SV8aO;t4BHiORk5@1yQ(TZ4xP4DZDe_n`OV7Z2?gMqz13z#PIV$I@u= zx0>P2SbjR;Zrl8)%cQ*kmtPV01za)0GEd(k)enLx#5`da7Q-6gr?d2#h`ap5;EKw9 z2bS{@&z&3nCjZHL9xJcWuiMALbux%|`Jb%MuyPwip7cp@b@xMtZ^oh}e(IluV@y1d zGs3I;IqH7=8AHKmqu4TB^*J}KzNIV9*H{y&Tu#5(&%Fk&-=-wo$(Y>p7Ra6U{SJ_cn$Rn^c${>AYtxp&iq;f zuj2a*_eNa7y2(lz-h#!vUzFi?{3d<{RShR%QL#lC&I4ZTw`jP-w@NqrH*WdA^QXzK zjPTjG{^rMh0N1HOUNmIF_wgMp8#rI#+^qvR|5|brI)j6H9QS;WmHw z+j3Widl4@62K`J{Td{&AQgYN%9|0Sv=MUobE=ISCg;O;GptQ=*kS(Q93MEh;LHh}J%3?-17j=-s8?GwrZ6(fm8Fgx?&q zq@|M;9-iQEkHiMoxzf1%I#K5d6GPlRaII0?81M|YPhyZu3;*tK7M8{Xj}f?qSZyty zTCfTJ1dC@*gV?y3x?~b+;A?jM2~59pr%_nJ^wE2*E3IF!mwN##$d?+sA--*?rIh_T zE5i^h`33JBE|BHl+B}X+MF@6^yRg(|j0E0p^x=D%T50@85$+{e8YP5e|Nl0YdW9Fa z^h!s*u_-?$HBWWMVV&w*D)DM8wN0=y{Rpd<@5id<9?W+))qm8K!88_2A^jG1t}*4Q zjJKUY6$!{F>U3ZbglbU594uuNjIe5~GqL>pvT&HMeU9`WHIBx0I-CZ)Gu_16lIS6P z=W~=Flo6jW^fuZ5Lr_vT-lXx0W_xorFaeBO~FKhqDlQ74tLP1-LY9 z{qHdFZ6=yWoVvxjk@QEM&5~{;oppp08tVrm`|$NxCwlc%M*@vLPPz^szST4PHVJIKF0CCF~&_Re%D~- z#{I&(v3T~uBj&zo!H{49;cZ0`mZo3u%y=c1mb_r1ZpG3ZYUw{GJvOLauqKVf3R+Tg z_;RdZEz|0?5lb~^P2hWl7+Oh!GL7!h)OsIlaNN&1&Xo6}3C=l=MW_)zYTV-)5POa@ z!;fb`#P}v}uw2s-PMN90(|raQ%#)2|@4Bev2aR9IP=^Y=4vDu#?8p)DDK*@qBsMUU;|| zlwxUJ3og;GVFf#LZDq{wpbGmT~*j(oX^v*)b{o z93Bz#W#qH|Fu%tf|A(2K>4f~7svz*am>P~YZp$nuxE=F-of)PslMEh!`la)q#C?OM zj%9+fU+x}_uduqR6=BKGzwryN#NrD9JZj&DE0~1Z`lOr~Ok7raF2LDX>H!v4wrO`@ zak09V?@*p-hM(y4OCdd_8EVE!i-jChaiYV8$5bI@08MuPXsU@2MlqXE|31uDW~hHi z_vbu=Gl6p~rw*y&(OiCbuEGBCZUp!WRDfGKb==A+ z`!=h0pgR6aRgil*Rp19XmBB-tI;6^A9jEwuYd?nSkSgAjsokScsVr{cfNspGFy1=;NT2C8_kb1L2&{`ioCQ-?H(^F0|HQU%;9gQKacKp%3- z|07Nv(nC0RajJ%UICV5t708c-l*yq4xE1pcX(}%EPyX?LS z*?LITH*8A$qp7ON*em#lRN>eI_(xL}Vh`XCKCZO>@mH$&Tv+`l2Z6%^^*t96ze~6@ z_(xN9%%_8$uIAt&P8)ydu)O+(8Gx#mgVE;bDK@TDg&%44ZACsQn!$~o2A zQl)zis&r;pjs`=w3BdbL0sbGh5}HE-s{LH6^KF7s@e*q{RiRSre?F>$T@XYIPB+j4l_nD!UweUvwF&mbn8}Mk{T&rm7iMTV_3K|3A{Q|4PQH$&(aR z<7*SDu6W57ys3)63YXVr>o1k_HEUZf`xDS|{GJWaRE4%$E>)L&WbOZs9V4 zu05(6c2YSIo8v+r;xsZ8}b#irR*m4CM7(qY)kP$6S^Q&oL#w7jV*{hKXss!IQs1UtKN zg!Td4i7KIL8{w~13EpGF-EYG+RmFb@u5jzD|6i#Z^YH{byC1g!r7Br1Dzw3J>3-Pt zmcN23+^beMqdKI@_zi0}Rq?kiZ`3k?O7fod_;;$BePF{$HFS2OD#+KCOVyG;qFSJT zwY;gSAiu#Cuk3dl;J>0uAb}5Qm=7hikJaXa98x)3Sl-g|f6}thKLOuX*!}o85bbCq zO6BZq?Y~la9m$6Z)YXQQ%I;>hyX8_>|7Nq71x?j4Y1!;ri5BW@z5hz(72!i|*w==W z9)KOQ_J2dCk<9;DLRyXfW()A2D7*9#PNYmDXPk}p?^Gl9EW)YYd8pcVqSZ+@zEuAC zmQS%|4xpBhs`$ALCRK}mjjBRFTHaKZ-%plHW&hpk&z4Kokh?Aa z#p7S@iB|TKcw>b-P%o61x@551wB~C|A^WP*ncHK<=uu474%SBkiSy-A7=ff zYN>Ws+oP&uC$&F+p)P!=$&Z%tFH{Mp+3-D4CD;qqZn2-WGf*8;`S-V)X}MH93snKi z23U{3QqATe__NA~a&5T(VJ-W=mGEDts}?+qib(TN|MqC2tyxo5lTPyka8qUd|DCEy zrxQ+tbAb)tRJHHE-14UC-ySWt9#Zwm6{r%t(sHS~VkxSUduU7aW>m9$HL81|8dMJj zUPE>KC+d&?|0Ljln&5viTrmIsT>;b*uTvvc>TN#M0u5H*Mb%>OqiTsSP<7q+D1V{9 z^P%`ZqiUgjXhyYMikj0`^4GL$4N$_VR@>SDQuSRIR0(!Rl~Et-|KCi8>FftTQ~_}8EbS`V+a5u{q*Z$KTBJHfsNtin|I+iftZ^1Z{_O;zdN1=r=`K~%{- zX2VHUz9&(|--s&w)7HPK%Kw?>_68n@AK_UW;dyIsvRa3#VP3NSFQYn|sxp2Zu8K6+ z@b96@XsZn`mHj@d@E>b($nOj5@udt7sWSZ9+EUf@@2Gn07gX1;eSiw*qN-_2tH}z? zAytO^S-Yt!`~ki3P)S>L~6?91VzO`0MG=1H!6|m{sYWd6F`__8zTWh_&)|F@P zTWh_&)=b&^*19(XLi@$0Z>uqVTNmKHZ>=?BIGVn_*0yr*Tk8+((AoRey6M|%jg7r; zt!V{4c=%7=Rujw*xA(2}-nZ5~k=gs!S`&ij`%EESdiK7x-uu@2fAf}m?^|o0w`x1j zvG=X@U%kE7PJZuO>%DKS_rA5>`__8zTkGJx(B8M!{sr?-Z?CoC-22wL7jF=C8{hP8 zwc1LyUSaNiYrXfaHA6wi|DWDkchT%``nFmXl&#%k<6G->r3;;}nwi=~&dK~sCigOD zj7hr8Nj6270a8qjzgE(g>Lv@)rS0c{oo7Ayv|Hgy7<1k%a@siw3X zP*M)qD$v$+Ujpd11W>UA(9SdnY!S%30?@&fUjbNr1z@K@C)4*zK>C${sw)AV%?^R> z0@)RSE~c^qu)G4WN1&^TT?H6+6=2O(fbM3uz%GH@s{u!w>Z<{(t_CDu14uJD*8s*| z1E>|~VceyFq@{qOrGTEMMqvF?XFt>aT4%U(yeYgEP;f0F>aQh4ACu|<+IWBk9w1`s z1U3nzT?goEO0NTyTnE@HkYTzn19V#ks8|NbGz|h<1TwD&L{0hifW_AXb_xtIeGMSp z0ICe&B(pR$!)aR|1k&0*Y1wW}6y;^#biy0g6rGDnP+1K)t{`lUfaE zQw>;94LH}-0h|)kX*E)6N)c1C8t<*E@jl;lzZ-smxk$3WG)OKqJ?}xvOu1yC`A~9^ z>AMEG*esP?Vs=O_H3RNN7MV)PW#&7{jgn<1^&wUCAyxDtGBKu3V3R=F z!+=Ut`mpNyu<9ysqv`$#pxYyWibnudra@qfK;}BYEv9@OVDUP@PJtDs?|MM`dO+2B z!0l#-z;=P`8o-^VvIelc2Czq9rHMTX81^V&&7**7vs+-7K<;CJyG`|DfK`tHk{<`G zF*%O|#y$?H6}Zp1wSc5rKv6AVt*H@MFVOA@z=Njn2|&RUfO>(4P3i_fn+<>k8vyG} zoxmo6v?l>Iru0cb$&-Ms0*{&Q8v)%m0xC8FYE6T{7Jv!?PH!18ARdjy_0v1b9po&~IV7Eot)3+xieeGc%VseTTy z>N!C2^MHDj^E_be^MG1`SB$#}khBRXjcb#-4xaV3hDs$0&kks7XWQu z04#U`@V2QF*d&nlBA~&Pz6dCJ5wKO@UDN#~K)06w6)yp{ng)R_0-5!IZKk{)u(%$u zQ{Y3>_hmr(%Ydqv0Uw(k0^0?$Ujck-DqjICe+95d;BymumGQgXtdQ(5yCq+kQJax3 zO||4J@a{Tl@S$)vss z|GSwj`PtM-c9~9ZA-hc}VoKg3@Yc5o{Hy8yHlW+vfQq*PdrX7C7J_8}nElzs>(`4F&Gpsnft5un>gfQpX* z?M#Ef7JF$AHBj19l2@GJQV*q<;da`UKF~>=4*4ko_s3i>dq+u>4cN9)YeV z_8DNnwgbj)2h7BJXUehXOs zEnttpP!szOFzh?Pn(qKHvs+-7K<@W|lTG#afK}fEl79eXo17m2V}AhD3XC-FkAS2f z0YyIoMwuFc^#bjF0-R6_xqATTnCd-%ReJ!*zX6I&&ToLRzX56m zW*YZ*K+^AkqTd0tO^v{Mz_NA;>>-vFCX88DkdVBr9&4USbrNU`CxNzb0Oy)IflUHw z34l^lngA$C0BjXF-*is|bV~$OBmx$g27xUCnau!Y{9-9!aWlY9fs0JvFd#h)s0srv zF*^jd3uNyDSY#^q0W9AKut(r>6Kf6_)*P^=IiTF^7T6__n*_MRR3`yeB>|FMK!wS1 z0b^Z2t-#gBZ2?GX0Vrw#SZZnn)(f<23Ghr|OF%(OK)t{+le#aU&AxyI`vQ!q6WAn> zmJFygrOAMjWWZK|8%_890NwTjRO|<+G7SP-1Tyyr++xc22Q1znuv1`#>3cxJ80R*# zRC2r7A-Tg0NI~v2m55oMLf}0q1YT)k2Lgs22v~C1&09Y1s*o3tpROX0~WLftTS~2 zn*`F@0BTHW8$d}Lz*d3BO!rhkw^TqyDxlUh2y79^JQT3OlphLMd?;Y2z(&)zEg-!u zpsFq4X|qFMyFm6~fM-qRVSwd_0rm(yZ({8L!`cDXXdu^_-2%G=a@zx5G}Y|^tJ(vS zI{@lUP6xo)4uD#LSB%>ckkk=S)Df`R)CjB>Xx9nwx+&}gDCh*J7kJa89u8=8IAFoy zfVWMZz$SsT&VU9}+8I#N8L(C0UDN#tK(`|R6-NNJng)R_0-0R^+xYE8z~U}|odO@4 zzDEMmj|5a53HaFT5ZEq|-4*busq6|^-W9M%;B$UR5iqP9U`;o`4zpWemq2cJz?Y`F zJ786JK=M(5ohIifz}TYzwF2K5_h>-U(SV|(0pFP#f%O9Ijsg5&3XcI490RBq_{pTE z0otSi7Nh}wHgy7<1k#QL>^7yx0!oerY!&#`bngM^)&o${1F*+52y79^JT8IV2EWsL zTmrj|;~+aB+HLeCNP15|RZl>o*&(o9AiEbJY$|&JmiGee5om5=#{-5P4_I?Nz%{!C zb_wM62DCKQy#cFw1Csjyl1)w@z}P;3T7mtIdjcTo1VGUVfD}_BuwI~D1aOcki~tHE zfO>&eCN&+kCLVrF{VIt@blno>zWvssd1x(`PBn~NlwrU5Zq29r?c5E6=-@*#l5 zLjXGk2AIA>0qH{lRYL(MnH>V#1+s?$2Aj%ZfaSvgdjy7>SPU>M23QjV#LRAiT>`no z0VkX4;eb`c0m&x=vQ5s(fUze7Y6V6b_Y^?VDS)C=0HaKez<3XC`1Po)*kG#5#7O@m~D={X8H%alvb zHXlmzOy9pD6U|b|B(p;@*$g-h$v2gfDds!LR1+JG6qpr~LbF>k&5Sx7Imc8>rW@xB zq{!q*W|*~-nZ_N1%rbeB*``J^$E1u!icKM63dU0K`mq#zo=MG7U2^~nascO=I)P0B zY2yH;rgWU@Iu5W^;C$14JfPcnK*e~#0@EO{MIiG`K$$5&6R`M9z)pdSOy68UdM=t;Bpf?3oz^~z?!oF`^lR6oGy_qdBrcScl zbjn96O{wGtvsrSZ={^Oy$y_9 z0<1MP0_z3Z%>X=T3TFTcW&r919yX~n0c~ai7R&^!Gj#%+1kz>!YE0=YK*=n?R)NP% z_t}7MvjG*e0kx(9z|Xe!oP%sI<%n542k)J8@ZM%1OC?X69g=6vfVs%Crc&~p z`A+h@iOnPGuz5sXGmofsX1BmDf!z6k7ftni0={INbCG(JBYD}ZmAqoy5&|cc5V)v> zz?)4Czb7Xs2R1XNuJ_}J_a*e;M=2KdxemI0QR z0rm)dZej}o!xjS8EClQ@y9IU$^N3+xiey%NyUR9^{LbtNFV0+4KSDga|E0JQ@9 z8}}+e(p7+>s{kpc2H+fMQm#f0GKGjKxEk;JtMP7SQm=s@VrENPn>tAw(`hM^YDy)C zn$427ru(%-?RG6uE3PGKJJSGg+MAvp(!rEVI+_n9olM{B2%LT$fvc_~aA&gv;2dEF zEJM1OO39JtJ4sg)yPm+qt|#!C>j~W5>=xK1kZS-(n`#4CWdO;`0cj>@IbiH^K&?Oz z<5mKaDgi~6fS#sCV7)-Q8vw_f!W#euHvsAd`k2%k0c~ysEVvO6F?9l)1k!E-^fje7 z0ZMKHY!%2b-KzlIssI&LfK1aMutgyAW96!s#^djnH>V# z1+s4i3^tXw0+!zj*ds91#8v=?tpKc90f?F10=ooqZv&ics&50Vx($$gJ0RQS+zuFf zJD^rzq;c;6B;5fhx&tuE)CjB>Xm=;zG*fsdpx{nGy};=v^)5i0y8sLB0*ol1FBX7 zCYc=q+Xb@k2IQN{y8+AZ2J8`-YGU^QhTQ{La}S`<>=xK1kh=zOj;US)ShWU_d@rEL zxCD7p_Y+tdiG7if1spx6}N4=A`FP%kjgq^_5h&Nls*6`c>u6g;C$2lK|r?$0TmAd7MKQsEdrSj0m@AILx9B(0d@*pWcoe~ zNPiem^)TQPvqNCJK=vbmMW*r*!16}`dju{wv2}o9>i}!k0m{v8fn5T*>j77o>h*wC z>jB9%fC`gS0~lKas1>-{xQ_yo9t9LV3Rr4t1l9|*dko;2!p8swj{)ihmYLMY0c{=! zEO;DXOr5|cfwWpcr75iil+*&Y3fySAKLP0W1fb#xK$U3_*dma*0dR{c-vC&=0kBhG zh3WewApJ=|)sukR%?^R>0@)h@cbdwLfaMzjdjwXR*i(REPXX3E1*kT=1$GJKJ`K3r zR6h+^^)w*)8NeEo^9*3@Gk{uw`;7Z6An92^(X)WHrbb}BK)dGv51PW~00qwh>IEJ) zsm}x2JP%m#JYb!v6WAn>wh2&UN;d&YHUYK@JZ8Gr0lL)zD(V2Wra@qfK;{d84W|4B zz~UDGI|VkHzApmOUj$UW2zc7;5ZEq|{Sx3=Q~45L`AdL30?(URJz!WpU`;)s&g>T0 zC6N0v;6+pYGGNuqfaF&I^(N;Pz}Qy+wF0jg_f*YzHY?w+Z%w2Hvn5rgTNMn z%r^nsO!=FD#cu+33Vdk#z6D5s3sChI;A68xV7ox}+kj6^<=cSeZv*xSd~RY}0K>Kb z)@%XnFuMhI3FI~azBJVhfK?5E!vyIiVxn<4qm)JVQJDeoaa zm_o^qW~1aMle(3tZMG71!B(RFZ0ZCy38cLb*lkMR2b8=I*edX=>AnrnZ5yCs8(@!V z5ZD4(m-#`$2FD!mQNq65OSStbVMFrQ{HO%~YiAr?dJaD5}UDuN!yv= zIepe`+?nvA!-Z_r(TP1&7Vf$?enUR9qx>^wv-Y=yq2|?n5;p}&ef@31KHt=tSP?qpViC*-h zUGsCo*>WV$FPu?QNS(Hf4~1H;BUx^H;Ohv`(FuPs8LG@rGxvX#&}`klyAy^v><6gE zV+!WZn=!X^R{rErhH3X}Lf7yswCVv$v-3k*i0c?Y`>Ud6Y|x?>qaCb_DWK;5xsB&GG{ij|bPvXyh|F(K57Su-rG{1P_yu#^|L;j$9+YS2ucyrjkj03+I%mMY4S>GXXI-4qL zROT1q)h>h>xNdF7#I9lfTN`wY&rkV=*HuYT-$J>F* zZXmN8W5geEJ=E-@{VCUU+^gDc|LA{b@H$32qy8AvE-f)5?9V9Gc$>0k*&c9gVn*__ z{B*hI;LGT~e@xY)dBt<{CKco#V^Vrh?uYqrEBsMf?5tyA-STvcO+V;6dZExKu37S9 zV${6!W8$XFqkY5(!B1aXVl0Gyw%^rCv`jw^694g#W|rxnzFK7cJZ0vV&o( z12!RafQ7Aq@vk7Jz?7MO@F3krZUs{T^`lz)i6I@WZCqwr=rhaOSf(H2+hI2TojQJH zy$-crZSmS=SzF5vgEb>7;lnIzhdcgjSM4lo4=b^8+gsKFHpj9KmUV;;gYkv3Y5wm3 z!$X}oBQ~J^6{Q;Ja8CVno{l3d>x_GeJUP0+`15~K=_8cEw*oJ zhW$OKij--?9jpI{q|^q?vakpC9@~#m%Z`KjzweKpXcUSTCd~r z8fMu^mi2}Ww`>rMKmG4Np_45e3Q&Dc;5@~$nBtn0-H8VkDR?^Psd#DhWy=fg%Q?!j z5jF|^T#bIsUL*2U%QA2;w7sN%`YC6B&d$Wuh&;`*OxzvJQnD=5Z=vZp9WVYuTn$4V z^g|~cV=X%ow|+CRGddooo*Tfq+YXYmV49HwIe)d`X2Mj{lQ@63Y&J|q8pP=Yd6b3b z0QvL(`QqGdb6W8w&f#vh!@XVX*O}d<1%dWii}mT6Q5!8FQ5mon@JR z`pgM24o(K1ZQ(+o(mq8etSfq{jhu~pu-W`eV(T&u<;%b-<4Df)2&$pH*s@b`>pyqU zP}UD5tBpr-wuNcfUSq@k4fma#IIKkHv7D}Mn z%Cx~IHWRmgd-O!~J(vnIi}PU1KK2s}evGM=WuI6!hj99z1ay3ASuyTYVFS?5VDg@; zA6Xk^18%o)9v-LJfM3|i^I@ZHxGya`7j}kaU%~hbm2i%=>>JCpy5`vQzPEADgH86s zm4!k-Sa?3L&<51cNULSoYKG3SY!^&%7I4n-BlCM=F#i1i@Mx}O@n41}>Ck-3ez)Nk z!WLMTKnDJg>Rtp~2vpPPXQK~7FXmii*+xrDPq4i5dQv&O-toL5=a4yLYN#JSWm z{d%*UmvLT;LwR(x;V#ErrvIH}AdXHzWxSZvSk@b++Ld$a_h<*95gU04?&g-I!?cWC z!MVR>@n3De5~g1P9*hpKaVv0N7qH=>fj070z$)B2hM~&%YRaU|LY17#L`&-g8}J6)Z*qo~H8kt&JhN<1vy3uL(9<|4 z+c3A{ZowSYk#E@w+~+U>wK7hDscCNG)b9&waVvzWz_)Yiw+C}k{RFhw9h`Zv322dJ zcjBH5%hUXuY2jT!{et2|bhc$HajR9PqQ#c2!mU;jn+L-gs{UWKy$5&|#rwaV6E^gg z5CY+Z5{f7#B!tkbfJiS=1f*B7(0fThnjj(y3`hqlQl)okg3^nkA_|0}ARSaXy!U5! z4+$9j{;unPy}7uV*_mg0*_mhd?1E}VZhc(0#n@FVa_fs5|NNGK-aW0qfwmljWGNSl zyffw?O!+SZMI^U4OeL}$RC-N)Lorpl70?;G{yuQ!zQV2=k=$@sZY6fLg8oLja;vaM z+p9k;degP4bu|PK^LvaQguJcNty516=II z(a-F#m`3Ec&-FTleI8yTF!#G|hp~U*x*c%cj^Jh#1%C%!x1-qA{F*Hf;U@26pq7@~ z5m)Ye>}qMb9knPH?)InK)Wn)rk7KHjY5G{=x?RK!<@!fZvrA4(SL({|Cs4wgYAxK9 z@Xw%x<)-NztKSJw!gAAePBebMs9I-Y`eO;ei(7KcUty8!7KED;{tdbi z;is5d7bxLVpt*1ore0sJginJe>nWI7aZ|!)K$G`{M=r6nLb{4yuKz})0 zw{zI-k%y^Gf)Y6|w-kPUb1-wcZWpk3bKSySx8HG71{Y%H#w}KX{{ZE1F=iedmBWjm z9Lg=f8~Bp^RdoJd#!V&u6As|E9J7EccNu#ZB3Xf1(3QJ_{X66|XBBqkuBuzNMo^Ph zgzI<>yI#koNvnwKb{)HNtvM#rb-RIGnby=1g_|;c6O?JW=`HYB?Tx7KOA^Ihx7)a9 zQ2@1n3D@y2F0ZuvG&x0>#AaUS1&m{r}t53n!B?GWZ`uG>TGss#--y;@$Gc?7Bj4Xf92 zQ)V86YC&$bT)8LM6;`9Aw(F({>}7<8Mjbb>2S){#TLV|npKA>tsy42 zMy{O2b!*qHu^V3i*BaUyLCtW}&`yqmCam(+zjkn#Jpkc*PJy^X~@3-et zUr)CaRwtX_MwkINFB!ar+h?v^M%=Es5l(d70&%-(!c#N3s_Uq`Om@Ba7PtMOV93)j%`duvxLIN6;8${AS@umij-8~TDYbIncDbXJpCDdeg_4Tyo_ zAl+L@C8%2>dhfp8@c1WnQk@Z018TxAT%Uv=Kngd$J?+P5r2S%`IQ)go-|!FIfxB=I z?!y7X=vCNzU@c68Ngz#Jb&&Q=+O}GHe|Bvwb)W$>ghntL;z*g$673t%BEg2f<}g0ydg zfk$!tGg@t{x2GBPK?vRYJ&+76=#KmMa2!s6-Xo$Rt0Af(IRiASYZBL_{fb$Z(JB&q zo7UGiOLVW+efTcdQrg`I57)`yH1;!a7XE;rL0?OKMRf#WkR5bW`2yy{LRbV}f^O48 zpdV-}@iugU#+qW8nVZh*SQ>yEBFcqu3i7f4QOC8?1v znQ=i@me`wEZ^K{kH{5}{a1ZXoLwE#_LAMVkAR($h2o>1LXM^fArhEocR8 zpdGY_PS6>;zwHfaP+AwQ_ zto?9XXa^mj6KKDyy=-}?02LvUM5CZ6L_=BJq;vAa^nf;VKZCS;zrZONldK4dWBCZi zfp%@GPhB+Q=<&0fNfwa%s3EDDg&oc!k!DP^_SPS;UpvBt`*a=(V zL(n$mU3d?=LO0O+_Vo__@`PvK0#r=qXL@F~!ed8MC@s44LOy84 zbqt9Yhtg06%0mUH2x0hZy(?Xs*0`4lBkiKJiCW&q!+Q7vv}9cXQ$fn+=^zF3K{y15 zrRA3TTkF^qkP?0*kuPB(OoQpLpOS5a+3+!Z0@@dS1`}a2^np% zp8qh5(&h(=X9CoPdY}WP#-NSZFwoai@^GCK^r4g3H2l|tgf@_fKB=P@7kz=?d{_YG zxz?w(?!W_h0u6C*0&jvA?$W_%vECZ=`s)#(gO%D)6<&jCP!n{DQVCv#2z`r9>qIYT zfw};)P~wVQmxOQ0TyIRBSm=bJFN}mypqkV7LUanDQ-{_t5ZOWCGlyeW;ly?dsFQaqs~Q2Wgl;0%?XP5wWya zDo}FbO94{men!nrgyZ;07kd&8s9mIl)#fQT)Pt_j2HJv_+U-H^!YAh|p_(G8X7I`V zKqlUSY}9IH5`PK#{E!#AP-C(0B&s$!c7$QL4TUz4xLVtB-5%P4F9Z9jPJR1w5ByiZ zkYZ9=PAMb%!vH{4o7&Mta`Bo8&1N-^ElE6usYU4yQ$R{c1*+A_pql-!bV8}=EKrUp zrQVAHsr016lgdsSx}Qix8n`ncZCX$02L=4m;J=Nf5j2J-@Fs+lfqakwdk{#AmIl(n zSGcbPY08DV{9uZg>pbM+pYQH`u<-%mu}~KP%h#y zhr#e|IErE7?6s+9K(O?de$Cw(fL?7YV;Y(F=hl7GI0X-iCUvMsZ&h zPWqEjxPj~~xDA`(`8N16Sto8Jc#HyRM_Pj-UhyB1zbMiFs=fbR_r7}5C$fC@Xmay1 zsK`DyrT#b9I#5-Smct5Y13np_`J$Y<{%QPv&P65hSEZ7zhtbO1?>rG{rJ)6!c4%7J z{sx*X)n5;T7H(Q{D`VOyt%oTv7W#k|yUM)QU4fu=*ejsIq=&Sic{3HHfaDO6j066E zNbqmC0Gcv4gC^F~WK2D#B;+e`nHlr9@RiX5>`N& z5|k-_xhXMSE5Yv21+=&C3{v`NZ&nYUB>HQ>bwkj${|%@QaubczmE`FYYV6v5f{haS zH^r{)c{6AS9ict6aIafqwt|+>7Ft6a*OYq)*UnhB{aRzjY7?Z*knTR(7)eN*BrSqe zy4IioDxG9n!&W#2zrrTi05jnPd=JN9J$wlZAO*~YUN8}Qf?Gkxo5xBQuDwWakf4O* zIRLXC^o9QBUOuZ`>_Dt@z`$gf(707cntbw&4K?LlIQ3ib#vKhNFD>qe?5)XxqAV0awEdaKF zGARF}unmrY{3Ul-ebranic}FCf`hOhzJ`6U7k0vSP$WCxJNOp%z%JMg-+<(l*#n@& z)v`)ZZoV4zWqhUvz|Zgl`~*M3aqzjF#IEN51tcSW14ZgHH~CX@Cj4b9Q@$lw$HINQ zlR+9d1Lt7?#6w?AQbhvKVO4g|!adu0>dRK$*b7*LJ?yhE=fWdM&UFA-@WgdjJ3oZS zpmvv>yU?MAN_+}_ zg%2sBwm$tq1EMkh`u3F~la@*0dtx?+X7CZ$D#T`J%=IixX-l(!_IOH1nJMO};3$Z5 za-kfnw9-jthBMexfO@r;Yn4QY4p~7N41r80ynt0RR$hu*QK(?bm#^>?(%x?>J^8D= zVW9l`%B`@<6Pr`N!k}GsE{KEzPy~E_g|HWd{O}UwQzhoXk{h%x(4AO;qR7ZAuG`DH z1|?VmibD)kM5YlHq`hYYkRGr$=w|#nNMTV3GQh9M{|KrJHGuY%ts$MBgZcr>_n?v= z17GQnU{^=fo>Dp24&rOr0;){4&?Z<3jo>S20HZ)%Y9x$+I-p9|&RrGX0qVnWXc^0Y zAAqV@J^y`7ZC&1jcj0a544ptt&=J%OYVG!zO`!>>)!%@sAZ=i2&@@^WvkX*(3Q!*8 zR~cS~*h>6Y1(b8;TDeURHQ+U<4(hkBV^)LOP!l90Kc8$Z>>5xiSzS8-fvR0K ztC~_;DkS}g=kTy-ORm7GYK_P?;LD89za?%8^cFM&jaZeuIVikJ+XAySsHqfA8Bn;k zpv2|wtA1th9kqW^EL}joHy@}2s=d`iBSAe@y;vPI0wkv%oCnl#6|tJJD<~ssE@ei4 z1F-jpe$W?sKzHZ{eW14j_Jm#_PuT~;phVY$u@8Y^Fcjika}G5y6VAdM+-8ERc^sqz zb!cVoBT$DO3#xwA${0uwvA&M=As3@T5##`Qeg>bw$1olyz^4#Kf-^9u!!-CDrod#F z1QWr)RG1CAU(UkR4OaKb6$x(Uj|eL&`CJ@A{Y;J`Ru10D$o&Vl!eQ72J7EWGhiz~O zz60H@zs39pc7r0{gQ*+ye$2hF4}!_u?+*1Goow;2-!K z)XHj2`Dcau5Q*C(%!lBiBt0;nfV%Bt7zh^b@{_wCW-{=1uTx`6J(XO~zsVwfRSHN8 zN+1+73urb7!ORSqAP9UV4aEK?vKcXhL9$sfB_nq=m71p@*RMcs$OZ4=mJKr}G|s_) zDp_{SFz~w9atp`)66ArrkPlvl{2)Dq)@W5QD}yv7Qoto6Twgzvx~Nq;HiGL{p*n7r zFv~+l(4oL|!cK*%*wvKPF#RxR~DYU(F~HCDsu44A`Z&)mfR&%$^@tvpgn!wAC#3E&SRLO>aychxGyJb=8VP zU@!~{4duEP*Gx>Y_RKU2 zhmqhLg(I*Jhtvp3FePLM6>c`BZ`K)ujFx@lFdt%Sx%d(GxtO}vLOSt^Z4=j80BdSp z4~zY)IT3tJp#LrG$c{&LB7t>J{{(wHrn8zgzZSDv$LerU8CAySf-hsrq%!Bru*_fz zBblVux7zh9tk$@aQQ>^6X{lK)!YU5mnpbl8$J)#7RS0M^RQW~%pTKbg=6X1T{Wnl^_)INI6PH4n@s)Zm ze!j$0VLwD1TikdKVjeJaid*>`ZN=IE?|qni;XBw5-$G(H$q4!R?7AN)>Z7V?{EPw!=G>oF2Z>@2Yj6!)p zjjRGbM%Y*8C)nlgyMEs8kG#*%7l-d!vKIcnJo<|IT#pEPrhEzABD*h~GM>2isIuL1 z`O1`%WX{^H7o%b9%Ry?KHB@ElxFsVn@;U8D}mElDF^1pwnLNU>(a?4Ptd_ zQyely_MpNC|y=txONOSPdIyarXEGQ0|vK$Cn$%uJXSFm)cLL;J#9>j1wvXmJvQ zSsKbh8PI`R4Wf8c-8zK^7jJSi9=7lVCXHgrRuEfpV>O^JPw%Qbu$-kyLGb zh`VG|I}@>g2A{$N_!vHck6^reJr47;WEI%IV3`b)pg(OSVMQ<<*10q$q(r}iJ>ZMHGIj+j2foPHV^?N+fx6eXn29qiIr%H| zic6VSrsTE{4#0jm1VZ;2-!4uEACKJz0CFgx!hn3J!n3pKuW_fi^>zF_j7Z z-Nt?kZo&_b9xfSi*=MTPu%i9rQ2%{v)3ZY(`iiHeJiW!Qp+mlS!HJ7#uLlm%gA6-)V2=u>XV;pb5-61-Xd(? zI#xDMF;k%q(Ty>`&BK^tde*Uq`riyP|J1Q+`9BIWRq9$rz0uq-(vszLA$ObI_~^`F zf7L^j>LJ!ls7tufrcFI7)H@luK;-%y{$x(Ax<7nx%SB3Vp*f9Q%z6ZZ5xA7DU7j8% zT0e9JxFLFRPL0o+$zO*3daTum1Ro~}Z$1Q6ZC6&5n-jdY(aW|#(TEuCPm`5s1Rt9wt31*!&aO${44U4)t;>PV3x`3%!pR)5+ng$rJnb`W&ZC$e(!`qC& z83;T%-*+8)2feY&4s6$$D}K;;3FR%|%0-mj)#2ipHyXQ%Jgr%;2LW}o@N_rl-brybBO?aoSE#x1h84%_dAm16zJwWz;jNm@ zo+RU9@=ka?D9qeMzGy^rL`mjjv#p`k$q^4RsDV;Z7{iEm88Z?Gy*E7qM?Hu%@DV`NmTE&X*c%Oubbfm{d_toHH5u$sk{ zvtkjHIXc>jfftj-rws`8-bYR~^H25Ci(Wlg=A(p&0!)!6L}YR}0-s-x+cmSt=GCe? zJ0U+O2LF>e%#bElRyV@17e{z9r#X-y$Y*k%5WgGDXS{D}ze%El!kuEp4lkyfd#>BYmXi~F zoKpC5v)R;dO7w0jZV9jDF|(UmS^e+jF^4uTa?^%&cZseO}mhQ%>KRG|egKg}kQL9Hn$3<%AL>Y2E}-%uQkmT zHm|-#f9YfTzQyeM;3c#4E%H1mzax3-)V3K*if%2%ORkhF?12jtq>kdFDK6{%`%w+{74MMXb0NQpXh911kzgK-i+uI-R_l{z@K2CPXWF!& zD0$343~z~-ot(VAvFxH^$=~}uL9V)4xylMoZs(+h3H;g$&Sl%X5kk3I^4i-idQ`|i zJt4$M^GH$7KtP!*5cG1iXY9E-2?8rkR7;Zn*2EK=Y1PsS2s`Pf|HtHBx8_v%XLEw^ z)t60NODfmj3|;L@JM$|}+C>8gH;&)bplyP5L35C(y%iA9NZfH@!lF!Bs@+KtsBb*2 zNV|jaVubZ`<%-ujux`$$YX&69oryH{TG6&wBPT@$1e+nPtdjnYTW|oA6rJd(;+bpe zF2mSp=Cro5n6IsX%(+qez0)OV(P8>=?2E!@@ypEc%08|JBT$DL*^cp>D4TC>{|N>7Bw)7=Mchj{K{a_mJwN4_EiTUikg*0 zPvT4O*w<|xQC!yMh|MnarXj`P3cp=Yrs>1s0JqL9dbLs)RZyPba=-q z=-FT<=xW3ICGS{GJ^xx%1$$D|;7~*LXDV$)Fv>K?Fb&?b2ZY@ZdLf~9D0qBckE8Fb zddp6csQ?X=ne!eKL6e$J6KoC+32HHZ>M+YwEF!v;W}Y7AIz5T6*R@$;vk0E(|&zmmt@{jF&(x0w@LJGMqgcRp{aVhY14H%uSLtKX$cp@haM= zjYR6M64IL@-Kpt%JFLz=r>*Ad4g|89_-+_u&EXyx?(FS|yOO4DciN|-8B7ekiTUU< z?k>x_TVdQR_RFb;x%m%^k_X+bmHrRkG|PLCx*OeI^WFfG_3vp7&xj^KyoT!j{`qB_ZWKUu2pm~KoYxsF4YJ`>9{L;so&4)7v z_9ZAW45Q%7`&z}q{vdo-!l%wZsNSWl3vSuF>5}TPPu?<*`?4}h+rpWV!*)#is>a0N zU6P1qn-J;8+sFG6U13ub!&?P8t)RXiKC|hf%Vlp!u4n{PcVrV2+mFF93IT0TOv|xd z^VB;qG1&7SE?V(UG6xmIVpm{)Xi4wgpCj_xd5LBO`E4*Bn#c2v@ya-4V*0Z<{HcZM z(%&lTAK${v#B|z_6_njsOpXC;!ks4eKi1OxI*d*D>;cv~M=Zp=H;_$DIa6s65x!>X zkE24q9!O7k)~VMZ?wG7^KgN3}cJH{>&W567)edvpCd<;?-%~V#tqFUIDJF2RHI!FU zem0nfFKxaZY}NJ_>fn@lUY9<-`#)};-DyTuUTITg2uW8l)rU})lO4^FAyoFAj!tFY zEq>wjlojVDA@44eQkj(q^5XXkLx{I%Cud>TJX@Wy>z1A`ha45H<<#rE@t=5Gm`<^D z!?C7B97bnTa115LHIzCTWV#NuF8Rhvw_#R2w?nwq)X_VF$TQL`TbkTD*uGt-PDIYl zMtx$Ei6(i-p`8q%JeE2#( z+FR42QD(+Svh7YP{-dnoyoS5fD8|$@GiMZO*h|L{3(?i)_$bXR*Y3>fh5(BLwVa(!D6hM>Y>s1SMm<%nz1Qz zYGIQ2+MFgLCtJa}>;+XDH`466(CNM2wel>xOk#CV5gUr)qU@^a5<`L3izYKKhtm}=wRYJxzOh$U{ zgmP)1Df%h#JQ`>^eri?G-D~Nm+`a7ENRa?)mMnQbqlX5Y9qX*N=I{V3h1u|#b;!G8 zurn3cNqfQEDKNYPky0-zL}N2&qLarh6Pa!%qdbxRq~4HsxBR+t^-{NLqFGy*>j=ga zj&-`>{K+$~1mtR62?5nHF_p$gz3az1uPwYhKE4<}ZU?Pw%1xqH>zXE$n6R3gvAXJL z=1(H41I65~uQ*kmQgH6_CvUG8n=`)%B?B4%w4|8URtQQ|g)EwV?kDD_R_L$$0 z_j*5Y78+|WTzvD~<0X%+rxS4r6KF`Wo+*goeFr(+V}_-_z38{I$2KL%jWDecDEj2iu#oJb^* z+r%pq70d%;)s7kSp|c%HlRo#y%__Z-p3=BF>&dQ#MJ(rk?k~S(wky?*X(SHJ?qTI&$)f{{n#ns*!B}wZuw;|D;;;z zAf@nWB(cks!SFvl!8D#`)$-Q=)akS1Uz#)M!!SyE9l7xby1xnbgo{X2ncuXwF2ZO}Fg*p+kJ9 zNZr5OyW}zxJBxg5aRqX}mF?YWv-9V)1!#6ovP={L{>LVoTC=zd*b#Fc(c+= z+lrGDx2;|`*XJ<28=7)+nKhD3+HU$|ltpc``PlDm7xi2-%AGx#vpbuy1oh-K^XFR8 zF~c||N`>6^Z26LJZq@n>DM=|r-Nldr`Q-FJ+p_+(Ynt`hU}^px%ja0AuE_+=h(Ksi z(7O%uWca}iO1T%9N(gW=(_BV#(|ev(u$-Ch%nxPJD=6L@-wf_H{ig%=F^SeAk$%O9 zO{H$S?OMR}Q&)A!p;D7)=YYmLZ-z5gW-k8pa!BT5BM{I%jLd#z0>2=sJ|_MPbPg51 zK=gbVV8Sw3J$&5=9H!^k% zYSGV;&}fZHee-gG*MiylkHjSym#?|`X@;*;War$_eY;S#(|PPLcBXWbRH{kcOx4LK zy3IVZpD=rc=E1I0kad)K5M}YxB{wL;}Y>TX1x>J>1 z!~ioqy&g!_e$l?w;JLNBzCqktSFqe!WV$Y5B%WEo$9SxZeBfipmsWJ)j9)snv3EuJ zP3;?&&4qkqgw$bv9r2O2>R!6sYcp4zeZ!y6XNNDg!oAn<2vQOSPL1td{goO7ac{DF zOpC>2c%qrG*lLw((Gq7nV5=cAoAgVp9G)ho@DkdsM@{!W`Q*y2)YfiQY(3qiUudTkQ;=A7)qh zvr_y+X?)z9YF;x#F?!9m6&UtHzF?{o^PTlT68%Vv9MfRA74%fd2^w~KjnhS=*M(2| z%76X?e-HDVJ$qbTW3FWg$m;*k8nb1&RVCFEBsEcZ`JNKTC%(koEVw=PxtzE<^cX)! z%6oC0Q$>wNwn^V*-Rci0kUJ!u@Fwk7RyO~e>rBPK0C#`#%qU3phzOL48fN@gi82vt zc6~()6yE4eL<>K>+To=(FIOXaRI?h;rA?sL3)M_P8UL|}$iY;eC-!-dPCOqF zO<0oWI<_3=I+3dwq)DFZ*b?@+PB2PL24EgDcoqF3$-^C6z&_m3u3G0h=JYBi@+8l7 zY~hjSiDFCgT*nr$&vo3Sox>e8xMU;A!yVhlKGX?i`8;hkF(-LyW6Rm6Ho;|Vdxuw? zT|mmf@&z||CLXZQY;2z-PjhS^jze>-v9g8@-{IWzjxYXY@94Jm(Vq`t-j*iwhaIND z8XiOVVW*jmr2na%X44wBDSzxV*SJo6uM0ap!Rc%}g3A2es@A9bozcS#=67SKDZ7^D zdGRgHy*=4mx!`d7o{;2TNwN8{SNeMG)4ftrlXo5WB7370&6oYyBo#8V)>+}sPH7ri zkrwOOr|&hp*IU!Pr*=C7@V$L?Lu#Lkdy=qTd}g=eUAf2U+YdKy95ptyce8|3SXVLG z#thv~-*%Oju8wnk{lhjK$7FZ z_UXsB&=@dD&H}c8J)-%Ly?tnEDsV9AO>*4W!p>oHY1^LU)MNYPdD&Td?ex1=?WIfp z7430n`#49$xomrqvxDtp&j=dp55A`3I3qfR?W0L;8_TAvq@V?1p?9*RxV@{(YY8bN zc?52Ub*4Kf?IfozTWY0Qxs60KoBbHxk))%!{^*>H*}r-DQr(1fmYFA#b36Vso!h6} zcSnQU|DPQq_LbLIJD4#3bqFxbH-g+l09Rm!3E%nj7$?<+>P{tRGhKJG4sxS+jt0D| zIrvch3iazpCz|>@t<2n>X6>SQ zea%Ms*ET10)z$pH3nibE9pAGIX7M*1H@5!4S!6ZLfBnen(P?wrcU3z@_bAG#46`p? zK#+S7mDg8-|KGCei}dSaV)sxs_Zg4A$L+U4#O+_5vqpt3)wMcs zpADH|Dm_LAdv=c%l4>_nQmH(9*7w}epHB}z>|3VdXPUSrH35!N@E*?rk&3fe`giUvk3vMngf1z&ZJ_>#$>I0@soM>!qXPa zZLWXE#8D9etxP-5Z@g|oy#?J81X|*w)3&m?^0j>J;@p@7pTVZwUfshH$c4b@%MBN- zSx}3y?90Sf(;Wf-Q$L%J_HrY8f}qyLd86t_RBpCue1c%O*`}DPoNx~8_LdpkIr3v6z9~F3BPs9cesGvhjjSiSIJb`M;p~MzhQV=BNt3cuatzDAwy0a=>m( zhf?;2D%yJyrJOdCo$n2bvns6B>P|ahvN*;RIe;#0&Kc)4sYB-ze|~uW)DM={vTPH1 zniro?g6>UTa73E8!)!x*KF7|OAqS|zD@b^e7<}}}_kFt`?wlZz!mK269>Ls?;VmEU zGNqueteFs*-PQt~&4ai){r{1qeG+&qj?*JM9L{O|wns0HVK(Z1WB&HaMx z*@c6B9zZws4_U1|Yt2gJ@~lNJf_&8e!^uyBCdrD|?Ec2P{ye;7FSh*NGnIa_sv;kL z)QV2!lXqO7UOKsbE-P%Ql(7RQ-;t*YCJG@>v;Bxw$c;O}Xmpf?kCVZt@=o#0-J@2a zr*2Nk&CfqtuO%(+v9lR%J|UDV;imFj97za1mFR4|KTxB6t~m2`+SDD&hjq?*$Enfc z)aY1K7Xe?hc;_IPl`7qGXa2qZzcgLu2%=B$+h}GWm~<`uJEu=G@LXLx`7zgyTZK~l zn&aA4llMof7LSy6|B)1Gn6ZhB|EQ+tqW%xbXS!urnj6me-ctB>rQ$Pojg~%?+ZVec zU*~+bS3c9}o))a5@x+tcXQJjydv)WvS^j6A_YNn0jnn~+I;NP=Dv)O?NnJV{KR4YO zruHvHz200_3Qp(oKX$|9I?E`&M(8j?Z?`;q&h+m1bHd^gg!}^5BEj+YzIl)z1`0HfoIY8&hca!O~RW(&51nBE=YfYQe)J!F_<20&N+X&~A zyisQo$=DIMVqtRn3=5P0XypGfqb1#V|Hql_X(vmv)smh&?a>g*r;iiOkWZ`axsKh| zR60-3a+~wtqn9?iex52?Ysy`)esXnmo^=((N`zVj&WS5LuPHOp;d+i)4^<23Q0Zi;|SOqx={azcTL(q*_&VSn2LY0d8ydM*+%b-trOq% z^yZd~2dP1sU|y`bA7;k=NugrQ!ate6#+sA5Dr^4!Gm!%S#Tw)PrJpx_F4Gl;n+cbR zb*@>0;pM?v`xK$WkV}1gG>un@0%6Ay&~l|*oo@ZJkJwq(E=H6l>Km5% z`X43%`-CjSDv5T%RJ%fdoopt_=xoN_A)$Qc(G`p+ljSP6RSMJ!r<>;6D9xYO~-5XLJYC@KIGNr^v$c+kYR?7WENrZ0m4Znshf= zai%omZ&DR@#zV~Qnvz_^1Q|&Knl>e{6 z=Ezew4zq(zj=#AZewf)~pUW%>e{=5r5l`}xA!%EQtA(lmH>qbe@5qQUBmSlw|L|jd z^{Ea$-+lZ2UMy-F#tmGNanaq3o_Df%^cKCir=9dF!D4>FRa=2m;h{mP%cb|XtGTG^ z`5HbU`1EbGZSkZOhjp;8<6DYnnS=L}?%#T-3xWNn>pv{zGiLSJM-M$~z0;_5qU2b5a-&u-f%syGiNyFK4q1PSHquV3YDA}H~O{V z9{UkQGiD17>^#S7T0Y^iQd?D#*?f{bz;d6;&gy&q+V>A z2btTi@(INF)xqZ_d@?L~ytMb{dG$yM*(^#neGeSzkk844splV1Oc`osW#+*l0w-9V zp9$a^72~?9E4e=sPzxnQryq0JWy)sCsA!3QCJ<^yCnF8V$(zKfGxN1(m(J#`U;OV>$hLB+T=I`u4uKO6>>qRsN`$8Ky-5>AtuQohRo1 z{Q$pPc@*IVzf@3-*}qg!KZ)^+G=-B>T<50=N%q3QW=<+3=s?{ zn#dFZ*<3Gg6fU(^oY5@*b@P?*o`tdzt$SkkOte|Ob6N0cUl>37WUrNV z_NBB1(GT(FAg2gln$46XY)=u7y+=<`XJFrMuUvaoXB#{-Mq|3Aa+Wob7CTyJ#bUap z4#?(awU)CE3r=p&`SuTLvm`6Z0bM#(PZE`-ZGh^`v&Za8lc>ZQ(^3m1P1Upk(J_ys zoOMCf9u0FYc(cr~gp-@@MIE1kS2`UW9dsGRt?tj!Y{%xAW%`BTT32A+{we)ubvgDn zE1;4T2bJoxKOi*0=>m#LDO@96Ku7LrE7JueQZwI47tj?YXs7hl`)V^SeL!u`>*kUS zuPKp%`JuTPoPqh_|9T`Gwzs&)e)iyv%OS-Z_kUX}9e02|e^{K?bj?WZxQE0$%)E>N z&mSHCKOYMFKU!yI2L|Ngmr_o3p4sg;wD#0epZI$=;G#QFj5!%d;k_n95JpK;8N>T3 zFDTM_ZPAEcnG3D#*ML$m_o++OG!ufzbyKq>h}vmuZUzN>h@O66CR+GXDf3mPfUGf* zrJWT@i5+9tO|Be&*bZ$ku3m7yPrTonN115V8D*SxZReiLpWM%|!E}&~#+#WOC;2Nm zTlMPvDlT=LzU9DQt`O5Bb3nzYWXYUy{cW=Li*s&i5|)$uzt&{_1xK`+yfE#RXs-2C zQ-%{$Mm?Ckf9tE}Qs#i1;U9N&o;Z0cUyk9c2Gzg&{O=GxHm`&PKK8O20D=} zm_H!yr#;uN)iwQbF8U=;-DDx|xj(JVxbpk3A=|mu@?*lujh}~2|1fwz*P6<%E=d{G z{k;nhPR%p>LIQHQv0|jk5>SDe$f@z+C1VbF`lWwdFT^R~$R_LCW(kN$F=~5`fc4vR JB;Ql|{{tW|c&PvY diff --git a/package.json b/package.json index 1592687..bb02a51 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "hono": "^4.8.2", "hono-openapi": "^0.4.6", "install": "^0.13.0", + "jose": "^6.1.0", "lucide-react": "^0.509.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/service/auth/README.md b/service/auth/README.md index 30d792c..36f0e77 100644 --- a/service/auth/README.md +++ b/service/auth/README.md @@ -1,21 +1,21 @@ -# Authentication Service +# Authentication Service - WorkOS Sessions -This authentication service has been updated to use WorkOS for OAuth authentication, replacing the previous fake user implementation. +This authentication service uses WorkOS sessions for secure, managed authentication instead of self-signed JWT tokens. This provides enterprise-grade session management, automatic token refresh, and comprehensive session controls. ## Overview -The authentication flow uses WorkOS AuthKit to handle user authentication via OAuth providers. Users are redirected to WorkOS for authentication, and upon successful authentication, a JWT session token is created and stored as an HTTP-only cookie. +The authentication flow leverages WorkOS's session management system: -## Flow - -1. **Login (`/auth/login`)**: Redirects user to WorkOS authorization URL -2. **Callback (`/auth/callback`)**: Handles OAuth callback, exchanges code for user info, creates session -3. **Logout (`/auth/logout`)**: Clears session cookies and redirects +1. **OAuth Flow**: Users authenticate via WorkOS OAuth providers +2. **Session Creation**: WorkOS creates and manages the user session +3. **Token Management**: WorkOS provides access tokens (JWTs) and refresh tokens +4. **Automatic Refresh**: Tokens are automatically refreshed as needed +5. **Session Controls**: Configure session behavior via WorkOS Dashboard ## Files -- `auth.endpoints.ts` - Main authentication endpoints -- `auth.helpers.ts` - Helper functions for session management and authentication middleware +- `auth.endpoints.ts` - Authentication endpoints using WorkOS sessions +- `auth.helpers.ts` - Helper functions for session validation and user management - `auth.endpoint.test.ts` - Comprehensive tests for auth endpoints - `auth.helpers.test.ts` - Tests for auth helper functions @@ -27,15 +27,23 @@ Required environment variables for WorkOS integration: WORKOS_API_KEY=sk_test_... WORKOS_CLIENT_ID=client_... WORKOS_REDIRECT_URI=http://localhost:4000/api/auth/callback -JWT_SECRET=your_jwt_secret_here +JWT_SECRET=your_jwt_secret_here # Used for any additional signing if needed ``` -## Usage +## Authentication Flow -### Authentication Endpoints +### 1. Login Process + +``` +User → /api/auth/login + → WorkOS Authorization URL + → User authenticates + → WorkOS callback → /api/auth/callback + → Store WorkOS tokens → Redirect to app +``` -#### `GET /auth/login` -Initiates WorkOS OAuth flow. Optionally accepts a `return` query parameter to specify redirect destination after successful authentication. +#### `GET /api/auth/login` +Initiates WorkOS OAuth flow. **Query Parameters:** - `return` (optional): URL to redirect to after successful authentication @@ -45,42 +53,127 @@ Initiates WorkOS OAuth flow. Optionally accepts a `return` query parameter to sp - `302` - Redirects to WorkOS authorization URL - `200` - Returns JSON with authorization URL (when `test=true`) -#### `GET /auth/callback` -Handles OAuth callback from WorkOS. Exchanges authorization code for user information and creates session. +### 2. Callback Handling + +#### `GET /api/auth/callback` +Handles OAuth callback from WorkOS and establishes session. **Query Parameters:** - `code` (required): Authorization code from WorkOS - `state` (optional): Original redirect destination -**Response:** -- `302` - Redirects to original destination or home page -- `401` - Authentication failed +**Process:** +1. Exchange authorization code for WorkOS tokens +2. Store access token and refresh token as secure HTTP-only cookies +3. Redirect to original destination or home page -#### `GET /auth/logout` -Clears user session and redirects. +**Cookies Set:** +- `wos_access_token`: Short-lived WorkOS JWT (configurable duration) +- `wos_refresh_token`: Long-lived refresh token +- `user_id`: User ID for quick reference -**Query Parameters:** -- `return` (optional): URL to redirect to after logout -- `test` (optional): When set to "true", returns JSON confirmation instead of redirecting +### 3. Session Validation + +The system validates sessions using WorkOS-signed JWTs: + +```typescript +// Validate WorkOS JWT using their JWKS endpoint +const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`) +); + +const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", +}); +``` + +### 4. Automatic Token Refresh + +When an access token expires, the system automatically attempts to refresh it using the refresh token: + +```typescript +const { user, accessToken, refreshToken: newRefreshToken } = + await workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + }); +``` + +## API Endpoints + +### Authentication Endpoints + +#### `GET /api/auth/login` +Initiate WorkOS OAuth flow. + +#### `GET /api/auth/callback` +Handle OAuth callback and create session. + +#### `GET /api/auth/logout` +Clear session and logout from WorkOS. + +**Process:** +1. Extract session ID from access token +2. Clear all auth cookies +3. Redirect to WorkOS logout URL to invalidate session +4. User redirected back to configured logout URL + +#### `POST /api/auth/switch-organization` +Switch to a different organization context within the same session. + +**Request Body:** +```json +{ + "organizationId": "org_123" +} +``` + +**Response:** +```json +{ + "success": true, + "organizationId": "org_123" +} +``` + +#### `GET /api/auth/me` +Get current user information from WorkOS session. **Response:** -- `302` - Redirects to specified location or home page -- `200` - Returns JSON confirmation (when `test=true`) +```json +{ + "id": "user_123", + "name": "John Doe", + "email": "john@example.com", + "roles": [], + "profilePicture": "https://...", + "organizationId": "org_123", + "role": "admin", + "permissions": ["read", "write"] +} +``` -### Helper Functions +## Helper Functions -#### `getUser(c: SurfaceContext): Promise` -Extracts user information from the current session JWT token. +### `getUser(c: SurfaceContext): Promise` +Extracts user information from the current WorkOS session. Automatically handles token refresh if needed. -#### `hasSession(c: SurfaceContext): Promise` -Checks if the current request has a valid session (via cookie or Authorization header). +### `hasSession(c: SurfaceContext): Promise` +Checks if the current request has a valid WorkOS session. Returns JWT payload if valid. -#### `requireAuth(c: SurfaceContext, next: Function): Promise` +### `requireAuth(c: SurfaceContext, next: Function)` Middleware to require authentication for protected routes. Returns 401 if no valid session exists. -### Usage Examples +### Organization & Permission Helpers -#### Protecting a Route +- `getCurrentOrganizationId(c)`: Get current organization ID from session +- `getUserPermissions(c)`: Get user permissions array +- `hasPermission(c, permission)`: Check if user has specific permission +- `getUserRole(c)`: Get user's role in current organization + +## Usage Examples + +### Protecting a Route ```typescript export const protectedRoute = new Hono() .use(requireAuth) @@ -90,45 +183,60 @@ export const protectedRoute = new Hono() }); ``` -#### Manual Session Check +### Manual Session Check ```typescript export const optionalAuth = new Hono() .get("/optional", async (c) => { - const session = await hasSession(c); - if (session) { - const user = await getUser(c); - return c.json({ message: `Welcome back ${user?.name}` }); + const user = await getUser(c); + if (user) { + return c.json({ message: `Welcome back ${user.name}` }); } else { return c.json({ message: "Hello anonymous user" }); } }); ``` -## User Type - +### Permission-Based Access ```typescript -export type User = { - id: string; - name: string; - email: string; - roles: string[]; - profilePicture?: string; -}; +export const adminRoute = new Hono() + .use(requireAuth) + .get("/admin", async (c) => { + if (await hasPermission(c, "admin:write")) { + return c.json({ message: "Admin access granted" }); + } + return c.json({ error: "Insufficient permissions" }, 403); + }); ``` -## JWT Payload +## WorkOS Dashboard Configuration + +Configure session behavior in the WorkOS Dashboard under Authentication settings: + +- **Maximum session length**: How long before user must re-authenticate +- **Access token duration**: How often tokens are refreshed (recommended: 15-60 minutes) +- **Inactivity timeout**: Session ends if no activity for this period +- **Logout redirect**: Where users go after logout -Session tokens contain the following claims: -- `sub`: User ID -- `email`: User email address -- `name`: User display name -- `iat`: Issued at timestamp -- `exp`: Expiration timestamp (24 hours from issue) +## JWT Token Claims + +WorkOS access tokens contain the following claims: + +- `sub`: WorkOS user ID +- `sid`: Session ID (used for logout) +- `iss`: `https://api.workos.com` (or custom domain) +- `org_id`: Selected organization ID (if applicable) +- `role`: User's role in the organization +- `permissions`: Array of permissions assigned to the role +- `exp`: Token expiration timestamp +- `iat`: Token issued timestamp +- `email`: User's email address +- `name`: User's display name ## Testing The authentication system includes comprehensive tests covering: -- All authentication endpoints (login, callback, logout) +- All authentication endpoints (login, callback, logout, organization switching) +- Token validation and refresh logic - Error handling for various failure scenarios - Helper functions for session management - Mock WorkOS integration @@ -138,20 +246,32 @@ Run tests with: bun test ./service/auth/ ``` -## Security Considerations +## Security Features + +- **HTTP-Only Cookies**: Tokens stored as secure, HTTP-only cookies +- **Automatic Refresh**: Seamless token refresh without user interaction +- **Session Invalidation**: Proper logout that clears WorkOS session +- **JWKS Validation**: Tokens validated using WorkOS's public keys +- **Token Rotation**: Refresh tokens may be rotated for security +- **Organization Context**: Built-in multi-organization support +- **Permission Management**: Fine-grained permission checking + +## Migration from Self-Signed JWT + +Key differences from the previous self-signed JWT approach: -- JWT tokens are stored as HTTP-only cookies to prevent XSS attacks -- Cookies use `sameSite: "lax"` and `secure` flag in production -- Session tokens expire after 24 hours -- All authentication errors are properly handled and logged -- WorkOS handles the actual OAuth flow, reducing attack surface +1. **Token Source**: WorkOS manages token creation and signing +2. **Validation**: Uses WorkOS JWKS instead of local JWT secret +3. **Refresh Logic**: Built-in refresh token rotation +4. **Session Management**: WorkOS tracks and manages session state +5. **Dashboard Controls**: Configure session behavior via WorkOS UI +6. **Organization Support**: Native multi-organization context switching -## Migration from Fake User +## Benefits -The following changes were made during the WorkOS migration: -1. Removed `fakeUser` from auth helpers -2. Updated authentication flow to use WorkOS OAuth -3. Added proper JWT session management -4. Updated error handling with `AuthError` class -5. Fixed dependency injection to support JWT mocking in tests -6. Updated member service client and tests to work with real authentication \ No newline at end of file +- **Enterprise Security**: Leverages WorkOS's security infrastructure +- **Reduced Complexity**: No need to implement session management +- **Automatic Refresh**: Built-in token refresh and rotation +- **Session Monitoring**: View and manage sessions from WorkOS dashboard +- **Compliance**: Built-in compliance and security features +- **Scalability**: Handles enterprise-scale session management \ No newline at end of file diff --git a/service/auth/auth.endpoint.test.ts b/service/auth/auth.endpoint.test.ts index 1f37669..a968967 100644 --- a/service/auth/auth.endpoint.test.ts +++ b/service/auth/auth.endpoint.test.ts @@ -10,15 +10,23 @@ describe("auth endpoints", () => { // Mock logger const mockLogger = { ...logger, - info: mock().mockImplementation(() => null), - error: mock().mockImplementation(() => null), + info: mock(() => null), + error: mock(() => null), } as unknown as typeof logger; + // Mock functions for WorkOS + const mockGetAuthorizationURL = mock(); + const mockAuthenticateWithCode = mock(); + const mockAuthenticateWithRefreshToken = mock(); + const mockGetLogoutUrl = mock(); + // Mock WorkOS const mockWorkOS = { userManagement: { - getAuthorizationUrl: mock(), - authenticateWithCode: mock(), + getAuthorizationUrl: mockGetAuthorizationURL, + authenticateWithCode: mockAuthenticateWithCode, + authenticateWithRefreshToken: mockAuthenticateWithRefreshToken, + getLogoutUrl: mockGetLogoutUrl, }, } as unknown as WorkOS; @@ -37,15 +45,17 @@ describe("auth endpoints", () => { let testApp: Hono; beforeEach(() => { - // Reset all mocks without restoring them - mockLogger.info.mockClear(); - mockLogger.error.mockClear(); - mockCookies.get.mockClear(); - mockCookies.set.mockClear(); - mockWorkOS.userManagement.getAuthorizationUrl.mockClear(); - mockWorkOS.userManagement.authenticateWithCode.mockClear(); - mockJwt.sign.mockClear(); - mockJwt.verify.mockClear(); + // Reset all mocks + (mockLogger.info as any).mockClear?.(); + (mockLogger.error as any).mockClear?.(); + (mockCookies.get as any).mockClear?.(); + (mockCookies.set as any).mockClear?.(); + mockGetAuthorizationURL.mockClear?.(); + mockAuthenticateWithCode.mockClear?.(); + mockAuthenticateWithRefreshToken.mockClear?.(); + mockGetLogoutUrl.mockClear?.(); + (mockJwt.sign as any).mockClear?.(); + (mockJwt.verify as any).mockClear?.(); testApp = new Hono() .use( @@ -53,7 +63,7 @@ describe("auth endpoints", () => { logger: mockLogger, cookies: mockCookies, workos: mockWorkOS, - jwt: mockJwt, + jwt: mockJwt as any, }), ) .route("/", sessions) @@ -65,17 +75,13 @@ describe("auth endpoints", () => { const mockAuthUrl = "https://api.workos.com/sso/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:4000/api/auth/callback"; - mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( - mockAuthUrl, - ); + mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); const response = await testApp.request("/login"); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe(mockAuthUrl); - expect( - mockWorkOS.userManagement.getAuthorizationUrl, - ).toHaveBeenCalledWith({ + expect(mockGetAuthorizationURL).toHaveBeenCalledWith({ provider: "authkit", redirectUri: "http://localhost:4000/api/auth/callback", clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", @@ -88,15 +94,11 @@ describe("auth endpoints", () => { it("should use return query parameter as state", async () => { const mockAuthUrl = "https://api.workos.com/sso/authorize"; - mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( - mockAuthUrl, - ); + mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); await testApp.request("/login?return=/dashboard"); - expect( - mockWorkOS.userManagement.getAuthorizationUrl, - ).toHaveBeenCalledWith({ + expect(mockGetAuthorizationURL).toHaveBeenCalledWith({ provider: "authkit", redirectUri: "http://localhost:4000/api/auth/callback", clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", @@ -106,9 +108,7 @@ describe("auth endpoints", () => { it("should return JSON for test requests", async () => { const mockAuthUrl = "https://api.workos.com/sso/authorize"; - mockWorkOS.userManagement.getAuthorizationUrl.mockReturnValue( - mockAuthUrl, - ); + mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); const response = await testApp.request("/login?test=true"); const body = await response.json(); @@ -118,7 +118,7 @@ describe("auth endpoints", () => { }); it("should handle WorkOS errors", async () => { - mockWorkOS.userManagement.getAuthorizationUrl.mockImplementation(() => { + mockGetAuthorizationURL.mockImplementation(() => { throw new Error("WorkOS error"); }); @@ -146,10 +146,11 @@ describe("auth endpoints", () => { }; beforeEach(() => { - mockWorkOS.userManagement.authenticateWithCode.mockResolvedValue({ + mockAuthenticateWithCode.mockResolvedValue({ user: mockUser, + accessToken: "mock.workos.access.token", + refreshToken: "mock.workos.refresh.token", }); - mockJwt.sign.mockResolvedValue("mock.jwt.token"); }); it("should authenticate user and create session", async () => { @@ -160,32 +161,25 @@ describe("auth endpoints", () => { expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/dashboard"); - expect( - mockWorkOS.userManagement.authenticateWithCode, - ).toHaveBeenCalledWith({ + expect(mockAuthenticateWithCode).toHaveBeenCalledWith({ code: "auth_code_123", clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", }); - expect(mockCookies.set).toHaveBeenCalledWith("user_id", "user_123", { - path: "/", - httpOnly: true, - secure: false, // development mode - sameSite: "lax", - }); - - expect(mockJwt.sign).toHaveBeenCalledWith( - expect.objectContaining({ - sub: "user_123", - email: "john.doe@example.com", - name: "John Doe", - }), - "sup4h.secr1t.jwt.🔑", + expect(mockCookies.set).toHaveBeenCalledWith( + "wos_access_token", + "mock.workos.access.token", + { + path: "/", + httpOnly: true, + secure: false, // development mode + sameSite: "lax", + }, ); expect(mockCookies.set).toHaveBeenCalledWith( - "session", - "mock.jwt.token", + "wos_refresh_token", + "mock.workos.refresh.token", { path: "/", httpOnly: true, @@ -194,8 +188,15 @@ describe("auth endpoints", () => { }, ); + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "user_123", { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }); + expect(mockLogger.info).toHaveBeenCalledWith( - "User john.doe@example.com authenticated successfully", + "User john.doe@example.com authenticated successfully with WorkOS session", ); }); @@ -219,9 +220,7 @@ describe("auth endpoints", () => { }); it("should handle WorkOS authentication failure", async () => { - mockWorkOS.userManagement.authenticateWithCode.mockRejectedValue( - new Error("Invalid code"), - ); + mockAuthenticateWithCode.mockRejectedValue(new Error("Invalid code")); const response = await testApp.request("/callback?code=invalid_code"); @@ -242,17 +241,24 @@ describe("auth endpoints", () => { lastName: null, }; - mockWorkOS.userManagement.authenticateWithCode.mockResolvedValue({ + mockAuthenticateWithCode.mockResolvedValue({ user: userWithoutName, + accessToken: "mock.workos.access.token", + refreshToken: "mock.workos.refresh.token", }); const response = await testApp.request("/callback?code=auth_code_123"); - expect(mockJwt.sign).toHaveBeenCalledWith( - expect.objectContaining({ - name: "john.doe@example.com", // Should fallback to email - }), - expect.any(String), + expect(response.status).toBe(302); + expect(mockCookies.set).toHaveBeenCalledWith( + "wos_access_token", + "mock.workos.access.token", + { + path: "/", + httpOnly: true, + secure: false, + sameSite: "lax", + }, ); }); }); @@ -264,13 +270,19 @@ describe("auth endpoints", () => { expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); - expect(mockCookies.set).toHaveBeenCalledWith("user_id", "", { + expect(mockCookies.set).toHaveBeenCalledWith("wos_access_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + expect(mockCookies.set).toHaveBeenCalledWith("wos_refresh_token", "", { path: "/", httpOnly: true, expires: new Date(0), }); - expect(mockCookies.set).toHaveBeenCalledWith("session", "", { + expect(mockCookies.set).toHaveBeenCalledWith("user_id", "", { path: "/", httpOnly: true, expires: new Date(0), @@ -293,5 +305,92 @@ describe("auth endpoints", () => { expect(response.status).toBe(200); expect(body).toEqual({ message: "Logged out successfully" }); }); + + it("should redirect to WorkOS logout URL when access token is present", async () => { + // Mock getting an access token + (mockCookies.get as any).mockReturnValueOnce("mock.workos.access.token"); + + // Mock WorkOS getLogoutUrl + mockGetLogoutUrl.mockReturnValue( + "https://api.workos.com/sso/logout?session_id=test_session", + ); + + const response = await testApp.request("/logout"); + + expect(response.status).toBe(302); + }); + }); + + describe("POST /switch-organization", () => { + beforeEach(() => { + const updatedMockUser = { + id: "user_123", + email: "test@example.com", + firstName: "Test", + lastName: "User", + profilePictureURL: "https://example.com/avatar.jpg", + createdAt: "2023-01-01T00:00:00Z", + updatedAt: "2023-01-01T00:00:00Z", + emailVerified: true, + }; + + mockAuthenticateWithRefreshToken.mockResolvedValue({ + user: updatedMockUser, + organizationId: "org_123", + accessToken: "new_access_token_123", + refreshToken: "new_refresh_token_123", + }); + }); + + it("should switch organization successfully", async () => { + (mockCookies.get as any).mockReturnValueOnce("valid.refresh.token"); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "org_123" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ success: true, organizationId: "org_123" }); + + expect(mockAuthenticateWithRefreshToken).toHaveBeenCalledWith({ + refreshToken: "valid.refresh.token", + clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + organizationId: "org_123", + }); + }); + + it("should return 401 when no refresh token", async () => { + (mockCookies.get as any).mockReturnValueOnce(undefined); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "org_123" }), + }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("No active session"); + }); + + it("should handle WorkOS errors", async () => { + (mockCookies.get as any).mockReturnValueOnce("valid.refresh.token"); + mockAuthenticateWithRefreshToken.mockRejectedValue( + new Error("Organization not accessible"), + ); + + const response = await testApp.request("/switch-organization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: "invalid_org" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Failed to switch organization"); + }); }); }); diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index c5719a9..00cf2a2 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; import { z } from "zod"; import { AuthError } from "../../handlers/error.handler"; +import * as jose from "jose"; export type User = { id: string; @@ -12,6 +13,9 @@ export type User = { email: string; roles: string[]; profilePicture?: string; + organizationId?: string; + role?: string; + permissions?: string[]; }; // Zod schemas for OpenAPI documentation @@ -23,6 +27,26 @@ const logoutResponse = z.object({ message: z.string(), }); +const switchOrganizationRequest = z.object({ + organizationId: z.string(), +}); + +const switchOrganizationResponse = z.object({ + success: z.boolean(), + organizationId: z.string(), +}); + +const userResponse = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + roles: z.array(z.string()), + profilePicture: z.string().optional(), + organizationId: z.string().optional(), + role: z.string().optional(), + permissions: z.array(z.string()).optional(), +}); + export const sessions = new Hono() .get( "/login", @@ -85,7 +109,7 @@ export const sessions = new Hono() }, }), async (c) => { - const { workos, cookies, jwt, logger } = c.var; + const { workos, cookies, logger } = c.var; try { const code = c.req.query("code"); @@ -98,50 +122,40 @@ export const sessions = new Hono() logger.info("Processing WorkOS callback with authorization code"); - // Exchange code for user information - const { user } = await workos.userManagement.authenticateWithCode({ - code, - clientId: env("WORKOS_CLIENT_ID"), + // Exchange code for WorkOS tokens and user information + const { user, accessToken, refreshToken } = + await workos.userManagement.authenticateWithCode({ + code, + clientId: env("WORKOS_CLIENT_ID"), + }); + + // Store WorkOS access token (short-lived JWT) + cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", }); - // Create our user object from WorkOS user - const surfaceUser: User = { - id: user.id, - name: - `${user.firstName || ""} ${user.lastName || ""}`.trim() || - user.email, - email: user.email, - roles: [], // You might want to fetch roles from WorkOS organizations - profilePicture: user.profilePictureUrl ?? undefined, - }; - - // Store user ID in cookie - cookies.set("user_id", surfaceUser.id, { + // Store WorkOS refresh token (longer-lived) + cookies.set("wos_refresh_token", refreshToken, { path: "/", httpOnly: true, secure: env("NODE_ENV") === "production", sameSite: "lax", }); - // Generate JWT session token - const now = Math.floor(Date.now() / 1000); - const payload = { - sub: surfaceUser.id, - email: surfaceUser.email, - name: surfaceUser.name, - iat: now, - exp: now + 60 * 60 * 24, // 24 hours - }; - - const token = await jwt.sign(payload, env("JWT_SECRET")); - cookies.set("session", token, { + // Also store user ID for quick reference (optional) + cookies.set("user_id", user.id, { path: "/", httpOnly: true, secure: env("NODE_ENV") === "production", sameSite: "lax", }); - logger.info(`User ${surfaceUser.email} authenticated successfully`); + logger.info( + `User ${user.email} authenticated successfully with WorkOS session`, + ); // Redirect to original destination or home page const redirectTo = state || "/"; @@ -161,10 +175,10 @@ export const sessions = new Hono() .get( "/logout", describeRoute({ - description: "Clear user session and logout", + description: "Clear user session and logout from WorkOS", responses: { 302: { - description: "Redirect to specified location or home page", + description: "Redirect to WorkOS logout URL or specified location", }, 200: { description: "Logout confirmation (for testing)", @@ -175,30 +189,183 @@ export const sessions = new Hono() }, }), async (c) => { - const { cookies, logger } = c.var; + const { cookies, logger, workos } = c.var; logger.info("User logging out"); - // Clear all auth cookies - cookies.set("user_id", "", { - path: "/", - httpOnly: true, - expires: new Date(0), - }); + try { + const accessToken = cookies.get("wos_access_token"); + + // Clear all auth cookies first + cookies.set("wos_access_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + cookies.set("wos_refresh_token", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + cookies.set("user_id", "", { + path: "/", + httpOnly: true, + expires: new Date(0), + }); + + // For testing purposes, if test=true query param is present, return JSON + if (c.req.query("test") === "true") { + return c.json({ message: "Logged out successfully" }); + } + + if (accessToken) { + try { + // Extract session ID from WorkOS access token + const decoded = jose.decodeJwt(accessToken); + const sessionId = decoded.sid as string; + + if (sessionId) { + // Redirect to WorkOS logout endpoint to properly end the session + const logoutUrl = workos.userManagement.getLogoutUrl({ + sessionId, + }); + return c.redirect(logoutUrl); + } + } catch (decodeError) { + logger.error( + "Failed to decode access token for logout", + decodeError, + ); + } + } + + // Fallback redirect if we can't get session ID + const redirectTo = c.req.query("return") ?? "/"; + return c.redirect(redirectTo); + } catch (error) { + logger.error("Error during logout", error); + + // Always redirect somewhere even if logout fails + const redirectTo = c.req.query("return") ?? "/"; + return c.redirect(redirectTo); + } + }, + ) + .post( + "/switch-organization", + describeRoute({ + description: + "Switch to a different organization context within the same session", + requestBody: { + content: { + "application/json": { schema: resolver(switchOrganizationRequest) }, + }, + }, + responses: { + 200: { + description: "Successfully switched organization", + content: { + "application/json": { + schema: resolver(switchOrganizationResponse), + }, + }, + }, + 401: { + description: "No active session", + }, + 400: { + description: "Failed to switch organization", + }, + }, + }), + async (c) => { + const { workos, cookies, logger } = c.var; + + try { + const { organizationId } = await c.req.json(); + const refreshToken = cookies.get("wos_refresh_token"); + + if (!refreshToken) { + return c.json({ error: "No active session" }, 401); + } - cookies.set("session", "", { - path: "/", - httpOnly: true, - expires: new Date(0), - }); + logger.info(`Switching to organization: ${organizationId}`); - const redirectTo = c.req.query("return") ?? "/"; + // Get new access token for the specific organization + const { accessToken, refreshToken: newRefreshToken } = + await workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + organizationId, // This will set the org context in the new token + }); + + // Update stored access token + cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + // Update refresh token if it was rotated + if (newRefreshToken) { + cookies.set("wos_refresh_token", newRefreshToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + } - // For testing purposes, if test=true query param is present, return JSON - if (c.req.query("test") === "true") { - return c.json({ message: "Logged out successfully" }); + logger.info(`Successfully switched to organization: ${organizationId}`); + return c.json({ success: true, organizationId }); + } catch (error) { + logger.error("Failed to switch organization", error); + return c.json( + { + error: "Failed to switch organization", + message: error instanceof Error ? error.message : "Unknown error", + }, + 400, + ); } + }, + ) + .get( + "/me", + describeRoute({ + description: "Get current user information from WorkOS session", + responses: { + 200: { + description: "Current user information", + content: { + "application/json": { schema: resolver(userResponse) }, + }, + }, + 401: { + description: "Not authenticated", + }, + }, + }), + async (c) => { + const { logger } = c.var; - return c.redirect(redirectTo); + try { + // Use the getUser helper to get current user from WorkOS session + const { getUser } = await import("./auth.helpers"); + const user = await getUser(c); + + if (!user) { + return c.json({ error: "Not authenticated" }, 401); + } + + logger.info(`Retrieved user info for: ${user.email}`); + return c.json(user); + } catch (error) { + logger.error("Failed to get current user", error); + return c.json({ error: "Failed to get user information" }, 500); + } }, ); diff --git a/service/auth/auth.helpers.test.ts b/service/auth/auth.helpers.test.ts index 889a8ae..481ae27 100644 --- a/service/auth/auth.helpers.test.ts +++ b/service/auth/auth.helpers.test.ts @@ -1,6 +1,8 @@ import { expect, it, mock, describe, beforeEach } from "bun:test"; import { getUser, hasSession, requireAuth } from "./auth.helpers"; import { SurfaceContext } from "../../surface.app.ctx"; +import { WorkOS } from "@workos-inc/node"; +import * as jose from "jose"; describe("auth helpers", () => { const mockLogger = { @@ -18,12 +20,19 @@ describe("auth helpers", () => { sign: mock(), }; + const mockWorkOS = { + userManagement: { + authenticateWithRefreshToken: mock(), + }, + } as unknown as WorkOS; + const createMockContext = (overrides = {}): SurfaceContext => { return { var: { logger: mockLogger, cookies: mockCookies, jwt: mockJwt, + workos: mockWorkOS, ...overrides, }, req: { @@ -36,26 +45,34 @@ describe("auth helpers", () => { beforeEach(() => { // Reset all mocks without restoring them - mockLogger.error.mockClear(); - mockLogger.info.mockClear(); - mockCookies.get.mockClear(); - mockCookies.set.mockClear(); - mockJwt.verify.mockClear(); - mockJwt.sign.mockClear(); + (mockLogger.error as any).mockClear?.(); + (mockLogger.info as any).mockClear?.(); + (mockCookies.get as any).mockClear?.(); + (mockCookies.set as any).mockClear?.(); + (mockJwt.verify as any).mockClear?.(); + (mockJwt.sign as any).mockClear?.(); }); describe("getUser", () => { - it("should return user from valid session token", async () => { + it("should return user from valid WorkOS access token", async () => { const mockPayload = { sub: "user_123", email: "john@example.com", name: "John Doe", iat: 1234567890, exp: 1234567990, + org_id: "org_123", + role: "admin", + permissions: ["read", "write"], }; - mockCookies.get.mockReturnValue("valid.jwt.token"); - mockJwt.verify.mockResolvedValue(mockPayload); + (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); + + // Mock jose.jwtVerify + const mockJwtVerify = mock(() => + Promise.resolve({ payload: mockPayload }), + ); + (jose as any).jwtVerify = mockJwtVerify; const context = createMockContext(); const user = await getUser(context); @@ -66,42 +83,51 @@ describe("auth helpers", () => { email: "john@example.com", roles: [], profilePicture: undefined, + organizationId: "org_123", + role: "admin", + permissions: ["read", "write"], }); - expect(mockCookies.get).toHaveBeenCalledWith("session"); - expect(mockJwt.verify).toHaveBeenCalledWith( - "valid.jwt.token", - "sup4h.secr1t.jwt.🔑", - ); + expect(mockCookies.get).toHaveBeenCalledWith("wos_access_token"); }); - it("should return undefined when no session token", async () => { - mockCookies.get.mockReturnValue(undefined); + it("should return undefined when no access token and refresh fails", async () => { + (mockCookies.get as any).mockReturnValue(undefined); const context = createMockContext(); const user = await getUser(context); expect(user).toBeUndefined(); - expect(mockJwt.verify).not.toHaveBeenCalled(); }); - it("should return undefined when JWT verification fails", async () => { - mockCookies.get.mockReturnValue("invalid.jwt.token"); - mockJwt.verify.mockRejectedValue(new Error("Invalid token")); + it("should attempt refresh when JWT verification fails", async () => { + (mockCookies.get as any) + .mockReturnValueOnce("invalid.access.token") + .mockReturnValueOnce("valid.refresh.token") + .mockReturnValueOnce(undefined); // No new access token after failed refresh + + const mockJwtVerify = mock(() => + Promise.reject(new Error("Invalid token")), + ); + (jose as any).jwtVerify = mockJwtVerify; + + ( + mockWorkOS.userManagement.authenticateWithRefreshToken as any + ).mockRejectedValue(new Error("Refresh failed")); const context = createMockContext(); const user = await getUser(context); expect(user).toBeUndefined(); expect(mockLogger.error).toHaveBeenCalledWith( - "Failed to get user from session", + "Failed to refresh WorkOS tokens", expect.any(Error), ); }); }); describe("hasSession", () => { - it("should return JWT payload for valid session cookie", async () => { + it("should return JWT payload for valid WorkOS access token", async () => { const mockPayload = { sub: "user_123", email: "john@example.com", @@ -110,8 +136,12 @@ describe("auth helpers", () => { exp: 1234567990, }; - mockCookies.get.mockReturnValue("valid.jwt.token"); - mockJwt.verify.mockResolvedValue(mockPayload); + (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); + + const mockJwtVerify = mock(() => + Promise.resolve({ payload: mockPayload }), + ); + (jose as any).jwtVerify = mockJwtVerify; const context = createMockContext(); const session = await hasSession(context); @@ -128,12 +158,17 @@ describe("auth helpers", () => { exp: 1234567990, }; - mockCookies.get.mockReturnValue(undefined); + (mockCookies.get as any).mockReturnValue(undefined); const context = createMockContext(); - context.req.header = mock().mockReturnValue("Bearer valid.jwt.token"); + context.req.header = mock().mockReturnValue( + "Bearer valid.workos.access.token", + ); - mockJwt.verify.mockResolvedValue(mockPayload); + const mockJwtVerify = mock(() => + Promise.resolve({ payload: mockPayload }), + ); + (jose as any).jwtVerify = mockJwtVerify; const session = await hasSession(context); @@ -142,7 +177,7 @@ describe("auth helpers", () => { }); it("should return false when no token available", async () => { - mockCookies.get.mockReturnValue(undefined); + (mockCookies.get as any).mockReturnValue(undefined); const context = createMockContext(); context.req.header = mock().mockReturnValue(undefined); @@ -152,16 +187,27 @@ describe("auth helpers", () => { expect(session).toBe(false); }); - it("should return false when JWT verification fails", async () => { - mockCookies.get.mockReturnValue("invalid.token"); - mockJwt.verify.mockRejectedValue(new Error("Invalid token")); + it("should return false when JWT verification fails and refresh fails", async () => { + (mockCookies.get as any) + .mockReturnValueOnce("invalid.token") + .mockReturnValueOnce("valid.refresh.token") + .mockReturnValueOnce(undefined); // No new access token + + const mockJwtVerify = mock(() => + Promise.reject(new Error("Invalid token")), + ); + (jose as any).jwtVerify = mockJwtVerify; + + ( + mockWorkOS.userManagement.authenticateWithRefreshToken as any + ).mockRejectedValue(new Error("Refresh failed")); const context = createMockContext(); const session = await hasSession(context); expect(session).toBe(false); expect(mockLogger.error).toHaveBeenCalledWith( - "No valid session was found", + "No valid WorkOS session found", expect.any(Error), ); }); @@ -170,8 +216,12 @@ describe("auth helpers", () => { describe("requireAuth", () => { it("should call next() for authenticated requests", async () => { const mockPayload = { sub: "user_123" }; - mockCookies.get.mockReturnValue("valid.token"); - mockJwt.verify.mockResolvedValue(mockPayload); + (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); + + const mockJwtVerify = mock(() => + Promise.resolve({ payload: mockPayload }), + ); + (jose as any).jwtVerify = mockJwtVerify; const context = createMockContext(); const next = mock(); @@ -183,7 +233,7 @@ describe("auth helpers", () => { }); it("should return 401 for unauthenticated requests", async () => { - mockCookies.get.mockReturnValue(undefined); + (mockCookies.get as any).mockReturnValue(undefined); const context = createMockContext(); context.req.header = mock().mockReturnValue(undefined); diff --git a/service/auth/auth.helpers.ts b/service/auth/auth.helpers.ts index 099d4d2..91820c3 100644 --- a/service/auth/auth.helpers.ts +++ b/service/auth/auth.helpers.ts @@ -2,46 +2,150 @@ import { JWTPayload } from "hono/utils/jwt/types"; import { SurfaceContext } from "../../surface.app.ctx"; import { User } from "./auth.endpoints"; import { env } from "../../env"; +import * as jose from "jose"; /** - * Get the current user from session or JWT token + * Get the current user from WorkOS session tokens * @param c Surface context * @returns User object or undefined if not authenticated */ export async function getUser(c: SurfaceContext): Promise { try { - const sessionToken = c.var.cookies.get("session"); + const accessToken = c.var.cookies.get("wos_access_token"); - if (!sessionToken) { + if (!accessToken) { + // Try to refresh the token + return await refreshAndGetUser(c); + } + + // Validate the WorkOS JWT using their JWKS + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", + }); + + // Extract user info from WorkOS JWT claims + // Try different possible field names that WorkOS might use + const email = + (payload.email as string) || ((payload as any).user_email as string); + const name = + (payload.name as string) || + ((payload as any).user_name as string) || + ((payload as any).given_name as string) || + ((payload as any).first_name as string) || + email; + + const user: User = { + id: payload.sub as string, + name: name || email, + email: email, + roles: [], // Could be enhanced to derive from payload + profilePicture: + (payload.picture as string) || + ((payload as any).profile_picture_url as string), + // Organization context from JWT + organizationId: + (payload.org_id as string) || + ((payload as any).organization_id as string), + role: + (payload.role as string) || ((payload as any).user_role as string), + permissions: + (payload.permissions as string[]) || + ((payload as any).user_permissions as string[]), + }; + + c.var.logger.debug(`User ${user.email} authenticated from WorkOS token`); + return user; + } catch (jwtError) { + c.var.logger.debug("Access token validation failed, attempting refresh"); + // Token might be expired, try to refresh + return await refreshAndGetUser(c); + } + } catch (error) { + c.var.logger.error("Error getting user from WorkOS session", error); + return undefined; + } +} + +/** + * Attempt to refresh tokens and get user + * @param c Surface context + * @returns User object or undefined if refresh fails + */ +async function refreshAndGetUser(c: SurfaceContext): Promise { + try { + const refreshToken = c.var.cookies.get("wos_refresh_token"); + + if (!refreshToken) { + c.var.logger.debug("No refresh token available"); return undefined; } - const decoded = (await c.var.jwt.verify( - sessionToken, - env("JWT_SECRET"), - )) as JWTPayload & { - email: string; - name: string; - }; + c.var.logger.info("Attempting to refresh WorkOS tokens"); + + // Use WorkOS refresh token endpoint + const refreshResult = + await c.var.workos.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: env("WORKOS_CLIENT_ID"), + }); + + const { user, accessToken, refreshToken: newRefreshToken } = refreshResult; + + // Store the new access token + c.var.cookies.set("wos_access_token", accessToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); - // Construct user from JWT payload - const user: User = { - id: decoded.sub as string, - name: decoded.name, - email: decoded.email, - roles: [], // TODO: Add role management - profilePicture: undefined, // Could be added to JWT payload if needed + // Update refresh token if it was rotated + if (newRefreshToken) { + c.var.cookies.set("wos_refresh_token", newRefreshToken, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + } + + // Update user ID cookie + c.var.cookies.set("user_id", user.id, { + path: "/", + httpOnly: true, + secure: env("NODE_ENV") === "production", + sameSite: "lax", + }); + + c.var.logger.info("Successfully refreshed WorkOS tokens"); + + // Convert WorkOS user to our User type + const surfaceUser: User = { + id: user.id, + name: + `${user.firstName || ""} ${user.lastName || ""}`.trim() || user.email, + email: user.email, + roles: [], + profilePicture: user.profilePictureUrl ?? undefined, }; - return user; + c.var.logger.info( + `Successfully refreshed tokens for user ${surfaceUser.email}`, + ); + return surfaceUser; } catch (error) { - c.var.logger.error("Failed to get user from session", error); + c.var.logger.error("Failed to refresh WorkOS tokens", error); return undefined; } } /** - * Check if the current request has a valid session + * Check if the current request has a valid WorkOS session * @param c Surface context * @returns JWT payload if valid session exists, false otherwise */ @@ -49,24 +153,61 @@ export const hasSession = async ( c: SurfaceContext, ): Promise => { try { - // Check for session cookie first - const sessionToken = c.var.cookies.get("session"); - if (sessionToken) { - const decoded = await c.var.jwt.verify(sessionToken, env("JWT_SECRET")); - return decoded; + // Check for WorkOS access token first + const accessToken = c.var.cookies.get("wos_access_token"); + if (accessToken) { + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(accessToken, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } catch (jwtError) { + c.var.logger.info("Access token validation failed, attempting refresh"); + // Try to refresh the token + const user = await refreshAndGetUser(c); + if (user) { + // Get the new access token after refresh + const newAccessToken = c.var.cookies.get("wos_access_token"); + if (newAccessToken) { + const { payload } = await jose.jwtVerify(newAccessToken, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } + } + return false; + } } // Fallback to Authorization header for API calls const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.replace("Bearer ", ""); - const decoded = await c.var.jwt.verify(token, env("JWT_SECRET")); - return decoded; + const JWKS = jose.createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${env("WORKOS_CLIENT_ID")}`), + ); + + try { + const { payload } = await jose.jwtVerify(token, JWKS, { + issuer: "https://api.workos.com", + }); + return payload; + } catch (jwtError) { + c.var.logger.error( + "Authorization header token validation failed", + jwtError, + ); + return false; + } } return false; } catch (error) { - c.var.logger.error("No valid session was found", error); + c.var.logger.error("No valid WorkOS session found", error); return false; } }; @@ -87,7 +228,63 @@ export const requireAuth = async ( } // Store the session data in context for use in handlers - // Note: Using a custom context extension since "session" is not in Dependencies (c as any).session = session; await next(); }; + +/** + * Get the current organization ID from the session + * @param c Surface context + * @returns Organization ID or undefined + */ +export async function getCurrentOrganizationId( + c: SurfaceContext, +): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return session.org_id as string; + } + return undefined; +} + +/** + * Get user permissions from the current session + * @param c Surface context + * @returns Array of permissions or empty array + */ +export async function getUserPermissions(c: SurfaceContext): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return (session.permissions as string[]) || []; + } + return []; +} + +/** + * Check if user has a specific permission + * @param c Surface context + * @param permission Permission to check + * @returns True if user has the permission + */ +export async function hasPermission( + c: SurfaceContext, + permission: string, +): Promise { + const permissions = await getUserPermissions(c); + return permissions.includes(permission); +} + +/** + * Get user role in the current organization + * @param c Surface context + * @returns User role or undefined + */ +export async function getUserRole( + c: SurfaceContext, +): Promise { + const session = await hasSession(c); + if (session && typeof session === "object") { + return session.role as string; + } + return undefined; +} diff --git a/service/members/members.endpoints.test.ts b/service/members/members.endpoints.test.ts index dd5ef1c..5ee62fb 100644 --- a/service/members/members.endpoints.test.ts +++ b/service/members/members.endpoints.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, mock, it } from "bun:test"; +import { expect, describe, mock, it, beforeEach } from "bun:test"; import { applyContext, cookies, @@ -9,6 +9,7 @@ import { logger } from "../../logger/logger"; import { members } from "./members.endpoints"; import { Hono } from "hono"; import { User } from "../auth/auth.endpoints"; +import * as jose from "jose"; describe("members endpoint tests", () => { const mockLogger = { @@ -18,7 +19,8 @@ describe("members endpoint tests", () => { } as unknown as typeof logger; const mockCookies = { - get: mock(() => "valid.jwt.token"), + get: mock(() => "valid.workos.access.token"), + set: mock(), } as unknown as ReturnType; const mockJwt = { @@ -28,6 +30,12 @@ describe("members endpoint tests", () => { sign: mock(() => Promise.resolve("token")), }; + const mockWorkOS = { + userManagement: { + authenticateWithRefreshToken: mock(), + }, + }; + // Create mocks with correct return types to avoid type errors const getMembersMock = mock<() => Promise>(); const getMemberMock = mock<() => Promise>(); @@ -56,11 +64,27 @@ describe("members endpoint tests", () => { logger: mockLogger, memberServiceClient: mockMemberServiceClient, cookies: mockCookies, - jwt: mockJwt, + jwt: mockJwt as any, + workos: mockWorkOS as any, }), ) .route("/api/members", members); + beforeEach(() => { + // Mock jose.jwtVerify to simulate valid WorkOS token + const mockPayload = { + sub: "user_123", + email: "test@example.com", + name: "Test User", + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 3600, + }; + + (jose as any).jwtVerify = mock(() => + Promise.resolve({ payload: mockPayload }), + ); + }); + it("returns members from api", async () => { // the mock service method delays for a second for UI, skipping this. const response = await testApp.request("/api/members"); diff --git a/state/user.store.ts b/state/user.store.ts index e833f7b..4c41160 100644 --- a/state/user.store.ts +++ b/state/user.store.ts @@ -26,9 +26,19 @@ export const useUserStore: StoreCreator = (set) => ({ }); registerLoader(async (c: SurfaceContext) => { - const user = await getUser(c); - logger.debug("loading user server side:", user); - return { - user, - }; + try { + const user = await getUser(c); + if (user) { + logger.debug(`User ${user.email} loaded for SSR`); + } + + return { + user, + }; + } catch (error) { + logger.error("Error loading user data server side:", error); + return { + user: undefined, + }; + } }); diff --git a/views/components/app-sidebar.tsx b/views/components/app-sidebar.tsx index c72b839..f12ded2 100644 --- a/views/components/app-sidebar.tsx +++ b/views/components/app-sidebar.tsx @@ -23,14 +23,10 @@ import { SidebarHeader, SidebarRail, } from "@/components/ui/sidebar"; +import { useAppState } from "@/hooks/use-app-state"; -// This is sample data. +// This is sample data for teams and navigation. const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, teams: [ { name: "Acme Inc", @@ -155,6 +151,21 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps) { + const { user } = useAppState(); + + // Use real user data if available, otherwise fallback to default + const userData = user + ? { + name: user.name, + email: user.email, + avatar: user.profilePicture || "/avatars/default.jpg", + } + : { + name: "Guest", + email: "guest@example.com", + avatar: "/avatars/default.jpg", + }; + return ( @@ -165,7 +176,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/views/components/nav-user.tsx b/views/components/nav-user.tsx index da3c46c..0869272 100644 --- a/views/components/nav-user.tsx +++ b/views/components/nav-user.tsx @@ -97,8 +97,10 @@ export function NavUser({ - - Log out + + + Log out + diff --git a/views/home.tsx b/views/home.tsx index c4cd920..be70d10 100644 --- a/views/home.tsx +++ b/views/home.tsx @@ -4,7 +4,7 @@ import { SurfaceIcon } from "./components/icon/SurfaceIcon"; import { Header } from "./header"; import { useAppState } from "./hooks/use-app-state"; function App() { - const { count, increment } = useAppState(); + const { count, increment, user } = useAppState(); return (
diff --git a/views/hooks/use-app-state.ts b/views/hooks/use-app-state.ts index b785b6b..0650af4 100644 --- a/views/hooks/use-app-state.ts +++ b/views/hooks/use-app-state.ts @@ -1,6 +1,7 @@ import { useAppStore } from "../../state/root.store"; import { RootContext } from "@/providers/root-context-provider"; import React from "react"; +import { User } from "../../service/auth/auth.endpoints"; export function useAppState() { const ssrContext = React.useContext(RootContext); @@ -10,3 +11,13 @@ export function useAppState() { ...ssrContext, }; } + +export function useUser(): User | undefined { + const { user } = useAppState(); + return user; +} + +export function useIsAuthenticated(): boolean { + const user = useUser(); + return !!user; +} diff --git a/views/ssr.ts b/views/ssr.ts index 4190d9c..0768577 100644 --- a/views/ssr.ts +++ b/views/ssr.ts @@ -45,10 +45,11 @@ export async function render(opts: { }); const ssrState = await loadServerRouterContext(opts.c); + const accessToken = opts.c.var.cookies.get("wos_access_token"); const rpcClient = hc(`${process.env.SELF_RPC_HOST}/`, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${opts.c.var.cookies.get("session")}`, + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, }); From e04861347894e92a7b0a37d18c91687ddd34ce1a Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 22:53:56 -0700 Subject: [PATCH 04/16] refactor: wiggle the LLM a bit more for working auth --- service/auth/auth.endpoint.test.ts | 50 ++++--- service/auth/auth.endpoints.ts | 58 +++----- service/auth/auth.helpers.test.ts | 171 ++++++++++++++-------- service/auth/auth.helpers.ts | 96 +++++++----- service/members/member.service.client.ts | 19 ++- service/members/members.endpoints.test.ts | 16 +- state/user.store.ts | 2 +- views/components/app-sidebar.tsx | 12 +- 8 files changed, 257 insertions(+), 167 deletions(-) diff --git a/service/auth/auth.endpoint.test.ts b/service/auth/auth.endpoint.test.ts index a968967..9abbc1d 100644 --- a/service/auth/auth.endpoint.test.ts +++ b/service/auth/auth.endpoint.test.ts @@ -7,6 +7,9 @@ import { WorkOS } from "@workos-inc/node"; import { errorHandler } from "../../handlers/error.handler"; describe("auth endpoints", () => { + // Set up test environment variables + process.env.WORKOS_CLIENT_ID = "client_test_123"; + process.env.WORKOS_REDIRECT_URI = "http://localhost:4000/auth/callback"; // Mock logger const mockLogger = { ...logger, @@ -15,7 +18,7 @@ describe("auth endpoints", () => { } as unknown as typeof logger; // Mock functions for WorkOS - const mockGetAuthorizationURL = mock(); + const mockGetAuthorizationUrl = mock(); const mockAuthenticateWithCode = mock(); const mockAuthenticateWithRefreshToken = mock(); const mockGetLogoutUrl = mock(); @@ -23,7 +26,7 @@ describe("auth endpoints", () => { // Mock WorkOS const mockWorkOS = { userManagement: { - getAuthorizationUrl: mockGetAuthorizationURL, + getAuthorizationUrl: mockGetAuthorizationUrl, authenticateWithCode: mockAuthenticateWithCode, authenticateWithRefreshToken: mockAuthenticateWithRefreshToken, getLogoutUrl: mockGetLogoutUrl, @@ -50,7 +53,7 @@ describe("auth endpoints", () => { (mockLogger.error as any).mockClear?.(); (mockCookies.get as any).mockClear?.(); (mockCookies.set as any).mockClear?.(); - mockGetAuthorizationURL.mockClear?.(); + mockGetAuthorizationUrl.mockClear?.(); mockAuthenticateWithCode.mockClear?.(); mockAuthenticateWithRefreshToken.mockClear?.(); mockGetLogoutUrl.mockClear?.(); @@ -75,16 +78,16 @@ describe("auth endpoints", () => { const mockAuthUrl = "https://api.workos.com/sso/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:4000/api/auth/callback"; - mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); const response = await testApp.request("/login"); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe(mockAuthUrl); - expect(mockGetAuthorizationURL).toHaveBeenCalledWith({ + expect(mockGetAuthorizationUrl).toHaveBeenCalledWith({ provider: "authkit", - redirectUri: "http://localhost:4000/api/auth/callback", - clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + redirectUri: "http://localhost:4000/auth/callback", + clientId: "client_test_123", state: "/", }); expect(mockLogger.info).toHaveBeenCalledWith( @@ -94,21 +97,21 @@ describe("auth endpoints", () => { it("should use return query parameter as state", async () => { const mockAuthUrl = "https://api.workos.com/sso/authorize"; - mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); await testApp.request("/login?return=/dashboard"); - expect(mockGetAuthorizationURL).toHaveBeenCalledWith({ + expect(mockGetAuthorizationUrl).toHaveBeenCalledWith({ provider: "authkit", - redirectUri: "http://localhost:4000/api/auth/callback", - clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + redirectUri: "http://localhost:4000/auth/callback", + clientId: "client_test_123", state: "/dashboard", }); }); it("should return JSON for test requests", async () => { const mockAuthUrl = "https://api.workos.com/sso/authorize"; - mockGetAuthorizationURL.mockReturnValue(mockAuthUrl); + mockGetAuthorizationUrl.mockReturnValue(mockAuthUrl); const response = await testApp.request("/login?test=true"); const body = await response.json(); @@ -118,7 +121,7 @@ describe("auth endpoints", () => { }); it("should handle WorkOS errors", async () => { - mockGetAuthorizationURL.mockImplementation(() => { + mockGetAuthorizationUrl.mockImplementation(() => { throw new Error("WorkOS error"); }); @@ -163,7 +166,7 @@ describe("auth endpoints", () => { expect(mockAuthenticateWithCode).toHaveBeenCalledWith({ code: "auth_code_123", - clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + clientId: "client_test_123", }); expect(mockCookies.set).toHaveBeenCalledWith( @@ -308,7 +311,10 @@ describe("auth endpoints", () => { it("should redirect to WorkOS logout URL when access token is present", async () => { // Mock getting an access token - (mockCookies.get as any).mockReturnValueOnce("mock.workos.access.token"); + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_access_token") return "mock.workos.access.token"; + return undefined; + }); // Mock WorkOS getLogoutUrl mockGetLogoutUrl.mockReturnValue( @@ -343,7 +349,10 @@ describe("auth endpoints", () => { }); it("should switch organization successfully", async () => { - (mockCookies.get as any).mockReturnValueOnce("valid.refresh.token"); + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_refresh_token") return "valid.refresh.token"; + return undefined; + }); const response = await testApp.request("/switch-organization", { method: "POST", @@ -357,13 +366,13 @@ describe("auth endpoints", () => { expect(mockAuthenticateWithRefreshToken).toHaveBeenCalledWith({ refreshToken: "valid.refresh.token", - clientId: "client_01HRQ08WC5PECTJJFEVW410MC5", + clientId: "client_test_123", organizationId: "org_123", }); }); it("should return 401 when no refresh token", async () => { - (mockCookies.get as any).mockReturnValueOnce(undefined); + (mockCookies.get as any).mockImplementation(() => undefined); const response = await testApp.request("/switch-organization", { method: "POST", @@ -377,7 +386,10 @@ describe("auth endpoints", () => { }); it("should handle WorkOS errors", async () => { - (mockCookies.get as any).mockReturnValueOnce("valid.refresh.token"); + (mockCookies.get as any).mockImplementation((key: string) => { + if (key === "wos_refresh_token") return "valid.refresh.token"; + return undefined; + }); mockAuthenticateWithRefreshToken.mockRejectedValue( new Error("Organization not accessible"), ); diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index 00cf2a2..9f98faf 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -5,17 +5,21 @@ import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; import { z } from "zod"; import { AuthError } from "../../handlers/error.handler"; -import * as jose from "jose"; export type User = { id: string; - name: string; - email: string; - roles: string[]; - profilePicture?: string; - organizationId?: string; + email?: string; + given_name?: string; + family_name?: string; + name?: string; + picture?: string; + org_id?: string; role?: string; permissions?: string[]; + // Legacy fields for backward compatibility + profilePicture?: string; + organizationId?: string; + roles?: string[]; }; // Zod schemas for OpenAPI documentation @@ -38,13 +42,18 @@ const switchOrganizationResponse = z.object({ const userResponse = z.object({ id: z.string(), - name: z.string(), - email: z.string(), - roles: z.array(z.string()), - profilePicture: z.string().optional(), - organizationId: z.string().optional(), + email: z.string().optional(), + given_name: z.string().optional(), + family_name: z.string().optional(), + name: z.string().optional(), + picture: z.string().optional(), + org_id: z.string().optional(), role: z.string().optional(), permissions: z.array(z.string()).optional(), + // Legacy fields for backward compatibility + profilePicture: z.string().optional(), + organizationId: z.string().optional(), + roles: z.array(z.string()).optional(), }); export const sessions = new Hono() @@ -194,8 +203,6 @@ export const sessions = new Hono() logger.info("User logging out"); try { - const accessToken = cookies.get("wos_access_token"); - // Clear all auth cookies first cookies.set("wos_access_token", "", { path: "/", @@ -220,28 +227,9 @@ export const sessions = new Hono() return c.json({ message: "Logged out successfully" }); } - if (accessToken) { - try { - // Extract session ID from WorkOS access token - const decoded = jose.decodeJwt(accessToken); - const sessionId = decoded.sid as string; - - if (sessionId) { - // Redirect to WorkOS logout endpoint to properly end the session - const logoutUrl = workos.userManagement.getLogoutUrl({ - sessionId, - }); - return c.redirect(logoutUrl); - } - } catch (decodeError) { - logger.error( - "Failed to decode access token for logout", - decodeError, - ); - } - } - - // Fallback redirect if we can't get session ID + // Simple logout: just clear cookies and redirect + // Note: This doesn't invalidate the WorkOS session, but clears our local session + logger.info("Performing local logout - clearing session cookies"); const redirectTo = c.req.query("return") ?? "/"; return c.redirect(redirectTo); } catch (error) { diff --git a/service/auth/auth.helpers.test.ts b/service/auth/auth.helpers.test.ts index 481ae27..546bb7f 100644 --- a/service/auth/auth.helpers.test.ts +++ b/service/auth/auth.helpers.test.ts @@ -1,13 +1,17 @@ -import { expect, it, mock, describe, beforeEach } from "bun:test"; +import { expect, it, mock, describe, beforeEach, spyOn } from "bun:test"; import { getUser, hasSession, requireAuth } from "./auth.helpers"; import { SurfaceContext } from "../../surface.app.ctx"; import { WorkOS } from "@workos-inc/node"; import * as jose from "jose"; describe("auth helpers", () => { + // Set up test environment variables + process.env.WORKOS_CLIENT_ID = "client_test_123"; + const mockLogger = { error: mock(), info: mock(), + debug: mock(), }; const mockCookies = { @@ -20,9 +24,12 @@ describe("auth helpers", () => { sign: mock(), }; + const mockGetUser = mock(); + const mockWorkOS = { userManagement: { authenticateWithRefreshToken: mock(), + getUser: mockGetUser, }, } as unknown as WorkOS; @@ -43,14 +50,18 @@ describe("auth helpers", () => { } as unknown as SurfaceContext; }; + let jwtVerifySpy: any; + beforeEach(() => { - // Reset all mocks without restoring them - (mockLogger.error as any).mockClear?.(); - (mockLogger.info as any).mockClear?.(); - (mockCookies.get as any).mockClear?.(); - (mockCookies.set as any).mockClear?.(); - (mockJwt.verify as any).mockClear?.(); - (mockJwt.sign as any).mockClear?.(); + // Reset existing spy or create new one + if (jwtVerifySpy) { + jwtVerifySpy.mockClear(); + } else { + jwtVerifySpy = spyOn(jose, "jwtVerify"); + } + + // Reset WorkOS API mocks + mockGetUser.mockClear(); }); describe("getUser", () => { @@ -58,7 +69,10 @@ describe("auth helpers", () => { const mockPayload = { sub: "user_123", email: "john@example.com", + given_name: "John", + family_name: "Doe", name: "John Doe", + picture: undefined, iat: 1234567890, exp: 1234567990, org_id: "org_123", @@ -66,56 +80,76 @@ describe("auth helpers", () => { permissions: ["read", "write"], }; - (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); - // Mock jose.jwtVerify - const mockJwtVerify = mock(() => - Promise.resolve({ payload: mockPayload }), - ); - (jose as any).jwtVerify = mockJwtVerify; + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); + + // Mock WorkOS User Management API response + mockGetUser.mockResolvedValue({ + id: "user_123", + email: "john@example.com", + firstName: "John", + lastName: "Doe", + profilePictureUrl: null, + }); - const context = createMockContext(); const user = await getUser(context); expect(user).toEqual({ id: "user_123", - name: "John Doe", email: "john@example.com", - roles: [], - profilePicture: undefined, - organizationId: "org_123", + given_name: "John", + family_name: "Doe", + name: "John Doe", + picture: null, + org_id: "org_123", role: "admin", permissions: ["read", "write"], + // Legacy compatibility + profilePicture: null, + organizationId: "org_123", + roles: ["read", "write"], }); - expect(mockCookies.get).toHaveBeenCalledWith("wos_access_token"); + expect(context.var.cookies.get).toHaveBeenCalledWith("wos_access_token"); + expect(mockGetUser).toHaveBeenCalledWith("user_123"); }); it("should return undefined when no access token and refresh fails", async () => { - (mockCookies.get as any).mockReturnValue(undefined); - - const context = createMockContext(); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); const user = await getUser(context); expect(user).toBeUndefined(); }); it("should attempt refresh when JWT verification fails", async () => { - (mockCookies.get as any) + const mockGet = mock() .mockReturnValueOnce("invalid.access.token") .mockReturnValueOnce("valid.refresh.token") .mockReturnValueOnce(undefined); // No new access token after failed refresh - const mockJwtVerify = mock(() => - Promise.reject(new Error("Invalid token")), - ); - (jose as any).jwtVerify = mockJwtVerify; + const context = createMockContext({ + cookies: { + get: mockGet, + set: mock(), + }, + }); + + jwtVerifySpy.mockRejectedValue(new Error("Invalid token")); ( mockWorkOS.userManagement.authenticateWithRefreshToken as any ).mockRejectedValue(new Error("Refresh failed")); - - const context = createMockContext(); const user = await getUser(context); expect(user).toBeUndefined(); @@ -136,14 +170,15 @@ describe("auth helpers", () => { exp: 1234567990, }; - (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); - const mockJwtVerify = mock(() => - Promise.resolve({ payload: mockPayload }), - ); - (jose as any).jwtVerify = mockJwtVerify; + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); - const context = createMockContext(); const session = await hasSession(context); expect(session).toEqual(mockPayload); @@ -158,17 +193,17 @@ describe("auth helpers", () => { exp: 1234567990, }; - (mockCookies.get as any).mockReturnValue(undefined); - - const context = createMockContext(); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); context.req.header = mock().mockReturnValue( "Bearer valid.workos.access.token", ); - const mockJwtVerify = mock(() => - Promise.resolve({ payload: mockPayload }), - ); - (jose as any).jwtVerify = mockJwtVerify; + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); const session = await hasSession(context); @@ -177,9 +212,12 @@ describe("auth helpers", () => { }); it("should return false when no token available", async () => { - (mockCookies.get as any).mockReturnValue(undefined); - - const context = createMockContext(); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); context.req.header = mock().mockReturnValue(undefined); const session = await hasSession(context); @@ -188,26 +226,28 @@ describe("auth helpers", () => { }); it("should return false when JWT verification fails and refresh fails", async () => { - (mockCookies.get as any) + const mockGet = mock() .mockReturnValueOnce("invalid.token") .mockReturnValueOnce("valid.refresh.token") .mockReturnValueOnce(undefined); // No new access token - const mockJwtVerify = mock(() => - Promise.reject(new Error("Invalid token")), - ); - (jose as any).jwtVerify = mockJwtVerify; + const context = createMockContext({ + cookies: { + get: mockGet, + set: mock(), + }, + }); + + jwtVerifySpy.mockRejectedValue(new Error("Invalid token")); ( mockWorkOS.userManagement.authenticateWithRefreshToken as any ).mockRejectedValue(new Error("Refresh failed")); - - const context = createMockContext(); const session = await hasSession(context); expect(session).toBe(false); expect(mockLogger.error).toHaveBeenCalledWith( - "No valid WorkOS session found", + "Failed to refresh WorkOS tokens", expect.any(Error), ); }); @@ -216,14 +256,16 @@ describe("auth helpers", () => { describe("requireAuth", () => { it("should call next() for authenticated requests", async () => { const mockPayload = { sub: "user_123" }; - (mockCookies.get as any).mockReturnValue("valid.workos.access.token"); - const mockJwtVerify = mock(() => - Promise.resolve({ payload: mockPayload }), - ); - (jose as any).jwtVerify = mockJwtVerify; + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue("valid.workos.access.token"), + set: mock(), + }, + }); + + jwtVerifySpy.mockResolvedValue({ payload: mockPayload }); - const context = createMockContext(); const next = mock(); await requireAuth(context, next); @@ -233,9 +275,12 @@ describe("auth helpers", () => { }); it("should return 401 for unauthenticated requests", async () => { - (mockCookies.get as any).mockReturnValue(undefined); - - const context = createMockContext(); + const context = createMockContext({ + cookies: { + get: mock().mockReturnValue(undefined), + set: mock(), + }, + }); context.req.header = mock().mockReturnValue(undefined); context.json = mock().mockReturnValue(new Response()); diff --git a/service/auth/auth.helpers.ts b/service/auth/auth.helpers.ts index 91820c3..f91cdcc 100644 --- a/service/auth/auth.helpers.ts +++ b/service/auth/auth.helpers.ts @@ -28,38 +28,60 @@ export async function getUser(c: SurfaceContext): Promise { issuer: "https://api.workos.com", }); - // Extract user info from WorkOS JWT claims - // Try different possible field names that WorkOS might use - const email = - (payload.email as string) || ((payload as any).user_email as string); - const name = - (payload.name as string) || - ((payload as any).user_name as string) || - ((payload as any).given_name as string) || - ((payload as any).first_name as string) || - email; - - const user: User = { - id: payload.sub as string, - name: name || email, - email: email, - roles: [], // Could be enhanced to derive from payload - profilePicture: - (payload.picture as string) || - ((payload as any).profile_picture_url as string), - // Organization context from JWT - organizationId: - (payload.org_id as string) || - ((payload as any).organization_id as string), - role: - (payload.role as string) || ((payload as any).user_role as string), - permissions: - (payload.permissions as string[]) || - ((payload as any).user_permissions as string[]), - }; - - c.var.logger.debug(`User ${user.email} authenticated from WorkOS token`); - return user; + // Validate we got a payload + if (!payload || !payload.sub) { + c.var.logger.error("Invalid JWT payload - missing sub claim"); + throw new Error("Invalid JWT payload"); + } + + // WorkOS JWT only contains minimal claims (sub, org_id, role, permissions) + // We need to fetch full user details from the User Management API + const userId = payload.sub as string; + + try { + // Fetch user details from WorkOS User Management API + const workosUser = await c.var.workos.userManagement.getUser(userId); + + const user: User = { + id: workosUser.id, + email: workosUser.email, + given_name: workosUser.firstName, + family_name: workosUser.lastName, + name: + `${workosUser.firstName || ""} ${workosUser.lastName || ""}`.trim() || + workosUser.email, + picture: workosUser.profilePictureUrl, + org_id: payload.org_id as string, + role: payload.role as string, + permissions: payload.permissions as string[], + // Legacy compatibility mappings + profilePicture: workosUser.profilePictureUrl, + organizationId: payload.org_id as string, + roles: payload.permissions as string[], + }; + + c.var.logger.info(`User authenticated: ${user.email} (${user.name})`); + return user; + } catch (userFetchError) { + c.var.logger.error( + "Failed to fetch user details from WorkOS", + userFetchError, + ); + + // Fallback: create minimal user object from JWT claims only + const user: User = { + id: userId, + org_id: payload.org_id as string, + role: payload.role as string, + permissions: payload.permissions as string[], + // Legacy compatibility mappings + organizationId: payload.org_id as string, + roles: payload.permissions as string[], + }; + + c.var.logger.info(`User authenticated (minimal): ${user.id}`); + return user; + } } catch (jwtError) { c.var.logger.debug("Access token validation failed, attempting refresh"); // Token might be expired, try to refresh @@ -127,15 +149,19 @@ async function refreshAndGetUser(c: SurfaceContext): Promise { // Convert WorkOS user to our User type const surfaceUser: User = { id: user.id, + email: user.email, + given_name: user.firstName ?? undefined, + family_name: user.lastName ?? undefined, name: `${user.firstName || ""} ${user.lastName || ""}`.trim() || user.email, - email: user.email, - roles: [], + picture: user.profilePictureUrl ?? undefined, + // Legacy compatibility profilePicture: user.profilePictureUrl ?? undefined, + roles: [], }; c.var.logger.info( - `Successfully refreshed tokens for user ${surfaceUser.email}`, + `Successfully refreshed tokens for user ${surfaceUser.email || surfaceUser.id}`, ); return surfaceUser; } catch (error) { diff --git a/service/members/member.service.client.ts b/service/members/member.service.client.ts index 9c9e22b..9b578a5 100644 --- a/service/members/member.service.client.ts +++ b/service/members/member.service.client.ts @@ -3,18 +3,27 @@ import { User } from "../auth/auth.endpoints"; const fakeMembers: User[] = [ { id: "123", - name: "Alice", email: "alice@domain.com", - roles: ["admin"], + given_name: "Alice", + family_name: "Smith", + name: "Alice Smith", + picture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + // Legacy compatibility profilePicture: "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + roles: ["admin"], }, { - name: "Bob", - email: "bob@member.net", id: "2", - roles: [], + email: "bob@member.net", + given_name: "Bob", + family_name: "Johnson", + name: "Bob Johnson", + picture: "", + // Legacy compatibility profilePicture: "", + roles: [], }, ]; diff --git a/service/members/members.endpoints.test.ts b/service/members/members.endpoints.test.ts index 5ee62fb..be6233d 100644 --- a/service/members/members.endpoints.test.ts +++ b/service/members/members.endpoints.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, mock, it, beforeEach } from "bun:test"; +import { expect, describe, mock, it, beforeEach, spyOn } from "bun:test"; import { applyContext, cookies, @@ -42,11 +42,16 @@ describe("members endpoint tests", () => { const fakeUser: User = { id: "123", - name: "Alice", email: "alice@domain.com", - roles: ["admin"], + given_name: "Alice", + family_name: "Smith", + name: "Alice Smith", + picture: + "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + // Legacy compatibility profilePicture: "https://plus.unsplash.com/premium_photo-1672201106204-58e9af7a2888?q=80&w=80", + roles: ["admin"], }; // Set default implementation @@ -80,9 +85,8 @@ describe("members endpoint tests", () => { exp: Date.now() / 1000 + 3600, }; - (jose as any).jwtVerify = mock(() => - Promise.resolve({ payload: mockPayload }), - ); + // Use spyOn to mock jose.jwtVerify properly + spyOn(jose, "jwtVerify").mockResolvedValue({ payload: mockPayload }); }); it("returns members from api", async () => { diff --git a/state/user.store.ts b/state/user.store.ts index 4c41160..bac7473 100644 --- a/state/user.store.ts +++ b/state/user.store.ts @@ -29,7 +29,7 @@ registerLoader(async (c: SurfaceContext) => { try { const user = await getUser(c); if (user) { - logger.debug(`User ${user.email} loaded for SSR`); + logger.debug(`User ${user.email || user.id || "unknown"} loaded for SSR`); } return { diff --git a/views/components/app-sidebar.tsx b/views/components/app-sidebar.tsx index f12ded2..0fcd480 100644 --- a/views/components/app-sidebar.tsx +++ b/views/components/app-sidebar.tsx @@ -156,9 +156,15 @@ export function AppSidebar({ ...props }: React.ComponentProps) { // Use real user data if available, otherwise fallback to default const userData = user ? { - name: user.name, - email: user.email, - avatar: user.profilePicture || "/avatars/default.jpg", + name: + user.name || + (user.given_name && user.family_name + ? `${user.given_name} ${user.family_name}`.trim() + : user.given_name || user.family_name) || + user.email || + "Unknown User", + email: user.email || "unknown@example.com", + avatar: user.picture || user.profilePicture || "/avatars/default.jpg", } : { name: "Guest", From 3f3ca6cbaa57804b19f7696650cd0a393a358ade Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 22:56:03 -0700 Subject: [PATCH 05/16] chore: clean up more LLM slop --- service/auth/auth.endpoints.ts | 2 +- service/auth/auth.helpers.test.ts | 4 ++-- service/auth/auth.helpers.ts | 8 ++++---- service/members/members.endpoints.test.ts | 2 +- views/home.tsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index 9f98faf..b4fbdc6 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -198,7 +198,7 @@ export const sessions = new Hono() }, }), async (c) => { - const { cookies, logger, workos } = c.var; + const { cookies, logger } = c.var; logger.info("User logging out"); diff --git a/service/auth/auth.helpers.test.ts b/service/auth/auth.helpers.test.ts index 546bb7f..d6e2bd5 100644 --- a/service/auth/auth.helpers.test.ts +++ b/service/auth/auth.helpers.test.ts @@ -106,12 +106,12 @@ describe("auth helpers", () => { given_name: "John", family_name: "Doe", name: "John Doe", - picture: null, + picture: "", org_id: "org_123", role: "admin", permissions: ["read", "write"], // Legacy compatibility - profilePicture: null, + profilePicture: "", organizationId: "org_123", roles: ["read", "write"], }); diff --git a/service/auth/auth.helpers.ts b/service/auth/auth.helpers.ts index f91cdcc..e21a5ba 100644 --- a/service/auth/auth.helpers.ts +++ b/service/auth/auth.helpers.ts @@ -45,17 +45,17 @@ export async function getUser(c: SurfaceContext): Promise { const user: User = { id: workosUser.id, email: workosUser.email, - given_name: workosUser.firstName, - family_name: workosUser.lastName, + given_name: workosUser.firstName ?? "", + family_name: workosUser.lastName ?? "", name: `${workosUser.firstName || ""} ${workosUser.lastName || ""}`.trim() || workosUser.email, - picture: workosUser.profilePictureUrl, + picture: workosUser.profilePictureUrl ?? "", org_id: payload.org_id as string, role: payload.role as string, permissions: payload.permissions as string[], // Legacy compatibility mappings - profilePicture: workosUser.profilePictureUrl, + profilePicture: workosUser.profilePictureUrl ?? "", organizationId: payload.org_id as string, roles: payload.permissions as string[], }; diff --git a/service/members/members.endpoints.test.ts b/service/members/members.endpoints.test.ts index be6233d..97d2cb9 100644 --- a/service/members/members.endpoints.test.ts +++ b/service/members/members.endpoints.test.ts @@ -86,7 +86,7 @@ describe("members endpoint tests", () => { }; // Use spyOn to mock jose.jwtVerify properly - spyOn(jose, "jwtVerify").mockResolvedValue({ payload: mockPayload }); + spyOn(jose, "jwtVerify").mockResolvedValue({ payload: mockPayload } as any); }); it("returns members from api", async () => { diff --git a/views/home.tsx b/views/home.tsx index be70d10..c4cd920 100644 --- a/views/home.tsx +++ b/views/home.tsx @@ -4,7 +4,7 @@ import { SurfaceIcon } from "./components/icon/SurfaceIcon"; import { Header } from "./header"; import { useAppState } from "./hooks/use-app-state"; function App() { - const { count, increment, user } = useAppState(); + const { count, increment } = useAppState(); return (
From 2ec26d5a5348edea5be575b2ede9c28a79657bc0 Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 22:58:58 -0700 Subject: [PATCH 06/16] chore: use workos defaults in deploy script --- .github/workflows/deploy.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 30a5c64..0d7563e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -10,6 +10,10 @@ jobs: runs-on: ubuntu-latest env: JWT_SECRET: "test.fake.secret" + WORKOS_API_KEY: "test.fake.workos.api.key" + WORKOS_CLIENT_ID: "test.fake.client.id" + WORKOS_REDIRECT_URI: "http://localhost:3000/auth/callback" + NODE_ENV: "test" steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 From c3e1233db2be952254d4f482ebd41d24fc75ffff Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 23:04:04 -0700 Subject: [PATCH 07/16] chore: ye ole packae lock for npm install, let's try bun soon --- package-lock.json | 1023 +++++++++++++++++++++++++++++++++------------ package.json | 2 +- 2 files changed, 756 insertions(+), 269 deletions(-) diff --git a/package-lock.json b/package-lock.json index f87ca42..7d168d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@tanstack/react-router": "^1.115.0", "@tanstack/react-start": "^1.115.1", "@tanstack/router-plugin": "^1.115.0", + "@workos-inc/node": "^7.69.1", "bunyan": "^1.8.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -31,6 +32,7 @@ "hono": "^4.8.2", "hono-openapi": "^0.4.6", "install": "^0.13.0", + "jose": "^6.1.0", "lucide-react": "^0.509.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -58,7 +60,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", - "eslint": "^9.29.0", + "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "glob": "^10.3.12", @@ -892,79 +894,16 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", + "espree": "^9.6.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -972,7 +911,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -983,7 +922,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -994,7 +932,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1003,53 +940,12 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@fastify/busboy": { @@ -1138,42 +1034,41 @@ "zod": "^3.19.1" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, "engines": { - "node": ">=18.18.0" + "node": ">=10.10.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "brace-expansion": "^1.1.7" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": "*" } }, "node_modules/@humanwhocodes/module-importer": { @@ -1188,19 +1083,12 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true }, "node_modules/@ioredis/commands": { "version": "1.2.0", @@ -1691,6 +1579,42 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.4.0.tgz", + "integrity": "sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4054,6 +3978,14 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "license": "MIT", @@ -4088,6 +4020,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/braces": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", @@ -4118,6 +4059,35 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==" + }, + "node_modules/@types/cookie": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" + }, + "node_modules/@types/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4133,10 +4103,70 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "license": "MIT" }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/micromatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.7.tgz", @@ -4145,6 +4175,11 @@ "@types/braces": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -4153,7 +4188,6 @@ }, "node_modules/@types/node": { "version": "20.12.5", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4164,6 +4198,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, "node_modules/@types/react": { "version": "18.2.74", "devOptional": true, @@ -4192,6 +4236,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.10", "dev": true, @@ -4386,6 +4449,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, "node_modules/@vercel/nft": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.29.2.tgz", @@ -4465,6 +4534,29 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@workos-inc/node": { + "version": "7.69.1", + "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-7.69.1.tgz", + "integrity": "sha512-ml2TqUHjUVkubq4EnIIM1O1g+eR0ctKnpdHUJntG/1PuVt64CfntJrAUi/5ePgR4d12EeXunHyjOTK75k+f9Ww==", + "dependencies": { + "iron-session": "~6.3.1", + "jose": "~5.6.3", + "leb": "^1.0.0", + "pluralize": "8.0.0", + "qs": "6.14.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@workos-inc/node/node_modules/jose": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", + "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/abbrev": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", @@ -4509,7 +4601,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4535,7 +4626,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4700,6 +4790,19 @@ "printable-characters": "^1.0.42" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5053,12 +5156,38 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -5604,6 +5733,18 @@ "node": ">=8" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -5653,6 +5794,19 @@ "node": ">=0.10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -5696,11 +5850,38 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -6095,64 +6276,59 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", + "cross-spawn": "^7.0.2", "debug": "^4.3.2", + "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3" + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-plugin-react-hooks": { @@ -6175,17 +6351,16 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6202,19 +6377,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "dev": true, @@ -6236,31 +6398,17 @@ } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "eslint-visitor-keys": "^3.4.1" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6282,7 +6430,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -6383,8 +6530,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -6420,8 +6566,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -6450,16 +6595,15 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=16.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/file-uri-to-path": { @@ -6494,25 +6638,24 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.4" + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">=16" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/follow-redirects": { "version": "1.15.6", @@ -6606,6 +6749,29 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6619,6 +6785,18 @@ "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==" }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-source": { "version": "2.0.12", "dev": true, @@ -6721,13 +6899,15 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6761,6 +6941,17 @@ "csstype": "^3.0.10" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6810,6 +7001,17 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -6996,7 +7198,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7069,6 +7270,63 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/iron-session": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz", + "integrity": "sha512-3UJ7y2vk/WomAtEySmPgM6qtYF1cZ3tXuWX5GsVX4PJXAcs5y/sV9HuSfpjKS6HkTL/OhZcTDWJNLZ7w+Erx3A==", + "dependencies": { + "@peculiar/webcrypto": "^1.4.0", + "@types/cookie": "^0.5.1", + "@types/express": "^4.17.13", + "@types/koa": "^2.13.5", + "@types/node": "^17.0.41", + "cookie": "^0.5.0", + "iron-webcrypto": "^0.2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "express": ">=4", + "koa": ">=2", + "next": ">=10" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "koa": { + "optional": true + }, + "next": { + "optional": true + } + } + }, + "node_modules/iron-session/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" + }, + "node_modules/iron-session/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iron-session/node_modules/iron-webcrypto": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.2.8.tgz", + "integrity": "sha512-YPdCvjFMOBjXaYuDj5tiHst5CEk6Xw84Jo8Y2+jzhMceclAnb3+vNPP/CTtb5fO2ZEuXEaO4N+w62Vfko757KA==", + "dependencies": { + "buffer": "^6" + }, + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -7179,6 +7437,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-reference": { "version": "1.2.1", "license": "MIT", @@ -7264,6 +7531,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -7293,15 +7568,13 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-walker": { "version": "2.0.0", @@ -7340,7 +7613,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -7404,6 +7676,11 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/leb/-/leb-1.0.0.tgz", + "integrity": "sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==" + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -7830,6 +8107,14 @@ "source-map-js": "^1.2.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8692,6 +8977,17 @@ "pathe": "^2.0.3" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ofetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", @@ -8835,7 +9131,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -8861,8 +9156,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -8942,6 +9237,14 @@ "pathe": "^1.1.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -9031,11 +9334,40 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -9302,7 +9634,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -9323,6 +9654,65 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -9628,6 +10018,74 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "license": "ISC", @@ -9837,7 +10295,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -9971,6 +10428,12 @@ "b4a": "^1.6.4" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10038,8 +10501,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "license": "0BSD" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tslog": { "version": "4.9.3", @@ -10174,6 +10638,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.4.4", "dev": true, @@ -10599,7 +11075,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -11598,6 +12073,18 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index bb02a51..d0e81fb 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", - "eslint": "^9.29.0", + "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "glob": "^10.3.12", From 2f205e867753de60c9413428079f8d040d77c032 Mon Sep 17 00:00:00 2001 From: cif Date: Mon, 1 Sep 2025 23:14:34 -0700 Subject: [PATCH 08/16] chore: update workos secrets for workingd deploy --- .github/workflows/deploy.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0d7563e..558111b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -9,7 +9,6 @@ jobs: name: "Test" runs-on: ubuntu-latest env: - JWT_SECRET: "test.fake.secret" WORKOS_API_KEY: "test.fake.workos.api.key" WORKOS_CLIENT_ID: "test.fake.client.id" WORKOS_REDIRECT_URI: "http://localhost:3000/auth/callback" @@ -59,14 +58,15 @@ jobs: - name: "Deploy" run: | - echo "***DO NOT USE*** THESE VALUES IN PRODUCTION!" - echo "USE SECRETS MANAGER FOR JWT SIGNING KEY, OR A THIRD PARTY PROVIDER LIKE HASHICORP." echo "DEBUG: surface:*" >> env.yaml echo "SELF_RPC_HOST: https://surface-demo-app-5v6fvk5ela-uw.a.run.app/" >> env.yaml - echo "JWT_SECRET: sup4h.secr1t.jwt.🔑" >> env.yaml + echo "NODE_ENV: production" >> env.yaml + + echo "WORKOS_REDIRECT_URI: https://surface-demo-app-5v6fvk5ela-uw.a.run.app/auth/callback" >> env.yaml gcloud run deploy surface-demo-app \ --image "${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6}" \ --env-vars-file env.yaml \ + --set-secrets="WORKOS_API_KEY=WORKOS_API_KEY:latest,WORKOS_CLIENT_ID=WORKOS_CLIENT_ID:latest" \ --service-account ${{ secrets.GCP_APP_SERVICE_ACCOUNT }} \ --region us-west1 \ --allow-unauthenticated From 4f6dc861b748ccf0943186228b8bc34f181695da Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 07:45:38 -0700 Subject: [PATCH 09/16] chore: clean up env vars use subdomain for redirect --- .github/workflows/deploy.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 558111b..0b5eb6f 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -59,10 +59,9 @@ jobs: - name: "Deploy" run: | echo "DEBUG: surface:*" >> env.yaml - echo "SELF_RPC_HOST: https://surface-demo-app-5v6fvk5ela-uw.a.run.app/" >> env.yaml + echo "SELF_RPC_HOST: https://surface.makeitstable.com/" >> env.yaml echo "NODE_ENV: production" >> env.yaml - - echo "WORKOS_REDIRECT_URI: https://surface-demo-app-5v6fvk5ela-uw.a.run.app/auth/callback" >> env.yaml + echo "WORKOS_REDIRECT_URI: https://surface.makeitstable.com/auth/callback" >> env.yaml gcloud run deploy surface-demo-app \ --image "${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6}" \ --env-vars-file env.yaml \ From 8f1971de336b68ce47636feb61317182b42b6297 Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 08:15:23 -0700 Subject: [PATCH 10/16] chore: try bun --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0b5eb6f..cdcae8d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -39,7 +39,7 @@ jobs: - name: "Push to AR" run: | docker build . \ - -f Dockerfile.node \ + -f Dockerfile.bun \ -t ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} \ -t ${{ secrets.GCP_AR_PATH }}/app:latest docker push ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} From 7a827aac3c22702ea1384c48761bb97efaede0b5 Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 15:59:34 -0700 Subject: [PATCH 11/16] chore: not bun yet, have to sort client build dependencies first, style, manifest etc --- .github/workflows/deploy.yaml | 6 +++--- docker-compose.yaml | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index cdcae8d..7559b0e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -9,8 +9,8 @@ jobs: name: "Test" runs-on: ubuntu-latest env: - WORKOS_API_KEY: "test.fake.workos.api.key" - WORKOS_CLIENT_ID: "test.fake.client.id" + WORKOS_API_KEY: "test.workos.api.key" + WORKOS_CLIENT_ID: "test.client.id" WORKOS_REDIRECT_URI: "http://localhost:3000/auth/callback" NODE_ENV: "test" steps: @@ -39,7 +39,7 @@ jobs: - name: "Push to AR" run: | docker build . \ - -f Dockerfile.bun \ + -f Dockerfile.node \ -t ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} \ -t ${{ secrets.GCP_AR_PATH }}/app:latest docker push ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} diff --git a/docker-compose.yaml b/docker-compose.yaml index 89d358b..50fe160 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,7 +33,8 @@ services: - DEBUG=surface:* - JWT_SECRET=foo.bar.baz - SELF_RPC_HOST=http://dev:4000 - + env_file: + - .env ports: - "4000:4000" From 9425d048c31446950f95be87fba7ad964460675a Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 16:04:21 -0700 Subject: [PATCH 12/16] feat: okay, now try bun --- .github/workflows/deploy.yaml | 2 +- Dockerfile.bun | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7559b0e..9b1fbb9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -39,7 +39,7 @@ jobs: - name: "Push to AR" run: | docker build . \ - -f Dockerfile.node \ + -f Dockerfile.bun \ -t ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} \ -t ${{ secrets.GCP_AR_PATH }}/app:latest docker push ${{ secrets.GCP_AR_PATH }}/app:${GITHUB_SHA::6} diff --git a/Dockerfile.bun b/Dockerfile.bun index cb7a56c..7eaaf6a 100644 --- a/Dockerfile.bun +++ b/Dockerfile.bun @@ -13,5 +13,5 @@ FROM base as dev CMD ["bun", "run", "dev:docker"] FROM base as prod -RUN bunx tsr generate -CMD ["bun", "surface.server.bun.ts"] \ No newline at end of file +RUN bun run build +CMD ["bun", "surface.server.bun.ts"] From d5002b7a986591d93439fee438364c5a7b34fe48 Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 16:22:30 -0700 Subject: [PATCH 13/16] fix: use workos logout to actually log out --- service/auth/auth.endpoints.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index b4fbdc6..00a1908 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -187,7 +187,7 @@ export const sessions = new Hono() description: "Clear user session and logout from WorkOS", responses: { 302: { - description: "Redirect to WorkOS logout URL or specified location", + description: "Redirect to WorkOS logout URL to terminate session", }, 200: { description: "Logout confirmation (for testing)", @@ -198,7 +198,7 @@ export const sessions = new Hono() }, }), async (c) => { - const { cookies, logger } = c.var; + const { cookies, logger, workos } = c.var; logger.info("User logging out"); @@ -227,15 +227,20 @@ export const sessions = new Hono() return c.json({ message: "Logged out successfully" }); } - // Simple logout: just clear cookies and redirect - // Note: This doesn't invalidate the WorkOS session, but clears our local session - logger.info("Performing local logout - clearing session cookies"); + // Get the return URL for after logout const redirectTo = c.req.query("return") ?? "/"; - return c.redirect(redirectTo); + + // Create WorkOS logout URL that will properly terminate the session + const logoutUrl = workos.userManagement.getLogoutUrl({ + redirectUri: `${env("BASE_URL")}${redirectTo}`, // Full URL where to redirect after logout + }); + + logger.info("Redirecting to WorkOS logout URL to terminate session"); + return c.redirect(logoutUrl); } catch (error) { logger.error("Error during logout", error); - // Always redirect somewhere even if logout fails + // Fallback: just redirect locally if WorkOS logout fails const redirectTo = c.req.query("return") ?? "/"; return c.redirect(redirectTo); } From 987004fef251961128b93867ce6c46a2494df250 Mon Sep 17 00:00:00 2001 From: cif Date: Tue, 2 Sep 2025 16:37:09 -0700 Subject: [PATCH 14/16] fix: logout again, using docs ref this time --- service/auth/auth.endpoints.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index 00a1908..6f0dc28 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; import { z } from "zod"; import { AuthError } from "../../handlers/error.handler"; +import * as jose from "jose"; export type User = { id: string; @@ -203,6 +204,24 @@ export const sessions = new Hono() logger.info("User logging out"); try { + // Get the access token to extract session ID + const accessToken = cookies.get("wos_access_token"); + let sessionId: string | undefined; + + if (accessToken) { + try { + // Extract session ID from the access token + const payload = jose.decodeJwt(accessToken); + sessionId = payload.sid as string; + logger.info(`Extracted session ID for logout: ${sessionId}`); + } catch (jwtError) { + logger.warn( + "Failed to extract session ID from access token", + jwtError, + ); + } + } + // Clear all auth cookies first cookies.set("wos_access_token", "", { path: "/", @@ -230,10 +249,17 @@ export const sessions = new Hono() // Get the return URL for after logout const redirectTo = c.req.query("return") ?? "/"; - // Create WorkOS logout URL that will properly terminate the session - const logoutUrl = workos.userManagement.getLogoutUrl({ - redirectUri: `${env("BASE_URL")}${redirectTo}`, // Full URL where to redirect after logout - }); + // Create WorkOS logout URL with session ID to properly terminate the session + const logoutParams: any = { + redirectUri: `${env("BASE_URL")}${redirectTo}`, + }; + + // Include session ID if we were able to extract it + if (sessionId) { + logoutParams.sessionId = sessionId; + } + + const logoutUrl = workos.userManagement.getLogoutUrl(logoutParams); logger.info("Redirecting to WorkOS logout URL to terminate session"); return c.redirect(logoutUrl); From 26ada591fe8be5f9a7007da747013e85a0df8f26 Mon Sep 17 00:00:00 2001 From: cif Date: Wed, 3 Sep 2025 06:39:53 -0700 Subject: [PATCH 15/16] fix: try programatically killing off session --- service/auth/auth.endpoints.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/service/auth/auth.endpoints.ts b/service/auth/auth.endpoints.ts index 6f0dc28..bf1a1f1 100644 --- a/service/auth/auth.endpoints.ts +++ b/service/auth/auth.endpoints.ts @@ -249,20 +249,24 @@ export const sessions = new Hono() // Get the return URL for after logout const redirectTo = c.req.query("return") ?? "/"; - // Create WorkOS logout URL with session ID to properly terminate the session - const logoutParams: any = { - redirectUri: `${env("BASE_URL")}${redirectTo}`, - }; - - // Include session ID if we were able to extract it + // Revoke the session directly via WorkOS API to properly terminate the session if (sessionId) { - logoutParams.sessionId = sessionId; + try { + await workos.userManagement.revokeSession({ + sessionId: sessionId, + }); + logger.info(`Successfully revoked WorkOS session: ${sessionId}`); + } catch (revokeError) { + logger.error("Failed to revoke WorkOS session", revokeError); + // Continue with logout even if revoke fails + } + } else { + logger.warn("No session ID found, performing local logout only"); } - const logoutUrl = workos.userManagement.getLogoutUrl(logoutParams); - - logger.info("Redirecting to WorkOS logout URL to terminate session"); - return c.redirect(logoutUrl); + // Redirect after session is revoked + logger.info("Session revoked, redirecting user"); + return c.redirect(redirectTo); } catch (error) { logger.error("Error during logout", error); From 1c52e88cbc035fbde5d215a523a4b53457c8d352 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 08:27:49 +0000 Subject: [PATCH 16/16] build(deps-dev): bump tw-animate-css from 1.2.9 to 1.4.0 Bumps [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) from 1.2.9 to 1.4.0. - [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases) - [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.2.9...v1.4.0) --- updated-dependencies: - dependency-name: tw-animate-css dependency-version: 1.4.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d168d7..db72fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "glob": "^10.3.12", "rollup": "^4.44.0", "rollup-plugin-ignore-import": "^1.3.2", - "tw-animate-css": "^1.2.9", + "tw-animate-css": "^1.4.0", "typescript": "^5.2.2", "vite": "6.3.5" } @@ -10619,10 +10619,11 @@ } }, "node_modules/tw-animate-css": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.9.tgz", - "integrity": "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" } diff --git a/package.json b/package.json index d0e81fb..cf357b0 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "glob": "^10.3.12", "rollup": "^4.44.0", "rollup-plugin-ignore-import": "^1.3.2", - "tw-animate-css": "^1.2.9", + "tw-animate-css": "^1.4.0", "typescript": "^5.2.2", "vite": "6.3.5" }