From fe3a46a9e7a8561f0ce6dffe1c32b34adbed30cb Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Sat, 20 Sep 2025 06:12:08 +0400
Subject: [PATCH 1/6] feat: redis app connection & secret rotation
---
.../v2/secret-rotation-v2-routers/index.ts | 4 +-
.../redis-credentials-rotation-router.ts | 19 ++
.../secret-rotation-v2-router.ts | 4 +-
.../redis-credentials/index.ts | 4 +
.../redis-credentials-rotation-constants.ts | 15 +
.../redis-credentials-rotation-fns.ts | 170 ++++++++++
.../redis-credentials-rotation-schemas.ts | 75 +++++
.../redis-credentials-rotation-types.ts | 24 ++
.../secret-rotation-v2-enums.ts | 3 +-
.../secret-rotation-v2-fns.ts | 4 +-
.../secret-rotation-v2-maps.ts | 6 +-
.../secret-rotation-v2-service.ts | 4 +-
.../secret-rotation-v2-types.ts | 22 +-
.../secret-rotation-v2-union-schema.ts | 4 +-
backend/src/lib/api-docs/constants.ts | 7 +
.../app-connection-router.ts | 7 +-
.../routes/v1/app-connection-routers/index.ts | 4 +-
.../redis-connection-router.ts | 18 +
.../app-connection/app-connection-enums.ts | 3 +-
.../app-connection/app-connection-fns.ts | 11 +-
.../app-connection/app-connection-maps.ts | 6 +-
.../app-connection/app-connection-service.ts | 4 +-
.../app-connection/app-connection-types.ts | 14 +-
.../services/app-connection/redis/index.ts | 4 +
.../redis/redis-connection-enums.ts | 3 +
.../redis/redis-connection-fns.ts | 56 ++++
.../redis/redis-connection-schemas.ts | 87 +++++
.../redis/redis-connection-types.ts | 22 ++
frontend/public/images/integrations/Redis.png | Bin 0 -> 3478 bytes
...redentialsRotationGeneratedCredentials.tsx | 38 +++
...ewSecretRotationV2GeneratedCredentials.tsx | 8 +
...disCredentialsRotationParametersFields.tsx | 197 +++++++++++
.../SecretRotationV2ParametersFields.tsx | 4 +-
.../RedisCredentialsRotationReviewFields.tsx | 50 +++
.../SecretRotationReviewFields.tsx | 4 +-
...redentialsRotationSecretsMappingFields.tsx | 58 ++++
.../SecretRotationV2SecretsMappingFields.tsx | 4 +-
.../forms/schemas/index.ts | 4 +-
.../redis-credentials-rotation-schema.ts | 20 ++
frontend/src/helpers/appConnections.ts | 3 +-
frontend/src/helpers/secretRotationsV2.ts | 11 +-
.../src/hooks/api/appConnections/enums.ts | 3 +-
.../api/appConnections/types/app-options.ts | 5 +
.../hooks/api/appConnections/types/index.ts | 5 +-
.../appConnections/types/redis-connection.ts | 21 ++
.../src/hooks/api/secretRotationsV2/enums.ts | 3 +-
.../api/secretRotationsV2/types/index.ts | 14 +-
.../types/redis-credentials-rotation.ts | 39 +++
.../AppConnectionForm/AppConnectionForm.tsx | 3 +
.../AppConnectionForm/RedisConnectionForm.tsx | 316 ++++++++++++++++++
50 files changed, 1377 insertions(+), 37 deletions(-)
create mode 100644 backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts
create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts
create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts
create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts
create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts
create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts
create mode 100644 backend/src/server/routes/v1/app-connection-routers/redis-connection-router.ts
create mode 100644 backend/src/services/app-connection/redis/index.ts
create mode 100644 backend/src/services/app-connection/redis/redis-connection-enums.ts
create mode 100644 backend/src/services/app-connection/redis/redis-connection-fns.ts
create mode 100644 backend/src/services/app-connection/redis/redis-connection-schemas.ts
create mode 100644 backend/src/services/app-connection/redis/redis-connection-types.ts
create mode 100644 frontend/public/images/integrations/Redis.png
create mode 100644 frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx
create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx
create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx
create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx
create mode 100644 frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts
create mode 100644 frontend/src/hooks/api/appConnections/types/redis-connection.ts
create mode 100644 frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts
create mode 100644 frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts
index 5f8dea5d7..8d1702850 100644
--- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts
@@ -9,6 +9,7 @@ import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rota
import { registerOktaClientSecretRotationRouter } from "./okta-client-secret-rotation-router";
import { registerOracleDBCredentialsRotationRouter } from "./oracledb-credentials-rotation-router";
import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router";
+import { registerRedisCredentialsRotationRouter } from "./redis-credentials-rotation-router";
export * from "./secret-rotation-v2-router";
@@ -24,5 +25,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record<
[SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter,
[SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter,
[SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter,
- [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter
+ [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter,
+ [SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter
};
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts
new file mode 100644
index 000000000..b83cec52c
--- /dev/null
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts
@@ -0,0 +1,19 @@
+import {
+ CreateRedisCredentialsRotationSchema,
+ RedisCredentialsRotationGeneratedCredentialsSchema,
+ RedisCredentialsRotationSchema,
+ UpdateRedisCredentialsRotationSchema
+} from "@app/ee/services/secret-rotation-v2/redis-credentials";
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+
+import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
+
+export const registerRedisCredentialsRotationRouter = async (server: FastifyZodProvider) =>
+ registerSecretRotationEndpoints({
+ type: SecretRotation.RedisCredentials,
+ server,
+ responseSchema: RedisCredentialsRotationSchema,
+ createSchema: CreateRedisCredentialsRotationSchema,
+ updateSchema: UpdateRedisCredentialsRotationSchema,
+ generatedCredentialsSchema: RedisCredentialsRotationGeneratedCredentialsSchema
+ });
diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts
index 7db99c8c4..6ea6497e4 100644
--- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts
+++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts
@@ -10,6 +10,7 @@ import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-
import { OktaClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret";
import { OracleDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials";
import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
+import { RedisCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
@@ -25,7 +26,8 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [
AzureClientSecretRotationListItemSchema,
AwsIamUserSecretRotationListItemSchema,
LdapPasswordRotationListItemSchema,
- OktaClientSecretRotationListItemSchema
+ OktaClientSecretRotationListItemSchema,
+ RedisCredentialsRotationListItemSchema
]);
export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => {
diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts
new file mode 100644
index 000000000..2d90beab3
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts
@@ -0,0 +1,4 @@
+export * from "./redis-credentials-rotation-constants";
+export * from "./redis-credentials-rotation-fns";
+export * from "./redis-credentials-rotation-schemas";
+export * from "./redis-credentials-rotation-types";
diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts
new file mode 100644
index 000000000..1cb14a922
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts
@@ -0,0 +1,15 @@
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+export const REDIS_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
+ name: "Redis Credentials",
+ type: SecretRotation.RedisCredentials,
+ connection: AppConnection.Redis,
+ template: {
+ secretsMapping: {
+ username: "REDIS_USERNAME",
+ password: "REDIS_PASSWORD"
+ }
+ }
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts
new file mode 100644
index 000000000..c321cdeb0
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts
@@ -0,0 +1,170 @@
+/* eslint-disable no-await-in-loop */
+import Redis from "ioredis";
+
+import {
+ TRotationFactory,
+ TRotationFactoryGetSecretsPayload,
+ TRotationFactoryIssueCredentials,
+ TRotationFactoryRevokeCredentials,
+ TRotationFactoryRotateCredentials
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
+import { BadRequestError } from "@app/lib/errors";
+
+import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils";
+import {
+ TRedisCredentialsRotationGeneratedCredentials,
+ TRedisCredentialsRotationWithConnection
+} from "./redis-credentials-rotation-types";
+
+export const redisCredentialsRotationFactory: TRotationFactory<
+ TRedisCredentialsRotationWithConnection,
+ TRedisCredentialsRotationGeneratedCredentials
+> = (secretRotation) => {
+ const { connection, secretsMapping, parameters } = secretRotation;
+
+ const $getClient = async () => {
+ let conn: Redis | null = null;
+ try {
+ conn = new Redis({
+ username: connection.credentials.username,
+ host: connection.credentials.host,
+ port: connection.credentials.port,
+ password: connection.credentials.password,
+ ...(connection.credentials.sslEnabled && {
+ tls: {
+ rejectUnauthorized: connection.credentials.sslRejectUnauthorized,
+ ca: connection.credentials.sslCertificate
+ }
+ })
+ });
+
+ let result: string;
+ if (connection.credentials.password) {
+ result = await conn.auth(connection.credentials.username, connection.credentials.password, () => {});
+ } else {
+ result = await conn.auth(connection.credentials.username, () => {});
+ }
+
+ if (result !== "OK") {
+ throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
+ }
+
+ return conn;
+ } catch (err) {
+ if (conn) await conn.quit();
+
+ throw err;
+ }
+ };
+
+ /**
+ * Creates a new user and password for the redis user using ACL
+ */
+ const $rotateAclUser = async () => {
+ const client = await $getClient();
+
+ const username = generatePassword({
+ length: 32,
+ required: {
+ symbols: 0,
+ digits: 5,
+ uppercase: 5,
+ lowercase: 5
+ }
+ });
+
+ const password = generatePassword(parameters.passwordRequirements || DEFAULT_PASSWORD_REQUIREMENTS);
+
+ try {
+ // important: permissionScope is user input so we need to sanitize it, which we do by splitting the permission scope into parts and then passing them to the ACL command as separate arguments
+ const permissionParts = (parameters.permissionScope || "~* +@all").split(" ");
+ await client.call("ACL", "SETUSER", username, `>${password}`, "on", ...permissionParts);
+
+ return {
+ username,
+ password
+ };
+ } catch (error: unknown) {
+ throw new BadRequestError({
+ message: "Unable to validate connection: verify credentials"
+ });
+ }
+ };
+
+ /**
+ * Revokes a ACL password from the Redis server using its username and password.
+ */
+ const revokeCredential = async (username: string) => {
+ const client = await $getClient();
+
+ try {
+ await client.call("ACL", "DELUSER", username);
+ } catch (error: unknown) {
+ throw new BadRequestError({
+ message: "Unable to revoke credential: verify credentials"
+ });
+ }
+ };
+
+ /**
+ * Issues a new set of credentials.
+ */
+ const issueCredentials: TRotationFactoryIssueCredentials = async (
+ callback
+ ) => {
+ const credentials = await $rotateAclUser();
+
+ return callback(credentials);
+ };
+
+ /**
+ * Revokes a list of credentials.
+ */
+ const revokeCredentials: TRotationFactoryRevokeCredentials = async (
+ credentials,
+ callback
+ ) => {
+ if (!credentials?.length) return callback();
+
+ for (const { username } of credentials) {
+ await revokeCredential(username);
+ // eslint-disable-next-line no-promise-executor-return
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ return callback();
+ };
+
+ /**
+ * Rotates credentials by issuing new ones and revoking the old.
+ */
+ const rotateCredentials: TRotationFactoryRotateCredentials = async (
+ oldCredentials,
+ callback
+ ) => {
+ const newCredentials = await $rotateAclUser();
+
+ if (oldCredentials?.username) {
+ await revokeCredential(oldCredentials.username);
+ }
+
+ return callback(newCredentials);
+ };
+
+ /**
+ * Maps the generated credentials into the secret payload format.
+ */
+ const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({
+ username,
+ password
+ }) => [
+ { key: secretsMapping.username, value: username },
+ { key: secretsMapping.password, value: password }
+ ];
+
+ return {
+ issueCredentials,
+ revokeCredentials,
+ rotateCredentials,
+ getSecretsPayload
+ };
+};
diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts
new file mode 100644
index 000000000..4df00f336
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts
@@ -0,0 +1,75 @@
+import { z } from "zod";
+
+import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
+import {
+ BaseCreateSecretRotationSchema,
+ BaseSecretRotationSchema,
+ BaseUpdateSecretRotationSchema
+} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
+import { SecretRotations } from "@app/lib/api-docs";
+import { SecretNameSchema } from "@app/server/lib/schemas";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+import { PasswordRequirementsSchema } from "../shared/general";
+
+export const RedisCredentialsRotationGeneratedCredentialsSchema = z
+ .object({
+ username: z.string(),
+ password: z.string()
+ })
+ .array()
+ .min(1)
+ .max(2);
+
+const RedisCredentialsRotationSecretsMappingSchema = z.object({
+ username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.username),
+ password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.password)
+});
+
+export const RedisCredentialsRotationParametersSchema = z.object({
+ passwordRequirements: PasswordRequirementsSchema.optional(),
+ permissionScope: z.string().optional().describe(SecretRotations.PARAMETERS.REDIS_CREDENTIALS.permissionScope)
+});
+
+export const RedisCredentialsRotationTemplateSchema = z.object({
+ secretsMapping: z.object({
+ username: z.string(),
+ password: z.string()
+ })
+});
+
+export const RedisCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.RedisCredentials).extend({
+ type: z.literal(SecretRotation.RedisCredentials),
+ parameters: z.object({
+ passwordRequirements: PasswordRequirementsSchema.optional(),
+ permissionScope: z.string().optional()
+ }),
+ secretsMapping: RedisCredentialsRotationSecretsMappingSchema
+});
+
+export const CreateRedisCredentialsRotationSchema = BaseCreateSecretRotationSchema(
+ SecretRotation.RedisCredentials
+).extend({
+ parameters: z.object({
+ passwordRequirements: PasswordRequirementsSchema.optional(),
+ permissionScope: z.string().optional()
+ }),
+ secretsMapping: RedisCredentialsRotationSecretsMappingSchema
+});
+
+export const UpdateRedisCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
+ SecretRotation.RedisCredentials
+).extend({
+ parameters: z.object({
+ passwordRequirements: PasswordRequirementsSchema.optional(),
+ permissionScope: z.string().optional()
+ }),
+ secretsMapping: RedisCredentialsRotationSecretsMappingSchema.optional()
+});
+
+export const RedisCredentialsRotationListItemSchema = z.object({
+ name: z.literal("Redis Credentials"),
+ connection: z.literal(AppConnection.Redis),
+ type: z.literal(SecretRotation.RedisCredentials),
+ template: RedisCredentialsRotationTemplateSchema
+});
diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts
new file mode 100644
index 000000000..46f217d61
--- /dev/null
+++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+import { TRedisConnection } from "@app/services/app-connection/redis";
+
+import {
+ CreateRedisCredentialsRotationSchema,
+ RedisCredentialsRotationGeneratedCredentialsSchema,
+ RedisCredentialsRotationListItemSchema,
+ RedisCredentialsRotationSchema
+} from "./redis-credentials-rotation-schemas";
+
+export type TRedisCredentialsRotation = z.infer;
+
+export type TRedisCredentialsRotationInput = z.infer;
+
+export type TRedisCredentialsRotationListItem = z.infer;
+
+export type TRedisCredentialsRotationWithConnection = TRedisCredentialsRotation & {
+ connection: TRedisConnection;
+};
+
+export type TRedisCredentialsRotationGeneratedCredentials = z.infer<
+ typeof RedisCredentialsRotationGeneratedCredentialsSchema
+>;
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts
index cf0fe578a..661a2399a 100644
--- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts
@@ -7,7 +7,8 @@ export enum SecretRotation {
AzureClientSecret = "azure-client-secret",
AwsIamUserSecret = "aws-iam-user-secret",
LdapPassword = "ldap-password",
- OktaClientSecret = "okta-client-secret"
+ OktaClientSecret = "okta-client-secret",
+ RedisCredentials = "redis-credentials"
}
export enum SecretRotationStatus {
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts
index 4d8cea6a3..e4e6a8531 100644
--- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts
@@ -14,6 +14,7 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials";
import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret";
import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
+import { REDIS_CREDENTIALS_ROTATION_LIST_OPTION } from "./redis-credentials";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
@@ -35,7 +36,8 @@ const SECRET_ROTATION_LIST_OPTIONS: Record {
diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts
index d9a771101..2087fa195 100644
--- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts
+++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts
@@ -10,7 +10,8 @@ export const SECRET_ROTATION_NAME_MAP: Record = {
[SecretRotation.AzureClientSecret]: "Azure Client Secret",
[SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret",
[SecretRotation.LdapPassword]: "LDAP Password",
- [SecretRotation.OktaClientSecret]: "Okta Client Secret"
+ [SecretRotation.OktaClientSecret]: "Okta Client Secret",
+ [SecretRotation.RedisCredentials]: "Redis Credentials"
};
export const SECRET_ROTATION_CONNECTION_MAP: Record = {
@@ -22,5 +23,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record {
diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts
index 70804d173..11d9ce5e6 100644
--- a/backend/src/server/routes/v1/app-connection-routers/index.ts
+++ b/backend/src/server/routes/v1/app-connection-routers/index.ts
@@ -31,6 +31,7 @@ import { registerNetlifyConnectionRouter } from "./netlify-connection-router";
import { registerOktaConnectionRouter } from "./okta-connection-router";
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
import { registerRailwayConnectionRouter } from "./railway-connection-router";
+import { registerRedisConnectionRouter } from "./redis-connection-router";
import { registerRenderConnectionRouter } from "./render-connection-router";
import { registerSupabaseConnectionRouter } from "./supabase-connection-router";
import { registerTeamCityConnectionRouter } from "./teamcity-connection-router";
@@ -80,5 +81,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record {
+ registerAppConnectionEndpoints({
+ app: AppConnection.Redis,
+ server,
+ sanitizedResponseSchema: SanitizedRedisConnectionSchema,
+ createSchema: CreateRedisConnectionSchema,
+ updateSchema: UpdateRedisConnectionSchema
+ });
+};
diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts
index 76dcdd5f0..996cd872a 100644
--- a/backend/src/services/app-connection/app-connection-enums.ts
+++ b/backend/src/services/app-connection/app-connection-enums.ts
@@ -36,7 +36,8 @@ export enum AppConnection {
Supabase = "supabase",
DigitalOcean = "digital-ocean",
Netlify = "netlify",
- Okta = "okta"
+ Okta = "okta",
+ Redis = "redis"
}
export enum AWSRegion {
diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts
index f88b5a357..7455a6ac0 100644
--- a/backend/src/services/app-connection/app-connection-fns.ts
+++ b/backend/src/services/app-connection/app-connection-fns.ts
@@ -111,6 +111,7 @@ import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } fr
import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta";
import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway";
+import { getRedisConnectionListItem, RedisConnectionMethod, validateRedisConnectionCredentials } from "./redis";
import { RenderConnectionMethod } from "./render/render-connection-enums";
import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns";
import {
@@ -191,7 +192,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
getSupabaseConnectionListItem(),
getDigitalOceanConnectionListItem(),
getNetlifyConnectionListItem(),
- getOktaConnectionListItem()
+ getOktaConnectionListItem(),
+ getRedisConnectionListItem()
]
.filter((option) => {
switch (projectType) {
@@ -317,7 +319,8 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator,
- [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator
+ [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator,
+ [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator
};
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service);
@@ -364,6 +367,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case MySqlConnectionMethod.UsernameAndPassword:
case OracleDBConnectionMethod.UsernameAndPassword:
case AzureADCSConnectionMethod.UsernamePassword:
+ case RedisConnectionMethod.UsernameAndPassword:
return "Username & Password";
case WindmillConnectionMethod.AccessToken:
case HCVaultConnectionMethod.AccessToken:
@@ -451,7 +455,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Supabase]: platformManagedCredentialsNotSupported,
[AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported,
[AppConnection.Netlify]: platformManagedCredentialsNotSupported,
- [AppConnection.Okta]: platformManagedCredentialsNotSupported
+ [AppConnection.Okta]: platformManagedCredentialsNotSupported,
+ [AppConnection.Redis]: platformManagedCredentialsNotSupported
};
export const enterpriseAppCheck = async (
diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts
index a2ce02669..e3235d2f7 100644
--- a/backend/src/services/app-connection/app-connection-maps.ts
+++ b/backend/src/services/app-connection/app-connection-maps.ts
@@ -38,7 +38,8 @@ export const APP_CONNECTION_NAME_MAP: Record = {
[AppConnection.Supabase]: "Supabase",
[AppConnection.DigitalOcean]: "DigitalOcean App Platform",
[AppConnection.Netlify]: "Netlify",
- [AppConnection.Okta]: "Okta"
+ [AppConnection.Okta]: "Okta",
+ [AppConnection.Redis]: "Redis"
};
export const APP_CONNECTION_PLAN_MAP: Record = {
@@ -79,5 +80,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>;
@@ -306,6 +313,7 @@ export type TAppConnectionInput = { id: string } & (
| TDigitalOceanConnectionInput
| TNetlifyConnectionInput
| TOktaConnectionInput
+ | TRedisConnectionInput
);
export type TSqlConnectionInput =
@@ -368,7 +376,8 @@ export type TAppConnectionConfig =
| TSupabaseConnectionConfig
| TDigitalOceanConnectionConfig
| TNetlifyConnectionConfig
- | TOktaConnectionConfig;
+ | TOktaConnectionConfig
+ | TRedisConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
| TValidateAwsConnectionCredentialsSchema
@@ -408,7 +417,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateSupabaseConnectionCredentialsSchema
| TValidateDigitalOceanCredentialsSchema
| TValidateNetlifyConnectionCredentialsSchema
- | TValidateOktaConnectionCredentialsSchema;
+ | TValidateOktaConnectionCredentialsSchema
+ | TValidateRedisConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
diff --git a/backend/src/services/app-connection/redis/index.ts b/backend/src/services/app-connection/redis/index.ts
new file mode 100644
index 000000000..76b071958
--- /dev/null
+++ b/backend/src/services/app-connection/redis/index.ts
@@ -0,0 +1,4 @@
+export * from "./redis-connection-enums";
+export * from "./redis-connection-fns";
+export * from "./redis-connection-schemas";
+export * from "./redis-connection-types";
diff --git a/backend/src/services/app-connection/redis/redis-connection-enums.ts b/backend/src/services/app-connection/redis/redis-connection-enums.ts
new file mode 100644
index 000000000..01befeee9
--- /dev/null
+++ b/backend/src/services/app-connection/redis/redis-connection-enums.ts
@@ -0,0 +1,3 @@
+export enum RedisConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
diff --git a/backend/src/services/app-connection/redis/redis-connection-fns.ts b/backend/src/services/app-connection/redis/redis-connection-fns.ts
new file mode 100644
index 000000000..901016b3c
--- /dev/null
+++ b/backend/src/services/app-connection/redis/redis-connection-fns.ts
@@ -0,0 +1,56 @@
+import Redis from "ioredis";
+
+import { BadRequestError } from "@app/lib/errors";
+import { AppConnection } from "@app/services/app-connection/app-connection-enums";
+
+import { RedisConnectionMethod } from "./redis-connection-enums";
+import { TRedisConnectionConfig } from "./redis-connection-types";
+
+export const getRedisConnectionListItem = () => {
+ return {
+ name: "Redis" as const,
+ app: AppConnection.Redis as const,
+ methods: Object.values(RedisConnectionMethod) as [RedisConnectionMethod.UsernameAndPassword],
+ supportsPlatformManagement: false as const
+ };
+};
+
+export const validateRedisConnectionCredentials = async (config: TRedisConnectionConfig) => {
+ let connection: Redis | null = null;
+ try {
+ connection = new Redis({
+ username: config.credentials.username,
+ host: config.credentials.host,
+ port: config.credentials.port,
+ password: config.credentials.password,
+ ...(config.credentials.sslEnabled && {
+ tls: {
+ rejectUnauthorized: config.credentials.sslRejectUnauthorized,
+ ca: config.credentials.sslCertificate
+ }
+ })
+ });
+
+ let result: string;
+ if (config.credentials.password) {
+ result = await connection.auth(config.credentials.username, config.credentials.password, () => {});
+ } else {
+ result = await connection.auth(config.credentials.username, () => {});
+ }
+
+ if (result !== "OK") {
+ throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
+ }
+
+ return config.credentials;
+ } catch (err) {
+ if (err instanceof BadRequestError) {
+ throw err;
+ }
+ throw new BadRequestError({
+ message: `Unable to validate connection: ${(err as Error)?.message || "verify credentials"}`
+ });
+ } finally {
+ if (connection) await connection.quit();
+ }
+};
diff --git a/backend/src/services/app-connection/redis/redis-connection-schemas.ts b/backend/src/services/app-connection/redis/redis-connection-schemas.ts
new file mode 100644
index 000000000..60c8b8458
--- /dev/null
+++ b/backend/src/services/app-connection/redis/redis-connection-schemas.ts
@@ -0,0 +1,87 @@
+import z from "zod";
+
+import { AppConnections } from "@app/lib/api-docs";
+import {
+ BaseAppConnectionSchema,
+ GenericCreateAppConnectionFieldsSchema,
+ GenericUpdateAppConnectionFieldsSchema
+} from "@app/services/app-connection/app-connection-schemas";
+
+import { AppConnection } from "../app-connection-enums";
+import { RedisConnectionMethod } from "./redis-connection-enums";
+
+export const BaseRedisUsernameAndPasswordConnectionSchema = z.object({
+ host: z.string().toLowerCase().min(1),
+ port: z.coerce.number(),
+ username: z.string().min(1),
+ password: z.string().min(1).optional(),
+
+ sslRejectUnauthorized: z.boolean(),
+ sslEnabled: z.boolean(),
+ sslCertificate: z
+ .string()
+ .trim()
+ .transform((value) => value || undefined)
+ .optional()
+});
+
+export const RedisConnectionAccessTokenCredentialsSchema = BaseRedisUsernameAndPasswordConnectionSchema;
+
+const BaseRedisConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Redis) });
+
+export const RedisConnectionSchema = BaseRedisConnectionSchema.extend({
+ method: z.literal(RedisConnectionMethod.UsernameAndPassword),
+ credentials: RedisConnectionAccessTokenCredentialsSchema
+});
+
+export const SanitizedRedisConnectionSchema = z.discriminatedUnion("method", [
+ BaseRedisConnectionSchema.extend({
+ method: z.literal(RedisConnectionMethod.UsernameAndPassword),
+ credentials: RedisConnectionAccessTokenCredentialsSchema.pick({
+ host: true,
+ port: true,
+ username: true,
+ sslEnabled: true,
+ sslRejectUnauthorized: true,
+ sslCertificate: true
+ })
+ })
+]);
+
+export const ValidateRedisConnectionCredentialsSchema = z.discriminatedUnion("method", [
+ z.object({
+ method: z
+ .literal(RedisConnectionMethod.UsernameAndPassword)
+ .describe(AppConnections.CREATE(AppConnection.Redis).method),
+ credentials: RedisConnectionAccessTokenCredentialsSchema.describe(
+ AppConnections.CREATE(AppConnection.Redis).credentials
+ )
+ })
+]);
+
+export const CreateRedisConnectionSchema = ValidateRedisConnectionCredentialsSchema.and(
+ GenericCreateAppConnectionFieldsSchema(AppConnection.Redis, {
+ supportsPlatformManagedCredentials: true,
+ supportsGateways: true
+ })
+);
+
+export const UpdateRedisConnectionSchema = z
+ .object({
+ credentials: RedisConnectionAccessTokenCredentialsSchema.optional().describe(
+ AppConnections.UPDATE(AppConnection.Redis).credentials
+ )
+ })
+ .and(
+ GenericUpdateAppConnectionFieldsSchema(AppConnection.Redis, {
+ supportsPlatformManagedCredentials: true,
+ supportsGateways: true
+ })
+ );
+
+export const RedisConnectionListItemSchema = z.object({
+ name: z.literal("Redis"),
+ app: z.literal(AppConnection.Redis),
+ methods: z.nativeEnum(RedisConnectionMethod).array(),
+ supportsPlatformManagement: z.literal(false)
+});
diff --git a/backend/src/services/app-connection/redis/redis-connection-types.ts b/backend/src/services/app-connection/redis/redis-connection-types.ts
new file mode 100644
index 000000000..2d1ba7699
--- /dev/null
+++ b/backend/src/services/app-connection/redis/redis-connection-types.ts
@@ -0,0 +1,22 @@
+import z from "zod";
+
+import { DiscriminativePick } from "@app/lib/types";
+
+import { AppConnection } from "../app-connection-enums";
+import {
+ CreateRedisConnectionSchema,
+ RedisConnectionSchema,
+ ValidateRedisConnectionCredentialsSchema
+} from "./redis-connection-schemas";
+
+export type TRedisConnection = z.infer;
+
+export type TRedisConnectionInput = z.infer & {
+ app: AppConnection.Redis;
+};
+
+export type TValidateRedisConnectionCredentialsSchema = typeof ValidateRedisConnectionCredentialsSchema;
+
+export type TRedisConnectionConfig = DiscriminativePick & {
+ orgId: string;
+};
diff --git a/frontend/public/images/integrations/Redis.png b/frontend/public/images/integrations/Redis.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ef8adffda10502a274319cf6dec210ea7e005ec
GIT binary patch
literal 3478
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
literal 0
HcmV?d00001
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx
new file mode 100644
index 000000000..18feefa09
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx
@@ -0,0 +1,38 @@
+import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
+
+import { ViewRotationGeneratedCredentialsDisplay } from "./shared";
+import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation";
+
+type Props = {
+ generatedCredentialsResponse: TRedisCredentialsRotationGeneratedCredentialsResponse;
+};
+
+export const ViewRedisCredentialsRotationGeneratedCredentials = ({
+ generatedCredentialsResponse: { generatedCredentials, activeIndex }
+}: Props) => {
+ const inactiveIndex = activeIndex === 0 ? 1 : 0;
+
+ const activeCredentials = generatedCredentials[activeIndex];
+ const inactiveCredentials = generatedCredentials[inactiveIndex];
+
+ return (
+
+ {activeCredentials?.username}
+
+ {activeCredentials?.password}
+
+ >
+ }
+ inactiveCredentials={
+ <>
+ {inactiveCredentials?.username}
+
+ {inactiveCredentials?.password}
+
+ >
+ }
+ />
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx
index 33d3fccc1..e8553f6d9 100644
--- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx
+++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx
@@ -23,6 +23,7 @@ import {
import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared";
import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials";
import { ViewOktaClientSecretRotationGeneratedCredentials } from "./ViewOktaClientSecretRotationGeneratedCredentials";
+import { ViewRedisCredentialsRotationGeneratedCredentials } from "./ViewRedisCredentialsRotationGeneratedCredentials";
type Props = {
secretRotation?: TSecretRotationV2;
@@ -107,6 +108,13 @@ const Content = ({ secretRotation }: ContentProps) => {
/>
);
break;
+ case SecretRotation.RedisCredentials:
+ Component = (
+
+ );
+ break;
default:
throw new Error("Unhandled View Generated Credential Rotation Type");
}
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx
new file mode 100644
index 000000000..2c15b3ca8
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx
@@ -0,0 +1,197 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { FormControl, Input } from "@app/components/v2";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+import { DEFAULT_PASSWORD_REQUIREMENTS } from "../schemas/shared";
+
+export const RedisCredentialsRotationParametersFields = () => {
+ const { control } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.RedisCredentials;
+ }
+ >();
+
+ return (
+ <>
+
+
(
+
+
+ This is the access control permissions that will be set for the issued Redis
+ users. The format must be a valid Redis ACL pattern.
+
+
+ The default value is{" "}
+
+ ~* +@all
+
+ . You can modify it to suit your needs.
+
+
+ For more information, please refer to the{" "}
+
+ Redis ACL documentation
+
+ .
+
+
+ }
+ label="Permission Scope"
+ isError={Boolean(error)}
+ errorText={error?.message}
+ >
+
+
+ )}
+ />
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx
index 3f489b04e..f8f2685ec 100644
--- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx
@@ -9,6 +9,7 @@ import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRo
import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields";
import { OktaClientSecretRotationParametersFields } from "./OktaClientSecretRotationParametersFields";
import { SqlCredentialsRotationParametersFields } from "./shared";
+import { RedisCredentialsRotationParametersFields } from "./RedisCredentialsRotationParametersFields";
const COMPONENT_MAP: Record = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields,
@@ -19,7 +20,8 @@ const COMPONENT_MAP: Record = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields,
- [SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields
+ [SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields,
+ [SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields
};
export const SecretRotationV2ParametersFields = () => {
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx
new file mode 100644
index 000000000..871faf8b6
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx
@@ -0,0 +1,50 @@
+import { useFormContext } from "react-hook-form";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { GenericFieldLabel } from "@app/components/v2";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+import { SecretRotationReviewSection } from "./shared";
+
+export const RedisCredentialsRotationReviewFields = () => {
+ const { watch } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.RedisCredentials;
+ }
+ >();
+
+ const [parameters, { username, password }] = watch(["parameters", "secretsMapping"]);
+
+ const { passwordRequirements, permissionScope } = parameters;
+ return (
+ <>
+
+ {permissionScope}
+
+ {passwordRequirements && (
+
+ {passwordRequirements.length}
+
+ {passwordRequirements.required.digits}
+
+
+ {passwordRequirements.required.lowercase}
+
+
+ {passwordRequirements.required.uppercase}
+
+
+ {passwordRequirements.required.symbols}
+
+
+ {passwordRequirements.allowedSymbols}
+
+
+ )}
+
+ {username}
+ {password}
+
+ >
+ );
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx
index 636cc98cc..05b6ad63c 100644
--- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx
@@ -12,6 +12,7 @@ import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotati
import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields";
import { OktaClientSecretRotationReviewFields } from "./OktaClientSecretRotationReviewFields";
import { SqlCredentialsRotationReviewFields } from "./shared";
+import { RedisCredentialsRotationReviewFields } from "./RedisCredentialsRotationReviewFields";
const COMPONENT_MAP: Record = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields,
@@ -22,7 +23,8 @@ const COMPONENT_MAP: Record = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields,
- [SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields
+ [SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields,
+ [SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields
};
export const SecretRotationV2ReviewFields = () => {
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx
new file mode 100644
index 000000000..2ffac6a62
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx
@@ -0,0 +1,58 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
+import { FormControl, Input } from "@app/components/v2";
+import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
+
+import { SecretsMappingTable } from "./shared";
+
+export const RedisCredentialsRotationSecretsMappingFields = () => {
+ const { control } = useFormContext<
+ TSecretRotationV2Form & {
+ type: SecretRotation.RedisCredentials;
+ }
+ >();
+
+ const { rotationOption } = useSecretRotationV2Option(SecretRotation.RedisCredentials);
+
+ const items = [
+ {
+ name: "Username",
+ input: (
+ (
+
+
+
+ )}
+ control={control}
+ name="secretsMapping.username"
+ />
+ )
+ },
+ {
+ name: "Password",
+ input: (
+ (
+
+
+
+ )}
+ control={control}
+ name="secretsMapping.password"
+ />
+ )
+ }
+ ];
+
+ return ;
+};
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx
index dd0ce9cab..15338c48a 100644
--- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx
+++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx
@@ -9,6 +9,7 @@ import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecr
import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields";
import { OktaClientSecretRotationSecretsMappingFields } from "./OktaClientSecretRotationSecretsMappingFields";
import { SqlCredentialsRotationSecretsMappingFields } from "./shared";
+import { RedisCredentialsRotationSecretsMappingFields } from "./RedisCredentialsRotationSecretsMappingFields";
const COMPONENT_MAP: Record = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields,
@@ -19,7 +20,8 @@ const COMPONENT_MAP: Record = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields,
- [SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields
+ [SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields,
+ [SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields
};
export const SecretRotationV2SecretsMappingFields = () => {
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts
index a6ebe2f64..199036a8f 100644
--- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts
@@ -12,6 +12,7 @@ import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/typ
import { OktaClientSecretRotationSchema } from "./okta-client-secret-rotation-schema";
import { OracleDBCredentialsRotationSchema } from "./oracledb-credentials-rotation-schema";
+import { RedisCredentialsRotationSchema } from "./redis-credentials-rotation-schema";
export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
z
@@ -25,7 +26,8 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
OracleDBCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema,
- OktaClientSecretRotationSchema
+ OktaClientSecretRotationSchema,
+ RedisCredentialsRotationSchema
]),
z.object({ id: z.string().optional() })
)
diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts
new file mode 100644
index 000000000..8e4dad117
--- /dev/null
+++ b/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts
@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+
+import { PasswordRequirementsSchema } from "./shared";
+
+export const RedisCredentialsRotationSchema = z
+ .object({
+ type: z.literal(SecretRotation.RedisCredentials),
+ parameters: z.object({
+ passwordRequirements: PasswordRequirementsSchema.optional(),
+ permissionScope: z.string().optional()
+ }),
+ secretsMapping: z.object({
+ username: z.string().trim().min(1, "Username required"),
+ password: z.string().trim().min(1, "Password required")
+ })
+ })
+ .merge(BaseSecretRotationSchema);
diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts
index 99103c794..ab7ee7c90 100644
--- a/frontend/src/helpers/appConnections.ts
+++ b/frontend/src/helpers/appConnections.ts
@@ -113,7 +113,8 @@ export const APP_CONNECTION_MAP: Record<
name: "Netlify",
image: "Netlify.png"
},
- [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }
+ [AppConnection.Okta]: { name: "Okta", image: "Okta.png" },
+ [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts
index 2979a7623..d3bb83f19 100644
--- a/frontend/src/helpers/secretRotationsV2.ts
+++ b/frontend/src/helpers/secretRotationsV2.ts
@@ -49,6 +49,11 @@ export const SECRET_ROTATION_MAP: Record<
name: "Okta Client Secret",
image: "Okta.png",
size: 50
+ },
+ [SecretRotation.RedisCredentials]: {
+ name: "Redis Credentials",
+ image: "Redis.png",
+ size: 50
}
};
@@ -61,7 +66,8 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = {
[SecretRotation.AzureClientSecret]: true,
[SecretRotation.LdapPassword]: false,
[SecretRotation.AwsIamUserSecret]: true,
- [SecretRotation.OktaClientSecret]: true
+ [SecretRotation.OktaClientSecret]: true,
+ [SecretRotation.RedisCredentials]: true
};
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => {
diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts
index 7b041b797..e897cf0f0 100644
--- a/frontend/src/hooks/api/appConnections/enums.ts
+++ b/frontend/src/hooks/api/appConnections/enums.ts
@@ -36,5 +36,6 @@ export enum AppConnection {
Supabase = "supabase",
DigitalOcean = "digital-ocean",
Netlify = "netlify",
- Okta = "okta"
+ Okta = "okta",
+ Redis = "redis"
}
diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts
index 67d8feb48..fdaae2c74 100644
--- a/frontend/src/hooks/api/appConnections/types/app-options.ts
+++ b/frontend/src/hooks/api/appConnections/types/app-options.ts
@@ -168,6 +168,10 @@ export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.AzureADCS;
};
+export type TRedisConnectionOption = TAppConnectionOptionBase & {
+ app: AppConnection.Redis;
+};
+
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -247,4 +251,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.Netlify]: TNetlifyConnectionOption;
[AppConnection.Okta]: TOktaConnectionOption;
[AppConnection.AzureADCS]: TAzureAdCsConnectionOption;
+ [AppConnection.Redis]: TRedisConnectionOption;
};
diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts
index 8c1d86ca3..9f8df7cfa 100644
--- a/frontend/src/hooks/api/appConnections/types/index.ts
+++ b/frontend/src/hooks/api/appConnections/types/index.ts
@@ -31,6 +31,7 @@ import { TOktaConnection } from "./okta-connection";
import { TOracleDBConnection } from "./oracledb-connection";
import { TPostgresConnection } from "./postgres-connection";
import { TRailwayConnection } from "./railway-connection";
+import { TRedisConnection } from "./redis-connection";
import { TRenderConnection } from "./render-connection";
import { TSupabaseConnection } from "./supabase-connection";
import { TTeamCityConnection } from "./teamcity-connection";
@@ -68,6 +69,7 @@ export * from "./okta-connection";
export * from "./oracledb-connection";
export * from "./postgres-connection";
export * from "./railway-connection";
+export * from "./redis-connection";
export * from "./render-connection";
export * from "./supabase-connection";
export * from "./teamcity-connection";
@@ -114,7 +116,8 @@ export type TAppConnection =
| TSupabaseConnection
| TDigitalOceanConnection
| TNetlifyConnection
- | TOktaConnection;
+ | TOktaConnection
+ | TRedisConnection;
export type TAvailableAppConnection = Pick;
diff --git a/frontend/src/hooks/api/appConnections/types/redis-connection.ts b/frontend/src/hooks/api/appConnections/types/redis-connection.ts
new file mode 100644
index 000000000..efbb78b07
--- /dev/null
+++ b/frontend/src/hooks/api/appConnections/types/redis-connection.ts
@@ -0,0 +1,21 @@
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
+
+export enum RedisConnectionMethod {
+ UsernameAndPassword = "username-and-password"
+}
+
+export type TRedisConnectionCredentials = {
+ host: string;
+ port: number;
+ username: string;
+ password?: string;
+ sslEnabled: boolean;
+ sslRejectUnauthorized: boolean;
+ sslCertificate?: string;
+};
+
+export type TRedisConnection = TRootAppConnection & { app: AppConnection.Redis } & {
+ method: RedisConnectionMethod.UsernameAndPassword;
+ credentials: TRedisConnectionCredentials;
+};
diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts
index be692cee3..264a6a4a4 100644
--- a/frontend/src/hooks/api/secretRotationsV2/enums.ts
+++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts
@@ -7,7 +7,8 @@ export enum SecretRotation {
AzureClientSecret = "azure-client-secret",
LdapPassword = "ldap-password",
AwsIamUserSecret = "aws-iam-user-secret",
- OktaClientSecret = "okta-client-secret"
+ OktaClientSecret = "okta-client-secret",
+ RedisCredentials = "redis-credentials"
}
export enum SecretRotationStatus {
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts
index 06783944b..a04b0e020 100644
--- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts
+++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts
@@ -44,6 +44,11 @@ import {
TOracleDBCredentialsRotation,
TOracleDBCredentialsRotationGeneratedCredentialsResponse
} from "./oracledb-credentials-rotation";
+import {
+ TRedisCredentialsRotation,
+ TRedisCredentialsRotationGeneratedCredentialsResponse,
+ TRedisCredentialsRotationOption
+} from "./redis-credentials-rotation";
export type TSecretRotationV2 = (
| TPostgresCredentialsRotation
@@ -55,6 +60,7 @@ export type TSecretRotationV2 = (
| TLdapPasswordRotation
| TAwsIamUserSecretRotation
| TOktaClientSecretRotation
+ | TRedisCredentialsRotation
) & {
secrets: (SecretV3RawSanitized | null)[];
};
@@ -65,7 +71,8 @@ export type TSecretRotationV2Option =
| TAzureClientSecretRotationOption
| TLdapPasswordRotationOption
| TAwsIamUserSecretRotationOption
- | TOktaClientSecretRotationOption;
+ | TOktaClientSecretRotationOption
+ | TRedisCredentialsRotationOption;
export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
@@ -80,7 +87,8 @@ export type TViewSecretRotationGeneratedCredentialsResponse =
| TAzureClientSecretRotationGeneratedCredentialsResponse
| TLdapPasswordRotationGeneratedCredentialsResponse
| TAwsIamUserSecretRotationGeneratedCredentialsResponse
- | TOktaClientSecretRotationGeneratedCredentialsResponse;
+ | TOktaClientSecretRotationGeneratedCredentialsResponse
+ | TRedisCredentialsRotationGeneratedCredentialsResponse;
export type TCreateSecretRotationV2DTO = DiscriminativePick<
TSecretRotationV2,
@@ -133,6 +141,7 @@ export type TSecretRotationOptionMap = {
[SecretRotation.LdapPassword]: TLdapPasswordRotationOption;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption;
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationOption;
+ [SecretRotation.RedisCredentials]: TRedisCredentialsRotationOption;
};
export type TSecretRotationGeneratedCredentialsResponseMap = {
@@ -145,4 +154,5 @@ export type TSecretRotationGeneratedCredentialsResponseMap = {
[SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse;
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationGeneratedCredentialsResponse;
+ [SecretRotation.RedisCredentials]: TRedisCredentialsRotationGeneratedCredentialsResponse;
};
diff --git a/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts
new file mode 100644
index 000000000..bcbc7a3f4
--- /dev/null
+++ b/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts
@@ -0,0 +1,39 @@
+import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
+import {
+ TSecretRotationV2Base,
+ TSecretRotationV2GeneratedCredentialsResponseBase
+} from "@app/hooks/api/secretRotationsV2/types/shared";
+
+export type TRedisCredentialsRotation = TSecretRotationV2Base & {
+ type: SecretRotation.RedisCredentials;
+ parameters: {
+ passwordRequirements?: TPasswordRequirements;
+ permissionScope?: string;
+ };
+ secretsMapping: {
+ username: string;
+ password: string;
+ };
+};
+
+export type TRedisCredentialsRotationGeneratedCredentials = {
+ username: string;
+ password: string;
+};
+
+export type TRedisCredentialsRotationGeneratedCredentialsResponse =
+ TSecretRotationV2GeneratedCredentialsResponseBase<
+ SecretRotation.RedisCredentials,
+ TRedisCredentialsRotationGeneratedCredentials
+ >;
+
+export type TRedisCredentialsRotationOption = {
+ name: string;
+ type: SecretRotation.RedisCredentials;
+ connection: AppConnection.Redis;
+ template: {
+ secretsMapping: TRedisCredentialsRotation["secretsMapping"];
+ };
+};
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx
index f82e4107d..fdb820962 100644
--- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx
@@ -47,6 +47,7 @@ import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
import { WindmillConnectionForm } from "./WindmillConnectionForm";
import { ZabbixConnectionForm } from "./ZabbixConnectionForm";
+import { RedisConnectionForm } from "./RedisConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
@@ -167,6 +168,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
return ;
case AppConnection.Okta:
return ;
+ case AppConnection.Redis:
+ return ;
default:
throw new Error(`Unhandled App ${app}`);
}
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx
new file mode 100644
index 000000000..602b9486c
--- /dev/null
+++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx
@@ -0,0 +1,316 @@
+import { useState } from "react";
+import { Controller, FormProvider, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { Tab } from "@headlessui/react";
+import {
+ Button,
+ FormControl,
+ Input,
+ ModalClose,
+ SecretInput,
+ Select,
+ SelectItem,
+ Switch,
+ TextArea,
+ Tooltip
+} from "@app/components/v2";
+import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
+import { RedisConnectionMethod, TRedisConnection } from "@app/hooks/api/appConnections";
+import { AppConnection } from "@app/hooks/api/appConnections/enums";
+
+import {
+ genericAppConnectionFieldsSchema,
+ GenericAppConnectionsFields
+} from "./GenericAppConnectionFields";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
+
+type Props = {
+ appConnection?: TRedisConnection;
+ onSubmit: (formData: FormData) => Promise;
+};
+
+const rootSchema = genericAppConnectionFieldsSchema.extend({
+ app: z.literal(AppConnection.Redis)
+});
+
+const formSchema = z.discriminatedUnion("method", [
+ rootSchema.extend({
+ method: z.literal(RedisConnectionMethod.UsernameAndPassword),
+ credentials: z.object({
+ host: z.string().trim().min(1, "Host required"),
+ port: z.coerce.number().default(6379),
+ username: z.string().trim().min(1, "Username required"),
+ password: z.string().trim().optional(),
+ sslEnabled: z.boolean().default(false),
+ sslRejectUnauthorized: z.boolean().default(true),
+ sslCertificate: z
+ .string()
+ .trim()
+ .transform((value) => value || undefined)
+ .optional()
+ })
+ })
+]);
+
+type FormData = z.infer;
+
+export const RedisConnectionForm = ({ appConnection, onSubmit }: Props) => {
+ const isUpdate = Boolean(appConnection);
+ const [selectedTabIndex, setSelectedTabIndex] = useState(0);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: appConnection ?? {
+ app: AppConnection.Redis,
+ method: RedisConnectionMethod.UsernameAndPassword,
+ credentials: {
+ host: "",
+ port: 6379,
+ username: "",
+ password: "",
+ sslEnabled: false,
+ sslRejectUnauthorized: true,
+ sslCertificate: undefined
+ }
+ }
+ });
+
+ const {
+ handleSubmit,
+ watch,
+ control,
+ formState: { isSubmitting, isDirty }
+ } = form;
+
+ const sslEnabled = watch("credentials.sslEnabled");
+
+ return (
+
+
+
+
+ )}
+ />
+
+
+
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
From f4ce154b33cee4cad8a4a692c046565f8a72f7e3 Mon Sep 17 00:00:00 2001
From: Daniel Hougaard
Date: Mon, 22 Sep 2025 23:26:08 +0400
Subject: [PATCH 2/6] docs: redis secret rotation & app connection
---
.infisicalignore | 1 +
.../app-connections/redis/available.mdx | 4 +
.../app-connections/redis/create.mdx | 9 +
.../app-connections/redis/delete.mdx | 4 +
.../app-connections/redis/get-by-id.mdx | 4 +
.../app-connections/redis/get-by-name.mdx | 4 +
.../endpoints/app-connections/redis/list.mdx | 4 +
.../app-connections/redis/update.mdx | 9 +
.../redis-credentials/create.mdx | 10 ++
.../redis-credentials/delete.mdx | 4 +
.../redis-credentials/get-by-id.mdx | 4 +
.../redis-credentials/get-by-name.mdx | 4 +
.../get-generated-credentials-by-id.mdx | 4 +
.../redis-credentials/list.mdx | 4 +
.../redis-credentials/rotate-secrets.mdx | 4 +
.../redis-credentials/update.mdx | 10 ++
docs/docs.json | 31 +++-
.../secret-rotation/redis-credentials.mdx | 162 ++++++++++++++++++
.../redis/redis-app-connection-generated.png | Bin 0 -> 687135 bytes
.../redis/redis-app-connection-option.png | Bin 0 -> 526569 bytes
.../redis/redis-connection-form.png | Bin 0 -> 512425 bytes
.../redis-credentials-configuration.png | Bin 0 -> 522256 bytes
.../redis-credentials-confirm.png | Bin 0 -> 546922 bytes
.../redis-credentials-created.png | Bin 0 -> 765276 bytes
.../redis-credentials-details.png | Bin 0 -> 523817 bytes
.../redis-credentials-parameters.png | Bin 0 -> 516359 bytes
.../redis-credentials-secrets-mapping.png | Bin 0 -> 533535 bytes
docs/integrations/app-connections/redis.mdx | 87 ++++++++++
docs/snippets/AppConnectionsBrowser.jsx | 1 +
docs/snippets/RotationsBrowser.jsx | 1 +
30 files changed, 363 insertions(+), 2 deletions(-)
create mode 100644 docs/api-reference/endpoints/app-connections/redis/available.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/create.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/delete.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/list.mdx
create mode 100644 docs/api-reference/endpoints/app-connections/redis/update.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx
create mode 100644 docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx
create mode 100644 docs/documentation/platform/secret-rotation/redis-credentials.mdx
create mode 100644 docs/images/app-connections/redis/redis-app-connection-generated.png
create mode 100644 docs/images/app-connections/redis/redis-app-connection-option.png
create mode 100644 docs/images/app-connections/redis/redis-connection-form.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png
create mode 100644 docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png
create mode 100644 docs/integrations/app-connections/redis.mdx
diff --git a/.infisicalignore b/.infisicalignore
index 66e2fb635..b935763c8 100644
--- a/.infisicalignore
+++ b/.infisicalignore
@@ -51,3 +51,4 @@ docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123
docs/integrations/app-connections/railway.mdx:generic-api-key:156
.github/workflows/validate-db-schemas.yml:generic-api-key:21
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
+docs/integrations/app-connections/redis.mdx:generic-api-key:80
diff --git a/docs/api-reference/endpoints/app-connections/redis/available.mdx b/docs/api-reference/endpoints/app-connections/redis/available.mdx
new file mode 100644
index 000000000..6b0389d3f
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/available.mdx
@@ -0,0 +1,4 @@
+---
+title: "Available"
+openapi: "GET /api/v1/app-connections/redis/available"
+---
diff --git a/docs/api-reference/endpoints/app-connections/redis/create.mdx b/docs/api-reference/endpoints/app-connections/redis/create.mdx
new file mode 100644
index 000000000..b203048d6
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/create.mdx
@@ -0,0 +1,9 @@
+---
+title: "Create"
+openapi: "POST /api/v1/app-connections/redis"
+---
+
+
+ Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain
+ the required credentials.
+
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/app-connections/redis/delete.mdx b/docs/api-reference/endpoints/app-connections/redis/delete.mdx
new file mode 100644
index 000000000..bf8178a23
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/app-connections/redis/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx
new file mode 100644
index 000000000..9879fff7f
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v1/app-connections/redis/{connectionId}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx
new file mode 100644
index 000000000..42807f221
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v1/app-connections/redis/connection-name/{connectionName}"
+---
diff --git a/docs/api-reference/endpoints/app-connections/redis/list.mdx b/docs/api-reference/endpoints/app-connections/redis/list.mdx
new file mode 100644
index 000000000..913da1679
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v1/app-connections/redis"
+---
diff --git a/docs/api-reference/endpoints/app-connections/redis/update.mdx b/docs/api-reference/endpoints/app-connections/redis/update.mdx
new file mode 100644
index 000000000..e2414b971
--- /dev/null
+++ b/docs/api-reference/endpoints/app-connections/redis/update.mdx
@@ -0,0 +1,9 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/app-connections/redis/{connectionId}"
+---
+
+
+ Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain
+ the required credentials.
+
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx
new file mode 100644
index 000000000..8bce0dc4f
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx
@@ -0,0 +1,10 @@
+---
+title: "Create"
+openapi: "POST /api/v2/secret-rotations/redis-credentials"
+---
+
+
+ Check out the configuration docs for [Redis
+ Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the
+ required parameters.
+
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx
new file mode 100644
index 000000000..28d9e3a6b
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v2/secret-rotations/redis-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx
new file mode 100644
index 000000000..a3ec932e0
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by ID"
+openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx
new file mode 100644
index 000000000..2dc50d581
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get by Name"
+openapi: "GET /api/v2/secret-rotations/redis-credentials/rotation-name/{rotationName}"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx
new file mode 100644
index 000000000..c0002edd6
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Get Credentials by ID"
+openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}/generated-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx
new file mode 100644
index 000000000..bc72d101e
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/secret-rotations/redis-credentials"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx
new file mode 100644
index 000000000..53c2c7651
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx
@@ -0,0 +1,4 @@
+---
+title: "Rotate Secrets"
+openapi: "POST /api/v2/secret-rotations/redis-credentials/{rotationId}/rotate-secrets"
+---
diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx
new file mode 100644
index 000000000..4817a0f65
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx
@@ -0,0 +1,10 @@
+---
+title: "Update"
+openapi: "PATCH /api/v2/secret-rotations/redis-credentials/{rotationId}"
+---
+
+
+ Check out the configuration docs for [Redis
+ Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the
+ required parameters.
+
\ No newline at end of file
diff --git a/docs/docs.json b/docs/docs.json
index 3b7197da8..0fcc4ab51 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -140,7 +140,8 @@
"integrations/app-connections/terraform-cloud",
"integrations/app-connections/vercel",
"integrations/app-connections/windmill",
- "integrations/app-connections/zabbix"
+ "integrations/app-connections/zabbix",
+ "integrations/app-connections/redis"
]
}
]
@@ -442,7 +443,8 @@
"documentation/platform/secret-rotation/mysql-credentials",
"documentation/platform/secret-rotation/okta-client-secret",
"documentation/platform/secret-rotation/oracledb-credentials",
- "documentation/platform/secret-rotation/postgres-credentials"
+ "documentation/platform/secret-rotation/postgres-credentials",
+ "documentation/platform/secret-rotation/redis-credentials"
]
},
{
@@ -1377,6 +1379,19 @@
"api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets",
"api-reference/endpoints/secret-rotations/postgres-credentials/update"
]
+ },
+ {
+ "group": "Redis Credentials",
+ "pages": [
+ "api-reference/endpoints/secret-rotations/redis-credentials/create",
+ "api-reference/endpoints/secret-rotations/redis-credentials/delete",
+ "api-reference/endpoints/secret-rotations/redis-credentials/get-by-id",
+ "api-reference/endpoints/secret-rotations/redis-credentials/get-by-name",
+ "api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id",
+ "api-reference/endpoints/secret-rotations/redis-credentials/list",
+ "api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets",
+ "api-reference/endpoints/secret-rotations/redis-credentials/update"
+ ]
}
]
},
@@ -1937,6 +1952,18 @@
"api-reference/endpoints/app-connections/zabbix/update",
"api-reference/endpoints/app-connections/zabbix/delete"
]
+ },
+ {
+ "group": "Redis",
+ "pages": [
+ "api-reference/endpoints/app-connections/redis/list",
+ "api-reference/endpoints/app-connections/redis/available",
+ "api-reference/endpoints/app-connections/redis/get-by-id",
+ "api-reference/endpoints/app-connections/redis/get-by-name",
+ "api-reference/endpoints/app-connections/redis/create",
+ "api-reference/endpoints/app-connections/redis/update",
+ "api-reference/endpoints/app-connections/redis/delete"
+ ]
}
]
},
diff --git a/docs/documentation/platform/secret-rotation/redis-credentials.mdx b/docs/documentation/platform/secret-rotation/redis-credentials.mdx
new file mode 100644
index 000000000..558980fa6
--- /dev/null
+++ b/docs/documentation/platform/secret-rotation/redis-credentials.mdx
@@ -0,0 +1,162 @@
+---
+title: "Redis Credentials Rotation"
+description: "Learn how to automatically rotate Redis credentials."
+---
+
+## Prerequisites
+
+1. Create a [Redis Connection](/integrations/app-connections/redis) with the required **Secret Rotation** permissions
+2. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply.
+
+Create a Redis Credentials Rotation in Infisical
+
+
+
+ 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown.
+ 
+
+ 2. Select the **Redis Credentials** option.
+ 
+
+ 3. Select the **Redis Connection** to use and configure the rotation behavior. Then click **Next**.
+ 
+
+ - **Redis Connection** - the connection that will perform the rotation of the configured database user credentials.
+ - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation.
+ - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed.
+ - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation.
+
+ 4. Input the password requirements and permission scope for the Redis users that will be created for the rotation. Then click **Next**.
+ 
+
+ - **Permission Scope** - The scope of the Redis users that will be created for the rotation. This will default to `~* +@all` if not specified.
+ - **Password Requirements** - The requirements for the password of the Redis users that will be created for the rotation.
+
+ 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**.
+ 
+
+ - **Username** - the name of the secret that the active username will be mapped to.
+ - **Password** - the name of the secret that the active password will be mapped to.
+
+ 6. Give your rotation a name and description (optional). Then click **Next**.
+ 
+
+ - **Name** - the name of the secret rotation configuration. Must be slug-friendly.
+ - **Description** (optional) - a description of this rotation configuration.
+
+ 7. Review your configuration, then click **Create Secret Rotation**.
+ 
+
+ 8. Your **Redis Credentials** are now available for use via the mapped secrets.
+ 
+
+
+ To create a Redis Credentials Rotation, make an API request to the [Create Redis
+ Credentials Rotation](/api-reference/endpoints/secret-rotations/redis-credentials/create) API endpoint.
+
+ ### Sample request
+
+ ```bash Request
+ curl --request POST \
+ --url https://us.infisical.com/api/v2/secret-rotations/redis-credentials \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "name": my-redis-rotation",
+ "projectId": "",
+ "description": "",
+ "connectionId": "",
+ "environment": "dev|staging|prod",
+ "secretPath": "",
+ "isAutoRotationEnabled": true,
+ "rotationInterval": 2,
+ "rotateAtUtc": {
+ "hours": 11.5,
+ "minutes": 29.5
+ },
+ "parameters": {
+ "passwordRequirements": {
+ "length": 64,
+ "required": {
+ "digits": 1,
+ "lowercase": 1,
+ "uppercase": 1,
+ "symbols": 1
+ },
+ "allowedSymbols": "@!+"
+ },
+ "permissionScope": "~* +@all"
+ },
+ "secretsMapping": {
+ "username": "REDIS_USERNAME",
+ "password": "REDIS_PASSWORD"
+ }
+ }'
+ ```
+
+ ### Sample response
+
+ ```bash Response
+ {
+ "secretRotation": {
+ "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "name": "my-redis-rotation",
+ "description": "my database credentials rotation",
+ "secretsMapping": {
+ "username": "REDIS_USERNAME",
+ "password": "REDIS_PASSWORD"
+ },
+ "isAutoRotationEnabled": true,
+ "activeIndex": 0,
+ "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "createdAt": "2023-11-07T05:31:56Z",
+ "updatedAt": "2023-11-07T05:31:56Z",
+ "rotationInterval": 30,
+ "rotationStatus": "success",
+ "lastRotationAttemptedAt": "2023-11-07T05:31:56Z",
+ "lastRotatedAt": "2023-11-07T05:31:56Z",
+ "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "nextRotationAt": "2023-11-07T05:31:56Z",
+ "connection": {
+ "app": "redis",
+ "name": "my-redis-connection",
+ "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
+ },
+ "environment": {
+ "slug": "dev",
+ "name": "Development",
+ "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
+ },
+ "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "folder": {
+ "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
+ "path": "/"
+ },
+ "rotateAtUtc": {
+ "hours": 0,
+ "minutes": 0
+ },
+ "lastRotationMessage": null,
+ "type": "redis-credentials",
+ "parameters": {
+ "passwordRequirements": {
+ "length": 64,
+ "required": {
+ "digits": 1,
+ "lowercase": 1,
+ "uppercase": 1,
+ "symbols": 1
+ },
+ "allowedSymbols": "@!+"
+ },
+ "permissionScope": "~* +@all"
+ },
+ "secretsMapping": {
+ "username": "REDIS_USERNAME",
+ "password": "REDIS_PASSWORD"
+ }
+ }
+ }
+ ```
+
+
diff --git a/docs/images/app-connections/redis/redis-app-connection-generated.png b/docs/images/app-connections/redis/redis-app-connection-generated.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3eb5af728ae9f9222edf8960390eec4ac0ff580
GIT binary patch
literal 687135
zcmbTdcT`i|w>An$5s@N_2uM+yNQrdmARxUc(nIeh2@rZOA}Uo{q$5(L_ui%V8hSu_
zhlCyiH~!8U-#O!saqoEF@1Klhuf6A9d##x{pJ&czf6-J|AiYO>4+jT_R7p|pJq`{@
zJ`N7y*Smz+BmPU3=QuccoOU1dT=mpcMJ*s89y3dbxfPEm$O$`+gCj2G>11Z%VCBkW
zZe?Q!mUw>H*!rBw&QjvJuAtg0H78jsTRTN>7b`7q^$!-_4i+Mo&!r^qiF=A-4S=j%
z&6qqvj$o*$r^NIB*cHWo|9cqloasNOxH?EY*HhDEl7+ZfF$wX!;(7I4@*b18i>0;b
zd%3s&eKGcz#B*C$S0_;bz{A6X$Ag~-;$j2f6%i2uyy64!@o{6%;D&mEUClhX!BFPE
zCjQHYoE6l<#m>pq4gzNSYtzge;^r#x{5f`<>A#J;+FAdPbuje5A0N9AfWIREUY=Kg
z|9!fZr``X@>3>K5=k&i9i|W0%f=*@3LU(ElWO5wW?*|VuiJ0
z3vqo8_`B$U|6X1p*nPB)B=i5bPat4Q2T{QNzhBJq#Vr3a25S)^a^vf)$t=znn9&%zUO
z)s`#2W3I5r^|cxB^~H-_^Y)S^C0wKPgu}GM6rWr4NYZVphzFPkM?k}+>yP*cl0e^g
zK`fF2F}$LJkup1kcbV{BGKH)F(?1M;jf}(}B^(Z@^$`9cT;-tdQXFu0t2?nlk}ZxC
zqxYHr;=QrtZz9|_PA_vMTpT$Oh)BIOS5FzP5wR-8pHP?7oZks9sXu=EP)U9{cKn=z
ztNGKVK9&ndphP%0`&m}gmLgTgH;(%&()V@m
zipuM?DSe#5`4fs~=M>y;6K84O^zyL*jKi$*!?jn5+yh;P-5*@SUvkC+(zuuNPk1A<
z_I^M5%t2vDbuaGcv+2MY{p1viA4nSQ_@O9~UHubl%
zn25eIan!1bd05;;s+apIYw3i_NNkjWeq74`Hxi3!D{qExYIRti?u
zd{@cOBzUU{7S3N^19aA-4euh~tTz$-29I~(Pw9!*JRCH
zz87l_k08$?k4d~AK`;l>L%>wykw(JZ$_kPvTo>{buU49>Y<@QuI4GsZxV+O7{|;@<>rE8@_oc
zY4@q^kshAdYewB=TwiINyM!xDge<`yUNKl_kizTnRS75BpK9J$Zs#kbjSq=5C7Ho>
z55Z(IJSE6$({~~U;tID()Om}xlZ=uq1&!Pdj-jA^qj3KL|8H7nIp71fKVkUH-xS4T
zzh*P*f4=+e*~B+5MUDzQ4h13Rsb0yoS1$MJ1FN2S#3(2VBWW)`n!li$l2_BDbA8<}
zr*Dg&{5eXta#YFofheO;d~9*VG`fcB-eJ*jL1TDJ378M8H;kKA4j^(>n7y
zOE{Asvy?~9$n0Ss?GuYaEP&(FZ&s?9uS&a$jf(7wx=Lx;*eS~?*(qt{+sYpV
zqvN&&RjgI^b9QsGb3~*KpDGCzLP~j6e!lsszWS?MMXe&YnoR}TWc|%LX#;m?AX&n|
zlADgZ=CQ6ru1@H*S+Svk-<<3IgE`*0%enAs4||i*gYjqEGh6t(M{XOtj$dthXLu4OQ!iUfkADVzfGgtNkcR~-S0H*1a_`}I{ic*;lxbz
z^YzcZw{#K1PfqVo(eLob3s+fg-Td%~d}l$l>bYvyZ5Uw~fmT&jd!zQ7ZC6!2<>dQL
z3gP#S?3f
zr>3{2`>ch2Xcz&GHA+}?^U0E!kc9g;W2lHG+ZUhB|L9n$VBXnUdEp8?oDqt6HR$l&
zR&T1zQ2hgYqSg`r*@IK$sz^23?EqMg0l_xL||?)-){8{osG@`+MO*
zB(rg&XdM#B2`r7yj=WDJ;$Yd?s|$_-)lHNX^)SiduHv>I=>~Nbr4fu=scfs!r72GZiE?oSC
z(KTq^>v4?GWi>hl4aF2*GZN_$9^I)S5Vx4EbkS?n1KcsfV+{(!LsT};^3Sk`lx2=)
z#%4~NrY&bLBbURTJ$|PDOz>IrkAoi*Z!|tV=y=|d7`FK5yOU?Cy*i}M<|5)~IpTS?t^myf4S?E
z3n=l)M`B{JhfA!4Y&>dBFiRMVioNo*Dnkk|?OKxcg_XQAdTZsUO+-t)efRkRo#q
zYaU2tm+qms_38KF@&k+To~5TYV&MKdlGVuW%}6OB87Q7dN093qZ{@4u6Trd~FjXxe
zB<@|(*nkCU`pEd`&PCzHBA!Y6z4l5~#2{6Yk$#IlXJtbrrgEUd=cA)p{gJl1-f(y7
zc|C9aJQR(3k33r|T+iENe7ybHMSkd^8oPSZ44eBS>hO7XFAW2U0$s{yvuyI&tJ$Ok
z4Mz$s^49EV`Lq1d(z2|h&b75ZqFh^z2WoanJFncvCT--O$+xO?<{jq>stIRE+Fbpl
zDWXZSdCt#oEi_b-Dj1-%xi>m1wxPMPHhk$y4dif_@jwS`D9L2(aC`M(x33ws0+9KQj7{8tcvGUufdGQ
zImvc*X#HluqiEFlT~(Ha)ntEzc7!%-;ZjL@+0~mQ!`W`Zc)<$6eFtW^P*J5pt>YpL
zxrd~q3Zn9P8Ocel-C{Cg65uA=_GvDXjZQ__e~x9r)d#w@(eN_mR|WTP?oh*Yu-cOM
zf$#=oYCfVS0gP@is%rLAxQ^PeAKBSS>lTAeX9`v%9~fD97|qgGHU5|`a7Qsme2A74xK
zJJe6Ao|7nU33U<4ThH4sSr7f#yV!lyt$JrvIqHZ9UUS}0mC7PPMO&N31J
z`^F5{oXe$&OJmJxr7WdaCIEkn3v0AtZLOMLxgR-Vy%l}kvKF!uM$eeUIO3;#d9sIE
z17rgnTIT{l*N>0){R8|1TEBCyy!0n{H8VP>ien5R3QW9*Lw_*x!Xh3m75c1Sq7X0W
z_G5yNT&Ne0Cdn;g)q5E+#Eo+X4xI<&mx??N)T71uVvIvC;!;MsH}wduT>oH2(o|~@
z9&vsD2%=iQdHw7#V??@ShtOa0mP7>CSMRPrlQIsDG>(#-%m+{0y+x0}NAh!l$9RdU
z^7!%u@8p8NQhpe`985hP)aOl4Pv7*gQQxE)Y2@Eb<+T{T_wp_NI~nQV%)sBQ2OYW)
zw^j)WiHS=7O8&;m0j-7)y4WmvTI%3^9$o7|TZE`azm$E0gNsig{jWYU$w&n3KUh!e
z{S0XgmJffN%&PJjzEa3BJSyhCw(AHF$lfG^x-($u7CClqV#sI>H6#>HL5@(GDR3-W>9Uv&$E4w*!9jr0ik}C&Xzq9Oyd!jKEZ%!omsI&Ug1;!PG(noIrrTPuf^(0kxQ?JiM?zzf=
z(y@2ei~*li=3wuTMwywe6z2DDw+{!86`-l3v(a5=E-&8~oq+pXlbYbaEW)M#@8{h4
z$yjmk?*d8b5PolL3zp|}k;<1=nB1r*aU~|$ek)~or9UvqfxDCBIurKqoel@D`sFLX
zjXwrr0oYZT=4nV~XOnJD4tyH*e`Blb;S*sqnPbt?tj#%b4=P#C*+nZ>F+RdiSYwkF
z()#*IcGFFvcH?zl>?ZTyaYyHR=9Lx|2@L3R39G=kgs7;fy2KtxF#oH)jR|YpL=5v1
z-Iz6tM-|;Xa{<2xU-!B2E9m^ES@J0hC?sbzsoAWuN{X=hPn0%#Ukqg;BgT5lHhO78xfV3w(VkBh7HRi|-=J=sE~cVWJ;
z?I=^;E)3dVuT)P=k(r)CUHm{gGo7Tjf19$%NMC;?xQg@T&Mn|^efQU|@;nqKAt=Kd
zgFfY*Q{NJ(*T3*=X(LL{y-Ou8qWl0P@|%~@uhh8sMjYIo`V?n;>?3R8>MJ$LqaGjZ
zSP~phJ$o(T%en5o-Y{Q#xcoGc+o)oW=A*>S+kX+WT^?-B=S7R`mC1(4Ud>cwms|8S
z_(o(%U9M@BG>P6mtf3^Y?z2R$W`}OH+ps5Fknf}y=(nHs)vu)rCUmg53^#}e{3l=h
zN)BEX$3Cbu2T7IvVz&TfdV$Lr2<{)CPC#mgi`FD^8zw#GHb`a_lJ*J>l7RH7*Q7LlKmzU00{UF~sMcoX>Sz0H~frI{|!b5sE
zT!&e^t>JGLvQH^jbMuHwd4+iDzM(L+&sMm)yL$ahTB6X<#5?lpY~LYS7;-=;WYK+}
zA2e4rPRGLhCNqk-sJuHl_ubF8)&~nBg{S>16|qf4S;qSMcK)}1?(~h}g2KS`PC=WY
zq<@?w^OZA&@Jdm{Tv5gfYDB-_pR`VrbHw*%ZDN0si*GGwyX3x!1{V)ugdHvBs_at>
z^q0_&xeUyrqyn;Or0VMm^WdA8T8XuiMAONE_|Y|X6Kt;o(GwwP|9Fv+M`ih97Ul=OfzAd76BVd9HJljj
zEuZ*qYPjayyib#2tqt}LAtY6;v{oCUbtp0ub=@*Gs0D*)VS++b2~^fA9ii@mhy0-l4bem{D6Ui<05wxEedM;4ZHETCt(R^FQ7
zNoMod{BY+bQ$TGouzoxq1}iPW2MYhAwVI5TR_hYC3v_$k3&fjXeIb#uY`-#wvu|5=
z%nAc0yD?t!!Q-`DeYY2~K-pZ0fAVE+w&!fqin9HuE>lvB>8huG*{`p%!%|2}FP$(u9RYs3aTZix
zUPjV6A^f=1i=5I9U)k3xXS|-rg7{gBKf;+~E7>Se#^KIb|w=7}3hD!&xDh
zy6*KoY7es>%aWf;ZVG)}2by=65vIgvYe#jd(m9p|%}$9brE7R^rg;qh{t~y~H?Vo(
z7-hHARBvY%U{GrZ^1rnDq~GG}B^0JTyRlPP@=D>UbDe|7LH%aZrio{Hzs0?&mmq&4df)s@w2?^ia`qg7QY@7z1?5mvCYp(LABu+UOn*f;+PRtZEK?w!G<
zGhd56T_T|GagKEsX-w(RWQoxqwP*s=Cbi#-#YRYshf~9SFwBUh&M&rpGuX1@+^E)2U*7%Vf(iDt%_9Dh
zSL_+SS!f{J{t*eX9&^y>ym1)N+Ut9I3g1rF43lntn7_U0#MY*zE;8%7J>DgFX?j>STW${tdDtg}yMs$a
z_m2XUrUrgV1b&{bM;DZ|ZF(6*JeUXt0;7W`p~ULs8V?2E{XA;fI*as(<_9zj4n5_y
zyaQcM-<9{U;Uf3(`{jkrs@4ZtC*{9s$cLRbcLW0LC8V3N-eZmRc0S=Hr)E0~-Bxv&
zPO(7(Wei(;hoNJ6Xg#LJa7f~O$pPGoQRjy&HANH_sAw9d2!RHPdl+qo#Zj)ce;kE2
z<5n%Zw)Si0r<;yCpc~h%#->YNn?{x9U0+?psH@e3cT)r%7*Z{aN%8Yl0=?X{S_(^T
zpi6>q;DjqGWFiMMZ3tNpZm@E@Y)IZ1PP2K;rMJ9M2<+r6yJCm4JPjXTQ
zIHC7i`*zSaB8eWnuYvNh`=cQ>OHC>a1+l4KaGb|EVX??R$tjv0c$BuqoIV4utkzF>
zEqZi(ep3cYO!H(GaXJi$tN~~7t##3B@&`bpi7rdnHK6sMyHnauwoGF8Gb4_%EMA{Ab;?2f_=XOpNVYUlf$?^q3sY&uq=4;r@@
z-0Ja>L$&Q~QW`h2swt9-TqZSx0+liWyQQ@yh9crk9S!(nChps;xk;q)K2{fnxmg`4
z-<9Yemowp>t+2>k7W#;8j@Yn1=xw^1$RHDpmN=7p?`Op6Sc_5Ls9TNV{rT1p{5Il=
z{%R-VO@DZ<%}9E?7mQ2aKHcw3AN9lN$4xE5pFhQMeB!>yG7i7(s5CVNC%^^hcbZ@w
zic!t>xl2et6&JbaK0kH83{euJ)^N4s5FtLHcq*G2pWZp|Sm{P-Z-Ks!V{*F;$(Cf%
z7r_ix4hvYFt#hfxv1tfp_#+FFBy4K7>#P+fntePbAmRRRON{*CjL^Zc4QzxhFZ6(4
zL$!MJ7*W=0DIym2HFc0jUONSOiwLtqonlunKiB2te*}+APpL+!n&qW~CszNgxh6Q0
z^5~SS%5?Kw%$yZ$#)y{$>_go8Sj&7=$vFE}+Aaqm8Xi(Pmf7bz-5;i|zYNoJI2bi}
zz59yF)44Ornt$Ddx;3BHx7jx(E7E&sCV6orO9#BJGj#LZ7&I9|t`98QZ_1v7H2sVXc9)AI7F_u5D@
z+t~M5;?>Gi=dW~JF2pW}!y34nO8Ww|JzxHpU5k5dW
zdB`Kc^EDtRGUB4r?M%2*9NR245nDa)R{zeSo|uw>I=ox(#kdrDzt%)~g#BrNZyLE~
z21K`~bv^Mm7ig&VA=UK{u~PdsGD-YuYc*CKgI#}-)dM<5(@*39eZ9)Vu_Y}#_YD1~
zxPW!93NSlB#q;sOoxOMP(&n0sPP5jaJ4Bzt!9MdKB6teUsq8)@j5d|06?M>f=+E!`
z=l625%{5==%`hWq$$OU@IwWPIlT
zCjkIUECsokWX6;E2&ffw`=%{eN?-Qs;YC}mJCe#Jwu}=nhoQOFK{NrcUx*>fzlPOW
zR(|zv*?e*49Qs{~@9n9oFhCh}GieamP*mf)t_@2m*)3&UfLjSsVA2~8d25&4!@jj0
z+r~=s>udY1v1fem$?zOsaNiA3i)N5m*cpo4+xcZUvy7rExrK?Wjdn)jpWDYyfgw#R
zqI6D;>|<
zj1Xd>%3+gW|MqA#+Pb)Uns2{&>@6K=OJ|OJB&2R)V1dR~x8q>GHkzZh5Ex*odpE;g
z_n`D;HF$BZ%5k=1I8B+~=gbif)PW#u4x9G|C_#g0M&IQPojOMS#afMwCF>Z{mkHXv
zhzfU=Jz@N8m5Mi@LLKgXVZ0(2P$x*VsQi=NLUy@?yWK0e75JdO=OB00fPQE*Bfu;K
zzb6+X6yzl3JWp-xDO0u^4ro!uW>fdM$S-H1q4e$xUA}xsX6D35I$RnI)!J~z1(EtIPG$6LSu*z
zO-d9yPP64Kn51%a*+p&gJ}z$!Sk0w5KVsJG>$S9Pe-O9->(Bmtt;b07>HgtV?wO(F
zNoCf4r&p!*(88v63xV8xAFok^EAKws7UG|QYP`hyf3V+*gh4FWNupLZYa%L9b7@16)*EZAvzxu-Cxbe>)J|0uIVsJlLn60X6
zWw*R`0bkCu&aW8-D0ryx1^j9GCg05kU&;zSr;I&8WII1Zqf2wdroK{fE`)!4AA`W<
z1F(E~mkWc=)qpIfD*$*km4Kzb+L_NL3s16J>sq>GbjDy7R>Rf~Oc`l^K|B>_Vk2T|
z`{p1X75k6EC=Tv}(Y9q(+F3$N6%z}PuSr9o12JFApRWUpcG*S^(rc;~`K9QqnTugp
zW4Cqh$|NZl?iKeH>SrC$!ji!Z0}{>KRmQOIH-=!EK~iFK@1r21AvIw{EF~9%08*GG
z6MTI^ypbKJAxM3F>RXBR`8pg>p$ZipTFg1DAP^pmS
z!CvLanEBlGkg&0!L+O6;4Z>p8Scn4YRauVF!4av=?UTB7j1osa5gW;+@sbAm&*r%2
zI0HO2<`tdJ7|-VB2h&n~rA)k!MW|ib_piujUfvCcA*oG#H-?#^%j5ww^G!$BP$A9o
zxV?L^_)5m;(%m5=k45@PtIFdrsl9DUjd7i;*V}7azrck7FVjkdbrZLx^_tXGQ
zlfBI66QHed`(HgBlAO8s!6ONkC0dJyW5+=`QZ5V3<9-_~s8*olRHM4Vj
zHS=H@Cbv5NOSS|oRT%dJcbr!E&DqH=s%Tbl19G04wHnFaY>c)NXB)>h$q8R4tZ-OQ
zNi6Lyeg_NXU$x!2wZ)bJGPPwS%{uVUNd=P9sSH7ay?ZU_gZ5hEt#+z2FDa}3N2t$!
z514$f-zm`BnOw~&rqnm-ma3_i8sBc#te=mPo*i;1i5=91_-)(j)=KQvZw^wQSrypM
zwfXOUx+eV&z8;68Mx-EYGp)y2b{gnLyZQDIqf?7$3G3-HH~`QkSdM$L!qR?*brs+ZwmdR3E@U8Gm@XNtocg7biN@n!m9z)!P2LTuq6ax4PK{gFUrM4FJcx4y
z0%Ctc|GWh*dlOek?({Opdi!B_vhtofIpYaxL~D>r*BG=hJ2f6%6=Y8PWWga$ZLo_P
zv$z1#MN6*7$AKucOP5~}*ZFj`m?1N6uK`xtJDKm2Nr}J0m`)jl6jkub1eBJicS~E%
z9s|@{@72_B%JFczhiM5FhJ?*in;YshCbHmK$FjUujRPypZ{3)$(PnZ4eTkO5`fjME
zgEz@a@3|Zkfx8nn?~a`pc+Sjirz+9w&liOuVT5m-d+P@w+vVnbruk@zt1^-g@%v6Y
z*1B^6m+RDnZg3;Wjn~lBicxzFeqt;7VBWfM-`%wkXO}E(qdfqm6Q8Lc#f0S9`
zCr}2EOtWr%mc5$nV6PoBunB^SNzn;9S~3J&A7@H7y!$Mf<)>Wlarpc2ptR-crBPgb
zgNEeg7Zn3=YXWO>&5}1~o~ku5>m3
ze5(!YSa~{qfI1%yArCE#OZU2b3P|hwwaK3D`DX7BpO^(E?WwP3ydf_@T7_6Dzr8^#
z6dKhJ-VASn18Pm2+D(SiJeCqJ==z33_O!B!#W%C;_T8q1*i)T+_b%4yG^51M^v=x_
z@CU-Q?!;|DP($c?2s7~^eE)Ca`b#Q-7ABe`biKl<_vyXukIa*5;6VQj2B$V$p(m|j
zq)q{;yElNfEatu;BJe_<+qT
zNYhmiQ1|Kz+6uP%#JVY&YBgRL(l_Mi^u^(cogk8W?)Jt=OoEm|G{e5m5=`6OO5TO|
z%6Nhhc(zwxZsc~}6F&jA-|QCDHKCat_*D#P#YEAFxMa_E5qcg}O+@j`RGPH<%cQ8%
z-60}#I$u3zPZvPUW$rDW&n)ga=-OfD;X#`!>HJ3y6>k*jIbEc0ozZ+=gL@-J1R|@%
zjXq!UXyvp)YhXzja6$yS@f~LD-Q|0Ie7Etd4>_HwmqFQFOA%!N-00jAa~X@{qK5Dn
zdn;TxXw_w;ziuc+HxCwjeOHs5kqb97*3t^1U!W5_!>Ox7sD?#Pp-iuhf=OC0{;2R@
zPwA?KZ*cF~ANwS?oE3^%609{Ov)uhKUUIfhM*2M3IQ)rnjN?SETAVw!7s3Y@S}1G0
zJa2_Kb1sC@xmJ(-w1^gA^Cat+B>uI(P!NayW~1ntb9Gc+UCj-sT0;$bVnfSdgesurEsao(H)MP1ZR3
z8#G%5`~PB~G%W^j4b_ZbAT2j@H^V90EzK5CG+KG%Y%=tb(-h+mkp`f>z)?JSL~o#
zzpE2>0Kyk!@*M5IaDHHg@5Nys&RyfQzs}GV&X85&j!HwDtB5h)kYJr2S+t?N;kl6+^&8eH@*u}e
z4NrobouVfIXyKy`jbi+Kh~u4FfzR5xBUnI0OB~-XtfaZ5CVV?j_^8nA|H{<*`27n=R=>0h+dgAK#kmI!~Qcld_1L_R1ItxTK4LTc`G_
z;f%mE{J;&`OdcX-ip3iDQ#WD?*hzN^q`|-27hV00%3@vN%73?5E;HTX2})yk+IX~a
zkAW8%_g!bZFi+#Qt3$LD}Oz0&Se9sqOPk8MQpVimpVM`^|YPUC-MUtn?
zZ?nk-N=OHMgD0#~T~eDqyq_pAY8>|@gETJIwC4W(+5=d{>
zY9~LjIlYZ7QNy!ngu$s^ostg2x4V~bb;c7%Cj8O+DU)orj>0-KZKt=G^8h8>$%?O-
z?XxaQ%)BgK)+*j4O62AVdjIhb9|mSJxL>5L{&iMC!w+?+#0%xy~EajPVynCBp-f
zkK*WRfVX1`^y@Y28Dm1Pu9eVl64;D_Zi8EmOxIYGr_VexeYN_tmEsl-pIk~zR4Gj`
zN&7{S+TZdH4VcWCG+gVU2PqAekUqM`I5Dk5EJ`MjIfRkT?6F5h;qQSbQBpUr(Qj5F
z!2t~c>1C@hXNOp7f{j_&(1{`bTGv-Bz7NF3R2N`NstSXiXwairY=PI5O(Wegk;lkB
zda;k7m%Iug{R)eZWqgGG8P5yyB`B#1+E{6G-Odv&wA~aViz+n4nEa)((@!%{M1fXbbp(ElL+lhlw@9y~tI0(_@=}Mc=oE7eqJ=`bSl+PEFQ1KcrU8SvOy&be*fPdhVnbTxQR#RF+%>j@Us-)0#uaf!k
zl~i9sHx6H6Gjr<9Ya^$Iszqlaz!ROCbzE99b(pBqIpQ|ci+{k>=kW8N
z8_q%+avDCCGaJk)Ttf`U>&J3#3fNsH2N@jYOZSmQze_(D&f0v9wMtIrB}$p9M=$pWdYi*O;Hjx7?BtHi<{2#o6VXUsFXs9~-^B`GvmB_=shp2I5K
z*k^I@WHV>7nDJ`kA?T;qv#Ze-78rzNlx2jU)N;B32-SZE)MbECb!G(IgvRr%p^(dW
z$O%u8`VlHfN7dSf^Z6e(T0PaG-Sl7=31hqoD|vS%$_1W`<1H0?O1YbHl`n`;I`?zq
z$FUY`bXF%#gtfR>4&IoH?bJIYS
zl()v|ENfv3_5tv*)r!@cpSQhVuU3{_7jLl@!)@#Fr?JYHjWSwYUndieS-)el!g+*l
zFM8zh^Ee-g{3ELLI>;%|tAT2K^?O!fu1X^o+zH$jjS}PWjXJ7dor9VCc}^X@i7b8M
z_t@Ss>53T9dDI-qz>Mwy02HOK%4{;6rLaXF4GAgzXRY4dp9E;o{bVdeA&l@2`Y(D$;xO9!sDQ_`{&}%X9_2Uo3TMT0h^CRvKFw
zM)$Ep78~5g%}|>o8ZY4=WORBHq~(n2^@8Q9i*SFA33WZ0AJx)uE=*NMV7ayaI0v>@
zv#BGSpiI0oLDgS9_DQHr_s%KH3(uX&f(A~G+p~FIHp5LU@?zcA<51Mh!5>kH6?r{I
zc&;#ElM#9ESARb((flm9*Y~==WH6nl&%PqrRo;Z#roU4r8rvc4pA@h%VGeKu&X&7a
zT~XbztA3AhN!987+L_D{AYR^3I&H_pxtaK$_O(;s7xb*{&&YAIQ?W*K)Ss?!t+T#g
zU9pMWCg2WE3x`f!6F)WK#d|#lqg1T0I1ZDi8wC0e7-hkUmiBEjqU~I*)PIdX4Q^@!hpq4khEYyLlOTs>ENhOr>cS
zRBIF$JROqe_z$bq1e8x4Rt2!s_&A7&-m#;-v!PUS!|&>>AR4B9;t(6^hiwRtJ?wwp
z;bwN~O5>sLdL;NRitw$HW?_-4;peJQBVL`5=xQmB^Lbw=s-Ls7`j>&%>$|$a3t4cj
z;$aIivQjoqwZ}ZbClfBG`I0B2vcV*N?laI_s0~EkwK!fo56ksfbf0?f^szmiY@CP^
zv*kxkXgmzrccsJj8&vc|1&tc(OhrI+es*tmS)H0iIVQxv^ZTf0?LL(>p6qkeV;#3r
z`(eUJ2UA%S&sl7TXe#X0Q!#;wHDpqd5jwBe(l#FhE-Fa
z+@{7MOp?->wf2>#lBdHjg#xj_U~^Rb+C>w!F|>Gix^rCunqVuZ^tebq@m=w4!PEp{
zo11mNu%dw+zi*aTCu=b26Hon6pjK|H!(TtTOrVY`X+qwUP;*?WhyF3->VQkJKFX)}
zaSHPI{4>`Hr(rr2WwKW*zh!?sHj-Ks-tzJ1Y3x9_8S*;fN~Nv2=qutkVaWg@RP3;+
z68$anA|oK(+{7I{`O#$W-g)0YtNau#;ZZ&+;(Ryoa;Y9aX`npTh4>{|>7$aK(j||!
zMsNLZ{t-q&EhRa?&FEpOBqGtnZ1RE;>w|>;>w*NFQ!mtJJynhy6@tfT+L`mJo8Z`*
zKPkHubB-9bYj!4Q6t-A#5*i8r4ZuQK8w{ll5{Lgp}=nfW0e~s1o<8ZP42JKIfUw@;E#-_
z^dxJYaSJL+Kg32Hx87n*hC9DBdhE}$g$@
z+Jf!V)b=XQ{s>rZ8Q@hBc~1awZ7@p1$0MxhA2Kx>EI$
zB8Rtol~>Cl0G?9wOfP}}$y`_}g_)da50|RQ`1sFcRBN0}?Z4f4Ger@*q>LUj
zKf*qRJ8r&UYTwe>@Kh}jEXD4%osg4u@Q8q?eOurS*r%TKe!nm&t-xri|J5i{KG&QE
zmedaE+oYSZ%z%G?{x-I#55wvWtO?1UQdGfqztdJyx{T&0(@|!^e`!BgbXaP-oZ92P
z?G(H#u^?N;V?b8R{rgO0Gc`?1n6lRcsl=XqyXc)F1|FO_jF7yttlm~FFsub9xx)wi
zRG)5{C1_;Mb1eDdhb@!ZN{aIUyiaAVgvedkAZ6cX$mkX9@Dv&deR
z4;JFIhRqdI{4{6LR|8&6Q+0*De9N&asK`cGv(W5s$J)i3
zjVWD|f!(FEgT;YsRB~ZN$qskgHTcfFA38)Q`>eoN?dFz-4O|DX)2E+H9wdtgcXYZ`7A|%yMrw$mhi%SOi&c*{Mf#lSd&4^^wjzF4H1KY*j`z4
z<6ydwCq%8$0P0D#Ubl;7YMTU&k7rGPJ0k)Z<0}`R@LLy=Xu{5V<`RBj*;#ZA?#EL>
zwG^SDW?L<@JF2bKh^rX2SnUPsnYJ^bV#Aa!mB%)IKp{W4Yl^GPPBnch4`B}S>5@x0
zcd#L!{p1UN&Z-WtvQDt0xm4pKhT5rS$vS0bBo@D{7Bt^In33Vx_iSP0YPXpBQQjOR
zUJ-V0OrgQQXKu6oubRfaFWiw?K?mcKgipulu~2^S9pRs1bx^lTR!i^y#bVJ7E{O0&
zsWoUdG}M6R3DH<6N9PFN=x(b20(x*4$v
z^ug95FwpQ5Qb83MPc;A;;Uwi!(c*_r_S+Xe+9G*?tYyaCX$P6!`3H*QpebW%ysXl?
zKgd-2t1H|om&}uIptAa>x-czXT@6^vu0f!84T@ddgyo5pQS6LOl0wpbWvd$jRS(>*
zk`(dF&Ftl}iV8mJ*Gnyajj8I~GR}r4yEY8oM>pBauEf*6$iBRx%S{v0bnh!WW-B#1
z$I&72i#9*7yuOGej3wwmcP=a<0!yn#`@eQ|sjirch>X0U``pmL<|UKqh1xhkvMwL2
zX?;p|C~fB1$EtxCOkeK5oQ!R7-8OHykb!9Bs*>)A%}xdRoGr{$f4QkV)iERhypubh
z+C*ZL>2Ax4bjdqOX7YsqNCIr$FNAcEx92HUl@DsDHh3Q>wB&Q%oO$Ulq<0h(LVmBm
z;Th-F00}qNX_5tIjgpIw8ZS3e@_@OMjQ$rZ8=sk@@8ZSei~%MBoIyU0Rqy5?v3etf
zv;(|I=Jx5FR2D^};BnT3H%<)^Kd}XPqy1WvEtH0_qUX&e^wOg-P>YrD$i1K7tu)Rnsbyl=fKm}&jCWAmb4WwC#|3Ei^tYYN;
zrTgPc8Y#(gyt6vzFVxmoSZS%m{P=107e6!gTOqwB&+>258}Ah#vZ>zvG}gDI;YIeF
zRsWgsTyHp0GRurfoG<^UqKb4L&=Ld+UAZ-~y*WI*wW%38N*y|-;q$+~SawB@Yk4MI
znT9Ref=nuiiOQ(?6oJNH;~6n$=|^5Y5i}MISn+12a;rQ`{y*|9TznmeAScs$d@>t4
z9Iiqu6(6HZF*-SkxAWHb#qU-upC`3*El#C3q(q&6wCCLl+
z$T4?p7s@4kG|E^>u-{|Jt=34sS$xYZQgALM0NdmFhSPLpzakUT`w9QaKID!br0Lju
z0;?Z4J;VxZhE}7cuy6BRB672`Iu?9U+y`p71@Ou8)#Qy&bS3;md#xsO(yXAo`UnM`
zxt8_BlsLQ=7R~E}#zL~CDSzb9e6?!da^+4r&_Sfv#YzJ
zC}DrhsV)k0{>9+}s~{t3p<BmagjR!-WJB2KZNCG)srYeCQ;}#Ty>gbJax1lS@>_i2KMF**(Ur
zWJ?~dT0}pSlCjHcY(3FcH)i0J(RWk^Om(bt#+t|>b^CNLE;46;vi%C#p~7P~TVZ8=
z1z1uMK>D1=YiTIav^G6)`c(|4#>ZB@V(Y{LLu$E1S2=non}DK#?A
zxoJKu|LnChCa$A3Ee(RMaO~He4{a%zJ2c
z^aaIE#_G}x5j%r}DUiAyGp$RAne!oB`Y$j?Y2
zSE-ejzZyDb+;ZC9Cri5UT2_ivZ1Z2(W}ZolSZ?vJx>w_*0Xr!_eyo}%)4M;OW1}E1
z-M!UmjUb&lN)dm~p4kg5adxpd<3Oc`ad1pRonZ4(2Z?nk<8qPPSQCYx9NKrzpQInb
zbDd&merI&ui%DjS-!bzn|J~2QBK=A=G4v4U?!3*I^_hH9?#-dcw#8bnMr=1H?k@Sl
zZJ@~U?}tDsB+*WNfC$`I3Y?ns|4{dxVNGq_x(Wg|#Ig|u0k;Aw2q?W53n*1YT0$ry
zozMdknur}K0-;xFLP$bOr~$En5Q?+}5{gPs2nZn{C2$w+^PTfO=Q+iu>9p(Ja~
zIp!$uJH{LXMX#(O@AuIiI_e7hvo0LX*`y*dI!{fJlU&00KW|PpG}X}N?ARAYzpQU8
zG%0EtX1I{6JRNX@z6bg)_;=fc)3J69^Il28^YT}pW>L|$jm&SGO<7)z0vnlY%xYV@
z;`3!WAYrl>9gYjdU276_ntGY49e$U^M_cVw%%ih@h|Yd|onvzEYD;+5%Q21Y3DJn{
zN(pRi<#@JRHUv^v>K>l#aWtepa!=n_pzT+iqX~Rx97jL3&n#KR@7gxjWL>=f{Aw2-
zxbMkvR){(D#x9-v*Gx%k&plB;?nWYS4-zyoEI5uiZ({~l)|I=ZobE7DbNeRM^w{_j
zQX=0D(%s(vf
z_-?W=itp%BU6|P9SCpzma(IoKZy^x+qJzHu)7cbWIl{<`?20qr$#X{m9o@
zJr;9WKk4FS6bM_T-Of&DpFGU>VH5`i^Yu?2s@iUFv{()Op8GUPXlCg3^{9M?-f75%
zf?$ug>0Yh~;l7cCFe@^6Zrf?mx?IQoDKYskkuMcyNOLC|3MZ2tvR=Ff
z6j?f~oUzPgH;?nc=G<1E9u)4Xq-F63OF|@Li2*QFUH-x|IG6wT_xJc63>3?56
zXj0h3S3gk=t6@GGO;!=WVlN+n5pR%y-r(FQbbR(^XkdCm38UB}6M
z9r-(P&MEnbuq>LZ1qyUB_}QMUG1xR@3b=#RSLz<=C8}i6cVpoD{4b82QKu9e$0KNQ!M?w)X5
zVTd$;s5rBtLMwF$TeK)AMiyj$TnV&sR<%`_A$GE6G&-BAkl0v$X6a15cE?Nex+h;|EL3{7KaloPrqzkkJ$`C@cL7Rp2y@2}4~cjT_D)`?g_)lVhP
ziPJR?%vI+N`)mk`u0lmC(tgmFmR_)qbF6s~O1`i*Rs%Iv0y=HsvjvH}7yvCAO8y~K
ziTofun})5HDD;Rm~wi_Gw{6vU>0x>k*}W;pk$wQ_Ry?q#M|#LTNES!N6go0t{R&1bS`ezl6PR
zjjoiI{G@0V_lwn+HxEj0pmx8Fj}??9e&9GtD6f#FoTj?u_(CyZg7_7W_M_I4%_R9(
zZdA3$4N3kC-RXo3-4zSoJ3^zFRvkY&GR~p92m@TVOK#SS8(}IA*mpboAI)6Q8|8
zr_|>#GXuyys0ZTO#TC8S(z;ZswvunLnv3~B@qByyA)ycZ>g_xdQ%jt;bsLjW{&3@M
zt;lR(CU8B2OK@3twbElq=4?b;1*m$@MEeWWSCI(Qqg5f1f~yNn+d`veiSz(^gOA(v
z<3oeJVT?A>LFu%7Pxg8l_shM&)tJBhH#Qm4dnjg5z_G|Ai)7yawkV|q!8yu*kD9aI
zXxiQYm$@PP_2X$uu$ft>nc^Zi@5&f&fY6gZplkN)-4;4R0&h2E5dE5EW-i8-B5`(_
z7$(D^OqkX&9bT-{gh=_ZjGqd-0!1@Rzb6nV&Y?ACHmx>UZbEi37N{ZsS&17IF%Lc=
z3vvr?Nl{==(A&7kVP_i~gW0?6?vs@LV!&m4!86^@XPjlxn7adnQyq>%RHZH8%l@(s
zQV|nCaLjf;8krqE@Hfc;}MN(PyPMZHC(w
z+Jr-o?zwogkwau817Y_Czd|O^dBV{XCr^*1eAhcJo%z@6*c$ivv9Tr9z;5-`Dv9RR
zz_E*mp%M|Ze$!pp-Gj09h>E-oEO75|G)i!3N%y{4$dGjCT`nb7lDhB<7&Ctxj=O
z*w;HF;|0=d7sp&}ZC)im7P+OFb`S9#xM0}U_*P!Dj(`x2vwpc!F?RmUZt7PNWD;Sa
zv6a46GqAK!*_}TUI5y(p*%Ye7hFARTrSY#>0C_j3UKY+U_3@h{fvtjpDVm2)-nB4X
zcx@ONGS!jl8MEbPQ5IW>$W4a7o4)Bf9jk47rk7Q5=I1qoQ(+&z76SR83yd2!TR|)=
zDY_v}`xndoz58x>d)o>;KsER5^i7#-f1F+4Pr8}SfA1o*?nOY2A!P8l@Z}2uN3#v2
zIse+&TwBTZ+;XuU+Ee5XZp#ZreA{#_3nR^&>2^KAl)R-JJxD^5#1szy_zOtGoNzyW
z>`~TyRYmU-fcc?9@E;}&AQ-25u-S@!50MVe%8SwH<)F8@e!SY8udesBm>F<$loLkPQ!-3NZB`tt$<|e<~F!%q$Q*
z{#?~+fA`l9F8eEC17;R=$tNZXio#hm6;ko0MiQUN#N)mDM3Mk`DF7O0jnT0Yr;)Kp
z){_TV0~!%JR|(JMF3Ix9)|K_!%DgBMM^G%5^XwhGn9`KfPV0n~e)N=r*B-u|^vW|}h@g$vHuSs3UOOJB91LR^
zDoB6%z(g41t(!vn9$8m+WLwWIDGRs$Qt3o%k;6@VYF1C+5lWltYgWt2_f+(?L=mBp
zokvA!IR6Vpe)7)u2)^3)X}-_9glkJfrs5g~Bw-Qcn7_Ev~S8@AZ4qoeqpC`&{5D*M0*Z%Fnl0H7G4F+ggWQ<
zlKajA5Cm&ziS!pQ^cVvq+tjDR@DCj0?Jwx^2=WgJJYj9B6Isi6f-z?6LDq|}0~v~A
z^A!^~(ZecIC+a3R!TRj$Kyh=R}
z!!nml1Il{JgHG%bclRc~^Q$>Q;-#C02@|UMJ{dO_9}c~zQoZMjafVV4>z-S3XQOZZ
zUEF5>fXLX7GhjLMVDZ>gH0sJ**{r0Y>xjWyvJp_@$nz2U=*h9>lx%NDD%v
zM?6@bRdzLjZG=W`pB=K6h8|_>SvwniPh-$=d8B>jdD0?SCH$S_P7ekALEX2GOx5m#
z!Sg|UYNMF3DW`3qMK6+z2ZwY=yNaBKrE=d(bBvtQz#_p-%ijo7l>m$e?yfI?&+y$B
zk}2a@5;T+h^e3?glF@c6lt5vi<>+)@vERAAel)e#BUOD2xQF)P*^#Noe6})UA+ltm
zBw8U$j
z92)C04g#lC38h$bUB%U>+`;4!;L7wAqr1r)Nn63~o~vJIOn>EdB~B!JvM4L8a1`!s
zkCnb9-u;!FY`5pvgt(K@JmlK--a
zfB=y^1nBB*cfuRL&+0tIu$be0>n@-)viWN5w5Ck#J$cwonWwKAMaE+qo>Bvk*Gy%0
zQ{nGL_ipwXYx-$M^UKS0rpwDlxAOxXya=b+-U1zSE$Mslr&o`h)u|KS2ZuUEer1LL
z;I(`jb#Y?Wmx_eCF#VOif#wQjLEASiNA09EkG`=n+i5UCy1fhABeOq)HSO2IS{(t}
z)~mBaHxAD96;*FM7ZS
zMBmV^zE0uVQ9BJs<)?bEEs>;Yspk6g&99=`2@ty#0Cv9+x+K24_$y-|z}u-l02*0e
zG0ZuS>DpbndyfFq#txun9mm0kE&&)N;??P8Z=iV@0Wh-0wTT4dPKC&7Kdt1ue;1Ji4Lb}aOW#XxJixiH%GbB?yBsB>-7SlC9V
zLCU3E4eR5i2Umo}_K3#rLtq;Vmu`E<
zyK`eSq%~^pi`_L9p5oa}^*yJ5Nghh>UtSo~JTVljx#i;2oh6t!q_r#J+ukaV0dkeg
z=({`Q2R}FtOkvD{PUno#LPK!h8xvqwSMYP7yrS(BN4tShdrJEheK+No7i<}EdtZYT
zGd;p|UGEpA@?GIeE%6
zKSmKcD;)J{u$;O*h5tK!*9lbxg^OmEGP*Lxm3X`
zVSZxm@4p{tb?ybmc1VIz{u0IdpK+X?q>dr?)r+MWS>LCXGs^4^{6gOTzdjP-!Lj#N
zw|U7Eiea|K$fkyKj4G{SlHmShF5}Q{fL1dd^^;SaI>9Mo(dj8V(fkA8M4*+wGUTH#
z(0KX{j=vp(R*akWM>{{hh68y*+x$zga5wVh@;0&YvDh5?tQQQ-+%RA;`+b*L@fd4oWPh}
zmpy;~{pZSjC-ssQJqGt40o3Z@gD~83Wzy_ld)_$r+6({Eb)Ia+N80Abc#R`9g`
zIk{+W|MlH0k6CBQK6zmF%O8#^D!cp9SFr_12PSP=o;=M4Gtv4vNys1Q3IEeY0uygK
zqRXu+rwZ-g>xgRFa6SXZ$G8lZ<-Y1p5@D4(@@xL#l%iWpYzSZ|$81G`XNDvgcX4e|
zMr#i>UW<@~8up%)70bq!9NkSd+gC39e>utfO&dJT60?f+c-&ckZ-iL-XhNN1RzkX=PjBkoOSf;RtU%}%Z
zkWG^X%4D$YlY)_e=K|)foFe^x#f-d07hw!d@r+)EC|7J(G`1Szfe@?Ew(t_Ot8c1=PhSYbTPo8Cy7vHf~Qxg;W
zCP%wkE1ZXwO~kB<9b3QI!&ASnPd58+#HsAty~j-_$G$V;N#(yS;CGhXAGn*XpSB@5
zwf;Zr?f>G^BKVI2&hJT%GWTzLn;j^6YYd1Yn1#R;+SUK%MgOya+-^MB#pHiDCHDts
z56o-|cQf9aM
z57=+bikzRd_R2UUgPt3}RLGi6MQGT4YX@cfc}^!7XfAlH_#L*uaa-5R)gCflbp{gy
zF4XqUI07%qQj2^L=zNSD_^|EZaJn)4EG$g^k|2pX@kDNIy6t&x`QzWz3!|LUhyzkM
z%F)Pk7veD$juw+7;5`}&tM!LgxE#4tR=N`Q-il7bLfp`jcrE(12Y0JV>G8gNmn8Y7
zg_Cim^-$GnO0g)7(ej!`|zGaU-+p-Wv(+qtT07hyWi)l(aXRq1!ExNI&hE0XoDv
zf2+qT(iyge+^PDQ7^t1HI#
zAHfCL<;%8V_G)|~YpqTv(;9kC9Qb-7(9tnPdWrDrp`?jjKcO)*FJfokysa8;04-5aPJR2iHq(Y8#TG>^z9z)mH
zx#<
z))#4^KN2g5(`g$FNu}7AAH^hr=N=zDM?=0(0XI;!5B_|O7Ij}bh;&5QF~W_ffc1-0`73OTM>GW
zG$AS-E8&jcFc@M#>ZnfE99@ud;Y(x%a7YE?;ZAjuwcF);EqLw0QexR_7LRg2i~e5l
zU|!2Hu()WnXV$eWE4$v#zLxAV#P^x?>U~v^Tyl;rsoyIbYDM`twY68&WspOm6(N6E
z{^_tl`3iD(bg?5eIkEnk*_a+$4V^KgFzd}1EN&h&pPXXOH9w6$#c#yEo0)#2KEDD<
zV#z1cLVP1}9s+^syVZ=aO}bXIME>y}>aE{1XG
zutT|4ZGA{^Eg93BFTJ6tBOp3-vtT}Us(JUX-ueDl%+$J;bI~%F{88HNQ4SP=D`gH4
zAO`;DZEh<-;!kLSpGmlf4%Cv)^hg1=o$4p}YL7GgLM?tiwv%~DT?6>PTx*5M9N(SK
z;SZd~zCB4$n33s`I^rVTJj?$8lsSBWy1W5Y)6zY2ceWAAnW
zn=zQ+qhI>&LgO5^kHtZN+^PM%#wtq0_Y|O?(PP1~nk2$v{qszsjcRb*cKlV3p;2ej
z&2}!$%VP}*ACf1@!y(1u)saTrgQQXXT2VwhI@8^2l|s1De=ZsJqi)9^U8prWl&u}5
zU7vGij2rI*-?2D90?a1p#gf<3Vspb1&6F@&^ANuMy}trS5UtKz1X|~8q0>DTJalYA
zLa^c)5OICZ*X!kG)Ngv!Vm8M*wz8<91lG~Vv%b<%Nun#=*zf6%fEzEbRnPQ4Imgq^
zpHgBU3HV0iPUa16?`S~Sx6!YVQa^fQJ|jW}PL?M@h8?Od^5yfz+hzrD_;XD%i<)V-
zMc>~9?N$c`7+UL7s%mPTUA70-M8+#G;O4k>c_hN-xm&w{9MX@E{T28K6)!iDYDOD}
z5iJN}Rb;Zh%RNwnu0~LO0GjhlEqXB-(1`Ok`NCusLvr!;gFUYZ@|2GBtW-Z+q{YjpLwTjHneRQE=h40l>gSfF^yLc}V>o5mHeiT|TI-8~di!
z)r!I~IRUtdX1{GY?IvLTJd-kaY9-Td6ZmuJ*uGsB(B$*(^=#93sojeER05Dvz;|aL
zvLg$KW3R9vqii5(ydC^$wi+R~ge{2cCBET8vzrD65+>EjRL88xNbdn{{8*@-6%Cmg
zT%;djP-UHHEv3LXIFNdwwoBK`dnlP%lQ?|0sPR(VO!eX2!%{a!I&HT{*SpJ4pQcE=
z*C$$gy}?B#!h~kI^)D24D28XPv93*nLx$3KJ!h>UU_Z#QM#z$OK)gXX1`6){)#dlV
zBeha16)l&smunIX7
z8Hi+!Z)>~Gg+H8mK~j00GnYx^IChT5GOz}uUi!Uu4BzD-srMQFO;)V(Oeg@6vM6hJ
zMgZM3+^Ry%o&;o@;rB5f%$piJO1VhZ^4$*OGy|_^GE&|4$3PcWC>83c^rD-N
zD2xr7VbU?FcEK^bg_6DNWfI=$IZIscx3Wh7*=l2R9DUJ`lliNrXmXF*NWVihT
z%a=YheD698$n4?tT~E``UV1~q$;9AJ$oI1Foa8m;yp3Ks+GG1w^%aqV3j}4yu)0i@
zrq_;{#y|bz*Wp8Fws(ar@XL8COH~*7y(h5%xf!>8)VVF$skz;9-@$vE8r?6x6M!0e
z8TpAYo3&%D(&YHS#+*|6ro9LiaH@{MiRqf7_yoT~X?HxmBiqZSUe0m5WzU8~`*VSr
z?!d{jzYCs3B4i+6NgpTLq@wW*zvpznbhAV(#A7~CaK~#NMtuzjR*6v|WyyV3aj<$V
z#MRJ#X7eM+p*;9e!rc2)%S`d4%M1a{p_EU6HG{o~30f%7wUCrYh!!%)7
zWlif`!9)ds9~9+)@S;4u6|z4jN`fZW2SjUukV}#d)XZXN3H>K%LYm0L(r;;gC!YpB
z+E%+KD3~-qu!G}A_fKsd5XTkSUH7XcdJhHIQY11cUq!mBU51W`dzbs+ZGShRahUgf
zg4XqY*O$+tP5lJQ@a@+f*Xg}U`1jWT;PRf~70K<5_i;I2mmN{D6Z8BN_c5b_7mJ=H
zu>zXIB=v9A>4#ae4*uG$
z$(=HAKu^3rf+7K*M?jF#KM=`#k9Z_vsCbSKkpFJ@7Vq?xc8qF?ZDFr
z1NT6E*7O|@D=(Lcps9xxy3)g-Z`A}`Dg8A=@^a?~vTz$jg3PGUv6?R-nyKuxklFXsCLMs)lZgF?PdieFoUXD`uE7VctHycq2T$dxaf_5w^cwX?8(mxQC
zM}Nz3`TXnh$;E{wzIlw(t0XY@XjT#_pL)wz7d~RUaD;OCebg+Kd3Dk4w!g?
zq*q9~s|HskzmOXn{<3^ZDp2vA=j0CQ95-!$_54`?mV?a_M^a)>{J-1A&=BTiyWP<$t3w~CV$&3xg1top!A+N&*P2%k;9?%_5
zcfsTvJL=&2Zqjj3mozD8Rx@Yr;=tM*vuN^k_ua-e`QX^*x&}7*rL%u&v|2Cl{8~r$
zeWNzEl&yaC0%@IC7~UX3b8Wbx!&m7RSRYU~hJeU;9V6E{7Wn-x0-
zmIPQ;I(e4E=@Tr=b;EA-+LUeC*i#Zf*xNzt3`qcuTKk3DaIiiN
zj^OtzH2B`za_!NCe4Wk=hX?w4X*Cxr=1klT6UCdr{r_>E41llifQ_OF*D0|7)<-T6
zXTG=ehyC>LRoOo(1T*UoZWt?(1WpAtx@!6w+AJ*rpXHKB-)Nmx)0Xrujl=Whi^6A)EgS0ZSl9W*76w&f=9h?w?|-`n(Q1G
zmUDw=E%v;%v3eK+o4cKGK3H)l1P^r4L-YmgpsnAg@D#6B$EV}u*-d{(%xG%C1^3Xd
zTHW!XeUW0dx?7ulC30`K(94Q)_H_DA4AT>m1vpXNm?plf^-Fnj#h8HS-SDOF*wK?@
zh#3N#Ii}AqwA)O;l^1X|!@ilZWG_8sdPrr@4jf!>^P4?-`PfrS$#j$jmIo+Ecg$
z^_~ix*l&WO-Mr`hkdt~HnNj-vH?$>5x6pY#KG)i1s=QwSEBrT>&SSjeBF9lBrL7!K
zXP*R&uO8kLO@R*~tZ}=u6rNeoAYYux0NXuNt)22b%mYwo+XW8jI=$hBX&`f6$>KQo
z&7L)0Ns8F~mHionV$qKs(q7J&w-Hf#`2f_U9(BY(fOA7;64c8t%(M#I=nZ*I&p)ps
z5k9X-Rd2f-4oa(7p2bgm!RmvF0^?Kk?aI}u<`u|5==7`QtL%}F4&Uv)cgaa1rRTx(
zW2#2IT^@lH1aACui)*Wqa%O&p(XiP~=Ze0YMSYVZkb&YDiP;W?7<#~yuKtr{m!Dbf
zz5J|suZYv7(;`|fKbUEa8!tET!DIEtoSVv50y7uLK-<>pbZPLKGd^#eh8oBtWkcOY
zyu$}MU7#5GOBRBX*l*?vE|k{fo4_<#gLGd3p7{^vusk&hd`Y#nu}=wdWom`o1)9!k
z{V}|;R?@T#CK^g^lc~)UYPM>^{kI9(3ajtV?K!<`_g<}CY=SNwEs-KN|;UtGlbx$~a&VAV6n0k)S+o1LFO*+Rg
zqDj68%s)m-CJ1yfKCcw7UvSA=;ieS$T!zdZbngHUUtr1OCnZtvzB0v5Dt&N1A?$PM
z0IBXPdF894OKD;FtqS5vKIfM|YLmjfB$P_VQi<$oDzF8!+{LPTw~vDE$S{z#F-hT0
zENbqru@=9D@%x#Mm(dOvjOyGU=P%uuvQ0P0T>snT(Q;cUl_)Wk-J<73q?||&-*pM2
zsmZ7k(v{o}Q%D(FNpvoF{O+nTU6n!12mrj05V}
zVu-&z@kb%r%kPmlbh;}k1nBBEOn3f=QDwc!Qwr8U5d
zOQAw{hnaQV$N=VyDbKiq3=|^Hp$hwtorYx*YytwQAE3S!d$l
zVN7VMh=Q9!uu9PjZmfglA1hp!=9J^cc`aPWXOs_TOQ34-&_Y!cyeqYh-or~RtyESY
z2cC&XT^`+x+^rf%HI5!wkBl&}P%gQss*u(X-AE(#En!N9Tv%IJF6`>Pq&{8{
zk1w^KWf_YSy6Yp{9dtMRE|^K0Qp^m##VR%+fqZnqP;d|+Sw!b@fQNdS??u?I<1;L}
zWm9cexe8TU7%9747@+CUmF431X#1c?S=t2jO^EPT_=!5U)@x5~;jZ%g4*a%_9)9pP
z{%!6318Dd{LqfOeZAkh_RnCI$7M)nT(&ROk0-F4^>RTGRY#gTZ(nozqQ}r7(>=P>5MCy9Bb84pNzZ{
z>DG_HOb6W^K6FH2lUdyKT9b%BwJvlc`!i1E%^L9VX6S$!fmTWf&0c5SY^hxQv7<@d
zD8m=ho)=SukI{p_t2|?SRyRz1L7|WfN!;sZ3ufUC4*o<3+jEgV4JrLAlO&%cK!Vu5
zR%~f?f%$N`Igeh%&a%y6$GSU`l*}#QAB$tmK|VzlNm!}`sr;Tkgy-~lNO}3zk^x(I
zA}{#jFT1yYbA|dpJ)OcA2qFy8=Jc&Nrbl5OAASV12)w!rm((xt1}u;Zb8u$IlJ^%F
zdm=6{a0s_d6WxqJbHcKuH<1s6A&kPoBH6^W3x*d0X0itj%
zoyha#6tMChb6AP+YX_zi%XOuVe?1eDI2)soGFUF>Rq(haJ9k_7CA#2HQ;V@XcJJ}u
z3Jm{9ED%3gqDfm&wl)5#Jz$NGQD`eKg&4I1
zf?bxqE#rFW9>bScF}9+al_2W_nDj!4`Oon3hI^C%zf!Ld3Acge9>D1OzrY(b)`Sn=
z_gk##Iuok`kv#M5%Cxexih|3zfgd$r9SF9tyq3YNq;N-?wp=){@v6q@`wEVHb?@jd
z%A=&V>*HLN2;3yib#1i*ek-kiM-Qnwh!Nl{+@pQuwkY;ywmnKvO1dlM$(EK@^uRgn
zqQ$va(uBYQj>fRDrP9W~(3?{7GwwJ>(Le&5?Nm-oD547ieUmWv&x%ECEh6(L2V)ReR=5
zOlvgL0hNNHb`Pb&b$7JN9{;L;t4+-*xNcKC=UEiIolBLfMmK2Xl$81gRa=g{=ch`>
z^@C#17Bw*5M_V8xeWO*z0b?cx0E!>uY#*GS=r|{b<*?}caeG=V+6!#kM-vyi-|tE!
z-IkClrFs#_Vz0}G80+_EJ}HoJnGk^-=v@&P?hZjz4T2lAV!{xysN+7h-f~E_9tbV0
zQmWX6jp>jrJ?GrP7#XA6K8B>uH;lu6(8}Y?Y7}oZ4t`0wdo_{At$5N{mHowUfW3d|
zqn6#3FBzjh1k{5xzCcp!`z~Tv*>tWwrkC)Nu(*sOk8wkbyy7Z;V+)XvJ2+J`Tmb|?
z5n0+uTys7@pCZ<4Sn=MuXN!Oo11UBStO;Ijw&X1)k9=jU%Dk`I6<`nV2g$GgZVPdz
z3n*Y(z?LI&nLY{Z`QuvSCDovhRB4)X@qs4wDOhMRy6Xc-{kqlA6F4`ixkcg45@5{6
zP{1Wdu=iv8zmk67n6i@yAUtUj&DF6rh3>qwAakb0yxq(zeH64EFddAC)B=xB7*@J!
zxTLBeDO?i?M+v_%Z_-EAc+6llZ*Q|nEjC}O_X04TTL}@zu`H*~VJ@Yukowp;1cv)%XOiIN0)`fW
zs*FK@X|FH#8z1!uSq?v9=c^^yUrN30I6}>Dw+8v-t+Z`v8czkq^t_;uoYs9ZAEW(I
z!+_*XDM~0|OGU?I_~j6p*_*NGTV{n|dC>G!04c4y!raTmB55dnwEK00>Q>2zm?8c$
zWK0ssM*_)J{v*^hgKElCS~MBE5N9F`)2N-oOg9i;sAdlMkh?t!1!(nogit#_YnI@B
z90|_Q_%(LsFps{
zK~OQ#6*5QlUch0Yp{*p~rtS?nWx$
zY*#BC)TbK|qotbkE8N?K(7LU+A5P|LyyP~2rO80~_w>{~MAQjoWN?T0d9(is?0dKa
z`#k-xn>*a~=M>k=d|2pkzj#u!lqtp_>bla_dwmd1KpdtlzuW@0qKOowM!yHt=ZE+g
zvPr5>Ll>?4opiL9WgxcD_n5k5xLh3nmsDIK0gHPw`%E=-++Tq=HpmHn+iMh%%!#6v
zgrNuz=P0<6pSo3Jd0_TR?xG;wl217o5OEDk#VyF9Zbh_-FA@U3iaXKojcf(L+c2o&
z$CcoBpX!Kgs>RiNZQP<5viB-$&Iw>QCEU~1HWYKFhd_~j18uiyDH1fxO%UJ|o%{D`
z&?0cDz)AtkXU3zHWg>+Lx}*ETbfnauyK0f#GFv5DyiQOPsC%0ot7I$JEREfC6)^Dp
z1Acqh@~~2o;ufy3-o-ro+RfnZEZYNg_0Y?A#sQV1dxW}fSa{`oLLU(n^DWqEB__h>Oq2$y6NknNizrWEaqkCRjCd}kV&swO%b_=0k6&$tP<(@Bt6C}Y08wpe
zaS~fgH&_2ior26b;tdtDWPOA60+qlUCU}GgleByURsdIdgIxzHT06UYmmV80#9xed
zbpOGVxn)k2`;C
zPAj^<9_T?kMrr&8;8w&yBx1MbeSATtF%6Ks7@9e0xD@ty8<74yYdK~!Zg
z89FIhJT4bhAz)0#U=k+p9shj>@&%dxRvB+)X?VZY^&<=_SEO3yMs=^#(nC&r74RmT
zds$l40jM|)C5XcE7y7k2T&3QY@C-*9bSb23vc4l)>!l*K<*#^B_jcEuSOBEtJkHSt
zU>?c}Xgbm(wo<4cBr*TioS6p;u2aZYyo=0=$7gyDd#fV5LJp@}wZim*`__MCGD-%C
zOTxn-b(BEqYVg&7GNjDP(j4H+aHauSw#k~Z+gG~LwL(LM;TEVG2j~De%3VdV0B&l9
z#(s;zHFsP|ECM{vkb&2}0Z>WdO1WT*F9rBQbSc9r}0*WB*%rQX8xT?1d=Kk?};ZJmBeGCmux6Uhc^N}_B
zuQld|8e+PC#Jp)t0x|P<(2yJS7>#q&vh%eVcMq=mS=5i=U*BWTO7?~b~a^Gcg3tsWk6^WnO7Er|w=a?ZXxX{erV?SEf
z+s`%*7|5G16bz*Dcg6>NG87ajui^(}6GyrS$`!%42;A;VlUt@13)ibt4;?@t(Z_RL&P8ervLaD&(eS@nWhc0c-9
zTSMi*L$Z3SHZ_pIM?517w#6iaq~F%CFO@G`
ze7XxkI;qQET|g4vyNuO57Mo6aGxl6Ftv~Sd3->R4OpXY|d_|xQlIETZ10CI23D2m
zPFl@&!Ue`A!k%%4JEt5a<
zIU)I1!UX_PB$jy?zRe523O03;%hIt{u=D~jkHb0Ulwp%qY*0sP47U`m``f$l7KQ4B
z&ZfpJsZeNseSk_^^>E*;aO?CAn<&XkD#BIvM!^&MR{-dphmtHTWLT0kPvt{oGkqG1
zJUF|7w05|c`_pGF_rNwxTro6A{*pA*BV48tX+GS}`Eu~q(RDa%S~a+l03av3*7+Sj!pb78$sE(|fRIp}zrqK>#xYE0je
zX-I7In3A;H7!-gmCKU&_&I+S3@&6cr^_PR;5
zXaQ^LOMzA%q?s3DfNIR$H)}Opf`NZ2Y*8^)7wrE@bOX*TJUOIbR%jCjJhd2Qx0biW
z^nZ+=K4*K8_-MpM4V8TXled$QHnW8yvd|CAV-BF8ZXrM{M)1b3JU-5;uElX9yQ4%S
znF-{H7==FDMqSv~3?74w2$I!@Vxg;mqDUMC1)`bv(k&DnP=O=;Faa7V0r7!`XWntEC4EwPU`SqFsB)X4qyEPd3mqw>6(px@3sW&+tXQ~^IbyZ
zauwWQwTjM7k_R$TUPRo4X@jd0e{wLHEakU3firj8sU)N_rSvZ^3b=YL^-DyKpH_Km
zk6Nv2S$DjeE+GY8W#e+Q2$6#Qkl#8zWNwSK=_~JaIT}-c%4W8uu7#)2Z;eg#GBI0i
zbV4r^3Zzv@KlJ-xQm!ddfFKb&1OV;Ajhg
zSx7FFI#@si-#7d67diAV2b2PwuHA{OU@Vi?4=xL)MUArM8#|d5wb%D@z0q_&tQqBe
z@9`DdjXx>?4T^b7jXnW)#`oagaLzol05abR%yU^DSu50yB^XQ+wpR4_Jk@oLq@bP$
z@LB-R>5gUqc&@Rf-n`GFaSSKs6Kw96d9uZaM10_3IPrq!{*i8n%7t|}ECMuR?gdH-
z7QfyYviS)f97n}uy?{E0?3o7x1Z{B2^i;wM?D)eL9w*kx!GR@||2zt>tooiYH2Gog
z8`o%iw*zR+yU)%n$39PUy
z^-J{k**$ad8mUa@B*3ZJ3{cdJU<1e|p9b^d$zCs{8D8+8j9qGL!barRhkue}(gkvL
zGZeN4f-n3g0be${!G~S~Y)TbOi7eWz2!3B1sLYw*cITj^9nN9)gpHeK+@S!O`ZXQK
zF2owhukI$7QtJ4}~nCRzbTMt?e0FI
z85}TfI1!4fVU^;F8YCqDifN0@l~_*9kia7PzD2y6mH+4bo
zH`Ep~Bik#B{r*+CpyX|1k`@cULM6A~wTy;@t?{YEe~ecUvLN648dIxTcln$T)KXEh
z!80x9CelAG;1ZZn71Ap}tS(G45FWKfdfxdbPdAXTW%o8cjuAa)bv}>S+#$SEP8u%p
z>_0)-;i~G-rU3ktOk6z$7O(%zo8{d7_9dpqM{O9jC0Dw5jG{-G0*oB>J0GL(CM}HImJTPy3`D`*y<{+4Xcf0GT__0z
z5PdU4c%y)4J_
z#`TuOh~iyCH@nsxM*oAdj(xV7bW$Ze38z34XKyHXSJh%c5W8UuJx1A1j}VK~jx-aY$W?^|{)Uv@_0t
z$hoS`yd*I|#;&*d6P9{=+_7~rMM1{urG%%a+=?3~FilU`U6=H0OU)z(U2Ia9{aH5%M1#CH|P`fU0nBN$%@C7I`jNDM@$5Ya3?!=FQMu&a$
z0t;TX_Y6IIl>$$Zb><$D>a}}tCZvo})
z{c*5aRAF!|sYJj`AD|&E#O&*Wr>XTtu(D%==I45LsTQX{Hn$FIZK*+{x%yjz>wp@e
zWK~w>=#3E)b2u}?_Gv{b6u4WlwBX2mD_2}JnGJSM>YSYg{9-J&Wor(gcfr{0;;*V(
z)O)5BPnIP-E^17+6CnDC008!iD-v!qBu4ZI;dV&6yD%(eg&~frDXfk71IPBoU~lPY
z4q9;^5*b)LIX0x@SGNFxymL&htL3$r9@tcn3J2j$AhQ50L!$(D)J!Y$-1kC)i$C!`
zP*nB;?gZSgE)@36Q0yCp)w+BqS)G+?ae%jUR&AaGG9;bYPDAMIc!fj9=%>d1GPjReIZs`Bv>^q~H&i1ay
zaYl3$7{`KufHOEaihzg^I<^6orXoc`QIQ%#KtO5|y$C1@j3OWehz25!Deuv3ZLLYz)MrkNN|
zkOjSOp-wNg!|aa!S!nk8EpJSVOF?BL9;B(s3gd#|1-d7wYm%84bQnzPz+@v)O6LQB
z`#IkBkj*?Uc~h{_+s>LzcgSKDZNhlYGrQZuscCsEEg?A6G}ijDm5oW!z1nY?MSR%}
zW)T}-XA$d*e(zHpY#C><5T$dv3yt?s{0Hn(#T;Ihw~V6+k2O)zrED0zrh`>BU0O<(
zMnrTe#%ZBMD;kZHT1~6{6HB%=YHOQ$QBC^M8_g;S#tM$mm#eMme~1KuWI2w!>GIPj
z=$3tsyOJXZb5(6oRT*kX?_3eV7Imj)B^V1ADF%f12fm9{)=3T)KC+Dtso|H_d#|YW
z)+ys_majCYI=acP2(qxE)q**ORu|LtsfH^{{rFm^h3PQ?c5}5Lo*X?*y#-B<7-h!A
zyRDTiq8Ewlva(s|m+*BS8>{aU{xR0tHc9(~*{=2RFmWJ%zWUG6ymVJXRzHcQ9|JB}{Z4PyNHS2U$~iMi=J}9yf2Ah@2*igJDSVFkK&Xq
zaG-6h%(RqXoSj~E<)s+P)|6aHM^Y5Ped}n*f?XZQ(7fmsMG)^Y9QgLO;tuSJLtUSb
z>(kB}x5lHFn0)7+_HXICzcLJeDNY&WZC30q2Z`4i(Lj<7FP-Vx3CR){TaM)W$
z4XT~PLpDbRAlnyp#Gh4mftyAVAqOJ|HisO|yf*Hu2%f%Jx)%x&wP3~N6PD|YJ-*1%
z0o(a%6rFRxjaM=L%MzA1cqASxV4Ep3NlZGLvp6?ntR-kiHY`cwl~7AFsJ8dLvU08=
z9~a}%U(ot@I!)Z`yf=m|%uWgf>f#TJg|?{QrZ*qRA(@u(2$9D4{@}(50YX3^9at~-
zB?X^C0gbpT`U_v&vG}6hSaV@~v5vY+JSpcUzpjODm917=BrQ=z{fim4P-dO0oNM^U
z#c}bXN;2QoR5Y3_e6+n@#5*EAzAiL70Bk;y)I~?}GB(?`dR}4#*DNdSNY)d(^OwvZ
zqoLH9%rzw=QM&l8ER-p>pAZ`uiLDW+bECzt?1PX>E^lrsN
z^3q2$D&x5_xol{H2zp+JEng`{_|T2=sfNOnmAQob2eq!x+9yR2t$q$tFe~pl?%_78
zQ;zxreWGsKnfpd6{3GAXIY|NGQ`AFuZPTGL{5
zdLSy2?dl!{WrRc*VppWeDyYR|(W8Lix|sUDy5qtgmKo1Oyb>7O_>_*(6Jlk@mflV5
zbl*5z#(gnk%=xi4MZq!4db_wXM>zVBn+$0b5j1Rr7a~347e{l}YuLZQrkg}Oygcf&
zt!OwfvGG^a8R=%WJD;V;eY$pcRlPL)b&MbLY5giV=akF=WIqfwWna5SS*8t8oS;F>
z{K@h4Cg{Az+=vpdEZ!u#riF1;C4D&qNz=*Ewfx!T2on`G5i2K?oF#bo&S+djOl!x+
zxig;w!04?W>m3!}fT5W`&SU)nH_d(X=0$pt6g4L*6a6=muIONf!75sOVZ*p*}h=_77oy0>*cbRNt|uPLN;BX`>d5Ht!^FCF{}A
zZ#w6PK$lQQ*hVX3{0u1RS-CohqSV$&kLa>$m|5gTFGs|nTnir;uFH~EvEp+LzVz`9
z;c&YtYNKbIM~^Qs5oTzQDCWD1;?dz7P&5U>~
z16O+e^2g)=Fa@}g{J5H{87VamNBEJruAGO_P;iy}Ct=1o**HQ8&k@d`cq)XWa_)CN
zEIxphD!Sc3Tq1R3638QT3i?yBU}-{2IAXR>8!Mdio=jv(5>+1={n0W`ys9Ef^bGYv
z<%J3xX3!pWb1S_@W=7G=OchvUXs*f8#2-h+Z?`s!`C4eyzGLlMiD4R1V&u9kR?yP!
zd`@7Ov%ESnjT!$%%PA?mi~q#i1Zvl8D$H;<8{NosPuRQperlyz+I{8qM)UWwiR$}sEz-iw-2xU6B+wdTPs87LYya>I{O{Yaqn3ZK8SCPzCOmLMLHxSDK
zdG&f%eLvvqD3-&)0#g0ZCs!iC8%dbqUT|ReNiz2D%dd^`X>kQ;O5QfEW#F!zStiyI<@QF(8n9}EYq!~WyrLJ~V%8acr
zn`98Zkwnj(>eJyV_KRoD1ceh}q8!daH$lJuSrU)cE~6Fsp(_qD>XZ0oe+UyVxC*-mGItW<#5`NGvlM^s=qE*5@j^qc(VLKbOsPF%?C^
zo{87SxqQ`Th%qO&8O;-mtV_SRCO0kBXdcMn|Ev6rum=&HJ<1v@sX1wCtEfdqu8%2t
z`=JuU=5U?rw2y
zn&R&c>D9f`(d<32B%$5vU71o{GaQ;qF3pUYx~g;``S^+f2h5mDaFKpNCb*LW;f^!C
z>LsbL6P3S8wpOar{V%2a`{mq$Y(hdlBS?Afd=fgex`RcQw_*nkg1Ry$6>b~h;OryF
zkSGvJrH;C9)rxORcVK=~37+S5H)uCN@779+3Yn+4BUSOO7bbm&K#!
zi-S1|2-Q$E?4(g2Xm0IBP5BJzQA=nW0Tj+^Mdj
zYdkrT9yPgITaMNuK+4Sm-p_{OH>ZLcdU8ewL$F5mufxi!trYsP8!bo3{h*LjjqlQo
z^4U;+(-_GVGL^*-u8&qd5~g7gnTftT@05l{ekS`}@Hh1yOfV`V1V^+Hd;b%obGe`M
z{ZJ3BaW&R3Sg&M=ImjDH8nUUIoK(9FF8E&MyS;aH;I$C@iJRbug(Z5p!zS4%^xcU0
z0$q+1o7_h6k(6@`Vnua?i3aKVWA5T`Dm|z7Wl*#we^w3U4P4wc(8GVpLnOTbcRNxs
zC>35envmi$tM^v=zvp{=_kF>yfv-pg!{gF)#-Zr%g#x*zn)!)fEPr%y8p1em{WrXB
zraEa=OE6~!UlQ%-c4;;qXH3sA5s}TqU*;o(IP#M1h`LJXP~hg2V~c>V5`H6`Z4Fkc
zy5;U3b*eGCF~E`T)PM@pyS`-loEbn`Y$aE}iFXgM{-Pc7E|8qwqz_E~rD(%Do0jr)
zZCBz)=mWq&Iavl2Q;yxq1P$_XLg?5m+ZI1eZo%JdwO)-k3uzAoDW`qT!YyJd@Z+Cy
zQ<>8dluFVoPvm^D`_KYOWI*oXcdoqfD_U1uTPJbG;Ha#|o(jds)cy&XY~@X+{B56D
zAHkCn?WJz`m^B;_=f9GYkv+3@dz7s5dL(FHb#FjLsoAM?xYBId<}PG6{^4WHUto$!
zr+e{+?T~~2N@?9!c_H94?C^Z4b5J|&Uqh3iBj4jh&L-{i1xN0UF4P;!53YQ=h5+rQ
zA$ubjSEUZOE%@uai}6PXcW!rPuZ8?lX4rS^sAbCtyQLl9z??Xp{5E!J%|&s@2vtoW
z1yNO(HEK4e$FA6p*9gm#QToAuHca>6Al~f6v8Hkhv+43wadgA(F(k@lg2s*uUNh%7
z)h{=G`cvz}8Gf{w&(Ik~k0Y5itycKqSL9%^%w5B@9
z%o}%){g$&bDt8K9ijZcJqpo21afIZR;!htGm4)`YGTGz6GlK*
z#6J$lE`y|bL?AjY5|V`JxTiv2TeL$LAG|WE=F9t?stZgNVMm~ND`hSqB?W$6aqOac
zfFFz;`s~JJ6b5FOuoIwJD#N_Yfl#o*TWL2;J^;u@Wt3N0#)UHamhSay;q)t&74zp@
zyi0?k2j!1sQ#u@*RI7fsbCIA)enNk3I&KVL6^az9|BC%m;Gv_eAhmP&w?`gaSbWM5s__zr)N&n6h#ZX(QG&))zQ?iMi^s?IItiRj5W8H
z?B5oA%ET#MpJqefqQ;}VpbNLsd>SmmB7)vhM3grO}2MqFzST0j;%HEcFK
zvx--XAk(4kWNCNt+>DIYWkaB@4bwDs^Llx5ba?n%M#Vznobn9%#3$=n*qOiZVbLcTbc3OH3}Q2l#5$u2!V2+hGOgZ(nQ94Lb&~&mQL^t0$rZ`d^|(
z&Z#J5Ki=)l#MdvS$B*;*B%?c~oY-;whS@}XVu+)YDu17&@kxPu-MVEnuDZ!YFw?k`
zE-p}RCSp7Sj2YGYQrevm--~yeEs-Gcj6;lrcUL$ft`>-<+}e-`NWQB`FAO0N=uh_0cojhALIZr49`CrYm(davssP40P(1HXUV6?eov
zQrox<`s1HZzsy&U?p=dJNS=$^LdDJZ@WQ$R8BQ%XyP0Y-z3-Q7aD=K71aC-EI{0DRahv9^FPtzLfx(Z#c4RDOtidduK*TTi+9zcH@J%!<
zsYJk93v#l~21H5g`58`Am!4pFxQg_<0EdXk9djIb17=dXR0*`zg+SPmR)RlX%epDI
z3xQlwj2~a&H>pQcUBj9e{|c6sU#Ohs$TfY*gZI`mP>*pp5_{A1PQo(wYwuDK_02DN
ztvxCpiww?#Jdc9-^kR<;`(0Z9upT{|God0F6dR19`0#Qw@*e&leCMd*dWAtOzd=q0
z&m&V_2J9vyfmlaWM-|=icV0RBrmJGUuV5tSZ;#Lr9-TTK$izQMY8{Ij4`#pUO}^dp
zG7Pm)**r7fvnq@H(i~S@in4lQ*2UP4XX7AKm`~$(S1^fu^qv5;haid}?44Mmx+v1b
zhdwb?#6A`M=ey8ng!ZTVpni|l>tu6qkuWxq*JT}*66kVr=a`HW(>p&{?&Isy#yU&x
zwdOtZu8rQq!wB_1O`MT%J*Re77vxrfgc4vOZ~eYE;GB8@Lj6Fh+VMwH-QcR+%|
z3`RWhcFnGOH!dIHnlqS?IBofj)BwrQOixWyVfWBTL0hXdawZ!KAHfFpaQFKf{FZ3A
z6(0U2z1Urcj{{R;UX*RP`O={V8ToUv;Ax*}Mn4`q?K9NeRv|YM4sIs{j;Z
zraIJZg(dcXwLeR2iPjA|_C*c$9Mrn*zmE{o-Y>U!w}kK6z>EiaJVtj8oT_CXZ0chvMFpf3W$KsLt&lf
zw54-VnY;KHs(46Cw4y!z#t1WGCv5#d;xWQx8P69I*QqYSV~c|YW;$M|iJe##(-m0T
z_@Ep}L5m|~lp5nI2XUDsShkQ6=|zxCH}2DEIgRpE@?6iXL}X`z`{`H8qq>gw2SWWn
zRP-voZkM^Hm>gqvL9aR&Zi(aSpRBhNc8pL_f0)ta=|%NK8%;>5dgB}04>ZO`YEk&+
z3}qWz!fbdoZ{WR@n(Z^5OTDiwVa$~f&dt#!v%j37wNnyqNQqepDWv-v43x_bvKRJ%
ze=aQ1A*;_{!n5GY1$EA(r;U@xga;gp=Ra4ET@y|T2>ZW0S$QowRP$}HcugP1sBx?m
zhAY0K*YwJ4Z$loxGF$|dcdt+QoIJ#FYed7vbnBz^Wpe@
z$u1F=cyvf$D-{e*on-ce&}*^xD|%t;jnqPI67#7*teg1*Ie;1O>vxNs&1HhZh3MZ_RdX`?7^;&&~fH9UzV?;4~||J#pD44-RU&iwM_!+8=ss5!7*%IpRr
zL_D;AF?*bAVh~_p(z||wfJbuCzU5>jV`uzK<0nN73I}1dXJyW)mj3%0#WwSp_L_&7R#FM_80*|z6#qr?1vn)01B;LP&DyytUtkskT?{5
zX6(5ut99|i=aas+1>;y(H@X|yLRt0HNgjE$2G`3z
z6AUlA#o0GAN2zKPsTCrO2zxH-
z2bX0*g7{hK(E|`!Fu4i-Erz^eRRO?xv`_eOv+}~tphlLNs9vF1AC{G51UuQl&8|NG
z1Y(CCK=9HswOi;zOk9)l^`#1pj)33DcN%i5-KsGwLhA;(U1Bm>9Ab$avQJjd;m*y(
zjqkrcl8pM?+zci;`<9ZMc-qanR2RAR^#cWThL~aBU=Uqd+(nfp(Ps&({6GOT=qIZ2
zfXHFPolmg+Gwrd;oggQ4lrTyt&C);}2oQgQ&&zJz8wN7^RD@d4)3;SF$n4z-JM;HT
zv}t7;+u*JPP48VWS6!YUVAxzkqc>dLBkVS$@3{ZKG*A=D24oN2c18EX<{8WJ(iuV8
zhq5W_U(yQ_*;T~;Kk&ExeowD17~qig!FU!h^pz?$A%w(E6>zYyP&-{S3BRd==r!mx
z^(bmRws(dW9p~Aw9?^fKl6Zc~vc7-qM6}7KaHPh?x7Am$3y1Id@mrcr>{|mgEFgr>
zEg{7bbw7_JhFB?b)x{%b2SnXi;nKwDpK`5!=ufgJ@!|q>++g(13N#Lsm^+(u@pXob
zQ6vBes>T}ll(6$wpT~#WU~|@=$17UY>p0|5<~Rd1yUk?roSAh&&x(@k
zWvK{BFm2e#8y{7R{@fh4Yuzl6bhF`Q>*f>L29Ua`rkh7K7Lc6Ap@tcaG#l#lFh4DdRCS3iilpPDT
zge8@`fEP-&He^RaDY=s}0nB$+6`&9X*slHQ3VR3IgstFrsLMb?@stJHD%tzMNHOEi
zr~`d)84oX}T6KLBQ+en2GYv=e@kxo)AserY)r$?aGmqU5qF5Y3&%{2n4b>P%gYbEQ
zDRL2AT`G(EBy6@7Gf670n4EV}Kcug%$&Ttpe_WP?UX4}^BhW+l`LQMijK*(a{b%Fb
zji-Z_IA1cxk+YWTyQ-Vm`!SA>ZR49$6N?G`i(2T3)MmS!HMwo*T)t@=FI
zCA7foGqPp7ChLi4R6&2y8KEv|xya>JEZw>VQ=fTE=cOrh^b41O6=uyOCMDKm#WR$b
z3!DB;nFA(s`(baedT&U~wCd>*oxyWwqf~`eXTHLFEt~l#7Y;daKV!HJCfUu(KL_n=
zdKDag(Cn|$QyPfbm2Key8G|*z#(RewA_qxR%4hzt6mGc(N7-TOS?1i}pN%|cHbXaa
zOQvtsHdN$OJM<(4tE2(#Vf&_Lx78bljdxya_gxCDOA2Ur?}k}@PFHREWLKd4^844-
zIF|NL@Ar>G5fp4i;e;96Z|swncy`WdJmae|j5QlxsA_XOx$;uNH?j@SA}*{_S_M|1
zm|mZYU#LNDd?1OK%|_!vU5lqf>NL~A^geoGrI{qI7W-|~Q@T<3#(BEYEsy!Krl++{
zoB5EQXAPn@N83sGktH*6M}(~L;@jh*KY~Q}GZDciw)Fy2mnr;`5l1k$NP&=E#Uj}V
z{#sVSW`OWy)Qq>OPBhC7YX!%euJ2Ld`>=jmsi}Ew*Xo^h4mI*&<7st5I_{G!V+|GM
z!Rwg8Au=lMd&702cc7Cv<&ZJ9^XsgeD+dyj^*BjX$@vmSwFy=&QbH^G69zVtC@B_g
z2_P{+a6Q=)z`KxJsg!F{?H<`2;qILU__XTquCMGKb|BnFqK_$x3I8q)49m?Zyvi4!
zFHV~l3$z0+1?d2@3Q91cA?+>%AW6i0E%wSJx_|ayd)P-5<)Ta0STd_{j8pBa7qZK?
z%{CvYL8|J#G!r;?vHB*A(H;LSGWSn|KWNsh(G+`MTXQBKYsr_^gOa1DSVhO>&WF6_
zY^<<*F$mTt>WJs)bMp&!M6X0raePS)H7}5!XAI6Fpvo&3H~~P5CwNM$*JOrv6DIR1
z?-I{TxMg1r2hcw^S-VolMlU=`unf1LjyqAQ7X=5v_Wd4QutoN5xrhk;#?8*`qKA5m&VGXMv#$O}KPXYV(SQ|^Sh0gu$m#&ciB|N~
z&Hk&Ei46
zV#~w{(0Z|MFN2|_6VXD|Hg7}oJH}r%Q1gE?8g}U8oh|BW+4;rZ1ME*hWxh0_FqL@!
z7?N=>X6krb`0S?%({IA&5eaI
z6S_FV`uJk@7Q#|aX(TKNwZHx~EE73IJDrq1xREj9q@nk^zRDTBFlHC=On5AWcQ5-X
z^-oj*Uz(Nxz3!oqC{8`1NNhL8-KP@^&3?YD%5|n|{cVHb6rgS|cPIT4lR-`s0k20>5efc5JVLyeW>=!DzRq+4x2z4J6RN
zvIQIN_Jw4d*TFA?`7Vu>$ojW=OCRQ8_WkvfJR1j^`JL!6Xa=ppHMFJB!`|p8SF7mw
zet0u&<7UZvmD#HYL4&fHxtng7nPLw=ZMduoT&r;s;{
zQ*+i$o7vq`9|xAPG>KZbw6Yq)GHolKd*XSj(a4CNlgqtxt?@WP5CwhrCiJjQvT{}f
zcqXH2P%K6wcJnOG0sJ=D^&$6ILgc~F9Hzc2U3ywOcZv+C_jkIqYD>&aX7x(Pv-da;
zbFTA
zwju7}JNb;$R)NAJxmsSk4Gaq`=nrPF9_HhmAm^0|HpIKdAT|Hw`GIh0eqUOenZkM8
zBSAPkv2%BI48c%g#p@jRTCu3%5rpg?dOPe@6nDRraJi&a#R0?aeN#@S8$=e;0fS?&MO0!eebwtu6Z
zer+3f?(XVt#M)|ciQ7L
zqL+A*umK*iS0lT;R~ce5rdN`v(zeF%2^jG(@^hf-WHwG1pS`nUcs6QJh!X3v^Iy;L
zvkvHTk6_r5of_*gp0J1^#}5t&Yt9vKK4r?QLE_102t})!wpmAASLo7Ohh*VduaFtb
zXV$@i`f9zBQ(9*@tJq7JcWBbUvNjGB|LZCef>V((0HZnK|C$esQxhYDw+--s{{w&I
z2CV&RPU?P|v*YWN0Y6qr&m^{`-`ySeZB{D}A#b9B^A%*ryn+{p-%3`dKT=IsKhk@0
z^Gkn8=vkQA{v+__-H?HdHVg%cL9$~8mU1-FStHEq(8G^^hfBN%I{)9^1NQ9vcpQOs
z!5$nom0@S$ZM&$cu|ZlmW!$M0kO*q+$9w>hl~mC_h-LYEv%Q~aq*;3u9}j6wHOh>G
z-O9`?08);Q{fhmr&QsVZ*^=m|s93`ke;aZQ5cDSz+QPxLkC$_~rFx>2?L=fN2&TYr
z1xiBC$KPCv`h$cuz*o&%PffYA6C7MAtNW>du~bP*>Ws+K8FL!X+jr#Aw^XH{PW(Vq
z9Hc8gfa-Ybg?8fA$jqwAIz7P4S{XV;3sJh@Y?23z1FIsN>-xb-bcGuENhFbmVa9r|
zHGu-Ly7RMt<>r8_dqe~Kx0FppWT*s_u#ReZ-g
zSIE{gd;Ebr6Hueqvnv35ftxFuJ13x
zAL!vN>v*l6$EX4-{nmCsrGNKLLwd{)lhUjLKmBXx{9dp3p5c5RaNYwC(B&!7BJDP-
z5dG;1#e)6>Sl#<1>OUciKQh{YPEy~XWZupHkMw)zz~St1A~0Ej!KFL5*@%1&x%x%D
z%q1zXds6<9Y`iIm{_EpgQsMm1vjKa=Gh8o*c^iV0@rwjNY#>k}FPDY{
zSRq<~u~aD`K3`&G>h!;Ds+LHj>v;O;wz{tZYC
z;?8ZS`1(%5*)4wreVvSx596!UU7MGS1Av{L6d|pIrA6g_mdV`tEzw>1J_uIjvo<|p
zM$Vj~EH@KeSuB@93Y1vTjE~TAjHe>lO}&tmun^sv$V!j4d4zf}W_(~`U9X)p0smZbfFB*SsyiwufJx8@ClFzkn24Sa9wf#KM4i#lI%zfFVTR&X-SJwlwWa
zl(6~MiS`J%8?XRRh*l1$|UulWzGpU>dxZt`R)Cd8sc>P>4r077_D~Vd0iiG+1HRmEKMeGVG4Uq
z*w(i4G4*-`gaCgR2NVP#?c_CZtV>@*kY3(q~tI4Kjxv_8xpF!
zJFQ4{D3{@H$}+|abjOld@s*bOVBtyCJ@z!iXMpQ@aME1Xsy5P4*xSKA1eqbFfch7h
zh$gl9LeEH~-9{5;_Y+Bm8QlO_qsP1%ug_c4nND4EYAiDADV12~Q94UE)kP1S>Gq7&
z(t!?;vf%f=3B^qMtq&+Q@+bY<4q~3t1tC<~b+lGLGRpgNI_kGtjF?(rzIejfZR697GrG-gokMjeN
zA})u5@S(Cc&F6-LB|@~?BqrdF70y3*9fH;f!|NhUGAx^@<`JI~aZL%Kr*rJ!Ipj>9Y%ByI~vKDlYBtjaK
zokauCY~^p+g>NE;PYt8A->D%zEpv%G4!dg#e96&58nR844PrB3$!{d*=vG!IWs}AJ-8a@Yuq+}&ib=X
zHp*Q`+%d6zthn0K6n*2a;hOG&He7bVxa$h)A`*GUXs*ViJ`PNVEXYD
z410O1c3tdIn*m^MSjA90@PD_S=czT2DsZRRz=0)+uSG(41hX6L!EWH1e4-=*LxU_R
zRwzk!9;qI#Wn4N1A!xzHy!e0HsQd~(!A<}9j=yd<5!R$Jj!cb`fXv>S4_o?!UIq<8
zakseX+<_sZK@5v^h{8JB*{Qd8Q!k{f@FZ8_l_wkUV;A<()@)Zmbu+VbvjBQ%cySZ$
z7}#XEgL2OF!LQ@JeYyYZ9N=t3gtyA3tbm{s?GYh7lh6Z0oT%QF1Nv$?1jErJt`utq
z?Pd2}S9+iDV7VvM+>IW
zs3XpUKzR$wALD7vsMaeAtis6nyfc96}4193Y#q3T&TO
z`)0;#Z#Z%(LlVEdNQ^!Ect~xoyguZaahhjQO$^;7K%j6@rG;+Qv`7V70DS2QQ-
zqNHz~y5*P?u(cWsFv934!~2{o7U@dR9BUR;IrI7%q%BpZY#DEk_yh;=a;+
z&-;Iz%yePJ;Ryubu##B3w7SCL8u$q*Diyc(h8>@`&WZF4%*R+)#>)In
z-A4?sA`3AV(%$@m1Jcckh*|MaI%+%
zKZz5WyVmzda+bLQhj+M4ZbZ?X)!%3*YN
zF5zk4jc;+8-Y(g0{{5r)^2^e(^Qld&aEF+X*VUSrW-pKi_lLaqOwN=u`IypxQcQ=0
zW&*phuf6C+Y`339Br{M_=F=Y9sLjs_{S2qB@h`)ULBJceI9IsE^eR+p<^aU^Wm3c*
zV2xG68c=jhxnkPUSEOYYpS&L8U*mL5$thfE<1MAXHBYiVwC|QY-UYn1WCMzmoIyfF
ze&CeMCwT9M{K_miv_K2z<-Sl03WmSqTFA_f_eK7d!b^~W8~S
z4GWs%j5+BqmP)_w_I(vkDBn1Y9*{`E-yrKzy4m!PRh8AX4MR|h+HI);-#u0qs_0Bv?Ba8XO|o;;#bayQ#jdE`DG!8HaQH?=U1D@*DBrGtMs@&*xGA
zBAzX6PISeo{z8xZ@!G^UakdSA(DX;V!rg0H1I%p|SLnKxWO{LhVgxR7DjaiEZ>bq`
z%xbd@fN@f(a>=Rrv;_;&m}V7TiRlUAbcXaB12e%6gXJDD_!r%9cJI=nPtQ5CMs6f{
zb~qPUh$1`Tg;#*v3MD7Q+n??4r^xkY2WjURP6J`8gf~qCxez_Tqop2^l-bX5I#tH6
zgsNn(FprUloWSQtXUiwtd_cXNp}F>w?s6l)_rjN-0kSo_?wvvX>V7-7pdCJxB>|qU*MD<(_%EzhTTY?MADzdhZA>bT@ZX0=r^+c{#$OsjG7@p@$&<
zXqxVuY^o}Qdgud@p?FCJ
zUCN~n1ET`p+9?nufv@r`K?vFvX4SMVY&Lp|K*8W)qjf3(r8{jNEh%<@mNzNmTvWQ}
ziDTlgXtBS=R}=qUmh@bjc2YTI(|Bdz8~``A(jDAW!7XINKeN